diff --git a/.github/dependabot.yml b/.github/dependabot.yml index a640f740b2..7a351526bb 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -25,7 +25,6 @@ updates: # All packages grouped into a single configuration using multi-directory support - package-ecosystem: "pub" directories: - - "/sample_app" - "/packages/stream_chat" - "/packages/stream_chat_flutter_core" - "/packages/stream_chat_flutter" diff --git a/.github/workflows/distribute_external.yml b/.github/workflows/distribute_external.yml index dde97d8fdd..7fae497785 100644 --- a/.github/workflows/distribute_external.yml +++ b/.github/workflows/distribute_external.yml @@ -71,7 +71,6 @@ jobs: with: flutter-version: ${{ env.FLUTTER_VERSION }} channel: stable - cache: true cache-key: flutter-:os:-:channel:-:version:-:arch:-:hash:-${{ hashFiles('**/pubspec.lock') }} - name: Setup Ruby @@ -103,13 +102,16 @@ jobs: uses: actions/checkout@v6 with: fetch-depth: 0 + + - uses: maxim-lobanov/setup-xcode@v1 + with: + xcode-version: '26.3' - name: "Install Flutter" uses: subosito/flutter-action@v2 with: flutter-version: ${{ env.FLUTTER_VERSION }} channel: stable - cache: true cache-key: flutter-:os:-:channel:-:version:-:arch:-:hash:-${{ hashFiles('**/pubspec.lock') }} - name: Setup Ruby diff --git a/.github/workflows/distribute_internal.yml b/.github/workflows/distribute_internal.yml index 19969d27cc..11da883f75 100644 --- a/.github/workflows/distribute_internal.yml +++ b/.github/workflows/distribute_internal.yml @@ -4,8 +4,8 @@ on: push: branches: - master - # TODO: Remove feat/design-refresh once merged to master - - feat/design-refresh + # TODO: Remove once merged to master + - v10.0.0 workflow_dispatch: inputs: platform: @@ -75,7 +75,6 @@ jobs: with: flutter-version: ${{ env.FLUTTER_VERSION }} channel: stable - cache: true cache-key: flutter-:os:-:channel:-:version:-:arch:-:hash:-${{ hashFiles('**/pubspec.lock') }} - name: "Install Tools" @@ -113,13 +112,16 @@ jobs: uses: actions/checkout@v6 with: fetch-depth: 0 + + - uses: maxim-lobanov/setup-xcode@v1 + with: + xcode-version: '26.3' - name: "Install Flutter" uses: subosito/flutter-action@v2 with: flutter-version: ${{ env.FLUTTER_VERSION }} channel: stable - cache: true cache-key: flutter-:os:-:channel:-:version:-:arch:-:hash:-${{ hashFiles('**/pubspec.lock') }} - name: "Install Tools" @@ -144,3 +146,50 @@ jobs: MATCH_PASSWORD: ${{ secrets.MATCH_PASSWORD }} APPSTORE_API_KEY: ${{ secrets.APPSTORE_API_KEY }} run: bundle exec fastlane distribute_to_firebase + + # TODO: Remove once feat/design-refresh is merged to master + ios_testflight: + needs: determine_platforms + if: ${{ needs.determine_platforms.outputs.run_ios == 'true' }} + runs-on: macos-15 # Requires xcode 15 or later + timeout-minutes: 30 + steps: + - name: Connect Bot + uses: webfactory/ssh-agent@v0.9.1 + with: + ssh-private-key: ${{ secrets.BOT_SSH_PRIVATE_KEY }} + + - name: "Git Checkout" + uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - uses: maxim-lobanov/setup-xcode@v1 + with: + xcode-version: '26.3' + + - name: "Install Flutter" + uses: subosito/flutter-action@v2 + with: + flutter-version: ${{ env.FLUTTER_VERSION }} + channel: stable + cache-key: flutter-:os:-:channel:-:version:-:arch:-:hash:-${{ hashFiles('**/pubspec.lock') }} + + - name: "Install Tools" + run: flutter pub global activate melos + + - name: "Bootstrap Workspace" + run: melos bootstrap + + - name: Setup Ruby + uses: ruby/setup-ruby@v1 + with: + bundler-cache: true + working-directory: sample_app/ios + + - name: Distribute to TestFlight Internal + working-directory: sample_app/ios + env: + MATCH_PASSWORD: ${{ secrets.MATCH_PASSWORD }} + APPSTORE_API_KEY: ${{ secrets.APPSTORE_API_KEY }} + run: bundle exec fastlane distribute_to_testflight_internal diff --git a/.github/workflows/legacy_version_analyze.yml b/.github/workflows/legacy_version_analyze.yml index 7705c53b8b..443dcc7560 100644 --- a/.github/workflows/legacy_version_analyze.yml +++ b/.github/workflows/legacy_version_analyze.yml @@ -3,7 +3,7 @@ name: legacy_version_analyze env: # Note: The versions below should be manually updated after a new stable # version comes out. - flutter_version: "3.27.4" + flutter_version: "3.38.1" on: push: @@ -44,7 +44,6 @@ jobs: with: flutter-version: ${{ env.flutter_version }} channel: stable - cache: true cache-key: flutter-:os:-:channel:-:version:-:arch:-:hash:-${{ hashFiles('**/pubspec.lock') }} - name: 📊 Analyze and test packages diff --git a/.github/workflows/stream_flutter_workflow.yml b/.github/workflows/stream_flutter_workflow.yml index 5debb88dba..f7606bf510 100644 --- a/.github/workflows/stream_flutter_workflow.yml +++ b/.github/workflows/stream_flutter_workflow.yml @@ -38,7 +38,6 @@ jobs: with: flutter-version: ${{ env.flutter_version }} channel: stable - cache: true cache-key: flutter-:os:-:channel:-:version:-:arch:-:hash:-${{ hashFiles('**/pubspec.lock') }} - name: "Install Tools" run: | @@ -67,7 +66,6 @@ jobs: with: flutter-version: ${{ env.flutter_version }} channel: stable - cache: true cache-key: flutter-:os:-:channel:-:version:-:arch:-:hash:-${{ hashFiles('**/pubspec.lock') }} - name: "Install Tools" run: | @@ -94,7 +92,6 @@ jobs: with: flutter-version: ${{ env.flutter_version }} channel: stable - cache: true cache-key: flutter-:os:-:channel:-:version:-:arch:-:hash:-${{ hashFiles('**/pubspec.lock') }} # This step is needed due to https://github.com/actions/runner-images/issues/11279 - name: Install SQLite3 @@ -168,8 +165,11 @@ jobs: with: flutter-version: ${{ env.flutter_version }} channel: stable - cache: true cache-key: flutter-:os:-:channel:-:version:-:arch:-:hash:-${{ hashFiles('**/pubspec.lock') }} + - uses: maxim-lobanov/setup-xcode@v1 + if: matrix.platform == 'ios' + with: + xcode-version: '26.3' - name: "Install Tools" run: flutter pub global activate melos - name: "Bootstrap Workspace" diff --git a/.github/workflows/update_goldens.yml b/.github/workflows/update_goldens.yml index af6a6cff61..c81affdc59 100644 --- a/.github/workflows/update_goldens.yml +++ b/.github/workflows/update_goldens.yml @@ -16,7 +16,6 @@ jobs: with: flutter-version: "3.x" channel: stable - cache: true cache-key: flutter-:os:-:channel:-:version:-:arch:-:hash:-${{ hashFiles('**/pubspec.lock') }} - name: 📦 Install Tools diff --git a/.gitignore b/.gitignore index 981d572c14..ae47dab8f5 100644 --- a/.gitignore +++ b/.gitignore @@ -34,6 +34,7 @@ coverage_helper_test.dart **/doc/api/ pubspec.lock pubspec_overrides.yaml +devtools_options.yaml flutter_export_environment.sh generated_plugin_registrant.* GeneratedPluginRegistrant.* @@ -66,6 +67,7 @@ GeneratedPluginRegistrant.* **/ios/**/xcuserdata **/ios/.generated/ **/ios/Flutter/App.framework +**/ios/Flutter/ephemeral **/ios/Flutter/Flutter.framework **/ios/Flutter/Flutter.podspec **/ios/Flutter/Generated.xcconfig diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000000..0c3b05414d --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,148 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Overview + +This is a Dart/Flutter monorepo for Stream Chat's official Flutter SDK, managed with [Melos](https://pub.dev/packages/melos). All packages live under `packages/`. + +## Common Commands + +### Setup +```bash +melos bootstrap # Fetch and link all dependencies (equivalent to pub get for all packages) +``` + +### Testing +```bash +melos run test:all # Run all Dart & Flutter tests +melos run test:dart # Run Dart-only tests +melos run test:flutter # Run Flutter tests +# Run tests in a specific package: +cd packages/stream_chat_flutter && flutter test +cd packages/stream_chat_flutter && flutter test test/src/path/to/test_file.dart +``` + +### Golden Tests +```bash +melos run update:goldens # Regenerate all golden image files +# In CI, CI goldens are used; locally, platform goldens are used (configured via alchemist) +``` + +### Linting & Formatting +```bash +melos run lint:all # Run analyze + format +melos run analyze # Run dart analyze --fatal-infos on all packages +melos run format # Check formatting (page width: 120) +``` + +### Code Generation +```bash +melos run generate:all # Run build_runner for all packages +melos run generate:flutter # Run build_runner for Flutter packages only +melos run generate:dart # Run build_runner for Dart packages only +``` + +### Versioning +```bash +melos run version:update # Regenerate version.dart from pubspec.yaml (runs automatically after bootstrap) +``` + +## Package Architecture + +The SDK is layered — each package builds on top of the previous: + +``` +stream_chat # Pure Dart, no Flutter dependency + └── stream_chat_persistence # Local disk cache using Drift (optional) + └── stream_chat_flutter_core # Flutter business logic, no UI + └── stream_chat_flutter # Full UI component library + └── stream_chat_localizations # i18n for UI components +``` + +### `stream_chat` +Low-level Dart client. Key types: +- `StreamChatClient` — central API client, manages WebSocket, REST, and state +- `Channel` — represents a chat channel, has its own state and streaming APIs +- Models in `lib/src/core/models/`: `Message`, `User`, `Member`, `Reaction`, `Poll`, `Event`, `Attachment`, `Draft`, etc. +- Generated code (`.g.dart`, `.freezed.dart`) for JSON serialization/immutable models — do not edit manually + +### `stream_chat_flutter_core` +Business logic layer. Key types: +- `StreamChatCore` — root widget, manages app lifecycle, WebSocket reconnection, and connectivity +- `StreamChannel` — provides a `Channel` to the widget tree via `StreamChannel.of(context)` +- `PagedValueNotifier` — base class for all list controllers +- Controllers: `StreamChannelListController`, `StreamMessageListController` (via `MessageListCore`), `StreamUserListController`, `StreamMemberListController`, `StreamThreadListController`, `StreamDraftListController`, `StreamMessageReminderListController`, `StreamPollController` +- `BetterStreamBuilder` — efficient StreamBuilder that only rebuilds when data changes + +### `stream_chat_flutter` +Full UI package. Key architectural points: + +**Root widget hierarchy:** +`StreamChat` → `StreamChatTheme` → `StreamChatConfiguration` → `StreamChatCore` → app content + +**Theming:** `StreamChatThemeData` (accessed via `StreamChatTheme.of(context)`) contains per-component theme data objects. Components read their theme from context. `StreamChatConfigurationData` holds non-theme UI config. + +**Widget tree integration pattern:** +- `StreamChat.of(context)` — get the chat state (current user, client) +- `StreamChannel.of(context)` — get the current channel state +- `StreamChatTheme.of(context)` — get current theme data + +**Key UI components:** +- `StreamChannelListView` + `StreamChannelListTile` — channel list using `StreamChannelListController` +- `StreamMessageListView` — message list with floating date dividers, unread indicators, thread separators +- `StreamMessageComposer` (full-featured) / `StreamChatMessageInput` (new design system, UI-only) — message composition +- `StreamMessageWidget` — renders individual messages with attachments, reactions, threads +- Scroll views in `lib/src/scroll_view/` — generic paged scroll views for channels, threads, members, users, drafts, polls + +**New design system components** (`lib/src/components/`): +- `StreamUserAvatar`, `StreamChannelAvatar`, `StreamUserAvatarGroup` — avatar components; these are chat-domain wrappers around the base components in `stream_core_flutter` +- `StreamChatMessageInput` — new composer using `MessageComposerFactory` for custom layouts + +**Golden tests:** Use `alchemist` package. Platform goldens used locally, CI goldens used in CI (detected via `CI`/`GITHUB_ACTIONS` env vars). Goldens stored alongside tests in `goldens/` subdirectories. + +### `stream_chat_localizations` +Provides `StreamChatLocalizations` — Flutter localizations delegate with translations for all UI strings. + +### `stream_chat_persistence` +Optional local persistence using Drift (SQLite). Implements `ChatPersistenceClient` from `stream_chat`. + +## Code Style + +- Line length: **120 characters** (configured in `analysis_options.yaml`) +- Imports: always use package imports (`always_use_package_imports`), not relative imports +- All public APIs **must** have doc comments (`public_member_api_docs`) +- Sort constructors first, unnamed constructors before named +- Prefer `const` constructors, `final` locals, single quotes +- Trailing commas: `preserve` (formatter setting) +- Generated files (`.g.dart`, `.freezed.dart`) are excluded from analysis + +## PR & Commit Conventions + +PR titles follow [Conventional Commits](https://www.conventionalcommits.org/): +- `fix(scope): description` — bug fix +- `feat(scope): description` — new feature +- `refactor(scope)!: description` — breaking change +- `chore(scope): description`, `docs:`, `test:`, etc. + +After modifying any package, update its `CHANGELOG.md`. + +## Figma Designs + +UI designs for this SDK are in the **Chat SDK Design system** Figma project. Use the Figma MCP server to look up designs when implementing or updating UI components. + +## `stream_core_flutter` (external sibling repo) + +Basic UI components that can be shared across Stream products live in the `stream_core_flutter` package in the **stream-core-flutter** repository (a sibling repo, not inside this monorepo). These components: +- Never depend on chat domain models (`Channel`, `Message`, `User`, etc.) +- Provide primitive building blocks: avatars, layout primitives, theming tokens, etc. + +Components in this repo can be wrappers around those base components, adding chat domain models and extra logic on top. For example, `StreamChannelAvatar` wraps the base `StreamAvatarGroup` from `stream_core_flutter` and adds channel-specific member resolution logic. + +`stream_core_flutter` types used here are re-exported via a `show` allowlist in `lib/stream_chat_flutter.dart`. When adding a new type from `stream_core_flutter`, add it to that allowlist. + +## Dependency Management + +Dependencies are centrally managed in `melos.yaml` under `command.bootstrap.dependencies`. Do **not** edit version constraints directly in individual `pubspec.yaml` files — update `melos.yaml` and run `melos bootstrap`. + +> Note: `stream_chat_flutter` uses a local path dependency to `stream_core_flutter` (pointing to the sibling repo) when making changes to both repos together. Use a git dependency when no local changes to `stream_core_flutter` are needed. diff --git a/analysis_options.yaml b/analysis_options.yaml index d5ac4a48f4..6e7d2449c4 100644 --- a/analysis_options.yaml +++ b/analysis_options.yaml @@ -4,6 +4,10 @@ analyzer: - packages/*/lib/scrollable_positioned_list/** - packages/*/lib/**/*.freezed.dart +formatter: + page_width: 120 + trailing_commas: preserve + linter: rules: # these rules are documented on and in the same order as @@ -19,7 +23,6 @@ linter: - control_flow_in_finally - empty_statements - hash_and_equals - - invariant_booleans - literal_only_boolean_expressions - no_adjacent_strings_in_list - no_duplicate_case_values @@ -40,7 +43,9 @@ linter: - avoid_null_checks_in_equality_operators - avoid_positional_boolean_parameters - avoid_private_typedef_functions - - avoid_redundant_argument_values + # Does not always make sense to remove them; it also makes it hard + # to notice future breaking changes. + # - avoid_redundant_argument_values - avoid_return_types_on_setters - avoid_returning_null_for_void - avoid_shadowing_type_parameters @@ -64,7 +69,6 @@ linter: - leading_newlines_in_multiline_strings - library_names - library_prefixes - - lines_longer_than_80_chars - missing_whitespace_between_adjacent_strings - non_constant_identifier_names - null_closures @@ -81,7 +85,6 @@ linter: - prefer_const_declarations - prefer_const_literals_to_create_immutables - prefer_contains - - prefer_equal_for_default_values - prefer_final_fields - prefer_final_in_for_each - prefer_final_locals diff --git a/docs/analysis_options.yaml b/docs/analysis_options.yaml new file mode 100644 index 0000000000..6051e4ea37 --- /dev/null +++ b/docs/analysis_options.yaml @@ -0,0 +1,11 @@ +include: ../analysis_options.yaml + +analyzer: + errors: + unused_local_variable: ignore + +linter: + rules: + file_names: false + avoid_print: false + avoid_redundant_argument_values: false \ No newline at end of file diff --git a/docs/docs_screenshots/.gitignore b/docs/docs_screenshots/.gitignore new file mode 100644 index 0000000000..aaa712eda5 --- /dev/null +++ b/docs/docs_screenshots/.gitignore @@ -0,0 +1,2 @@ +# docs_screenshots uses platform (macOS) goldens only +!**/goldens/macos/* \ No newline at end of file diff --git a/docs/docs_screenshots/dart_test.yaml b/docs/docs_screenshots/dart_test.yaml new file mode 100644 index 0000000000..c329c9c85d --- /dev/null +++ b/docs/docs_screenshots/dart_test.yaml @@ -0,0 +1,5 @@ +# The existence of this file prevents warnings about unrecognized tags when running Alchemist tests. + +tags: + golden: + timeout: 15s \ No newline at end of file diff --git a/docs/docs_screenshots/pubspec.yaml b/docs/docs_screenshots/pubspec.yaml new file mode 100644 index 0000000000..a23572a9de --- /dev/null +++ b/docs/docs_screenshots/pubspec.yaml @@ -0,0 +1,37 @@ +name: docs_screenshots +description: Golden test screenshots for Stream Chat Flutter documentation. +publish_to: none + +# Note: The environment configuration and dependency versions are managed by Melos. +# +# Do not edit them manually. +# +# Steps to update dependencies: +# 1. Modify the version in the melos.yaml file. +# 2. Run `melos bootstrap` to apply changes. + +environment: + sdk: ^3.10.0 + flutter: ">=3.38.1" + +dependencies: + flutter: + sdk: flutter + record: ^6.2.0 + stream_chat_flutter: ^10.0.0-beta.13 + stream_core_flutter: + git: + url: https://github.com/GetStream/stream-core-flutter.git + ref: 333f7b72485f308b282cc85973223a2919fd8153 + path: packages/stream_core_flutter + +dev_dependencies: + alchemist: ^0.14.0 + device_preview: ^1.2.0 + flutter_test: + sdk: flutter + mocktail: ^1.0.0 + plugin_platform_interface: ^2.0.0 + +flutter: + uses-material-design: true diff --git a/docs/docs_screenshots/test/channel/channel_header_test.dart b/docs/docs_screenshots/test/channel/channel_header_test.dart new file mode 100644 index 0000000000..36aed46604 --- /dev/null +++ b/docs/docs_screenshots/test/channel/channel_header_test.dart @@ -0,0 +1,89 @@ +import 'package:alchemist/alchemist.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:stream_chat_flutter/stream_chat_flutter.dart'; + +import '../src/golden_theme.dart'; +import '../src/mocks.dart'; + +Widget _buildChannelHeaderScaffold({ + required MockClient client, + required MockChannel channel, + StreamChannelHeader? header, +}) { + return MaterialApp( + theme: docsScreenshotsTheme(), + debugShowCheckedModeBanner: false, + home: StreamChat( + client: client, + streamChatThemeData: docsStreamChatThemeData(), + connectivityStream: Stream.value([ConnectivityResult.mobile]), + child: StreamChannel( + showLoading: false, + channel: channel, + child: Scaffold(appBar: header), + ), + ), + ); +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + goldenTest( + 'channel header default', + fileName: 'channel_header', + constraints: const BoxConstraints.tightFor(width: 375, height: 72), + builder: () { + final client = MockClient(); + final clientState = MockClientState(); + final channel = MockChannel(); + final channelState = MockChannelState(); + + setupMockChannel( + client: client, + clientState: clientState, + channel: channel, + channelState: channelState, + channelName: 'General', + ); + + return _buildChannelHeaderScaffold( + client: client, + channel: channel, + header: const StreamChannelHeader( + automaticallyImplyLeading: false, + ), + ); + }, + ); + + goldenTest( + 'channel header with custom title', + fileName: 'channel_header_custom_title', + constraints: const BoxConstraints.tightFor(width: 375, height: 72), + builder: () { + final client = MockClient(); + final clientState = MockClientState(); + final channel = MockChannel(); + final channelState = MockChannelState(); + + setupMockChannel( + client: client, + clientState: clientState, + channel: channel, + channelState: channelState, + channelName: 'General', + ); + + return _buildChannelHeaderScaffold( + client: client, + channel: channel, + header: const StreamChannelHeader( + title: Text('My Custom Title'), + automaticallyImplyLeading: false, + ), + ); + }, + ); +} diff --git a/docs/docs_screenshots/test/channel/channel_list_header_test.dart b/docs/docs_screenshots/test/channel/channel_list_header_test.dart new file mode 100644 index 0000000000..b4d31cb404 --- /dev/null +++ b/docs/docs_screenshots/test/channel/channel_list_header_test.dart @@ -0,0 +1,64 @@ +import 'package:alchemist/alchemist.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:stream_chat_flutter/stream_chat_flutter.dart'; + +import '../src/golden_theme.dart'; +import '../src/mocks.dart'; + +Widget _buildListHeaderScaffold({ + required MockClient client, + StreamChannelListHeader? header, +}) { + return MaterialApp( + theme: docsScreenshotsTheme(), + debugShowCheckedModeBanner: false, + home: StreamChat( + client: client, + streamChatThemeData: docsStreamChatThemeData(), + connectivityStream: Stream.value([ConnectivityResult.mobile]), + child: Scaffold(appBar: header), + ), + ); +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + goldenTest( + 'channel list header default', + fileName: 'channel_list_header', + constraints: const BoxConstraints.tightFor(width: 375, height: 72), + builder: () { + final client = MockClient(); + final clientState = MockClientState(); + when(() => client.state).thenReturn(clientState); + when(() => clientState.currentUser).thenReturn(OwnUser(id: 'user-id', name: 'Alice')); + + return _buildListHeaderScaffold( + client: client, + header: const StreamChannelListHeader(), + ); + }, + ); + + goldenTest( + 'channel list header with custom subtitle', + fileName: 'channel_list_header_custom_subtitle', + constraints: const BoxConstraints.tightFor(width: 375, height: 72), + builder: () { + final client = MockClient(); + final clientState = MockClientState(); + when(() => client.state).thenReturn(clientState); + when(() => clientState.currentUser).thenReturn(OwnUser(id: 'user-id', name: 'Alice')); + + return _buildListHeaderScaffold( + client: client, + header: const StreamChannelListHeader( + subtitle: Text('12 channels'), + ), + ); + }, + ); +} diff --git a/docs/docs_screenshots/test/channel/channel_preview_test.dart b/docs/docs_screenshots/test/channel/channel_preview_test.dart new file mode 100644 index 0000000000..a67108ce66 --- /dev/null +++ b/docs/docs_screenshots/test/channel/channel_preview_test.dart @@ -0,0 +1,364 @@ +import 'package:alchemist/alchemist.dart'; +import 'package:device_preview/device_preview.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:stream_chat_flutter/stream_chat_flutter.dart'; + +import '../src/golden_client_stubs.dart'; +import '../src/golden_theme.dart'; +import '../src/mocks.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + goldenTest( + 'channel preview tile', + fileName: 'channel_preview', + constraints: const BoxConstraints.tightFor(width: 375, height: 80), + builder: () { + final client = MockClient(); + final channel = fakeChannel( + client: client, + id: 'general', + name: 'General', + messages: [ + Message( + id: 'msg-1', + text: 'Hey everyone!', + user: User(id: 'user-2', name: 'Bob'), + createdAt: DateTime(2024, 6, 1, 10, 30), + ), + ], + ); + + return MaterialApp( + theme: docsScreenshotsTheme(), + debugShowCheckedModeBanner: false, + home: StreamChat( + client: client, + streamChatThemeData: docsStreamChatThemeData(), + connectivityStream: Stream.value([ConnectivityResult.mobile]), + child: Scaffold( + body: StreamChannelListItem(channel: channel), + ), + ), + ); + }, + ); + + goldenTest( + 'channel list view', + fileName: 'channel_list_view', + constraints: const BoxConstraints.tightFor(width: 430, height: 932), + builder: () { + final client = MockClient(); + + final channels = [ + fakeChannel( + client: client, + id: 'general', + name: 'General', + messages: [ + Message( + id: 'msg-1', + text: 'Hey, how is everyone doing?', + user: User(id: 'user-2', name: 'Bob'), + createdAt: DateTime(2024, 6, 1, 10, 30), + ), + ], + unreadCount: 2, + ), + fakeChannel( + client: client, + id: 'design', + name: 'Design', + messages: [ + Message( + id: 'msg-2', + text: 'New mockups are ready!', + user: User(id: 'user-3', name: 'Carol'), + createdAt: DateTime(2024, 6, 1, 9, 15), + ), + ], + ), + fakeChannel( + client: client, + id: 'random', + name: 'Random', + messages: [ + Message( + id: 'msg-3', + text: 'Anyone up for lunch?', + user: User(id: 'user-4', name: 'Dave'), + createdAt: DateTime(2024, 5, 31, 12, 0), + ), + ], + ), + fakeChannel( + client: client, + id: 'engineering', + name: 'Engineering', + messages: [ + Message( + id: 'msg-4', + text: 'PR #42 is ready for review', + user: User(id: 'user-5', name: 'Eve'), + createdAt: DateTime(2024, 5, 30, 15, 45), + ), + ], + ), + ]; + + final controller = StreamChannelListController.fromValue( + PagedValue(items: channels), + client: client, + ); + + stubQueryChannelsForGoldens(client, channels); + + return DeviceFrame( + device: Devices.ios.iPhone13, + isFrameVisible: true, + screen: MaterialApp( + theme: docsScreenshotsTheme(), + debugShowCheckedModeBanner: false, + home: StreamChat( + client: client, + streamChatThemeData: docsStreamChatThemeData(), + connectivityStream: Stream.value([ConnectivityResult.mobile]), + child: Scaffold( + appBar: AppBar( + title: const Text('Stream Chat'), + actions: const [ + IconButton(icon: Icon(Icons.edit_outlined), onPressed: null), + ], + ), + body: StreamChannelListView( + controller: controller, + shrinkWrap: true, + ), + ), + ), + ), + ); + }, + ); + + goldenTest( + 'swipe channel to reveal actions', + fileName: 'swipe_channel', + constraints: const BoxConstraints.tightFor(width: 375, height: 80), + builder: () { + final client = MockClient(); + final channel = fakeChannel( + client: client, + id: 'general', + name: 'General', + messages: [ + Message( + id: 'msg-1', + text: 'Hey, how is everyone doing?', + user: User(id: 'user-2', name: 'Bob'), + createdAt: DateTime(2024, 6, 1, 10, 30), + ), + ], + ); + + return MaterialApp( + theme: docsScreenshotsTheme(), + debugShowCheckedModeBanner: false, + home: StreamChat( + client: client, + streamChatThemeData: docsStreamChatThemeData(), + connectivityStream: Stream.value([ConnectivityResult.mobile]), + child: Scaffold( + body: Stack( + children: [ + Container( + color: Colors.grey[200], + alignment: Alignment.centerRight, + padding: const EdgeInsets.only(right: 20), + child: const Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.delete, color: Colors.red), + Text('Delete', style: TextStyle(color: Colors.red, fontSize: 12)), + ], + ), + ), + Transform.translate( + offset: const Offset(-80, 0), + child: ColoredBox( + color: Colors.white, + child: StreamChannelListItem(channel: channel), + ), + ), + ], + ), + ), + ), + ); + }, + ); + + goldenTest( + 'slidable channel list with header', + fileName: 'slidable_channel_list', + constraints: const BoxConstraints.tightFor(width: 430, height: 932), + builder: () { + final client = MockClient(); + final clientState = MockClientState(); + when(() => client.state).thenReturn(clientState); + when(() => clientState.currentUser).thenReturn(OwnUser(id: 'user-id', name: 'Alice')); + + final channels = [ + fakeChannel( + client: client, + id: 'general', + name: 'General', + messages: [ + Message( + id: 'msg-1', + text: 'Hey, how is everyone doing?', + user: User(id: 'user-2', name: 'Bob'), + createdAt: DateTime(2024, 6, 1, 10, 30), + ), + ], + unreadCount: 2, + ), + fakeChannel( + client: client, + id: 'design', + name: 'Design', + messages: [ + Message( + id: 'msg-2', + text: 'New mockups are ready!', + user: User(id: 'user-3', name: 'Carol'), + createdAt: DateTime(2024, 6, 1, 9, 15), + ), + ], + ), + fakeChannel( + client: client, + id: 'random', + name: 'Random', + messages: [ + Message( + id: 'msg-3', + text: 'Anyone up for lunch?', + user: User(id: 'user-4', name: 'Dave'), + createdAt: DateTime(2024, 5, 31, 12, 0), + ), + ], + ), + fakeChannel( + client: client, + id: 'engineering', + name: 'Engineering', + messages: [ + Message( + id: 'msg-4', + text: 'PR #42 is ready for review', + user: User(id: 'user-5', name: 'Eve'), + createdAt: DateTime(2024, 5, 30, 15, 45), + ), + ], + ), + ]; + + final controller = StreamChannelListController.fromValue( + PagedValue(items: channels), + client: client, + ); + + stubQueryChannelsForGoldens(client, channels); + + return DeviceFrame( + device: Devices.ios.iPhone13, + isFrameVisible: true, + screen: MaterialApp( + theme: docsScreenshotsTheme(), + debugShowCheckedModeBanner: false, + home: StreamChat( + client: client, + streamChatThemeData: docsStreamChatThemeData(), + connectivityStream: Stream.value([ConnectivityResult.mobile]), + child: Builder( + builder: (context) { + final chatTheme = StreamChatTheme.of(context); + final backgroundColor = chatTheme.colorTheme.inputBg; + return Scaffold( + appBar: const StreamChannelListHeader(), + body: Column( + children: [ + // First channel shown swiped to reveal slidable actions + SizedBox( + height: 80, + child: Stack( + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + SizedBox( + width: 80, + height: 80, + child: ColoredBox( + color: backgroundColor, + child: const Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.more_horiz), + ], + ), + ), + ), + SizedBox( + width: 80, + height: 80, + child: ColoredBox( + color: backgroundColor, + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.delete_outline, + color: chatTheme.colorTheme.accentError, + ), + ], + ), + ), + ), + ], + ), + Transform.translate( + offset: const Offset(-160, 0), + child: ColoredBox( + color: Colors.white, + child: StreamChannelListItem(channel: channels[0]), + ), + ), + ], + ), + ), + Expanded( + child: StreamChannelListView( + controller: StreamChannelListController.fromValue( + PagedValue(items: channels.sublist(1)), + client: client, + ), + shrinkWrap: true, + ), + ), + ], + ), + ); + }, + ), + ), + ), + ); + }, + ); +} diff --git a/docs/docs_screenshots/test/channel/goldens/ci/channel_header.png b/docs/docs_screenshots/test/channel/goldens/ci/channel_header.png new file mode 100644 index 0000000000..4d18213300 Binary files /dev/null and b/docs/docs_screenshots/test/channel/goldens/ci/channel_header.png differ diff --git a/docs/docs_screenshots/test/channel/goldens/ci/channel_header_custom_title.png b/docs/docs_screenshots/test/channel/goldens/ci/channel_header_custom_title.png new file mode 100644 index 0000000000..78c61e9870 Binary files /dev/null and b/docs/docs_screenshots/test/channel/goldens/ci/channel_header_custom_title.png differ diff --git a/docs/docs_screenshots/test/channel/goldens/ci/channel_list_header.png b/docs/docs_screenshots/test/channel/goldens/ci/channel_list_header.png new file mode 100644 index 0000000000..287165df47 Binary files /dev/null and b/docs/docs_screenshots/test/channel/goldens/ci/channel_list_header.png differ diff --git a/docs/docs_screenshots/test/channel/goldens/ci/channel_list_header_custom_subtitle.png b/docs/docs_screenshots/test/channel/goldens/ci/channel_list_header_custom_subtitle.png new file mode 100644 index 0000000000..caebc7dee6 Binary files /dev/null and b/docs/docs_screenshots/test/channel/goldens/ci/channel_list_header_custom_subtitle.png differ diff --git a/docs/docs_screenshots/test/channel/goldens/ci/channel_list_view.png b/docs/docs_screenshots/test/channel/goldens/ci/channel_list_view.png new file mode 100644 index 0000000000..d2dedb3646 Binary files /dev/null and b/docs/docs_screenshots/test/channel/goldens/ci/channel_list_view.png differ diff --git a/docs/docs_screenshots/test/channel/goldens/ci/channel_preview.png b/docs/docs_screenshots/test/channel/goldens/ci/channel_preview.png new file mode 100644 index 0000000000..28856ac26f Binary files /dev/null and b/docs/docs_screenshots/test/channel/goldens/ci/channel_preview.png differ diff --git a/docs/docs_screenshots/test/channel/goldens/ci/slidable_channel_list.png b/docs/docs_screenshots/test/channel/goldens/ci/slidable_channel_list.png new file mode 100644 index 0000000000..52e79ff025 Binary files /dev/null and b/docs/docs_screenshots/test/channel/goldens/ci/slidable_channel_list.png differ diff --git a/docs/docs_screenshots/test/channel/goldens/ci/swipe_channel.png b/docs/docs_screenshots/test/channel/goldens/ci/swipe_channel.png new file mode 100644 index 0000000000..640187f5a6 Binary files /dev/null and b/docs/docs_screenshots/test/channel/goldens/ci/swipe_channel.png differ diff --git a/docs/docs_screenshots/test/channel/goldens/macos/channel_header.png b/docs/docs_screenshots/test/channel/goldens/macos/channel_header.png new file mode 100644 index 0000000000..72f5f57da8 Binary files /dev/null and b/docs/docs_screenshots/test/channel/goldens/macos/channel_header.png differ diff --git a/docs/docs_screenshots/test/channel/goldens/macos/channel_header_custom_title.png b/docs/docs_screenshots/test/channel/goldens/macos/channel_header_custom_title.png new file mode 100644 index 0000000000..9f21dd5e9d Binary files /dev/null and b/docs/docs_screenshots/test/channel/goldens/macos/channel_header_custom_title.png differ diff --git a/docs/docs_screenshots/test/channel/goldens/macos/channel_list_header.png b/docs/docs_screenshots/test/channel/goldens/macos/channel_list_header.png new file mode 100644 index 0000000000..33d9a674fd Binary files /dev/null and b/docs/docs_screenshots/test/channel/goldens/macos/channel_list_header.png differ diff --git a/docs/docs_screenshots/test/channel/goldens/macos/channel_list_header_custom_subtitle.png b/docs/docs_screenshots/test/channel/goldens/macos/channel_list_header_custom_subtitle.png new file mode 100644 index 0000000000..967a4fef66 Binary files /dev/null and b/docs/docs_screenshots/test/channel/goldens/macos/channel_list_header_custom_subtitle.png differ diff --git a/docs/docs_screenshots/test/channel/goldens/macos/channel_list_view.png b/docs/docs_screenshots/test/channel/goldens/macos/channel_list_view.png new file mode 100644 index 0000000000..4955a1cf0f Binary files /dev/null and b/docs/docs_screenshots/test/channel/goldens/macos/channel_list_view.png differ diff --git a/docs/docs_screenshots/test/channel/goldens/macos/channel_preview.png b/docs/docs_screenshots/test/channel/goldens/macos/channel_preview.png new file mode 100644 index 0000000000..41b5016759 Binary files /dev/null and b/docs/docs_screenshots/test/channel/goldens/macos/channel_preview.png differ diff --git a/docs/docs_screenshots/test/channel/goldens/macos/slidable_channel_list.png b/docs/docs_screenshots/test/channel/goldens/macos/slidable_channel_list.png new file mode 100644 index 0000000000..7b576cb79e Binary files /dev/null and b/docs/docs_screenshots/test/channel/goldens/macos/slidable_channel_list.png differ diff --git a/docs/docs_screenshots/test/channel/goldens/macos/swipe_channel.png b/docs/docs_screenshots/test/channel/goldens/macos/swipe_channel.png new file mode 100644 index 0000000000..6e1da412b8 Binary files /dev/null and b/docs/docs_screenshots/test/channel/goldens/macos/swipe_channel.png differ diff --git a/docs/docs_screenshots/test/draft_list/goldens/ci/channel_draft_message.png b/docs/docs_screenshots/test/draft_list/goldens/ci/channel_draft_message.png new file mode 100644 index 0000000000..fa9fa4451a Binary files /dev/null and b/docs/docs_screenshots/test/draft_list/goldens/ci/channel_draft_message.png differ diff --git a/docs/docs_screenshots/test/draft_list/goldens/ci/draft_list_view.png b/docs/docs_screenshots/test/draft_list/goldens/ci/draft_list_view.png new file mode 100644 index 0000000000..6277f363bd Binary files /dev/null and b/docs/docs_screenshots/test/draft_list/goldens/ci/draft_list_view.png differ diff --git a/docs/docs_screenshots/test/draft_list/goldens/ci/thread_draft_message.png b/docs/docs_screenshots/test/draft_list/goldens/ci/thread_draft_message.png new file mode 100644 index 0000000000..fa9fa4451a Binary files /dev/null and b/docs/docs_screenshots/test/draft_list/goldens/ci/thread_draft_message.png differ diff --git a/docs/docs_screenshots/test/draft_list/goldens/macos/channel_draft_message.png b/docs/docs_screenshots/test/draft_list/goldens/macos/channel_draft_message.png new file mode 100644 index 0000000000..bf0efba09e Binary files /dev/null and b/docs/docs_screenshots/test/draft_list/goldens/macos/channel_draft_message.png differ diff --git a/docs/docs_screenshots/test/draft_list/goldens/macos/draft_list_view.png b/docs/docs_screenshots/test/draft_list/goldens/macos/draft_list_view.png new file mode 100644 index 0000000000..5e463115dd Binary files /dev/null and b/docs/docs_screenshots/test/draft_list/goldens/macos/draft_list_view.png differ diff --git a/docs/docs_screenshots/test/draft_list/goldens/macos/thread_draft_message.png b/docs/docs_screenshots/test/draft_list/goldens/macos/thread_draft_message.png new file mode 100644 index 0000000000..b8262fa04a Binary files /dev/null and b/docs/docs_screenshots/test/draft_list/goldens/macos/thread_draft_message.png differ diff --git a/docs/docs_screenshots/test/flutter_test_config.dart b/docs/docs_screenshots/test/flutter_test_config.dart new file mode 100644 index 0000000000..21f8b7b6c6 --- /dev/null +++ b/docs/docs_screenshots/test/flutter_test_config.dart @@ -0,0 +1,69 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:alchemist/alchemist.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; + +Future testExecutable(FutureOr Function() testMain) async { + TestWidgetsFlutterBinding.ensureInitialized(); + + // Flutter tests default to the Ahem font unless real fonts are loaded. This loads + // Material/Cupertino fonts and every family listed in the merged FontManifest + // (including transitive packages such as stream_core_flutter's Stream Icons). + await loadFonts(); + + // Load the platform emoji font so emoji glyphs render in golden screenshots + // instead of appearing as boxes. System fonts are not in the asset manifest + // and therefore not picked up by loadFonts(); they must be loaded explicitly. + await _loadEmojiFont(); + + final isRunningInCi = Platform.environment.containsKey('CI') || Platform.environment.containsKey('GITHUB_ACTIONS'); + + return AlchemistConfig.runWithConfig( + config: AlchemistConfig( + goldenTestTheme: GoldenTestTheme( + backgroundColor: Colors.transparent, + borderColor: Colors.transparent, + nameTextStyle: const TextStyle(fontSize: 18), + ), + ciGoldensConfig: CiGoldensConfig(enabled: isRunningInCi), + platformGoldensConfig: PlatformGoldensConfig(enabled: !isRunningInCi), + ), + run: testMain, + ); +} + +/// Loads the platform's color emoji font into the Flutter test renderer. +/// +/// [DefaultStreamEmoji] sets `fontFamilyFallback` to platform emoji font names +/// (e.g. 'Apple Color Emoji'), but the Flutter test renderer only knows about +/// fonts loaded via [FontLoader] — system fonts are invisible to it. Without +/// this, every emoji glyph renders as a tofu box. +Future _loadEmojiFont() async { + // Each entry: (FontLoader family name, candidate file paths). + final candidates = [ + ( + 'Apple Color Emoji', + ['/System/Library/Fonts/Apple Color Emoji.ttc'], + ), + ( + 'Noto Color Emoji', + [ + '/usr/share/fonts/truetype/noto/NotoColorEmoji.ttf', + '/usr/share/fonts/noto/NotoColorEmoji.ttf', + ], + ), + ]; + + for (final (family, paths) in candidates) { + for (final path in paths) { + final file = File(path); + if (!file.existsSync()) continue; + final loader = FontLoader(family)..addFont(file.readAsBytes().then(ByteData.sublistView)); + await loader.load(); + return; // Stop after the first font successfully loaded. + } + } +} diff --git a/docs/docs_screenshots/test/message_input/goldens/ci/message_input.png b/docs/docs_screenshots/test/message_input/goldens/ci/message_input.png new file mode 100644 index 0000000000..ea33d6843f Binary files /dev/null and b/docs/docs_screenshots/test/message_input/goldens/ci/message_input.png differ diff --git a/docs/docs_screenshots/test/message_input/goldens/ci/message_input_change_position.png b/docs/docs_screenshots/test/message_input/goldens/ci/message_input_change_position.png new file mode 100644 index 0000000000..ceaae047f4 Binary files /dev/null and b/docs/docs_screenshots/test/message_input/goldens/ci/message_input_change_position.png differ diff --git a/docs/docs_screenshots/test/message_input/goldens/ci/message_input_custom_send_icon.png b/docs/docs_screenshots/test/message_input/goldens/ci/message_input_custom_send_icon.png new file mode 100644 index 0000000000..67704e3091 Binary files /dev/null and b/docs/docs_screenshots/test/message_input/goldens/ci/message_input_custom_send_icon.png differ diff --git a/docs/docs_screenshots/test/message_input/goldens/ci/message_input_quoted_message.png b/docs/docs_screenshots/test/message_input/goldens/ci/message_input_quoted_message.png new file mode 100644 index 0000000000..0d41d9dfd2 Binary files /dev/null and b/docs/docs_screenshots/test/message_input/goldens/ci/message_input_quoted_message.png differ diff --git a/docs/docs_screenshots/test/message_input/goldens/ci/stream_message_composer_default.png b/docs/docs_screenshots/test/message_input/goldens/ci/stream_message_composer_default.png new file mode 100644 index 0000000000..ea33d6843f Binary files /dev/null and b/docs/docs_screenshots/test/message_input/goldens/ci/stream_message_composer_default.png differ diff --git a/docs/docs_screenshots/test/message_input/goldens/ci/stream_message_input_default.png b/docs/docs_screenshots/test/message_input/goldens/ci/stream_message_input_default.png new file mode 100644 index 0000000000..ea33d6843f Binary files /dev/null and b/docs/docs_screenshots/test/message_input/goldens/ci/stream_message_input_default.png differ diff --git a/docs/docs_screenshots/test/message_input/goldens/macos/message_input.png b/docs/docs_screenshots/test/message_input/goldens/macos/message_input.png new file mode 100644 index 0000000000..e656fc6837 Binary files /dev/null and b/docs/docs_screenshots/test/message_input/goldens/macos/message_input.png differ diff --git a/docs/docs_screenshots/test/message_input/goldens/macos/message_input_change_position.png b/docs/docs_screenshots/test/message_input/goldens/macos/message_input_change_position.png new file mode 100644 index 0000000000..bb409d9118 Binary files /dev/null and b/docs/docs_screenshots/test/message_input/goldens/macos/message_input_change_position.png differ diff --git a/docs/docs_screenshots/test/message_input/goldens/macos/message_input_custom_send_icon.png b/docs/docs_screenshots/test/message_input/goldens/macos/message_input_custom_send_icon.png new file mode 100644 index 0000000000..ba328df7b6 Binary files /dev/null and b/docs/docs_screenshots/test/message_input/goldens/macos/message_input_custom_send_icon.png differ diff --git a/docs/docs_screenshots/test/message_input/goldens/macos/message_input_quoted_message.png b/docs/docs_screenshots/test/message_input/goldens/macos/message_input_quoted_message.png new file mode 100644 index 0000000000..fb230595da Binary files /dev/null and b/docs/docs_screenshots/test/message_input/goldens/macos/message_input_quoted_message.png differ diff --git a/docs/docs_screenshots/test/message_input/goldens/macos/stream_message_input_default.png b/docs/docs_screenshots/test/message_input/goldens/macos/stream_message_input_default.png new file mode 100644 index 0000000000..e656fc6837 Binary files /dev/null and b/docs/docs_screenshots/test/message_input/goldens/macos/stream_message_input_default.png differ diff --git a/docs/docs_screenshots/test/message_input/stream_message_composer_test.dart b/docs/docs_screenshots/test/message_input/stream_message_composer_test.dart new file mode 100644 index 0000000000..44aab77dec --- /dev/null +++ b/docs/docs_screenshots/test/message_input/stream_message_composer_test.dart @@ -0,0 +1,225 @@ +import 'package:alchemist/alchemist.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:record/record.dart'; +import 'package:stream_chat_flutter/stream_chat_flutter.dart'; +import 'package:stream_core_flutter/stream_core_flutter.dart' as core; + +import '../src/fakes.dart'; +import '../src/golden_theme.dart'; +import '../src/mocks.dart'; + +Widget _buildMessageInputScaffold({ + required MockClient client, + required MockChannel channel, + StreamMessageComposer? messageInput, +}) { + return MaterialApp( + theme: docsScreenshotsTheme(), + debugShowCheckedModeBanner: false, + home: StreamChat( + client: client, + streamChatThemeData: docsStreamChatThemeData(), + connectivityStream: Stream.value([ConnectivityResult.mobile]), + child: StreamChannel( + showLoading: false, + channel: channel, + child: Scaffold( + body: Column( + children: [ + Expanded(child: Container()), + messageInput ?? StreamMessageComposer(), + ], + ), + ), + ), + ), + ); +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + final originalRecordPlatform = RecordPlatform.instance; + setUp(() => RecordPlatform.instance = FakeRecordPlatform()); + tearDown(() => RecordPlatform.instance = originalRecordPlatform); + + goldenTest( + 'default state', + fileName: 'stream_message_composer_default', + constraints: const BoxConstraints.tightFor(width: 375, height: 100), + builder: () { + final client = MockClient(); + final clientState = MockClientState(); + final channel = MockChannel(); + final channelState = MockChannelState(); + + setupMockChannel( + client: client, + clientState: clientState, + channel: channel, + channelState: channelState, + ); + + return _buildMessageInputScaffold(client: client, channel: channel); + }, + ); + + goldenTest( + 'message input default', + fileName: 'message_input', + constraints: const BoxConstraints.tightFor(width: 375, height: 100), + builder: () { + final client = MockClient(); + final clientState = MockClientState(); + final channel = MockChannel(); + final channelState = MockChannelState(); + + setupMockChannel( + client: client, + clientState: clientState, + channel: channel, + channelState: channelState, + ); + + return _buildMessageInputScaffold(client: client, channel: channel); + }, + ); + + goldenTest( + 'message input actions on right', + fileName: 'message_input_change_position', + constraints: const BoxConstraints.tightFor(width: 375, height: 100), + builder: () { + final client = MockClient(); + final clientState = MockClientState(); + final channel = MockChannel(); + final channelState = MockChannelState(); + + setupMockChannel( + client: client, + clientState: clientState, + channel: channel, + channelState: channelState, + ); + + final controller = StreamMessageComposerController(); + + return MaterialApp( + theme: docsScreenshotsTheme(), + debugShowCheckedModeBanner: false, + home: StreamChat( + client: client, + streamChatThemeData: docsStreamChatThemeData(), + connectivityStream: Stream.value([ConnectivityResult.mobile]), + componentBuilders: StreamComponentBuilders( + extensions: streamChatComponentBuilders( + messageComposerInputTrailing: (context, props) => const SizedBox.shrink(), + messageComposerTrailing: (context, props) => DefaultStreamMessageComposerInputTrailing(props: props), + ), + ), + child: StreamChannel( + showLoading: false, + channel: channel, + child: Scaffold( + body: Column( + children: [ + const Expanded(child: SizedBox()), + StreamMessageComposer(messageComposerController: controller), + ], + ), + ), + ), + ), + ); + }, + ); + + goldenTest( + 'custom send icon via StreamIcons', + fileName: 'message_input_custom_send_icon', + constraints: const BoxConstraints.tightFor(width: 375, height: 100), + builder: () { + final client = MockClient(); + final clientState = MockClientState(); + final channel = MockChannel(); + final channelState = MockChannelState(); + + setupMockChannel( + client: client, + clientState: clientState, + channel: channel, + channelState: channelState, + ); + + final streamTextTheme = core.StreamTextTheme().apply( + color: core.StreamColorScheme.light().systemText, + fontFamily: 'Roboto', + ); + + return MaterialApp( + debugShowCheckedModeBanner: false, + theme: ThemeData( + useMaterial3: true, + brightness: Brightness.light, + extensions: [ + StreamTheme( + brightness: Brightness.light, + textTheme: streamTextTheme, + icons: const StreamIcons(send: Icons.reply_rounded), + ), + ], + ), + home: StreamChat( + client: client, + streamChatThemeData: docsStreamChatThemeData(), + connectivityStream: Stream.value([ConnectivityResult.mobile]), + child: StreamChannel( + showLoading: false, + channel: channel, + child: Scaffold( + body: Column( + children: [ + Expanded(child: Container()), + StreamMessageComposer(), + ], + ), + ), + ), + ), + ); + }, + ); + + goldenTest( + 'message input with quoted message', + fileName: 'message_input_quoted_message', + constraints: const BoxConstraints.tightFor(width: 375, height: 160), + builder: () { + final client = MockClient(); + final clientState = MockClientState(); + final channel = MockChannel(); + final channelState = MockChannelState(); + + setupMockChannel( + client: client, + clientState: clientState, + channel: channel, + channelState: channelState, + ); + + final controller = StreamMessageComposerController() + ..quotedMessage = Message( + id: 'quoted-msg', + text: 'This is the original message', + user: User(id: 'other-user', name: 'Alice'), + ); + + return _buildMessageInputScaffold( + client: client, + channel: channel, + messageInput: StreamMessageComposer(messageComposerController: controller), + ); + }, + ); +} diff --git a/docs/docs_screenshots/test/message_list/goldens/ci/message_list_view.png b/docs/docs_screenshots/test/message_list/goldens/ci/message_list_view.png new file mode 100644 index 0000000000..71a3eaf0e5 Binary files /dev/null and b/docs/docs_screenshots/test/message_list/goldens/ci/message_list_view.png differ diff --git a/docs/docs_screenshots/test/message_list/goldens/ci/message_list_view_pin.png b/docs/docs_screenshots/test/message_list/goldens/ci/message_list_view_pin.png new file mode 100644 index 0000000000..78dea32d13 Binary files /dev/null and b/docs/docs_screenshots/test/message_list/goldens/ci/message_list_view_pin.png differ diff --git a/docs/docs_screenshots/test/message_list/goldens/ci/message_list_view_threads.png b/docs/docs_screenshots/test/message_list/goldens/ci/message_list_view_threads.png new file mode 100644 index 0000000000..cffe407645 Binary files /dev/null and b/docs/docs_screenshots/test/message_list/goldens/ci/message_list_view_threads.png differ diff --git a/docs/docs_screenshots/test/message_list/goldens/ci/message_reaction_theming.png b/docs/docs_screenshots/test/message_list/goldens/ci/message_reaction_theming.png new file mode 100644 index 0000000000..2eab723295 Binary files /dev/null and b/docs/docs_screenshots/test/message_list/goldens/ci/message_reaction_theming.png differ diff --git a/docs/docs_screenshots/test/message_list/goldens/ci/message_rounded_avatar.png b/docs/docs_screenshots/test/message_list/goldens/ci/message_rounded_avatar.png new file mode 100644 index 0000000000..3a4fdbe443 Binary files /dev/null and b/docs/docs_screenshots/test/message_list/goldens/ci/message_rounded_avatar.png differ diff --git a/docs/docs_screenshots/test/message_list/goldens/ci/message_styles.png b/docs/docs_screenshots/test/message_list/goldens/ci/message_styles.png new file mode 100644 index 0000000000..abbea2c3a6 Binary files /dev/null and b/docs/docs_screenshots/test/message_list/goldens/ci/message_styles.png differ diff --git a/docs/docs_screenshots/test/message_list/goldens/ci/message_theming.png b/docs/docs_screenshots/test/message_list/goldens/ci/message_theming.png new file mode 100644 index 0000000000..a7494ad8d4 Binary files /dev/null and b/docs/docs_screenshots/test/message_list/goldens/ci/message_theming.png differ diff --git a/docs/docs_screenshots/test/message_list/goldens/ci/message_widget_actions.png b/docs/docs_screenshots/test/message_list/goldens/ci/message_widget_actions.png new file mode 100644 index 0000000000..7d1b72303a Binary files /dev/null and b/docs/docs_screenshots/test/message_list/goldens/ci/message_widget_actions.png differ diff --git a/docs/docs_screenshots/test/message_list/goldens/macos/message_list_view.png b/docs/docs_screenshots/test/message_list/goldens/macos/message_list_view.png new file mode 100644 index 0000000000..8cb395a19b Binary files /dev/null and b/docs/docs_screenshots/test/message_list/goldens/macos/message_list_view.png differ diff --git a/docs/docs_screenshots/test/message_list/goldens/macos/message_list_view_pin.png b/docs/docs_screenshots/test/message_list/goldens/macos/message_list_view_pin.png new file mode 100644 index 0000000000..9cca5a0eb8 Binary files /dev/null and b/docs/docs_screenshots/test/message_list/goldens/macos/message_list_view_pin.png differ diff --git a/docs/docs_screenshots/test/message_list/goldens/macos/message_list_view_threads.png b/docs/docs_screenshots/test/message_list/goldens/macos/message_list_view_threads.png new file mode 100644 index 0000000000..48ef4e2af9 Binary files /dev/null and b/docs/docs_screenshots/test/message_list/goldens/macos/message_list_view_threads.png differ diff --git a/docs/docs_screenshots/test/message_list/goldens/macos/message_reaction_theming.png b/docs/docs_screenshots/test/message_list/goldens/macos/message_reaction_theming.png new file mode 100644 index 0000000000..0b0979a944 Binary files /dev/null and b/docs/docs_screenshots/test/message_list/goldens/macos/message_reaction_theming.png differ diff --git a/docs/docs_screenshots/test/message_list/goldens/macos/message_rounded_avatar.png b/docs/docs_screenshots/test/message_list/goldens/macos/message_rounded_avatar.png new file mode 100644 index 0000000000..acdcb103d5 Binary files /dev/null and b/docs/docs_screenshots/test/message_list/goldens/macos/message_rounded_avatar.png differ diff --git a/docs/docs_screenshots/test/message_list/goldens/macos/message_styles.png b/docs/docs_screenshots/test/message_list/goldens/macos/message_styles.png new file mode 100644 index 0000000000..879707b8ed Binary files /dev/null and b/docs/docs_screenshots/test/message_list/goldens/macos/message_styles.png differ diff --git a/docs/docs_screenshots/test/message_list/goldens/macos/message_theming.png b/docs/docs_screenshots/test/message_list/goldens/macos/message_theming.png new file mode 100644 index 0000000000..e0621a0219 Binary files /dev/null and b/docs/docs_screenshots/test/message_list/goldens/macos/message_theming.png differ diff --git a/docs/docs_screenshots/test/message_list/goldens/macos/message_widget_actions.png b/docs/docs_screenshots/test/message_list/goldens/macos/message_widget_actions.png new file mode 100644 index 0000000000..5d16603aae Binary files /dev/null and b/docs/docs_screenshots/test/message_list/goldens/macos/message_widget_actions.png differ diff --git a/docs/docs_screenshots/test/message_list/message_list_view_test.dart b/docs/docs_screenshots/test/message_list/message_list_view_test.dart new file mode 100644 index 0000000000..cbab57cf18 --- /dev/null +++ b/docs/docs_screenshots/test/message_list/message_list_view_test.dart @@ -0,0 +1,187 @@ +import 'package:alchemist/alchemist.dart'; +import 'package:device_preview/device_preview.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:stream_chat_flutter/stream_chat_flutter.dart'; + +import '../src/golden_theme.dart'; +import '../src/mocks.dart'; + +final _currentUser = User(id: 'user-1', name: 'Alice'); +final _otherUser = User(id: 'user-2', name: 'Bob'); + +List _buildMessages({bool withPinned = false, bool withThreads = false}) { + return [ + Message( + id: 'msg-1', + text: 'Hey there! How are you?', + user: _otherUser, + createdAt: DateTime(2024, 6, 1, 10, 0), + ), + Message( + id: 'msg-2', + text: 'Doing great, thanks!', + user: _currentUser, + createdAt: DateTime(2024, 6, 1, 10, 1), + ), + if (withPinned) + Message( + id: 'msg-pinned', + text: 'This is an important announcement', + user: _otherUser, + createdAt: DateTime(2024, 6, 1, 10, 2), + pinned: true, + pinnedAt: DateTime(2024, 6, 1, 10, 3), + pinnedBy: _currentUser, + ), + Message( + id: 'msg-3', + text: 'What are you up to today?', + user: _otherUser, + createdAt: DateTime(2024, 6, 1, 10, 3), + replyCount: withThreads ? 3 : 0, + ), + Message( + id: 'msg-4', + text: 'Working on some Flutter features!', + user: _currentUser, + createdAt: DateTime(2024, 6, 1, 10, 4), + ), + ]; +} + +Widget _buildMessageListViewInDevice({ + required MockClient client, + required MockChannel channel, +}) { + return DeviceFrame( + device: Devices.ios.iPhone13, + isFrameVisible: true, + screen: MaterialApp( + theme: docsScreenshotsTheme(), + debugShowCheckedModeBanner: false, + home: StreamChat( + client: client, + streamChatThemeData: docsStreamChatThemeData(), + connectivityStream: Stream.value([ConnectivityResult.mobile]), + child: StreamChannel( + showLoading: false, + channel: channel, + child: const Scaffold( + body: StreamMessageListView(), + ), + ), + ), + ), + ); +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + goldenTest( + 'message list view default', + fileName: 'message_list_view', + constraints: const BoxConstraints.tightFor(width: 430, height: 932), + builder: () { + final messages = _buildMessages(); + final client = MockClient(); + final clientState = MockClientState(); + final channel = MockChannel(type: 'messaging', id: 'general'); + final channelState = MockChannelState(); + + setupMockChannel( + client: client, + clientState: clientState, + channel: channel, + channelState: channelState, + channelName: 'General', + messages: messages, + ); + when(() => clientState.currentUser).thenReturn(OwnUser(id: 'user-1', name: 'Alice')); + + return _buildMessageListViewInDevice(client: client, channel: channel); + }, + ); + + goldenTest( + 'message list view with pinned message', + fileName: 'message_list_view_pin', + constraints: const BoxConstraints.tightFor(width: 375, height: 600), + builder: () { + final messages = _buildMessages(withPinned: true); + final client = MockClient(); + final clientState = MockClientState(); + final channel = MockChannel(type: 'messaging', id: 'general'); + final channelState = MockChannelState(); + + setupMockChannel( + client: client, + clientState: clientState, + channel: channel, + channelState: channelState, + channelName: 'General', + messages: messages, + ); + when(() => clientState.currentUser).thenReturn(OwnUser(id: 'user-1', name: 'Alice')); + + return MaterialApp( + theme: docsScreenshotsTheme(), + debugShowCheckedModeBanner: false, + home: StreamChat( + client: client, + streamChatThemeData: docsStreamChatThemeData(), + connectivityStream: Stream.value([ConnectivityResult.mobile]), + child: StreamChannel( + showLoading: false, + channel: channel, + child: const Scaffold( + body: StreamMessageListView(), + ), + ), + ), + ); + }, + ); + + goldenTest( + 'message list view with threads', + fileName: 'message_list_view_threads', + constraints: const BoxConstraints.tightFor(width: 375, height: 600), + builder: () { + final messages = _buildMessages(withThreads: true); + final client = MockClient(); + final clientState = MockClientState(); + final channel = MockChannel(type: 'messaging', id: 'general'); + final channelState = MockChannelState(); + + setupMockChannel( + client: client, + clientState: clientState, + channel: channel, + channelState: channelState, + channelName: 'General', + messages: messages, + ); + when(() => clientState.currentUser).thenReturn(OwnUser(id: 'user-1', name: 'Alice')); + + return MaterialApp( + theme: docsScreenshotsTheme(), + debugShowCheckedModeBanner: false, + home: StreamChat( + client: client, + streamChatThemeData: docsStreamChatThemeData(), + connectivityStream: Stream.value([ConnectivityResult.mobile]), + child: StreamChannel( + showLoading: false, + channel: channel, + child: const Scaffold( + body: StreamMessageListView(), + ), + ), + ), + ); + }, + ); +} diff --git a/docs/docs_screenshots/test/message_list/message_widget_test.dart b/docs/docs_screenshots/test/message_list/message_widget_test.dart new file mode 100644 index 0000000000..874b0a81d3 --- /dev/null +++ b/docs/docs_screenshots/test/message_list/message_widget_test.dart @@ -0,0 +1,379 @@ +import 'package:alchemist/alchemist.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:stream_chat_flutter/stream_chat_flutter.dart'; +import 'package:stream_core_flutter/stream_core_flutter.dart' as core; + +import '../src/golden_theme.dart'; +import '../src/mocks.dart'; + +final _sender = User(id: 'user-2', name: 'Bob'); +final _currentUser = User(id: 'user-1', name: 'Alice'); + +/// Custom reaction resolver that maps 'celebrate' to 🎉, demonstrating the +/// [ReactionIconResolver] API. +class _CelebrationReactionResolver extends DefaultReactionIconResolver { + const _CelebrationReactionResolver(); + + @override + String? emojiCode(String type) { + if (type == 'celebrate') return '🎉'; + return super.emojiCode(type); + } +} + +Widget _buildMessageScaffold({ + required MockClient client, + required MockChannel channel, + required Widget child, + StreamChatConfigurationData? configData, +}) { + return MaterialApp( + theme: docsScreenshotsTheme(), + debugShowCheckedModeBanner: false, + home: StreamChat( + client: client, + streamChatThemeData: docsStreamChatThemeData(), + streamChatConfigData: configData, + connectivityStream: Stream.value([ConnectivityResult.mobile]), + child: StreamChannel( + showLoading: false, + channel: channel, + child: Scaffold(body: child), + ), + ), + ); +} + +void _setupBasicChannel( + MockClient client, + MockClientState clientState, + MockChannel channel, + MockChannelState channelState, +) { + setupMockChannel( + client: client, + clientState: clientState, + channel: channel, + channelState: channelState, + channelName: 'General', + ); + when(() => clientState.currentUser).thenReturn(OwnUser(id: 'user-1', name: 'Alice')); +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + goldenTest( + 'message widget actions', + fileName: 'message_widget_actions', + constraints: const BoxConstraints.tightFor(width: 375, height: 500), + builder: () { + final client = MockClient(); + final clientState = MockClientState(); + final channel = MockChannel(type: 'messaging', id: 'general'); + final channelState = MockChannelState(); + _setupBasicChannel(client, clientState, channel, channelState); + + final message = Message( + id: 'msg-1', + text: 'Hello! This message has actions.', + user: _sender, + createdAt: DateTime(2024, 6, 1, 10, 0), + reactionGroups: { + 'love': ReactionGroup( + count: 3, + sumScores: 3, + firstReactionAt: DateTime(2024, 6, 1, 10, 1), + lastReactionAt: DateTime(2024, 6, 1, 10, 2), + ), + }, + ); + + return _buildMessageScaffold( + client: client, + channel: channel, + child: Builder( + builder: (context) { + const effectiveAvatarSize = StreamAvatarSize.md; + final effectiveSpacing = context.streamSpacing.md + context.streamSpacing.xs; + final leadingInset = effectiveAvatarSize.value + effectiveSpacing; + + return StreamMessageActionsModal( + message: message, + showReactionPicker: true, + leadingInset: leadingInset, + messageWidget: StreamMessageItem(message: message), + messageActions: [ + StreamContextMenuAction( + value: const _ReplyAction(), + leading: const Icon(Icons.reply), + label: const Text('Reply'), + ), + StreamContextMenuAction( + value: const _ThreadReplyAction(), + leading: const Icon(Icons.comment_outlined), + label: const Text('Thread Reply'), + ), + StreamContextMenuAction( + value: const _EditAction(), + leading: const Icon(Icons.edit_outlined), + label: const Text('Edit Message'), + ), + StreamContextMenuAction( + value: const _CopyAction(), + leading: const Icon(Icons.copy_outlined), + label: const Text('Copy Message'), + ), + StreamContextMenuAction( + value: const _PinAction(), + leading: const Icon(Icons.push_pin_outlined), + label: const Text('Pin to Conversation'), + ), + StreamContextMenuAction.destructive( + value: const _DeleteAction(), + leading: const Icon(Icons.delete_outlined), + label: const Text('Delete Message'), + ), + ], + ); + }, + ), + ); + }, + ); + + goldenTest( + 'message theming', + fileName: 'message_theming', + constraints: const BoxConstraints.tightFor(width: 375, height: 200), + builder: () { + final client = MockClient(); + final clientState = MockClientState(); + final channel = MockChannel(type: 'messaging', id: 'general'); + final channelState = MockChannelState(); + _setupBasicChannel(client, clientState, channel, channelState); + stubMockClientCurrentUser(client, OwnUser(id: 'user-1', name: 'Alice')); + + final message = Message( + id: 'msg-2', + text: 'This message uses a custom theme!', + user: _currentUser, + createdAt: DateTime(2024, 6, 1, 10, 0), + ); + + return MaterialApp( + theme: docsScreenshotsTheme(), + debugShowCheckedModeBanner: false, + home: StreamChat( + client: client, + streamChatThemeData: docsStreamChatThemeData(), + connectivityStream: Stream.value([ConnectivityResult.mobile]), + child: StreamChannel( + showLoading: false, + channel: channel, + child: Scaffold( + body: Center( + child: core.StreamMessageLayout( + data: const core.StreamMessageLayoutData( + alignment: core.StreamMessageAlignment.end, + ), + child: core.StreamMessageItemTheme( + data: core.StreamMessageItemThemeData( + bubble: core.StreamMessageBubbleStyle.from( + backgroundColor: Colors.amber.shade300, + ), + text: core.StreamMessageTextStyle.from( + textColor: Colors.brown, + textStyle: const TextStyle( + fontWeight: FontWeight.bold, + fontFamily: 'Roboto', + ), + ), + ), + child: StreamMessageItem(message: message), + ), + ), + ), + ), + ), + ), + ); + }, + ); + + goldenTest( + 'message reaction theming', + fileName: 'message_reaction_theming', + constraints: const BoxConstraints.tightFor(width: 375, height: 200), + builder: () { + final client = MockClient(); + final clientState = MockClientState(); + final channel = MockChannel(type: 'messaging', id: 'general'); + final channelState = MockChannelState(); + _setupBasicChannel(client, clientState, channel, channelState); + + final message = Message( + id: 'msg-3', + text: 'Check out these reactions!', + user: _sender, + createdAt: DateTime(2024, 6, 1, 10, 0), + reactionGroups: { + 'celebrate': ReactionGroup( + count: 3, + sumScores: 3, + firstReactionAt: DateTime(2024, 6, 1), + lastReactionAt: DateTime(2024, 6, 1), + ), + 'love': ReactionGroup( + count: 2, + sumScores: 2, + firstReactionAt: DateTime(2024, 6, 1), + lastReactionAt: DateTime(2024, 6, 1), + ), + }, + ); + + return _buildMessageScaffold( + client: client, + channel: channel, + configData: StreamChatConfigurationData( + reactionIconResolver: const _CelebrationReactionResolver(), + ), + child: Center(child: StreamMessageItem(message: message)), + ); + }, + ); + + goldenTest( + 'message with rounded avatar', + fileName: 'message_rounded_avatar', + constraints: const BoxConstraints.tightFor(width: 375, height: 120), + builder: () { + final client = MockClient(); + final clientState = MockClientState(); + final channel = MockChannel(type: 'messaging', id: 'general'); + final channelState = MockChannelState(); + _setupBasicChannel(client, clientState, channel, channelState); + + final message = Message( + id: 'msg-4', + text: 'Message with user avatar shown.', + user: _sender, + createdAt: DateTime(2024, 6, 1, 10, 0), + ); + + return _buildMessageScaffold( + client: client, + channel: channel, + child: Center(child: StreamMessageItem(message: message)), + ); + }, + ); + + goldenTest( + 'message styles', + fileName: 'message_styles', + constraints: const BoxConstraints.tightFor(width: 375, height: 300), + builder: () { + final client = MockClient(); + final clientState = MockClientState(); + final channel = MockChannel(type: 'messaging', id: 'general'); + final channelState = MockChannelState(); + _setupBasicChannel(client, clientState, channel, channelState); + stubMockClientCurrentUser(client, OwnUser(id: 'user-1', name: 'Alice')); + + return MaterialApp( + theme: docsScreenshotsTheme(), + debugShowCheckedModeBanner: false, + home: StreamChat( + client: client, + streamChatThemeData: docsStreamChatThemeData(), + connectivityStream: Stream.value([ConnectivityResult.mobile]), + child: StreamChannel( + showLoading: false, + channel: channel, + child: Scaffold( + body: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + core.StreamMessageItemTheme( + data: core.StreamMessageItemThemeData( + text: core.StreamMessageTextStyle.from( + textColor: Colors.deepPurple, + textStyle: const TextStyle( + fontSize: 16, + fontStyle: FontStyle.italic, + fontFamily: 'Roboto', + ), + ), + ), + child: StreamMessageItem( + message: Message( + id: 'msg-from-other', + text: 'This is a message from Bob.', + user: _sender, + createdAt: DateTime(2024, 6, 1, 10, 0), + ), + ), + ), + core.StreamMessageLayout( + data: const core.StreamMessageLayoutData( + alignment: core.StreamMessageAlignment.end, + ), + child: core.StreamMessageItemTheme( + data: core.StreamMessageItemThemeData( + text: core.StreamMessageTextStyle.from( + textColor: Colors.indigo, + textStyle: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + fontFamily: 'Roboto', + ), + ), + ), + child: StreamMessageItem( + message: Message( + id: 'msg-from-me', + text: 'And this is my reply!', + user: _currentUser, + createdAt: DateTime(2024, 6, 1, 10, 1), + ), + ), + ), + ), + ], + ), + ), + ), + ), + ); + }, + ); +} + +// Placeholder action types used to populate the context menu in golden tests. +class _ReplyAction { + const _ReplyAction(); +} + +class _ThreadReplyAction { + const _ThreadReplyAction(); +} + +class _EditAction { + const _EditAction(); +} + +class _CopyAction { + const _CopyAction(); +} + +class _PinAction { + const _PinAction(); +} + +class _DeleteAction { + const _DeleteAction(); +} diff --git a/docs/docs_screenshots/test/message_search/goldens/ci/message_search_list_view.png b/docs/docs_screenshots/test/message_search/goldens/ci/message_search_list_view.png new file mode 100644 index 0000000000..9ef02ed0a5 Binary files /dev/null and b/docs/docs_screenshots/test/message_search/goldens/ci/message_search_list_view.png differ diff --git a/docs/docs_screenshots/test/message_search/goldens/macos/message_search_list_view.png b/docs/docs_screenshots/test/message_search/goldens/macos/message_search_list_view.png new file mode 100644 index 0000000000..4035a4990f Binary files /dev/null and b/docs/docs_screenshots/test/message_search/goldens/macos/message_search_list_view.png differ diff --git a/docs/docs_screenshots/test/message_search/message_search_list_view_test.dart b/docs/docs_screenshots/test/message_search/message_search_list_view_test.dart new file mode 100644 index 0000000000..b70dd0a495 --- /dev/null +++ b/docs/docs_screenshots/test/message_search/message_search_list_view_test.dart @@ -0,0 +1,95 @@ +import 'package:alchemist/alchemist.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:stream_chat_flutter/stream_chat_flutter.dart'; + +import '../src/golden_client_stubs.dart'; +import '../src/golden_theme.dart'; +import '../src/mocks.dart'; + +GetMessageResponse _makeSearchResult({ + required String messageId, + required String text, + required String userName, + required String channelName, +}) { + final response = GetMessageResponse() + ..message = Message( + id: messageId, + text: text, + user: User(id: 'user-$messageId', name: userName), + createdAt: DateTime(2024, 6, 1, 10, 0), + ) + ..channel = ChannelModel( + id: 'ch-$messageId', + type: 'messaging', + extraData: {'name': channelName}, + ); + return response; +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + goldenTest( + 'message search list view', + fileName: 'message_search_list_view', + constraints: const BoxConstraints.tightFor(width: 375, height: 500), + builder: () { + final client = MockClient(); + stubMockClientCurrentUser(client, OwnUser(id: 'user-1')); + + final results = [ + _makeSearchResult( + messageId: '1', + text: 'Flutter is an amazing UI toolkit!', + userName: 'Alice', + channelName: 'General', + ), + _makeSearchResult( + messageId: '2', + text: 'Flutter 3.0 has great performance improvements.', + userName: 'Bob', + channelName: 'Engineering', + ), + _makeSearchResult( + messageId: '3', + text: 'I love how Flutter handles animations.', + userName: 'Carol', + channelName: 'Design', + ), + _makeSearchResult( + messageId: '4', + text: 'Flutter Web support has come a long way.', + userName: 'Dave', + channelName: 'Random', + ), + ]; + + final controller = StreamMessageSearchListController.fromValue( + PagedValue(items: results), + client: client, + filter: Filter.equal('type', 'messaging'), + searchQuery: 'flutter', + ); + + stubSearchMessagesForGoldens(client, results); + + return MaterialApp( + theme: docsScreenshotsTheme(), + debugShowCheckedModeBanner: false, + home: StreamChat( + client: client, + streamChatThemeData: docsStreamChatThemeData(), + connectivityStream: Stream.value([ConnectivityResult.mobile]), + child: Scaffold( + body: StreamMessageSearchListView( + controller: controller, + shrinkWrap: true, + ), + ), + ), + ); + }, + ); +} diff --git a/docs/docs_screenshots/test/polls/goldens/ci/poll_creator.png b/docs/docs_screenshots/test/polls/goldens/ci/poll_creator.png new file mode 100644 index 0000000000..51205f2046 Binary files /dev/null and b/docs/docs_screenshots/test/polls/goldens/ci/poll_creator.png differ diff --git a/docs/docs_screenshots/test/polls/goldens/ci/poll_interactor.png b/docs/docs_screenshots/test/polls/goldens/ci/poll_interactor.png new file mode 100644 index 0000000000..8ef442cc54 Binary files /dev/null and b/docs/docs_screenshots/test/polls/goldens/ci/poll_interactor.png differ diff --git a/docs/docs_screenshots/test/polls/goldens/ci/polls_composer.png b/docs/docs_screenshots/test/polls/goldens/ci/polls_composer.png new file mode 100644 index 0000000000..deab485d68 Binary files /dev/null and b/docs/docs_screenshots/test/polls/goldens/ci/polls_composer.png differ diff --git a/docs/docs_screenshots/test/polls/goldens/macos/poll_creator.png b/docs/docs_screenshots/test/polls/goldens/macos/poll_creator.png new file mode 100644 index 0000000000..76b8461cb0 Binary files /dev/null and b/docs/docs_screenshots/test/polls/goldens/macos/poll_creator.png differ diff --git a/docs/docs_screenshots/test/polls/goldens/macos/poll_interactor.png b/docs/docs_screenshots/test/polls/goldens/macos/poll_interactor.png new file mode 100644 index 0000000000..8f23a4c305 Binary files /dev/null and b/docs/docs_screenshots/test/polls/goldens/macos/poll_interactor.png differ diff --git a/docs/docs_screenshots/test/polls/goldens/macos/polls_composer.png b/docs/docs_screenshots/test/polls/goldens/macos/polls_composer.png new file mode 100644 index 0000000000..b584057767 Binary files /dev/null and b/docs/docs_screenshots/test/polls/goldens/macos/polls_composer.png differ diff --git a/docs/docs_screenshots/test/polls/poll_test.dart b/docs/docs_screenshots/test/polls/poll_test.dart new file mode 100644 index 0000000000..67cb3faefc --- /dev/null +++ b/docs/docs_screenshots/test/polls/poll_test.dart @@ -0,0 +1,189 @@ +import 'package:alchemist/alchemist.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:stream_chat_flutter/stream_chat_flutter.dart'; + +import '../src/golden_theme.dart'; +import '../src/mocks.dart'; + +final _sender = User(id: 'user-2', name: 'Bob'); + +Widget _buildMessageScaffold({ + required MockClient client, + required MockChannel channel, + required Widget child, +}) { + return MaterialApp( + theme: docsScreenshotsTheme(), + debugShowCheckedModeBanner: false, + home: StreamChat( + client: client, + streamChatThemeData: docsStreamChatThemeData(), + connectivityStream: Stream.value([ConnectivityResult.mobile]), + child: StreamChannel( + showLoading: false, + channel: channel, + child: Scaffold(body: child), + ), + ), + ); +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + goldenTest( + 'poll creator widget', + fileName: 'poll_creator', + constraints: const BoxConstraints.tightFor(width: 375, height: 650), + builder: () { + final client = MockClient(); + + final controller = StreamPollController( + poll: Poll( + id: 'poll-1', + name: 'What is your favorite programming language?', + options: const [ + PollOption(id: 'opt-1', text: 'Dart'), + PollOption(id: 'opt-2', text: 'Swift'), + PollOption(id: 'opt-3', text: 'Kotlin'), + ], + ), + ); + + return MaterialApp( + theme: docsScreenshotsTheme(), + debugShowCheckedModeBanner: false, + home: StreamChat( + client: client, + streamChatThemeData: docsStreamChatThemeData(), + connectivityStream: Stream.value([ConnectivityResult.mobile]), + child: Scaffold( + appBar: AppBar( + leading: const IconButton( + icon: Icon(Icons.close), + onPressed: null, + ), + title: const Text('Create Poll'), + actions: const [ + IconButton( + icon: Icon(Icons.send), + onPressed: null, + ), + ], + ), + body: StreamPollCreatorWidget( + controller: controller, + shrinkWrap: true, + ), + ), + ), + ); + }, + ); + + goldenTest( + 'poll interactor widget', + fileName: 'poll_interactor', + constraints: const BoxConstraints.tightFor(width: 375, height: 500), + builder: () { + final client = MockClient(); + final clientState = MockClientState(); + final channel = MockChannel(type: 'messaging', id: 'general'); + final channelState = MockChannelState(); + + setupMockChannel( + client: client, + clientState: clientState, + channel: channel, + channelState: channelState, + channelName: 'General', + ); + + final poll = Poll( + id: 'poll-2', + name: 'Which feature would you like to see next?', + options: const [ + PollOption(id: 'opt-a', text: 'Offline mode'), + PollOption(id: 'opt-b', text: 'Message scheduling'), + PollOption(id: 'opt-c', text: 'Voice messages'), + PollOption(id: 'opt-d', text: 'Reactions 2.0'), + ], + voteCountsByOption: const { + 'opt-a': 8, + 'opt-b': 5, + 'opt-c': 12, + 'opt-d': 3, + }, + voteCount: 28, + ); + + final pollMessage = Message( + id: 'poll-msg', + user: _sender, + poll: poll, + createdAt: DateTime(2024, 6, 1, 10, 0), + ); + + return _buildMessageScaffold( + client: client, + channel: channel, + child: Center( + child: StreamMessageItem(message: pollMessage), + ), + ); + }, + ); + + goldenTest( + 'polls composer attachment picker', + fileName: 'polls_composer', + constraints: const BoxConstraints.tightFor(width: 375, height: 500), + builder: () { + final client = MockClient(); + final clientState = MockClientState(); + final channel = MockChannel(type: 'messaging', id: 'general'); + final channelState = MockChannelState(); + + setupMockChannel( + client: client, + clientState: clientState, + channel: channel, + channelState: channelState, + channelName: 'General', + ); + + final pollController = StreamPollController( + poll: Poll( + id: 'poll-3', + name: 'Pizza or Tacos for the team lunch?', + options: const [ + PollOption(id: 'p1', text: 'Pizza'), + PollOption(id: 'p2', text: 'Tacos'), + PollOption(id: 'p3', text: 'Both!'), + ], + ), + ); + + return MaterialApp( + theme: docsScreenshotsTheme(), + debugShowCheckedModeBanner: false, + home: StreamChat( + client: client, + streamChatThemeData: docsStreamChatThemeData(), + connectivityStream: Stream.value([ConnectivityResult.mobile]), + child: StreamChannel( + showLoading: false, + channel: channel, + child: Scaffold( + body: StreamPollCreatorWidget( + controller: pollController, + shrinkWrap: true, + ), + ), + ), + ), + ); + }, + ); +} diff --git a/docs/docs_screenshots/test/src/fakes.dart b/docs/docs_screenshots/test/src/fakes.dart new file mode 100644 index 0000000000..ea2d18f6b4 --- /dev/null +++ b/docs/docs_screenshots/test/src/fakes.dart @@ -0,0 +1,40 @@ +import 'package:mocktail/mocktail.dart'; +import 'package:plugin_platform_interface/plugin_platform_interface.dart'; +import 'package:record/record.dart'; + +class FakeRecordPlatform extends Fake with MockPlatformInterfaceMixin implements RecordPlatform { + @override + Future create(String recorderId) async {} + + @override + Future hasPermission(String recorderId, {bool request = true}) async { + return true; + } + + @override + Future isPaused(String recorderId) async { + return false; + } + + @override + Future isRecording(String recorderId) async { + return false; + } + + @override + Future pause(String recorderId) async {} + + @override + Future resume(String recorderId) async {} + + @override + Future stop(String recorderId) async { + return 'path'; + } + + @override + Future cancel(String recorderId) async {} + + @override + Future dispose(String recorderId) async {} +} diff --git a/docs/docs_screenshots/test/src/golden_client_stubs.dart b/docs/docs_screenshots/test/src/golden_client_stubs.dart new file mode 100644 index 0000000000..641c394c55 --- /dev/null +++ b/docs/docs_screenshots/test/src/golden_client_stubs.dart @@ -0,0 +1,108 @@ +import 'package:mocktail/mocktail.dart'; +import 'package:stream_chat_flutter/stream_chat_flutter.dart'; + +import 'mocks.dart'; + +bool _registeredFallbacks = false; + +void _ensureGoldenMocktailFallbacks() { + if (_registeredFallbacks) return; + registerFallbackValue(const Filter.empty()); + registerFallbackValue(const ThreadOptions()); + registerFallbackValue(const PaginationParams()); + registerFallbackValue(>[]); + registerFallbackValue(>[]); + registerFallbackValue(>[]); + registerFallbackValue(>[]); + _registeredFallbacks = true; +} + +/// Subscriptions after a successful paged load call [StreamChatClient.on]. Mocks must return a stream. +void stubStreamClientEventStream(MockClient client) { + when(() => client.on()).thenAnswer((_) => const Stream.empty()); +} + +/// Stubs channel queries for [StreamChannelListController] goldens using [StreamChannelListController.fromValue]. +void stubQueryChannelsForGoldens(MockClient client, List channels) { + _ensureGoldenMocktailFallbacks(); + stubStreamClientEventStream(client); + when( + () => client.queryChannels( + filter: any(named: 'filter'), + channelStateSort: any(named: 'channelStateSort'), + state: any(named: 'state'), + watch: any(named: 'watch'), + presence: any(named: 'presence'), + memberLimit: any(named: 'memberLimit'), + messageLimit: any(named: 'messageLimit'), + paginationParams: any(named: 'paginationParams'), + waitForConnect: any(named: 'waitForConnect'), + ), + ).thenAnswer((_) => Stream.value(channels)); +} + +/// Stubs thread queries for [StreamThreadListController] goldens using [StreamThreadListController.fromValue]. +void stubQueryThreadsForGoldens(MockClient client, List threads) { + _ensureGoldenMocktailFallbacks(); + stubStreamClientEventStream(client); + when( + () => client.queryThreads( + filter: any(named: 'filter'), + sort: any(named: 'sort'), + options: any(named: 'options'), + pagination: any(named: 'pagination'), + ), + ).thenAnswer( + (_) async => QueryThreadsResponse() + ..threads = threads + ..next = null, + ); +} + +/// Stubs user queries for [StreamUserListController] goldens using [StreamUserListController.fromValue]. +void stubQueryUsersForGoldens(MockClient client, List users) { + _ensureGoldenMocktailFallbacks(); + when( + () => client.queryUsers( + presence: any(named: 'presence'), + filter: any(named: 'filter'), + sort: any(named: 'sort'), + pagination: any(named: 'pagination'), + ), + ).thenAnswer((_) async => QueryUsersResponse()..users = users); +} + +/// Stubs draft queries for [StreamDraftListController] goldens using [StreamDraftListController.fromValue]. +void stubQueryDraftsForGoldens(MockClient client, List drafts) { + _ensureGoldenMocktailFallbacks(); + stubStreamClientEventStream(client); + when( + () => client.queryDrafts( + filter: any(named: 'filter'), + sort: any(named: 'sort'), + pagination: any(named: 'pagination'), + ), + ).thenAnswer( + (_) async => QueryDraftsResponse() + ..drafts = drafts + ..next = null, + ); +} + +/// Stubs search for [StreamMessageSearchListController] goldens using [StreamMessageSearchListController.fromValue]. +void stubSearchMessagesForGoldens(MockClient client, List results) { + _ensureGoldenMocktailFallbacks(); + when( + () => client.search( + any(), + query: any(named: 'query'), + sort: any(named: 'sort'), + paginationParams: any(named: 'paginationParams'), + messageFilters: any(named: 'messageFilters'), + ), + ).thenAnswer( + (_) async => SearchMessagesResponse() + ..results = results + ..next = null, + ); +} diff --git a/docs/docs_screenshots/test/src/golden_theme.dart b/docs/docs_screenshots/test/src/golden_theme.dart new file mode 100644 index 0000000000..ab08081392 --- /dev/null +++ b/docs/docs_screenshots/test/src/golden_theme.dart @@ -0,0 +1,62 @@ +import 'package:flutter/material.dart'; +import 'package:stream_chat_flutter/stream_chat_flutter.dart'; +import 'package:stream_core_flutter/stream_core_flutter.dart' as core; + +// --------------------------------------------------------------------------- +// StreamTheme (stream_core_flutter) — drives new message text rendering +// --------------------------------------------------------------------------- +// +// core.DefaultStreamMessageText reads its text style from +// StreamTheme.of(context).textTheme.bodyDefault. Those styles carry no +// fontFamily by default; when MarkdownBody passes them as the `p` style to +// RichText, RichText does NOT inherit DefaultTextStyle, so Flutter falls back +// to the Ahem test font (black rectangles). +// +// Fix: build a StreamTheme whose textTheme has fontFamily: 'Roboto' applied. +// +// --------------------------------------------------------------------------- +// StreamChatThemeData (stream_chat_flutter) — drives legacy message rendering +// --------------------------------------------------------------------------- +// +// StreamChatThemeData text styles (body, footnote, …) also carry no fontFamily. +// Same Ahem problem for any remaining legacy widgets that go through +// StreamMarkdownMessage → MarkdownBody → RichText. +// +// Fix: merge fontFamily: 'Roboto' into every StreamTextTheme style. + +ThemeData docsScreenshotsTheme() { + final streamTextTheme = core.StreamTextTheme().apply( + color: core.StreamColorScheme.light().systemText, + fontFamily: 'Roboto', + ); + + return ThemeData( + useMaterial3: true, + brightness: Brightness.light, + scaffoldBackgroundColor: const Color(0xFFFFFFFF), + appBarTheme: const AppBarTheme( + backgroundColor: Color(0xFFFFFFFF), + ), + extensions: [ + StreamTheme(brightness: Brightness.light, textTheme: streamTextTheme), + ], + ); +} + +StreamChatThemeData docsStreamChatThemeData() { + const roboto = TextStyle(fontFamily: 'Roboto'); + final base = StreamChatThemeData.light(); + final textTheme = base.textTheme.merge( + const StreamTextTheme.light( + body: roboto, + bodyBold: roboto, + title: roboto, + headline: roboto, + headlineBold: roboto, + footnote: roboto, + footnoteBold: roboto, + captionBold: roboto, + ), + ); + return StreamChatThemeData.fromColorAndTextTheme(base.colorTheme, textTheme); +} diff --git a/docs/docs_screenshots/test/src/mocks.dart b/docs/docs_screenshots/test/src/mocks.dart new file mode 100644 index 0000000000..f0f8695eab --- /dev/null +++ b/docs/docs_screenshots/test/src/mocks.dart @@ -0,0 +1,175 @@ +import 'package:mocktail/mocktail.dart'; +import 'package:stream_chat_flutter/stream_chat_flutter.dart'; + +/// [MockClient] cannot use `when(() => client.state.currentUser)` — mocktail +/// will treat [ClientState.currentUser] as the value of [StreamChatClient.state]. +/// Use this helper instead. +void stubMockClientCurrentUser(MockClient client, OwnUser user) { + final clientState = MockClientState(); + when(() => client.state).thenReturn(clientState); + when(() => clientState.currentUser).thenReturn(user); + when(() => clientState.currentUserStream).thenAnswer((_) => Stream.value(user)); +} + +class MockClient extends Mock implements StreamChatClient { + MockClient() { + when(() => wsConnectionStatus).thenReturn(ConnectionStatus.connected); + when(() => wsConnectionStatusStream).thenAnswer((_) => Stream.value(ConnectionStatus.connected)); + final mockState = MockClientState(); + when(() => state).thenReturn(mockState); + } +} + +class MockClientState extends Mock implements ClientState { + MockClientState() { + when(() => currentUserStream).thenAnswer((_) => Stream.value(OwnUser(id: 'user-id'))); + when(() => currentUser).thenReturn(OwnUser(id: 'user-id')); + } +} + +class MockChannel extends Mock implements Channel { + MockChannel({ + this.type = 'test-channel-type', + this.id = 'test-channel-id', + this.ownCapabilities = const [ + ChannelCapability.sendMessage, + ChannelCapability.uploadFile, + ], + }); + + @override + final String type; + + @override + final String? id; + + @override + String? get cid { + if (id != null) return '$type:$id'; + return null; + } + + @override + final List ownCapabilities; + + @override + Stream> get ownCapabilitiesStream { + return Stream.value(ownCapabilities); + } + + @override + Future get initialized async => true; + + @override + Future watch({ + bool presence = false, + PaginationParams? messagesPagination, + PaginationParams? membersPagination, + PaginationParams? watchersPagination, + }) { + return Future.value(const ChannelState()); + } + + @override + // ignore: prefer_expression_function_bodies + Future keyStroke([String? parentId]) async { + return; + } + + @override + Stream on([ + String? eventType, + String? eventType2, + String? eventType3, + String? eventType4, + ]) => const Stream.empty(); +} + +class MockChannelState extends Mock implements ChannelClientState { + MockChannelState() { + when(() => typingEvents).thenReturn({}); + when(() => typingEventsStream).thenAnswer((_) => Stream.value({})); + when(() => unreadCount).thenReturn(0); + when(() => unreadCountStream).thenAnswer((_) => Stream.value(0)); + when(() => isUpToDate).thenReturn(true); + when(() => isUpToDateStream).thenAnswer((_) => Stream.value(true)); + when(() => read).thenReturn([]); + when(() => readStream).thenAnswer((_) => Stream.value([])); + when(() => currentUserReadStream).thenAnswer((_) => Stream.value(null)); + when(() => draft).thenReturn(null); + when(() => draftStream).thenAnswer((_) => Stream.value(null)); + when(() => pinnedMessages).thenReturn([]); + when(() => pinnedMessagesStream).thenAnswer((_) => Stream.value([])); + when(() => channelState).thenReturn(const ChannelState()); + when(() => channelStateStream).thenAnswer((_) => Stream.value(const ChannelState())); + } +} + +/// Sets up a [MockChannel] with all stubs required by [StreamMessageComposer]. +void setupMockChannel({ + required MockClient client, + required MockClientState clientState, + required MockChannel channel, + required MockChannelState channelState, + String channelName = 'test', + List messages = const [], + List members = const [], +}) { + final allMembers = members.isNotEmpty + ? members + : [ + Member( + userId: 'user-id', + user: User(id: 'user-id'), + ), + ]; + + when(() => client.state).thenReturn(clientState); + when(() => channel.lastMessageAt).thenReturn(DateTime.parse('2020-06-22 12:00:00')); + when(() => channel.lastMessageAtStream).thenAnswer((_) => Stream.value(DateTime.parse('2020-06-22 12:00:00'))); + when(() => channel.state).thenReturn(channelState); + when(() => channel.client).thenReturn(client); + when(channel.getRemainingCooldown).thenReturn(0); + when(() => channel.isDistinct).thenReturn(false); + when(() => channel.isMuted).thenReturn(false); + when(() => channel.isMutedStream).thenAnswer((_) => Stream.value(false)); + when(() => channel.isPinned).thenReturn(false); + when(() => channel.isPinnedStream).thenAnswer((_) => Stream.value(false)); + when(() => channel.extraDataStream).thenAnswer((_) => Stream.value({'name': channelName})); + when(() => channel.extraData).thenReturn({'name': channelName}); + when(() => channel.name).thenReturn(channelName); + when(() => channel.nameStream).thenAnswer((_) => Stream.value(channelName)); + when(() => channel.image).thenReturn(null); + when(() => channel.imageStream).thenAnswer((_) => Stream.value(null)); + when(() => channelState.membersStream).thenAnswer((_) => Stream.value(allMembers)); + when(() => channelState.members).thenReturn(allMembers); + when(() => channelState.messages).thenReturn(messages); + when(() => channelState.messagesStream).thenAnswer((_) => Stream.value(messages)); +} + +/// Creates a [MockChannel] pre-configured with fake data for list views. +MockChannel fakeChannel({ + required MockClient client, + String id = 'test-channel-id', + String name = 'General', + List messages = const [], + int unreadCount = 0, +}) { + final channel = MockChannel(type: 'messaging', id: id); + final channelState = MockChannelState(); + final clientState = MockClientState(); + + setupMockChannel( + client: client, + clientState: clientState, + channel: channel, + channelState: channelState, + channelName: name, + messages: messages, + ); + + when(() => channelState.unreadCount).thenReturn(unreadCount); + when(() => channelState.unreadCountStream).thenAnswer((_) => Stream.value(unreadCount)); + + return channel; +} diff --git a/docs/docs_screenshots/test/thread_list/goldens/ci/thread_list_tile_custom.png b/docs/docs_screenshots/test/thread_list/goldens/ci/thread_list_tile_custom.png new file mode 100644 index 0000000000..909cf785cd Binary files /dev/null and b/docs/docs_screenshots/test/thread_list/goldens/ci/thread_list_tile_custom.png differ diff --git a/docs/docs_screenshots/test/thread_list/goldens/ci/thread_list_unread_banner.png b/docs/docs_screenshots/test/thread_list/goldens/ci/thread_list_unread_banner.png new file mode 100644 index 0000000000..777bc6e2b6 Binary files /dev/null and b/docs/docs_screenshots/test/thread_list/goldens/ci/thread_list_unread_banner.png differ diff --git a/docs/docs_screenshots/test/thread_list/goldens/ci/thread_list_view.png b/docs/docs_screenshots/test/thread_list/goldens/ci/thread_list_view.png new file mode 100644 index 0000000000..2d3021f2db Binary files /dev/null and b/docs/docs_screenshots/test/thread_list/goldens/ci/thread_list_view.png differ diff --git a/docs/docs_screenshots/test/thread_list/goldens/ci/thread_list_view_empty.png b/docs/docs_screenshots/test/thread_list/goldens/ci/thread_list_view_empty.png new file mode 100644 index 0000000000..482bdd8d5a Binary files /dev/null and b/docs/docs_screenshots/test/thread_list/goldens/ci/thread_list_view_empty.png differ diff --git a/docs/docs_screenshots/test/thread_list/goldens/macos/thread_list_tile_custom.png b/docs/docs_screenshots/test/thread_list/goldens/macos/thread_list_tile_custom.png new file mode 100644 index 0000000000..2b1ce833d6 Binary files /dev/null and b/docs/docs_screenshots/test/thread_list/goldens/macos/thread_list_tile_custom.png differ diff --git a/docs/docs_screenshots/test/thread_list/goldens/macos/thread_list_unread_banner.png b/docs/docs_screenshots/test/thread_list/goldens/macos/thread_list_unread_banner.png new file mode 100644 index 0000000000..07839c663f Binary files /dev/null and b/docs/docs_screenshots/test/thread_list/goldens/macos/thread_list_unread_banner.png differ diff --git a/docs/docs_screenshots/test/thread_list/goldens/macos/thread_list_view.png b/docs/docs_screenshots/test/thread_list/goldens/macos/thread_list_view.png new file mode 100644 index 0000000000..440917ece9 Binary files /dev/null and b/docs/docs_screenshots/test/thread_list/goldens/macos/thread_list_view.png differ diff --git a/docs/docs_screenshots/test/thread_list/goldens/macos/thread_list_view_empty.png b/docs/docs_screenshots/test/thread_list/goldens/macos/thread_list_view_empty.png new file mode 100644 index 0000000000..4c853e6958 Binary files /dev/null and b/docs/docs_screenshots/test/thread_list/goldens/macos/thread_list_view_empty.png differ diff --git a/docs/docs_screenshots/test/thread_list/thread_list_view_test.dart b/docs/docs_screenshots/test/thread_list/thread_list_view_test.dart new file mode 100644 index 0000000000..763b197f56 --- /dev/null +++ b/docs/docs_screenshots/test/thread_list/thread_list_view_test.dart @@ -0,0 +1,289 @@ +import 'package:alchemist/alchemist.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:stream_chat_flutter/stream_chat_flutter.dart'; + +import '../src/golden_client_stubs.dart'; +import '../src/golden_theme.dart'; +import '../src/mocks.dart'; + +final _user1 = User(id: 'user-1', name: 'Alice'); +final _user2 = User(id: 'user-2', name: 'Bob'); + +Thread _makeThread({ + required String id, + required String channelName, + required String parentText, + required String latestReplyText, + int unreadCount = 0, +}) { + final parentMessage = Message( + id: 'parent-$id', + text: parentText, + user: _user1, + createdAt: DateTime(2024, 6, 1, 9, 0), + ); + final latestReply = Message( + id: 'reply-$id', + text: latestReplyText, + user: _user2, + parentId: 'parent-$id', + createdAt: DateTime(2024, 6, 1, 10, 0), + ); + + return Thread( + channelCid: 'messaging:$id', + parentMessageId: 'parent-$id', + createdByUserId: 'user-1', + replyCount: 3, + participantCount: 2, + channel: ChannelModel( + id: id, + type: 'messaging', + extraData: {'name': channelName}, + ), + parentMessage: parentMessage, + latestReplies: [latestReply], + read: unreadCount > 0 + ? [ + Read( + user: _user1, + lastRead: DateTime(2024, 6, 1, 8, 0), + unreadMessages: unreadCount, + ), + ] + : [], + ); +} + +Widget _buildFullAppThreadScaffold({ + required MockClient client, + required StreamThreadListController controller, + Widget Function(BuildContext)? emptyBuilder, + Widget Function(BuildContext, Thread)? customItemBuilder, + Widget? banner, +}) { + return MaterialApp( + theme: docsScreenshotsTheme(), + debugShowCheckedModeBanner: false, + home: StreamChat( + client: client, + streamChatThemeData: docsStreamChatThemeData(), + connectivityStream: Stream.value([ConnectivityResult.mobile]), + child: Scaffold( + appBar: AppBar( + title: const Text('Stream Chat'), + actions: const [ + IconButton(icon: Icon(Icons.edit_outlined), onPressed: null), + ], + ), + body: Column( + children: [ + if (banner != null) banner, + Expanded( + child: StreamThreadListView( + controller: controller, + emptyBuilder: emptyBuilder, + itemBuilder: customItemBuilder != null + ? (context, threads, index, defaultWidget) => customItemBuilder(context, threads[index]) + : null, + ), + ), + ], + ), + bottomNavigationBar: BottomNavigationBar( + currentIndex: 2, + items: const [ + BottomNavigationBarItem( + icon: Icon(Icons.chat_bubble_outline), + label: 'Chats', + ), + BottomNavigationBarItem( + icon: Icon(Icons.alternate_email), + label: 'Mentions', + ), + BottomNavigationBarItem( + icon: Icon(Icons.comment_outlined), + label: 'Threads', + ), + ], + ), + ), + ), + ); +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + goldenTest( + 'thread list view with threads', + fileName: 'thread_list_view', + constraints: const BoxConstraints.tightFor(width: 375, height: 700), + builder: () { + final client = MockClient(); + stubMockClientCurrentUser(client, OwnUser(id: 'user-1')); + + final threads = [ + _makeThread( + id: 'general', + channelName: 'General', + parentText: 'Has anyone tried the new Flutter version?', + latestReplyText: 'Yes! The performance improvements are amazing.', + unreadCount: 2, + ), + _makeThread( + id: 'design', + channelName: 'Design', + parentText: 'The new color palette looks great!', + latestReplyText: 'Agreed, especially the dark mode colors.', + ), + _makeThread( + id: 'engineering', + channelName: 'Engineering', + parentText: 'We should refactor the auth module', + latestReplyText: 'I can take that on next sprint.', + ), + ]; + + final controller = StreamThreadListController.fromValue( + PagedValue(items: threads), + client: client, + ); + + stubQueryThreadsForGoldens(client, threads); + + return _buildFullAppThreadScaffold(client: client, controller: controller); + }, + ); + + goldenTest( + 'thread list view empty state', + fileName: 'thread_list_view_empty', + constraints: const BoxConstraints.tightFor(width: 375, height: 700), + builder: () { + final client = MockClient(); + stubMockClientCurrentUser(client, OwnUser(id: 'user-1')); + + final controller = StreamThreadListController.fromValue( + const PagedValue(items: []), + client: client, + ); + + stubQueryThreadsForGoldens(client, const []); + + return _buildFullAppThreadScaffold( + client: client, + controller: controller, + emptyBuilder: (context) => const Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.forum_outlined, size: 64, color: Colors.grey), + SizedBox(height: 16), + Text( + 'No threads yet', + style: TextStyle(fontSize: 18, fontWeight: FontWeight.w600), + ), + SizedBox(height: 8), + Text( + 'Threads will appear here once\nyou reply to a message.', + textAlign: TextAlign.center, + style: TextStyle(color: Colors.grey), + ), + ], + ), + ), + ); + }, + ); + + goldenTest( + 'thread list tile custom', + fileName: 'thread_list_tile_custom', + constraints: const BoxConstraints.tightFor(width: 375, height: 120), + builder: () { + final client = MockClient(); + stubMockClientCurrentUser(client, OwnUser(id: 'user-1')); + + final thread = _makeThread( + id: 'general', + channelName: 'General', + parentText: 'Has anyone tried the new Flutter version?', + latestReplyText: 'Yes! The performance improvements are amazing.', + unreadCount: 3, + ); + + return MaterialApp( + theme: docsScreenshotsTheme(), + debugShowCheckedModeBanner: false, + home: StreamChat( + client: client, + streamChatThemeData: docsStreamChatThemeData(), + connectivityStream: Stream.value([ConnectivityResult.mobile]), + child: Scaffold( + body: DecoratedBox( + decoration: BoxDecoration( + border: Border( + left: BorderSide(color: Colors.blue.shade700, width: 4), + ), + ), + child: StreamThreadListTile( + thread: thread, + currentUser: _user1, + ), + ), + ), + ), + ); + }, + ); + + goldenTest( + 'thread list unread banner', + fileName: 'thread_list_unread_banner', + constraints: const BoxConstraints.tightFor(width: 375, height: 700), + builder: () { + final client = MockClient(); + stubMockClientCurrentUser(client, OwnUser(id: 'user-1')); + + final threads = [ + _makeThread( + id: 'general', + channelName: 'General', + parentText: 'Has anyone tried the new Flutter version?', + latestReplyText: 'Yes! The performance improvements are amazing.', + unreadCount: 2, + ), + _makeThread( + id: 'design', + channelName: 'Design', + parentText: 'The new color palette looks great!', + latestReplyText: 'Agreed, especially the dark mode colors.', + ), + _makeThread( + id: 'engineering', + channelName: 'Engineering', + parentText: 'We should refactor the auth module', + latestReplyText: 'I can take that on next sprint.', + ), + ]; + + final controller = StreamThreadListController.fromValue( + PagedValue(items: threads), + client: client, + ); + + stubQueryThreadsForGoldens(client, threads); + + return _buildFullAppThreadScaffold( + client: client, + controller: controller, + banner: const StreamUnreadThreadsBanner( + enabled: true, + unreadThreads: {'thread-1', 'thread-2', 'thread-3'}, + ), + ); + }, + ); +} diff --git a/docs/docs_screenshots/test/user_list/goldens/ci/user_list_view.png b/docs/docs_screenshots/test/user_list/goldens/ci/user_list_view.png new file mode 100644 index 0000000000..29781e03e8 Binary files /dev/null and b/docs/docs_screenshots/test/user_list/goldens/ci/user_list_view.png differ diff --git a/docs/docs_screenshots/test/user_list/goldens/macos/user_list_view.png b/docs/docs_screenshots/test/user_list/goldens/macos/user_list_view.png new file mode 100644 index 0000000000..c9948533b6 Binary files /dev/null and b/docs/docs_screenshots/test/user_list/goldens/macos/user_list_view.png differ diff --git a/docs/docs_screenshots/test/user_list/user_list_view_test.dart b/docs/docs_screenshots/test/user_list/user_list_view_test.dart new file mode 100644 index 0000000000..bb6cfcc071 --- /dev/null +++ b/docs/docs_screenshots/test/user_list/user_list_view_test.dart @@ -0,0 +1,53 @@ +import 'package:alchemist/alchemist.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:stream_chat_flutter/stream_chat_flutter.dart'; + +import '../src/golden_client_stubs.dart'; +import '../src/golden_theme.dart'; +import '../src/mocks.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + goldenTest( + 'user list view', + fileName: 'user_list_view', + constraints: const BoxConstraints.tightFor(width: 375, height: 500), + builder: () { + final client = MockClient(); + stubMockClientCurrentUser(client, OwnUser(id: 'user-1')); + + final users = [ + User(id: 'user-2', name: 'Alice Johnson', online: true), + User(id: 'user-3', name: 'Bob Smith', online: false), + User(id: 'user-4', name: 'Carol White', online: true), + User(id: 'user-5', name: 'David Brown', online: false), + User(id: 'user-6', name: 'Eve Davis', online: true), + ]; + + final controller = StreamUserListController.fromValue( + PagedValue(items: users), + client: client, + ); + + stubQueryUsersForGoldens(client, users); + + return MaterialApp( + theme: docsScreenshotsTheme(), + debugShowCheckedModeBanner: false, + home: StreamChat( + client: client, + streamChatThemeData: docsStreamChatThemeData(), + connectivityStream: Stream.value([ConnectivityResult.mobile]), + child: Scaffold( + body: StreamUserListView( + controller: controller, + shrinkWrap: true, + ), + ), + ), + ); + }, + ); +} diff --git a/docs/docs_screenshots/test/voice_recording/goldens/ci/voice_recording_attachment.png b/docs/docs_screenshots/test/voice_recording/goldens/ci/voice_recording_attachment.png new file mode 100644 index 0000000000..6939af7e33 Binary files /dev/null and b/docs/docs_screenshots/test/voice_recording/goldens/ci/voice_recording_attachment.png differ diff --git a/docs/docs_screenshots/test/voice_recording/goldens/ci/voice_recording_attachment_custom.png b/docs/docs_screenshots/test/voice_recording/goldens/ci/voice_recording_attachment_custom.png new file mode 100644 index 0000000000..5944f56451 Binary files /dev/null and b/docs/docs_screenshots/test/voice_recording/goldens/ci/voice_recording_attachment_custom.png differ diff --git a/docs/docs_screenshots/test/voice_recording/goldens/ci/voice_recording_attachment_playing.png b/docs/docs_screenshots/test/voice_recording/goldens/ci/voice_recording_attachment_playing.png new file mode 100644 index 0000000000..6939af7e33 Binary files /dev/null and b/docs/docs_screenshots/test/voice_recording/goldens/ci/voice_recording_attachment_playing.png differ diff --git a/docs/docs_screenshots/test/voice_recording/goldens/ci/voice_recording_enabled.png b/docs/docs_screenshots/test/voice_recording/goldens/ci/voice_recording_enabled.png new file mode 100644 index 0000000000..a1a8490c4b Binary files /dev/null and b/docs/docs_screenshots/test/voice_recording/goldens/ci/voice_recording_enabled.png differ diff --git a/docs/docs_screenshots/test/voice_recording/goldens/ci/voice_recording_finished.png b/docs/docs_screenshots/test/voice_recording/goldens/ci/voice_recording_finished.png new file mode 100644 index 0000000000..3b1e56c832 Binary files /dev/null and b/docs/docs_screenshots/test/voice_recording/goldens/ci/voice_recording_finished.png differ diff --git a/docs/docs_screenshots/test/voice_recording/goldens/ci/voice_recording_hold_recording.png b/docs/docs_screenshots/test/voice_recording/goldens/ci/voice_recording_hold_recording.png new file mode 100644 index 0000000000..ffe1601e94 Binary files /dev/null and b/docs/docs_screenshots/test/voice_recording/goldens/ci/voice_recording_hold_recording.png differ diff --git a/docs/docs_screenshots/test/voice_recording/goldens/ci/voice_recording_idle.png b/docs/docs_screenshots/test/voice_recording/goldens/ci/voice_recording_idle.png new file mode 100644 index 0000000000..a1a8490c4b Binary files /dev/null and b/docs/docs_screenshots/test/voice_recording/goldens/ci/voice_recording_idle.png differ diff --git a/docs/docs_screenshots/test/voice_recording/goldens/ci/voice_recording_idle_tooltip.png b/docs/docs_screenshots/test/voice_recording/goldens/ci/voice_recording_idle_tooltip.png new file mode 100644 index 0000000000..33ce80fa50 Binary files /dev/null and b/docs/docs_screenshots/test/voice_recording/goldens/ci/voice_recording_idle_tooltip.png differ diff --git a/docs/docs_screenshots/test/voice_recording/goldens/ci/voice_recording_locked_recording.png b/docs/docs_screenshots/test/voice_recording/goldens/ci/voice_recording_locked_recording.png new file mode 100644 index 0000000000..4beb780ba3 Binary files /dev/null and b/docs/docs_screenshots/test/voice_recording/goldens/ci/voice_recording_locked_recording.png differ diff --git a/docs/docs_screenshots/test/voice_recording/goldens/ci/voice_recording_stopped.png b/docs/docs_screenshots/test/voice_recording/goldens/ci/voice_recording_stopped.png new file mode 100644 index 0000000000..5a36d484d9 Binary files /dev/null and b/docs/docs_screenshots/test/voice_recording/goldens/ci/voice_recording_stopped.png differ diff --git a/docs/docs_screenshots/test/voice_recording/goldens/macos/voice_recording_attachment.png b/docs/docs_screenshots/test/voice_recording/goldens/macos/voice_recording_attachment.png new file mode 100644 index 0000000000..d9742b5dbf Binary files /dev/null and b/docs/docs_screenshots/test/voice_recording/goldens/macos/voice_recording_attachment.png differ diff --git a/docs/docs_screenshots/test/voice_recording/goldens/macos/voice_recording_attachment_custom.png b/docs/docs_screenshots/test/voice_recording/goldens/macos/voice_recording_attachment_custom.png new file mode 100644 index 0000000000..81a81b61e0 Binary files /dev/null and b/docs/docs_screenshots/test/voice_recording/goldens/macos/voice_recording_attachment_custom.png differ diff --git a/docs/docs_screenshots/test/voice_recording/goldens/macos/voice_recording_attachment_playing.png b/docs/docs_screenshots/test/voice_recording/goldens/macos/voice_recording_attachment_playing.png new file mode 100644 index 0000000000..d9742b5dbf Binary files /dev/null and b/docs/docs_screenshots/test/voice_recording/goldens/macos/voice_recording_attachment_playing.png differ diff --git a/docs/docs_screenshots/test/voice_recording/goldens/macos/voice_recording_enabled.png b/docs/docs_screenshots/test/voice_recording/goldens/macos/voice_recording_enabled.png new file mode 100644 index 0000000000..c412178ff3 Binary files /dev/null and b/docs/docs_screenshots/test/voice_recording/goldens/macos/voice_recording_enabled.png differ diff --git a/docs/docs_screenshots/test/voice_recording/goldens/macos/voice_recording_finished.png b/docs/docs_screenshots/test/voice_recording/goldens/macos/voice_recording_finished.png new file mode 100644 index 0000000000..bd6a72fc00 Binary files /dev/null and b/docs/docs_screenshots/test/voice_recording/goldens/macos/voice_recording_finished.png differ diff --git a/docs/docs_screenshots/test/voice_recording/goldens/macos/voice_recording_hold_recording.png b/docs/docs_screenshots/test/voice_recording/goldens/macos/voice_recording_hold_recording.png new file mode 100644 index 0000000000..f09eca04bd Binary files /dev/null and b/docs/docs_screenshots/test/voice_recording/goldens/macos/voice_recording_hold_recording.png differ diff --git a/docs/docs_screenshots/test/voice_recording/goldens/macos/voice_recording_idle.png b/docs/docs_screenshots/test/voice_recording/goldens/macos/voice_recording_idle.png new file mode 100644 index 0000000000..c412178ff3 Binary files /dev/null and b/docs/docs_screenshots/test/voice_recording/goldens/macos/voice_recording_idle.png differ diff --git a/docs/docs_screenshots/test/voice_recording/goldens/macos/voice_recording_idle_tooltip.png b/docs/docs_screenshots/test/voice_recording/goldens/macos/voice_recording_idle_tooltip.png new file mode 100644 index 0000000000..29cba0a34f Binary files /dev/null and b/docs/docs_screenshots/test/voice_recording/goldens/macos/voice_recording_idle_tooltip.png differ diff --git a/docs/docs_screenshots/test/voice_recording/goldens/macos/voice_recording_locked_recording.png b/docs/docs_screenshots/test/voice_recording/goldens/macos/voice_recording_locked_recording.png new file mode 100644 index 0000000000..ed110c3bfc Binary files /dev/null and b/docs/docs_screenshots/test/voice_recording/goldens/macos/voice_recording_locked_recording.png differ diff --git a/docs/docs_screenshots/test/voice_recording/goldens/macos/voice_recording_stopped.png b/docs/docs_screenshots/test/voice_recording/goldens/macos/voice_recording_stopped.png new file mode 100644 index 0000000000..49d2a05921 Binary files /dev/null and b/docs/docs_screenshots/test/voice_recording/goldens/macos/voice_recording_stopped.png differ diff --git a/docs/docs_screenshots/test/voice_recording/voice_recording_test.dart b/docs/docs_screenshots/test/voice_recording/voice_recording_test.dart new file mode 100644 index 0000000000..608f9031f3 --- /dev/null +++ b/docs/docs_screenshots/test/voice_recording/voice_recording_test.dart @@ -0,0 +1,586 @@ +import 'package:alchemist/alchemist.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:record/record.dart'; +import 'package:stream_chat_flutter/src/message_input/stream_chat_message_input.dart'; +import 'package:stream_chat_flutter/stream_chat_flutter.dart'; + +import '../src/fakes.dart'; +import '../src/golden_theme.dart'; +import '../src/mocks.dart'; + +class _MockAudioRecorder extends Mock implements AudioRecorder {} + +StreamAudioRecorderController _makeRecorderController(AudioRecorderState initialState) { + final mockRecorder = _MockAudioRecorder(); + when(() => mockRecorder.onAmplitudeChanged(any())).thenAnswer((_) => const Stream.empty()); + when(mockRecorder.dispose).thenAnswer((_) async {}); + return StreamAudioRecorderController.raw( + config: const RecordConfig(numChannels: 1), + recorder: mockRecorder, + initialState: initialState, + ); +} + +Widget _buildVoiceRecordingMessageInputScaffold({ + required MockClient client, + required MockChannel channel, + StreamMessageComposerController? messageComposerController, +}) { + return MaterialApp( + theme: docsScreenshotsTheme(), + debugShowCheckedModeBanner: false, + home: StreamChat( + client: client, + streamChatThemeData: docsStreamChatThemeData(), + connectivityStream: Stream.value([ConnectivityResult.mobile]), + child: StreamChannel( + showLoading: false, + channel: channel, + child: Scaffold( + body: Column( + children: [ + Expanded(child: Container()), + StreamMessageComposer( + enableVoiceRecording: true, + messageComposerController: messageComposerController, + ), + ], + ), + ), + ), + ), + ); +} + +/// Scaffold that shows a message bubble + the voice widget + an input bar, +/// giving context to how voice recording looks in a real conversation. +Widget _buildVoiceRecordingContextScaffold({ + required MockClient client, + required MockChannel channel, + required Widget voiceWidget, + StreamChatThemeData? streamChatThemeData, +}) { + return MaterialApp( + theme: docsScreenshotsTheme(), + debugShowCheckedModeBanner: false, + home: StreamChat( + client: client, + streamChatThemeData: streamChatThemeData ?? docsStreamChatThemeData(), + connectivityStream: Stream.value([ConnectivityResult.mobile]), + child: StreamChannel( + showLoading: false, + channel: channel, + child: Scaffold( + body: Column( + children: [ + Expanded( + child: ListView( + reverse: true, + children: [ + Padding( + padding: const EdgeInsets.all(8), + child: voiceWidget, + ), + StreamMessageItem( + message: Message( + id: 'ctx-msg', + text: 'Hey, listen to this!', + user: User(id: 'user-2', name: 'Bob'), + createdAt: DateTime(2024, 6, 1, 10, 0), + ), + ), + ], + ), + ), + StreamMessageComposer(enableVoiceRecording: true), + ], + ), + ), + ), + ), + ); +} + +/// Scaffold that shows a full message input bar (with attachment button and +/// placeholder) using [StreamChatMessageInput] so we can inject a custom +/// [audioRecorderController] to control the recording state. +/// +/// The outer [Material] + bottom padding mirrors what [StreamMessageComposer] +/// wraps around [StreamChatMessageInput] internally. +Widget _buildVoiceRecordingComposerScaffold({ + required MockClient client, + required MockChannel channel, + required StreamAudioRecorderController audioRecorderController, +}) { + return MaterialApp( + theme: docsScreenshotsTheme(), + debugShowCheckedModeBanner: false, + home: StreamChat( + client: client, + streamChatThemeData: docsStreamChatThemeData(), + connectivityStream: Stream.value([ConnectivityResult.mobile]), + child: StreamChannel( + showLoading: false, + channel: channel, + child: Scaffold( + body: Column( + children: [ + Expanded(child: Container()), + Builder( + builder: (context) { + return Material( + child: DecoratedBox( + decoration: BoxDecoration( + color: context.streamColorScheme.backgroundElevation1, + ), + child: Padding( + padding: EdgeInsets.only(bottom: context.streamSpacing.md), + child: StreamChatMessageInput( + onSendPressed: () {}, + onAttachmentButtonPressed: () {}, + placeholder: 'Send a message', + audioRecorderController: audioRecorderController, + ), + ), + ), + ); + }, + ), + ], + ), + ), + ), + ), + ); +} + +void _setupChannel(MockClient client, MockClientState clientState, MockChannel channel, MockChannelState channelState) { + setupMockChannel( + client: client, + clientState: clientState, + channel: channel, + channelState: channelState, + ); +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + setUpAll(() { + registerFallbackValue(Duration.zero); + }); + + final originalRecordPlatform = RecordPlatform.instance; + setUp(() => RecordPlatform.instance = FakeRecordPlatform()); + tearDown(() => RecordPlatform.instance = originalRecordPlatform); + + goldenTest( + 'voice recording idle state', + fileName: 'voice_recording_idle', + constraints: const BoxConstraints.tightFor(width: 375, height: 100), + builder: () { + final client = MockClient(); + final clientState = MockClientState(); + final channel = MockChannel(); + final channelState = MockChannelState(); + _setupChannel(client, clientState, channel, channelState); + + return _buildVoiceRecordingMessageInputScaffold( + client: client, + channel: channel, + ); + }, + ); + + goldenTest( + 'voice recording enabled (mic button visible)', + fileName: 'voice_recording_enabled', + constraints: const BoxConstraints.tightFor(width: 375, height: 100), + builder: () { + final client = MockClient(); + final clientState = MockClientState(); + final channel = MockChannel(); + final channelState = MockChannelState(); + _setupChannel(client, clientState, channel, channelState); + + return _buildVoiceRecordingMessageInputScaffold( + client: client, + channel: channel, + ); + }, + ); + + goldenTest( + 'voice recording hold recording state', + fileName: 'voice_recording_hold_recording', + constraints: const BoxConstraints.tightFor(width: 375, height: 200), + builder: () { + final client = MockClient(); + final clientState = MockClientState(); + final channel = MockChannel(); + final channelState = MockChannelState(); + _setupChannel(client, clientState, channel, channelState); + + final holdState = RecordStateRecordingHold( + duration: const Duration(seconds: 5), + waveform: List.generate(20, (i) => (i % 5) / 5.0), + ); + + return _buildVoiceRecordingComposerScaffold( + client: client, + channel: channel, + audioRecorderController: _makeRecorderController(holdState), + ); + }, + ); + + goldenTest( + 'voice recording locked recording state', + fileName: 'voice_recording_locked_recording', + constraints: const BoxConstraints.tightFor(width: 375, height: 200), + builder: () { + final client = MockClient(); + final clientState = MockClientState(); + final channel = MockChannel(); + final channelState = MockChannelState(); + _setupChannel(client, clientState, channel, channelState); + + final lockedState = RecordStateRecordingLocked( + duration: const Duration(seconds: 12), + waveform: List.generate(20, (i) => (i % 5) / 5.0), + ); + + return _buildVoiceRecordingComposerScaffold( + client: client, + channel: channel, + audioRecorderController: _makeRecorderController(lockedState), + ); + }, + ); + + goldenTest( + 'voice recording stopped state', + fileName: 'voice_recording_stopped', + constraints: const BoxConstraints.tightFor(width: 375, height: 150), + builder: () { + final client = MockClient(); + final clientState = MockClientState(); + final channel = MockChannel(); + final channelState = MockChannelState(); + _setupChannel(client, clientState, channel, channelState); + + final stoppedState = RecordStateStopped( + audioRecording: Attachment( + type: 'voiceRecording', + assetUrl: 'https://example.com/recording.m4a', + uploadState: const UploadState.success(), + extraData: const { + 'duration': 15.0, + 'waveform_data': [0.1, 0.5, 0.9, 0.4, 0.2], + }, + ), + ); + + return _buildVoiceRecordingComposerScaffold( + client: client, + channel: channel, + audioRecorderController: _makeRecorderController(stoppedState), + ); + }, + ); + + goldenTest( + 'voice recording finished state', + fileName: 'voice_recording_finished', + constraints: const BoxConstraints.tightFor(width: 375, height: 200), + builder: () { + final client = MockClient(); + final clientState = MockClientState(); + final channel = MockChannel(); + final channelState = MockChannelState(); + _setupChannel(client, clientState, channel, channelState); + + final messageComposerController = StreamMessageComposerController() + ..addAttachment( + Attachment( + type: 'voiceRecording', + assetUrl: 'https://example.com/recording.m4a', + uploadState: const UploadState.success(), + extraData: const { + 'duration': 15.0, + 'waveform_data': [0.1, 0.5, 0.9, 0.4, 0.2], + }, + ), + ); + + return _buildVoiceRecordingMessageInputScaffold( + client: client, + channel: channel, + messageComposerController: messageComposerController, + ); + }, + ); + + goldenTest( + 'voice recording attachment idle', + fileName: 'voice_recording_attachment', + constraints: const BoxConstraints.tightFor(width: 375, height: 400), + builder: () { + final client = MockClient(); + final clientState = MockClientState(); + final channel = MockChannel(); + final channelState = MockChannelState(); + _setupChannel(client, clientState, channel, channelState); + + final voiceMessage = Message( + id: 'voice-msg', + user: User(id: 'user-2', name: 'Bob'), + attachments: [ + Attachment( + type: 'voiceRecording', + assetUrl: 'https://example.com/recording.m4a', + uploadState: const UploadState.success(), + extraData: const { + 'duration': 15.0, + 'waveform_data': [ + 0.1, + 0.3, + 0.5, + 0.7, + 0.9, + 0.7, + 0.5, + 0.3, + 0.1, + 0.2, + 0.4, + 0.6, + 0.8, + 0.6, + 0.4, + 0.2, + 0.5, + 0.8, + 0.6, + 0.3, + 0.1, + 0.4, + 0.7, + 0.9, + 0.6, + 0.3, + 0.1, + 0.4, + 0.7, + 0.5, + 0.2, + 0.6, + 0.8, + 0.4, + 0.2, + ], + }, + ), + ], + createdAt: DateTime(2024, 6, 1, 10, 0), + ); + + return _buildVoiceRecordingContextScaffold( + client: client, + channel: channel, + voiceWidget: StreamMessageItem(message: voiceMessage), + ); + }, + ); + + goldenTest( + 'voice recording idle tooltip', + fileName: 'voice_recording_idle_tooltip', + constraints: const BoxConstraints.tightFor(width: 375, height: 150), + builder: () { + final client = MockClient(); + final clientState = MockClientState(); + final channel = MockChannel(); + final channelState = MockChannelState(); + _setupChannel(client, clientState, channel, channelState); + + final audioRecorderController = _makeRecorderController( + const RecordStateIdle(message: 'Hold to record, release to send.'), + ); + + return _buildVoiceRecordingComposerScaffold( + client: client, + channel: channel, + audioRecorderController: audioRecorderController, + ); + }, + ); + + goldenTest( + 'voice recording attachment playing', + fileName: 'voice_recording_attachment_playing', + constraints: const BoxConstraints.tightFor(width: 375, height: 400), + builder: () { + final client = MockClient(); + final clientState = MockClientState(); + final channel = MockChannel(); + final channelState = MockChannelState(); + _setupChannel(client, clientState, channel, channelState); + + final voiceMessage = Message( + id: 'voice-msg-playing', + user: User(id: 'user-2', name: 'Bob'), + attachments: [ + Attachment( + type: 'voiceRecording', + assetUrl: 'https://example.com/recording.m4a', + uploadState: const UploadState.success(), + extraData: const { + 'duration': 15.0, + 'waveform_data': [ + 0.1, + 0.3, + 0.5, + 0.7, + 0.9, + 0.7, + 0.5, + 0.3, + 0.1, + 0.2, + 0.4, + 0.6, + 0.8, + 0.6, + 0.4, + 0.2, + 0.5, + 0.8, + 0.6, + 0.3, + 0.1, + 0.4, + 0.7, + 0.9, + 0.6, + 0.3, + 0.1, + 0.4, + 0.7, + 0.5, + 0.2, + 0.6, + 0.8, + 0.4, + 0.2, + ], + }, + ), + ], + createdAt: DateTime(2024, 6, 1, 10, 0), + ); + + return _buildVoiceRecordingContextScaffold( + client: client, + channel: channel, + voiceWidget: StreamMessageItem(message: voiceMessage), + ); + }, + ); + + goldenTest( + 'voice recording attachment custom theme', + fileName: 'voice_recording_attachment_custom', + constraints: const BoxConstraints.tightFor(width: 375, height: 400), + builder: () { + final client = MockClient(); + final clientState = MockClientState(); + final channel = MockChannel(); + final channelState = MockChannelState(); + _setupChannel(client, clientState, channel, channelState); + + const roboto = TextStyle(fontFamily: 'Roboto'); + final customTheme = docsStreamChatThemeData().copyWith( + voiceRecordingAttachmentTheme: StreamVoiceRecordingAttachmentThemeData( + titleTextStyle: roboto.copyWith(color: Colors.black54), + durationTextStyle: roboto.copyWith(color: Colors.black54), + activeDurationTextStyle: roboto.copyWith(color: Colors.black), + controlButtonStyle: StreamButtonThemeStyle.from( + backgroundColor: Colors.green, + foregroundColor: Colors.white, + ), + speedToggleStyle: StreamPlaybackSpeedToggleStyle.from( + backgroundColor: Colors.green, + foregroundColor: Colors.white, + ), + waveformStyle: const StreamAudioWaveformThemeData( + color: Colors.black54, + progressColor: Colors.black, + ), + ), + ); + + final voiceMessage = Message( + id: 'voice-msg-custom', + user: User(id: 'user-2', name: 'Bob'), + attachments: [ + Attachment( + type: 'voiceRecording', + assetUrl: 'https://example.com/recording.m4a', + uploadState: const UploadState.success(), + extraData: const { + 'duration': 15.0, + 'waveform_data': [ + 0.1, + 0.3, + 0.5, + 0.7, + 0.9, + 0.7, + 0.5, + 0.3, + 0.1, + 0.2, + 0.4, + 0.6, + 0.8, + 0.6, + 0.4, + 0.2, + 0.5, + 0.8, + 0.6, + 0.3, + 0.1, + 0.4, + 0.7, + 0.9, + 0.6, + 0.3, + 0.1, + 0.4, + 0.7, + 0.5, + 0.2, + 0.6, + 0.8, + 0.4, + 0.2, + ], + }, + ), + ], + createdAt: DateTime(2024, 6, 1, 10, 0), + ); + + return _buildVoiceRecordingContextScaffold( + client: client, + channel: channel, + voiceWidget: StreamMessageItem(message: voiceMessage), + streamChatThemeData: customTheme, + ); + }, + ); +} diff --git a/melos.yaml b/melos.yaml index 2155166eb0..2740e47901 100644 --- a/melos.yaml +++ b/melos.yaml @@ -1,12 +1,15 @@ -name: stream_chat_flutter +name: stream_chat_flutter_workspace repository: https://github.com/GetStream/stream-chat-flutter packages: + - docs/* - sample_app - packages/* - packages/*/example categories: + docs: + - docs/* sample_app: - sample_app packages: @@ -16,15 +19,19 @@ categories: command: bootstrap: + # Run `pub get` sequentially to avoid races on the shared git-dep cache. + runPubGetInParallel: false + # Dart and Flutter environment used in the project. environment: - sdk: ^3.6.2 + sdk: ^3.10.0 # We are not using carat '^' syntax here because flutter don't follow semantic versioning. - flutter: ">=3.27.4" + flutter: ">=3.38.1" # List of all the dependencies used in the project. dependencies: async: ^2.11.0 + avatar_glow: ^3.0.0 cached_network_image: ^3.3.1 chewie: ^1.8.1 collection: ^1.17.2 @@ -32,17 +39,20 @@ command: cupertino_icons: ^1.0.3 desktop_drop: '>=0.5.0 <0.8.0' device_info_plus: '>=11.0.0 <13.0.0' + device_preview: ^1.2.0 diacritic: ^0.1.5 dio: ^5.4.3+1 - drift: ^2.22.1 + drift: ^2.28.0 equatable: ^2.0.5 ezanimation: ^0.6.0 - firebase_core: ^3.0.0 - firebase_messaging: ^15.0.0 + firebase_core: ^4.0.0 + firebase_messaging: ^16.0.0 file_picker: ^10.1.2 file_selector: ^1.0.3 flutter_app_badger: ^1.5.0 - flutter_local_notifications: ^18.0.1 + flutter_local_notifications: ^21.0.0 + flutter_map: ^8.1.1 + flutter_map_animations: ^0.9.0 flutter_markdown: ^0.7.2+1 flutter_portal: ^1.1.4 flutter_secure_storage: ^9.2.2 @@ -50,6 +60,7 @@ command: flutter_svg: ^2.0.10+1 freezed_annotation: ">=2.4.1 <4.0.0" gal: ^2.3.1 + geolocator: ^13.0.0 get_thumbnail_video: ^0.7.3 go_router: ^14.6.2 http_parser: ^4.0.2 @@ -59,6 +70,7 @@ command: jose: ^0.3.4 json_annotation: ^4.9.0 just_audio: ">=0.9.38 <0.11.0" + latlong2: ^0.9.1 logging: ^1.2.0 lottie: ^3.1.2 media_kit: ^1.2.2 @@ -68,24 +80,29 @@ command: package_info_plus: ">=8.3.0 <10.0.0" path: ^1.8.3 path_provider: ^2.1.3 - photo_manager: ^3.2.0 + photo_manager: ^3.8.3 photo_view: ^0.15.0 - provider: ^6.0.5 rate_limiter: ^1.0.0 - record: ">=5.2.0 <7.0.0" + record: ^6.2.0 responsive_builder: ^0.7.0 rxdart: ^0.28.0 sentry_flutter: ^8.3.0 share_plus: ">=11.0.0 <13.0.0" shimmer: ^3.0.0 sqlite3_flutter_libs: ^0.5.26 - stream_chat: ^9.23.0 - stream_chat_flutter: ^9.23.0 - stream_chat_flutter_core: ^9.23.0 - stream_chat_localizations: ^9.23.0 - stream_chat_persistence: ^9.23.0 + stream_chat: ^10.0.0-beta.13 + stream_chat_flutter: ^10.0.0-beta.13 + stream_chat_flutter_core: ^10.0.0-beta.13 + stream_chat_localizations: ^10.0.0-beta.13 + stream_chat_persistence: ^10.0.0-beta.13 streaming_shared_preferences: ^2.0.0 svg_icon_widget: ^0.0.1 + # TODO: Replace with hosted version before merging PR + stream_core_flutter: + git: + url: https://github.com/GetStream/stream-core-flutter.git + ref: 333f7b72485f308b282cc85973223a2919fd8153 + path: packages/stream_core_flutter synchronized: ^3.1.0+1 thumblr: ^0.0.4 url_launcher: ^6.3.0 @@ -95,10 +112,10 @@ command: # List of all the dev_dependencies used in the project. dev_dependencies: - alchemist: ">=0.11.0 <0.14.0" + alchemist: ^0.14.0 build_runner: ^2.4.9 connectivity_plus_platform_interface: ^2.0.0 - drift_dev: ^2.22.1 + drift_dev: ^2.28.0 fake_async: ^1.3.1 faker_dart: ^0.2.1 flutter_launcher_icons: ^0.14.2 @@ -109,6 +126,7 @@ command: path_provider_platform_interface: ^2.0.0 plugin_platform_interface: ^2.0.0 test: ^1.24.6 + theme_extensions_builder: ^7.2.0 hooks: # Updates the version.dart file after bootstrapping with the current version from pubspec.yaml diff --git a/migrations/redesign/README.md b/migrations/redesign/README.md new file mode 100644 index 0000000000..5f1e3a45f8 --- /dev/null +++ b/migrations/redesign/README.md @@ -0,0 +1,141 @@ +# Stream Chat Flutter UI Redesign Migration Guide + +This folder contains migration guides for the redesigned UI components in Stream Chat Flutter SDK. + +## Overview + +The redesigned components aim to provide: +- Simplified and consistent APIs +- Better theme integration +- Improved developer experience +- Reduced boilerplate + +Each component migration guide contains specific details about the changes and how to migrate. + +## Theming + +The redesigned components use `StreamTheme` for theming. If no `StreamTheme` is provided, a default theme is automatically created based on `Theme.of(context).brightness` (light or dark mode). + +To customize the default theming, add `StreamTheme` as a theme extension to your `MaterialApp`: + +```dart +MaterialApp( + theme: ThemeData( + extensions: [ + StreamTheme( + brightness: Brightness.light, + colorScheme: StreamColorScheme.light().copyWith( + // Customize colors... + ), + avatarTheme: const StreamAvatarThemeData( + // Customize avatar defaults... + ), + ), + ], + ), + // ... +) +``` + +You can also use the convenience factories `StreamTheme.light()` or `StreamTheme.dark()` as a starting point. + + +## Component factories + +In the redesigned components we don't use builders in the constructors anymore, but have a centralized component factory. +The component factory contains product agnotic component builders, such as the button and the avatar, and also product specific component builders, such as the channel list item. +You can supply your component factory at any point in the widget tree, but you would usually wrap your full app around it. + +An example of a component factory with custom buttons and a custom channel list item: + +```dart +class MyApp extends StatelessWidget { + const MyApp({super.key}); + + @override + Widget build(BuildContext context) { + return StreamComponentFactory( + builders: StreamComponentBuilders( + button: (context, props) => switch (props.type) { + StreamButtonType.solid => ElevatedButton( + onPressed: props.onPressed, + child: props.child ?? const SizedBox.shrink(), + ), + StreamButtonType.outline => OutlinedButton(onPressed: props.onPressed, child: props.child ?? const SizedBox.shrink()), + StreamButtonType.ghost => TextButton(onPressed: props.onPressed, child: props.child ?? const SizedBox.shrink()), + }, + extensions: streamChatComponentBuilders( + channelListItem: (context, props) => StreamChannelListTile( + title: Text(props.channel.name ?? ''), + avatar: StreamChannelAvatar(channel: props.channel), + onTap: props.onTap, + onLongPress: props.onLongPress, + selected: props.selected, + ), + ), + ), + child: ... + ); + } +} +``` + +You should make the builder themselves as simple as possible by extracting this into separate widgets, such as this: + +```dart +class MyApp extends StatelessWidget { + const MyApp({super.key}); + + @override + Widget build(BuildContext context) { + return StreamComponentFactory( + builders: StreamComponentBuilders( + button: (context, props) => MyCustomButton(props: props), + ), + child: ... + ); + } +} + +class MyCustomButton extends StatelessWidget { + const MyCustomButton({super.key, required this.props}); + + final StreamButtonProps props; + + @override + Widget build(BuildContext context) { + return switch (props.type) { + StreamButtonType.solid => ElevatedButton( + onPressed: props.onPressed, + child: props.child ?? const SizedBox.shrink(), + ), + StreamButtonType.outline => OutlinedButton(onPressed: props.onPressed, child: props.child ?? const SizedBox.shrink()), + StreamButtonType.ghost => TextButton(onPressed: props.onPressed, child: props.child ?? const SizedBox.shrink()), + }; + } +} +``` + +## Components + +| Component | Migration Guide | +|-----------|-----------------| +| Stream Avatar | [stream_avatar.md](stream_avatar.md) | +| Channel List Item | [channel_list_item.md](channel_list_item.md) | +| Message Actions | [message_actions.md](message_actions.md) | +| Reaction Picker / Reactions | [reaction_picker.md](reaction_picker.md) | +| Image CDN & Thumbnails | [image_cdn.md](image_cdn.md) | +| Message Widget & Message List | [message_widget.md](message_widget.md) | +| Message Composer | [message_composer.md](message_composer.md) | +| Unread Indicator | [unread_indicator.md](unread_indicator.md) | +| Unread Indicator Button | [unread_indicator_button.md](unread_indicator_button.md) | +| Reaction List & Detail Sheet | [reaction_list.md](reaction_list.md) | +| Audio Waveform Theme | [audio_theme.md](audio_theme.md) | +| Attachments & Polls | [attachments_and_polls.md](attachments_and_polls.md) | +| Media Viewer (Full-screen Media) | [media_viewer.md](media_viewer.md) | +| Headers, Icons & Configuration | [headers_and_icons.md](headers_and_icons.md) | +| Localizations | [localizations.md](localizations.md) | + +## Need Help? + +If you encounter any issues during migration or have questions, please [open an issue](https://github.com/GetStream/stream-chat-flutter/issues) on GitHub. diff --git a/migrations/redesign/attachments_and_polls.md b/migrations/redesign/attachments_and_polls.md new file mode 100644 index 0000000000..b992e136c5 --- /dev/null +++ b/migrations/redesign/attachments_and_polls.md @@ -0,0 +1,579 @@ +# Attachments & Polls Migration Guide + +This guide covers the migration for the redesigned attachment components, voice recording player, and poll interactor theming in Stream Chat Flutter SDK. + +--- + +## Table of Contents + +- [Quick Reference](#quick-reference) +- [Architecture: Props + Component Factory](#architecture-props--component-factory) +- [StreamImageAttachment](#streamimageattachment) +- [StreamGalleryAttachment](#streamgalleryattachment) +- [StreamFileAttachment](#streamfileattachment) +- [StreamVideoAttachment](#streamvideoattachment) +- [StreamGiphyAttachment](#streamgiphyattachment) +- [StreamUrlAttachment → StreamLinkPreviewAttachment](#streamurlattachment--streamlinkpreviewattachment) +- [StreamVoiceRecordingAttachmentPlaylist](#streamvoicerecordingattachmentplaylist) +- [Attachment Builders](#attachment-builders) +- [StreamPollInteractorThemeData](#streampollinteractorthemedata) +- [Poll Dialogs → Poll Sheets](#poll-dialogs--poll-sheets) +- [Poll Creator Dialog → Sheet](#poll-creator-dialog--sheet) +- [StreamVoiceRecordingAttachmentThemeData](#streamvoicerecordingattachmentthemedata) +- [Migration Checklist](#migration-checklist) + +--- + +## Quick Reference + +| Symbol | Change | +|--------|--------| +| All attachment widgets | Adopt **Props + Component Factory** pattern (see below) | +| `StreamUrlAttachment` | **Renamed** to `StreamLinkPreviewAttachment` | +| `UrlAttachmentBuilder` | **Renamed** to `LinkPreviewAttachmentBuilder` | +| `shape` parameter | **Removed** from all attachment widgets | +| `constraints` parameter | Changed from required to optional on most attachments | +| `StreamImageAttachment` thumbnail params | `imageThumbnailSize`, `imageThumbnailResizeType`, `imageThumbnailCropType` replaced by `ImageResize? resize` | +| `StreamFileAttachment.backgroundColor` | **Removed** | +| `StreamPollInteractorThemeData` | Fully redesigned — old properties removed, new structured theme | +| `StreamPollOptionsDialog` / `StreamPollResultsDialog` / `StreamPollOptionVotesDialog` / `StreamPollCommentsDialog` | **Renamed** to `...Sheet` and now presented as modal bottom sheets | +| `StreamPollOptionsDialogThemeData` / `StreamPollResultsDialogThemeData` / `StreamPollOptionVotesDialogThemeData` / `StreamPollCommentsDialogThemeData` | **Renamed** to `...SheetThemeData` and fully redesigned | +| `StreamPollCreatorDialog` / `StreamPollCreatorFullScreenDialog` | **Replaced** by `StreamPollCreatorSheet` — see [Poll Creator Dialog → Sheet](#poll-creator-dialog--sheet) | +| `StreamVoiceRecordingAttachmentThemeData` | Fully redesigned — old properties removed, new design-token-based theme | + +--- + +## Architecture: Props + Component Factory + +All attachment widgets now follow a consistent **Props + Component Factory** pattern: + +1. **Public widget** (e.g. `StreamImageAttachment`) — thin wrapper that reads from `StreamComponentFactory` or falls back to a default implementation. +2. **Props class** (e.g. `StreamImageAttachmentProps`) — holds all configuration. Properties that were previously direct constructor parameters now live here. +3. **Default implementation** (e.g. `DefaultStreamImageAttachment`) — the built-in rendering. + +This means you can replace any attachment's rendering globally via `StreamComponentFactory`: + +```dart +StreamComponentFactory( + builders: StreamComponentBuilders( + extensions: streamChatComponentBuilders( + imageAttachment: (context, props) => MyCustomImageAttachment(props: props), + fileAttachment: (context, props) => MyCustomFileAttachment(props: props), + ), + ), + child: ... +) +``` + +For widget users, the public constructor API is largely unchanged — you still pass `message`, `image`, `constraints`, etc. directly. They are forwarded into the `props` object internally. + +--- + +## StreamImageAttachment + +### Breaking Changes: + +- `shape` parameter removed +- `constraints` changed from `BoxConstraints` (required) to `BoxConstraints?` (optional, auto-sized) +- `imageThumbnailSize`, `imageThumbnailResizeType`, and `imageThumbnailCropType` replaced by a single `ImageResize? resize` parameter + +### Migration: + +**Before:** +```dart +StreamImageAttachment( + message: message, + image: attachment, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)), + constraints: BoxConstraints.tight(const Size(300, 300)), + imageThumbnailSize: const Size(300, 300), + imageThumbnailResizeType: 'crop', + imageThumbnailCropType: 'center', +) +``` + +**After:** +```dart +StreamImageAttachment( + message: message, + image: attachment, + constraints: BoxConstraints.tight(const Size(300, 300)), + resize: ImageResize( + width: 300, + height: 300, + mode: ResizeMode.crop, + crop: CropMode.center, + ), +) +``` + +> **Note:** Shape customization is now handled via theming or the component factory pattern. When `resize` is null, the size is auto-calculated from layout constraints. + +--- + +## StreamGalleryAttachment + +### Breaking Changes: + +- `shape` parameter removed +- `constraints` is now optional + +### Migration: + +**Before:** +```dart +StreamGalleryAttachment( + message: message, + attachments: attachments, + shape: const RoundedRectangleBorder(...), + constraints: BoxConstraints(...), + itemBuilder: itemBuilder, +) +``` + +**After:** +```dart +StreamGalleryAttachment( + message: message, + attachments: attachments, + itemBuilder: itemBuilder, +) +``` + +--- + +## StreamFileAttachment + +### Breaking Changes: + +- `shape` parameter removed +- `backgroundColor` parameter removed +- `constraints` is now optional + +### Migration: + +**Before:** +```dart +StreamFileAttachment( + message: message, + file: attachment, + shape: const RoundedRectangleBorder(...), + backgroundColor: Colors.grey, + constraints: BoxConstraints(...), +) +``` + +**After:** +```dart +StreamFileAttachment( + message: message, + file: attachment, +) +``` + +--- + +## StreamVideoAttachment + +### Breaking Changes: + +- `shape` parameter removed +- `constraints` is now optional + +### Migration: + +**Before:** +```dart +StreamVideoAttachment( + message: message, + video: attachment, + shape: const RoundedRectangleBorder(...), + constraints: BoxConstraints.tight(const Size(300, 300)), +) +``` + +**After:** +```dart +StreamVideoAttachment( + message: message, + video: attachment, + constraints: BoxConstraints.tight(const Size(300, 300)), +) +``` + +--- + +## StreamGiphyAttachment + +### Breaking Changes: + +- `shape` parameter removed +- `constraints` is now optional + +--- + +## StreamUrlAttachment → StreamLinkPreviewAttachment + +### Breaking Changes: + +- **Renamed** from `StreamUrlAttachment` to `StreamLinkPreviewAttachment` +- `messageTheme` parameter removed +- `hostDisplayName` parameter removed +- `shape` parameter removed +- `constraints` is now optional + +### Migration: + +**Before:** +```dart +StreamUrlAttachment( + message: message, + urlAttachment: attachment, + messageTheme: theme.ownMessageTheme, + hostDisplayName: 'GitHub', +) +``` + +**After:** +```dart +StreamLinkPreviewAttachment( + message: message, + urlAttachment: attachment, +) +``` + +> **Note:** The corresponding builder was also renamed from `UrlAttachmentBuilder` to `LinkPreviewAttachmentBuilder`. + +--- + +## StreamVoiceRecordingAttachmentPlaylist + +### Breaking Changes: + +- `shape` parameter removed +- `constraints` is now optional +- New `itemDecorator` parameter for wrapping individual voice recording items +- New `voiceRecordingTitle` parameter + +### Migration: + +**Before:** +```dart +StreamVoiceRecordingAttachmentPlaylist( + message: message, + voiceRecordings: attachments, + shape: const RoundedRectangleBorder(...), + constraints: BoxConstraints(...), +) +``` + +**After:** +```dart +StreamVoiceRecordingAttachmentPlaylist( + message: message, + voiceRecordings: attachments, +) +``` + +--- + +## Attachment Builders + +| Old Builder | New Builder | +|------------|------------| +| `UrlAttachmentBuilder` | `LinkPreviewAttachmentBuilder` | +| All others | Same name, updated constructor signatures | + +All builders have had their `shape` and `padding` parameters removed. If you subclass any attachment builder, update to use the new Props-based attachment constructors. + +--- + +## StreamPollInteractorThemeData + +### Breaking Changes: + +The theme has been fully redesigned. All previous properties have been removed and replaced with a structured theme using design system tokens. + +**Removed properties:** +- `pollTitleStyle` → replaced by `titleTextStyle` +- `pollSubtitleStyle` → replaced by `subtitleTextStyle` +- `pollOptionTextStyle`, `pollOptionVoteCountTextStyle` → moved to `optionStyle` (`StreamPollOptionStyle`) +- `pollOptionCheckboxShape`, `pollOptionCheckboxCheckColor`, `pollOptionCheckboxActiveColor`, `pollOptionCheckboxBorderSide` → moved to `optionStyle.checkboxStyle` (`StreamCheckboxStyle`) +- `pollOptionVotesProgressBarMinHeight`, `pollOptionVotesProgressBarTrackColor`, `pollOptionVotesProgressBarValueColor`, `pollOptionVotesProgressBarWinnerColor`, `pollOptionVotesProgressBarBorderRadius` → moved to `optionStyle.progressBarStyle` (`StreamProgressBarStyle`) +- `pollActionButtonStyle` → replaced by `primaryActionStyle` and `secondaryActionStyle` (`StreamButtonThemeStyle`) +- `pollActionDialogTitleStyle`, `pollActionDialogTextFieldStyle`, `pollActionDialogTextFieldFillColor`, `pollActionDialogTextFieldBorderRadius` → removed + +### Migration: + +**Before:** +```dart +StreamPollInteractorThemeData( + pollTitleStyle: TextStyle(fontWeight: FontWeight.bold), + pollActionButtonStyle: ButtonStyle(...), + pollOptionVotesProgressBarValueColor: Colors.blue, +) +``` + +**After:** +```dart +StreamPollInteractorThemeData( + titleTextStyle: TextStyle(fontWeight: FontWeight.bold), + primaryActionStyle: StreamButtonThemeStyle.from( + borderColor: Colors.blue, + ), + optionStyle: StreamPollOptionStyle( + progressBarStyle: StreamProgressBarStyle( + fillColor: Colors.blue, + ), + ), +) +``` + +--- + +## Poll Dialogs → Poll Sheets + +### Breaking Changes: + +The four poll dialogs — `StreamPollOptionsDialog`, `StreamPollResultsDialog`, `StreamPollOptionVotesDialog`, and `StreamPollCommentsDialog` — have been renamed to `...Sheet` and now present as modal bottom sheets via the new `showStreamSheet` helper from `stream_core_flutter` (a Stream-styled sheet route with scroll-aware drag-to-dismiss) instead of full-screen page routes. The previous `Scaffold` + `StreamAppBar` chrome has been replaced by a `StreamSheetHeader` at the top of each sheet. + +Each sheet is now full-size with a small fixed peek from the screen top, and the previous snap-to-half affordance from `DraggableScrollableSheet` is gone — dragging down on the body's scroll view past its top now dismisses the sheet directly. + +**Renamed symbols:** + +| Old | New | +|-----|-----| +| `StreamPollOptionsDialog` | `StreamPollOptionsSheet` | +| `StreamPollResultsDialog` | `StreamPollResultsSheet` | +| `StreamPollOptionVotesDialog` | `StreamPollOptionVotesSheet` | +| `StreamPollCommentsDialog` | `StreamPollCommentsSheet` | +| `showStreamPollOptionsDialog` | `showStreamPollOptionsSheet` | +| `showStreamPollResultsDialog` | `showStreamPollResultsSheet` | +| `showStreamPollOptionVotesDialog` | `showStreamPollOptionVotesSheet` | +| `showStreamPollCommentsDialog` | `showStreamPollCommentsSheet` | +| `StreamPollOptionsDialogTheme` / `StreamPollOptionsDialogThemeData` | `StreamPollOptionsSheetTheme` / `StreamPollOptionsSheetThemeData` | +| `StreamPollResultsDialogTheme` / `StreamPollResultsDialogThemeData` | `StreamPollResultsSheetTheme` / `StreamPollResultsSheetThemeData` | +| `StreamPollOptionVotesDialogTheme` / `StreamPollOptionVotesDialogThemeData` | `StreamPollOptionVotesSheetTheme` / `StreamPollOptionVotesSheetThemeData` | +| `StreamPollCommentsDialogTheme` / `StreamPollCommentsDialogThemeData` | `StreamPollCommentsSheetTheme` / `StreamPollCommentsSheetThemeData` | +| `StreamChatThemeData.pollOptionsDialogTheme` | `StreamChatThemeData.pollOptionsSheetTheme` | +| `StreamChatThemeData.pollResultsDialogTheme` | `StreamChatThemeData.pollResultsSheetTheme` | +| `StreamChatThemeData.pollOptionVotesDialogTheme` | `StreamChatThemeData.pollOptionVotesSheetTheme` | +| `StreamChatThemeData.pollCommentsDialogTheme` | `StreamChatThemeData.pollCommentsSheetTheme` | + +Each `...Sheet` widget also exposes a new optional `scrollController` parameter which `show*Sheet` wires to the enclosing `DraggableScrollableSheet`. + +### Theme Data: Removed / Replaced Properties + +All four theme data classes dropped their app-bar styling slots (`appBarElevation`, `appBarBackgroundColor`, `appBarForegroundColor`, `appBarTitleTextStyle`). The old app-bar chrome is replaced by a `StreamSheetHeader` at the top of each sheet — use the new per-sheet `sheetHeaderStyle` field to scope a `StreamSheetHeaderStyle` override. + +**`StreamPollOptionsSheetThemeData`** + +| Removed | Replacement | +|---------|-------------| +| `pollTitleTextStyle`, `pollTitleDecoration` | `questionStyle` (`StreamPollQuestionStyle`) | +| `pollOptionsListViewDecoration` | `optionsCardStyle` (`StreamPollCardStyle`) | +| — | New: `contentPadding`, `sectionSpacing`, `optionsItemSpacing`, `optionStyle` (`StreamPollOptionStyle`), `sheetHeaderStyle` | + +**`StreamPollResultsSheetThemeData`** + +| Removed | Replacement | +|---------|-------------| +| `pollTitleTextStyle`, `pollTitleDecoration` | `questionStyle` (`StreamPollQuestionStyle`) | +| `pollOptionsDecoration`, `pollOptionsWinnerDecoration`, `pollOptionsTextStyle`, `pollOptionsWinnerTextStyle`, `pollOptionsVoteCountTextStyle`, `pollOptionsWinnerVoteCountTextStyle`, `pollOptionsShowAllVotesButtonStyle` | `optionStyle` (`StreamPollOptionVotesStyle`) — bundles card chrome, text styles, winner trophy color/size, `footerDividerColor`, and `footerButtonStyle` for the "View all" action | +| — | New: `contentPadding`, `sectionSpacing`, `optionsItemSpacing`, `totalVoteCountTextStyle` (for the new total-vote-count footer), `sheetHeaderStyle` | + +**`StreamPollOptionVotesSheetThemeData`** + +| Removed | Replacement | +|---------|-------------| +| `pollOptionVoteCountTextStyle`, `pollOptionWinnerVoteCountTextStyle`, `pollOptionVoteItemBackgroundColor`, `pollOptionVoteItemBorderRadius` | `optionStyle` (reuses `StreamPollOptionVotesStyle` from the results sheet) | +| — | New: `contentPadding`, `sheetHeaderStyle` | + +Per-vote tile styling will ship later under a dedicated `StreamPollVoteListTile` theme. + +**`StreamPollCommentsSheetThemeData`** + +| Removed | Replacement | +|---------|-------------| +| `pollCommentItemBackgroundColor`, `pollCommentItemBorderRadius`, `updateYourCommentButtonStyle` (`ButtonStyle`) | `commentStyle` (reuses `StreamPollOptionVotesStyle`) — only its `cardStyle`, `footerDividerColor` and `footerButtonStyle` are consumed | +| — | New: `contentPadding`, `itemSpacing`, `sheetHeaderStyle` | + +### Migration: + +**Before:** +```dart +showStreamPollOptionsDialog( + context: context, + messageNotifier: messageNotifier, +); + +StreamChatThemeData( + pollOptionsDialogTheme: StreamPollOptionsDialogThemeData( + appBarBackgroundColor: Colors.white, + pollTitleTextStyle: TextStyle(fontWeight: FontWeight.w700), + pollOptionsListViewDecoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(12), + ), + ), +); +``` + +**After:** +```dart +showStreamPollOptionsSheet( + context: context, + messageNotifier: messageNotifier, +); + +StreamChatThemeData( + pollOptionsSheetTheme: StreamPollOptionsSheetThemeData( + sheetHeaderStyle: StreamSheetHeaderStyle(backgroundColor: Colors.white), + questionStyle: StreamPollQuestionStyle( + headerTextStyle: TextStyle(fontWeight: FontWeight.w700), + ), + optionsCardStyle: StreamPollCardStyle( + backgroundColor: Colors.white, + borderRadius: BorderRadius.circular(12), + ), + ), +); +``` + +--- + +## Poll Creator Dialog → Sheet + +### Breaking Changes: + +The poll creator UI has been unified into a single bottom-sheet surface. The previous responsive split between a desktop `AlertDialog` (`StreamPollCreatorDialog`) and a mobile full-screen page (`StreamPollCreatorFullScreenDialog`) has been replaced by a single `StreamPollCreatorSheet` that renders as a modal bottom sheet via the new `showStreamSheet` helper from `stream_core_flutter`, matching the other poll sheets. + +**Renamed / removed symbols:** + +| Old | New | +|-----|-----| +| `showStreamPollCreatorDialog` | `showStreamPollCreatorSheet` | +| `StreamPollCreatorDialog` | `StreamPollCreatorSheet` | +| `StreamPollCreatorFullScreenDialog` | `StreamPollCreatorSheet` | + +`showStreamPollCreatorSheet` keeps the `poll`, `config`, and `padding` parameters from the old dialog helper; the dialog-specific parameters (`barrierDismissible`, `barrierColor`, `barrierLabel`, `useSafeArea`, `useRootNavigator`, `routeSettings`, `anchorPoint`, `traversalEdgeBehavior`) are no longer accepted — the sheet always presents as a modal bottom sheet over a safe area. + +`StreamPollCreatorWidget` also gained an optional `scrollController` parameter so it can be embedded inside a `DraggableScrollableSheet`. + +### Migration: + +**Before:** +```dart +final poll = await showStreamPollCreatorDialog( + context: context, + poll: initialPoll, + config: pollConfig, +); +``` + +**After:** +```dart +final poll = await showStreamPollCreatorSheet( + context: context, + poll: initialPoll, + config: pollConfig, +); +``` + +If you were directly instantiating either of the legacy widgets, replace them with `StreamPollCreatorSheet`: + +**Before:** +```dart +StreamPollCreatorDialog(poll: poll, config: config); +StreamPollCreatorFullScreenDialog(poll: poll, config: config); +``` + +**After:** +```dart +StreamPollCreatorSheet(poll: poll, config: config); +``` + +#### Theme changes + +`StreamPollCreatorThemeData` now exposes a `sheetHeaderStyle` (`StreamSheetHeaderStyle`) that styles the sheet's `StreamSheetHeader` — matching the other poll sheet theme datas. The previous `primaryActionStyle` and `secondaryActionStyle` fields have been removed; style the sheet's leading/trailing action buttons through `sheetHeaderStyle.leadingStyle` / `sheetHeaderStyle.trailingStyle` instead. + +**Before:** +```dart +StreamPollCreatorThemeData( + primaryActionStyle: StreamButtonThemeStyle.from(...), + secondaryActionStyle: StreamButtonThemeStyle.from(...), +) +``` + +**After:** +```dart +StreamPollCreatorThemeData( + sheetHeaderStyle: StreamSheetHeaderStyle( + trailingStyle: StreamButtonThemeStyle.from(...), + leadingStyle: StreamButtonThemeStyle.from(...), + ), +) +``` + +--- + +## StreamVoiceRecordingAttachmentThemeData + +### Breaking Changes: + +The theme has been fully redesigned using `theme_extensions_builder` code generation. + +**Removed properties:** +- `backgroundColor` → removed (handled by attachment container styling) +- `playIcon`, `pauseIcon`, `loadingIndicator` → removed (handled by `controlButtonStyle`) +- `audioControlButtonStyle` → replaced by `controlButtonStyle` (`StreamButtonThemeStyle`) +- `speedControlButtonStyle` → replaced by `speedToggleStyle` (`StreamPlaybackSpeedToggleStyle`) +- `audioWaveformSliderTheme` → replaced by `waveformStyle` (`StreamAudioWaveformThemeData`) + +**Retained (renamed):** +- `titleTextStyle` → `titleTextStyle` (unchanged) +- `durationTextStyle` → `durationTextStyle` (unchanged) + +**New properties:** +- `activeDurationTextStyle` — text style for duration while playing + +### Migration: + +**Before:** +```dart +StreamVoiceRecordingAttachmentThemeData( + backgroundColor: Colors.grey, + audioControlButtonStyle: ButtonStyle(...), + speedControlButtonStyle: ButtonStyle(...), + durationTextStyle: TextStyle(...), +) +``` + +**After:** +```dart +StreamVoiceRecordingAttachmentThemeData( + controlButtonStyle: StreamButtonThemeStyle.from(...), + speedToggleStyle: StreamPlaybackSpeedToggleStyle(...), + durationTextStyle: TextStyle(...), + activeDurationTextStyle: TextStyle(...), +) +``` + +--- + +## Migration Checklist + +- [ ] Remove `shape` parameter from all attachment widget usages +- [ ] Replace `StreamUrlAttachment` with `StreamLinkPreviewAttachment` +- [ ] Replace `UrlAttachmentBuilder` with `LinkPreviewAttachmentBuilder` +- [ ] Remove `messageTheme` and `hostDisplayName` from link preview usage +- [ ] Replace `imageThumbnailSize` / `imageThumbnailResizeType` / `imageThumbnailCropType` with `ImageResize? resize` on `StreamImageAttachment` +- [ ] Remove `backgroundColor` from `StreamFileAttachment` usage +- [ ] Remove `shape` and `padding` from attachment builder usages +- [ ] Update `StreamPollInteractorThemeData` — see property mapping above +- [ ] Rename `StreamPoll*Dialog` widgets, `show*Dialog` helpers, and their theme types to the `...Sheet` variants +- [ ] Replace `StreamPollCreatorDialog` / `StreamPollCreatorFullScreenDialog` (and `showStreamPollCreatorDialog`) with `StreamPollCreatorSheet` / `showStreamPollCreatorSheet` +- [ ] Update `StreamPoll*SheetThemeData` entries on `StreamChatThemeData` — see property mapping above +- [ ] Update `StreamVoiceRecordingAttachmentThemeData` — see property mapping above +- [ ] If using custom attachment builders, update to new Props-based constructors +- [ ] If using component factory, register custom builders via `streamChatComponentBuilders` diff --git a/migrations/redesign/audio_theme.md b/migrations/redesign/audio_theme.md new file mode 100644 index 0000000000..c03430f7ac --- /dev/null +++ b/migrations/redesign/audio_theme.md @@ -0,0 +1,81 @@ +# Audio Waveform Theme Migration Guide + +This guide covers the migration for the audio waveform theming changes in the Stream Chat Flutter SDK design refresh. + +--- + +## Table of Contents + +- [Overview](#overview) +- [What Changed](#what-changed) +- [New Theming Approach](#new-theming-approach) +- [Migration Checklist](#migration-checklist) + +--- + +## Overview + +The audio waveform theme types and the `StreamAudioWaveform` / `StreamAudioWaveformSlider` widgets have moved from `stream_chat_flutter` to `stream_core_flutter`. The widgets are re-exported via `stream_chat_flutter` so import paths remain unchanged, but theming is no longer done through `StreamChatThemeData`. + +--- + +## What Changed + +| Item | Before | After | +|------|--------|-------| +| `StreamAudioWaveformTheme` | Defined in `stream_chat_flutter` | Moved to `stream_core_flutter`; no longer in `StreamChatThemeData` | +| `StreamAudioWaveformSliderTheme` | Defined in `stream_chat_flutter` | Moved to `stream_core_flutter`; no longer in `StreamChatThemeData` | +| `StreamAudioWaveform` widget | In `stream_chat_flutter` | Re-exported from `stream_core_flutter` via `stream_chat_flutter` | +| `StreamAudioWaveformSlider` widget | In `stream_chat_flutter` | Re-exported from `stream_core_flutter` via `stream_chat_flutter` | +| Theming entry point | `StreamChatThemeData.audioWaveformTheme` / `.audioWaveformSliderTheme` | `StreamTheme` (via `MaterialApp.theme.extensions`) | + +--- + +## New Theming Approach + +Audio waveform theming is now part of `StreamTheme` from `stream_core_flutter`. Configure it by adding `StreamTheme` as a theme extension to your `MaterialApp`: + +**Before:** +```dart +StreamChat( + client: client, + streamChatThemeData: StreamChatThemeData( + audioWaveformTheme: StreamAudioWaveformThemeData( + waveColor: Colors.blue, + playedWaveColor: Colors.blueAccent, + ), + audioWaveformSliderTheme: StreamAudioWaveformSliderThemeData( + thumbColor: Colors.blue, + ), + ), + child: ..., +) +``` + +**After:** +```dart +MaterialApp( + theme: ThemeData( + extensions: [ + StreamTheme( + brightness: Brightness.light, + // Audio waveform theming is now part of StreamTheme's component themes. + // Refer to StreamThemeData for available audio waveform properties. + ), + ], + ), + home: StreamChat(client: client, child: ...), +) +``` + +> **Note:** If no `StreamTheme` extension is provided, a default theme is automatically derived from `Theme.of(context).brightness`. + +--- + +## Migration Checklist + +- [ ] Remove any `StreamChatThemeData.audioWaveformTheme` usages +- [ ] Remove any `StreamChatThemeData.audioWaveformSliderTheme` usages +- [ ] Remove `audioWaveformTheme` and `audioWaveformSliderTheme` from any `StreamChatThemeData.copyWith()` calls — these parameters no longer exist and will cause a compile error +- [ ] Move audio waveform color / style customizations into a `StreamTheme` extension on `MaterialApp` +- [ ] Import paths for `StreamAudioWaveform` and `StreamAudioWaveformSlider` remain the same (`package:stream_chat_flutter/stream_chat_flutter.dart`) diff --git a/migrations/redesign/channel_list_item.md b/migrations/redesign/channel_list_item.md new file mode 100644 index 0000000000..919beccb8b --- /dev/null +++ b/migrations/redesign/channel_list_item.md @@ -0,0 +1,270 @@ +# Channel List Item Migration Guide + +This guide covers the migration for the redesigned channel list item components in Stream Chat Flutter SDK. + +--- + +## Table of Contents + +- [Quick Reference](#quick-reference) +- [StreamChannelListTile → StreamChannelListItem](#streamchannellisttile--streamchannellistitem) +- [Customizing Slots](#customizing-slots) +- [Low-level Presentational Component](#low-level-presentational-component) +- [Theme Migration](#theme-migration) +- [Migration Checklist](#migration-checklist) + +--- + +## Quick Reference + +| Old | New | +|-----|-----| +| `StreamChannelListTile` | `StreamChannelListItem` | +| Constructor props: `leading`, `title`, `subtitle`, `trailing` | `StreamChannelListItemProps` (via `StreamComponentFactory`) | +| `tileColor`, `visualDensity`, `contentPadding` | Removed — use `StreamChannelListItemThemeData` | +| `selectedTileColor` | Removed — use `StreamChannelListItemThemeData.backgroundColor` | +| `unreadIndicatorBuilder` | Removed | +| `StreamChannelPreviewThemeData` | `StreamChannelListItemThemeData` | +| `StreamChannelPreviewTheme.of(context)` | `StreamChannelListItemTheme.of(context)` | +| `StreamChatThemeData.channelPreviewTheme` | `StreamChatThemeData.channelListItemTheme` | + +--- + +## StreamChannelListTile → StreamChannelListItem + +The old `StreamChannelListTile` accepted all slot widgets directly in its constructor. The new `StreamChannelListItem` takes only the essential interaction properties. Slot customization is now done via `StreamChannelListItemProps` and the `StreamComponentFactory`. + +### Breaking Changes + +- `leading`, `title`, `subtitle`, `trailing` removed from constructor +- `tileColor` removed — use `StreamChannelListItemThemeData.backgroundColor` +- `visualDensity` removed +- `contentPadding` removed +- `selectedTileColor` removed +- `unreadIndicatorBuilder` removed +- `sendingIndicatorBuilder` removed from constructor — pass via `StreamChannelListItemProps` + +### Migration + +**Before:** +```dart +StreamChannelListTile( + channel: channel, + onTap: () => openChannel(channel), + onLongPress: () => showOptions(channel), + tileColor: Colors.white, + selectedTileColor: Colors.blue.shade50, + selected: isSelected, + contentPadding: const EdgeInsets.symmetric(horizontal: 16), + leading: StreamChannelAvatar(channel: channel), + title: StreamChannelName(channel: channel), + subtitle: ChannelListTileSubtitle(channel: channel), + trailing: ChannelLastMessageDate(channel: channel), +) +``` + +**After:** +```dart +StreamChannelListItem( + channel: channel, + onTap: () => openChannel(channel), + onLongPress: () => showOptions(channel), + selected: isSelected, +) +``` + +--- + +## Customizing Slots + +To customize the slot widgets (avatar, title, subtitle, timestamp), provide a custom builder via `StreamComponentFactory`: + +**Before:** +```dart +StreamChannelListTile( + channel: channel, + leading: MyCustomAvatar(channel: channel), + subtitle: MyCustomSubtitle(channel: channel), +) +``` + +**After:** +```dart +StreamComponentFactory( + builders: StreamComponentBuilders( + extensions: streamChatComponentBuilders( + channelListItem: (context, props) => StreamChannelListTile( + avatar: StreamChannelAvatar(channel: props.channel), + title: Text(props.channel.name ?? ''), + subtitle: Text(props.channel.lastMessageAt?.toString() ?? ''), + ), + ), + ), + child: ..., +) +``` + +--- + +## Low-level Presentational Component + +The new `StreamChannelListTile` is a low-level component that renders pre-resolved data without any channel-specific logic. Use this when you want to display a channel-shaped list item with fully controlled content (e.g., in a skeleton loader or a custom list): + +```dart +StreamChannelListTile( + avatar: StreamChannelAvatar(channel: channel), + title: Text('General'), + subtitle: Text('Last message preview'), + timestamp: Text('9:41 AM'), + unreadCount: 3, + isMuted: false, + onTap: () {}, +) +``` + +> **Note:** This widget does not subscribe to any streams — all values must be provided explicitly. + +--- + +## Theme Migration + +`StreamChannelPreviewThemeData` has been replaced by `StreamChannelListItemThemeData`. Additionally, the `StreamChannelPreviewTheme` inherited widget itself is deprecated — replace it with `StreamChannelListItemTheme`. + +### Property Mapping + +| Old (`StreamChannelPreviewThemeData`) | New (`StreamChannelListItemThemeData`) | +|---------------------------------------|----------------------------------------| +| `titleStyle` | `titleStyle` | +| `subtitleStyle` | `subtitleStyle` | +| `lastMessageAtStyle` | `timestampStyle` | +| `avatarTheme` | Removed — use `StreamAvatarThemeData` directly | +| `unreadCounterColor` | Removed — use `StreamBadgeNotificationThemeData` | +| `indicatorIconSize` | Removed | +| `lastMessageAtFormatter` | Removed from theme — pass to `ChannelLastMessageDate(formatter: ...)` | + +### New Properties + +| Property | Type | Description | +|----------|------|-------------| +| `backgroundColor` | `WidgetStateProperty?` | Background color resolved per state (default, hover, pressed, selected) | +| `borderColor` | `Color?` | Bottom border color | +| `muteIconPosition` | `MuteIconPosition?` | Whether the mute icon appears in `title` or `subtitle` row | + +### Global Theme Migration + +**Before:** +```dart +StreamChatTheme( + data: StreamChatThemeData( + channelPreviewTheme: StreamChannelPreviewThemeData( + titleStyle: TextStyle(fontWeight: FontWeight.bold), + subtitleStyle: TextStyle(color: Colors.grey), + lastMessageAtStyle: TextStyle(fontSize: 12), + unreadCounterColor: Colors.red, + ), + ), + child: ..., +) +``` + +**After:** +```dart +StreamChatTheme( + data: StreamChatThemeData( + channelListItemTheme: StreamChannelListItemThemeData( + titleStyle: TextStyle(fontWeight: FontWeight.bold), + subtitleStyle: TextStyle(color: Colors.grey), + timestampStyle: TextStyle(fontSize: 12), + // unreadCounterColor → customize via StreamBadgeNotificationThemeData + ), + ), + child: ..., +) +``` + +### Subtree Theme Override + +**Before:** +```dart +StreamChannelPreviewTheme( + data: StreamChannelPreviewThemeData( + titleStyle: TextStyle(color: Colors.blue), + ), + child: StreamChannelListView(...), +) +``` + +**After:** +```dart +StreamChannelListItemTheme( + data: StreamChannelListItemThemeData( + titleStyle: TextStyle(color: Colors.blue), + ), + child: StreamChannelListView(...), +) +``` + +### Background and Selected Color + +**Before:** +```dart +StreamChannelListTile( + channel: channel, + tileColor: Colors.white, + selectedTileColor: Colors.blue.shade50, + selected: isSelected, +) +``` + +**After:** +```dart +StreamChannelListItemTheme( + data: StreamChannelListItemThemeData( + backgroundColor: WidgetStateProperty.resolveWith((states) { + if (states.contains(WidgetState.selected)) return Colors.blue.shade50; + return Colors.white; + }), + ), + child: StreamChannelListItem( + channel: channel, + selected: isSelected, + ), +) +``` + +### Custom Timestamp Formatter + +**Before:** +```dart +StreamChannelPreviewThemeData( + lastMessageAtFormatter: (context, date) { + return Jiffy.parseFromDateTime(date).format('d MMMM'); + }, +) +``` + +**After:** +```dart +// Pass directly to the widget — no longer a theme property +ChannelLastMessageDate( + channel: channel, + formatter: (context, date) { + return Jiffy.parseFromDateTime(date).format('d MMMM'); + }, +) +``` + +--- + +## Migration Checklist + +- [ ] Replace `StreamChannelListTile` with `StreamChannelListItem` +- [ ] Remove `tileColor`, `visualDensity`, `contentPadding`, `selectedTileColor`, `unreadIndicatorBuilder` parameters +- [ ] Move slot customization (`leading`, `title`, `subtitle`, `trailing`) to `StreamComponentFactory` +- [ ] Replace `StreamChannelPreviewTheme` inherited widget with `StreamChannelListItemTheme` — `StreamChannelPreviewTheme` is `@Deprecated` and will be removed in a future release +- [ ] Replace `StreamChatThemeData.channelPreviewTheme` with `StreamChatThemeData.channelListItemTheme` +- [ ] Rename `lastMessageAtStyle` to `timestampStyle` +- [ ] Move `lastMessageAtFormatter` from theme to `ChannelLastMessageDate(formatter: ...)` +- [ ] Replace `tileColor`/`selectedTileColor` with `StreamChannelListItemThemeData.backgroundColor` using `WidgetStateProperty` +- [ ] Replace `unreadCounterColor` with `StreamBadgeNotificationThemeData` +- [ ] Replace `avatarTheme` in `StreamChannelPreviewThemeData` with `StreamAvatarThemeData` on the avatar widget directly diff --git a/migrations/redesign/headers_and_icons.md b/migrations/redesign/headers_and_icons.md new file mode 100644 index 0000000000..053a9f36c9 --- /dev/null +++ b/migrations/redesign/headers_and_icons.md @@ -0,0 +1,383 @@ +# Headers, Icons & Configuration Migration Guide + +This guide covers several cross-cutting API changes in the Stream Chat Flutter SDK design refresh: the icon system migration, header widget defaults, the new `StreamChat.componentBuilders` parameter, and new `StreamChatConfigurationData` fields. + +--- + +## Table of Contents + +- [StreamSvgIcon Deprecated](#streamsvgicon-deprecated) +- [Header Widgets](#header-widgets) +- [StreamChat.componentBuilders](#streamchatcomponentbuilders) +- [StreamChatConfigurationData New Fields](#streamchatconfigurationdata-new-fields) +- [Migration Checklist](#migration-checklist) + +--- + +## StreamSvgIcon Deprecated + +`StreamSvgIcon` and `StreamSvgIcons` are now `@Deprecated`. Replace all usages with the standard Flutter `Icon` widget and the new `StreamIcons` token set accessed via `context.streamIcons`. + +### Breaking Change + +`StreamSvgIcon` is deprecated — usages will produce deprecation warnings. The class will be removed in a future release. + +### Migration + +**Before:** +```dart +StreamSvgIcon(icon: StreamSvgIcons.reply) +StreamSvgIcon(icon: StreamSvgIcons.copy, color: Colors.red, size: 24) +``` + +**After:** +```dart +Icon(context.streamIcons.reply20) +Icon(context.streamIcons.copy20, color: Colors.red, size: 24) +``` + +`context.streamIcons` is a `StreamIcons` extension on `BuildContext` — it reads from the nearest `StreamTheme` in the widget tree. + +### Icon Name Mapping + +| Old (`StreamSvgIcons.*`) | New (`context.streamIcons.*`) | +|--------------------------|-------------------------------| +| `arrowRight` | `arrowRight20` | +| `attach` | `attachment20` | +| `award` | `trophy20` | +| `camera` | `camera20` | +| `check` | `checkmark20` | +| `checkAll` | `checks20` | +| `checkSend` | `checkmark20` | +| `circleUp` | `arrowUp20` | +| `close` | `xmark20` | +| `closeSmall` | `xmark16` | +| `contacts` | `users20` | +| `copy` | `copy20` | +| `delete` | `delete20` | +| `down` | `chevronDown20` | +| `download` | `download20` | +| `edit` | `edit20` | +| `emptyCircleRight` | `chevronRight20` | +| `error` | `exclamationCircleFill20` | +| `eye` | `eyeFill20` | +| `files` | `file20` | +| `flag` | `flag20` | +| `grid` | `gallery20` | +| `group` | `users20` | +| `left` | `chevronLeft20` | +| `lightning` | `bolt20` | +| `link` | `link20` | +| `lock` | `lock20` | +| `mentions` | `mention20` | +| `menuPoint` | `more20` | +| `message` | `messageBubble20` | +| `messageUnread` | `notification20` | +| `mic` | `voice20` | +| `mute` | `mute20` | +| `notification` | `bell20` | +| `pause` | `pauseFill20` | +| `penWrite` | `edit20` | +| `pictures` | `image20` | +| `pin` | `pin20` | +| `play` | `playFill20` | +| `polls` | `poll20` | +| `record` | `video20` | +| `reload` | `refresh20` | +| `reply` | `reply20` | +| `retry` | `retry20` | +| `right` | `chevronRight20` | +| `save` | `save20` | +| `search` | `search20` | +| `send` | `send20` | +| `sendMessage` | `send20` | +| `share` | `export20` | +| `shareArrow` | `share20` | +| `smile` | `emoji20` | +| `stop` | `stopFill20` | +| `threadReply` | `thread20` | +| `time` | `clock20` | +| `up` | `chevronUp20` | +| `user` | `user20` | +| `userAdd` | `userAdd20` | +| `userDelete` | `userRemove20` | +| `userRemove` | `userRemove20` | +| `userSettings` | `userCheck20` | +| `videoCall` | `videoFill20` | +| `volumeUp` | `audio20` | + +The following icons have been **removed with no equivalent** in the new set: +`cloudDownload`, `lolReaction`, `loveReaction`, `moon`, `settings`, `thumbsDownReaction`, `thumbsUpReaction`, `wutReaction`. + +--- + +## Header Widgets + +All four chat headers — `StreamChannelHeader`, `StreamChannelListHeader`, +`StreamThreadHeader`, and `StreamGalleryHeader` — have been rebuilt on top +of the new design system's `StreamAppBar`. They now share a single slot +model (`leading` / `title` / `subtitle` / `trailing`) and a single theme +type (`StreamAppBarThemeData`), replacing the legacy +`AppBar`-style API. + +### What changed across all headers + +* **New layout primitive.** The headers render a [`StreamAppBar`] with a + fixed 72-px height (`kStreamHeaderHeight`) instead of Material's + `kToolbarHeight` (56 px). Pass the header directly to `Scaffold.appBar` + as before — it implements `PreferredSizeWidget`. +* **Slot model.** All four headers now expose `leading`, `title`, + `subtitle`, and `trailing` as plain `Widget?` slots. Anything you used + to compose with `actions: [...]` should move into `trailing:` (single + widget) or be wrapped into a `Row` you build yourself. +* **Auto-implied leading.** Headers that previously had `showBackButton` + / `onBackPressed` now use `automaticallyImplyLeading` (default `true`) + to insert a default back button. To override, pass `leading:` directly; + to suppress, pass `automaticallyImplyLeading: false`. +* **Theme.** `StreamChannelHeaderThemeData`, `StreamChannelListHeaderThemeData`, + and `StreamGalleryHeaderThemeData` are deleted. The corresponding + accessors on `StreamChatThemeData` (`channelHeaderTheme`, + `channelListHeaderTheme`, `threadHeaderTheme`, `galleryHeaderTheme`) + now return [`StreamAppBarThemeData`]. +* **Per-instance overrides.** A new `style: StreamAppBarStyle?` parameter + lets callers override colours / padding / typography for one instance — + it merges over the ambient `StreamAppBarTheme`. +* **Removed parameters.** `centerTitle`, `elevation`, `bottomOpacity`, + `bottom`, and `backgroundColor` are gone — the new bar always centres + the title, draws a hairline `borderSubtle` divider instead of an + elevation shadow, and reads its background from the theme / `style`. + +### `StreamChannelHeader` + +| Old parameter | New equivalent | +|---------------|----------------| +| `showBackButton: false` | `automaticallyImplyLeading: false` | +| `onBackPressed: cb` | `leading: StreamBackButton(onPressed: cb)` | +| `onTitleTap: cb` | `title: GestureDetector(onTap: cb, child: ...)` | +| `onImageTap: cb` | `onChannelAvatarPressed: (channel) => cb()` (or replace `trailing:`) | +| `showTypingIndicator: false` | `subtitle: Text(channel.name)` (or any custom widget) | +| `actions: [a, b]` | `trailing: Row(children: [a, b])` | +| `centerTitle`, `elevation`, `bottom`, `bottomOpacity`, `backgroundColor` | Use `style: StreamAppBarStyle(backgroundColor: ...)` for the background; the rest are gone | + +**Before:** + +```dart +StreamChannelHeader( + showBackButton: true, + onBackPressed: () => GoRouter.of(context).pop(), + onImageTap: () => openChannelInfo(channel), + showTypingIndicator: true, + elevation: 1, +) +``` + +**After:** + +```dart +StreamChannelHeader( + leading: StreamBackButton(onPressed: () => GoRouter.of(context).pop()), + onChannelAvatarPressed: (channel) => openChannelInfo(channel), +) +``` + +The default leading is now [`StreamBackButton`] with a channel-aware +unread badge; the default trailing is the channel avatar wrapped in a +48×48 tap target wired to `onChannelAvatarPressed`. + +### `StreamChannelListHeader` + +| Old parameter | New equivalent | +|---------------|----------------| +| `titleBuilder: (context, user) => ...` | `title: ...` (a `Widget`) | +| `onUserAvatarTap: cb` | `onUserAvatarPressed: cb` (renamed) | +| `onNewChatButtonTap: cb` | `trailing: StreamButton.icon(icon: Icon(context.streamIcons.plus), onPressed: cb)` | +| `preNavigationCallback`, `leading`, `actions`, `centerTitle`, `elevation`, `backgroundColor` | Removed — see notes below | + +The leading slot is no longer caller-overridable: the SDK always renders +the signed-in user's avatar. When `onUserAvatarPressed` is null the +avatar mirrors Material `AppBar`'s auto-implied leading and opens the +enclosing `Scaffold`'s drawer if one exists, so the previous +`onUserAvatarTap: (_) => Scaffold.of(context).openDrawer()` callsites +can drop the callback entirely. + +The trailing slot is empty by default — the SDK no longer ships a +"new chat" button. Pass your own widget if you want one. + +**Before:** + +```dart +StreamChannelListHeader( + titleBuilder: (context, user) => Text(user?.name ?? 'Stream Chat'), + onUserAvatarTap: (_) => Scaffold.of(context).openDrawer(), + onNewChatButtonTap: () => GoRouter.of(context).pushNamed('new-chat'), + elevation: 1, +) +``` + +**After:** + +```dart +StreamChannelListHeader( + title: Text('Chats', style: context.streamTextTheme.headingSm), + trailing: StreamButton.icon( + icon: Icon(context.streamIcons.plus), + onPressed: () => GoRouter.of(context).pushNamed('new-chat'), + ), +) +``` + +### `StreamThreadHeader` + +| Old parameter | New equivalent | +|---------------|----------------| +| `showBackButton: false` | `automaticallyImplyLeading: false` | +| `onBackPressed: cb` | `leading: StreamBackButton(onPressed: cb)` | +| `onTitleTap: cb` | `title: GestureDetector(onTap: cb, child: ...)` | +| `showTypingIndicator: false` | `subtitle: Text(...)` (or any custom widget) | +| `actions: [a, b]` | `trailing: Row(children: [a, b])` | +| `centerTitle`, `elevation`, `backgroundColor` | Use `style:`; the rest are gone | + +The default subtitle is a [`StreamTypingIndicator`] that falls back to +the thread's reply count when nobody is typing. + +### `StreamGalleryHeader` + +| Old parameter | New equivalent | +|---------------|----------------| +| `showBackButton: false` | `automaticallyImplyLeading: false` | +| `onBackPressed: cb` | `leading: StreamBackButton(onPressed: cb)` | +| `onTitleTap: cb` | `title: GestureDetector(onTap: cb, child: ...)` | +| `onImageTap: cb` | `trailing: GestureDetector(onTap: cb, child: ...)` | +| `elevation`, `backgroundColor` | Use `style:`; the rest are gone | + +`onShowMessage`, `onReplyMessage`, and `attachmentActionsModalBuilder` +are unchanged. The default trailing is still an icon button that opens +the attachment actions modal. + +### Theming + +The theme accessors on `StreamChatThemeData` keep their names but their +type changes to [`StreamAppBarThemeData`]: + +```dart +// Before +StreamChannelHeaderThemeData( + color: theme.colorTheme.barsBg, + titleStyle: theme.textTheme.headlineBold, +) + +// After +StreamAppBarThemeData( + style: StreamAppBarStyle( + backgroundColor: colorScheme.backgroundElevation1, + titleTextStyle: textTheme.headingSm, + ), +) +``` + +Use `StreamAppBarTheme(data: ..., child: ...)` to override the theme for +a subtree, or pass `style:` directly on a single header instance. + +### Header height + +`kStreamHeaderHeight` (72 px) is now the canonical header height — +exposed from `package:stream_chat_flutter/stream_chat_flutter.dart`. If +you read `kToolbarHeight` (56 px) to size custom chrome that sits next +to a Stream header, switch to `kStreamHeaderHeight` to stay aligned. + +--- + +## StreamChat.componentBuilders + +`StreamChat` now accepts an optional `componentBuilders` parameter. When provided, it automatically inserts a `StreamComponentFactory` into the widget tree below the theme, making app-wide component customization available without wrapping the app manually. + +### Migration + +**Before (manual wrapping required):** +```dart +StreamComponentFactory( + builders: StreamComponentBuilders( + extensions: streamChatComponentBuilders( + messageWidget: (context, props) => MyMessage(props: props), + ), + ), + child: StreamChat( + client: client, + child: MyApp(), + ), +) +``` + +**After (pass via `StreamChat` directly):** +```dart +StreamChat( + client: client, + componentBuilders: StreamComponentBuilders( + extensions: streamChatComponentBuilders( + messageWidget: (context, props) => MyMessage(props: props), + ), + ), + child: MyApp(), +) +``` + +Both approaches are equivalent. If you already have a `StreamComponentFactory` elsewhere in the tree it continues to work. Use `componentBuilders` when `StreamChat` is your natural customization entry point. + +--- + +## StreamChatConfigurationData New Fields + +Three new optional fields have been added to `StreamChatConfigurationData`. Existing code that does not pass them will use the defaults and requires no changes. + +### New Fields + +| Field | Type | Default | Description | +|-------|------|---------|-------------| +| `attachmentBuilders` | `List?` | `null` | Custom attachment widget builders. When non-null, these are **prepended** to the SDK's built-in builders so your types are matched first. | +| `reactionType` | `StreamReactionsType?` | `null` | Controls the visual style of the reactions display (e.g. segmented). Falls back to the SDK default when `null`. | +| `reactionPosition` | `StreamReactionsPosition?` | `null` | Controls where reactions appear relative to the message bubble (e.g. header). Falls back to the SDK default when `null`. | + +> **Note:** The `imageCDN` field was also added to `StreamChatConfigurationData`. It is covered in the [Image CDN & Thumbnails](image_cdn.md) guide. + +### Migration + +```dart +// Before: no attachment builder or reaction customization on the config +StreamChat( + client: client, + streamChatConfigData: StreamChatConfigurationData( + enforceUniqueReactions: false, + ), + child: MyApp(), +) + +// After: optionally add the new fields +StreamChat( + client: client, + streamChatConfigData: StreamChatConfigurationData( + enforceUniqueReactions: false, + attachmentBuilders: [MyCustomAttachmentBuilder()], + reactionType: StreamReactionsType.segmented, + reactionPosition: StreamReactionsPosition.header, + ), + child: MyApp(), +) +``` + +--- + +## Migration Checklist + +- [ ] Replace all `StreamSvgIcon(icon: StreamSvgIcons.*)` with `Icon(context.streamIcons.*)` using the mapping table above +- [ ] Replace `showBackButton: false` with `automaticallyImplyLeading: false` on every header that used it +- [ ] Move `onBackPressed: cb` to `leading: StreamBackButton(onPressed: cb)` +- [ ] Move `onTitleTap` / `onImageTap` callbacks into `GestureDetector` wrappers around the new `title:` / `trailing:` slots (or use `onChannelAvatarPressed` on `StreamChannelHeader`) +- [ ] Rename `StreamChannelListHeader.onUserAvatarTap` to `onUserAvatarPressed`; drop manual `Scaffold.of(context).openDrawer()` callbacks if you only want the default drawer behaviour +- [ ] Replace `StreamChannelListHeader.onNewChatButtonTap` with a `trailing: StreamButton.icon(...)` widget — the SDK no longer ships the default button +- [ ] Replace `titleBuilder: (context, user) => ...` with `title: ...` (a `Widget`) +- [ ] Replace `actions: [a, b]` with `trailing: Row(children: [a, b])` — only one trailing slot is exposed +- [ ] Drop `centerTitle`, `elevation`, `bottomOpacity`, `bottom`, and `backgroundColor` from header callsites; use `style: StreamAppBarStyle(backgroundColor: ...)` if you need to override the background +- [ ] Update theme overrides: `StreamChannelHeaderThemeData` / `StreamChannelListHeaderThemeData` / `StreamGalleryHeaderThemeData` are deleted — switch to `StreamAppBarThemeData` +- [ ] If you sized custom chrome to `kToolbarHeight` next to a Stream header, switch to `kStreamHeaderHeight` (72 px) +- [ ] Optionally move `StreamComponentFactory` wrapping into the `componentBuilders` parameter on `StreamChat` +- [ ] Use the new `attachmentBuilders`, `reactionType`, and `reactionPosition` fields on `StreamChatConfigurationData` if you need custom attachment rendering or global reaction style control diff --git a/migrations/redesign/image_cdn.md b/migrations/redesign/image_cdn.md new file mode 100644 index 0000000000..bb341447a3 --- /dev/null +++ b/migrations/redesign/image_cdn.md @@ -0,0 +1,209 @@ +# Image CDN & Thumbnails Migration Guide + +This guide covers the migration for the redesigned image CDN handling and thumbnail resize parameters in Stream Chat Flutter SDK. + +--- + +## Table of Contents + +- [Quick Reference](#quick-reference) +- [StreamImageCDN](#streamimagecdn) +- [StreamImageAttachmentThumbnail](#streamimageattachmentthumbnail) +- [StreamMediaAttachmentThumbnail](#streammediaattachmentthumbnail) +- [StreamImageAttachment](#streamimageattachment) +- [Migration Checklist](#migration-checklist) + +--- + +## Quick Reference + +| Component | Key Changes | +|-----------|-------------| +| [**StreamImageCDN**](#streamimagecdn) | New class replacing `getResizedImageUrl` String extension (stable cache keys now via `StreamImageCDN.cacheKey()`) | +| [**StreamImageAttachmentThumbnail**](#streamimageattachmentthumbnail) | `thumbnailSize`, `thumbnailResizeType`, `thumbnailCropType` → single `resize` parameter | +| [**StreamMediaAttachmentThumbnail**](#streammediaattachmentthumbnail) | `thumbnailSize`, `thumbnailResizeType`, `thumbnailCropType` → single `resize` parameter | +| [**StreamImageAttachment**](#streamimageattachment) | `imageThumbnailSize`, `imageThumbnailResizeType`, `imageThumbnailCropType` → single `resize` parameter | + +--- + +## StreamImageCDN + +### What Changed: + +The `getResizedImageUrl` String extension has been replaced with a dedicated `StreamImageCDN` class. This class handles CDN URL resolution and stable cache key generation, preventing image reloads caused by expiring signed URL tokens. + +### Key Changes: + +- `getResizedImageUrl` String extension removed — use `StreamImageCDN.resolveUrl` instead +- New `StreamImageCDN.cacheKey` method generates stable cache keys that strip volatile signed URL tokens +- Raw `String` resize/crop type parameters replaced with `ResizeMode` and `CropMode` enums +- `StreamImageCDN` is injectable via `StreamChatConfigurationData` for custom CDN support + +### Migration: + +**Before:** +```dart +final resizedUrl = imageUrl.getResizedImageUrl( + width: 200, + height: 300, + resize: 'clip', + crop: 'center', +); +``` + +**After:** +```dart +const imageCDN = StreamImageCDN(); + +final resizedUrl = imageCDN.resolveUrl( + imageUrl, + resize: ImageResize(width: 200, height: 300), +); + +final cacheKey = imageCDN.cacheKey(resizedUrl); +``` + +### Custom CDN Support: + +Extend `StreamImageCDN` and inject it via configuration: + +```dart +class MyImageCDN extends StreamImageCDN { + @override + String cacheKey(String imageUrl) { + return Uri.parse(imageUrl).path; + } +} + +StreamChat( + client: client, + config: StreamChatConfigurationData( + imageCDN: MyImageCDN(), + ), + child: ..., +) +``` + +--- + +## StreamImageAttachmentThumbnail + +### Breaking Changes: + +- `thumbnailSize` parameter removed +- `thumbnailResizeType` parameter removed +- `thumbnailCropType` parameter removed +- New `resize` parameter (`ImageResize?`) replaces all three + +### Migration: + +**Before:** +```dart +StreamImageAttachmentThumbnail( + image: attachment, + thumbnailSize: const Size(200, 300), + thumbnailResizeType: 'clip', + thumbnailCropType: 'center', +) +``` + +**After:** +```dart +StreamImageAttachmentThumbnail( + image: attachment, + resize: ImageResize( + width: 200, + height: 300, + mode: ResizeMode.clip, + crop: CropMode.center, + ), +) +``` + +> **Note:** When `resize` is null, the size is auto-calculated from layout constraints and defaults to `ResizeMode.clip` and `CropMode.center`. + +--- + +## StreamMediaAttachmentThumbnail + +### Breaking Changes: + +- `thumbnailSize` parameter removed +- `thumbnailResizeType` parameter removed +- `thumbnailCropType` parameter removed +- New `resize` parameter (`ImageResize?`) replaces all three + +### Migration: + +**Before:** +```dart +StreamMediaAttachmentThumbnail( + media: attachment, + thumbnailSize: const Size(200, 300), + thumbnailResizeType: 'clip', + thumbnailCropType: 'center', +) +``` + +**After:** +```dart +StreamMediaAttachmentThumbnail( + media: attachment, + resize: ImageResize( + width: 200, + height: 300, + mode: ResizeMode.clip, + crop: CropMode.center, + ), +) +``` + +--- + +## StreamImageAttachment + +### Breaking Changes: + +- `imageThumbnailSize` parameter removed +- `imageThumbnailResizeType` parameter removed +- `imageThumbnailCropType` parameter removed +- New `resize` parameter (`ImageResize?`) replaces all three + +### Migration: + +**Before:** +```dart +StreamImageAttachment( + message: message, + image: attachment, + imageThumbnailSize: const Size(400, 600), + imageThumbnailResizeType: 'crop', + imageThumbnailCropType: 'center', +) +``` + +**After:** +```dart +StreamImageAttachment( + message: message, + image: attachment, + resize: ImageResize( + width: 400, + height: 600, + mode: ResizeMode.crop, + crop: CropMode.center, + ), +) +``` + +--- + +## Migration Checklist + +- [ ] Replace `getResizedImageUrl` String extension calls with `StreamImageCDN.resolveUrl` +- [ ] Use `StreamImageCDN.cacheKey` to generate stable cache keys for `CachedNetworkImage` +- [ ] Replace raw `String` resize/crop values (`'clip'`, `'crop'`, etc.) with `ResizeMode` and `CropMode` enums +- [ ] Update `StreamImageAttachmentThumbnail` to use `resize` parameter instead of `thumbnailSize`, `thumbnailResizeType`, `thumbnailCropType` +- [ ] Update `StreamMediaAttachmentThumbnail` to use `resize` parameter instead of `thumbnailSize`, `thumbnailResizeType`, `thumbnailCropType` +- [ ] Update `StreamImageAttachment` to use `resize` parameter instead of `imageThumbnailSize`, `imageThumbnailResizeType`, `imageThumbnailCropType` +- [ ] If using a custom CDN, extend `StreamImageCDN` and inject via `StreamChatConfigurationData` diff --git a/migrations/redesign/localizations.md b/migrations/redesign/localizations.md new file mode 100644 index 0000000000..ab090565ef --- /dev/null +++ b/migrations/redesign/localizations.md @@ -0,0 +1,229 @@ +# Localizations Migration Guide + +This guide covers the breaking changes to `Translations` and `StreamChatLocalizations` in the Stream Chat Flutter SDK design refresh. + +--- + +## Table of Contents + +- [New Required Abstract Members](#new-required-abstract-members) +- [Renamed Abstract Members](#renamed-abstract-members) +- [Changed Default String Values](#changed-default-string-values) +- [Migration Checklist](#migration-checklist) + +--- + +## New Required Abstract Members + +If you have a custom `Translations` subclass (used to provide custom localization strings), it **will fail to compile** unless you add implementations for the following new abstract members. + +### New getters and methods + +Add these to your `Translations` subclass: + +```dart +// Channel/message list empty states +@override +String get noConversationsYetText => 'No conversations yet'; + +@override +String get replyToStartThreadText => 'Reply to a message to start a thread'; + +@override +String get sendMessageToStartConversationText => 'Send a message to start the conversation'; + +// Message annotation labels +@override +String get savedForLaterLabel => 'Saved for later'; + +@override +String get repliedToThreadAnnotationLabel => 'Replied to a thread'; + +@override +String get alsoSentInChannelAnnotationLabel => 'Also sent in channel'; + +@override +String get viewLabel => 'View'; + +// Reminder labels +@override +String get reminderSetLabel => 'Reminder set'; + +@override +String reminderAtText(String time) => 'Today at $time'; + +// Channel list attachment previews +@override +String get fileAttachmentText => 'File'; + +@override +String get linkAttachmentText => 'Link'; + +@override +String filesAttachmentCountText(int count) => count == 1 ? 'File' : '$count files'; + +@override +String photosAttachmentCountText(int count) => count == 1 ? 'Photo' : '$count photos'; + +@override +String videosAttachmentCountText(int count) => count == 1 ? 'Video' : '$count videos'; + +// Attachment picker labels +@override +String get createPollPromptLabel => 'Create a poll and let everyone vote!'; + +@override +String get takePhotoAndShareLabel => 'Take a photo and share'; + +@override +String get takeVideoAndShareLabel => 'Take a video and share'; + +@override +String get openCameraLabel => 'Open camera'; + +@override +String get selectFilesToShareLabel => 'Select files to share'; + +@override +String get openFilesLabel => 'Open files'; + +// Reactions list / detail sheet +@override +String get emptyReactionsText => 'No reactions yet'; + +@override +String get loadingReactionsError => 'Error loading reactions'; + +@override +String get tapToRemoveReactionLabel => 'Tap to remove'; + +@override +String reactionsCountText(int count) => + count == 1 ? '1 Reaction' : '$count Reactions'; + +// Confirmation dialogs +@override +String get confirmLabel => 'CONFIRM'; + +// Relative timestamps +@override +String get justNowLabel => 'Just now'; + +// Composer reply header +@override +String replyToUserLabel(String userName) => 'Reply to $userName'; + +// Poll creator toggle descriptions +@override +String get multipleAnswersDescription => 'Select more than one option'; + +@override +String maximumVotesPerPersonDescription([Range? range]) { + final (:min, :max) = range ?? (min: 2, max: 10); + return 'Choose between $min\u2013$max options'; +} + +@override +String get anonymousPollDescription => 'Hide who voted'; + +@override +String get suggestAnOptionDescription => 'Let others add options'; + +@override +String get addACommentDescription => 'Allow others to add comments'; + +// Channel header subtitle for group channels +@override +String membersCountWithOnlineText({ + required int memberCount, + required int onlineCount, +}) { + final members = membersCountText(memberCount); + if (onlineCount <= 0) return members; + return '$members, ${watchersCountText(onlineCount)}'; +} + +// Composer placeholder for user-target commands (`/mute`, `/unmute`, `/ban`, `/unban`) +@override +String get commandUsernameLabel => '@username'; + +// Poll results dialog footer +@override +String totalVoteCountLabel({int? count}) => switch (count) { + null || < 1 => '0 votes total', + 1 => '1 vote total', + _ => '$count votes total', +}; + +// Generic "view all" CTA (used by the poll results dialog footer action) +@override +String get viewAllLabel => 'View all'; + +// Poll option votes dialog app bar title +@override +String get pollVotesLabel => 'Votes'; + +// Poll end-vote confirmation dialog body +@override +String get endVoteConfirmationMessage => + 'Do you want to end this poll now? Nobody will be able to vote in this poll anymore.'; +``` + +> **Note:** The values shown above are the English defaults from `DefaultTranslations`. Provide your own translated strings in place of these. + +--- + +## Renamed Abstract Members + +The following members were renamed. If you have overridden them in a custom `Translations` subclass you must update the override signature; otherwise the compiler will flag the old name as an unknown member. + +| Old member | New member | Notes | +|------------|------------|-------| +| `String get questionsLabel` | `String questionLabel({bool isPlural = false})` | Now mirrors `optionLabel({bool isPlural})`. Pass `isPlural: true` to get the previous plural value, or call with no arguments for the singular "Question" label used in the poll results/options dialogs. | +| `String get endVoteConfirmationText` | `String get endVoteConfirmationTitle` | Renamed to reflect that this string is the dialog title rather than body text; see also the new default value in [Changed Default String Values](#changed-default-string-values). | + +Example migration: + +```dart +// Before +@override +String get questionsLabel => 'Questions'; + +// After +@override +String questionLabel({bool isPlural = false}) { + if (isPlural) return 'Questions'; + return 'Question'; +} +``` + +If you previously read `translations.questionsLabel`, replace it with `translations.questionLabel(isPlural: true)` to preserve the original plural behavior, or `translations.questionLabel()` where the singular form is appropriate. + +--- + +## Changed Default String Values + +The following strings changed their default English value in `DefaultTranslations`. If you have not overridden them in a custom `Translations` subclass you do not need to do anything, but you should review whether the new values are appropriate for your app. + +| Getter | Old default | New default | +|--------|-------------|-------------| +| `threadReplyLabel` | `'Thread Reply'` | `'Thread'` | +| `threadReplyCountText(int)` | `'$count Thread Replies'` | `count == 1 ? '1 reply' : '$count replies'` | +| `alsoSendAsDirectMessageLabel` | `'Also send as direct message'` | `'Also send in Channel'` | +| `addMoreFilesLabel` | `'Add more files'` | `'Add more'` | +| `emptyMessagesText` | `'There are no messages currently'` | `'No messages yet'` | +| `writeAMessageLabel` | `'Write a message'` | `'Send a message'` | +| `endVoteConfirmationTitle` (was `endVoteConfirmationText`) | `'Are you sure you want to end the vote?'` | `'End This Poll?'` | +| `endVoteLabel` | `'End Vote'` | `'End Poll'` | + +If your app overrides these in a `Translations` subclass, your custom values are unaffected. + +--- + +## Migration Checklist + +- [ ] Search your codebase for any class that `extends Translations` or `extends DefaultTranslations` +- [ ] Add implementations for all 35 new abstract members listed above — the compiler will flag missing ones +- [ ] Update the signature of any `questionsLabel` override to `questionLabel({bool isPlural = false})`, and replace any call to `translations.questionsLabel` with `translations.questionLabel(isPlural: true)` +- [ ] Rename any `endVoteConfirmationText` override (and consumer) to `endVoteConfirmationTitle` +- [ ] Review the changed default string values and decide whether to keep the new defaults or override them to preserve the old text diff --git a/migrations/redesign/media_viewer.md b/migrations/redesign/media_viewer.md new file mode 100644 index 0000000000..c5713a12ab --- /dev/null +++ b/migrations/redesign/media_viewer.md @@ -0,0 +1,314 @@ +# Media Viewer Migration Guide + +The full-screen media viewer and its thumbnail companion have been redesigned and split into two widgets — both built on the design system's `StreamMediaViewer`, `StreamAppBar`, and `StreamBottomAppBar` chrome. The legacy `StreamFullScreenMedia` (and its desktop/builder/stub variants) and the related `StreamMediaListView` / `StreamImageGallery` flow have all been replaced. + +This guide also covers the related theme cleanups (`StreamGalleryFooterThemeData`, `StreamChatThemeData.galleryHeaderTheme`, `StreamChatThemeData.galleryFooterTheme`, `StreamAvatarThemeData`) and the removal of the `onShowMessage` / `attachmentActionsModalBuilder` callbacks from the message widget and message list view. + +--- + +## Table of Contents + +- [Quick Reference](#quick-reference) +- [Architecture: Props + Component Factory](#architecture-props--component-factory) +- [StreamFullScreenMedia → StreamMediaGalleryPreview](#streamfullscreenmedia--streammediagallerypreview) +- [StreamAttachmentPackage → StreamMediaGalleryAttachment](#streamattachmentpackage--streammediagalleryattachment) +- [StreamGalleryHeader → StreamMediaGalleryPreviewHeader](#streamgalleryheader--streammediagallerypreviewheader) +- [StreamGalleryFooter → StreamMediaGalleryPreviewFooter](#streamgalleryfooter--streammediagallerypreviewfooter) +- [New StreamMediaGallery (Thumbnail Grid)](#new-streammediagallery-thumbnail-grid) +- [Removed message-widget callbacks](#removed-message-widget-callbacks) +- [Removed themes](#removed-themes) +- [Migration Checklist](#migration-checklist) + +--- + +## Quick Reference + +| Old | New | +|-----|-----| +| `StreamFullScreenMedia` / `StreamFullScreenMediaBuilder` / `FullScreenMediaWidget` / `FullScreenMediaDesktop` | `StreamMediaGalleryPreview` (single widget, all platforms) | +| `StreamAttachmentPackage` | `StreamMediaGalleryAttachment` | +| `StreamGalleryHeader` | `StreamMediaGalleryPreviewHeader` | +| `StreamGalleryFooter` | `StreamMediaGalleryPreviewFooter` | +| `VideoPackage` / `DesktopVideoPackage` / `GalleryNavigationItem` | **Removed from the public API** — each preview page now owns its own player state internally | +| *(none)* | `StreamMediaGallery` — **new** thumbnail-grid companion | +| `StreamMessageItem.onShowMessage` / `attachmentActionsModalBuilder` | **Removed** | +| `StreamMessageListView.onShowMessage` / `attachmentActionsModalBuilder` | **Removed** | +| `StreamGalleryFooterThemeData`, `StreamChatThemeData.imageFooterTheme` / `galleryFooterTheme` / `galleryHeaderTheme` | **Removed** | +| `StreamAvatarThemeData` | **Removed** — was unused | +| `Translations.photosAndVideosLabel` | **New** — used by the footer's thumbnail-grid sheet header | + +--- + +## Architecture: Props + Component Factory + +`StreamMediaGalleryPreview` and `StreamMediaGallery` follow the same **Props + Component Factory** pattern used by other redesigned components: + +1. **Public widget** (e.g. `StreamMediaGalleryPreview`) — thin wrapper that reads from `StreamComponentFactory` or falls back to a default implementation. +2. **Props class** (e.g. `StreamMediaGalleryPreviewProps`) — holds all configuration. +3. **Default implementation** (e.g. `DefaultStreamMediaGalleryPreview`) — the built-in rendering. + +Replace either widget globally via `StreamComponentFactory`: + +```dart +StreamComponentFactory( + builders: StreamComponentBuilders( + extensions: streamChatComponentBuilders( + mediaGallery: (context, props) => MyCustomMediaGallery(props: props), + mediaGalleryPreview: (context, props) => MyCustomMediaGalleryPreview(props: props), + ), + ), + child: ..., +) +``` + +--- + +## StreamFullScreenMedia → StreamMediaGalleryPreview + +### Breaking Changes + +- **Renamed** to `StreamMediaGalleryPreview`. There is one widget across mobile, desktop and web — the platform-specific subclasses (`FullScreenMediaDesktop`), the abstract `FullScreenMediaWidget`, and the conditional-import builder `StreamFullScreenMediaBuilder` / `fsm_stub.dart` have all been removed. +- The constructor surface shrunk to the minimum the gallery actually owns. The following parameters are **gone**: + - `userName` — sender metadata is now read from `attachment.message.user` inside the header. + - `sentAt` — read from `attachment.message.createdAt`. + - `onReplyMessage` — no longer surfaced as a header action. + - `onShowMessage` — no longer surfaced as a header action. + - `attachmentActionsModalBuilder` — the more-actions overflow has been removed from the header. +- `mediaAttachmentPackages` → `attachments` (renamed and the element type changed — see [StreamAttachmentPackage → StreamMediaGalleryAttachment](#streamattachmentpackage--streammediagalleryattachment)). +- `startIndex` → `initialIndex` (semantics unchanged). +- `autoplayVideos` is retained. + +### Behaviour built in + +- Tapping the media area toggles the chrome (slides off the top/bottom edges with a fade). +- Keyboard shortcuts: `← / →` advance pages, `esc` pops the route. +- The footer's gallery-grid button now opens a `StreamMediaGallery` inside `showStreamSheet`; tapping a thumbnail pops the sheet and animates the page view to that index. +- The footer's share button downloads the active attachment's bytes via `Dio` and hands them to the system share sheet via `share_plus` (no more platform-specific `dart:io` paths — works on web too). +- Video attachments are played by `StreamVideoPlayer`, which pauses itself when its page is no longer active (see `StreamMediaGalleryPreviewScope`). + +### Migration + +**Before:** +```dart +Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => StreamChannel( + channel: channel, + child: StreamFullScreenMedia( + mediaAttachmentPackages: [ + for (final a in message.attachments) + StreamAttachmentPackage(attachment: a, message: message), + ], + startIndex: 3, + userName: message.user!.name, + autoplayVideos: false, + onReplyMessage: handleReply, + onShowMessage: handleShowInChat, + attachmentActionsModalBuilder: buildActions, + ), + ), + ), +); +``` + +**After:** +```dart +Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => StreamChannel( + channel: channel, + child: StreamMediaGalleryPreview( + attachments: message.toMediaGalleryAttachments( + filter: (a) => + a.type == AttachmentType.image || + a.type == AttachmentType.video || + a.type == AttachmentType.giphy, + ), + initialIndex: 3, + autoplayVideos: false, + ), + ), + ), +); +``` + +If you depended on `onReplyMessage`, `onShowMessage`, or `attachmentActionsModalBuilder`, replace the preview via the component factory (`mediaGalleryPreview: ...`) and surface those actions in your own chrome. + +### Active-page scope + +Per-page widgets (video players, custom items) that need to react when their page goes off-screen read from `StreamMediaGalleryPreviewScope`: + +```dart +final scope = StreamMediaGalleryPreviewScope.of(context); +final isActive = scope.activeIndex.value == myPageIndex; +``` + +The bundled `StreamVideoPlayer` already uses this — it pauses when inactive and resumes when the user swipes back, preserving prior playing/paused state. + +--- + +## StreamAttachmentPackage → StreamMediaGalleryAttachment + +### Breaking Changes + +`StreamAttachmentPackage` has been renamed to `StreamMediaGalleryAttachment` and moved into `media_gallery/`. The shape (an `Attachment` + its parent `Message`) is unchanged. + +A new convenience extension `MessageMediaGalleryX.toMediaGalleryAttachments({filter})` produces a `List` from a `Message`: + +```dart +final attachments = message.toMediaGalleryAttachments( + filter: (a) => + a.type == AttachmentType.image || + a.type == AttachmentType.video || + a.type == AttachmentType.giphy, +); +``` + +--- + +## StreamGalleryHeader → StreamMediaGalleryPreviewHeader + +### Breaking Changes + +- **Renamed** to `StreamMediaGalleryPreviewHeader`. +- Rebuilt on top of `StreamAppBar`; the back affordance is auto-implied from the route. +- The constructor surface shrunk to a `title` and `subtitle` widget pair. The following parameters are **gone**: + - `userName`, `sentAt` — render whatever you want in the `title` / `subtitle` slots. + - `message`, `attachment` — the header no longer reads from the active package; the parent passes presentation-ready widgets in. + - `onShowMessage`, `onBackPressed` — the more-actions overflow has been removed; back is handled by `StreamAppBar`. + +### Migration + +**Before:** +```dart +StreamGalleryHeader( + userName: message.user!.name, + sentAt: 'Sent ${formatted}', + message: message, + attachment: attachment, + onShowMessage: handleShowInChat, + onBackPressed: () => Navigator.of(context).pop(), +) +``` + +**After:** +```dart +StreamMediaGalleryPreviewHeader( + title: Text(message.user?.name ?? ''), + subtitle: Text( + context.translations.sentAtText( + date: message.createdAt, + time: message.createdAt, + ), + ), +) +``` + +--- + +## StreamGalleryFooter → StreamMediaGalleryPreviewFooter + +### Breaking Changes + +- **Renamed** to `StreamMediaGalleryPreviewFooter`. +- Rebuilt on top of `StreamBottomAppBar` — the underlying chrome and theming flow through `StreamBottomAppBarThemeData`. +- The constructor surface shrunk to `title` + two callbacks. The following parameters are **gone**: + - `currentPage`, `totalPages` — render the page counter as a `Text` in the `title` slot using `Translations.galleryPaginationText`. + - `mediaSelectedCallBack` — the gallery-grid action no longer hands a tile index back through a callback. The parent owns the bottom-sheet flow and decides what to do when a tile is tapped. + - `userName`, `sentAt`, `message`, `mediaAttachmentPackages` — not needed; the parent passes presentation-ready widgets and intent callbacks. + +### Migration + +**Before:** +```dart +StreamGalleryFooter( + currentPage: currentPage, + totalPages: totalPages, + mediaAttachmentPackages: packages, + mediaSelectedCallBack: (index, _) { + Navigator.pop(context); + _pageController.jumpToPage(index); + }, +) +``` + +**After:** +```dart +StreamMediaGalleryPreviewFooter( + title: Text( + context.translations.galleryPaginationText( + currentPage: currentPage, + totalPages: totalPages, + ), + ), + onSharePressed: shareCurrentAttachment, + onGalleryPressed: openThumbnailSheet, +) +``` + +> **Note:** The footer is wired up automatically when you use `StreamMediaGalleryPreview`. It's only relevant if you compose your own chrome on top of the design-system primitives. + +--- + +## New StreamMediaGallery (Thumbnail Grid) + +`StreamMediaGallery` is a **new** widget — a 3-up grid of `StreamMediaGalleryItem` tiles, designed to be the thumbnail companion to `StreamMediaGalleryPreview`. The footer's gallery-grid button now opens this widget in a `showStreamSheet`. + +Each tile renders the sender's avatar plus a media badge for videos (`StreamMediaBadge`). Inter-cell gutters and the outer padding default to `spacing.xxxs` (2 logical pixels) for a uniform mosaic. + +```dart +StreamMediaGallery( + attachments: message.toMediaGalleryAttachments(filter: isMedia), + onItemTap: (index) => Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => StreamMediaGalleryPreview( + attachments: attachments, + initialIndex: index, + ), + ), + ), +) +``` + +Replace globally via `mediaGallery: ...` on `streamChatComponentBuilders`. + +--- + +## Removed message-widget callbacks + +`onShowMessage` and `attachmentActionsModalBuilder` have been removed from both `StreamMessageItem` (and its props) and `StreamMessageListView`. They were forwarded into the now-removed gallery overflow header, so they no longer have a destination. + +If you relied on either callback, replace the gallery preview via the component factory (`mediaGalleryPreview: ...`) and surface those actions in your own chrome. + +`StreamMessageItem.onReplyTap` and `StreamMessageListView.onReplyTap` are unchanged. + +--- + +## Removed themes + +The following theme types and `StreamChatThemeData` fields have been removed: + +| Removed | Notes | +|---------|-------| +| `StreamGalleryFooterThemeData` | The new footer is themed via `StreamBottomAppBarThemeData` from the design system. | +| `StreamChatThemeData.galleryFooterTheme` / `imageFooterTheme` (named param) | Footer themeing now flows through `StreamBottomAppBarThemeData`. | +| `StreamChatThemeData.galleryHeaderTheme` | Header themeing now flows through `StreamAppBarThemeData`. | +| `StreamAvatarThemeData` | Was unused after the avatar redesign. Use `StreamUserAvatarThemeData` from `stream_core_flutter` to theme avatars globally. | + +The full-screen page background and chrome bands themselves are themed via `StreamMediaViewerThemeData` (from `stream_core_flutter`, re-exported here). + +--- + +## Migration Checklist + +- [ ] Replace `StreamFullScreenMedia` / `StreamFullScreenMediaBuilder` with `StreamMediaGalleryPreview`. +- [ ] Replace `StreamAttachmentPackage` with `StreamMediaGalleryAttachment` (or `Message.toMediaGalleryAttachments(...)`). +- [ ] Rename `startIndex` → `initialIndex`, `mediaAttachmentPackages` → `attachments`. +- [ ] Drop `userName`, `sentAt`, `onReplyMessage`, `onShowMessage`, `attachmentActionsModalBuilder` from preview usage. +- [ ] Replace `StreamGalleryHeader` with `StreamMediaGalleryPreviewHeader`; render sender / timestamp in the `title` / `subtitle` slots. +- [ ] Replace `StreamGalleryFooter` with `StreamMediaGalleryPreviewFooter`; render the page counter via `Translations.galleryPaginationText` in the `title` slot. +- [ ] Drop usages of `VideoPackage`, `DesktopVideoPackage`, `GalleryNavigationItem`, `FullScreenMediaWidget`, `FullScreenMediaDesktop`. +- [ ] Drop `StreamMessageItem.onShowMessage` / `attachmentActionsModalBuilder`, `StreamMessageListView.onShowMessage` / `attachmentActionsModalBuilder` from any constructors. +- [ ] Drop `StreamChatThemeData.galleryHeaderTheme`, `galleryFooterTheme` and `imageFooterTheme:` named-parameter usages. +- [ ] Drop `StreamAvatarThemeData` references. +- [ ] Optionally adopt `StreamMediaGallery` as a thumbnail grid for channel-level media listings. diff --git a/migrations/redesign/message_actions.md b/migrations/redesign/message_actions.md new file mode 100644 index 0000000000..9b8d907108 --- /dev/null +++ b/migrations/redesign/message_actions.md @@ -0,0 +1,577 @@ +# Message Actions Migration Guide + +This guide covers the migration for the redesigned message action components in Stream Chat Flutter SDK. + +--- + +## Table of Contents + +- [Quick Reference](#quick-reference) +- [StreamMessageAction → StreamContextMenuAction](#streammessageaction--streamcontextmenuaction) +- [StreamMessageActionItem](#streammessageactionitem) +- [StreamMessageActionsModal](#streammessageactionsmodal) +- [StreamMessageReactionsModal](#streammessagereactionsmodal) +- [ModeratedMessageActionsModal](#moderatedmessageactionsmodal) +- [StreamMessageItem.customActions → actionsBuilder](#streammessageitemcustomactions) +- [StreamMessageActionsBuilder](#streammessageactionsbuilder) +- [New Components](#new-components) +- [Migration Checklist](#migration-checklist) + +--- + +## Quick Reference + +| Symbol | Change | +|--------|--------| +| `StreamMessageAction` | **Removed** — replaced by `StreamContextMenuAction` | +| `StreamMessageActionItem` | **Removed** — rendering built into `StreamContextMenuAction` | +| `StreamMessageActionsModal.onActionTap` | **Removed** — use `onTap` per-action or await the dialog return value | +| `StreamMessageActionsModal.messageActions` | **Type changed**: `List` → `List` | +| `StreamMessageActionsModal.reverse` | **Removed** — use `alignment: AlignmentGeometry?` | +| `StreamMessageActionsModal.reactionPickerBuilder` | **Removed** — use `showReactionPicker: bool` | +| `StreamMessageReactionsModal` | **Deleted** — use `ReactionDetailSheet` (see [reaction_list.md](reaction_list.md)) | +| `StreamMessageReactionsModal.onReactionPicked` | **Removed** — await the dialog return value (`SelectReaction`) | +| `ModeratedMessageActionsModal.onActionTap` | **Removed** — use `onTap` per-action or await the dialog return value | +| `ModeratedMessageActionsModal.messageActions` | **Type changed**: `List` → `List` | +| `StreamMessageItem.customActions` | **Removed** — replaced by `actionsBuilder` (`MessageActionsBuilder?`) | +| `StreamMessageItem.onCustomActionTap` | **Removed** — use `onTap` directly on each `StreamContextMenuAction` in `actionsBuilder` | +| `CustomMessageAction` | **Removed** — no longer needed; custom actions use `onTap` directly | +| `OnMessageActionTap` | **Removed** — no longer needed | +| `StreamMessageItem.actionsBuilder` | **New** — `MessageActionsBuilder?` for the normal long-press menu | +| `StreamMessageActionsBuilder.buildActions` | **Changed**: return type `List`, `customActions` param **removed** | +| `StreamMessageActionsBuilder.buildBouncedErrorActions` | **Return type changed**: `List` → `List` | +| `MessageActionsBuilder` | **New typedef** — `List Function(BuildContext, List>)` | +| `StreamContextMenu` | **New** — exported from `stream_core_flutter` | +| `StreamContextMenuAction` | **New** — exported from `stream_core_flutter` | +| `StreamContextMenuSeparator` | **New** — exported from `stream_core_flutter` | + +> **Note:** `MessageAction` and all its built-in subclasses (`SelectReaction`, `CopyMessage`, `DeleteMessage`, etc.) are **unchanged**. `CustomMessageAction` (the escape-hatch subclass) has been **removed** — it was only needed for the old `onCustomActionTap` dispatch pattern. + +--- + +## StreamMessageAction → StreamContextMenuAction + +The `StreamMessageAction` data class has been removed. It was a pure data object that described how an action should look and which `MessageAction` it represents. It is replaced by `StreamContextMenuAction`, which is a self-rendering widget that carries a typed `value` and handles dispatch automatically. + +### Breaking Change + +`StreamMessageAction` no longer exists. Replace every usage with `StreamContextMenuAction`. + +### Tap dispatch behaviour + +`StreamContextMenuAction` has two complementary dispatch mechanisms: + +- **`value`** — when the action is tapped inside a popup route (dialog, bottom sheet, etc.) it calls `Navigator.pop(value)` first, then calls `onTap` if provided. The route is already closed when `onTap` runs. +- **`onTap`** — an optional `VoidCallback?`. When the action is used *inline* (outside any popup route) this is the only callback that fires. + +You can use `value`, `onTap`, or both together. + +### Migration + +**Before:** +```dart +StreamMessageAction( + action: QuotedReply(message: message), + leading: const StreamSvgIcon(icon: StreamSvgIcons.reply), + title: Text(context.translations.replyLabel), +) +``` + +**After (value-based — recommended for modals):** +```dart +StreamContextMenuAction( + value: QuotedReply(message: message), + leading: Icon(context.streamIcons.arrowShareLeft), + label: Text(context.translations.replyLabel), +) +// The caller receives QuotedReply via the Future returned by showStreamDialog. +``` + +**After (onTap-based — for inline usage or when you prefer callbacks):** +```dart +StreamContextMenuAction( + value: QuotedReply(message: message), + leading: Icon(context.streamIcons.arrowShareLeft), + label: Text(context.translations.replyLabel), + onTap: () => onReply(message), // called after the route is dismissed +) +``` + +### Property mapping + +| `StreamMessageAction` | `StreamContextMenuAction` | +|-----------------------|--------------------------| +| `action: T` | `value: T?` | +| `title: Widget?` | `label: Widget` (required) | +| `leading: Widget?` | `leading: Widget?` | +| `isDestructive: bool` | `isDestructive: bool` (or use `.destructive` constructor) | +| `iconColor: Color?` | Controlled via `StreamContextMenuActionTheme` | +| `titleTextColor: Color?` | Controlled via `StreamContextMenuActionTheme` | +| `titleTextStyle: TextStyle?` | Controlled via `StreamContextMenuActionTheme` | +| `backgroundColor: Color?` | Controlled via `StreamContextMenuActionTheme` | +| — | `onTap: VoidCallback?` (new) | +| — | `trailing: Widget?` (new) | +| — | `enabled: bool` (new) | + +> **Important:** +> - **`label` is now required** — `title: Widget?` was optional in `StreamMessageAction`; `label: Widget` is a required, non-nullable parameter in `StreamContextMenuAction`. Any call site that omitted `title` will fail to compile; you must supply a non-null `label` widget (typically a `Text`). +> - `onTap` signature changed from `void Function(MessageAction)` to `VoidCallback?` — capture data in a closure instead +> - Per-item colours and text styles are now unified via `StreamContextMenuActionTheme` rather than individual properties + +--- + +## StreamMessageActionItem + +The `StreamMessageActionItem` widget has been removed. `StreamContextMenuAction` is now a full self-rendering widget — no separate "item" wrapper is needed. + +### Breaking Change + +`StreamMessageActionItem` no longer exists. Remove all direct usages. + +### Migration + +**Before:** +```dart +StreamMessageActionItem( + action: StreamMessageAction( + action: CopyMessage(message: message), + leading: const StreamSvgIcon(icon: StreamSvgIcons.copy), + title: Text('Copy'), + ), + onTap: (action) => _handle(action), +) +``` + +**After:** +```dart +StreamContextMenuAction( + value: CopyMessage(message: message), + leading: Icon(context.streamIcons.copy), + label: Text('Copy'), + onTap: () => _handle(message), +) +``` + +--- + +## StreamMessageActionsModal + +### Breaking Changes + +- `onActionTap: OnMessageActionTap?` parameter **removed** — the modal no longer holds a top-level callback; use `onTap` on individual actions or await the dialog's return value +- `messageActions` parameter type changed from `List` to `List` +- `reverse: bool` parameter **removed** — use `alignment: AlignmentGeometry?` instead +- `reactionPickerBuilder: ReactionPickerBuilder` parameter **removed** — use `showReactionPicker: bool` instead +- New `leadingInset: double` parameter added (default `0`) + +### Migration + +**Before:** +```dart +StreamMessageActionsModal( + message: message, + messageWidget: messageWidget, + messageActions: [ + StreamMessageAction( + action: CopyMessage(message: message), + leading: const StreamSvgIcon(icon: StreamSvgIcons.copy), + title: Text(context.translations.copyMessageLabel), + ), + ], + onActionTap: (action) { + if (action is CopyMessage) _copyMessage(action.message); + }, +) +``` + +**After (onTap per-action):** +```dart +StreamMessageActionsModal( + message: message, + messageWidget: messageWidget, + messageActions: [ + StreamContextMenuAction( + value: CopyMessage(message: message), + leading: Icon(context.streamIcons.copy), + label: Text(context.translations.copyMessageLabel), + onTap: () => _copyMessage(message), // called after route dismissal + ), + ], +) +``` + +**After (await return value):** + +> `showStreamDialog` is a Stream-themed wrapper around `showGeneralDialog` — see [New Components](#showstreamdialogt) for details. + +```dart +final action = await showStreamDialog( + context: context, + builder: (_) => StreamMessageActionsModal( + message: message, + messageWidget: messageWidget, + messageActions: [ + StreamContextMenuAction( + value: CopyMessage(message: message), + leading: Icon(context.streamIcons.copy), + label: Text(context.translations.copyMessageLabel), + ), + ], + ), +); + +if (action is CopyMessage) _copyMessage(action.message); +``` + +> **Important:** +> - `onActionTap` on the modal is gone — move handling to `onTap` on each item or await the `Future` +> - Replace `StreamMessageAction` entries with `StreamContextMenuAction` + +--- + +## StreamMessageReactionsModal + +### Breaking Changes + +- `StreamMessageReactionsModal` class has been **deleted**. Any direct reference to the class will cause a compile error. Use `ReactionDetailSheet` instead — see [Reaction List & Detail Sheet](reaction_list.md). +- `onReactionPicked: OnMessageActionTap?` parameter **removed** — the modal now pops the route with a `SelectReaction`; await the dialog return value to handle it + +### Migration + +**Before:** +```dart +StreamMessageReactionsModal( + message: message, + messageWidget: messageWidget, + onReactionPicked: (SelectReaction action) { + _addReaction(action.reaction); + }, +) +``` + +**After:** +```dart +final action = await showStreamDialog( + context: context, + builder: (_) => StreamMessageReactionsModal( + message: message, + messageWidget: messageWidget, + ), +); + +if (action is SelectReaction) { + _addReaction(action.reaction); +} +``` + +> **Important:** +> - The old `onReactionPicked` already received a `SelectReaction`, not a raw `Reaction` — the migration only changes *where* you handle it (caller vs callback) + +--- + +## ModeratedMessageActionsModal + +### Breaking Changes + +- `onActionTap: OnMessageActionTap?` parameter **removed** — move handling to `onTap` on each action or await the dialog return value +- `messageActions` parameter type changed from `List` to `List` + +### Migration + +**Before:** +```dart +ModeratedMessageActionsModal( + message: message, + messageActions: [ + StreamMessageAction( + action: ResendMessage(message: message), + title: Text(context.translations.sendAnywayLabel), + ), + StreamMessageAction( + action: EditMessage(message: message), + title: Text(context.translations.editMessageLabel), + ), + StreamMessageAction( + isDestructive: true, + action: HardDeleteMessage(message: message), + title: Text(context.translations.deleteMessageLabel), + ), + ], + onActionTap: (action) { + if (action is ResendMessage) _resend(action.message); + }, +) +``` + +**After (onTap per-action):** +```dart +ModeratedMessageActionsModal( + message: message, + messageActions: [ + StreamContextMenuAction( + value: ResendMessage(message: message), + label: Text(context.translations.sendAnywayLabel), + onTap: () => _resend(message), + ), + StreamContextMenuAction( + value: EditMessage(message: message), + label: Text(context.translations.editMessageLabel), + ), + StreamContextMenuAction.destructive( + value: HardDeleteMessage(message: message), + label: Text(context.translations.deleteMessageLabel), + ), + ], +) +``` + +**After (await return value):** +```dart +final action = await showStreamDialog( + context: context, + builder: (_) => ModeratedMessageActionsModal( + message: message, + messageActions: [ + StreamContextMenuAction( + value: ResendMessage(message: message), + label: Text(context.translations.sendAnywayLabel), + ), + // ... + ], + ), +); + +if (action is ResendMessage) _resend(action.message); +``` + +--- + +## StreamMessageItem.customActions + +### Breaking Change + +`customActions: List` has been **removed**. It is replaced by `actionsBuilder`: + +```dart +typedef MessageActionsBuilder = + List Function( + BuildContext context, + List> defaultActions, + ); +``` + +`StreamMessageItem.actionsBuilder` is declared as `MessageActionsBuilder?` (i.e. `MessageActionsBuilder?`), so `defaultActions` is typed as `List>` — each item's `.props.value` is a `MessageAction?`. + +The `defaultActions` list passed into the builder is already filtered by the widget's `show*` flags, so callers always start from a clean, ready-to-render baseline. + +`actionsBuilder` returns `List` — any widget can be mixed in alongside the default `StreamContextMenuAction` items (e.g. `StreamContextMenuSeparator`). + +### Migration + +**Before (append a custom action):** +```dart +StreamMessageItem( + message: message, + messageTheme: messageTheme, + customActions: [ + StreamMessageAction( + action: CustomMessageAction( + message: message, + extraData: const {'type': 'favourite'}, + ), + leading: const Icon(Icons.star), + title: Text('Favourite'), + ), + ], + onCustomActionTap: (CustomMessageAction action) { + _favourite(action.message); + }, +) +``` + +**After:** +```dart +StreamMessageItem( + message: message, + messageTheme: messageTheme, + actionsBuilder: (context, defaultActions) => [ + ...defaultActions, + StreamContextMenuAction( + leading: const Icon(Icons.star), + label: Text('Favourite'), + onTap: () => _favourite(message), + ), + ], +) +``` + +**After (remove an existing action and add a custom one):** +```dart +StreamMessageItem( + message: message, + messageTheme: messageTheme, + actionsBuilder: (context, defaultActions) => [ + ...defaultActions.where((a) => a.props.value is! DeleteMessage), + StreamContextMenuSeparator(), + StreamContextMenuAction( + leading: const Icon(Icons.star), + label: Text('Favourite'), + onTap: () => _favourite(message), + ), + ], +) +``` + +> **Important:** +> - `onCustomActionTap` is **removed** — put dispatch logic directly in `onTap` on each action +> - `actionsBuilder` receives the defaults **already** filtered by `show*` flags (e.g. `showDeleteMessage`) +> - When `actionsBuilder` is not provided, the default list is wrapped in `StreamContextMenuAction.partitioned` automatically + +--- + +## StreamMessageActionsBuilder + +### Breaking Changes + +Both static methods now return `List` instead of `List`. Additionally, the `customActions` parameter of `buildActions` has been **removed** — appending custom actions is now handled by `StreamMessageItem.actionsBuilder`. + +| Method / Parameter | Old type | New type | +|--------------------|----------|----------| +| `buildActions` return | `List` | `List` | +| `buildBouncedErrorActions` return | `List` | `List` | +| `buildActions(customActions:)` | `Iterable?` | **Removed** | + +### Migration + +**Before:** +```dart +final List actions = + StreamMessageActionsBuilder.buildActions( + context: context, + message: message, + channel: channel, + currentUser: currentUser, + customActions: myCustomStreamMessageActions, +); +``` + +**After:** +```dart +// buildActions no longer accepts customActions — add extras via actionsBuilder +final List> actions = + StreamMessageActionsBuilder.buildActions( + context: context, + message: message, + channel: channel, + currentUser: currentUser, +); +``` + +--- + +## New Components + +### showStreamDialog\ + +A top-level function from `package:stream_chat_flutter/stream_chat_flutter.dart` that replaces direct calls to `showDialog` when presenting Stream modals. It wraps `showGeneralDialog` and: + +- **Re-wraps `StreamChatTheme`** across the route boundary so the theme is available inside the dialog even when `useRootNavigator: true` +- **Applies a blur + scale transition** for a consistent Stream look +- **Returns `Future`** — the value passed to `Navigator.pop` inside the dialog, which is how `StreamMessageActionsModal`, `StreamMessageReactionsModal`, and `ModeratedMessageActionsModal` deliver the selected action back to the caller + +```dart +// Replace showDialog with showStreamDialog when presenting Stream modals: +final action = await showStreamDialog( + context: context, + builder: (_) => StreamMessageActionsModal(/* … */), +); +``` + +> **Note:** If you were calling `showDialog` directly and passing Stream modals to it, switch to `showStreamDialog` to ensure theming works correctly across the route boundary. + +### StreamContextMenuAction + +A self-contained menu action widget from `stream_core_flutter` that replaces `StreamMessageAction` + `StreamMessageActionItem`. It renders itself and supports two dispatch mechanisms: + +- **Inside a popup route** (dialog/bottom sheet): pops `value` via `Navigator.pop` first, then calls `onTap` if provided. +- **Inline** (outside any popup route): only `onTap` fires. + +```dart +// Standard action — value-based (recommended for modals) +StreamContextMenuAction( + value: CopyMessage(message: message), + leading: Icon(context.streamIcons.copy), + label: Text('Copy'), +) + +// With optional onTap callback (called after route dismissal) +StreamContextMenuAction( + value: CopyMessage(message: message), + leading: Icon(context.streamIcons.copy), + label: Text('Copy'), + onTap: () => _copyMessage(message), +) + +// Destructive action +StreamContextMenuAction.destructive( + value: DeleteMessage(message: message), + leading: Icon(context.streamIcons.trashBin), + label: Text('Delete'), +) +``` + +#### Helper methods for grouping + +```dart +// Insert a separator between every item +StreamContextMenuAction.separated(items: actions) + +// Insert separators between logical groups (provide groups as separate lists) +StreamContextMenuAction.sectioned(sections: [normalActions, destructiveActions]) + +// Automatically partition into normal / destructive groups with a separator between them +StreamContextMenuAction.partitioned(items: actions) +``` + +All three methods return `List` because they interleave `StreamContextMenuSeparator` widgets. + +### StreamContextMenu + +A themed container that wraps a list of `StreamContextMenuAction` and `StreamContextMenuSeparator` widgets. + +```dart +StreamContextMenu( + children: StreamContextMenuAction.partitioned(items: actions), +) +``` + +### StreamContextMenuSeparator + +A thin horizontal divider for use inside `StreamContextMenu`. + +```dart +StreamContextMenu( + children: [ + StreamContextMenuAction(value: reply, label: Text('Reply')), + const StreamContextMenuSeparator(), + StreamContextMenuAction.destructive(value: delete, label: Text('Delete')), + ], +) +``` + +--- + +## Migration Checklist + +- [ ] Replace all `StreamMessageAction(action: ..., title: ..., leading: ...)` with `StreamContextMenuAction(value: ..., label: ..., leading: ...)` +- [ ] Add a non-null `label` widget wherever `title` was previously omitted — `label` is now a required parameter and code that relied on a null/omitted `title` will not compile +- [ ] Update `onTap` callsites: old type was `void Function(MessageAction)`, new type is `VoidCallback?` — capture needed data in a closure +- [ ] Remove all `StreamMessageActionItem` usages +- [ ] Remove `onActionTap` from `StreamMessageActionsModal`; handle via per-action `onTap` or await the dialog return value +- [ ] Remove `onReactionPicked` from `StreamMessageReactionsModal`; await a `SelectReaction` return value +- [ ] Remove `onActionTap` from `ModeratedMessageActionsModal`; handle via per-action `onTap` or await the dialog return value +- [ ] Replace `StreamMessageItem.customActions` with `actionsBuilder` +- [ ] Update `StreamMessageActionsBuilder.buildActions` call sites — return type is now `List` and `customActions` parameter no longer exists +- [ ] Update `StreamMessageActionsBuilder.buildBouncedErrorActions` call sites — return type is now `List` +- [ ] Replace `StreamSvgIcon` leading widgets in custom actions with `Icon(context.streamIcons.*)` +- [ ] Replace per-action color/style properties (`iconColor`, `titleTextColor`, etc.) with `StreamContextMenuActionTheme` diff --git a/migrations/redesign/message_composer.md b/migrations/redesign/message_composer.md new file mode 100644 index 0000000000..7ba2c55014 --- /dev/null +++ b/migrations/redesign/message_composer.md @@ -0,0 +1,439 @@ +# Message Composer Migration Guide + +This guide covers the migration for the message composer components in the Stream Chat Flutter SDK design refresh. + +--- + +## Table of Contents + +- [Overview](#overview) +- [StreamMessageComposer](#streammessagecomposer) +- [StreamChatMessageInput (new)](#streamchatmessageinput-new) +- [Message Input Placeholder API](#message-input-placeholder-api) +- [Attachment Customization](#attachment-customization) +- [Migration Checklist](#migration-checklist) + +--- + +## Overview + +There are two distinct composer components with different responsibilities: + +| Component | Responsibility | +|-----------|---------------| +| `StreamMessageComposer` | Full-featured widget: handles sending, editing, attachments, autocomplete, mentions, commands, OG previews, voice recording flow, etc. | +| `StreamChatMessageInput` | UI-only component: renders the composer layout using design system primitives. No business logic. | + +`StreamMessageComposer` wraps `StreamChatMessageInput` for its visual layer. If you are using `StreamMessageComposer` today, it remains the right choice — it is not deprecated. `StreamChatMessageInput` exists for cases where you want to build your own message-sending logic and use the new design system UI. + +--- + +## StreamMessageComposer + +`StreamMessageComposer` handles all message composition logic. This section documents all breaking changes. + +### Breaking Change: `hideSendAsDm` renamed to `canAlsoSendToChannelFromThread` (logic inverted) + +| Old | New | +|-----|-----| +| `hideSendAsDm: true` | `canAlsoSendToChannelFromThread: false` | +| `hideSendAsDm: false` (old default) | `canAlsoSendToChannelFromThread: true` (new default) | + +The logic is **inverted**: the old parameter hid the "also send to channel" checkbox when `true`; the new parameter **shows** it when `true`. + +**Before:** +```dart +StreamMessageInput( + hideSendAsDm: true, // hide the "also send to channel" checkbox +) +``` + +**After:** +```dart +StreamMessageComposer( + canAlsoSendToChannelFromThread: false, // hide the checkbox +) +``` + +> **Note:** `canAlsoSendToChannelFromThread` defaults to `true`, matching the old default of showing the checkbox when inside a thread. + +### Breaking Change: `attachmentLimit` is now optional + +`attachmentLimit` changed from a required `int` (default `10`) to an optional `int?`. When `null` (the new default), no attachment count limit is enforced. + +**Before:** +```dart +StreamMessageInput( + attachmentLimit: 5, +) +``` + +**After (with limit):** +```dart +StreamMessageComposer( + attachmentLimit: 5, +) +``` + +**After (no limit — new default behaviour):** +```dart +StreamMessageComposer( + // attachmentLimit not set — no limit applied +) +``` + +### Removed parameters + +Many parameters that existed in `StreamMessageInput` have been removed from `StreamMessageComposer`. The table below lists each removed parameter and the recommended migration path. + +#### Layout and visual parameters + +These parameters have been removed. The composer layout is now fully owned by `StreamChatMessageInput` and its sub-components, customizable via `StreamComponentFactory`. + +| Removed parameter | Migration path | +|-------------------|---------------| +| `maxHeight` | No direct replacement. The text field grows to fit its content without a height cap. | +| `maxLines` | No direct replacement. | +| `minLines` | No direct replacement. | +| `padding` | No direct replacement. Layout is controlled by the design system. | +| `textInputMargin` | No direct replacement. | +| `elevation` | No direct replacement. Visual styling is controlled by the design system theme. | +| `shadow` | No direct replacement. | +| `enableActionAnimation` | Removed. Actions no longer animate in/out. | +| `contentInsertionConfiguration` | Removed. | +| `sendButtonLocation` | Removed. The send button is always placed in the trailing position by the design system layout. | + +#### Action and button parameters + +These parameters have been removed. To customize buttons and actions in the composer, override the relevant sub-component via `StreamComponentFactory`. + +| Removed parameter | Migration path | +|-------------------|---------------| +| `actionsBuilder` | Override `messageComposerLeading` or `messageComposerTrailing` in `StreamComponentFactory`. | +| `spaceBetweenActions` | No direct replacement. | +| `actionsLocation` | Removed. The design system defines a fixed layout. | +| `attachmentButtonBuilder` | Override `messageComposerLeading` in `StreamComponentFactory`. | +| `commandButtonBuilder` | Override `messageComposerInputTrailing` in `StreamComponentFactory`. | +| `sendButtonBuilder` | Override `messageComposerTrailing` in `StreamComponentFactory`. | +| `idleSendIcon` | Override `messageComposerTrailing` in `StreamComponentFactory`. | +| `activeSendIcon` | Override `messageComposerTrailing` in `StreamComponentFactory`. | +| `showCommandsButton` | Override `messageComposerInputTrailing` in `StreamComponentFactory`. | + +#### Attachment builder parameters + +These parameters have been removed. Attachment rendering in the composer input header is now customizable via `StreamComponentFactory` — see [Attachment Customization](#attachment-customization). + +| Removed parameter | Migration path | +|-------------------|---------------| +| `attachmentListBuilder` | Override `messageComposerAttachmentList` in `StreamComponentFactory`. | +| `fileAttachmentListBuilder` | Override `messageComposerAttachmentList` in `StreamComponentFactory`. | +| `mediaAttachmentListBuilder` | Override `messageComposerAttachmentList` in `StreamComponentFactory`. | +| `voiceRecordingAttachmentListBuilder` | Override `messageComposerAttachmentList` in `StreamComponentFactory`. | +| `fileAttachmentBuilder` | Override `messageComposerAttachment` in `StreamComponentFactory`. | +| `mediaAttachmentBuilder` | Override `messageComposerAttachment` in `StreamComponentFactory`. | +| `voiceRecordingAttachmentBuilder` | Override `messageComposerAttachment` in `StreamComponentFactory`. | +| `quotedMessageBuilder` | Override `messageComposerInputHeader` or `messageComposerInput` in `StreamComponentFactory`. | +| `quotedMessageAttachmentThumbnailBuilders` | Override `messageComposerInputHeader`, `messageComposerInput`, or `messageComposerAttachment` in `StreamComponentFactory`. | + +### Attachment button visibility + +Previously, the attachment button was always rendered (though inactive) when `disableAttachments: true` was set. The button is now fully hidden (removed from the layout) when no attachment callback is wired up. When you pass `disableAttachments: true` to `StreamMessageComposer`, the attachment button no longer appears at all. + +If you are using `StreamChatMessageInput` directly, the button hides when `onAttachmentButtonPressed` is `null`. + +--- + +## StreamChatMessageInput (new) + +`StreamChatMessageInput` is a pure UI component from the new design system. It renders the composer layout but contains no message-sending logic — your code is responsible for wiring up the controller and callbacks. + +Use this when you want the new design system visuals with custom business logic. If you want the full out-of-the-box experience (send, edit, attachments, mentions, commands, etc.), use `StreamMessageComposer` instead. + +### Constructor Parameters + +| Parameter | Type | Default | Description | +|-----------|------|---------|-------------| +| `onSendPressed` | `VoidCallback` | **required** | Called when the send button is pressed | +| `controller` | `StreamMessageComposerController?` | `null` | Controller for the input; created internally if not provided | +| `onAttachmentButtonPressed` | `VoidCallback?` | `null` | Called when the attachment button is pressed. When `null`, the attachment button is hidden. | +| `isPickerOpen` | `bool` | `false` | Whether the inline attachment picker is currently open | +| `focusNode` | `FocusNode?` | `null` | Focus node for the text field | +| `currentUserId` | `String?` | `null` | Current user's ID | +| `placeholder` | `String?` | `null` | Placeholder text for the input field. `StreamChatMessageInput` is a pure UI component — when wiring it up directly, compute this string yourself (use `MessageInputPlaceholder.resolve(controller)` from the [Message Input Placeholder API](#message-input-placeholder-api) if you want the built-in state machine), or pass `null` for no placeholder. `StreamMessageComposer` resolves it for you reactively from its controller. | +| `audioRecorderController` | `StreamAudioRecorderController?` | `null` | Enables the voice recording UI when provided | +| `sendVoiceRecordingAutomatically` | `bool` | `false` | Sends the voice recording immediately on finish | +| `feedback` | `AudioRecorderFeedback` | `const AudioRecorderFeedback()` | Haptic/audio feedback callbacks for the recording flow | +| `canAlsoSendToChannel` | `bool` | `false` | Shows the "also send to channel" checkbox (used in threads) | +| `onQuotedMessageCleared` | `VoidCallback?` | `null` | Called when the user removes the quoted message in the input header | +| `textInputAction` | `TextInputAction?` | `null` | The keyboard action button type | +| `keyboardType` | `TextInputType?` | `null` | The keyboard type for the text field | +| `textCapitalization` | `TextCapitalization` | `sentences` | Text capitalization behaviour for the text field | +| `autofocus` | `bool` | `false` | Whether the text field should autofocus when built | +| `autocorrect` | `bool` | `true` | Whether autocorrect is enabled | + +### Sub-components + +The layout is composed of named default sub-widgets that can be replaced via the `StreamComponentFactory`. Use the factory builder keys below to override any slot; the public default class (where one exists) can be referenced when you want to call the built-in implementation from inside a custom override. + +| Factory builder key | Description | Public default class | +|---------------------|-------------|----------------------| +| `messageComposerLeading` | Left side of the composer row (e.g., attachment button) | `DefaultStreamMessageComposerLeading` | +| `messageComposerTrailing` | Right side of the composer row (empty by default; add a custom widget here to extend the outer row) | — | +| `messageComposerInput` | The whole input container (assembles header, leading, center, and trailing) | `DefaultStreamMessageComposerInput` | +| `messageComposerInputLeading` | Left side inside the input area (empty by default) | — | +| `messageComposerInputCenter` | The actual text field area (text input or audio recording UI) | `DefaultStreamMessageComposerInputCenter` | +| `messageComposerInputTrailing` | Right side inside the input area (send/mic button) | `DefaultStreamMessageComposerInputTrailing` | +| `messageComposerInputHeader` | Header above the input (reply/edit preview, attachment thumbnails) | — | + +### Customization via Component Factory + +To replace the entire composer UI, provide a builder for `MessageComposerProps` in your `StreamComponentFactory`: + +```dart +StreamComponentFactory( + builders: StreamComponentBuilders( + extensions: streamChatComponentBuilders( + messageComposer: (context, props) => MyCustomComposer(props: props), + ), + ), + child: ..., +) +``` + +--- + +## Message Input Placeholder API + +The input placeholder text (the dimmed text shown inside the input field when it is empty) is now driven by a sealed-class hierarchy that adapts to the current input state. The previous `HintType` enum and `HintGetter` typedef have been removed, and the customization hook on `StreamMessageComposer` is now called `placeholderBuilder`. + +The new placeholder types live in `lib/src/message_input/message_input_placeholder.dart` and are re-exported from `package:stream_chat_flutter/stream_chat_flutter.dart`. + +> **Layered model.** The placeholder *resolution* (state machine that turns controller state into a string) lives on `StreamMessageComposer`, the higher-level full-featured widget. The lower-level `StreamChatMessageInput` design-system component stays a pure UI primitive and accepts a plain `String placeholder` — see [StreamChatMessageInput (new)](#streamchatmessageinput-new). If you build directly on `StreamChatMessageInput`, call `MessageInputPlaceholder.resolve(controller)` and your own builder yourself, then pass the resulting string in. + +### What was removed + +| Removed | Replacement | +|---------|-------------| +| `enum HintType` (`searchGif`, `addACommentOrSend`, `slowModeOn`, `command`, `writeAMessage`) | `sealed class MessageInputPlaceholder` with `final` cases `WriteMessagePlaceholder`, `SlowModePlaceholder`, `CommandPlaceholder`, `AttachmentsPlaceholder` (each carries the contextual data for that state — see [Sealed-class state shape](#sealed-class-state-shape)) | +| `typedef HintGetter = String? Function(BuildContext, HintType, Command?)` | `typedef MessageInputPlaceholderBuilder = String? Function(BuildContext, MessageInputPlaceholder)` | +| `HintType resolveMessageInputHintType(controller)` | `MessageInputPlaceholder.resolve(controller)` factory | +| `Command? resolveActiveMessageInputCommand(context, controller)` | Removed. Use `controller.message.command` (a `String?`) directly. The SDK no longer looks up the full `Command` object from the channel config when resolving the placeholder. | +| `String? defaultMessageInputHintGetter(...)` | Removed from the public API. The default behaviour is now baked into `StreamMessageComposer.placeholderBuilder`'s default value. To customize, supply your own builder with an exhaustive `switch` over [`MessageInputPlaceholder`](#sealed-class-state-shape). | +| `StreamMessageInput.hintGetter` | `StreamMessageComposer.placeholderBuilder` | + +### Behavior change: precedence + +The order in which input states are evaluated to pick a placeholder has changed: + +| | Old order | New order | +|---|---|---| +| 1 | `command` | `slowMode` | +| 2 | `attachments` | `command` | +| 3 | `slowMode` | `attachments` | +| 4 | `writeMessage` | `writeMessage` | + +When slow mode is active and a command is also active (or attachments are present), the slow-mode placeholder now wins. This matches the iOS SDK. To restore the old order, override `placeholderBuilder` and short-circuit on `CommandPlaceholder` / `AttachmentsPlaceholder` before falling back to the default. + +### Behavior change: default placeholders for built-in commands + +The default builder now renders dedicated placeholders for Stream's built-in user-target commands, matching the redesigned Figma: + +| Command | Placeholder (English) | Localization key | +|---------|----------------------|-------------------| +| `/giphy` | `Search GIFs` | `searchGifLabel` | +| `/mute`, `/unmute`, `/ban`, `/unban` | `@username` | `commandUsernameLabel` (new) | +| Any other backend command | `Send a message` | `writeAMessageLabel` | + +`commandUsernameLabel` is a new translation key — see the [Localizations migration guide](localizations.md) if you have a custom `Translations` subclass. + +> **Note:** The previous default fell back to `Command.args` (the server-provided argument template, e.g. `[@username] [text]`) for unknown commands. The new default uses `writeAMessageLabel`. If you want command-aware placeholders for backend-defined custom commands, override `placeholderBuilder` and pattern-match on `CommandPlaceholder.command`. + +### Behavior change: default placeholder for pending attachments + +The default builder no longer uses `addACommentOrSendLabel` ("Add a comment or send") when the input only has attachments and no text — it now falls back to `writeAMessageLabel` ("Send a message"), matching the empty/idle state. The `addACommentOrSendLabel` translation key is still part of the public `Translations` interface, so to restore the old behaviour override `placeholderBuilder` and map `AttachmentsPlaceholder()` to `translations.addACommentOrSendLabel` yourself. + +### Sealed-class state shape + +Each case carries the contextual data relevant to that input state. Pattern-match on these fields in your `placeholderBuilder` to render rich, state-aware placeholders. + +| Case | Field | Type | Description | +|------|-------|------|-------------| +| `WriteMessagePlaceholder` | `isEditing` | `bool` | `true` when the input is editing an existing message instead of composing a new one. Useful for swapping the placeholder while editing. | +| `SlowModePlaceholder` | `cooldownTimeOut` | `int` | Remaining slow-mode cooldown in seconds. Mirrors `StreamMessageComposerController.cooldownTimeOut`. | +| `SlowModePlaceholder` | `cooldown` | `Duration` | Convenience getter wrapping `cooldownTimeOut` for formatting timer strings. | +| `CommandPlaceholder` | `command` | `String` | Active command name (e.g. `'giphy'`, `'mute'`, `'ban'`, or any backend-defined command). | +| `AttachmentsPlaceholder` | `attachments` | `List` | Pending attachments held by the input. OG link previews are still included — filter via `Attachment.ogScrapeUrl` if you only want user-added ones. | + +Example using the new fields (note that the sealed type forces an exhaustive switch — every case must be handled): + +```dart +StreamMessageComposer( + placeholderBuilder: (context, placeholder) { + final translations = context.translations; + return switch (placeholder) { + SlowModePlaceholder(:final cooldownTimeOut) => + 'Slow mode on – ${cooldownTimeOut}s left', + CommandPlaceholder(command: 'giphy') => translations.searchGifLabel, + CommandPlaceholder(command: 'mute' || 'unmute' || 'ban' || 'unban') => + translations.commandUsernameLabel, + CommandPlaceholder() => translations.writeAMessageLabel, + AttachmentsPlaceholder(:final attachments) + when attachments.every((a) => a.type == AttachmentType.image) => + 'Add a comment to your photo', + AttachmentsPlaceholder(:final attachments) + when attachments.every((a) => a.type == AttachmentType.video) => + 'Add a comment to your video', + AttachmentsPlaceholder() => translations.addACommentOrSendLabel, + WriteMessagePlaceholder(isEditing: true) => 'Edit your message…', + WriteMessagePlaceholder() => translations.writeAMessageLabel, + }; + }, +) +``` + +### Migration + +**Before:** + +```dart +StreamMessageInput( + hintGetter: (context, type, command) { + return switch (type) { + HintType.searchGif => 'Search a GIF', + HintType.slowModeOn => 'Slow mode is on', + HintType.addACommentOrSend => 'Add a comment...', + HintType.command => command?.args ?? 'Type a message', + HintType.writeAMessage => 'Write a message', + }; + }, +) +``` + +**After:** + +```dart +StreamMessageComposer( + placeholderBuilder: (context, placeholder) { + return switch (placeholder) { + SlowModePlaceholder() => 'Slow mode is on', + CommandPlaceholder(command: 'giphy') => 'Search a GIF', + CommandPlaceholder(command: 'mute' || 'ban') => '@username', + CommandPlaceholder() => 'Type a message', + AttachmentsPlaceholder() => 'Add a comment...', + WriteMessagePlaceholder() => 'Write a message', + }; + }, +) +``` + +For backend-defined custom commands, pattern-match the relevant `CommandPlaceholder.command` values and use the SDK's localized labels for everything else: + +```dart +StreamMessageComposer( + placeholderBuilder: (context, placeholder) { + final translations = context.translations; + return switch (placeholder) { + SlowModePlaceholder() => translations.slowModeOnLabel, + CommandPlaceholder(command: 'weather') => 'Type a city name', + CommandPlaceholder(command: 'tip') => 'Type @user amount', + CommandPlaceholder(command: 'poll') => 'Type your question', + CommandPlaceholder(command: 'giphy') => translations.searchGifLabel, + CommandPlaceholder(command: 'mute' || 'unmute' || 'ban' || 'unban') => + translations.commandUsernameLabel, + CommandPlaceholder() => translations.writeAMessageLabel, + AttachmentsPlaceholder() => translations.addACommentOrSendLabel, + WriteMessagePlaceholder() => translations.writeAMessageLabel, + }; + }, +) +``` + +The exhaustive `switch` over `MessageInputPlaceholder` means adding a new case in a future SDK release will be a compile error rather than a silent fallthrough — your override stays explicit about which states it handles. + +--- + +## Attachment Customization + +The attachment thumbnails shown in the composer input header are now rendered by two new customizable widgets. Both integrate with `StreamComponentFactory`. + +### `StreamMessageComposerAttachmentList` + +Renders the full list of attachment thumbnails in the composer. The old `StreamMessageInputAttachmentList` class has been **deleted** — any direct reference to it will cause a compile error. Use `StreamMessageComposerAttachmentList` instead. + +**Props class:** `StreamMessageComposerAttachmentListProps` + +| Property | Type | Description | +|----------|------|-------------| +| `attachments` | `Iterable` | The attachments to display (OG link previews are filtered out before this widget receives them) | +| `onRemovePressed` | `ValueSetter?` | Called when the user removes an attachment | + +Override the whole list using the `messageComposerAttachmentList` builder key: + +```dart +streamChatComponentBuilders( + messageComposerAttachmentList: (context, props) { + return MyCustomAttachmentList( + attachments: props.attachments, + onRemovePressed: props.onRemovePressed, + ); + }, +) +``` + +### `StreamMessageComposerAttachment` + +Renders a single attachment thumbnail inside the list. Use this to customise how individual attachment types are displayed without replacing the whole list. + +**Props class:** `StreamMessageComposerAttachmentProps` + +| Property | Type | Description | +|----------|------|-------------| +| `attachment` | `Attachment` | The attachment to render | +| `onRemovePressed` | `ValueSetter?` | Called when the user taps the remove button | +| `audioPlaylistController` | `StreamAudioPlaylistController?` | Shared playlist controller for audio/voice-recording attachments | + +Override individual attachment items using the `messageComposerAttachment` builder key: + +```dart +streamChatComponentBuilders( + messageComposerAttachment: (context, props) { + // Render video attachments differently; fall back to default for everything else. + if (props.attachment.type == AttachmentType.video) { + return MyVideoAttachmentThumbnail( + attachment: props.attachment, + onRemovePressed: props.onRemovePressed, + ); + } + return DefaultMessageComposerAttachment(props: props); + }, +) +``` + +### Built-in attachment builder helpers + +The following public widgets are provided as building blocks for custom attachment renderers: + +| Widget | Description | +|--------|-------------| +| `StreamAudioAttachmentBuilder` | Renders an audio or voice-recording attachment with playback controls | +| `StreamFileAttachmentBuilder` | Renders a generic file attachment with file type icon, name, and size | +| `StreamMediaAttachmentBuilder` | Renders an image, video, or GIF attachment thumbnail with an optional media badge | +| `RemoveAttachmentButton` | The standard filled icon button used to dismiss an attachment | + +--- + +## Migration Checklist + +- [ ] Rename `StreamMessageInput` to `StreamMessageComposer` in all usages +- [ ] Rename `hideSendAsDm` to `canAlsoSendToChannelFromThread` in all `StreamMessageComposer` usages and invert the value +- [ ] Review usages of `attachmentLimit` — it is now `int?` and defaults to no limit; set an explicit value if you relied on the old default of `10` +- [ ] Remove any usage of `maxHeight`, `maxLines`, `minLines`, `padding`, `textInputMargin`, `elevation`, `shadow`, `enableActionAnimation`, `contentInsertionConfiguration`, `sendButtonLocation` +- [ ] Replace `actionsBuilder` / `actionsLocation` / button builder params (`attachmentButtonBuilder`, `commandButtonBuilder`, `sendButtonBuilder`, `idleSendIcon`, `activeSendIcon`, `showCommandsButton`) with sub-component overrides via `StreamComponentFactory` +- [ ] Replace attachment list builder params (`attachmentListBuilder`, `fileAttachmentListBuilder`, `mediaAttachmentListBuilder`, `voiceRecordingAttachmentListBuilder`) with the `messageComposerAttachmentList` builder in `StreamComponentFactory` +- [ ] Replace attachment item builder params (`fileAttachmentBuilder`, `mediaAttachmentBuilder`, `voiceRecordingAttachmentBuilder`) with the `messageComposerAttachment` builder in `StreamComponentFactory` +- [ ] Replace `quotedMessageBuilder` / `quotedMessageAttachmentThumbnailBuilders` with `messageComposerInputHeader` or `messageComposerAttachment` overrides in `StreamComponentFactory` +- [ ] If adopting `StreamChatMessageInput` directly, wire up your own send/attachment logic via `onSendPressed` and `onAttachmentButtonPressed` +- [ ] Move any composer UI customizations to `StreamComponentFactory` +- [ ] Rename `StreamMessageInput.hintGetter` to `placeholderBuilder` and rewrite the callback to switch over `MessageInputPlaceholder` cases (`SlowModePlaceholder`, `CommandPlaceholder`, `AttachmentsPlaceholder`, `WriteMessagePlaceholder`) instead of the removed `HintType` enum. If you build directly on `StreamChatMessageInput`, compute the placeholder string yourself via `MessageInputPlaceholder.resolve(controller)` and pass it via the `placeholder: String` parameter. +- [ ] Review the new placeholder precedence (`slowMode > command > attachments > writeMessage`) and override `placeholderBuilder` if you need to preserve the old order +- [ ] Add command-specific placeholders for any backend-defined commands you ship by pattern-matching on `CommandPlaceholder.command` in your `placeholderBuilder` diff --git a/migrations/redesign/message_widget.md b/migrations/redesign/message_widget.md new file mode 100644 index 0000000000..abaff8606e --- /dev/null +++ b/migrations/redesign/message_widget.md @@ -0,0 +1,500 @@ +# Message Widget & Message List Migration Guide + +This guide covers migrating the message widget and message list view from the old design (`feat/design-refresh`) to the new redesigned API. + +--- + +## Table of Contents + +- [Quick Reference](#quick-reference) +- [Architecture Changes](#architecture-changes) +- [StreamMessageItem](#streammessageitem) + - [Removed Parameters](#removed-parameters) + - [New Parameters](#new-parameters) + - [Changed Signatures](#changed-signatures) +- [StreamMessageListView](#streammessagelistview) + - [Builder Signature Changes](#builder-signature-changes) + - [New List-Level Callbacks](#new-list-level-callbacks) + - [Removed: MessageDetails](#removed-messagedetails) +- [Custom Actions Migration](#custom-actions-migration) +- [Theme Migration](#theme-migration) +- [Swipeable Message Example](#swipeable-message-example) +- [Deleted Classes & Files](#deleted-classes--files) +- [Typedef Changes](#typedef-changes) +- [Migration Checklist](#migration-checklist) + +--- + +## Quick Reference + +| Old | New | +|-----|-----| +| `StreamMessageItem` (50+ params) | `StreamMessageItem` (thin shell) + `StreamMessageItemProps` | +| `MessageWidgetContent` | `DefaultStreamMessageItem` + `StreamMessageContent` | +| `BottomRow` | `StreamMessageFooter` | +| `StreamMessageText` (message_text.dart) | `StreamMessageText` (components/stream_message_text.dart) | +| `StreamDeletedMessage` | `StreamMessageDeleted` | +| `MessageCard` | `core.StreamMessageBubble` | +| `TextBubble` | `core.StreamMessageBubble` | +| `PinnedMessage` | `StreamMessageHeader` widget | +| `QuotedMessage` | Inline in `StreamMessageContent` | +| `Username` | Inline in `StreamMessageFooter` | +| `SendingIndicatorBuilder` | `StreamMessageSendingStatus` | +| `ThreadReplyPainter` | `core.StreamMessageReplies` | +| `ThreadParticipants` | Inline in `core.StreamMessageReplies` | +| `UserAvatarTransform` | `StreamUserAvatar` (inline in `DefaultStreamMessageItem`) | +| `DisplayWidget` enum | `StreamVisibility` (from theme) | +| `MessageBuilder` typedef | `StreamMessageItemBuilder` typedef | +| `ParentMessageBuilder` typedef | `StreamMessageItemBuilder` typedef | +| `OnQuotedMessageTap = void Function(String?)` | `void Function(Message quotedMessage)` | +| `StreamMessageItem.customActions` | `StreamMessageItemProps.actionsBuilder` | +| `StreamMessageItem.onCustomActionTap` | Use `onTap` per `StreamContextMenuAction` | +| `CustomMessageAction` | Removed — use `StreamContextMenuAction` with `onTap` | +| `StreamMessageItem.copyWith()` | `StreamMessageItemProps.copyWith()` | + +--- + +## Architecture Changes + +The old design used a single monolithic `StreamMessageItem` with 50+ parameters controlling every aspect of rendering. The new design splits responsibilities: + +- **`StreamMessageItem`** — thin shell that resolves the `StreamComponentFactory` and delegates to the factory builder or `DefaultStreamMessageItem`. +- **`StreamMessageItemProps`** — plain data class holding all configuration. Supports `copyWith()`. +- **`DefaultStreamMessageItem`** — the default rendering implementation. Composes the sub-components below. +- **`StreamMessageContent`** — bubble, attachments, text, reactions. Thread replies are passed in as a pre-built widget from `DefaultStreamMessageItem`. +- **`StreamMessageFooter`** — username, timestamp, sending status, edited indicator. +- **`StreamMessageHeader`** — pinned, saved-for-later, show-in-channel annotations. +- **`StreamUserAvatar`** — author avatar (inline in `DefaultStreamMessageItem`). +- **`StreamMessageReactions`** — clustered reaction chips around the bubble. +- **`StreamMessageText`** — markdown-rendered message text. +- **`StreamMessageDeleted`** — deleted message placeholder. +- **`StreamMessageSendingStatus`** — delivery status icon. + +### Component Factory Pattern + +The new design adds a **component factory** layer for app-wide customization. The `messageBuilder` / `parentMessageBuilder` callbacks on `StreamMessageListView` are still supported for per-list customization. + +**App-wide customization via component factory:** +```dart +StreamChat( + client: client, + componentBuilders: StreamComponentBuilders( + extensions: streamChatComponentBuilders( + messageItem: (context, props) { + return DefaultStreamMessageItem( + props: props.copyWith( + actionsBuilder: (context, defaultActions) { + return [...defaultActions, myCustomAction]; + }, + ), + ); + }, + ), + ), + child: ..., +) +``` + +**Per-list customization via `messageBuilder` (still supported):** +```dart +StreamMessageListView( + messageBuilder: (context, message, defaultProps) { + return StreamMessageItem.fromProps(props: defaultProps); + }, +) +``` + +Both can be combined — the component factory applies first, then the per-list `messageBuilder` can further customize or wrap the result. + +--- + +## StreamMessageItem + +### Removed Parameters + +These parameters have been removed entirely. See the **Migration Path** column for how to achieve the same result. + +#### Visibility Booleans + +| Old Parameter | Migration Path | +|---|---| +| `showReactions` | Controlled via `StreamMessageItemThemeData` visibility | +| `showDeleteMessage` | Controlled via channel permissions (`canDeleteOwnMessage`, `canDeleteAnyMessage`) | +| `showEditMessage` | Controlled via channel permissions (`canUpdateOwnMessage`, `canUpdateAnyMessage`) | +| `showReplyMessage` | Controlled via channel permissions (`canSendReply`) | +| `showThreadReplyMessage` | Controlled via channel permissions (`canSendReply`) | +| `showMarkUnreadMessage` | Shown automatically when applicable | +| `showResendMessage` | Shown automatically for failed messages | +| `showCopyMessage` | Shown automatically when message has text | +| `showFlagButton` | Controlled via channel permissions (`canFlagMessage`) | +| `showPinButton` | Controlled via channel permissions (`canPinMessage`) | +| `showPinHighlight` | Controlled via `StreamMessageItemThemeData` background color | +| `showReactionPicker` | Removed | +| `showUsername` | Controlled via `StreamMessageItemThemeData.metadataVisibility` | +| `showTimestamp` | Controlled via `StreamMessageItemThemeData.metadataVisibility` | +| `showEditedLabel` | Controlled via `StreamMessageItemThemeData.metadataVisibility` | +| `showSendingIndicator` | Controlled via `StreamMessageItemThemeData.metadataVisibility` | +| `showThreadReplyIndicator` | Controlled via `StreamMessageItemThemeData.repliesVisibility` | +| `showInChannelIndicator` | Shown automatically via `StreamMessageHeader` | +| `showUserAvatar` (`DisplayWidget`) | Controlled via `StreamMessageItemThemeData.avatarVisibility` | + +#### Builder Callbacks + +| Old Parameter | Migration Path | +|---|---| +| `userAvatarBuilder` | Use component factory to replace `DefaultStreamMessageItem` | +| `textBuilder` | Use component factory to replace `StreamMessageContent` | +| `quotedMessageBuilder` | Use component factory to replace `StreamMessageContent` | +| `deletedMessageBuilder` | Use component factory to replace `StreamMessageContent` | +| `editMessageInputBuilder` | Removed; use `onEditMessageTap` callback instead | +| `bottomRowBuilderWithDefaultWidget` | Use component factory; `StreamMessageFooter` is the new equivalent | +| `reactionPickerBuilder` | Configured globally via `StreamChatConfigurationData.reactionIconResolver` | +| `reactionIndicatorBuilder` | Replaced by `StreamMessageReactions` component | + +#### Shape & Style + +| Old Parameter | Migration Path | +|---|---| +| `shape` | Controlled via `StreamMessageBubble` theming in `stream_core_flutter` | +| `borderSide` | Controlled via `StreamMessageBubble` theming | +| `borderRadiusGeometry` | Controlled via `StreamMessageBubble` theming | +| `attachmentShape` | Controlled via attachment builder theming | +| `textPadding` | Controlled via `StreamMessageBubble` content padding theming | +| `attachmentPadding` | Configured internally by `StreamMessageAttachments` | +| `messageTheme` | Resolved from context via `StreamMessageItemTheme.of(context)` | + +#### Other Removed Parameters + +| Old Parameter | Migration Path | +|---|---| +| `reverse` | Determined by `StreamMessagePlacement` context (set by list view) | +| `translateUserAvatar` | Removed; avatar positioning is theme-driven | +| `onConfirmDeleteTap` | Handled internally by `StreamMessageActionsBuilder` | +| `onShowMessage` | Removed | +| `onReactionsHover` | Removed | +| `customActions` | Use `actionsBuilder` on `StreamMessageItemProps` | +| `onCustomActionTap` | Use `actionsBuilder` on `StreamMessageItemProps` | +| `onAttachmentTap` | Handle in custom attachment builders | +| `imageAttachmentThumbnailSize` | Configured in attachment builders | +| `imageAttachmentThumbnailResizeType` | Configured in attachment builders | +| `imageAttachmentThumbnailCropType` | Configured in attachment builders | +| `attachmentActionsModalBuilder` | Configured in attachment builders | +| `attachmentBuilders` | Moved to `StreamChatConfigurationData.attachmentBuilders` (still overridable per-message via `StreamMessageItemProps.attachmentBuilders`) | +| `copyWith()` on `StreamMessageItem` | Use `StreamMessageItemProps.copyWith()` instead | + +### New Parameters + +| New Parameter | Description | +|---|---| +| `padding` | Outer padding around the message item (overrides theme) | +| `spacing` | Horizontal spacing between avatar and content (overrides theme) | +| `backgroundColor` | Background color for the message row (overrides theme) | +| `maxWidth` | Max content width in logical pixels (default: `264`) | +| `onMessageLinkTap` | `void Function(Message, String)` — receives message and URL | +| `onUserMentionTap` | `void Function(User)` — receives the mentioned user | +| `onQuotedMessageTap` | `void Function(Message)` — receives the quoted message object | +| `onReactionsTap` | `void Function(Message)` — overrides default reaction detail sheet | +| `reactionSorting` | `Comparator` for reaction display order | +| `actionsBuilder` | `MessageActionsBuilder` for customizing the actions list | +| `onMessageActions` | Override the default long-press modal entirely | +| `onBouncedErrorMessageActions` | Override the bounced-error modal entirely | +| `onEditMessageTap` | Called when edit action is selected | + +### Changed Signatures + +| Callback | Old Signature | New Signature | +|---|---|---| +| Link tap | `void Function(String url)` | `void Function(Message message, String url)` | +| Mention tap | `void Function(User user)` | `void Function(User user)` (renamed: `onMentionTap` → `onUserMentionTap`) | +| Quoted message tap | `void Function(String? quotedMessageId)` | `void Function(Message quotedMessage)` | +| Thread tap | `void Function(Message message)` | `void Function(Message message)` (unchanged signature, renamed: `onThreadTap`) | +| Reply tap | `void Function(Message message)` | `void Function(Message message)` (new: `onReplyTap`) | + +--- + +## StreamMessageListView + +### Builder Signature Changes + +Both `messageBuilder` and `parentMessageBuilder` now use the same typedef: + +**Before:** +```dart +typedef MessageBuilder = Widget Function( + BuildContext context, + MessageDetails details, + List messages, + StreamMessageItem defaultMessageWidget, +); + +typedef ParentMessageBuilder = Widget Function( + BuildContext context, + Message? parentMessage, + StreamMessageItem defaultMessageWidget, +); +``` + +**After:** +```dart +typedef StreamMessageItemBuilder = Widget Function( + BuildContext context, + Message message, + StreamMessageItemProps defaultProps, +); +``` + +The old builders received a pre-built `StreamMessageItem` that you could `copyWith`. The new builders receive `StreamMessageItemProps` — raw configuration data. Use `StreamMessageItem.fromProps(props:)` to build the default widget through the component factory. + +**Before:** +```dart +StreamMessageListView( + messageBuilder: (context, details, messages, defaultWidget) { + return defaultWidget.copyWith(showReactions: false); + }, +) +``` + +**After:** +```dart +StreamMessageListView( + messageBuilder: (context, message, defaultProps) { + // Build default widget (goes through component factory) + return StreamMessageItem.fromProps(props: defaultProps); + + // Or customize props before building + return StreamMessageItem.fromProps( + props: defaultProps.copyWith( + actionsBuilder: (context, actions) => [...actions, myAction], + ), + ); + + // Or replace entirely + return MyCustomMessageWidget(message: message); + }, +) +``` + +> **Important:** The `messageBuilder` callback now receives a `BuildContext` that has `StreamMessagePlacement` in its ancestor chain. You can call `StreamMessagePlacement.alignmentDirectionalOf(context)` to determine message alignment. + +### New List-Level Callbacks + +These callbacks were previously only configurable per-message on `StreamMessageItem`. They are now available at the list level and forwarded to all messages: + +| New Parameter | Type | +|---|---| +| `onEditMessageTap` | `void Function(Message)?` | +| `onReplyTap` | `void Function(Message)?` | +| `onUserAvatarTap` | `void Function(User)?` | +| `onReactionsTap` | `void Function(Message)?` | +| `onQuotedMessageTap` | `void Function(Message)?` | +| `onMessageLinkTap` | `void Function(Message, String)?` | +| `onUserMentionTap` | `void Function(User)?` | + +### Changed: `showUnreadCountOnScrollToBottom` Default + +```dart +// Old +showUnreadCountOnScrollToBottom: false + +// New +showUnreadCountOnScrollToBottom: true +``` + +### Removed: MessageDetails + +The old `messageBuilder` received `MessageDetails` which contained `userId`, `message`, `messages`, and `index`. The new builder receives just `Message` and `StreamMessageItemProps`. The user ID is accessible via `StreamChat.of(context).currentUser?.id`. Message alignment is provided by `StreamMessagePlacement.of(context)`. + +--- + +## Custom Actions Migration + +**Before (using `customActions` + `onCustomActionTap`):** +```dart +StreamMessageItem( + message: message, + messageTheme: theme, + customActions: [ + StreamMessageAction( + leading: Icon(Icons.info), + title: Text('Info'), + onTap: (message) => showInfo(message), + ), + ], + onCustomActionTap: (action) { + // handle CustomMessageAction + }, +) +``` + +**After (using `actionsBuilder` via component factory):** +```dart +StreamChat( + client: client, + componentBuilders: StreamComponentBuilders( + extensions: streamChatComponentBuilders( + messageItem: (context, props) { + return DefaultStreamMessageItem( + props: props.copyWith( + actionsBuilder: (context, defaultActions) { + return StreamContextMenuAction.partitioned( + items: [ + ...defaultActions, + StreamContextMenuAction( + leading: Icon(context.streamIcons.informationCircle), + label: Text('Info'), + onTap: () => showInfo(props.message), + ), + ], + ); + }, + ), + ); + }, + ), + ), + child: ..., +) +``` + +**After (removing a default action):** +```dart +actionsBuilder: (context, defaultActions) { + return StreamContextMenuAction.partitioned( + items: defaultActions.where( + (a) => a.props.value is! DeleteMessage, + ).toList(), + ); +}, +``` + +> **Important:** +> - `customActions` and `onCustomActionTap` are removed +> - `CustomMessageAction` class is removed — use `StreamContextMenuAction` with `onTap` +> - `actionsBuilder` receives defaults already filtered by channel permissions +> - Return `List` — you can mix `StreamContextMenuAction` and `StreamContextMenuSeparator` + +--- + +## Theme Migration + +**Before (explicit `messageTheme` parameter):** +```dart +StreamMessageItem( + message: message, + messageTheme: isMyMessage + ? streamTheme.ownMessageTheme + : streamTheme.otherMessageTheme, +) +``` + +**After (theme resolved automatically from context):** +```dart +StreamMessageItem(message: message) +``` + +`StreamMessageItemTheme` is provided by `StreamChatTheme` and resolved based on `StreamMessagePlacement` (alignment, stack position, etc.). + +### StreamMessageItemThemeData + +The old per-property visibility booleans are replaced by a structured visibility system: + +```dart +StreamMessageItemThemeData( + avatarVisibility: StreamMessageStyleVisibility( + incoming: StreamVisibility.visible, + outgoing: StreamVisibility.gone, + ), + annotationVisibility: StreamMessageStyleVisibility(...), + metadataVisibility: StreamMessageStyleVisibility(...), + repliesVisibility: StreamMessageStyleVisibility(...), + + incoming: StreamMessageItemStyle( + padding: EdgeInsets.all(4), + backgroundColor: Colors.white, + ), + outgoing: StreamMessageItemStyle( + padding: EdgeInsets.all(4), + backgroundColor: Colors.blue.shade50, + ), +) +``` + +--- + +## Swipeable Message Example + +```dart +StreamMessageListView( + messageBuilder: (context, message, defaultProps) { + final defaultWidget = StreamMessageItem.fromProps(props: defaultProps); + + if (message.isDeleted || message.state.isFailed) return defaultWidget; + + final alignment = StreamMessagePlacement.alignmentDirectionalOf(context); + final isEnd = alignment == AlignmentDirectional.centerEnd; + + return Swipeable( + key: ValueKey(message.id), + direction: isEnd ? SwipeDirection.endToStart : SwipeDirection.startToEnd, + swipeThreshold: 0.2, + onSwiped: (_) => onReply(message), + child: defaultWidget, + ); + }, +) +``` + +--- + +## Deleted Classes & Files + +| Old File | Old Class | Replacement | +|---|---|---| +| `message_widget_content.dart` | `MessageWidgetContent` | `DefaultStreamMessageItem` + `StreamMessageContent` | +| `message_widget_content_components.dart` | Various internal helpers | Merged into `components/` sub-widgets | +| `bottom_row.dart` | `BottomRow` | `StreamMessageFooter` | +| `message_text.dart` | `StreamMessageText` | `components/stream_message_text.dart` | +| `deleted_message.dart` | `StreamDeletedMessage` | `StreamMessageDeleted` | +| `message_card.dart` | `MessageCard` | `core.StreamMessageBubble` | +| `text_bubble.dart` | `TextBubble` | `core.StreamMessageBubble` | +| `pinned_message.dart` | `PinnedMessage` | `StreamMessageHeader` widget | +| `quoted_message.dart` | `QuotedMessage` | Inline in `StreamMessageContent` | +| `thread_painter.dart` | `ThreadReplyPainter` | `core.StreamMessageReplies` | +| `thread_participants.dart` | `ThreadParticipants` | Inline in `core.StreamMessageReplies` | +| `user_avatar_transform.dart` | `UserAvatarTransform` | `StreamUserAvatar` (inline in `DefaultStreamMessageItem`) | +| `username.dart` | `Username` | Inline in `StreamMessageFooter` | +| `sending_indicator_builder.dart` | `SendingIndicatorBuilder` | `StreamMessageSendingStatus` | + +--- + +## Typedef Changes + +| Old Typedef | New Typedef | +|---|---| +| `MessageBuilder = Widget Function(BuildContext, MessageDetails, List, StreamMessageItem)` | `StreamMessageItemBuilder = Widget Function(BuildContext, Message, StreamMessageItemProps)` | +| `ParentMessageBuilder = Widget Function(BuildContext, Message?, StreamMessageItem)` | `StreamMessageItemBuilder` (same as above) | +| `OnQuotedMessageTap = void Function(String?)` | Removed — use `void Function(Message)` directly | +| — | `MessageActionsBuilder = List Function(BuildContext, List>)` (new) | + +> **Note:** `MessageBuilder` and `ParentMessageBuilder` are removed from `typedefs.dart`. The new `StreamMessageItemBuilder` is defined in `message_list_view.dart` and exported via the barrel file. + +--- + +## Migration Checklist + +- [ ] Replace `StreamMessageItem(message:, messageTheme:, ...)` with `StreamMessageItem(message:)` — theme is now resolved from context +- [ ] Remove all `show*` boolean parameters — visibility is now controlled via `StreamMessageItemThemeData` and channel permissions +- [ ] Remove `customActions` and `onCustomActionTap` — use `actionsBuilder` via component factory or `StreamMessageItemProps.copyWith()` +- [ ] Remove all per-widget builder callbacks (`userAvatarBuilder`, `textBuilder`, `quotedMessageBuilder`, `deletedMessageBuilder`, `bottomRowBuilderWithDefaultWidget`, `reactionPickerBuilder`, `reactionIndicatorBuilder`) — use component factory instead +- [ ] Remove `shape`, `borderSide`, `borderRadiusGeometry`, `attachmentShape`, `textPadding`, `attachmentPadding` — controlled via `StreamMessageBubble` theming +- [ ] Remove `reverse` — determined by `StreamMessagePlacement` context +- [ ] Remove `translateUserAvatar` — avatar positioning is theme-driven +- [ ] Update `messageBuilder` / `parentMessageBuilder` callbacks to new `StreamMessageItemBuilder` signature +- [ ] Replace `MessageDetails` usage — use `StreamMessagePlacement.of(context)` for alignment, `StreamChat.of(context).currentUser` for user ID +- [ ] Update `onLinkTap` to `onMessageLinkTap` with new signature `void Function(Message, String)` +- [ ] Update `onMentionTap` to `onUserMentionTap` +- [ ] Update `onQuotedMessageTap` from `void Function(String?)` to `void Function(Message)` +- [ ] Replace `StreamDeletedMessage` with `StreamMessageDeleted` +- [ ] Replace `StreamMessageAction` with `StreamContextMenuAction` (see [message_actions.md](message_actions.md)) +- [ ] Replace `StreamSvgIcon(icon: StreamSvgIcons.*)` with `Icon(context.streamIcons.*)` +- [ ] Remove `StreamMessageItem.copyWith()` usage — use `StreamMessageItemProps.copyWith()` instead diff --git a/migrations/redesign/reaction_list.md b/migrations/redesign/reaction_list.md new file mode 100644 index 0000000000..df9539beb3 --- /dev/null +++ b/migrations/redesign/reaction_list.md @@ -0,0 +1,173 @@ +# Reaction List Migration Guide + +This guide covers the new reaction list controller, view, and detail sheet introduced in the Stream Chat Flutter SDK design refresh. + +--- + +## Table of Contents + +- [StreamReactionListController](#streamreactionlistcontroller) +- [StreamReactionListView](#streamreactionlistview) +- [ReactionDetailSheet](#reactiondetailsheet) +- [Migration Checklist](#migration-checklist) + +--- + +## StreamReactionListController + +`StreamReactionListController` is a new controller in `stream_chat_flutter_core` for fetching and paginating reactions for a message. It extends `PagedValueNotifier`, following the same pattern as `StreamChannelListController` and other list controllers. + +### Constructor + +```dart +StreamReactionListController({ + required StreamChatClient client, + required String messageId, + Filter? filter, + SortOrder? sort, + int limit = 25, // defaultReactionPagedLimit +}) +``` + +| Parameter | Type | Default | Description | +|-----------|------|---------|-------------| +| `client` | `StreamChatClient` | **required** | The Stream chat client | +| `messageId` | `String` | **required** | ID of the message to load reactions for | +| `filter` | `Filter?` | `null` | Query filter; supports fields `type`, `user_id`, `created_at` | +| `sort` | `SortOrder?` | `null` | Sort order; only `created_at` is backend-supported (`ReactionSortKey.createdAt`) | +| `limit` | `int` | `25` | Page size | + +### Methods + +| Method | Description | +|--------|-------------| +| `doInitialLoad()` | Loads the first page of reactions | +| `loadMore(String? nextPageKey)` | Loads the next page using cursor-based pagination | +| `refresh({bool resetValue = true})` | Reloads from the beginning; resets active filter/sort to constructor values when `resetValue` is `true` | + +### Runtime Filter / Sort Changes + +You can update `filter` and `sort` at runtime (e.g., when the user taps a reaction-type tab) and then call `doInitialLoad()` to reload: + +```dart +controller.filter = Filter.equal('type', 'like'); +controller.doInitialLoad(); +``` + +### Basic Usage + +```dart +final controller = StreamReactionListController( + client: StreamChat.of(context).client, + messageId: message.id, + sort: const [SortOption.desc(ReactionSortKey.createdAt)], +); + +await controller.doInitialLoad(); +``` + +--- + +## StreamReactionListView + +`StreamReactionListView` is a new widget in `stream_chat_flutter` that renders a paginated list of reactions using a `StreamReactionListController`. + +### Constructor + +```dart +StreamReactionListView({ + required StreamReactionListController controller, + required StreamReactionListViewIndexedWidgetBuilder itemBuilder, + PagedValueScrollViewIndexedWidgetBuilder? separatorBuilder, + WidgetBuilder? emptyBuilder, + WidgetBuilder? loadingBuilder, + Widget Function(BuildContext, StreamChatError)? errorBuilder, + int loadMoreTriggerIndex = 3, + // Standard ListView params: scrollDirection, reverse, scrollController, + // primary, physics, shrinkWrap, padding, cacheExtent, etc. +}) +``` + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| `controller` | `StreamReactionListController` | yes | Provides and paginates the reaction data | +| `itemBuilder` | `StreamReactionListViewIndexedWidgetBuilder` | yes | Builds each reaction item | +| `separatorBuilder` | `PagedValueScrollViewIndexedWidgetBuilder?` | no | Builds separators between items (defaults to `SizedBox.shrink`) | +| `emptyBuilder` | `WidgetBuilder?` | no | Widget shown when there are no reactions | +| `loadingBuilder` | `WidgetBuilder?` | no | Widget shown during initial load | +| `errorBuilder` | `Widget Function(BuildContext, StreamChatError)?` | no | Widget shown on error | +| `loadMoreTriggerIndex` | `int` | no | How many items from the end to trigger the next page load (default: 3) | + +### Usage + +```dart +StreamReactionListView( + controller: controller, + itemBuilder: (context, reactions, index) { + final reaction = reactions[index]; + return ListTile( + leading: Text(reaction.type), + title: Text(reaction.user?.name ?? ''), + ); + }, +) +``` + +--- + +## ReactionDetailSheet + +`ReactionDetailSheet` replaces the old `MessageReactionsModal`. It shows a bottom sheet with the total reaction count, emoji filter chips per reaction type, and a scrollable list of reactors using `StreamReactionListController` internally. + +### Showing the Sheet + +Use the static `show` method — the constructor is private: + +```dart +final action = await ReactionDetailSheet.show( + context: context, + message: message, + initialReactionType: 'like', // optional: pre-select a reaction type +); +``` + +`show` returns a `MessageAction?`: +- `SelectReaction` — if the user picks or removes a reaction +- `null` — if the sheet is dismissed without selection + +### Parameters of `show` + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| `context` | `BuildContext` | yes | Build context | +| `message` | `Message` | yes | The message whose reactions to display | +| `initialReactionType` | `String?` | no | Pre-selects this reaction type chip when the sheet opens | + +### Migration from `MessageReactionsModal` + +**Before:** +```dart +showDialog( + context: context, + builder: (_) => MessageReactionsModal(message: message), +); +``` + +**After:** +```dart +await ReactionDetailSheet.show( + context: context, + message: message, +); +``` + +> **Note:** `ReactionDetailSheet` is displayed as a `DraggableScrollableSheet` (snapping between 50% and full height) and supports cursor-based pagination for large reaction lists. + +--- + +## Migration Checklist + +- [ ] Replace `MessageReactionsModal` with `ReactionDetailSheet.show()` +- [ ] Use `StreamReactionListController` to load/paginate reactions programmatically +- [ ] Use `StreamReactionListView` with a `StreamReactionListController` for custom reaction list UIs +- [ ] For runtime reaction-type filtering, set `controller.filter` and call `controller.doInitialLoad()` diff --git a/migrations/redesign/reaction_picker.md b/migrations/redesign/reaction_picker.md new file mode 100644 index 0000000000..ce2acf3433 --- /dev/null +++ b/migrations/redesign/reaction_picker.md @@ -0,0 +1,341 @@ +# Reaction Picker Migration Guide + +This guide covers the migration for the redesigned reaction picker and reaction indicator components in Stream Chat Flutter SDK. + +--- + +## Table of Contents + +- [Quick Reference](#quick-reference) +- [StreamChatConfigurationData](#streamchatconfigurationdata) +- [Removed Icon-List APIs](#removed-icon-list-apis) +- [ReactionIconResolver and DefaultReactionIconResolver](#reactioniconresolver-and-defaultreactioniconresolver) +- [StreamMessageReactionPicker](#streammessagereactionpicker-formerly-streamreactionpicker) +- [StreamReactionIndicator](#streamreactionindicator) +- [New Components](#new-components) +- [Migration Checklist](#migration-checklist) + +--- + +## Quick Reference + +| Symbol | Change | +|--------|--------| +| `StreamChatConfigurationData.reactionIcons` | **Removed** — replaced by `reactionIconResolver` | +| `StreamChatConfigurationData.reactionIconResolver` | **New** — optional (default: `DefaultReactionIconResolver()`). Replaces `reactionIcons` | +| `ReactionIconResolver` | **New** — abstract contract for mapping reaction type → `StreamEmojiContent` | +| `DefaultReactionIconResolver` | **New** — ready-to-use default; extend to customize `defaultReactions`, `emojiCode`, or `resolve` | +| `ReactionPickerIconList` / `ReactionIndicatorIconList` | **Removed** — list rendering now lives inside picker/indicator widgets | +| `ReactionPickerIcon` / `ReactionIndicatorIcon` | **Removed** — use resolver-based reaction mapping instead | +| `StreamReactionPicker` | **Renamed** to `StreamMessageReactionPicker` — reaction set from `config.reactionIconResolver.defaultReactions` only | +| `StreamReactionPickerTheme` / `StreamReactionPickerThemeData` | **New** (from `stream_core_flutter`) — theme-based visual customisation for the picker | +| `StreamReactionIndicator` | **Changed** — uses `config.reactionIconResolver.resolve(type)` only | +| `ReactionDetailSheet` | **New** — `ReactionDetailSheet.show()` for reaction details bottom sheet | + +> **Note:** If you were using default reactions only, behavior stays the same (`like`, `haha`, `love`, `wow`, `sad`). Migration is required only for custom reaction icon/type setups. + +--- + +## StreamChatConfigurationData + +### Breaking Changes: + +- `reactionIcons` **removed** — was a list of reaction type + builder pairs for picker/indicator +- `reactionIconResolver` **new** — optional; defaults to `DefaultReactionIconResolver()`. All reaction UI uses it + +### Migration + +**Before:** +```dart +StreamChat( + client: client, + streamChatConfigData: StreamChatConfigurationData( + reactionIcons: [ /* type + builder per reaction */ ], + ), + child: MyApp(), +) +``` + +**After:** +```dart +StreamChat( + client: client, + streamChatConfigData: StreamChatConfigurationData( + reactionIconResolver: const MyReactionIconResolver(), + ), + child: MyApp(), +) +``` + +Extend `DefaultReactionIconResolver` (see below), pass as `reactionIconResolver`. Omit to keep defaults. + +> **Important:** +> - Resolver replaces the old list: use `defaultReactions` + `resolve(type)` (which uses `emojiCode(type)`) + +--- + +## Removed Icon-List APIs + +### Breaking Changes: + +- `ReactionPickerIconList` and `ReactionIndicatorIconList` were removed +- `ReactionPickerIcon` and `ReactionIndicatorIcon` were removed +- Per-widget icon list injection moved to a single global resolver (`StreamChatConfigurationData.reactionIconResolver`) + +### Migration + +**Before:** +```dart +StreamChat( + client: client, + streamChatConfigData: StreamChatConfigurationData( + reactionIcons: [ + // old reaction icon entries + ], + ), + child: MyApp(), +) +``` + +**After:** +```dart +class MyReactionIconResolver extends DefaultReactionIconResolver { + const MyReactionIconResolver(); + + @override + Set get defaultReactions => const {'like', 'love', 'celebrate'}; + + @override + String? emojiCode(String type) { + if (type == 'celebrate') return '🎉'; + return super.emojiCode(type); + } +} + +StreamChat( + client: client, + streamChatConfigData: StreamChatConfigurationData( + reactionIconResolver: const MyReactionIconResolver(), + ), + child: MyApp(), +) +``` + +--- + +## ReactionIconResolver and DefaultReactionIconResolver + +Picker uses `defaultReactions`; picker and indicator call `resolve(type)` → uses `emojiCode(type)` to return a `StreamEmojiContent` model. Extend `DefaultReactionIconResolver` and override only what you need. + +### Contract + +- **`defaultReactions`** — types in quick-pick bar. Every type here must be resolvable by `emojiCode(type)` (return non-null emoji) or fallback is shown. +- **`emojiCode(type)`** — return Unicode emoji (e.g. `'👍'`) or `null`. Used by `resolve`. +- **`supportedReactions`** — full resolver-supported type set. Keep this in sync with your resolver implementation. +- **`resolve(type)`** — returns a `StreamEmojiContent` for display. Default: `emojiCode(type)` → `StreamUnicodeEmoji` else `StreamUnicodeEmoji('❓')`. Override to return `StreamImageEmoji` for custom emoji. + +Override points on `DefaultReactionIconResolver`: `defaultReactions`, `emojiCode`, `resolve`, `supportedReactions`. + +### Migration (custom quick-pick set) + +Restrict `defaultReactions` to keys in `streamSupportedEmojis` so inherited `emojiCode` returns the emoji. + +**Before:** Custom list of reaction types on config or picker. + +**After:** +```dart +class MyReactionIconResolver extends DefaultReactionIconResolver { + const MyReactionIconResolver(); + static const _defaults = {'like', 'haha', 'love', 'wow', 'sad'}; + + @override + Set get defaultReactions => _defaults; +} +// StreamChatConfigurationData(reactionIconResolver: const MyReactionIconResolver(), ...) +``` + +> **Important:** If you add a type not in `streamSupportedEmojis`, override `emojiCode` to return the Unicode emoji for it (see next section). + +### Migration (custom types not in streamSupportedEmojis) + +Override `defaultReactions` (and/or `supportedReactions`) and `emojiCode` so every type has an emoji. + +**After:** +```dart +class MyReactionIconResolver extends DefaultReactionIconResolver { + const MyReactionIconResolver(); + static const _defaults = {'like', 'love', 'custom_celebration'}; + static const _supported = {'like', 'love', 'custom_celebration'}; + static const _customEmojis = {'custom_celebration': '🎉'}; + + @override + Set get defaultReactions => _defaults; + + @override + Set get supportedReactions => _supported; + + @override + String? emojiCode(String type) => _customEmojis[type] ?? streamSupportedEmojis[type]?.emoji; +} +``` + +### Migration (custom rendering, e.g. Twemoji) + +For type-based custom rendering (e.g. Twemoji assets keyed by reaction type), +override `resolve(type)` and return `StreamImageEmoji` for custom emoji. + +**After:** +```dart +class MyReactionIconResolver extends DefaultReactionIconResolver { + const MyReactionIconResolver(); + + @override + StreamEmojiContent resolve(String type) { + switch (type) { + case 'love': + return StreamImageEmoji(url: Uri.parse('https://cdn.example.com/twemoji/heart.png')); + case 'haha': + return StreamImageEmoji(url: Uri.parse('https://cdn.example.com/twemoji/joy.png')); + default: + return super.resolve(type); + } + } +} +``` + +--- + +## StreamMessageReactionPicker (formerly StreamReactionPicker) + +### Breaking Changes: + +- **Renamed** from `StreamReactionPicker` to `StreamMessageReactionPicker` +- `StreamReactionPicker` now refers to the domain-agnostic core component from `stream_core_flutter` +- Picker icons are no longer configured with per-widget icon models +- Quick-pick entries now come from `config.reactionIconResolver.defaultReactions` +- Visual properties (`backgroundColor`, `padding`, `shape`) removed from the widget — use `StreamReactionPickerTheme` instead +- The core picker now uses a `StreamComponentFactory` pattern with `StreamReactionPickerProps` for full customization + +### Migration + +**Before:** +```dart +StreamReactionPicker( + message: message, +) +``` + +**After:** +```dart +StreamMessageReactionPicker( + message: message, + onReactionPicked: onReactionPicked, +) +``` + +Configure reactions globally via `reactionIconResolver`: + +```dart +StreamChat( + client: client, + streamChatConfigData: StreamChatConfigurationData( + reactionIconResolver: const MyReactionIconResolver(), + ), + child: MyApp(), +) +``` + +Customize visual appearance via theme: + +```dart +StreamReactionPickerTheme( + data: StreamReactionPickerThemeData( + backgroundColor: Colors.white, + elevation: 4, + spacing: 2, + shape: RoundedSuperellipseBorder( + borderRadius: BorderRadius.all(Radius.circular(24)), + ), + side: BorderSide(color: Colors.grey), + ), + child: // ... +) +``` + +--- + +## StreamReactionIndicator + +### Breaking Changes: + +- Indicator icons are resolved only through `config.reactionIconResolver.resolve(type)` +- Old icon-list based customization paths were removed + +### Migration + +**Before:** +```dart +StreamChat( + client: client, + streamChatConfigData: StreamChatConfigurationData( + reactionIcons: [ /* old icon list */ ], + ), + child: MyApp(), +) +``` + +**After:** +```dart +StreamChat( + client: client, + streamChatConfigData: StreamChatConfigurationData( + reactionIconResolver: const MyReactionIconResolver(), + ), + child: MyApp(), +) +``` + +Then keep indicator usage unchanged: + +```dart +StreamReactionIndicator( + message: message, + onTap: onTap, +) +``` + +Customize via `reactionIconResolver` in config. + +--- + +## New Components + +### ReactionDetailSheet + +Bottom sheet: reaction counts, filter chips per type, list of users. Returns `Future` (e.g. `SelectReaction`). + +```dart +final action = await ReactionDetailSheet.show( + context: context, + message: message, + initialReactionType: selectedType, // optional +); +if (action is SelectReaction) handleSelectReaction(action); +``` + +### ReactionIconResolver / DefaultReactionIconResolver + +Exported for `StreamChatConfigurationData`. See [ReactionIconResolver and DefaultReactionIconResolver](#reactioniconresolver-and-defaultreactioniconresolver). + +--- + +## Migration Checklist + +- [ ] Rename `StreamReactionPicker` → `StreamMessageReactionPicker` in your code +- [ ] Remove `reactionIcons` from `StreamChatConfigurationData` +- [ ] Remove `backgroundColor`, `padding`, `shape` props from picker usage — use `StreamReactionPickerTheme` instead +- [ ] Custom quick-pick: extend `DefaultReactionIconResolver`, override `defaultReactions` with types from `streamSupportedEmojis` (so `emojiCode` returns emoji); set `reactionIconResolver` +- [ ] Custom types not in `streamSupportedEmojis`: also override `emojiCode` to return Unicode emoji for each; optionally `supportedReactions` +- [ ] Custom rendering (e.g. Twemoji): extend `DefaultReactionIconResolver`, override `resolve(type)` to return `StreamImageEmoji`, set `reactionIconResolver` +- [ ] Remove old icon-list based customization and configure reactions via `reactionIconResolver` only +- [ ] Optionally use `ReactionDetailSheet.show()` diff --git a/migrations/redesign/stream_avatar.md b/migrations/redesign/stream_avatar.md new file mode 100644 index 0000000000..4897a4c7f2 --- /dev/null +++ b/migrations/redesign/stream_avatar.md @@ -0,0 +1,227 @@ +# Stream Avatar Components Migration Guide + +This guide covers the migration for the redesigned avatar components in Stream Chat Flutter SDK. + +--- + +## Table of Contents + +- [Quick Reference](#quick-reference) +- [StreamUserAvatar](#streamuseravatar) +- [StreamChannelAvatar](#streamchannelavatar) +- [StreamGroupAvatar](#streamgroupavatar) +- [StreamUserAvatarStack](#streamuseravatarstack) +- [Size Reference](#size-reference) +- [Migration Checklist](#migration-checklist) + +--- + +## Quick Reference + +| Component | Key Changes | +|-----------|-------------| +| [**StreamUserAvatar**](#streamuseravatar) | `constraints` → `size` enum, `showOnlineStatus` → `showOnlineIndicator`, `onTap` removed | +| [**StreamChannelAvatar**](#streamchannelavatar) | `constraints` → `size` enum, `onTap` and builder callbacks removed | +| [**StreamGroupAvatar**](#streamgroupavatar) | Renamed to `StreamUserAvatarGroup`, `members` → `users` | +| [**StreamUserAvatarStack**](#streamuseravatarstack) | New component for overlapping avatars | + +--- + +## StreamUserAvatar + +### Breaking Changes: + +- `constraints` parameter replaced with `size` enum (`StreamAvatarSize`) +- `showOnlineStatus` renamed to `showOnlineIndicator` +- `onTap` callback removed — wrap with `GestureDetector` or `InkWell` instead +- `borderRadius` parameter removed +- `selected`, `selectionColor`, `selectionThickness` parameters removed +- `onlineIndicatorAlignment` and `onlineIndicatorConstraints` removed + +### Migration: + +**Before:** +```dart +StreamUserAvatar( + user: user, + constraints: BoxConstraints.tight(const Size(40, 40)), + borderRadius: BorderRadius.circular(20), + showOnlineStatus: false, + onTap: (user) => print('Tapped ${user.name}'), +) +``` + +**After:** +```dart +GestureDetector( + onTap: () => print('Tapped ${user.name}'), + child: StreamUserAvatar( + size: StreamAvatarSize.lg, + user: user, + showOnlineIndicator: false, + ), +) +``` + +> **Important:** +> - Use `GestureDetector` or `InkWell` to handle tap events +> - Use `StreamAvatarSize` enum values (`.xs`, `.sm`, `.md`, `.lg`, `.xl`, `.xxl`) instead of `BoxConstraints` +> - See [Size Reference](#size-reference) for mapping old constraints to new enum values + +--- + +## StreamChannelAvatar + +### Breaking Changes: + +- `constraints` parameter replaced with `size` enum (`StreamAvatarGroupSize`) +- `onTap` callback removed — wrap with `GestureDetector` or `InkWell` instead +- `borderRadius` parameter removed +- `selected`, `selectionColor`, `selectionThickness` parameters removed +- `ownSpaceAvatarBuilder`, `oneToOneAvatarBuilder`, `groupAvatarBuilder` callbacks removed + +### Migration: + +**Before:** +```dart +StreamChannelAvatar( + channel: channel, + constraints: BoxConstraints.tight(const Size(40, 40)), + onTap: () => print('Tapped channel'), + selected: isSelected, +) +``` + +**After:** +```dart +GestureDetector( + onTap: () => print('Tapped channel'), + child: StreamChannelAvatar( + size: StreamAvatarGroupSize.lg, + channel: channel, + ), +) +``` + +> **Important:** +> - Use `StreamAvatarGroupSize` enum values (`.lg`, `.xl`, `.xxl`) instead of `BoxConstraints` +> - Custom avatar builders are no longer supported + +--- + +## StreamGroupAvatar + +### Breaking Changes: + +- Renamed from `StreamGroupAvatar` to `StreamUserAvatarGroup` +- `members` parameter replaced with `users` (`Iterable` instead of `List`) +- `constraints` parameter replaced with `size` enum (`StreamAvatarGroupSize`) +- `channel` parameter removed +- `onTap` callback removed — wrap with `GestureDetector` or `InkWell` instead +- `borderRadius` parameter removed +- `selected`, `selectionColor`, `selectionThickness` parameters removed + +### Migration: + +**Before:** +```dart +StreamGroupAvatar( + channel: channel, + members: otherMembers, + constraints: BoxConstraints.tight(const Size(40, 40)), + onTap: () => print('Tapped group'), +) +``` + +**After:** +```dart +GestureDetector( + onTap: () => print('Tapped group'), + child: StreamUserAvatarGroup( + size: StreamAvatarGroupSize.lg, + users: otherMembers.map((m) => m.user!), + ), +) +``` + +> **Important:** +> - Extract `User` objects from `Member` when migrating: `members.map((m) => m.user!)` +> - The component no longer requires a `channel` reference + +--- + +## StreamUserAvatarStack + +### Breaking Changes: + +- **New component** for displaying overlapping user avatars (e.g., thread participants) +- Replaces custom `Stack` + `Positioned` implementations + +### Parameters: + +| Parameter | Type | Default | Description | +|-----------|------|---------|-------------| +| `users` | `Iterable` | required | Users to display | +| `size` | `StreamAvatarStackSize?` | `.sm` | Size of avatars | +| `max` | `int` | `5` | Max avatars before overflow badge | +| `overlap` | `double` | `0.33` | Overlap fraction (0.0 - 1.0) | + +### Usage: + +```dart +StreamUserAvatarStack( + max: 3, + size: StreamAvatarStackSize.xs, + users: threadParticipants, +) +``` + +> **Important:** +> - Use this component instead of manually building overlapping avatar stacks +> - The `overlap` parameter controls how much each avatar overlaps the previous one + +--- + +## Size Reference + +### StreamAvatarSize + +| Old Constraints | New Size | Diameter | +|-----------------|----------|----------| +| `BoxConstraints.tight(Size(20, 20))` | `.xs` | 20px | +| `BoxConstraints.tight(Size(24, 24))` | `.sm` | 24px | +| `BoxConstraints.tight(Size(32, 32))` | `.md` | 32px | +| `BoxConstraints.tight(Size(40, 40))` | `.lg` | 40px | +| `BoxConstraints.tight(Size(48, 48))` | `.xl` | 48px | +| `BoxConstraints.tight(Size(80, 80))` | `.xxl` | 80px | + +### StreamAvatarGroupSize + +| Old Constraints | New Size | Diameter | +|-----------------|----------|----------| +| `BoxConstraints.tight(Size(40, 40))` | `.lg` | 40px | +| `BoxConstraints.tight(Size(48, 48))` | `.xl` | 48px | +| `BoxConstraints.tight(Size(80, 80))` | `.xxl` | 80px | + +### StreamAvatarStackSize + +| Old Constraints | New Size | Diameter | +|-----------------|----------|----------| +| `BoxConstraints.tight(Size(20, 20))` | `.xs` | 20px | +| `BoxConstraints.tight(Size(24, 24))` | `.sm` | 24px | + +> **Note:** +> If your old constraints don't match exactly, choose the closest available size. + +--- + +## Migration Checklist + +- [ ] Replace `StreamUserAvatar` `constraints` with `size` enum (`StreamAvatarSize`) +- [ ] Rename `showOnlineStatus` to `showOnlineIndicator` +- [ ] Move `onTap` callbacks to parent `GestureDetector` or `InkWell` widgets +- [ ] Replace `StreamGroupAvatar` with `StreamUserAvatarGroup` +- [ ] Change `members` parameter to `users` (extract `User` from `Member`) +- [ ] Replace `StreamChannelAvatar` `constraints` with `size` enum (`StreamAvatarGroupSize`) +- [ ] Remove `selected`, `selectionColor`, `selectionThickness` parameters +- [ ] Use `StreamUserAvatarStack` for overlapping avatar displays diff --git a/migrations/redesign/unread_indicator.md b/migrations/redesign/unread_indicator.md new file mode 100644 index 0000000000..2a41b87f54 --- /dev/null +++ b/migrations/redesign/unread_indicator.md @@ -0,0 +1,83 @@ +# Unread Indicator Migration Guide + +This guide covers the migration for `StreamUnreadIndicator` — the small badge +that shows an unread count. + +For the floating jump-to-unread button shown inside `StreamMessageListView`, +see [unread_indicator_button.md](unread_indicator_button.md). + +--- + +## StreamUnreadIndicator + +`StreamUnreadIndicator` shows a small badge with an unread count. It now wraps +`StreamBadgeNotification` (from `stream_core_flutter`) and the custom styling +parameters have been removed. + +### Breaking Changes + +- `backgroundColor`, `textColor`, and `textStyle` constructor parameters + removed — styling is now controlled via `StreamTheme`. +- The widget is now wrapped in `IgnorePointer`; it does not respond to taps + itself. +- Now supports named constructors for different unread count types. + +### Named Constructors + +| Constructor | Description | +|-------------|-------------| +| `StreamUnreadIndicator()` | Shows total unread message count | +| `StreamUnreadIndicator.channels({String? cid})` | Shows unread channel count; optionally filtered to a specific channel by `cid` | +| `StreamUnreadIndicator.threads({String? id})` | Shows unread thread count | + +### Overlay Mode + +`StreamUnreadIndicator` can now be overlaid on top of another widget by +passing a `child`. When `child` is non-null, the badge is positioned over the +child using `alignment` and `offset`. + +| Parameter | Type | Default | Description | +|-----------|------|---------|-------------| +| `child` | `Widget?` | `null` | Widget to overlay the badge on. When `null`, only the badge is rendered. | +| `alignment` | `AlignmentGeometry?` | `AlignmentDirectional.topEnd` | Alignment of the badge relative to `child`. | +| `offset` | `Offset?` | `Offset(8, -6)` (mirrored in RTL) | Pixel offset applied after `alignment`. | + +```dart +// Standalone badge (no child). +const StreamUnreadIndicator() + +// Badge overlaid on an icon. +StreamUnreadIndicator(child: Icon(Icons.chat_bubble_outline)) +``` + +### Migration + +**Before:** +```dart +StreamUnreadIndicator( + backgroundColor: Colors.red, + textColor: Colors.white, + textStyle: TextStyle(fontSize: 12), +) +``` + +**After:** +```dart +// Styling via StreamTheme — see README.md for theming setup. +StreamUnreadIndicator() +``` + +> **Note:** The badge automatically displays `99+` for counts above 99. + +--- + +## Migration Checklist + +- [ ] Remove `backgroundColor`, `textColor`, `textStyle` from + `StreamUnreadIndicator` usages. +- [ ] Use the appropriate named constructor (`StreamUnreadIndicator()`, + `.channels()`, `.threads()`). +- [ ] If overlaying the badge on an icon or button, pass it as `child` instead + of wrapping `StreamUnreadIndicator` in a `Stack`. +- [ ] For the floating jump-to-unread button inside `StreamMessageListView`, + follow [unread_indicator_button.md](unread_indicator_button.md). diff --git a/migrations/redesign/unread_indicator_button.md b/migrations/redesign/unread_indicator_button.md new file mode 100644 index 0000000000..72fcce3e20 --- /dev/null +++ b/migrations/redesign/unread_indicator_button.md @@ -0,0 +1,167 @@ +# Unread Indicator Button Migration + +## Overview + +The unread indicator in `StreamMessageListView` has been rebuilt on top of +`StreamJumpToUnreadButton` — a new design-system component in +`stream_core_flutter`. The old hand-rolled `Material` / `InkWell` / `Row` +implementation and its associated typedefs have been removed in favour of the +reusable, fully themeable component. + +## Breaking Changes + +### 1. `UnreadIndicatorBuilder` typedef — removed + +The `UnreadIndicatorBuilder`, `OnUnreadIndicatorTap`, and +`OnUnreadIndicatorDismissTap` typedefs no longer exist. + +### 2. `UnreadIndicatorProps` class — removed + +The chat-level `UnreadIndicatorProps` class has been removed. Customisation now +goes through `StreamJumpToUnreadButtonProps` (from `stream_core_flutter`) via +the `StreamComponentFactory`. + +### 3. `StreamMessageListView.unreadIndicatorBuilder` parameter — removed + +The per-instance builder parameter on `StreamMessageListView` has been removed. +Use `StreamComponentFactory` instead for app-wide customisation. + +### 4. `UnreadIndicatorButton` callback renames + +| Before | After | +|----------------------|----------------| +| `onTap` | `onJumpTap` | +| `onDismissTap` | `onDismissTap` | + +### 5. `streamChatComponentBuilders` — `unreadIndicator` parameter removed + +The `unreadIndicator` parameter has been removed from the +`streamChatComponentBuilders()` helper. Register a builder for +`StreamJumpToUnreadButtonProps` via `StreamComponentFactory` instead. + +## Migration Guide + +### Using `unreadIndicatorBuilder` on `StreamMessageListView` + +**Before:** + +```dart +StreamMessageListView( + unreadIndicatorBuilder: (unreadCount, onTap, onDismissTap) { + return MyCustomUnreadBanner( + count: unreadCount, + onTap: () => onTap(null), + onDismiss: onDismissTap, + ); + }, +) +``` + +**After — use `StreamComponentFactory`:** + +```dart +StreamChat( + client: client, + componentBuilders: StreamComponentBuilders( + jumpToUnreadButton: (context, props) { + return MyCustomUnreadBanner( + label: props.label, + onTap: props.onJumpPressed, + onDismiss: props.onDismissPressed, + ); + }, + ), + child: // ... +) +``` + +The `StreamMessageListView` no longer needs any parameter — just remove +`unreadIndicatorBuilder`. + +### Using `streamChatComponentBuilders` with `unreadIndicator` + +**Before:** + +```dart +streamChatComponentBuilders( + unreadIndicator: (context, props) { + return MyCustomWidget( + count: props.unreadCount, + onTap: () => props.onTap(null), + onDismiss: props.onDismissTap, + ); + }, +) +``` + +**After — use the core factory's `jumpToUnreadButton`:** + +```dart +StreamComponentBuilders( + jumpToUnreadButton: (context, props) { + return MyCustomWidget( + label: props.label, + onTap: props.onJumpPressed, + onDismiss: props.onDismissPressed, + ); + }, +) +``` + +### Theming the default indicator + +The new `StreamJumpToUnreadButton` is fully themeable without replacing the +widget. Wrap any ancestor with `StreamJumpToUnreadButtonTheme` or configure it +in your `StreamTheme`: + +```dart +StreamJumpToUnreadButtonTheme( + data: StreamJumpToUnreadButtonThemeData( + backgroundColor: Colors.blue.shade50, + elevation: 0, + shape: const StadiumBorder(), + side: const BorderSide(color: Colors.blue), + leadingStyle: StreamButtonThemeStyle( + foregroundColor: const WidgetStatePropertyAll(Colors.blue), + ), + trailingStyle: StreamButtonThemeStyle( + foregroundColor: const WidgetStatePropertyAll(Colors.blue), + ), + ), + child: // ... +) +``` + +### Direct `UnreadIndicatorButton` usage + +If you were using `UnreadIndicatorButton` directly (rare), update the callback +names: + +**Before:** + +```dart +UnreadIndicatorButton( + onTap: scrollToUnread, + onDismissTap: markAsRead, + unreadIndicatorBuilder: myBuilder, +) +``` + +**After:** + +```dart +UnreadIndicatorButton( + onJumpTap: scrollToUnread, + onDismissTap: markAsRead, +) +``` + +## Props Mapping Reference + +| Old (`UnreadIndicatorProps`) | New (`StreamJumpToUnreadButtonProps`) | +|-------------------------------|--------------------------------------| +| `unreadCount` (int) | `label` (String) — pre-formatted | +| `onTap` (callback) | `onJumpPressed` (VoidCallback?) | +| `onDismissTap` (callback) | `onDismissPressed` (VoidCallback?) | +| — | `leadingIcon` (IconData?) | +| — | `trailingIcon` (IconData?) | diff --git a/migrations/v10-migration.md b/migrations/v10-migration.md new file mode 100644 index 0000000000..31b4dd4b51 --- /dev/null +++ b/migrations/v10-migration.md @@ -0,0 +1,1136 @@ +# Stream Chat Flutter SDK v10.0.0 Migration Guide + +This guide covers all breaking changes in **Stream Chat Flutter SDK v10.0.0**. Whether you're upgrading from v9.x or from a v10 beta, this document provides the complete migration path. + +--- + +## Table of Contents + +- [Who Should Read This](#who-should-read-this) +- [Quick Reference](#quick-reference) +- [Attachment Picker](#attachment-picker) + - [AttachmentPickerType](#attachmentpickertype) + - [StreamAttachmentPickerOption](#streamattachmentpickeroption) + - [showStreamAttachmentPickerModalBottomSheet](#showstreamattachmentpickermodalbottomsheet) + - [AttachmentPickerBottomSheet](#attachmentpickerbottomsheet) + - [customAttachmentPickerOptions](#customattachmentpickeroptions) + - [onCustomAttachmentPickerResult](#oncustomattachmentpickerresult) + - [StreamAttachmentPickerController](#streamattachmentpickercontroller) +- [Reactions](#reactions) + - [SendReaction](#sendreaction) + - [StreamReactionPicker](#streamreactionpicker) + - [ReactionPickerIconList](#reactionpickericonlist) + - [StreamMessageReactionsModal](#streammessagereactionsmodal) +- [Message UI](#message-ui) + - [onAttachmentTap](#onattachmenttap) + - [StreamMessageItem](#streammessageitem) + - [StreamMessageAction](#streammessageaction) +- [Message State & Deletion](#message-state--deletion) + - [MessageState](#messagestate) +- [File Upload](#file-upload) + - [AttachmentFileUploader](#attachmentfileuploader) +- [Unread Threads Banner](#unread-threads-banner) +- [Appendix: Beta Release Timeline](#appendix-beta-release-timeline) +- [Migration Checklist](#migration-checklist) + +--- + +## Who Should Read This + +| Upgrading From | Sections to Review | +|----------------|-------------------| +| **v9.x** | All sections | +| [**v10.0.0-beta.1**](#v1000-beta1) | All sections introduced after beta.1 | +| [**v10.0.0-beta.3**](#v1000-beta3) | Sections introduced in beta.4 and later | +| [**v10.0.0-beta.4**](#v1000-beta4) | Sections introduced in beta.7 and later | +| [**v10.0.0-beta.7**](#v1000-beta7) | Sections introduced in beta.8 and later | +| [**v10.0.0-beta.8**](#v1000-beta8) | Sections introduced in beta.9 and later | +| [**v10.0.0-beta.9**](#v1000-beta9) | Sections introduced in beta.12 | +| [**v10.0.0-beta.12**](#v1000-beta12) | No additional changes | + +Each breaking change section includes an **"Introduced in"** tag so you can quickly identify which changes apply to your upgrade path. + +--- + +## Quick Reference + +| Feature Area | Key Changes | +|-------------|-------------| +| [**Attachment Picker**](#attachment-picker) | Sealed class hierarchy, builder pattern for options, typed result handling | +| [**Reactions**](#reactions) | `Reaction` object API, explicit `onReactionPicked` callbacks required | +| [**Message UI**](#message-ui) | New `onAttachmentTap` signature with fallback support, generic `StreamMessageAction` | +| [**Message State**](#message-state--deletion) | `MessageDeleteScope` replaces `bool hard`, delete-for-me support | +| [**File Upload**](#file-upload) | Four new abstract methods on `AttachmentFileUploader` | +| [**Unread Threads Banner**](#unread-threads-banner) | Wrapper pattern with `child`, `enabled`, `onRefresh`; removed `onTap`, `minHeight` | + +--- + +## Attachment Picker + +The attachment picker system has been redesigned with a sealed class hierarchy, improved type safety, and a flexible builder pattern for customization. + +--- + +### AttachmentPickerType + +> **Introduced in:** [v10.0.0-beta.3](https://pub.dev/packages/stream_chat_flutter/versions/10.0.0-beta.3) + +#### Key Changes: + +- `AttachmentPickerType` enum replaced with sealed class hierarchy +- Now supports extensible custom types like contact and location pickers +- Use built-in types like `AttachmentPickerType.images` or define your own via `CustomAttachmentPickerType` + +#### Migration Steps: + +**Before:** +```dart +// Using enum-based attachment types +final attachmentType = AttachmentPickerType.images; +``` + +**After:** +```dart +// Using sealed class attachment types +final attachmentType = AttachmentPickerType.images; + +// For custom types +class LocationAttachmentPickerType extends CustomAttachmentPickerType { + const LocationAttachmentPickerType(); +} +``` + +> **Important:** +> The enum is now a sealed class, but the basic usage remains the same for built-in types. + +--- + +### StreamAttachmentPickerOption + +> **Introduced in:** [v10.0.0-beta.3](https://pub.dev/packages/stream_chat_flutter/versions/10.0.0-beta.3) + +#### Key Changes: + +- `StreamAttachmentPickerOption` replaced with two sealed classes: + - `SystemAttachmentPickerOption` for system pickers (camera, files) + - `TabbedAttachmentPickerOption` for tabbed pickers (gallery, polls, location) + +#### Migration Steps: + +**Before:** +```dart +final option = AttachmentPickerOption( + title: 'Gallery', + icon: Icon(Icons.photo_library), + supportedTypes: [AttachmentPickerType.images, AttachmentPickerType.videos], + optionViewBuilder: (context, controller) { + return GalleryPickerView(controller: controller); + }, +); + +final webOrDesktopOption = WebOrDesktopAttachmentPickerOption( + title: 'File Upload', + icon: Icon(Icons.upload_file), + type: AttachmentPickerType.files, +); +``` + +**After:** +```dart +// For custom UI pickers (gallery, polls) +final tabbedOption = TabbedAttachmentPickerOption( + title: 'Gallery', + icon: Icon(Icons.photo_library), + supportedTypes: [AttachmentPickerType.images, AttachmentPickerType.videos], + optionViewBuilder: (context, controller) { + return GalleryPickerView(controller: controller); + }, +); + +// For system pickers (camera, file dialogs) +final systemOption = SystemAttachmentPickerOption( + title: 'Camera', + icon: Icon(Icons.camera_alt), + supportedTypes: [AttachmentPickerType.images], + onTap: (context, controller) => pickFromCamera(), +); +``` + +> **Important:** +> - Use `SystemAttachmentPickerOption` for system pickers (camera, file dialogs) +> - Use `TabbedAttachmentPickerOption` for custom UI pickers (gallery, polls) + +--- + +### showStreamAttachmentPickerModalBottomSheet + +> **Introduced in:** [v10.0.0-beta.3](https://pub.dev/packages/stream_chat_flutter/versions/10.0.0-beta.3) + +#### Key Changes: + +- Now returns `StreamAttachmentPickerResult` instead of `AttachmentPickerValue` +- Improved type safety and clearer intent handling + +#### Migration Steps: + +**Before:** +```dart +final result = await showStreamAttachmentPickerModalBottomSheet( + context: context, + controller: controller, +); + +// result is AttachmentPickerValue +``` + +**After:** +```dart +final result = await showStreamAttachmentPickerModalBottomSheet( + context: context, + controller: controller, +); + +// result is StreamAttachmentPickerResult +switch (result) { + case AttachmentsPicked(): + // Handle picked attachments + case PollCreated(): + // Handle created poll + case AttachmentPickerError(): + // Handle error + case CustomAttachmentPickerResult(): + // Handle custom result +} +``` + +> **Important:** +> Always handle the new `StreamAttachmentPickerResult` return type with proper switch cases. + +--- + +### AttachmentPickerBottomSheet + +> **Introduced in:** [v10.0.0-beta.3](https://pub.dev/packages/stream_chat_flutter/versions/10.0.0-beta.3) + +#### Key Changes: + +- `StreamMobileAttachmentPickerBottomSheet` → `StreamTabbedAttachmentPickerBottomSheet` +- `StreamWebOrDesktopAttachmentPickerBottomSheet` → `StreamSystemAttachmentPickerBottomSheet` + +#### Migration Steps: + +**Before:** +```dart +StreamMobileAttachmentPickerBottomSheet( + context: context, + controller: controller, + customOptions: [option], +); + +StreamWebOrDesktopAttachmentPickerBottomSheet( + context: context, + controller: controller, + customOptions: [option], +); +``` + +**After:** +```dart +StreamTabbedAttachmentPickerBottomSheet( + context: context, + controller: controller, + customOptions: [tabbedOption], +); + +StreamSystemAttachmentPickerBottomSheet( + context: context, + controller: controller, + customOptions: [systemOption], +); +``` + +> **Important:** +> The new names better reflect their respective layouts and functionality. + +--- + +### customAttachmentPickerOptions + +> **Introduced in:** [v10.0.0-beta.8](https://pub.dev/packages/stream_chat_flutter/versions/10.0.0-beta.8) + +#### Key Changes: + +- `customAttachmentPickerOptions` has been removed. Use `attachmentPickerOptionsBuilder` instead. +- New builder pattern provides access to default options which can be modified, reordered, or extended. + +#### Migration Steps: + +**Before:** +```dart +StreamMessageComposer( + customAttachmentPickerOptions: [ + TabbedAttachmentPickerOption( + key: 'custom-location', + icon: const Icon(Icons.location_on), + supportedTypes: [AttachmentPickerType.images], + optionViewBuilder: (context, controller) { + return CustomLocationPicker(); + }, + ), + ], +) +``` + +**After:** +```dart +StreamMessageComposer( + attachmentPickerOptionsBuilder: (context, defaultOptions) { + // You can now modify, filter, reorder, or extend default options + return [ + ...defaultOptions, + TabbedAttachmentPickerOption( + key: 'custom-location', + icon: const Icon(Icons.location_on), + supportedTypes: [AttachmentPickerType.images], + optionViewBuilder: (context, controller) { + return CustomLocationPicker(); + }, + ), + ]; + }, +) +``` + +**Example: Filtering default options** +```dart +StreamMessageComposer( + attachmentPickerOptionsBuilder: (context, defaultOptions) { + // Remove poll option + return defaultOptions.where((option) => option.key != 'poll').toList(); + }, +) +``` + +**Example: Reordering options** +```dart +StreamMessageComposer( + attachmentPickerOptionsBuilder: (context, defaultOptions) { + // Reverse the order + return defaultOptions.reversed.toList(); + }, +) +``` + +**Using with `showStreamAttachmentPickerModalBottomSheet`:** +```dart +final result = await showStreamAttachmentPickerModalBottomSheet( + context: context, + optionsBuilder: (context, defaultOptions) { + return [ + ...defaultOptions, + TabbedAttachmentPickerOption( + key: 'custom-option', + icon: const Icon(Icons.star), + supportedTypes: [AttachmentPickerType.images], + optionViewBuilder: (context, controller) { + return CustomPickerView(); + }, + ), + ]; + }, +); +``` + +> **Important:** +> - The builder pattern gives you access to default options, allowing more flexible customization +> - The builder works with both mobile (tabbed) and desktop (system) pickers + +--- + +### onCustomAttachmentPickerResult + +> **Introduced in:** [v10.0.0-beta.8](https://pub.dev/packages/stream_chat_flutter/versions/10.0.0-beta.8) + +#### Key Changes: + +- `onCustomAttachmentPickerResult` has been removed. Use `onAttachmentPickerResult` which returns `FutureOr`. +- Result handler can now short-circuit default behavior by returning `true`. + +#### Migration Steps: + +**Before:** +```dart +StreamMessageComposer( + onCustomAttachmentPickerResult: (result) { + if (result is CustomAttachmentPickerResult) { + final data = result.data; + // Handle custom result + } + }, +) +``` + +**After:** +```dart +StreamMessageComposer( + onAttachmentPickerResult: (result) { + if (result is CustomAttachmentPickerResult) { + final data = result.data; + // Handle custom result + return true; // Indicate we handled it - skips default processing + } + return false; // Let default handler process other result types + }, +) +``` + +> **Important:** +> - `onAttachmentPickerResult` replaces `onCustomAttachmentPickerResult` and must return a boolean +> - Return `true` from `onAttachmentPickerResult` to skip default handling +> - Return `false` to allow the default handler to process the result + +--- + +### StreamAttachmentPickerController + +> **Introduced in:** [v10.0.0-beta.12](https://pub.dev/packages/stream_chat_flutter/versions/10.0.0-beta.12) + +#### Key Changes: + +- Replaced `ArgumentError('The size of the attachment is...')` with `AttachmentTooLargeError`. +- Replaced `ArgumentError('The maximum number of attachments is...')` with `AttachmentLimitReachedError`. + +#### Migration Steps: + +**Before:** +```dart +try { + await controller.addAttachment(attachment); +} on ArgumentError catch (e) { + // Generic error handling + showError(e.message); +} +``` + +**After:** +```dart +try { + await controller.addAttachment(attachment); +} on AttachmentTooLargeError catch (e) { + // File size exceeded + showError('File is too large. Max size is ${e.maxSize} bytes.'); +} on AttachmentLimitReachedError catch (e) { + // Too many attachments + showError('Cannot add more attachments. Maximum is ${e.maxCount}.'); +} +``` + +> **Important:** +> - Replace `ArgumentError` catches with the specific typed errors +> - `AttachmentTooLargeError` provides `fileSize` and `maxSize` properties +> - `AttachmentLimitReachedError` provides `maxCount` property + +--- + +## Reactions + +The reaction system has been updated to use explicit callbacks and a unified `Reaction` object API. + +--- + +### SendReaction + +> **Introduced in:** [v10.0.0-beta.4](https://pub.dev/packages/stream_chat_flutter/versions/10.0.0-beta.4) + +#### Key Changes: + +- `sendReaction` method now accepts a full `Reaction` object instead of individual parameters. + +#### Migration Steps: + +**Before:** +```dart +// Using individual parameters +channel.sendReaction( + message, + 'like', + score: 1, + extraData: {'custom_field': 'value'}, +); + +client.sendReaction( + messageId, + 'love', + enforceUnique: true, + extraData: {'custom_field': 'value'}, +); +``` + +**After:** +```dart +// Using Reaction object +channel.sendReaction( + message, + Reaction( + type: 'like', + score: 1, + emojiCode: '👍', + extraData: {'custom_field': 'value'}, + ), +); + +client.sendReaction( + messageId, + Reaction( + type: 'love', + emojiCode: '❤️', + extraData: {'custom_field': 'value'}, + ), + enforceUnique: true, +); +``` + +> **Important:** +> - The `sendReaction` method now requires a `Reaction` object +> - Optional parameters like `enforceUnique` and `skipPush` remain as method parameters +> - You can now specify custom emoji codes for reactions using the `emojiCode` field + +--- + +### StreamReactionPicker + +> **Introduced in:** [v10.0.0-beta.1](https://pub.dev/packages/stream_chat_flutter/versions/10.0.0-beta.1) + +#### Key Changes: + +- New `StreamReactionPicker.builder` constructor +- Added properties: `padding`, `scrollable`, `borderRadius` +- Automatic reaction handling removed — must now use `onReactionPicked` + +#### Migration Steps: + +**Before:** +```dart +StreamReactionPicker( + message: message, +); +``` + +**After (Recommended – Builder):** +```dart +StreamReactionPicker.builder( + context, + message, + (Reaction reaction) { + // Explicitly handle reaction + }, +); +``` + +**After (Alternative – Direct Configuration):** +```dart +StreamReactionPicker( + message: message, + reactionIcons: StreamChatConfiguration.of(context).reactionIcons, + onReactionPicked: (Reaction reaction) { + // Handle reaction here + }, + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + scrollable: true, + borderRadius: BorderRadius.circular(24), +); +``` + +> **Important:** +> Automatic reaction handling has been removed. You must explicitly handle reactions using `onReactionPicked`. + +--- + +### ReactionPickerIconList + +> **Introduced in:** [v10.0.0-beta.9](https://pub.dev/packages/stream_chat_flutter/versions/10.0.0-beta.9) + +#### Key Changes: + +- `message` parameter has been removed +- `reactionIcons` type changed from `List` to `List` +- `onReactionPicked` callback renamed to `onIconPicked` with new signature: `ValueSetter` +- `iconBuilder` parameter changed from default value to nullable with internal fallback +- Message-specific logic (checking for own reactions) moved to parent widget + +#### Migration Steps: + +**Before:** +```dart +ReactionPickerIconList( + message: message, + reactionIcons: icons, + onReactionPicked: (reaction) { + // Handle reaction + channel.sendReaction(message, reaction); + }, +) +``` + +**After:** +```dart +// Map StreamReactionIcon to ReactionPickerIcon with selection state +final ownReactions = [...?message.ownReactions]; +final ownReactionsMap = {for (final it in ownReactions) it.type: it}; + +final pickerIcons = icons.map((icon) { + return ReactionPickerIcon( + type: icon.type, + builder: icon.builder, + isSelected: ownReactionsMap[icon.type] != null, + ); +}).toList(); + +ReactionPickerIconList( + reactionIcons: pickerIcons, + onIconPicked: (pickerIcon) { + final reaction = ownReactionsMap[pickerIcon.type] ?? + Reaction(type: pickerIcon.type); + // Handle reaction + channel.sendReaction(message, reaction); + }, +) +``` + +> **Important:** +> - This is typically an internal widget used by `StreamReactionPicker` +> - If you were using it directly, you now need to handle reaction selection state externally +> - Use `StreamReactionPicker` for most use cases instead of `ReactionPickerIconList` + +--- + +### StreamMessageReactionsModal + +> **Introduced in:** [v10.0.0-beta.1](https://pub.dev/packages/stream_chat_flutter/versions/10.0.0-beta.1) + +#### Key Changes: + +- Based on `StreamMessageModal` for consistency +- `messageTheme` removed — inferred automatically +- Reaction handling must now be handled via `onReactionPicked` + +#### Migration Steps: + +**Before:** +```dart +StreamMessageReactionsModal( + message: message, + messageWidget: myMessageWidget, + messageTheme: myCustomMessageTheme, + reverse: true, +); +``` + +**After:** +```dart +StreamMessageReactionsModal( + message: message, + messageWidget: myMessageWidget, + reverse: true, + onReactionPicked: (SelectReaction reactionAction) { + // Handle reaction explicitly + }, +); +``` + +> **Important:** +> `messageTheme` has been removed. Reaction handling must now be explicit using `onReactionPicked`. + +--- + +## Message UI + +Updates to message widgets, attachment handling, and custom action patterns. + +--- + +### onAttachmentTap + +> **Introduced in:** [v10.0.0-beta.9](https://pub.dev/packages/stream_chat_flutter/versions/10.0.0-beta.9) + +#### Key Changes: + +- `onAttachmentTap` callback signature has changed to support custom attachment handling with automatic fallback to default behavior. +- Callback now receives `BuildContext` as the first parameter. +- Returns `FutureOr` to indicate whether the attachment was handled. +- Returning `true` skips default behavior, `false` uses default handling (URLs, images, videos, giphys). + +#### Migration Steps: + +**Before:** +```dart +StreamMessageItem( + message: message, + onAttachmentTap: (message, attachment) { + // Could only override - no way to fallback to default behavior + if (attachment.type == 'location') { + showLocationDialog(context, attachment); + } + // Other attachment types (images, videos, URLs) lost default behavior + }, +) +``` + +**After:** +```dart +StreamMessageItem( + message: message, + onAttachmentTap: (context, message, attachment) async { + if (attachment.type == 'location') { + await showLocationDialog(context, attachment); + return true; // Handled by custom logic + } + return false; // Use default behavior for images, videos, URLs, etc. + }, +) +``` + +**Example: Handling multiple custom types** +```dart +StreamMessageItem( + message: message, + onAttachmentTap: (context, message, attachment) async { + switch (attachment.type) { + case 'location': + await Navigator.push( + context, + MaterialPageRoute(builder: (_) => MapView(attachment)), + ); + return true; + + case 'product': + await showProductDialog(context, attachment); + return true; + + default: + return false; // Images, videos, URLs use default viewer + } + }, +) +``` + +> **Important:** +> - The callback now requires `BuildContext` as the first parameter +> - Must return `FutureOr` - `true` if handled, `false` for default behavior +> - Default behavior automatically handles URL previews, images, videos, and giphys +> - Supports both synchronous and asynchronous operations + +--- + +### StreamMessageItem + +> **Introduced in:** [v10.0.0-beta.1](https://pub.dev/packages/stream_chat_flutter/versions/10.0.0-beta.1) + +#### Key Changes: + +- `showReactionTail` parameter has been removed +- Tail now automatically shows when the picker is visible + +#### Migration Steps: + +**Before:** +```dart +StreamMessageItem( + message: message, + showReactionTail: true, +); +``` + +**After:** +```dart +StreamMessageItem( + message: message, +); +``` + +> **Important:** +> The `showReactionTail` parameter is no longer supported. Tail is now always shown when the picker is visible. + +--- + +### StreamMessageAction + +> **Introduced in:** [v10.0.0-beta.1](https://pub.dev/packages/stream_chat_flutter/versions/10.0.0-beta.1) + +#### Key Changes: + +- Now generic: `StreamMessageAction` +- Individual `onTap` handlers removed — use `onCustomActionTap` instead +- Added new styling props for better customization + +#### Migration Steps: + +**Before:** +```dart +final customAction = StreamMessageAction( + title: Text('Custom Action'), + leading: Icon(Icons.settings), + onTap: (message) { + // Handle action + }, +); +``` + +**After (Type-safe):** +```dart +final customAction = StreamMessageAction( + action: CustomMessageAction( + message: message, + extraData: {'type': 'custom_action'}, + ), + title: Text('Custom Action'), + leading: Icon(Icons.settings), + isDestructive: false, + iconColor: Colors.blue, +); + +StreamMessageItem( + message: message, + customActions: [customAction], + onCustomActionTap: (CustomMessageAction action) { + // Handle action here + }, +); +``` + +> **Important:** +> Individual `onTap` callbacks have been removed. Always handle actions using the centralized `onCustomActionTap`. + +--- + +## Message State & Deletion + +Message deletion now supports scoped deletion modes including delete-for-me functionality. + +--- + +### MessageState + +> **Introduced in:** [v10.0.0-beta.7](https://pub.dev/packages/stream_chat_flutter/versions/10.0.0-beta.7) + +#### Key Changes: + +- `MessageState` factory constructors now accept `MessageDeleteScope` instead of `bool hard` parameter +- Pattern matching callbacks in state classes now receive `MessageDeleteScope scope` instead of `bool hard` +- New delete-for-me functionality with dedicated states and methods + +#### Migration Steps: + +**Before:** +```dart +// Factory constructors with bool hard +final deletingState = MessageState.deleting(hard: true); +final deletedState = MessageState.deleted(hard: false); +final failedState = MessageState.deletingFailed(hard: true); + +// Pattern matching with bool hard +message.state.whenOrNull( + deleting: (hard) => handleDeleting(hard), + deleted: (hard) => handleDeleted(hard), + deletingFailed: (hard) => handleDeletingFailed(hard), +); +``` + +**After:** +```dart +// Factory constructors with MessageDeleteScope +final deletingState = MessageState.deleting( + scope: MessageDeleteScope.hardDeleteForAll, +); +final deletedState = MessageState.deleted( + scope: MessageDeleteScope.softDeleteForAll, +); +final failedState = MessageState.deletingFailed( + scope: MessageDeleteScope.deleteForMe(), +); + +// Pattern matching with MessageDeleteScope +message.state.whenOrNull( + deleting: (scope) => handleDeleting(scope.hard), + deleted: (scope) => handleDeleted(scope.hard), + deletingFailed: (scope) => handleDeletingFailed(scope.hard), +); + +// New delete-for-me functionality +channel.deleteMessageForMe(message); // Delete only for current user +client.deleteMessageForMe(messageId); // Delete only for current user + +// Check delete-for-me states +if (message.state.isDeletingForMe) { + // Handle deleting for me state +} +if (message.state.isDeletedForMe) { + // Handle deleted for me state +} +if (message.state.isDeletingForMeFailed) { + // Handle delete for me failed state +} +``` + +> **Important:** +> - All `MessageState` factory constructors now require `MessageDeleteScope` parameter +> - Pattern matching callbacks receive `MessageDeleteScope` instead of `bool hard` +> - Use `scope.hard` to access the hard delete boolean value +> - New delete-for-me methods are available on both `Channel` and `StreamChatClient` + +--- + +## File Upload + +The file uploader interface has been expanded with standalone upload and removal methods. + +--- + +### AttachmentFileUploader + +> **Introduced in:** [v10.0.0-beta.7](https://pub.dev/packages/stream_chat_flutter/versions/10.0.0-beta.7) + +#### Key Changes: + +- `AttachmentFileUploader` interface now includes four new abstract methods: `uploadImage`, `uploadFile`, `removeImage`, and `removeFile`. +- Custom implementations must implement these new standalone upload/removal methods. + +#### Migration Steps: + +**Before:** +```dart +class CustomAttachmentFileUploader implements AttachmentFileUploader { + // Only needed to implement sendImage, sendFile, deleteImage, deleteFile + + @override + Future sendImage(/* ... */) async { + // Implementation + } + + @override + Future sendFile(/* ... */) async { + // Implementation + } + + @override + Future deleteImage(/* ... */) async { + // Implementation + } + + @override + Future deleteFile(/* ... */) async { + // Implementation + } +} +``` + +**After:** +```dart +class CustomAttachmentFileUploader implements AttachmentFileUploader { + // Must now implement all 8 methods including the new standalone ones + + @override + Future sendImage(/* ... */) async { + // Implementation + } + + @override + Future sendFile(/* ... */) async { + // Implementation + } + + @override + Future deleteImage(/* ... */) async { + // Implementation + } + + @override + Future deleteFile(/* ... */) async { + // Implementation + } + + // New required methods + @override + Future uploadImage( + AttachmentFile image, { + ProgressCallback? onSendProgress, + CancelToken? cancelToken, + }) async { + // Implementation for standalone image upload + } + + @override + Future uploadFile( + AttachmentFile file, { + ProgressCallback? onSendProgress, + CancelToken? cancelToken, + }) async { + // Implementation for standalone file upload + } + + @override + Future removeImage( + String url, { + CancelToken? cancelToken, + }) async { + // Implementation for standalone image removal + } + + @override + Future removeFile( + String url, { + CancelToken? cancelToken, + }) async { + // Implementation for standalone file removal + } +} +``` + +> **Important:** +> - Custom `AttachmentFileUploader` implementations must now implement four additional methods +> - The new methods support standalone uploads/removals without requiring channel context +> - `UploadImageResponse` and `UploadFileResponse` are aliases for `SendAttachmentResponse` + +--- + +## Unread Threads Banner + +#### Key Changes: + +- `StreamUnreadThreadsBanner` is now a **wrapper widget** instead of a standalone banner placed in a `Column`. +- New `child` parameter — wrap your `StreamThreadListView` instead of placing the banner as a sibling. +- `onTap` (`VoidCallback?`) replaced by `onRefresh` (`Future Function()?`) — shows a loading spinner while the future is pending. +- `minHeight` parameter **removed**. +- `margin` default changed from `EdgeInsets.symmetric(horizontal: 8, vertical: 6)` to `EdgeInsets.zero`. +- `padding` default changed from `EdgeInsets.symmetric(horizontal: 16)` to `EdgeInsets.all(spacing.sm)`. + +#### Migration Steps: + +**Before:** +```dart +Column( + children: [ + ValueListenableBuilder( + valueListenable: controller.unseenThreadIds, + builder: (_, unreadThreads, __) => StreamUnreadThreadsBanner( + unreadThreads: unreadThreads, + onTap: () => controller + .refresh(resetValue: false) + .then((_) => controller.clearUnseenThreadIds()), + ), + ), + Expanded( + child: StreamThreadListView(controller: controller), + ), + ], +); +``` + +**After:** +```dart +ValueListenableBuilder>( + valueListenable: controller.unseenThreadIds, + builder: (context, unseenThreadIds, child) => StreamUnreadThreadsBanner( + enabled: unseenThreadIds.isNotEmpty, + unreadThreads: unseenThreadIds, + onRefresh: () async { + await controller.refresh(resetValue: false); + controller.clearUnseenThreadIds(); + }, + child: child!, + ), + child: StreamThreadListView(controller: controller), +); +``` + +--- + +## Appendix: Beta Release Timeline + +This appendix provides a chronological reference of breaking changes by beta version for users upgrading from specific pre-release versions. + +### v10.0.0-beta.1 + +- [StreamReactionPicker](#streamreactionpicker) +- [StreamMessageAction](#streammessageaction) +- [StreamMessageReactionsModal](#streammessagereactionsmodal) +- [StreamMessageItem](#streammessageitem) + +### v10.0.0-beta.3 + +- [AttachmentPickerType](#attachmentpickertype) +- [StreamAttachmentPickerOption](#streamattachmentpickeroption) +- [showStreamAttachmentPickerModalBottomSheet](#showstreamattachmentpickermodalbottomsheet) +- [AttachmentPickerBottomSheet](#attachmentpickerbottomsheet) + +### v10.0.0-beta.4 + +- [SendReaction](#sendreaction) + +### v10.0.0-beta.7 + +- [AttachmentFileUploader](#attachmentfileuploader) +- [MessageState](#messagestate) + +### v10.0.0-beta.8 + +- [customAttachmentPickerOptions](#customattachmentpickeroptions) +- [onCustomAttachmentPickerResult](#oncustomattachmentpickerresult) + +### v10.0.0-beta.9 + +- [onAttachmentTap](#onattachmenttap) +- [ReactionPickerIconList](#reactionpickericonlist) + +### v10.0.0-beta.12 + +- [StreamAttachmentPickerController](#streamattachmentpickercontroller) + +--- + +## Migration Checklist + +### Unread Threads Banner +- [ ] Replace `Column` + `Expanded` layout with `StreamUnreadThreadsBanner(child: StreamThreadListView(...))` +- [ ] Replace `onTap` with `onRefresh` (returns `Future`) +- [ ] Add `enabled: true` to show the banner (defaults to hidden) +- [ ] Remove `minHeight` parameter if used + +### For v10.0.0-beta.12: +- [ ] Replace `ArgumentError('The size of the attachment is...')` with `AttachmentTooLargeError` (provides `fileSize` and `maxSize` properties) +- [ ] Replace `ArgumentError('The maximum number of attachments is...')` with `AttachmentLimitReachedError` (provides `maxCount` property) + +### For v10.0.0-beta.9: +- [ ] Update `onAttachmentTap` callback signature to include `BuildContext` as first parameter +- [ ] Return `FutureOr` from `onAttachmentTap` - `true` if handled, `false` for default behavior +- [ ] Leverage automatic fallback to default handling for standard attachment types (images, videos, URLs) +- [ ] Update any direct usage of `ReactionPickerIconList` to handle reaction selection state externally + +### For v10.0.0-beta.8: +- [ ] Replace `customAttachmentPickerOptions` with `attachmentPickerOptionsBuilder` to access and modify default options +- [ ] Replace `onCustomAttachmentPickerResult` with `onAttachmentPickerResult` that returns `FutureOr` + +### For v10.0.0-beta.7: +- [ ] Update custom `AttachmentFileUploader` implementations to include the four new abstract methods: `uploadImage`, `uploadFile`, `removeImage`, and `removeFile` +- [ ] Update `MessageState` factory constructors to use `MessageDeleteScope` parameter +- [ ] Update pattern-matching callbacks to handle `MessageDeleteScope` instead of `bool hard` +- [ ] Leverage new delete-for-me functionality with `deleteMessageForMe` methods +- [ ] Use new state-checking methods for delete-for-me operations + +### For v10.0.0-beta.4: +- [ ] Update `sendReaction` method calls to use `Reaction` object instead of individual parameters + +### For v10.0.0-beta.3: +- [ ] Update attachment picker options to use `SystemAttachmentPickerOption` or `TabbedAttachmentPickerOption` +- [ ] Handle new `StreamAttachmentPickerResult` return type from attachment picker +- [ ] Use renamed bottom sheet classes (`StreamTabbedAttachmentPickerBottomSheet`, `StreamSystemAttachmentPickerBottomSheet`) + +### For v10.0.0-beta.1: +- [ ] Use `StreamReactionPicker.builder` or supply `onReactionPicked` +- [ ] Convert all `StreamMessageAction` instances to type-safe generic usage +- [ ] Centralize handling with `onCustomActionTap` +- [ ] Remove deprecated props like `showReactionTail` and `messageTheme` + +--- + +**You're ready to migrate!** For additional help, visit the [Stream Chat Flutter documentation](https://getstream.io/chat/docs/sdk/flutter/) or open an issue on [GitHub](https://github.com/GetStream/stream-chat-flutter/issues). \ No newline at end of file diff --git a/packages/stream_chat/CHANGELOG.md b/packages/stream_chat/CHANGELOG.md index 1998c95779..3b4c7d334b 100644 --- a/packages/stream_chat/CHANGELOG.md +++ b/packages/stream_chat/CHANGELOG.md @@ -1,7 +1,45 @@ +## Upcoming + +✅ Added + +- Added `StreamChatClient.recoverStateOnReconnect` (defaults to `true`); when `false`, the client no longer auto-re-queries active channels on connection recovery — useful for consumers driving their own refresh from the `connectionRecovered` event. +- Added `Message.updateWith(Message? other)` — merges a server-side update onto the local message while preserving locally-known `poll`, `sharedLocation`, `ownReactions`, and nested `quotedMessage` enrichment when the server omits them. +- Added `Channel.isOneToOne` — true when the channel is `isDistinct` and has exactly two members. For the looser count-only check, inline `channel.memberCount == 2`. + +⚠️ Deprecated + +- Deprecated `Message.syncWith` in favor of `Message.updateWith`. Note the arguments are flipped: `local.updateWith(remote)` replaces `remote.syncWith(local)`. + +🔄 Changed + +- Tightened `Channel.isGroup` from `memberCount != 2` to `memberCount > 2 || !isDistinct`. Two-member non-distinct channels now correctly report as groups, and 1-member distinct channels no longer do. Migrate via `!channel.isOneToOne` or `channel.memberCount != 2`. +- Tightened `Channel.isDistinct` to require the `!members-` prefix (with trailing dash), matching the backend's `DistinctChannelPrefix` constant. Real server-generated ids always include the dash; only malformed/test ids that previously matched the looser `!members` check are affected. + +🐞 Fixed + +- Fixed reactions, polls, and quoted-message enrichment briefly flickering after the app returned from the background. The reconnect path now refreshes channels and advances `lastSyncAt` to the current time instead of replaying every event since `lastSyncAt` through `handleEvent`. `client.sync()` remains available for consumers that need event-level replay. +- Fixed `Channel.sendMessage` / `Channel.updateMessage` hanging forever when any attachment upload failed; they now throw `StreamChatError`. +- Fixed quoted poll messages losing their poll, shared-location, or nested-quote content when the server omits it from the `quoted_message` payload during channel re-sync. +- Fixed a poll attached to a parent message disappearing when a thread reply was added; partial `message.updated` events no longer clobber locally-known `poll` / `sharedLocation` on the parent. + +## 10.0.0-beta.13 + +🛑️ Breaking +- SDK Redesign Changes. For more details, please refer to the [migration guide](https://github.com/GetStream/stream-chat-flutter/blob/210ff93f955be3f85c62e860309bd9aa240a5446/migrations). + The SDK redesign introduces a fresher default UI, but also better APIs for customization of the components. + +## 10.0.0-beta.12 + +- Included the changes from version [`9.23.0`](https://pub.dev/packages/stream_chat/changelog). + ## 9.23.0 - Minor bug fixes and improvements +## 10.0.0-beta.11 + +- Included the changes from version [`9.22.0`](https://pub.dev/packages/stream_chat/changelog). + ## 9.22.0 ✅ Added @@ -13,6 +51,10 @@ specify a timestamp before which channel history should be hidden for newly added members. When provided, it takes precedence over the `hideHistory` boolean flag. +## 10.0.0-beta.10 + +- Included the changes from version [`9.21.0`](https://pub.dev/packages/stream_chat/changelog). + ## 9.21.0 🐞 Fixed @@ -20,6 +62,15 @@ - Fixed user's ID from being inadvertently used as their display name during the WebSocket connection process. [[#2447]](https://github.com/GetStream/stream-chat-flutter/issues/2447) +## 10.0.0-beta.9 + +🐞 Fixed + +- Fixed `Location.endAt` field not being properly converted to UTC, causing "expected date" API + errors when sending location messages. + +- Included the changes from version [`9.20.0`](https://pub.dev/packages/stream_chat/changelog). + ## 9.20.0 ✅ Added @@ -44,10 +95,47 @@ - `markRead`, `markUnread`, `markThreadRead`, and `markThreadUnread` methods now throw `StreamChatError` when channel lacks required capabilities. +## 10.0.0-beta.8 + +✅ Added + +- Added support for `user.messages.deleted` event. + +- Included the changes from version [`9.19.0`](https://pub.dev/packages/stream_chat/changelog). + ## 9.19.0 - Minor bug fixes and improvements +## 10.0.0-beta.7 + +🛑️ Breaking + +- **Changed `MessageState` factory constructors**: The `deleting`, `deleted`, and `deletingFailed` + factory constructors now accept a `MessageDeleteScope` parameter instead of `bool hard`. + Pattern matching callbacks also receive `MessageDeleteScope scope` instead of `bool hard`. +- **Added new abstract methods to `AttachmentFileUploader`**: The `AttachmentFileUploader` interface + now includes four new abstract methods (`uploadImage`, `uploadFile`, `removeImage`, `removeFile`). + Custom implementations must implement these methods. + +For more details, please refer to the [migration guide](../../migrations/v10-migration.md). + +✅ Added + +- Added support for deleting messages only for the current user: + - `Channel.deleteMessageForMe()` - Delete a message only for the current user + - `StreamChatClient.deleteMessageForMe()` - Delete a message only for the current user via client + - `MessageDeleteScope` - New sealed class to represent deletion scope + - `MessageState.deletingForMe`, `MessageState.deletedForMe`, `MessageState.deletingForMeFailed` states + - `Message.deletedOnlyForMe`, `Event.deletedForMe`, `Member.deletedMessages` model fields +- Added standalone file and image upload/removal methods for CDN operations: + - `StreamChatClient.uploadImage()` - Upload an image to the Stream CDN + - `StreamChatClient.uploadFile()` - Upload a file to the Stream CDN + - `StreamChatClient.removeImage()` - Remove an image from the Stream CDN + - `StreamChatClient.removeFile()` - Remove a file from the Stream CDN + +- Included the changes from version [`9.18.0`](https://pub.dev/packages/stream_chat/changelog). + ## 9.18.0 🐞 Fixed @@ -68,6 +156,10 @@ - Fixed `ChannelState.memberCount`, `ChannelState.config` and `ChannelState.extraData` getting reset on first load. +## 10.0.0-beta.6 + +- Included the changes from version [`9.17.0`](https://pub.dev/packages/stream_chat/changelog). + ## 9.17.0 🐞 Fixed @@ -77,10 +169,15 @@ during upload. - Fixed `toDraftMessage` to only include successfully uploaded attachments in draft messages. +## 10.0.0-beta.5 + +- Included the changes from version [`9.16.0`](https://pub.dev/packages/stream_chat/changelog). + ## 9.16.0 🐞 Fixed +- Fixed `skipPush` and `skipEnrichUrl` not preserving during message send or update retry - Fixed `Channel` methods to throw proper `StateError` exceptions instead of relying on assertions for state validation. - Fixed `OwnUser` specific fields getting lost when creating a new `OwnUser` instance from @@ -92,6 +189,25 @@ - Added support for `Client.setPushPreferences` which allows setting PushPreferences for the current user or for a specific channel. +## 10.0.0-beta.4 + +🛑️ Breaking + +- **Changed `sendReaction` method signature**: The `sendReaction` method on both `Client` and + `Channel` now accepts a full `Reaction` object instead of individual parameters (`type`, `score`, + `extraData`). This change provides more flexibility and better type safety. + +✅ Added + +- Added comprehensive location sharing support with static and live location features: + - `Channel.sendStaticLocation()` - Send a static location message to the channel + - `Channel.startLiveLocationSharing()` - Start sharing live location with automatic updates + - `Channel.activeLiveLocations` - Track members active live location shares in the channel + - `Client.activeLiveLocations` - Access current user active live location shares across channels + - Location event listeners for `locationShared`, `locationUpdated`, and `locationExpired` events + +- Included the changes from version [`9.15.0`](https://pub.dev/packages/stream_chat/changelog). + ## 9.15.0 ✅ Added @@ -106,6 +222,18 @@ - Fixed draft message persistence issues where removed drafts were not properly deleted from the database. +## 10.0.0-beta.3 + +🛑️ Breaking + +- **Deprecated API Cleanup**: Removed all deprecated classes, methods, and properties for the v10 major release: + - **Removed Classes**: `PermissionType` (use string constants like `'delete-channel'`, `'update-channel'`), `CallApi`, `CallPayload`, `CallTokenPayload`, `CreateCallPayload` + - **Removed Methods**: `cooldownStartedAt` getter from `Channel`, `getCallToken` and `createCall` from `StreamChatClient` + - **Removed Properties**: `reactionCounts` and `reactionScores` getters from `Message` (use `reactionGroups` instead), `call` property from `StreamChatApi` + - **Removed Files**: `permission_type.dart`, `call_api.dart`, `call_payload.dart` and their associated tests + +- Included the changes from version [`9.14.0`](https://pub.dev/packages/stream_chat/changelog). + ## 9.14.0 🐞 Fixed @@ -124,10 +252,18 @@ - Deprecated `SortOption.new` constructor in favor of `SortOption.desc` and `SortOption.asc`. +## 10.0.0-beta.2 + +- Included the changes from version [`9.13.0`](https://pub.dev/packages/stream_chat/changelog). + ## 9.13.0 - Bug fixes and improvements +## 10.0.0-beta.1 + +- Bug fixes and improvements + ## 9.12.0 ✅ Added diff --git a/packages/stream_chat/example/lib/main.dart b/packages/stream_chat/example/lib/main.dart index 0744726424..f85f4d99b3 100644 --- a/packages/stream_chat/example/lib/main.dart +++ b/packages/stream_chat/example/lib/main.dart @@ -17,8 +17,7 @@ Future main() async { User( id: 'cool-shadow-7', name: 'Cool Shadow', - image: - 'https://getstream.io/random_png/?id=cool-shadow-7&name=Cool+shadow', + image: 'https://getstream.io/random_png/?id=cool-shadow-7&name=Cool+shadow', ), '''eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ1c2VyX2lkIjoiY29vbC1zaGFkb3ctNyJ9.gkOlCRb1qgy4joHPaxFwPOdXcGvSPvp6QY0S4mpRkVo''', ); @@ -60,9 +59,9 @@ class StreamExample extends StatelessWidget { @override Widget build(BuildContext context) => MaterialApp( - title: 'Stream Chat Dart Example', - home: HomeScreen(channel: channel), - ); + title: 'Stream Chat Dart Example', + home: HomeScreen(channel: channel), + ); } /// Main screen of our application. The layout is comprised of an [AppBar] @@ -87,30 +86,31 @@ class HomeScreen extends StatelessWidget { body: SafeArea( child: StreamBuilder?>( stream: messages, - builder: ( - BuildContext context, - AsyncSnapshot?> snapshot, - ) { - if (snapshot.hasData && snapshot.data != null) { - return MessageView( - messages: snapshot.data!.reversed.toList(), - channel: channel, - ); - } else if (snapshot.hasError) { - return const Center( - child: Text( - 'There was an error loading messages. Please see logs.', - ), - ); - } - return const Center( - child: SizedBox( - width: 100, - height: 100, - child: CircularProgressIndicator(), - ), - ); - }, + builder: + ( + BuildContext context, + AsyncSnapshot?> snapshot, + ) { + if (snapshot.hasData && snapshot.data != null) { + return MessageView( + messages: snapshot.data!.reversed.toList(), + channel: channel, + ); + } else if (snapshot.hasError) { + return const Center( + child: Text( + 'There was an error loading messages. Please see logs.', + ), + ); + } + return const Center( + child: SizedBox( + width: 100, + height: 100, + child: CircularProgressIndicator(), + ), + ); + }, ), ), ); @@ -168,80 +168,80 @@ class _MessageViewState extends State { @override Widget build(BuildContext context) => Column( - children: [ - Expanded( - child: ListView.builder( - controller: _scrollController, - itemCount: _messages.length, - reverse: true, - itemBuilder: (BuildContext context, int index) { - final item = _messages[index]; - if (item.user?.id == widget.channel.client.uid) { - return Align( - alignment: Alignment.centerRight, - child: Padding( - padding: const EdgeInsets.all(8), - child: Text(item.text ?? ''), - ), - ); - } else { - return Align( - alignment: Alignment.centerLeft, - child: Padding( - padding: const EdgeInsets.all(8), - child: Text(item.text ?? ''), - ), - ); - } - }, - ), - ), - Padding( - padding: const EdgeInsets.all(8), - child: Row( - children: [ - Expanded( - child: TextField( - controller: _controller, - decoration: const InputDecoration( - hintText: 'Enter your message', - ), - ), + children: [ + Expanded( + child: ListView.builder( + controller: _scrollController, + itemCount: _messages.length, + reverse: true, + itemBuilder: (BuildContext context, int index) { + final item = _messages[index]; + if (item.user?.id == widget.channel.client.uid) { + return Align( + alignment: Alignment.centerRight, + child: Padding( + padding: const EdgeInsets.all(8), + child: Text(item.text ?? ''), + ), + ); + } else { + return Align( + alignment: Alignment.centerLeft, + child: Padding( + padding: const EdgeInsets.all(8), + child: Text(item.text ?? ''), + ), + ); + } + }, + ), + ), + Padding( + padding: const EdgeInsets.all(8), + child: Row( + children: [ + Expanded( + child: TextField( + controller: _controller, + decoration: const InputDecoration( + hintText: 'Enter your message', ), - Material( - type: MaterialType.circle, - color: Colors.blue, - clipBehavior: Clip.hardEdge, - child: InkWell( - onTap: () async { - // We can send a new message by calling `sendMessage` on - // the current channel. After sending a message, the - // TextField is cleared and the list view is scrolled - // to show the new item. - if (_controller.value.text.isNotEmpty) { - await widget.channel.sendMessage( - Message(text: _controller.value.text), - ); - _controller.clear(); - _updateList(); - } - }, - child: const Padding( - padding: EdgeInsets.all(8), - child: Center( - child: Icon( - Icons.send, - color: Colors.white, - ), - ), + ), + ), + Material( + type: MaterialType.circle, + color: Colors.blue, + clipBehavior: Clip.hardEdge, + child: InkWell( + onTap: () async { + // We can send a new message by calling `sendMessage` on + // the current channel. After sending a message, the + // TextField is cleared and the list view is scrolled + // to show the new item. + if (_controller.value.text.isNotEmpty) { + await widget.channel.sendMessage( + Message(text: _controller.value.text), + ); + _controller.clear(); + _updateList(); + } + }, + child: const Padding( + padding: EdgeInsets.all(8), + child: Center( + child: Icon( + Icons.send, + color: Colors.white, ), ), ), - ], + ), ), - ), - ], - ); + ], + ), + ), + ], + ); } /// Helper extension for quickly retrieving diff --git a/packages/stream_chat/example/pubspec.yaml b/packages/stream_chat/example/pubspec.yaml index 6f50b1864b..02420b70c3 100644 --- a/packages/stream_chat/example/pubspec.yaml +++ b/packages/stream_chat/example/pubspec.yaml @@ -17,14 +17,14 @@ version: 1.0.0+1 # 2. Add it to the melos.yaml file for future updates. environment: - sdk: ^3.6.2 - flutter: ">=3.27.4" + sdk: ^3.10.0 + flutter: ">=3.38.1" dependencies: cupertino_icons: ^1.0.3 flutter: sdk: flutter - stream_chat: ^9.23.0 + stream_chat: ^10.0.0-beta.13 flutter: uses-material-design: true diff --git a/packages/stream_chat/lib/src/client/channel.dart b/packages/stream_chat/lib/src/client/channel.dart index 9c30799e60..8654c1599a 100644 --- a/packages/stream_chat/lib/src/client/channel.dart +++ b/packages/stream_chat/lib/src/client/channel.dart @@ -75,25 +75,25 @@ class Channel { String? name, String? image, Map? extraData, - }) : _cid = _id != null ? '$_type:$_id' : null, - _extraData = { - ...?extraData, - if (name != null) 'name': name, - if (image != null) 'image': image, - } { + }) : _cid = _id != null ? '$_type:$_id' : null, + _extraData = { + ...?extraData, + if (name != null) 'name': name, + if (image != null) 'image': image, + } { _client.logger.info('New Channel instance created, not yet initialized'); } /// Create a channel client instance from a [ChannelState] object. Channel.fromState(this._client, ChannelState channelState) - : assert( - channelState.channel != null, - 'No channel found inside channel state', - ), - _id = channelState.channel!.id, - _type = channelState.channel!.type, - _cid = channelState.channel!.cid, - _extraData = channelState.channel!.extraData { + : assert( + channelState.channel != null, + 'No channel found inside channel state', + ), + _id = channelState.channel!.id, + _type = channelState.channel!.type, + _cid = channelState.channel!.cid, + _extraData = channelState.channel!.extraData { _initState(channelState); // Initialize the state immediately. } @@ -143,24 +143,57 @@ class Channel { _extraData.addAll(extraData); } + /// Whether this channel is identified by its member set rather than an + /// explicit id. + /// + /// Stream auto-generates ids of the form `!members-` for channels + /// created with members but no id, so the same set of users always + /// references the same channel. + /// + /// Distinct channels can lose members but can't gain them after creation. + /// + /// See [isOneToOne] for the typical 1-to-1 predicate built on this. + bool get isDistinct => id?.startsWith('!members') == true; + + /// Whether this is a group channel. + /// + /// True when the channel has more than two members, or isn't [isDistinct]. + /// Custom-id channels are treated as groups regardless of current member + /// count because they aren't bounded — they can grow back into + /// multi-person conversations. + /// + /// Near-inverse of [isOneToOne]. + bool get isGroup => (memberCount ?? 0) > 2 || !isDistinct; + + /// Whether this is a 1-to-1 conversation. + /// + /// True when the channel is [isDistinct] and has exactly two members. + /// Distinct channels can't gain members, so a 2-member distinct channel + /// is permanently bounded to two participants — including channels that + /// shrunk down from a larger group DM. + /// + /// This is a structural predicate without a current-user check. Combine + /// with capability / permission checks at the call site if you need + /// perspective gating. + /// + /// Near-inverse of [isGroup]. + bool get isOneToOne => isDistinct && memberCount == 2; + /// Returns true if the channel is muted. - bool get isMuted => - _client.state.currentUser?.channelMutes - .any((element) => element.channel.cid == cid) == - true; + bool get isMuted { + final channelMutes = _client.state.currentUser?.channelMutes; + if (channelMutes == null) return false; - /// Returns true if the channel is muted, as a stream. - Stream get isMutedStream => _client.state.currentUserStream - .map((event) => - event?.channelMutes.any((element) => element.channel.cid == cid) == - true) - .distinct(); + return channelMutes.any((it) => it.channel.cid == cid); + } - /// True if the channel is a group. - bool get isGroup => memberCount != 2; + /// Returns true if the channel is muted, as a stream. + Stream get isMutedStream => _client.state.currentUserStream.map((user) { + final channelMutes = user?.channelMutes; + if (channelMutes == null) return false; - /// True if the channel is distinct. - bool get isDistinct => id?.startsWith('!members') == true; + return channelMutes.any((it) => it.channel.cid == cid); + }).distinct(); /// Channel configuration. ChannelConfig? get config { @@ -304,15 +337,6 @@ class Channel { return math.max(0, cooldownDuration - elapsedTime); } - /// Stores time at which cooldown was started - @Deprecated( - "Use a combination of 'remainingCooldown' and 'currentUserLastMessageAt'", - ) - DateTime? get cooldownStartedAt { - if (getRemainingCooldown() <= 0) return null; - return currentUserLastMessageAt; - } - /// Channel creation date. DateTime? get createdAt { _checkInitialized(); @@ -465,15 +489,12 @@ class Channel { } /// List of user permissions on this channel - List get ownCapabilities => - state?._channelState.channel?.ownCapabilities ?? []; + List get ownCapabilities => state?._channelState.channel?.ownCapabilities ?? []; /// List of user permissions on this channel Stream> get ownCapabilitiesStream { _checkInitialized(); - return state!.channelStateStream - .map((cs) => cs.channel?.ownCapabilities ?? []) - .distinct(); + return state!.channelStateStream.map((cs) => cs.channel?.ownCapabilities ?? []).distinct(); } /// Channel extra data as a stream. @@ -602,83 +623,93 @@ class Channel { } } - return Future.wait(attachments.map((it) { - client.logger.info('Uploading ${it.id} attachment...'); - - final throttledUpdateAttachment = updateAttachment.throttled( - const Duration(milliseconds: 500), - ); - - void onSendProgress(int sent, int total) { - throttledUpdateAttachment([ - it.copyWith( - uploadState: UploadState.inProgress(uploaded: sent, total: total), - ), - ]); - } + return Future.wait( + attachments.map((it) { + client.logger.info('Uploading ${it.id} attachment...'); - final isImage = it.type == AttachmentType.image; - final cancelToken = CancelToken(); - Future future; - if (isImage) { - future = sendImage( - it.file!, - onSendProgress: onSendProgress, - cancelToken: cancelToken, - extraData: it.extraData, - ); - } else { - future = sendFile( - it.file!, - onSendProgress: onSendProgress, - cancelToken: cancelToken, - extraData: it.extraData, + final throttledUpdateAttachment = updateAttachment.throttled( + const Duration(milliseconds: 500), ); - } - _cancelableAttachmentUploadRequest[it.id] = cancelToken; - return future.then((response) { - client.logger.info('Attachment ${it.id} uploaded successfully...'); - - // If the response is SendFileResponse, then we might also be getting - // thumbUrl in case of video. So we need to update the attachment with - // both the assetUrl and thumbUrl. - if (response is SendFileResponse) { - updateAttachment( + + void onSendProgress(int sent, int total) { + throttledUpdateAttachment([ it.copyWith( - assetUrl: response.file, - thumbUrl: response.thumbUrl, - uploadState: const UploadState.success(), + uploadState: UploadState.inProgress(uploaded: sent, total: total), ), + ]); + } + + final isImage = it.type == AttachmentType.image; + final cancelToken = CancelToken(); + Future future; + if (isImage) { + future = sendImage( + it.file!, + onSendProgress: onSendProgress, + cancelToken: cancelToken, + extraData: it.extraData, ); } else { - updateAttachment( - it.copyWith( - imageUrl: response.file, - uploadState: const UploadState.success(), - ), + future = sendFile( + it.file!, + onSendProgress: onSendProgress, + cancelToken: cancelToken, + extraData: it.extraData, ); } - }).catchError((e, stk) { - if (e is StreamChatNetworkError && e.isRequestCancelledError) { - client.logger.info('Attachment ${it.id} upload cancelled'); - - // remove attachment from message if cancelled. - updateAttachment(it, remove: true); - return; - } + _cancelableAttachmentUploadRequest[it.id] = cancelToken; + return future + .then((response) { + client.logger.info('Attachment ${it.id} uploaded successfully...'); + + // If the response is SendFileResponse, then we might also be getting + // thumbUrl in case of video. So we need to update the attachment with + // both the assetUrl and thumbUrl. + if (response is SendFileResponse) { + updateAttachment( + it.copyWith( + assetUrl: response.file, + thumbUrl: response.thumbUrl, + uploadState: const UploadState.success(), + ), + ); + } else { + updateAttachment( + it.copyWith( + imageUrl: response.file, + uploadState: const UploadState.success(), + ), + ); + } + }) + .catchError((e, stk) { + if (e is StreamChatNetworkError && e.isRequestCancelledError) { + client.logger.info('Attachment ${it.id} upload cancelled'); + + // remove attachment from message if cancelled. + updateAttachment(it, remove: true); + return; + } - client.logger.severe('error uploading the attachment', e, stk); - updateAttachment( - it.copyWith(uploadState: UploadState.failed(error: e.toString())), - ); - }).whenComplete(() { - throttledUpdateAttachment.cancel(); - _cancelableAttachmentUploadRequest.remove(it.id); - }); - })).whenComplete(() { - if (message!.attachments.every((it) => it.uploadState.isSuccess)) { - _messageAttachmentsUploadCompleter.remove(messageId)?.complete(message); - } + client.logger.severe('error uploading the attachment', e, stk); + updateAttachment( + it.copyWith(uploadState: UploadState.failed(error: e.toString())), + ); + }) + .whenComplete(() { + throttledUpdateAttachment.cancel(); + _cancelableAttachmentUploadRequest.remove(it.id); + }); + }), + ).whenComplete(() { + final completer = _messageAttachmentsUploadCompleter.remove(messageId); + if (completer == null || completer.isCompleted) return; + + // Always complete with the latest message view so callers can decide + // success vs. partial failure by inspecting per-attachment upload + // states. Cancellation is still surfaced via `completeError` from the + // sendMessage/updateMessage/deleteMessage entry points. + completer.complete(message); }); } @@ -698,13 +729,11 @@ class Channel { _checkInitialized(); // Clean up stale error messages before sending a new message. - state!.cleanUpStaleErrorMessages(); + state?.cleanUpStaleErrorMessages(); // Cancelling previous completer in case it's called again in the process // Eg. Updating the message while the previous call is in progress. - _messageAttachmentsUploadCompleter - .remove(message.id) - ?.completeError(const StreamChatError('Message cancelled')); + _messageAttachmentsUploadCompleter.remove(message.id)?.completeError(const StreamChatError('Message cancelled')); final quotedMessage = state!.messages.firstWhereOrNull( (m) => m.id == message.quotedMessageId, @@ -723,13 +752,12 @@ class Channel { ).toList(), ); - state!.updateMessage(message); + state?.updateMessage(message); try { if (message.attachments.any((it) => !it.uploadState.isSuccess)) { final attachmentsUploadCompleter = Completer(); - _messageAttachmentsUploadCompleter[message.id] = - attachmentsUploadCompleter; + _messageAttachmentsUploadCompleter[message.id] = attachmentsUploadCompleter; _uploadAttachments( message.id, @@ -738,6 +766,11 @@ class Channel { // ignore: parameter_assignments message = await attachmentsUploadCompleter.future; + + // Fail the whole message if any attachment failed to upload + if (message.attachments.any((it) => it.uploadState.isFailed)) { + throw const StreamChatError('Failed to upload one or more attachments'); + } } // Validate the final message before sending it to the server. @@ -761,22 +794,29 @@ class Channel { ), ); - final sentMessage = response.message.syncWith(message).copyWith( + final sentMessage = message + .updateWith(response.message) + .copyWith( // Update the message state to sent. state: MessageState.sent, ); - state!.updateMessage(sentMessage); + state?.updateMessage(sentMessage); return response; } catch (e) { + final failedMessage = message.copyWith( + // Update the message state to failed. + state: MessageState.sendingFailed( + skipPush: skipPush, + skipEnrichUrl: skipEnrichUrl, + ), + ); + + state?.updateMessage(failedMessage); + // If the error is retriable, add it to the retry queue. if (e is StreamChatNetworkError && e.isRetriable) { - state!._retryQueue.add([ - message.copyWith( - // Update the message state to failed. - state: MessageState.sendingFailed, - ), - ]); + state?._retryQueue.add([failedMessage]); } rethrow; @@ -795,13 +835,10 @@ class Channel { bool skipEnrichUrl = false, }) async { _checkInitialized(); - final originalMessage = message; // Cancelling previous completer in case it's called again in the process // Eg. Updating the message while the previous call is in progress. - _messageAttachmentsUploadCompleter - .remove(message.id) - ?.completeError(const StreamChatError('Message cancelled')); + _messageAttachmentsUploadCompleter.remove(message.id)?.completeError(const StreamChatError('Message cancelled')); // ignore: parameter_assignments message = message.copyWith( @@ -820,8 +857,7 @@ class Channel { try { if (message.attachments.any((it) => !it.uploadState.isSuccess)) { final attachmentsUploadCompleter = Completer(); - _messageAttachmentsUploadCompleter[message.id] = - attachmentsUploadCompleter; + _messageAttachmentsUploadCompleter[message.id] = attachmentsUploadCompleter; _uploadAttachments( message.id, @@ -830,6 +866,11 @@ class Channel { // ignore: parameter_assignments message = await attachmentsUploadCompleter.future; + + // Fail the whole message if any attachment failed to upload + if (message.attachments.any((it) => it.uploadState.isFailed)) { + throw const StreamChatError('Failed to upload one or more attachments'); + } } // Wait for the previous update call to finish. Otherwise, the order of @@ -842,32 +883,31 @@ class Channel { ), ); - final updateMessage = response.message.syncWith(message).copyWith( + final updateMessage = message + .updateWith(response.message) + .copyWith( // Update the message state to updated. state: MessageState.updated, - ownReactions: message.ownReactions, ); state?.updateMessage(updateMessage); return response; } catch (e) { - if (e is StreamChatNetworkError) { - if (e.isRetriable) { - state!._retryQueue.add([ - message.copyWith( - // Update the message state to failed. - state: MessageState.updatingFailed, - ), - ]); - } else { - // Reset the message to original state if the update fails and is not - // retriable. - state?.updateMessage(originalMessage.copyWith( - state: MessageState.updatingFailed, - )); - } + final failedMessage = message.copyWith( + // Update the message state to failed. + state: MessageState.updatingFailed( + skipPush: skipPush, + skipEnrichUrl: skipEnrichUrl, + ), + ); + + state?.updateMessage(failedMessage); + // If the error is retriable, add it to the retry queue. + if (e is StreamChatNetworkError && e.isRetriable) { + state?._retryQueue.add([failedMessage]); } + rethrow; } } @@ -884,13 +924,10 @@ class Channel { bool skipEnrichUrl = false, }) async { _checkInitialized(); - final originalMessage = message; // Cancelling previous completer in case it's called again in the process // Eg. Updating the message while the previous call is in progress. - _messageAttachmentsUploadCompleter - .remove(message.id) - ?.completeError(const StreamChatError('Message cancelled')); + _messageAttachmentsUploadCompleter.remove(message.id)?.completeError(const StreamChatError('Message cancelled')); // ignore: parameter_assignments message = message.copyWith( @@ -912,31 +949,30 @@ class Channel { ), ); - final updatedMessage = response.message.syncWith(message).copyWith( + final updatedMessage = message + .updateWith(response.message) + .copyWith( // Update the message state to updated. state: MessageState.updated, - ownReactions: message.ownReactions, ); state?.updateMessage(updatedMessage); return response; } catch (e) { - if (e is StreamChatNetworkError) { - if (e.isRetriable) { - state!._retryQueue.add([ - message.copyWith( - // Update the message state to failed. - state: MessageState.updatingFailed, - ), - ]); - } else { - // Reset the message to original state if the update fails and is not - // retriable. - state?.updateMessage(originalMessage.copyWith( - state: MessageState.updatingFailed, - )); - } + final failedMessage = message.copyWith( + // Update the message state to failed. + state: MessageState.partialUpdatingFailed( + set: set, + unset: unset, + skipEnrichUrl: skipEnrichUrl, + ), + ); + + state?.updateMessage(failedMessage); + // If the error is retriable, add it to the retry queue. + if (e is StreamChatNetworkError && e.isRetriable) { + state?._retryQueue.add([failedMessage]); } rethrow; @@ -945,31 +981,49 @@ class Channel { final _deleteMessageLock = Lock(); - /// Deletes the [message] from the channel. - Future deleteMessage( + /// Deletes the [message] for everyone. + /// + /// If [hard] is true, the message is permanently deleted from the server + /// and cannot be recovered. In this case, any attachments associated with the + /// message are also deleted from the server. + Future deleteMessage(Message message, {bool hard = false}) { + final deletionScope = MessageDeleteScope.deleteForAll(hard: hard); + + return _deleteMessage(message, scope: deletionScope); + } + + /// Deletes the [message] only for the current user. + /// + /// Note: This does not delete the message for other channel members and + /// they can still see the message. + Future deleteMessageForMe(Message message) { + const deletionScope = MessageDeleteScope.deleteForMe(); + + return _deleteMessage(message, scope: deletionScope); + } + + // Deletes the [message] from the channel. + // + // The [scope] defines whether to delete the message for everyone or just + // for the current user. + // + // If the message is a local message (not yet sent to the server) or a bounced + // error message, it is deleted locally without making an API call. + // + // If the message is deleted for everyone and [scope.hard] is true, the + // message is permanently deleted from the server and cannot be recovered. + // In this case, any attachments associated with the message are also deleted + // from the server. + Future _deleteMessage( Message message, { - bool hard = false, + required MessageDeleteScope scope, }) async { _checkInitialized(); // Directly deleting the local messages and bounced error messages as they // are not available on the server. if (message.remoteCreatedAt == null || message.isBouncedWithError) { - state!.deleteMessage( - message.copyWith( - type: MessageType.deleted, - localDeletedAt: DateTime.now(), - state: MessageState.deleted(hard: hard), - ), - hardDelete: hard, - ); - - // Removing the attachments upload completer to stop the `sendMessage` - // waiting for attachments to complete. - _messageAttachmentsUploadCompleter - .remove(message.id) - ?.completeError(const StreamChatError('Message deleted')); - + _deleteLocalMessage(message); // Returning empty response to mark the api call as success. return EmptyResponse(); } @@ -978,64 +1032,139 @@ class Channel { message = message.copyWith( type: MessageType.deleted, deletedAt: DateTime.now(), - state: MessageState.deleting(hard: hard), + deletedForMe: scope is DeleteForMe, + state: MessageState.deleting(scope: scope), ); - state?.deleteMessage(message, hardDelete: hard); + state?.deleteMessage(message, hardDelete: scope.hard); try { // Wait for the previous delete call to finish. Otherwise, the order of // messages will not be maintained. final response = await _deleteMessageLock.synchronized( - () => _client.deleteMessage(message.id, hard: hard), + () => switch (scope) { + DeleteForMe() => _client.deleteMessageForMe(message.id), + DeleteForAll() => _client.deleteMessage(message.id, hard: scope.hard), + }, ); final deletedMessage = message.copyWith( - state: MessageState.deleted(hard: hard), + deletedForMe: scope is DeleteForMe, + state: MessageState.deleted(scope: scope), ); - state?.deleteMessage(deletedMessage, hardDelete: hard); - - if (hard) { - deletedMessage.attachments.forEach((attachment) { - if (attachment.uploadState.isSuccess) { - if (attachment.type == AttachmentType.image) { - deleteImage(attachment.imageUrl!); - } else if (attachment.type == AttachmentType.file) { - deleteFile(attachment.assetUrl!); - } - } - }); - } + state?.deleteMessage(deletedMessage, hardDelete: scope.hard); + // If hard delete, also delete the attachments from the server. + if (scope.hard) _deleteMessageAttachments(deletedMessage); return response; } catch (e) { + final failedMessage = message.copyWith( + // Update the message state to failed. + state: MessageState.deletingFailed(scope: scope), + ); + + state?.deleteMessage(failedMessage, hardDelete: scope.hard); + // If the error is retriable, add it to the retry queue. if (e is StreamChatNetworkError && e.isRetriable) { - state!._retryQueue.add([ - message.copyWith( - // Update the message state to failed. - state: MessageState.deletingFailed(hard: hard), - ), - ]); + state?._retryQueue.add([failedMessage]); } + rethrow; } } - /// Retry the operation on the message based on the failed state. + // Deletes a local [message] that is not yet sent to the server. + // + // This is typically called when a user wants to delete a message that they + // have composed but not yet sent, or if a message failed to send and the user + // wants to remove it from their local view. + void _deleteLocalMessage(Message message) { + state?.deleteMessage( + hardDelete: true, // Local messages are always hard deleted. + message.copyWith( + type: MessageType.deleted, + localDeletedAt: DateTime.now(), + state: MessageState.hardDeleted, + ), + ); + + // Removing the attachments upload completer to stop the `sendMessage` + // waiting for attachments to complete. + final completer = _messageAttachmentsUploadCompleter.remove(message.id); + completer?.completeError(const StreamChatError('Message deleted')); + } + + // Deletes all the attachments associated with the given [message] + // from the server. This is typically called when a message is hard deleted. + Future _deleteMessageAttachments(Message message) async { + final attachments = message.attachments; + final deleteFutures = attachments.map((it) async { + if (it.imageUrl case final url?) return deleteImage(url); + if (it.assetUrl case final url?) return deleteFile(url); + }); + + try { + await Future.wait(deleteFutures); + } catch (e, stk) { + _client.logger.warning('Error deleting message attachments', e, stk); + } + } + + /// Retries operations on a message based on its failed state. + /// + /// This method examines the message's state and performs the appropriate + /// retry action: + /// - For [MessageState.sendingFailed], it attempts to send the message. + /// - For [MessageState.updatingFailed], it attempts to update the message. + /// - For [MessageState.partialUpdatingFailed], it attempts to partially + /// update the message with the same 'set' and 'unset' parameters that were + /// used in the original request. + /// - For [MessageState.deletingFailed], it attempts to delete the message + /// again, using the same scope (for me or for all) as the original request. + /// - For messages with [isBouncedWithError], it attempts to send the message. /// - /// For example, if the message failed to send, it will retry sending the - /// message and vice-versa. + /// Throws a [StateError] if the message is not in a failed state or + /// bounced with an error. Future retryMessage(Message message) async { - assert(message.state.isFailed, 'Message state is not failed'); + assert( + message.state.isFailed || message.isBouncedWithError, + 'Only failed or bounced messages can be retried', + ); return message.state.maybeWhen( failed: (state, _) => state.when( - sendingFailed: () => sendMessage(message), - updatingFailed: () => updateMessage(message), - deletingFailed: (hard) => deleteMessage(message, hard: hard), + sendingFailed: (skipPush, skipEnrichUrl) => sendMessage( + message, + skipPush: skipPush, + skipEnrichUrl: skipEnrichUrl, + ), + updatingFailed: (skipPush, skipEnrichUrl) => updateMessage( + message, + skipPush: skipPush, + skipEnrichUrl: skipEnrichUrl, + ), + partialUpdatingFailed: (set, unset, skipEnrichUrl) { + return partialUpdateMessage( + message, + set: set, + unset: unset, + skipEnrichUrl: skipEnrichUrl, + ); + }, + deletingFailed: (scope) => switch (scope) { + DeleteForMe() => deleteMessageForMe(message), + DeleteForAll(hard: final hard) => deleteMessage(message, hard: hard), + }, ), - orElse: () => throw StateError('Message state is not failed'), + orElse: () { + // Check if the message is bounced with error. + if (message.isBouncedWithError) return sendMessage(message); + + throw StateError( + 'Only failed or bounced messages can be retried', + ); + }, ); } @@ -1045,9 +1174,7 @@ class Channel { Object? /*num|DateTime*/ timeoutOrExpirationDate, }) { assert(() { - if (timeoutOrExpirationDate is! DateTime && - timeoutOrExpirationDate != null && - timeoutOrExpirationDate is! num) { + if (timeoutOrExpirationDate is! DateTime && timeoutOrExpirationDate != null && timeoutOrExpirationDate is! num) { throw ArgumentError('Invalid timeout or Expiration date'); } return true; @@ -1071,13 +1198,12 @@ class Channel { } /// Unpins provided message. - Future unpinMessage(Message message) => - partialUpdateMessage( - message, - set: { - 'pinned': false, - }, - ); + Future unpinMessage(Message message) => partialUpdateMessage( + message, + set: { + 'pinned': false, + }, + ); /// Creates or updates a new [draft] for this channel. Future createDraft( @@ -1108,6 +1234,72 @@ class Channel { return _client.deleteDraft(id!, type, parentId: parentId); } + /// Sends a static location to this channel. + /// + /// Optionally, provide a [messageText] and [extraData] to send along with + /// the location. + Future sendStaticLocation({ + String? id, + String? messageText, + String? createdByDeviceId, + required LocationCoordinates location, + Map extraData = const {}, + }) { + final message = Message( + id: id, + text: messageText, + extraData: extraData, + ); + + final currentUserId = _client.state.currentUser?.id; + final locationMessage = message.copyWith( + sharedLocation: Location( + channelCid: cid, + userId: currentUserId, + messageId: message.id, + latitude: location.latitude, + longitude: location.longitude, + createdByDeviceId: createdByDeviceId, + ), + ); + + return sendMessage(locationMessage); + } + + /// Sends a live location sharing message to this channel. + /// + /// Optionally, provide a [messageText] and [extraData] to send along with + /// the location. + Future startLiveLocationSharing({ + String? id, + String? messageText, + String? createdByDeviceId, + required DateTime endSharingAt, + required LocationCoordinates location, + Map extraData = const {}, + }) { + final message = Message( + id: id, + text: messageText, + extraData: extraData, + ); + + final currentUserId = _client.state.currentUser?.id; + final locationMessage = message.copyWith( + sharedLocation: Location( + channelCid: cid, + userId: currentUserId, + messageId: message.id, + endAt: endSharingAt, + latitude: location.latitude, + longitude: location.longitude, + createdByDeviceId: createdByDeviceId, + ), + ); + + return sendMessage(locationMessage); + } + /// Send a file to this channel. Future sendFile( AttachmentFile file, { @@ -1206,7 +1398,7 @@ class Channel { /// Optionally provide a [messageText] to send a message along with the poll. Future sendPoll( Poll poll, { - String messageText = '', + String? messageText, }) async { _checkInitialized(); final res = await _pollLock.synchronized(() => _client.createPoll(poll)); @@ -1370,28 +1562,17 @@ class Channel { /// Set [enforceUnique] to true to remove the existing user reaction. Future sendReaction( Message message, - String type, { - int score = 1, - Map extraData = const {}, + Reaction reaction, { + bool skipPush = false, bool enforceUnique = false, }) async { _checkInitialized(); - final currentUser = _client.state.currentUser; - if (currentUser == null) { - throw StateError( - 'Cannot send reaction: current user is not available. ' - 'Ensure the client is connected and a user is set.', - ); - } final messageId = message.id; - final reaction = Reaction( - type: type, + // ignore: parameter_assignments + reaction = reaction.copyWith( messageId: messageId, - user: currentUser, - score: score, - createdAt: DateTime.timestamp(), - extraData: extraData, + user: _client.state.currentUser, ); final updatedMessage = message.addMyReaction( @@ -1404,15 +1585,17 @@ class Channel { try { final reactionResp = await _client.sendReaction( messageId, - reaction.type, - score: reaction.score, - extraData: reaction.extraData, + reaction, + skipPush: skipPush, enforceUnique: enforceUnique, ); return reactionResp; } catch (_) { - // Reset the message if the update fails - state?.updateMessage(message); + // Reset the message if the update fails. Use replace (not merge) + // so the rollback wins over the optimistic local state — otherwise + // `Message.updateWith`'s enrichment preservation would keep the + // optimistic `ownReactions` for messages that previously had none. + state?.replaceMessage(message); rethrow; } } @@ -1422,6 +1605,8 @@ class Channel { Message message, Reaction reaction, ) async { + _checkInitialized(); + final updatedMessage = message.deleteMyReaction( reactionType: reaction.type, ); @@ -1435,8 +1620,9 @@ class Channel { ); return deleteResponse; } catch (_) { - // Reset the message if the update fails - state?.updateMessage(message); + // Reset the message if the update fails. Use replace (not merge) + // for symmetry with `sendReaction` — see that method for context. + state?.replaceMessage(message); rethrow; } } @@ -1466,8 +1652,7 @@ class Channel { /// ```dart /// channel.updateName('Updated channel name'); /// ``` - Future updateName(String name) => - updatePartial(set: {'name': name}); + Future updateName(String name) => updatePartial(set: {'name': name}); /// Update the channel's [image]. /// @@ -1484,8 +1669,7 @@ class Channel { /// ```dart /// channel.updateImage('https://getstream.io/new-image'); /// ``` - Future updateImage(String image) => - updatePartial(set: {'image': image}); + Future updateImage(String image) => updatePartial(set: {'image': image}); /// Update the channel custom data. This replaces all of the channel data /// with the given [channelData]. @@ -1790,11 +1974,10 @@ class Channel { Future getReactions( String messageId, { PaginationParams? pagination, - }) => - _client.getReactions( - messageId, - pagination: pagination, - ); + }) => _client.getReactions( + messageId, + pagination: pagination, + ); /// Retrieves a list of messages by given [messageIDs]. Future getMessagesById( @@ -1811,11 +1994,10 @@ class Channel { Future translateMessage( String messageId, String language, - ) => - _client.translateMessage( - messageId, - language, - ); + ) => _client.translateMessage( + messageId, + language, + ); /// Creates a new channel. Future create() => query(state: false); @@ -1922,15 +2104,14 @@ class Channel { Filter? filter, SortOrder? sort, PaginationParams? pagination, - }) => - _client.queryMembers( - type, - channelId: id, - filter: filter, - members: state?.members, - sort: sort, - pagination: pagination, - ); + }) => _client.queryMembers( + type, + channelId: id, + filter: filter, + members: state?.members, + sort: sort, + pagination: pagination, + ); /// Query channel banned users. Future queryBannedUsers({ @@ -2098,15 +2279,14 @@ class Channel { String? eventType2, String? eventType3, String? eventType4, - ]) => - _client - .on( - eventType, - eventType2, - eventType3, - eventType4, - ) - .where((e) => e.cid == cid); + ]) => _client + .on( + eventType, + eventType2, + eventType3, + eventType4, + ) + .where((e) => e.cid == cid); late final _keyStrokeHandler = KeyStrokeHandler( onStartTyping: startTyping, @@ -2138,10 +2318,12 @@ class Channel { if (!_canSendTypingEvents) return; client.logger.info('start typing'); - await sendEvent(Event( - type: EventType.typingStart, - parentId: parentId, - )); + await sendEvent( + Event( + type: EventType.typingStart, + parentId: parentId, + ), + ); } /// Sends the [EventType.typingStop] event. @@ -2149,10 +2331,12 @@ class Channel { if (!_canSendTypingEvents) return; client.logger.info('stop typing'); - await sendEvent(Event( - type: EventType.typingStop, - parentId: parentId, - )); + await sendEvent( + Event( + type: EventType.typingStop, + parentId: parentId, + ), + ); } /// Call this method to dispose the channel client. @@ -2195,87 +2379,90 @@ class ChannelClientState { _checkExpiredAttachmentMessages(channelState); + // region TYPING EVENTS _listenTypingEvents(); + // endregion + // region MESSAGE EVENTS _listenMessageNew(); - _listenMessageDeleted(); - _listenMessageUpdated(); + // endregion - /* Start of draft events */ - + // region DRAFT EVENTS _listenDraftUpdated(); - _listenDraftDeleted(); + // endregion - /* End of draft events */ - - _listenReactions(); - + // region REACTION EVENTS + _listenReactionNew(); + _listenReactionUpdated(); _listenReactionDeleted(); + // endregion - /* Start of poll events */ - + // region POLL EVENTS + _listenPollCreated(); _listenPollUpdated(); - _listenPollClosed(); - _listenPollAnswerCasted(); - _listenPollVoteCasted(); - _listenPollVoteChanged(); - _listenPollAnswerRemoved(); - _listenPollVoteRemoved(); + // endregion - /* End of poll events */ - + // region READ EVENTS _listenReadEvents(); + // endregion + // region CHANNEL EVENTS _listenChannelTruncated(); - _listenChannelUpdated(); - _listenChannelMessageCount(); + // endregion + // region MEMBER EVENTS _listenMemberAdded(); - _listenMemberRemoved(); - _listenMemberUpdated(); - _listenMemberBanned(); - _listenMemberUnbanned(); + _listenUserMessagesDeleted(); + // endregion + // region USER WATCHING EVENTS _listenUserStartWatching(); - _listenUserStopWatching(); + // endregion - /* Start of reminder events */ - + // region REMINDER EVENTS _listenReminderCreated(); - _listenReminderUpdated(); - _listenReminderDeleted(); + // endregion - /* End of reminder events */ + // region LOCATION EVENTS + _listenLocationShared(); + _listenLocationUpdated(); + _listenLocationExpired(); + // endregion _startCleaningStaleTypingEvents(); _startCleaningStalePinnedMessages(); + _startCleaningExpiredLocations(); + _listenChannelPushPreferenceUpdated(); final persistenceClient = _client.chatPersistenceClient; - persistenceClient?.getChannelThreads(_channel.cid!).then((threads) { - // Load all the threads for the channel from the offline storage. - if (threads.isNotEmpty) _threads = threads; - }).then((_) => retryFailedMessages()); + persistenceClient + ?.getChannelThreads(_channel.cid!) + .then((threads) { + // Load all the threads for the channel from the offline storage. + if (threads.isNotEmpty) _threads = threads; + }) + .then((_) => retryFailedMessages()); } final Channel _channel; @@ -2284,35 +2471,34 @@ class ChannelClientState { void _checkExpiredAttachmentMessages(ChannelState channelState) async { final expiredAttachmentMessagesId = channelState.messages - ?.where((m) => - !_updatedMessagesIds.contains(m.id) && - m.attachments.isNotEmpty && - m.attachments.any((e) { - final url = e.imageUrl ?? e.assetUrl; - if (url == null || !url.contains('')) { - return false; - } - try { - final uri = Uri.parse(url); - if (!uri.host.endsWith('stream-io-cdn.com') || - uri.queryParameters['Expires'] == null) { + ?.where( + (m) => + !_updatedMessagesIds.contains(m.id) && + m.attachments.isNotEmpty && + m.attachments.any((e) { + final url = e.imageUrl ?? e.assetUrl; + if (url == null || !url.contains('')) { return false; } - final secondsFromEpoch = - int.parse(uri.queryParameters['Expires']!); - final expiration = DateTime.fromMillisecondsSinceEpoch( - secondsFromEpoch * 1000, - ); - return expiration.isBefore(DateTime.now()); - } catch (_) { - return false; - } - })) + try { + final uri = Uri.parse(url); + if (!uri.host.endsWith('stream-io-cdn.com') || uri.queryParameters['Expires'] == null) { + return false; + } + final secondsFromEpoch = int.parse(uri.queryParameters['Expires']!); + final expiration = DateTime.fromMillisecondsSinceEpoch( + secondsFromEpoch * 1000, + ); + return expiration.isBefore(DateTime.now()); + } catch (_) { + return false; + } + }), + ) .map((e) => e.id) .toList(); - if (expiredAttachmentMessagesId != null && - expiredAttachmentMessagesId.isNotEmpty) { + if (expiredAttachmentMessagesId != null && expiredAttachmentMessagesId.isNotEmpty) { await _channel.initialized; _updatedMessagesIds.addAll(expiredAttachmentMessagesId); _channel.getMessagesById(expiredAttachmentMessagesId); @@ -2320,141 +2506,156 @@ class ChannelClientState { } void _listenMemberAdded() { - _subscriptions.add(_channel.on(EventType.memberAdded).listen((Event e) { - final member = e.member!; - final existingMembers = channelState.members ?? []; + _subscriptions.add( + _channel.on(EventType.memberAdded).listen((Event e) { + final member = e.member!; + final existingMembers = channelState.members ?? []; - updateChannelState( - channelState.copyWith( - members: [...existingMembers, member], - ), - ); - })); + updateChannelState( + channelState.copyWith( + members: [...existingMembers, member], + ), + ); + }), + ); } void _listenMemberRemoved() { - _subscriptions.add(_channel.on(EventType.memberRemoved).listen((Event e) { - final user = e.user!; - final existingRead = channelState.read ?? []; - final existingMembers = channelState.members ?? []; - - updateChannelState( - channelState.copyWith( - read: [...existingRead.where((r) => r.user.id != user.id)], - members: [...existingMembers.where((m) => m.userId != user.id)], - ), - ); - })); + _subscriptions.add( + _channel.on(EventType.memberRemoved).listen((Event e) { + final user = e.user!; + final existingRead = channelState.read ?? []; + final existingMembers = channelState.members ?? []; + + updateChannelState( + channelState.copyWith( + read: [...existingRead.where((r) => r.user.id != user.id)], + members: [...existingMembers.where((m) => m.userId != user.id)], + ), + ); + }), + ); } void _listenMemberUpdated() { _subscriptions // Listen to events containing member users - ..add(_channel.on().listen( - (event) { - final user = event.user; - if (user == null) return; + ..add( + _channel.on().listen( + (event) { + final user = event.user; + if (user == null) return; - final existingMembers = [...?channelState.members]; - final existingMembership = channelState.membership; + final existingMembers = [...?channelState.members]; + final existingMembership = channelState.membership; - // Return if the user is not a existing member of the channel. - if (!existingMembers.any((m) => m.userId == user.id)) return; + // Return if the user is not a existing member of the channel. + if (!existingMembers.any((m) => m.userId == user.id)) return; - Member? maybeUpdateMemberUser(Member? existingMember) { - if (existingMember == null) return null; - if (existingMember.userId == user.id) { - return existingMember.copyWith(user: user); + Member? maybeUpdateMemberUser(Member? existingMember) { + if (existingMember == null) return null; + if (existingMember.userId == user.id) { + return existingMember.copyWith(user: user); + } + return existingMember; } - return existingMember; - } - - updateChannelState( - channelState.copyWith( - membership: maybeUpdateMemberUser(existingMembership), - members: [...existingMembers.map(maybeUpdateMemberUser).nonNulls], - ), - ); - }, - )) + updateChannelState( + channelState.copyWith( + membership: maybeUpdateMemberUser(existingMembership), + members: [...existingMembers.map(maybeUpdateMemberUser).nonNulls], + ), + ); + }, + ), + ) // Listen to member updated events. - ..add(_channel.on(EventType.memberUpdated).listen( - (Event e) { - final member = e.member!; - final existingMembers = channelState.members ?? []; - final existingMembership = channelState.membership; - - Member? maybeUpdateMember(Member? existingMember) { - if (existingMember == null) return null; - if (existingMember.userId == member.userId) return member; - return existingMember; - } + ..add( + _channel.on(EventType.memberUpdated).listen( + (Event e) { + final member = e.member!; + final existingMembers = channelState.members ?? []; + final existingMembership = channelState.membership; + + Member? maybeUpdateMember(Member? existingMember) { + if (existingMember == null) return null; + if (existingMember.userId == member.userId) return member; + return existingMember; + } - updateChannelState( - channelState.copyWith( - membership: maybeUpdateMember(existingMembership), - members: [...existingMembers.map(maybeUpdateMember).nonNulls], - ), - ); - }, - )); + updateChannelState( + channelState.copyWith( + membership: maybeUpdateMember(existingMembership), + members: [...existingMembers.map(maybeUpdateMember).nonNulls], + ), + ); + }, + ), + ); } void _listenChannelUpdated() { - _subscriptions.add(_channel.on(EventType.channelUpdated).listen((Event e) { - final channel = e.channel!; - updateChannelState(channelState.copyWith( - channel: channelState.channel?.merge(channel), - members: channel.members, - )); - })); + _subscriptions.add( + _channel.on(EventType.channelUpdated).listen((Event e) { + final channel = e.channel!; + updateChannelState( + channelState.copyWith( + channel: channelState.channel?.merge(channel), + members: channel.members, + ), + ); + }), + ); } void _listenChannelMessageCount() { - _subscriptions.add(_channel.on().listen( - (Event e) { - final messageCount = e.channelMessageCount; - if (messageCount == null) return; + _subscriptions.add( + _channel.on().listen( + (Event e) { + final messageCount = e.channelMessageCount; + if (messageCount == null) return; - updateChannelState( - channelState.copyWith( - channel: channelState.channel?.copyWith( - messageCount: messageCount, + updateChannelState( + channelState.copyWith( + channel: channelState.channel?.copyWith( + messageCount: messageCount, + ), ), - ), - ); - }, - )); + ); + }, + ), + ); } void _listenChannelTruncated() { - _subscriptions.add(_channel - .on(EventType.channelTruncated, EventType.notificationChannelTruncated) - .listen((event) async { - final channel = event.channel!; - await _client.chatPersistenceClient?.deleteMessageByCid(channel.cid); - truncate(); - if (event.message != null) { - updateMessage(event.message!); - } - })); + _subscriptions.add( + _channel.on(EventType.channelTruncated, EventType.notificationChannelTruncated).listen((event) async { + final channel = event.channel!; + await _client.chatPersistenceClient?.deleteMessageByCid(channel.cid); + truncate(); + if (event.message != null) { + updateMessage(event.message!); + } + }), + ); } void _listenMemberBanned() { - _subscriptions.add(_channel - .on(EventType.userBanned) - .where((it) => it.cid != null) // filters channel ban from app ban - .listen( - (event) async { - final user = event.user!; - final member = await _channel - .queryMembers(filter: Filter.equal('id', user.id)) - .then((it) => it.members.first); - - _updateMember(member); - }, - )); + _subscriptions.add( + _channel + .on(EventType.userBanned) + .where((it) => it.cid != null) // filters channel ban from app ban + .listen( + (event) async { + final user = event.user!; + final member = await _channel + .queryMembers(filter: Filter.equal('id', user.id)) + .then((it) => it.members.first); + + _updateMember(member); + }, + ), + ); } void _listenUserStartWatching() { @@ -2463,12 +2664,14 @@ class ChannelClientState { final watcher = event.user; if (watcher != null) { final existingWatchers = channelState.watchers; - updateChannelState(channelState.copyWith( - watchers: [ - watcher, - ...?existingWatchers?.where((user) => user.id != watcher.id), - ], - )); + updateChannelState( + channelState.copyWith( + watchers: [ + watcher, + ...?existingWatchers?.where((user) => user.id != watcher.id), + ], + ), + ); } }), ); @@ -2480,30 +2683,32 @@ class ChannelClientState { final watcher = event.user; if (watcher != null) { final existingWatchers = channelState.watchers; - updateChannelState(channelState.copyWith( - watchers: [ - ...?existingWatchers?.where((user) => user.id != watcher.id) - ], - )); + updateChannelState( + channelState.copyWith( + watchers: [...?existingWatchers?.where((user) => user.id != watcher.id)], + ), + ); } }), ); } void _listenMemberUnbanned() { - _subscriptions.add(_channel - .on(EventType.userUnbanned) - .where((it) => it.cid != null) // filters channel ban from app ban - .listen( - (event) async { - final user = event.user!; - final member = await _channel - .queryMembers(filter: Filter.equal('id', user.id)) - .then((it) => it.members.first); - - _updateMember(member); - }, - )); + _subscriptions.add( + _channel + .on(EventType.userUnbanned) + .where((it) => it.cid != null) // filters channel ban from app ban + .listen( + (event) async { + final user = event.user!; + final member = await _channel + .queryMembers(filter: Filter.equal('id', user.id)) + .then((it) => it.members.first); + + _updateMember(member); + }, + ), + ); } void _updateMember(Member member) { @@ -2541,8 +2746,10 @@ class ChannelClientState { /// Retry failed message. Future retryFailedMessages() async { - final failedMessages = [...messages, ...threads.values.expand((v) => v)] - .where((it) => it.state.isFailed); + final allMessages = [...messages, ...threads.values.flattened]; + final failedMessages = allMessages.where((it) => it.state.isFailed); + + if (failedMessages.isEmpty) return; _retryQueue.add(failedMessages); } @@ -2557,185 +2764,206 @@ class ChannelClientState { return threadMessage; } + void _listenPollCreated() { + _subscriptions.add( + _channel.on(EventType.pollCreated).listen((event) { + final message = event.message; + if (message == null || message.poll == null) return; + + return addNewMessage(message); + }), + ); + } + void _listenPollUpdated() { - _subscriptions.add(_channel.on(EventType.pollUpdated).listen((event) { - final eventPoll = event.poll; - if (eventPoll == null) return; + _subscriptions.add( + _channel.on(EventType.pollUpdated).listen((event) { + final eventPoll = event.poll; + if (eventPoll == null) return; - final pollMessage = _findPollMessage(eventPoll.id); - if (pollMessage == null) return; + final pollMessage = _findPollMessage(eventPoll.id); + if (pollMessage == null) return; - final oldPoll = pollMessage.poll; + final oldPoll = pollMessage.poll; - final latestAnswers = oldPoll?.latestAnswers ?? eventPoll.latestAnswers; - final ownVotesAndAnswers = - oldPoll?.ownVotesAndAnswers ?? eventPoll.ownVotesAndAnswers; + final latestAnswers = oldPoll?.latestAnswers ?? eventPoll.latestAnswers; + final ownVotesAndAnswers = oldPoll?.ownVotesAndAnswers ?? eventPoll.ownVotesAndAnswers; - final poll = eventPoll.copyWith( - latestAnswers: latestAnswers, - ownVotesAndAnswers: ownVotesAndAnswers, - ); + final poll = eventPoll.copyWith( + latestAnswers: latestAnswers, + ownVotesAndAnswers: ownVotesAndAnswers, + ); - final message = pollMessage.copyWith(poll: poll); - updateMessage(message); - })); + final message = pollMessage.copyWith(poll: poll); + updateMessage(message); + }), + ); } void _listenPollClosed() { - _subscriptions.add(_channel.on(EventType.pollClosed).listen((event) { - final eventPoll = event.poll; - if (eventPoll == null) return; + _subscriptions.add( + _channel.on(EventType.pollClosed).listen((event) { + final eventPoll = event.poll; + if (eventPoll == null) return; - final pollMessage = _findPollMessage(eventPoll.id); - if (pollMessage == null) return; + final pollMessage = _findPollMessage(eventPoll.id); + if (pollMessage == null) return; - final oldPoll = pollMessage.poll; - final poll = oldPoll?.copyWith(isClosed: true) ?? eventPoll; + final oldPoll = pollMessage.poll; + final poll = oldPoll?.copyWith(isClosed: true) ?? eventPoll; - final message = pollMessage.copyWith(poll: poll); - updateMessage(message); - })); + final message = pollMessage.copyWith(poll: poll); + updateMessage(message); + }), + ); } void _listenPollAnswerCasted() { - _subscriptions.add(_channel.on(EventType.pollAnswerCasted).listen((event) { - final (eventPoll, eventPollVote) = (event.poll, event.pollVote); - if (eventPoll == null || eventPollVote == null) return; - - final pollMessage = _findPollMessage(eventPoll.id); - if (pollMessage == null) return; + _subscriptions.add( + _channel.on(EventType.pollAnswerCasted).listen((event) { + final (eventPoll, eventPollVote) = (event.poll, event.pollVote); + if (eventPoll == null || eventPollVote == null) return; - final oldPoll = pollMessage.poll; + final pollMessage = _findPollMessage(eventPoll.id); + if (pollMessage == null) return; - final latestAnswers = { - for (final ans in oldPoll?.latestAnswers ?? []) ans.id: ans, - eventPollVote.id!: eventPollVote, - }; + final oldPoll = pollMessage.poll; - final currentUserId = _client.state.currentUser?.id; - final ownVotesAndAnswers = { - for (final vote in oldPoll?.ownVotesAndAnswers ?? []) vote.id: vote, - if (eventPollVote.userId == currentUserId) + final latestAnswers = { + for (final ans in oldPoll?.latestAnswers ?? []) ans.id: ans, eventPollVote.id!: eventPollVote, - }; + }; - final poll = eventPoll.copyWith( - latestAnswers: [...latestAnswers.values], - ownVotesAndAnswers: [...ownVotesAndAnswers.values], - ); + final currentUserId = _client.state.currentUser?.id; + final ownVotesAndAnswers = { + for (final vote in oldPoll?.ownVotesAndAnswers ?? []) vote.id: vote, + if (eventPollVote.userId == currentUserId) eventPollVote.id!: eventPollVote, + }; - final message = pollMessage.copyWith(poll: poll); - updateMessage(message); - })); + final poll = eventPoll.copyWith( + latestAnswers: [...latestAnswers.values], + ownVotesAndAnswers: [...ownVotesAndAnswers.values], + ); + + final message = pollMessage.copyWith(poll: poll); + updateMessage(message); + }), + ); } void _listenPollVoteCasted() { - _subscriptions.add(_channel.on(EventType.pollVoteCasted).listen((event) { - final (eventPoll, eventPollVote) = (event.poll, event.pollVote); - if (eventPoll == null || eventPollVote == null) return; + _subscriptions.add( + _channel.on(EventType.pollVoteCasted).listen((event) { + final (eventPoll, eventPollVote) = (event.poll, event.pollVote); + if (eventPoll == null || eventPollVote == null) return; - final pollMessage = _findPollMessage(eventPoll.id); - if (pollMessage == null) return; + final pollMessage = _findPollMessage(eventPoll.id); + if (pollMessage == null) return; - final oldPoll = pollMessage.poll; + final oldPoll = pollMessage.poll; - final latestAnswers = oldPoll?.latestAnswers ?? eventPoll.latestAnswers; - final currentUserId = _client.state.currentUser?.id; - final ownVotesAndAnswers = { - for (final vote in oldPoll?.ownVotesAndAnswers ?? []) vote.id: vote, - if (eventPollVote.userId == currentUserId) - eventPollVote.id!: eventPollVote, - }; + final latestAnswers = oldPoll?.latestAnswers ?? eventPoll.latestAnswers; + final currentUserId = _client.state.currentUser?.id; + final ownVotesAndAnswers = { + for (final vote in oldPoll?.ownVotesAndAnswers ?? []) vote.id: vote, + if (eventPollVote.userId == currentUserId) eventPollVote.id!: eventPollVote, + }; - final poll = eventPoll.copyWith( - latestAnswers: latestAnswers, - ownVotesAndAnswers: [...ownVotesAndAnswers.values], - ); + final poll = eventPoll.copyWith( + latestAnswers: latestAnswers, + ownVotesAndAnswers: [...ownVotesAndAnswers.values], + ); - final message = pollMessage.copyWith(poll: poll); - updateMessage(message); - })); + final message = pollMessage.copyWith(poll: poll); + updateMessage(message); + }), + ); } void _listenPollAnswerRemoved() { - _subscriptions.add(_channel.on(EventType.pollAnswerRemoved).listen((event) { - final (eventPoll, eventPollVote) = (event.poll, event.pollVote); - if (eventPoll == null || eventPollVote == null) return; + _subscriptions.add( + _channel.on(EventType.pollAnswerRemoved).listen((event) { + final (eventPoll, eventPollVote) = (event.poll, event.pollVote); + if (eventPoll == null || eventPollVote == null) return; - final pollMessage = _findPollMessage(eventPoll.id); - if (pollMessage == null) return; + final pollMessage = _findPollMessage(eventPoll.id); + if (pollMessage == null) return; - final oldPoll = pollMessage.poll; + final oldPoll = pollMessage.poll; - final latestAnswers = { - for (final ans in oldPoll?.latestAnswers ?? []) ans.id: ans, - }..remove(eventPollVote.id); + final latestAnswers = { + for (final ans in oldPoll?.latestAnswers ?? []) ans.id: ans, + }..remove(eventPollVote.id); - final ownVotesAndAnswers = { - for (final vote in oldPoll?.ownVotesAndAnswers ?? []) vote.id: vote, - }..remove(eventPollVote.id); + final ownVotesAndAnswers = { + for (final vote in oldPoll?.ownVotesAndAnswers ?? []) vote.id: vote, + }..remove(eventPollVote.id); - final poll = eventPoll.copyWith( - latestAnswers: [...latestAnswers.values], - ownVotesAndAnswers: [...ownVotesAndAnswers.values], - ); + final poll = eventPoll.copyWith( + latestAnswers: [...latestAnswers.values], + ownVotesAndAnswers: [...ownVotesAndAnswers.values], + ); - final message = pollMessage.copyWith(poll: poll); - updateMessage(message); - })); + final message = pollMessage.copyWith(poll: poll); + updateMessage(message); + }), + ); } void _listenPollVoteRemoved() { - _subscriptions.add(_channel.on(EventType.pollVoteRemoved).listen((event) { - final (eventPoll, eventPollVote) = (event.poll, event.pollVote); - if (eventPoll == null || eventPollVote == null) return; + _subscriptions.add( + _channel.on(EventType.pollVoteRemoved).listen((event) { + final (eventPoll, eventPollVote) = (event.poll, event.pollVote); + if (eventPoll == null || eventPollVote == null) return; - final pollMessage = _findPollMessage(eventPoll.id); - if (pollMessage == null) return; + final pollMessage = _findPollMessage(eventPoll.id); + if (pollMessage == null) return; - final oldPoll = pollMessage.poll; + final oldPoll = pollMessage.poll; - final latestAnswers = oldPoll?.latestAnswers ?? eventPoll.latestAnswers; - final ownVotesAndAnswers = { - for (final vote in oldPoll?.ownVotesAndAnswers ?? []) vote.id: vote, - }..remove(eventPollVote.id); + final latestAnswers = oldPoll?.latestAnswers ?? eventPoll.latestAnswers; + final ownVotesAndAnswers = { + for (final vote in oldPoll?.ownVotesAndAnswers ?? []) vote.id: vote, + }..remove(eventPollVote.id); - final poll = eventPoll.copyWith( - latestAnswers: latestAnswers, - ownVotesAndAnswers: [...ownVotesAndAnswers.values], - ); + final poll = eventPoll.copyWith( + latestAnswers: latestAnswers, + ownVotesAndAnswers: [...ownVotesAndAnswers.values], + ); - final message = pollMessage.copyWith(poll: poll); - updateMessage(message); - })); + final message = pollMessage.copyWith(poll: poll); + updateMessage(message); + }), + ); } void _listenPollVoteChanged() { - _subscriptions.add(_channel.on(EventType.pollVoteChanged).listen((event) { - final (eventPoll, eventPollVote) = (event.poll, event.pollVote); - if (eventPoll == null || eventPollVote == null) return; + _subscriptions.add( + _channel.on(EventType.pollVoteChanged).listen((event) { + final (eventPoll, eventPollVote) = (event.poll, event.pollVote); + if (eventPoll == null || eventPollVote == null) return; - final pollMessage = _findPollMessage(eventPoll.id); - if (pollMessage == null) return; + final pollMessage = _findPollMessage(eventPoll.id); + if (pollMessage == null) return; - final oldPoll = pollMessage.poll; + final oldPoll = pollMessage.poll; - final latestAnswers = oldPoll?.latestAnswers ?? eventPoll.latestAnswers; - final currentUserId = _client.state.currentUser?.id; - final ownVotesAndAnswers = { - for (final vote in oldPoll?.ownVotesAndAnswers ?? []) vote.id: vote, - if (eventPollVote.userId == currentUserId) - eventPollVote.id!: eventPollVote, - }; + final latestAnswers = oldPoll?.latestAnswers ?? eventPoll.latestAnswers; + final currentUserId = _client.state.currentUser?.id; + final ownVotesAndAnswers = { + for (final vote in oldPoll?.ownVotesAndAnswers ?? []) vote.id: vote, + if (eventPollVote.userId == currentUserId) eventPollVote.id!: eventPollVote, + }; - final poll = eventPoll.copyWith( - latestAnswers: latestAnswers, - ownVotesAndAnswers: [...ownVotesAndAnswers.values], - ); + final poll = eventPoll.copyWith( + latestAnswers: latestAnswers, + ownVotesAndAnswers: [...ownVotesAndAnswers.values], + ); - final message = pollMessage.copyWith(poll: poll); - updateMessage(message); - })); + final message = pollMessage.copyWith(poll: poll); + updateMessage(message); + }), + ); } void _listenDraftUpdated() { @@ -2819,97 +3047,216 @@ class ChannelClientState { } } + Message? _findLocationMessage(String id) { + final message = messages.firstWhereOrNull((it) { + return it.sharedLocation?.messageId == id; + }); + + if (message != null) return message; + + final threadMessage = threads.values.flattened.firstWhereOrNull((it) { + return it.sharedLocation?.messageId == id; + }); + + return threadMessage; + } + + void _listenLocationShared() { + _subscriptions.add( + _channel.on(EventType.locationShared).listen((event) { + final message = event.message; + if (message == null || message.sharedLocation == null) return; + + return addNewMessage(message); + }), + ); + } + + void _listenLocationUpdated() { + _subscriptions.add( + _channel.on(EventType.locationUpdated).listen((event) { + final location = event.message?.sharedLocation; + if (location == null) return; + + final messageId = location.messageId; + if (messageId == null) return; + + final oldMessage = _findLocationMessage(messageId); + if (oldMessage == null) return; + + final updatedMessage = oldMessage.copyWith(sharedLocation: location); + return updateMessage(updatedMessage); + }), + ); + } + + void _listenLocationExpired() { + _subscriptions.add( + _channel.on(EventType.locationExpired).listen((event) { + final location = event.message?.sharedLocation; + if (location == null) return; + + final messageId = location.messageId; + if (messageId == null) return; + + final oldMessage = _findLocationMessage(messageId); + if (oldMessage == null) return; + + final updatedMessage = oldMessage.copyWith(sharedLocation: location); + return updateMessage(updatedMessage); + }), + ); + } + void _listenReactionDeleted() { - _subscriptions.add(_channel.on(EventType.reactionDeleted).listen((event) { - final oldMessage = - messages.firstWhereOrNull((it) => it.id == event.message?.id) ?? - threads[event.message?.parentId] - ?.firstWhereOrNull((e) => e.id == event.message?.id); - final reaction = event.reaction; - final ownReactions = oldMessage?.ownReactions - ?.whereNot((it) => - it.type == reaction?.type && - it.score == reaction?.score && - it.messageId == reaction?.messageId && - it.userId == reaction?.userId && - it.extraData == reaction?.extraData) - .toList(growable: false); - final message = event.message!.copyWith( - ownReactions: ownReactions, - ); - updateMessage(message); - })); - } - - void _listenReactions() { - _subscriptions.add(_channel.on(EventType.reactionNew).listen((event) { - final oldMessage = - messages.firstWhereOrNull((it) => it.id == event.message?.id) ?? - threads[event.message?.parentId] - ?.firstWhereOrNull((e) => e.id == event.message?.id); - final message = event.message!.copyWith( - ownReactions: oldMessage?.ownReactions, - ); - updateMessage(message); - })); + _subscriptions.add( + _channel.on(EventType.reactionDeleted).listen((event) { + final (eventReaction, eventMessage) = (event.reaction, event.message); + if (eventReaction == null || eventMessage == null) return; + + final messageId = eventMessage.id; + final parentId = eventMessage.parentId; + + for (final message in [...messages, ...?threads[parentId]]) { + if (message.id == messageId) { + final currentUserId = _channel.client.state.currentUser?.id; + + final currentMessage = switch (currentUserId) { + final userId? when userId == eventReaction.userId => message.deleteMyReaction( + reactionType: eventReaction.type, + ), + _ => message, + }; + + return updateMessage( + eventMessage.copyWith( + ownReactions: currentMessage.ownReactions, + ), + ); + } + } + }), + ); + } + + void _listenReactionNew() { + _subscriptions.add( + _channel.on(EventType.reactionNew).listen((event) { + final (eventReaction, eventMessage) = (event.reaction, event.message); + if (eventReaction == null || eventMessage == null) return; + + final messageId = eventMessage.id; + final parentId = eventMessage.parentId; + + for (final message in [...messages, ...?threads[parentId]]) { + if (message.id == messageId) { + final currentUserId = _channel.client.state.currentUser?.id; + + final currentMessage = switch (currentUserId) { + final userId? when userId == eventReaction.userId => message.addMyReaction(eventReaction), + _ => message, + }; + + return updateMessage( + eventMessage.copyWith( + ownReactions: currentMessage.ownReactions, + ), + ); + } + } + }), + ); + } + + void _listenReactionUpdated() { + _subscriptions.add( + _channel.on(EventType.reactionUpdated).listen((event) { + final (eventReaction, eventMessage) = (event.reaction, event.message); + if (eventReaction == null || eventMessage == null) return; + + final messageId = eventMessage.id; + final parentId = eventMessage.parentId; + + for (final message in [...messages, ...?threads[parentId]]) { + if (message.id == messageId) { + final currentUserId = _channel.client.state.currentUser?.id; + + final currentMessage = switch (currentUserId) { + final userId? when userId == eventReaction.userId => + // reaction.updated is only called if enforce_unique is true + message.addMyReaction(eventReaction, enforceUnique: true), + _ => message, + }; + + return updateMessage( + eventMessage.copyWith( + ownReactions: currentMessage.ownReactions, + ), + ); + } + } + }), + ); } void _listenMessageUpdated() { - _subscriptions.add(_channel - .on( - EventType.messageUpdated, - EventType.reactionUpdated, - ) - .listen((event) { - final oldMessage = - messages.firstWhereOrNull((it) => it.id == event.message?.id) ?? - threads[event.message?.parentId] - ?.firstWhereOrNull((e) => e.id == event.message?.id); - final message = event.message!.copyWith( - poll: oldMessage?.poll, - pollId: oldMessage?.pollId, - ownReactions: oldMessage?.ownReactions, - ); - updateMessage(message); - })); + _subscriptions.add( + _channel.on(EventType.messageUpdated).listen((event) { + final message = event.message; + if (message == null) return; + + return updateMessage(message); + }), + ); } void _listenMessageDeleted() { - _subscriptions.add(_channel.on(EventType.messageDeleted).listen((event) { - final message = event.message!; - final hardDelete = event.hardDelete ?? false; + _subscriptions.add( + _channel.on(EventType.messageDeleted).listen((event) { + final hardDelete = event.hardDelete ?? false; - deleteMessage(message, hardDelete: hardDelete); - })); + final message = event.message!.copyWith( + // TODO: Remove once deletedForMe is properly enriched on the backend. + deletedForMe: event.deletedForMe, + ); + + return deleteMessage(message, hardDelete: hardDelete); + }), + ); } void _listenMessageNew() { - _subscriptions.add(_channel - .on( - EventType.messageNew, - EventType.notificationMessageNew, - ) - .listen((event) { - final message = event.message; - if (message == null) return; - - final isThreadMessage = message.parentId != null; - final isNotShownInChannel = message.showInChannel != true; - final isThreadOnlyMessage = isThreadMessage && isNotShownInChannel; - - // Only add the message if the channel is upToDate or if the message is - // a thread-only message. - if (isUpToDate || isThreadOnlyMessage) { - updateMessage(message); - } + _subscriptions.add( + _channel + .on( + EventType.messageNew, + EventType.notificationMessageNew, + ) + .listen((event) { + final message = event.message; + if (message == null) return; - // Otherwise, check if we can count the message as unread. - if (MessageRules.canCountAsUnread(message, _channel)) { - unreadCount += 1; // Increment unread count - } + return addNewMessage(message); + }), + ); + } + + /// Adds a new message to the channel state and updates the unread count. + void addNewMessage(Message message) { + final isThreadMessage = message.parentId != null; + final isNotShownInChannel = message.showInChannel != true; + final isThreadOnlyMessage = isThreadMessage && isNotShownInChannel; + + // Only add the message if the channel is upToDate or if the message is + // a thread-only message. + if (isUpToDate || isThreadOnlyMessage) updateMessage(message); + + // Otherwise, check if we can count the message as unread. + if (MessageRules.canCountAsUnread(message, _channel)) { + unreadCount += 1; // Increment unread count + } - _client.channelDeliveryReporter.submitForDelivery([_channel]); - })); + _client.channelDeliveryReporter.submitForDelivery([_channel]); } /// Updates the [read] in the state if it exists. Adds it otherwise. @@ -2973,79 +3320,29 @@ class ChannelClientState { } /// Updates the [message] in the state if it exists. Adds it otherwise. - void updateMessage(Message message) { - // Determine if the message should be displayed in the channel view. - if (message.parentId == null || message.showInChannel == true) { - // Create a new list of messages to avoid modifying the original - // list directly. - var newMessages = [...messages]; - final oldIndex = newMessages.indexWhere((m) => m.id == message.id); - - if (oldIndex != -1) { - // If the message already exists, prepare it for update. - final oldMessage = newMessages[oldIndex]; - var updatedMessage = message.syncWith(oldMessage); - - // Preserve quotedMessage if the update doesn't include a new - // quotedMessage. - if (message.quotedMessageId != null && - message.quotedMessage == null && - oldMessage.quotedMessage != null) { - updatedMessage = updatedMessage.copyWith( - quotedMessage: oldMessage.quotedMessage, - ); - } - - // Update the message in the list. - newMessages[oldIndex] = updatedMessage; - - // Update quotedMessage references in all messages. - newMessages = newMessages.map((it) { - // Skip if the current message does not quote the updated message. - if (it.quotedMessageId != message.id) return it; - - // Update the quotedMessage only if the updatedMessage indicates - // deletion. - if (message.isDeleted) { - return it.copyWith( - quotedMessage: updatedMessage.copyWith( - type: message.type, - deletedAt: message.deletedAt, - ), - ); - } - return it; - }).toList(); - } else { - // If the message is new, add it to the list. - newMessages.add(message); - } - - // Handle updates to pinned messages. - final newPinnedMessages = _updatePinnedMessages(message); + /// + /// Reconciles via `Message.updateWith`, so locally-known enrichment + /// (poll, sharedLocation, ownReactions, nested quotedMessage) is + /// preserved when [message] omits those fields. Use [replaceMessage] + /// for paths that need a strict overwrite. + void updateMessage(Message message) => _updateMessages([message]); - // Calculate the new last message at time. - var lastMessageAt = _channelState.channel?.lastMessageAt; - lastMessageAt ??= message.createdAt; - if (MessageRules.canUpdateChannelLastMessageAt(message, _channel)) { - lastMessageAt = [lastMessageAt, message.createdAt].max; - } + /// Replaces the [message] in the state if it exists, no-op otherwise. + /// + /// Unlike [updateMessage], this does **not** merge with the existing + /// state — [message] is used as-is. Useful for local rollbacks of an + /// optimistic update, where the caller has the full prior snapshot and + /// doesn't want the merge falling back to the optimistic values. + void replaceMessage(Message message) => _updateMessages([message], update: _replaceUpdate); - // Apply the updated lists to the channel state. - _channelState = _channelState.copyWith( - messages: newMessages.sorted(_sortByCreatedAt), - pinnedMessages: newPinnedMessages, - channel: _channelState.channel?.copyWith( - lastMessageAt: lastMessageAt, - ), - ); - } + // Default `update` for [_updateMessages]: merge incoming with the + // locally-known message via `Message.updateWith`, preserving enrichment + // the server may strip on partial payloads. + static Message _mergeUpdate(Message original, Message updated) => original.updateWith(updated); - // If the message is part of a thread, update thread information. - if (message.parentId case final parentId?) { - updateThreadInfo(parentId, [message]); - } - } + // Replace `update` for [_updateMessages]: take the incoming as-is. Used + // by local rollback paths. + static Message _replaceUpdate(Message _, Message updated) => updated; /// Cleans up all the stale error messages which requires no action. void cleanUpStaleErrorMessages() { @@ -3054,80 +3351,15 @@ class ChannelClientState { }); if (errorMessages.isEmpty) return; - return errorMessages.forEach(removeMessage); - } - - /// Updates the list of pinned messages based on the current message's - /// pinned status. - List _updatePinnedMessages(Message message) { - final newPinnedMessages = [...pinnedMessages]; - final oldPinnedIndex = - newPinnedMessages.indexWhere((m) => m.id == message.id); - - if (message.pinned) { - // If the message is pinned, add or update it in the list of pinned - // messages. - if (oldPinnedIndex != -1) { - newPinnedMessages[oldPinnedIndex] = message; - } else { - newPinnedMessages.add(message); - } - } else { - // If the message is not pinned, remove it from the list of pinned - // messages. - newPinnedMessages.removeWhere((m) => m.id == message.id); - } - - return newPinnedMessages; + return _removeMessages(errorMessages); } /// Remove a [message] from this [channelState]. - void removeMessage(Message message) async { - await _client.chatPersistenceClient?.deleteMessageById(message.id); - - final parentId = message.parentId; - // i.e. it's a thread message, Remove it - if (parentId != null) { - final newThreads = {...threads}; - // Early return in case the thread is not available - if (!newThreads.containsKey(parentId)) return; - - // Remove thread message shown in thread page. - newThreads.update( - parentId, - (messages) => [...messages.where((e) => e.id != message.id)], - ); - - _threads = newThreads; - - // Early return if the thread message is not shown in channel. - if (message.showInChannel == false) return; - } - - // Remove regular message, thread message shown in channel - var updatedMessages = [...messages]..removeWhere((e) => e.id == message.id); - - // Remove quoted message reference from every message if available. - updatedMessages = [...updatedMessages].map((it) { - // Early return if the message doesn't have a quoted message. - if (it.quotedMessageId != message.id) return it; - - // Setting it to null will remove the quoted message from the message. - return it.copyWith( - quotedMessage: null, - quotedMessageId: null, - ); - }).toList(); - - _channelState = _channelState.copyWith( - messages: updatedMessages, - ); - } + void removeMessage(Message message) => _removeMessages([message]); /// Removes/Updates the [message] based on the [hardDelete] value. void deleteMessage(Message message, {bool hardDelete = false}) { - if (hardDelete) return removeMessage(message); - return updateMessage(message); + return _deleteMessages([message], hardDelete: hardDelete); } void _listenReadEvents() { @@ -3219,27 +3451,22 @@ class ChannelClientState { List get messages => _channelState.messages ?? []; /// Channel message list as a stream. - Stream> get messagesStream => channelStateStream - .map((cs) => cs.messages ?? []) - .distinct(const ListEquality().equals); + Stream> get messagesStream => + channelStateStream.map((cs) => cs.messages ?? []).distinct(const ListEquality().equals); /// Channel pinned message list. - List get pinnedMessages => - _channelState.pinnedMessages ?? []; + List get pinnedMessages => _channelState.pinnedMessages ?? []; /// Channel pinned message list as a stream. - Stream> get pinnedMessagesStream => channelStateStream - .map((cs) => cs.pinnedMessages ?? []) - .distinct(const ListEquality().equals); + Stream> get pinnedMessagesStream => + channelStateStream.map((cs) => cs.pinnedMessages ?? []).distinct(const ListEquality().equals); /// Channel pending message list. - List get pendingMessages => - _channelState.pendingMessages ?? []; + List get pendingMessages => _channelState.pendingMessages ?? []; /// Channel pending message list as a stream. - Stream> get pendingMessagesStream => channelStateStream - .map((cs) => cs.pendingMessages ?? []) - .distinct(const ListEquality().equals); + Stream> get pendingMessagesStream => + channelStateStream.map((cs) => cs.pendingMessages ?? []).distinct(const ListEquality().equals); /// Get channel last message. Message? get lastMessage => messages.lastOrNull; @@ -3250,38 +3477,41 @@ class ChannelClientState { } /// Channel members list. - List get members => (_channelState.members ?? []) - .map((e) => e.copyWith(user: _client.state.users[e.user!.id])) - .toList(); + List get members => + (_channelState.members ?? []).map((e) => e.copyWith(user: _client.state.users[e.user!.id])).toList(); /// Channel members list as a stream. - Stream> get membersStream => CombineLatestStream.combine2< - List?, Map, List>( + Stream> get membersStream => + CombineLatestStream.combine2?, Map, List>( channelStateStream.map((cs) => cs.members), _client.state.usersStream, - (members, users) => - [...?members?.map((e) => e!.copyWith(user: users[e.user!.id]))], + (members, users) => [...?members?.map((e) => e!.copyWith(user: users[e.user!.id]))], ).distinct(const ListEquality().equals); /// Channel watcher count. int? get watcherCount => _channelState.watcherCount; /// Channel watcher count as a stream. - Stream get watcherCountStream => - channelStateStream.map((cs) => cs.watcherCount); + Stream get watcherCountStream => channelStateStream.map((cs) => cs.watcherCount); /// Channel watchers list. - List get watchers => (_channelState.watchers ?? []) - .map((e) => _client.state.users[e.id] ?? e) - .toList(); + List get watchers => (_channelState.watchers ?? []).map((e) => _client.state.users[e.id] ?? e).toList(); /// Channel watchers list as a stream. - Stream> get watchersStream => CombineLatestStream.combine2< - List?, Map, List>( - channelStateStream.map((cs) => cs.watchers), - _client.state.usersStream, - (watchers, users) => [...?watchers?.map((e) => users[e.id] ?? e)], - ).distinct(const ListEquality().equals); + Stream> get watchersStream => CombineLatestStream.combine2?, Map, List>( + channelStateStream.map((cs) => cs.watchers), + _client.state.usersStream, + (watchers, users) => [...?watchers?.map((e) => users[e.id] ?? e)], + ).distinct(const ListEquality().equals); + + /// Channel active live locations. + List get activeLiveLocations { + return _channelState.activeLiveLocations ?? []; + } + + /// Channel active live locations as a stream. + Stream> get activeLiveLocationsStream => + channelStateStream.map((cs) => cs.activeLiveLocations ?? []).distinct(const ListEquality().equals); /// Channel draft. Draft? get draft => _channelState.draft; @@ -3293,8 +3523,8 @@ class ChannelClientState { /// Channel member for the current user. Member? get currentUserMember => members.firstWhereOrNull( - (m) => m.user?.id == _client.state.currentUser?.id, - ); + (m) => m.user?.id == _client.state.currentUser?.id, + ); /// Channel role for the current user String? get currentUserChannelRole => currentUserMember?.channelRole; @@ -3375,13 +3605,10 @@ class ChannelClientState { /// Update channelState with updated information. void updateChannelState(ChannelState updatedState) { final _existingStateMessages = [...messages]; - final newMessages = [ - ..._existingStateMessages.merge( - updatedState.messages, - key: (message) => message.id, - update: (original, updated) => updated.syncWith(original), - ), - ].sorted(_sortByCreatedAt); + final newMessages = _mergeMessagesIntoExisting( + existing: _existingStateMessages, + toMerge: updatedState.messages ?? [], + ).sorted(_sortByCreatedAt); final _existingStateWatchers = [...?_channelState.watchers]; final newWatchers = [ @@ -3417,11 +3644,11 @@ class ChannelClientState { pinnedMessages: updatedState.pinnedMessages, pendingMessages: updatedState.pendingMessages, pushPreferences: updatedState.pushPreferences, + activeLiveLocations: updatedState.activeLiveLocations, ); } - int _sortByCreatedAt(Message a, Message b) => - a.createdAt.compareTo(b.createdAt); + int _sortByCreatedAt(Message a, Message b) => a.createdAt.compareTo(b.createdAt); /// The channel state related to this client. ChannelState get _channelState => _channelStateController.value; @@ -3480,19 +3707,18 @@ class ChannelClientState { /// Update threads with updated information about messages. void updateThreadInfo(String parentId, List messages) { - final newThreads = {...threads}..update( - parentId, - (original) => [ - ...original.merge( - messages, - key: (message) => message.id, - update: (original, updated) => updated.syncWith(original), - ), - ].sorted(_sortByCreatedAt), - ifAbsent: () => messages.sorted(_sortByCreatedAt), - ); + final updatedThreads = {...threads}; + + final threadMessages = [...?updatedThreads[parentId]]; + final updatedThreadMessages = _mergeMessagesIntoExisting( + existing: threadMessages, + toMerge: messages, + ).sorted(_sortByCreatedAt); + + // Update the thread with the modified message list. + updatedThreads[parentId] = updatedThreadMessages; - _threads = newThreads; + _threads = updatedThreads; } Draft? _getThreadDraft(String parentId, List? messages) { @@ -3506,13 +3732,11 @@ class ChannelClientState { /// /// This stream emits a new value whenever the draft associated with the /// specified thread is updated or removed. - Stream threadDraftStream(String parentId) => channelStateStream - .map((cs) => _getThreadDraft(parentId, cs.messages)) - .distinct(); + Stream threadDraftStream(String parentId) => + channelStateStream.map((cs) => _getThreadDraft(parentId, cs.messages)).distinct(); /// Channel related typing users stream. - Stream> get typingEventsStream => - _typingEventsController.stream; + Stream> get typingEventsStream => _typingEventsController.stream; /// Channel related typing users last value. Map get typingEvents => _typingEventsController.value; @@ -3561,8 +3785,7 @@ class ChannelClientState { (_) { final now = DateTime.now(); typingEvents.forEach((user, event) { - if (now.difference(event.createdAt).inSeconds > - incomingTypingStartEventTimeout) { + if (now.difference(event.createdAt).inSeconds > incomingTypingStartEventTimeout) { _client.handleEvent( Event( type: EventType.typingStop, @@ -3585,21 +3808,59 @@ class ChannelClientState { const Duration(seconds: 30), (_) { final now = DateTime.now(); - var expiredMessages = channelState.pinnedMessages - ?.where((m) => m.pinExpires?.isBefore(now) == true) - .toList(); + var expiredMessages = channelState.pinnedMessages?.where((m) => m.pinExpires?.isBefore(now) == true).toList(); if (expiredMessages != null && expiredMessages.isNotEmpty) { expiredMessages = expiredMessages - .map((m) => m.copyWith( - pinExpires: null, - pinned: false, - )) + .map( + (m) => m.copyWith( + pinExpires: null, + pinned: false, + ), + ) .toList(); - updateChannelState(_channelState.copyWith( - pinnedMessages: pinnedMessages.where(_pinIsValid).toList(), - messages: expiredMessages, - )); + updateChannelState( + _channelState.copyWith( + pinnedMessages: pinnedMessages.where(_pinIsValid).toList(), + messages: expiredMessages, + ), + ); + } + }, + ); + } + + Timer? _staleLiveLocationsCleanerTimer; + void _startCleaningExpiredLocations() { + _staleLiveLocationsCleanerTimer?.cancel(); + _staleLiveLocationsCleanerTimer = Timer.periodic( + const Duration(seconds: 1), + (_) { + final currentUserId = _channel._client.state.currentUser?.id; + if (currentUserId == null) return; + + final expired = activeLiveLocations.where((it) => it.isExpired); + if (expired.isEmpty) return; + + for (final sharedLocation in expired) { + // Skip if the location is shared by the current user, + // as we are already handling them in the client. + if (sharedLocation.userId == currentUserId) continue; + + final lastUpdatedAt = DateTime.timestamp(); + final locationExpiredEvent = Event( + type: EventType.locationExpired, + cid: sharedLocation.channelCid, + message: Message( + id: sharedLocation.messageId, + updatedAt: lastUpdatedAt, + sharedLocation: sharedLocation.copyWith( + updatedAt: lastUpdatedAt, + ), + ), + ); + + _channel._client.handleEvent(locationExpiredEvent); } }, ); @@ -3623,6 +3884,400 @@ class ChannelClientState { ); } + Future _deleteMessagesFromUser({ + required String userId, + bool hardDelete = false, + DateTime? deletedAt, + }) async { + // Delete messages from persistence. + // + // Note: We perform this operation separately even though [_removeMessages] + // already handles it as we need to delete all messages from the user, not + // only the ones present in the current state. + final persistence = _channel.client.chatPersistenceClient; + await persistence?.deleteMessagesFromUser( + userId: userId, + cid: _channel.cid, + hardDelete: hardDelete, + deletedAt: deletedAt, + ); + + // Gather messages to delete from state. + final userMessages = {}; + for (final message in [...messages, ...threads.values.flattened]) { + if (message.user?.id != userId) continue; + userMessages[message.id] = message.copyWith( + type: MessageType.deleted, + deletedAt: deletedAt ?? DateTime.now(), + state: switch (hardDelete) { + true => MessageState.hardDeleted, + false => MessageState.softDeleted, + }, + ); + } + + final messagesToDelete = userMessages.values; + return _deleteMessages(messagesToDelete, hardDelete: hardDelete); + } + + void _deleteMessages( + Iterable messages, { + bool hardDelete = false, + }) { + if (messages.isEmpty) return; + + if (hardDelete) return _removeMessages(messages); + return _updateMessages(messages); + } + + void _updateMessages( + Iterable messages, { + Message Function(Message original, Message updated) update = _mergeUpdate, + }) { + if (messages.isEmpty) return; + + _updateThreadMessages(messages, update: update); + _updateChannelMessages(messages, update: update); + _updatePinnedMessages(messages, update: update); + _updateActiveLiveLocations(messages); + } + + void _updateThreadMessages( + Iterable messages, { + Message Function(Message original, Message updated) update = _mergeUpdate, + }) { + if (messages.isEmpty) return; + + final affectedThreads = {...messages.map((it) => it.parentId).nonNulls}; + // If there are no affected threads, return early. + if (affectedThreads.isEmpty) return; + + final updatedThreads = {...threads}; + for (final thread in affectedThreads) { + final threadMessages = [...?updatedThreads[thread]]; + final updatedThreadMessages = _mergeMessagesIntoExisting( + existing: threadMessages, + toMerge: messages, + update: update, + ); + + // Update the thread with the modified message list. + updatedThreads[thread] = updatedThreadMessages.toList(); + } + + // Update the threads map. + _threads = updatedThreads; + } + + void _updateChannelMessages( + Iterable messages, { + Message Function(Message original, Message updated) update = _mergeUpdate, + }) { + if (messages.isEmpty) return; + + final affectedMessages = messages.map((it) { + // If it's not a thread message, consider it affected. + if (it.parentId == null) return it; + // If it's a thread message shown in channel, consider it affected. + if (it.showInChannel == true) return it; + + return null; // Thread message not shown in channel, ignore it. + }).nonNulls; + + // If there are no affected messages, return early. + if (affectedMessages.isEmpty) return; + + final channelMessages = [...this.messages]; + final updatedChannelMessages = _mergeMessagesIntoExisting( + existing: channelMessages, + toMerge: affectedMessages, + update: update, + ); + + // Calculate the new last message at time. + var lastMessageAt = _channelState.channel?.lastMessageAt; + for (final message in affectedMessages) { + if (MessageRules.canUpdateChannelLastMessageAt(message, _channel)) { + lastMessageAt = [lastMessageAt, message.createdAt].nonNulls.max; + } + } + + _channelState = _channelState.copyWith( + messages: updatedChannelMessages.sorted(_sortByCreatedAt), + channel: _channelState.channel?.copyWith(lastMessageAt: lastMessageAt), + ); + } + + void _updatePinnedMessages( + Iterable messages, { + Message Function(Message original, Message updated) update = _mergeUpdate, + }) { + if (messages.isEmpty) return; + + final pinnedMessages = [...this.pinnedMessages]; + final updatedPinnedMessages = _mergePinnedMessagesIntoExisting( + existing: pinnedMessages, + toMerge: messages, + update: update, + ); + + _channelState = _channelState.copyWith( + pinnedMessages: updatedPinnedMessages.sorted(_sortByCreatedAt), + ); + } + + void _updateActiveLiveLocations(Iterable messages) { + if (messages.isEmpty) return; + + final activeLiveLocations = [...this.activeLiveLocations]; + final updatedActiveLiveLocations = _mergeActiveLocationsIntoExisting( + existing: activeLiveLocations, + toMerge: messages, + ); + + _channelState = _channelState.copyWith( + activeLiveLocations: updatedActiveLiveLocations.toList(), + ); + } + + Iterable _mergeActiveLocationsIntoExisting({ + required Iterable existing, + required Iterable toMerge, + }) { + if (toMerge.isEmpty) return existing; + + final mergedLocations = existing.mergeFrom( + toMerge, + key: (it) => (it.userId, it.channelCid, it.createdByDeviceId), + value: (message) => message.sharedLocation, + update: (original, updated) => updated, + ); + + final toUpdateMap = {for (final m in toMerge) m.id: m}; + final updatedLocations = mergedLocations.where((it) { + // Remove the location if it's expired. + if (it.isExpired) return false; + + final updatedMessage = toUpdateMap[it.messageId]; + // Remove the location if the attached message is deleted. + if (updatedMessage?.isDeleted == true) return false; + + return true; + }); + + return updatedLocations; + } + + Iterable _mergePinnedMessagesIntoExisting({ + required Iterable existing, + required Iterable toMerge, + Message Function(Message original, Message updated) update = _mergeUpdate, + }) { + return _mergeMessagesIntoExisting( + existing: existing, + toMerge: toMerge, + update: update, + ).where(_pinIsValid); + } + + Iterable _mergeMessagesIntoExisting({ + required Iterable existing, + required Iterable toMerge, + Message Function(Message original, Message updated) update = _mergeUpdate, + }) { + if (toMerge.isEmpty) return existing; + + // [update] decides whether each pair is reconciled (default — see + // `_mergeUpdate`) or replaced (`_replaceUpdate`, used by local rollback + // paths that don't want enrichment fallback to keep optimistic values). + final mergedMessages = existing.merge( + toMerge, + key: (message) => message.id, + update: update, + ); + + // Replace each embedded quotedMessage with the fully-formed top-level + // copy from the merged state, so quoted parents that aren't in the + // current batch are still resolved correctly. + final mergedList = mergedMessages.toList(growable: false); + final mergedById = {for (final m in mergedList) m.id: m}; + + return mergedList.map((message) { + final quoted = mergedById[message.quotedMessageId]; + if (quoted == null) return message; + // Update the quotedMessage reference in the message. + return message.copyWith(quotedMessage: quoted); + }); + } + + void _removeMessages(Iterable messages) { + if (messages.isEmpty) return; + + final messageIds = messages.map((m) => m.id).toSet().toList(); + final persistenceClient = _channel.client.chatPersistenceClient; + // Remove the messages from the persistence client. + persistenceClient?.deleteMessageByIds(messageIds); + persistenceClient?.deletePinnedMessageByIds(messageIds); + + _removeThreadMessages(messages); + _removeChannelMessages(messages); + _removePinnedMessages(messages); + _removeActiveLiveLocations(messages); + } + + void _removeThreadMessages(Iterable messages) { + if (messages.isEmpty) return; + + final affectedThreads = {...messages.map((it) => it.parentId).nonNulls}; + // If there are no affected threads, return early. + if (affectedThreads.isEmpty) return; + + final updatedThreads = {...threads}; + for (final thread in affectedThreads) { + final threadMessages = updatedThreads[thread]; + // Continue if the thread doesn't exist. + if (threadMessages == null) continue; + + // Remove the deleted message from the thread messages and reference from + // other messages quoting it. + final updatedThreadMessages = _removeMessagesFromExisting( + existing: threadMessages, + toRemove: messages, + ); + + // If there are no more messages in the thread, remove the thread entry. + if (updatedThreadMessages.isEmpty) { + updatedThreads.remove(thread); + continue; + } + + // Otherwise, update the thread with the modified message list. + updatedThreads[thread] = updatedThreadMessages.toList(); + } + + // Update the threads map. + _threads = updatedThreads; + } + + void _removeChannelMessages(Iterable messages) { + if (messages.isEmpty) return; + + final affectedMessages = messages.map((it) { + // If it's not a thread message, consider it affected. + if (it.parentId == null) return it; + // If it's a thread message shown in channel, consider it affected. + if (it.showInChannel == true) return it; + + return null; // Thread message not shown in channel, ignore it. + }).nonNulls; + + // If there are no affected messages, return early. + if (affectedMessages.isEmpty) return; + + final channelMessages = [...this.messages]; + final updatedChannelMessages = _removeMessagesFromExisting( + existing: channelMessages, + toRemove: affectedMessages, + ); + + _channelState = _channelState.copyWith( + messages: updatedChannelMessages.toList(), + ); + } + + void _removePinnedMessages(Iterable messages) { + if (messages.isEmpty) return; + + final pinnedMessages = [...this.pinnedMessages]; + final updatedPinnedMessages = _removePinnedMessagesFromExisting( + existing: pinnedMessages, + toRemove: messages, + ); + + _channelState = _channelState.copyWith( + pinnedMessages: updatedPinnedMessages.toList(), + ); + } + + void _removeActiveLiveLocations(Iterable messages) { + if (messages.isEmpty) return; + + final activeLiveLocations = [...this.activeLiveLocations]; + final updatedActiveLiveLocations = _removeActiveLocationsFromExisting( + existing: activeLiveLocations, + toRemove: messages, + ); + + _channelState = _channelState.copyWith( + activeLiveLocations: updatedActiveLiveLocations.toList(), + ); + } + + Iterable _removeActiveLocationsFromExisting({ + required Iterable existing, + required Iterable toRemove, + }) { + if (toRemove.isEmpty) return existing; + + final toRemoveIds = toRemove.map((m) => m.id).toSet(); + final updatedLocations = existing.where( + // Remove the location if its attached message is in the toRemove list. + (it) => !toRemoveIds.contains(it.messageId), + ); + + return updatedLocations; + } + + Iterable _removePinnedMessagesFromExisting({ + required Iterable existing, + required Iterable toRemove, + }) { + return _removeMessagesFromExisting( + existing: existing, + toRemove: toRemove, + ).where(_pinIsValid); + } + + Iterable _removeMessagesFromExisting({ + required Iterable existing, + required Iterable toRemove, + }) { + if (toRemove.isEmpty) return existing; + + final toRemoveIds = toRemove.map((m) => m.id).toSet(); + final updatedMessages = existing + .where((it) { + // Remove the message if it's in the toRemove list. + return !toRemoveIds.contains(it.id); + }) + .map((it) { + // Continue if the message doesn't quote any of the deleted messages. + if (!toRemoveIds.contains(it.quotedMessageId)) return it; + + // Setting it to null will remove the quoted message from the message. + return it.copyWith(quotedMessageId: null, quotedMessage: null); + }); + + return updatedMessages; + } + + // Listens to user message deleted events and marks messages from that user + // as either soft or hard deleted based on the event data. + void _listenUserMessagesDeleted() { + _subscriptions.add( + _channel.on(EventType.userMessagesDeleted).listen((event) async { + final user = event.user; + if (user == null) return; + + return _deleteMessagesFromUser( + userId: user.id, + hardDelete: event.hardDelete ?? false, + deletedAt: event.createdAt, + ); + }), + ); + } + /// Call this method to dispose this object. void dispose() { _debouncedUpdatePersistenceChannelThreads.cancel(); @@ -3634,13 +4289,24 @@ class ChannelClientState { _threadsController.close(); _staleTypingEventsCleanerTimer?.cancel(); _stalePinnedMessagesCleanerTimer?.cancel(); + _staleLiveLocationsCleanerTimer?.cancel(); _typingEventsController.close(); } } bool _pinIsValid(Message message) { - final now = DateTime.now(); - return message.pinExpires!.isAfter(now); + // If the message is deleted, the pin is not valid. + if (message.isDeleted) return false; + + // If the message is not pinned, it's not valid. + if (message.pinned != true) return false; + + // If there's no expiration, the pin is valid. + final pinExpires = message.pinExpires; + if (pinExpires == null) return true; + + // If there's an expiration, check if it's still valid. + return pinExpires.isAfter(DateTime.now()); } /// Extension methods for reading related operations on a ChannelClientState. @@ -3887,4 +4553,9 @@ extension ChannelCapabilityCheck on Channel { bool get canUseDeliveryReceipts { return ownCapabilities.contains(ChannelCapability.deliveryEvents); } + + /// True, if the current user can share location in the channel. + bool get canShareLocation { + return ownCapabilities.contains(ChannelCapability.shareLocation); + } } diff --git a/packages/stream_chat/lib/src/client/channel_delivery_reporter.dart b/packages/stream_chat/lib/src/client/channel_delivery_reporter.dart index 001f19625a..1342d397a3 100644 --- a/packages/stream_chat/lib/src/client/channel_delivery_reporter.dart +++ b/packages/stream_chat/lib/src/client/channel_delivery_reporter.dart @@ -10,9 +10,10 @@ import 'package:synchronized/synchronized.dart'; /// /// Each [MessageDeliveryInfo] represents an acknowledgment that the current /// user has received a message. -typedef MarkChannelsDelivered = Future Function( - Iterable deliveries, -); +typedef MarkChannelsDelivered = + Future Function( + Iterable deliveries, + ); /// Manages the delivery reporting for channel messages. /// @@ -31,8 +32,8 @@ class ChannelDeliveryReporter { Logger? logger, required this.onMarkChannelsDelivered, Duration throttleDuration = const Duration(seconds: 1), - }) : _logger = logger, - _markAsDeliveredThrottleDuration = throttleDuration; + }) : _logger = logger, + _markAsDeliveredThrottleDuration = throttleDuration; final Logger? _logger; final Duration _markAsDeliveredThrottleDuration; @@ -43,7 +44,7 @@ class ChannelDeliveryReporter { final MarkChannelsDelivered onMarkChannelsDelivered; final _deliveryCandidatesLock = Lock(); - final _deliveryCandidates = {}; + final _deliveryCandidates = {}; /// Submits [channels] for delivery reporting. /// diff --git a/packages/stream_chat/lib/src/client/client.dart b/packages/stream_chat/lib/src/client/client.dart index 6682ec28cc..611b6a8b26 100644 --- a/packages/stream_chat/lib/src/client/client.dart +++ b/packages/stream_chat/lib/src/client/client.dart @@ -7,6 +7,7 @@ import 'package:meta/meta.dart'; import 'package:rxdart/rxdart.dart'; import 'package:stream_chat/src/client/channel.dart'; import 'package:stream_chat/src/client/channel_delivery_reporter.dart'; +import 'package:stream_chat/src/client/event_resolvers.dart' as event_resolvers; import 'package:stream_chat/src/client/retry_policy.dart'; import 'package:stream_chat/src/core/api/attachment_file_uploader.dart'; import 'package:stream_chat/src/core/api/requests.dart'; @@ -26,6 +27,8 @@ import 'package:stream_chat/src/core/models/draft.dart'; import 'package:stream_chat/src/core/models/draft_message.dart'; import 'package:stream_chat/src/core/models/event.dart'; import 'package:stream_chat/src/core/models/filter.dart'; +import 'package:stream_chat/src/core/models/location.dart'; +import 'package:stream_chat/src/core/models/location_coordinates.dart'; import 'package:stream_chat/src/core/models/member.dart'; import 'package:stream_chat/src/core/models/message.dart'; import 'package:stream_chat/src/core/models/message_delivery.dart'; @@ -35,8 +38,11 @@ import 'package:stream_chat/src/core/models/poll.dart'; import 'package:stream_chat/src/core/models/poll_option.dart'; import 'package:stream_chat/src/core/models/poll_vote.dart'; import 'package:stream_chat/src/core/models/push_preference.dart'; +import 'package:stream_chat/src/core/models/reaction.dart'; import 'package:stream_chat/src/core/models/thread.dart'; import 'package:stream_chat/src/core/models/user.dart'; +import 'package:stream_chat/src/core/util/event_controller.dart'; +import 'package:stream_chat/src/core/util/extension.dart'; import 'package:stream_chat/src/core/util/utils.dart'; import 'package:stream_chat/src/db/chat_persistence_client.dart'; import 'package:stream_chat/src/event_type.dart'; @@ -80,15 +86,15 @@ class StreamChatClient { RetryPolicy? retryPolicy, String? baseURL, String? baseWsUrl, - Duration connectTimeout = const Duration(seconds: 6), - Duration receiveTimeout = const Duration(seconds: 6), + Duration connectTimeout = kDefaultConnectTimeout, + Duration receiveTimeout = kDefaultReceiveTimeout, StreamChatApi? chatApi, WebSocket? ws, - AttachmentFileUploaderProvider attachmentFileUploaderProvider = - StreamAttachmentFileUploader.new, + AttachmentFileUploaderProvider attachmentFileUploaderProvider = StreamAttachmentFileUploader.new, Iterable? chatApiInterceptors, HttpClientAdapter? httpClientAdapter, - }) { + bool recoverStateOnReconnect = true, + }) : _recoverStateOnReconnect = recoverStateOnReconnect { logger.info('Initiating new StreamChatClient'); final options = StreamHttpClientOptions( @@ -97,7 +103,8 @@ class StreamChatClient { receiveTimeout: receiveTimeout, ); - _chatApi = chatApi ?? + _chatApi = + chatApi ?? StreamChatApi( apiKey, options: options, @@ -110,7 +117,8 @@ class StreamChatClient { httpClientAdapter: httpClientAdapter, ); - _ws = ws ?? + _ws = + ws ?? WebSocket( apiKey: apiKey, baseUrl: baseWsUrl ?? options.baseUrl, @@ -120,7 +128,8 @@ class StreamChatClient { logger: detachedLogger('🔌'), ); - _retryPolicy = retryPolicy ?? + _retryPolicy = + retryPolicy ?? RetryPolicy( shouldRetry: (_, __, error) { return error is StreamChatNetworkError && error.isRetriable; @@ -192,6 +201,21 @@ class StreamChatClient { /// The retry policy options getter RetryPolicy get retryPolicy => _retryPolicy; + /// Whether the client should automatically refresh local state from the + /// server when the WebSocket connection recovers. + /// + /// When `true` (default), the client re-queries the active channels on + /// reconnect (capped at 30, ordered by `state.channels.keys`). The set of + /// state recovered on reconnect may grow in the future to cover threads, + /// reminders, etc. + /// + /// Setting this to `false` disables that client-level recovery. Consumers + /// that opt out are responsible for refreshing their own state when the + /// [EventType.connectionRecovered] event fires — for example, by re-running + /// their channel list query. + set recoverStateOnReconnect(bool value) => _recoverStateOnReconnect = value; + bool _recoverStateOnReconnect; + /// By default the Chat client will write all messages with level Warn or /// Error to stdout. /// @@ -239,21 +263,19 @@ class StreamChatClient { onMarkChannelsDelivered: markChannelsDelivered, ); - final _eventController = PublishSubject(); - /// Stream of [Event] coming from [_ws] connection /// Listen to this or use the [on] method to filter specific event types - Stream get eventStream => _eventController.stream.map( - // If the poll vote is an answer, we should emit a different event - // to make it easier to handle in the state. - (event) => switch ((event.type, event.pollVote?.isAnswer == true)) { - (EventType.pollVoteCasted || EventType.pollVoteChanged, true) => - event.copyWith(type: EventType.pollAnswerCasted), - (EventType.pollVoteRemoved, true) => - event.copyWith(type: EventType.pollAnswerRemoved), - _ => event, - }, - ); + Stream get eventStream => _eventController.stream; + late final _eventController = EventController( + resolvers: [ + event_resolvers.pollCreatedResolver, + event_resolvers.pollAnswerCastedResolver, + event_resolvers.pollAnswerRemovedResolver, + event_resolvers.locationSharedResolver, + event_resolvers.locationUpdatedResolver, + event_resolvers.locationExpiredResolver, + ], + ); /// The current status value of the [_ws] connection ConnectionStatus get wsConnectionStatus => _ws.connectionStatus; @@ -288,12 +310,11 @@ class StreamChatClient { User user, String token, { bool connectWebSocket = true, - }) => - _connectUser( - user, - token: Token.fromRawValue(token), - connectWebSocket: connectWebSocket, - ); + }) => _connectUser( + user, + token: Token.fromRawValue(token), + connectWebSocket: connectWebSocket, + ); /// Connects the current user using the [tokenProvider] to fetch the token. /// It returns a [Future] that resolves when the connection is setup. @@ -301,12 +322,11 @@ class StreamChatClient { User user, TokenProvider tokenProvider, { bool connectWebSocket = true, - }) => - _connectUser( - user, - provider: tokenProvider, - connectWebSocket: connectWebSocket, - ); + }) => _connectUser( + user, + provider: tokenProvider, + connectWebSocket: connectWebSocket, + ); /// Connects the current user with an anonymous id, this triggers a connection /// to the API. It returns a [Future] that resolves when the connection is @@ -519,10 +539,12 @@ class StreamChatClient { final isConnected = currStatus == ConnectionStatus.connected; // Notify the connection status change event - handleEvent(Event( - type: EventType.connectionChanged, - online: isConnected, - )); + handleEvent( + Event( + type: EventType.connectionChanged, + online: isConnected, + ), + ); final connectionRecovered = !wasConnected && isConnected; @@ -530,19 +552,25 @@ class StreamChatClient { // connection recovered final cids = [...state.channels.keys.toSet()]; if (cids.isNotEmpty) { - await queryChannelsOnline( - filter: Filter.in_('cid', cids), - paginationParams: const PaginationParams(limit: 30), - ); - // Sync the persistence client if available if (persistenceEnabled) await sync(cids: cids); + + // Recover the channels that were active before the connection was lost, + // only if the client is configured to do so. + if (_recoverStateOnReconnect) { + await queryChannelsOnline( + filter: Filter.in_('cid', cids), + paginationParams: const PaginationParams(limit: 30), + ); + } } - handleEvent(Event( - type: EventType.connectionRecovered, - online: true, - )); + handleEvent( + Event( + type: EventType.connectionRecovered, + online: true, + ), + ); } } @@ -555,11 +583,10 @@ class StreamChatClient { String? eventType4, ]) { if (eventType == null || eventType == EventType.any) return eventStream; - return eventStream.where((event) => - event.type == eventType || - event.type == eventType2 || - event.type == eventType3 || - event.type == eventType4); + return eventStream.where( + (event) => + event.type == eventType || event.type == eventType2 || event.type == eventType3 || event.type == eventType4, + ); } // Lock to make sure only one sync process is running at a time. @@ -661,6 +688,7 @@ class StreamChatClient { offlineChannels = await queryChannelsOffline( filter: filter, channelStateSort: channelStateSort, + messageLimit: messageLimit, paginationParams: paginationParams, ); @@ -671,26 +699,29 @@ class StreamChatClient { } try { - final newQueryChannelsFuture = queryChannelsOnline( - filter: filter, - sort: channelStateSort, - state: state, - watch: watch, - presence: presence, - memberLimit: memberLimit, - messageLimit: messageLimit, - paginationParams: paginationParams, - waitForConnect: waitForConnect, - ).timeout( - const Duration(seconds: 30), - onTimeout: () { - logger.warning('Online channel query timed out'); - throw TimeoutException('Channel query timed out'); - }, - ).whenComplete(() { - // Always clean up cache reference when done - _queryChannelsStreams.remove(hash); - }); + final newQueryChannelsFuture = + queryChannelsOnline( + filter: filter, + sort: channelStateSort, + state: state, + watch: watch, + presence: presence, + memberLimit: memberLimit, + messageLimit: messageLimit, + paginationParams: paginationParams, + waitForConnect: waitForConnect, + ) + .timeout( + const Duration(seconds: 30), + onTimeout: () { + logger.warning('Online channel query timed out'); + throw TimeoutException('Channel query timed out'); + }, + ) + .whenComplete(() { + // Always clean up cache reference when done + _queryChannelsStreams.remove(hash); + }); // Store the future in cache _queryChannelsStreams[hash] = newQueryChannelsFuture; @@ -703,27 +734,6 @@ class StreamChatClient { } } - /// Returns a token associated with the [callId]. - @Deprecated('Will be removed in the next major version') - Future getCallToken(String callId) async => - _chatApi.call.getCallToken(callId); - - /// Creates a new call. - @Deprecated('Will be removed in the next major version') - Future createCall({ - required String callId, - required String callType, - required String channelType, - required String channelId, - }) { - return _chatApi.call.createCall( - callId: callId, - callType: callType, - channelType: channelType, - channelId: channelId, - ); - } - /// Requests channels with a given query from the API. Future> queryChannelsOnline({ Filter? filter, @@ -762,7 +772,8 @@ class StreamChatClient { watch: watch, presence: presence, memberLimit: memberLimit, - messageLimit: messageLimit, + // Default limit is set to 25 in backend. + messageLimit: messageLimit ?? 25, paginationParams: paginationParams, ); @@ -777,10 +788,7 @@ class StreamChatClient { final channels = res.channels; - final users = channels - .expand((it) => it.members ?? []) - .map((it) => it.user) - .toList(growable: false); + final users = channels.expand((it) => it.members ?? []).map((it) => it.user).toList(growable: false); this.state.updateUsers(users); @@ -805,14 +813,22 @@ class StreamChatClient { Future> queryChannelsOffline({ Filter? filter, SortOrder? channelStateSort, + int? messageLimit, PaginationParams paginationParams = const PaginationParams(), }) async { - final offlineChannels = (await chatPersistenceClient?.getChannelStates( - filter: filter, - channelStateSort: channelStateSort, - paginationParams: paginationParams, - )) ?? - []; + final offlineChannels = await chatPersistenceClient?.getChannelStates( + filter: filter, + channelStateSort: channelStateSort, + // Default limit is set to 25 in backend. + messageLimit: messageLimit ?? 25, + paginationParams: paginationParams, + ); + + if (offlineChannels == null || offlineChannels.isEmpty) { + logger.info('No channels found in offline storage for the given query'); + return []; + } + final updatedData = _mapChannelStateToChannel(offlineChannels); state.addChannels(updatedData.key); return updatedData.value; @@ -861,12 +877,11 @@ class StreamChatClient { required Filter filter, SortOrder? sort, PaginationParams? pagination, - }) => - _chatApi.moderation.queryBannedUsers( - filter: filter, - sort: sort, - pagination: pagination, - ); + }) => _chatApi.moderation.queryBannedUsers( + filter: filter, + sort: sort, + pagination: pagination, + ); /// A message search. Future search( @@ -875,14 +890,13 @@ class StreamChatClient { SortOrder? sort, PaginationParams? paginationParams, Filter? messageFilters, - }) => - _chatApi.general.searchMessages( - filter, - query: query, - sort: sort, - pagination: paginationParams, - messageFilters: messageFilters, - ); + }) => _chatApi.general.searchMessages( + filter, + query: query, + sort: sort, + pagination: paginationParams, + messageFilters: messageFilters, + ); /// Send a [file] to the [channelId] of type [channelType] Future sendFile( @@ -892,15 +906,14 @@ class StreamChatClient { ProgressCallback? onSendProgress, CancelToken? cancelToken, Map? extraData, - }) => - _chatApi.fileUploader.sendFile( - file, - channelId, - channelType, - onSendProgress: onSendProgress, - cancelToken: cancelToken, - extraData: extraData, - ); + }) => _chatApi.fileUploader.sendFile( + file, + channelId, + channelType, + onSendProgress: onSendProgress, + cancelToken: cancelToken, + extraData: extraData, + ); /// Send a [image] to the [channelId] of type [channelType] Future sendImage( @@ -910,15 +923,14 @@ class StreamChatClient { ProgressCallback? onSendProgress, CancelToken? cancelToken, Map? extraData, - }) => - _chatApi.fileUploader.sendImage( - image, - channelId, - channelType, - onSendProgress: onSendProgress, - cancelToken: cancelToken, - extraData: extraData, - ); + }) => _chatApi.fileUploader.sendImage( + image, + channelId, + channelType, + onSendProgress: onSendProgress, + cancelToken: cancelToken, + extraData: extraData, + ); /// Delete a file from this channel Future deleteFile( @@ -927,14 +939,13 @@ class StreamChatClient { String channelType, { CancelToken? cancelToken, Map? extraData, - }) => - _chatApi.fileUploader.deleteFile( - url, - channelId, - channelType, - cancelToken: cancelToken, - extraData: extraData, - ); + }) => _chatApi.fileUploader.deleteFile( + url, + channelId, + channelType, + cancelToken: cancelToken, + extraData: extraData, + ); /// Delete an image from this channel Future deleteImage( @@ -943,14 +954,71 @@ class StreamChatClient { String channelType, { CancelToken? cancelToken, Map? extraData, - }) => - _chatApi.fileUploader.deleteImage( - url, - channelId, - channelType, - cancelToken: cancelToken, - extraData: extraData, - ); + }) => _chatApi.fileUploader.deleteImage( + url, + channelId, + channelType, + cancelToken: cancelToken, + extraData: extraData, + ); + + /// Upload an image to the Stream CDN + /// + /// Upload progress can be tracked using [onProgress], and the operation can + /// be cancelled using [cancelToken]. + /// + /// Returns a [UploadImageResponse] once uploaded successfully. + Future uploadImage( + AttachmentFile image, { + ProgressCallback? onUploadProgress, + CancelToken? cancelToken, + }) => _chatApi.fileUploader.uploadImage( + image, + onSendProgress: onUploadProgress, + cancelToken: cancelToken, + ); + + /// Upload a file to the Stream CDN + /// + /// Upload progress can be tracked using [onProgress], and the operation can + /// be cancelled using [cancelToken]. + /// + /// Returns a [UploadFileResponse] once uploaded successfully. + Future uploadFile( + AttachmentFile file, { + ProgressCallback? onUploadProgress, + CancelToken? cancelToken, + }) => _chatApi.fileUploader.uploadFile( + file, + onSendProgress: onUploadProgress, + cancelToken: cancelToken, + ); + + /// Remove an image from the Stream CDN using its [url]. + /// + /// The operation can be cancelled using [cancelToken] if needed. + /// + /// Returns an [EmptyResponse] once removed successfully. + Future removeImage( + String url, { + CancelToken? cancelToken, + }) => _chatApi.fileUploader.removeImage( + url, + cancelToken: cancelToken, + ); + + /// Remove a file from the Stream CDN using its [url]. + /// + /// The operation can be cancelled using [cancelToken] if needed. + /// + /// Returns an [EmptyResponse] once removed successfully. + Future removeFile( + String url, { + CancelToken? cancelToken, + }) => _chatApi.fileUploader.removeFile( + url, + cancelToken: cancelToken, + ); /// Replaces the [channelId] of type [ChannelType] data with [data]. /// @@ -960,13 +1028,12 @@ class StreamChatClient { String channelType, Map data, { Message? message, - }) => - _chatApi.channel.updateChannel( - channelId, - channelType, - data, - message: message, - ); + }) => _chatApi.channel.updateChannel( + channelId, + channelType, + data, + message: message, + ); /// Partial update for the [channelId] of type [ChannelType]. Sets the /// data provided in [set], and removes the attributes given in [unset]. @@ -977,32 +1044,29 @@ class StreamChatClient { String channelType, { Map? set, List? unset, - }) => - _chatApi.channel.updateChannelPartial( - channelId, - channelType, - set: set, - unset: unset, - ); + }) => _chatApi.channel.updateChannelPartial( + channelId, + channelType, + set: set, + unset: unset, + ); /// Add a device for Push Notifications. Future addDevice( String id, PushProvider pushProvider, { String? pushProviderName, - }) => - _chatApi.device.addDevice( - id, - pushProvider, - pushProviderName: pushProviderName, - ); + }) => _chatApi.device.addDevice( + id, + pushProvider, + pushProviderName: pushProviderName, + ); /// Gets a list of user devices. Future getDevices() => _chatApi.device.getDevices(); /// Remove a user's device. - Future removeDevice(String id) => - _chatApi.device.removeDevice(id); + Future removeDevice(String id) => _chatApi.device.removeDevice(id); /// Set push preferences for the current user. /// @@ -1103,13 +1167,12 @@ class StreamChatClient { String channelType, { String? channelId, Map? channelData, - }) => - queryChannel( - channelType, - channelId: channelId, - state: false, - channelData: channelData, - ); + }) => queryChannel( + channelType, + channelId: channelId, + state: false, + channelData: channelData, + ); /// watches the provided channel /// Creates first if not yet created @@ -1117,13 +1180,12 @@ class StreamChatClient { String channelType, { String? channelId, Map? channelData, - }) => - queryChannel( - channelType, - channelId: channelId, - watch: true, - channelData: channelData, - ); + }) => queryChannel( + channelType, + channelId: channelId, + watch: true, + channelData: channelData, + ); /// Query the API, get messages, members or other channel fields /// Creates the channel first if not yet created @@ -1137,18 +1199,17 @@ class StreamChatClient { PaginationParams? messagesPagination, PaginationParams? membersPagination, PaginationParams? watchersPagination, - }) => - _chatApi.channel.queryChannel( - channelType, - channelId: channelId, - channelData: channelData, - state: state, - watch: watch, - presence: presence, - messagesPagination: messagesPagination, - membersPagination: membersPagination, - watchersPagination: watchersPagination, - ); + }) => _chatApi.channel.queryChannel( + channelType, + channelId: channelId, + channelData: channelData, + state: state, + watch: watch, + presence: presence, + messagesPagination: messagesPagination, + membersPagination: membersPagination, + watchersPagination: watchersPagination, + ); /// Query channel members Future queryMembers( @@ -1158,15 +1219,14 @@ class StreamChatClient { List? members, SortOrder? sort, PaginationParams? pagination, - }) => - _chatApi.general.queryMembers( - channelType, - channelId: channelId, - filter: filter, - members: members, - sort: sort, - pagination: pagination, - ); + }) => _chatApi.general.queryMembers( + channelType, + channelId: channelId, + filter: filter, + members: members, + sort: sort, + pagination: pagination, + ); /// Hides the channel from [queryChannels] for the user /// until a message is added If [clearHistory] is set to true - all messages @@ -1175,32 +1235,29 @@ class StreamChatClient { String channelId, String channelType, { bool clearHistory = false, - }) => - _chatApi.channel.hideChannel( - channelId, - channelType, - clearHistory: clearHistory, - ); + }) => _chatApi.channel.hideChannel( + channelId, + channelType, + clearHistory: clearHistory, + ); /// Removes the hidden status for the channel Future showChannel( String channelId, String channelType, - ) => - _chatApi.channel.showChannel( - channelId, - channelType, - ); + ) => _chatApi.channel.showChannel( + channelId, + channelType, + ); /// Delete this channel. Messages are permanently removed. Future deleteChannel( String channelId, String channelType, - ) => - _chatApi.channel.deleteChannel( - channelId, - channelType, - ); + ) => _chatApi.channel.deleteChannel( + channelId, + channelType, + ); /// Removes all messages from the channel up to [truncatedAt] or now if /// [truncatedAt] is not provided. @@ -1212,52 +1269,47 @@ class StreamChatClient { Message? message, bool? skipPush, DateTime? truncatedAt, - }) => - _chatApi.channel.truncateChannel( - channelId, - channelType, - message: message, - skipPush: skipPush, - truncatedAt: truncatedAt, - ); + }) => _chatApi.channel.truncateChannel( + channelId, + channelType, + message: message, + skipPush: skipPush, + truncatedAt: truncatedAt, + ); /// Mutes the channel Future muteChannel( String channelCid, { Duration? expiration, - }) => - _chatApi.moderation.muteChannel( - channelCid, - expiration: expiration, - ); + }) => _chatApi.moderation.muteChannel( + channelCid, + expiration: expiration, + ); /// Unmutes the channel - Future unmuteChannel(String channelCid) => - _chatApi.moderation.unmuteChannel(channelCid); + Future unmuteChannel(String channelCid) => _chatApi.moderation.unmuteChannel(channelCid); /// Accept invitation to the channel Future acceptChannelInvite( String channelId, String channelType, { Message? message, - }) => - _chatApi.channel.acceptChannelInvite( - channelId, - channelType, - message: message, - ); + }) => _chatApi.channel.acceptChannelInvite( + channelId, + channelType, + message: message, + ); /// Reject invitation to the channel Future rejectChannelInvite( String channelId, String channelType, { Message? message, - }) => - _chatApi.channel.rejectChannelInvite( - channelId, - channelType, - message: message, - ); + }) => _chatApi.channel.rejectChannelInvite( + channelId, + channelType, + message: message, + ); /// Add members to the channel Future addChannelMembers( @@ -1267,15 +1319,14 @@ class StreamChatClient { Message? message, bool hideHistory = false, DateTime? hideHistoryBefore, - }) => - _chatApi.channel.addMembers( - channelId, - channelType, - memberIds, - message: message, - hideHistory: hideHistory, - hideHistoryBefore: hideHistoryBefore, - ); + }) => _chatApi.channel.addMembers( + channelId, + channelType, + memberIds, + message: message, + hideHistory: hideHistory, + hideHistoryBefore: hideHistoryBefore, + ); /// Remove members from the channel Future removeChannelMembers( @@ -1283,13 +1334,12 @@ class StreamChatClient { String channelType, List memberIds, { Message? message, - }) => - _chatApi.channel.removeMembers( - channelId, - channelType, - memberIds, - message: message, - ); + }) => _chatApi.channel.removeMembers( + channelId, + channelType, + memberIds, + message: message, + ); /// Invite members to the channel Future inviteChannelMembers( @@ -1297,23 +1347,21 @@ class StreamChatClient { String channelType, List memberIds, { Message? message, - }) => - _chatApi.channel.inviteChannelMembers( - channelId, - channelType, - memberIds, - message: message, - ); + }) => _chatApi.channel.inviteChannelMembers( + channelId, + channelType, + memberIds, + message: message, + ); /// Stop watching the channel Future stopChannelWatching( String channelId, String channelType, - ) => - _chatApi.channel.stopWatching( - channelId, - channelType, - ); + ) => _chatApi.channel.stopWatching( + channelId, + channelType, + ); /// Send action for a specific message of this channel Future sendAction( @@ -1321,13 +1369,12 @@ class StreamChatClient { String channelType, String messageId, Map formData, - ) => - _chatApi.message.sendAction( - channelId, - channelType, - messageId, - formData, - ); + ) => _chatApi.message.sendAction( + channelId, + channelType, + messageId, + formData, + ); /// Mark [channelId] of type [channelType] all messages as read /// Optionally provide a [messageId] if you want to mark a @@ -1336,12 +1383,11 @@ class StreamChatClient { String channelId, String channelType, { String? messageId, - }) => - _chatApi.channel.markRead( - channelId, - channelType, - messageId: messageId, - ); + }) => _chatApi.channel.markRead( + channelId, + channelType, + messageId: messageId, + ); /// Marks the [channelId] of type [channelType] as unread /// by a given [messageId]. @@ -1351,12 +1397,11 @@ class StreamChatClient { String channelId, String channelType, String messageId, - ) => - _chatApi.channel.markUnread( - channelId, - channelType, - messageId, - ); + ) => _chatApi.channel.markUnread( + channelId, + channelType, + messageId, + ); /// Marks the [channelId] of type [channelType] as unread /// by a given [timestamp]. @@ -1366,12 +1411,11 @@ class StreamChatClient { String channelId, String channelType, DateTime timestamp, - ) => - _chatApi.channel.markUnreadByTimestamp( - channelId, - channelType, - timestamp, - ); + ) => _chatApi.channel.markUnreadByTimestamp( + channelId, + channelType, + timestamp, + ); /// Mark the thread with [threadId] in the channel with [channelId] of type /// [channelType] as read. @@ -1379,12 +1423,11 @@ class StreamChatClient { String channelId, String channelType, String threadId, - ) => - _chatApi.channel.markThreadRead( - channelId, - channelType, - threadId, - ); + ) => _chatApi.channel.markThreadRead( + channelId, + channelType, + threadId, + ); /// Mark the thread with [threadId] in the channel with [channelId] of type /// [channelType] as unread. @@ -1392,24 +1435,20 @@ class StreamChatClient { String channelId, String channelType, String threadId, - ) => - _chatApi.channel.markThreadUnread( - channelId, - channelType, - threadId, - ); + ) => _chatApi.channel.markThreadUnread( + channelId, + channelType, + threadId, + ); /// Creates a new Poll - Future createPoll(Poll poll) => - _chatApi.polls.createPoll(poll); + Future createPoll(Poll poll) => _chatApi.polls.createPoll(poll); /// Retrieves a Poll by [pollId] - Future getPoll(String pollId) => - _chatApi.polls.getPoll(pollId); + Future getPoll(String pollId) => _chatApi.polls.getPoll(pollId); /// Updates a Poll - Future updatePoll(Poll poll) => - _chatApi.polls.updatePoll(poll); + Future updatePoll(Poll poll) => _chatApi.polls.updatePoll(poll); /// Partially updates a Poll by [pollId]. /// @@ -1419,50 +1458,46 @@ class StreamChatClient { String pollId, { Map? set, List? unset, - }) => - _chatApi.polls.partialUpdatePoll( - pollId, - set: set, - unset: unset, - ); + }) => _chatApi.polls.partialUpdatePoll( + pollId, + set: set, + unset: unset, + ); /// Deletes the Poll by [pollId]. - Future deletePoll(String pollId) => - _chatApi.polls.deletePoll(pollId); + Future deletePoll(String pollId) => _chatApi.polls.deletePoll(pollId); /// Marks the Poll [pollId] as closed. - Future closePoll(String pollId) => - partialUpdatePoll(pollId, set: { - 'is_closed': true, - }); + Future closePoll(String pollId) => partialUpdatePoll( + pollId, + set: { + 'is_closed': true, + }, + ); /// Creates a new Poll Option for the Poll [pollId]. Future createPollOption( String pollId, PollOption option, - ) => - _chatApi.polls.createPollOption(pollId, option); + ) => _chatApi.polls.createPollOption(pollId, option); /// Retrieves a Poll Option by [optionId] for the Poll [pollId]. Future getPollOption( String pollId, String optionId, - ) => - _chatApi.polls.getPollOption(pollId, optionId); + ) => _chatApi.polls.getPollOption(pollId, optionId); /// Updates a Poll Option for the Poll [pollId]. Future updatePollOption( String pollId, PollOption option, - ) => - _chatApi.polls.updatePollOption(pollId, option); + ) => _chatApi.polls.updatePollOption(pollId, option); /// Deletes a Poll Option by [optionId] for the Poll [pollId]. Future deletePollOption( String pollId, String optionId, - ) => - _chatApi.polls.deletePollOption(pollId, optionId); + ) => _chatApi.polls.deletePollOption(pollId, optionId); /// Cast a [vote] for the Poll [pollId]. Future castPollVote( @@ -1489,20 +1524,18 @@ class StreamChatClient { String messageId, String pollId, String voteId, - ) => - _chatApi.polls.removePollVote(messageId, pollId, voteId); + ) => _chatApi.polls.removePollVote(messageId, pollId, voteId); /// Queries Polls with the given [filter] and [sort] options. Future queryPolls({ Filter? filter, SortOrder? sort, PaginationParams pagination = const PaginationParams(), - }) => - _chatApi.polls.queryPolls( - filter: filter, - sort: sort, - pagination: pagination, - ); + }) => _chatApi.polls.queryPolls( + filter: filter, + sort: sort, + pagination: pagination, + ); /// Queries Poll Votes for the Poll [pollId] with the given [filter] /// and [sort] options. @@ -1511,20 +1544,18 @@ class StreamChatClient { Filter? filter, SortOrder? sort, PaginationParams pagination = const PaginationParams(), - }) => - _chatApi.polls.queryPollVotes( - pollId, - filter: filter, - sort: sort, - pagination: pagination, - ); + }) => _chatApi.polls.queryPollVotes( + pollId, + filter: filter, + sort: sort, + pagination: pagination, + ); /// Update or Create the given user object. Future updateUser(User user) => updateUsers([user]); /// Batch update a list of users - Future updateUsers(List users) => - _chatApi.user.updateUsers(users); + Future updateUsers(List users) => _chatApi.user.updateUsers(users); /// Partially update the given user with [id]. /// Use [set] to define values to be set. @@ -1545,48 +1576,43 @@ class StreamChatClient { /// Batch partial updates the [users]. Future partialUpdateUsers( List users, - ) => - _chatApi.user.partialUpdateUsers(users); + ) => _chatApi.user.partialUpdateUsers(users); /// Bans a user from all channels Future banUser( String targetUserId, [ Map options = const {}, - ]) => - _chatApi.moderation.banUser( - targetUserId, - options: options, - ); + ]) => _chatApi.moderation.banUser( + targetUserId, + options: options, + ); /// Remove global ban for a user Future unbanUser( String targetUserId, [ Map options = const {}, - ]) => - _chatApi.moderation.unbanUser( - targetUserId, - options: options, - ); + ]) => _chatApi.moderation.unbanUser( + targetUserId, + options: options, + ); /// Shadow bans a user Future shadowBan( String targetID, [ Map options = const {}, - ]) => - banUser(targetID, { - 'shadow': true, - ...options, - }); + ]) => banUser(targetID, { + 'shadow': true, + ...options, + }); /// Removes shadow ban from a user Future removeShadowBan( String targetID, [ Map options = const {}, - ]) => - unbanUser(targetID, { - 'shadow': true, - ...options, - }); + ]) => unbanUser(targetID, { + 'shadow': true, + ...options, + }); final _userBlockLock = Lock(); @@ -1656,38 +1682,34 @@ class StreamChatClient { // Emit an local event with the unread count information as a side effect // in order to update the current user state. - handleEvent(Event( - totalUnreadCount: response.totalUnreadCount, - unreadChannels: response.channels.length, - unreadThreads: response.threads.length, - )); + handleEvent( + Event( + totalUnreadCount: response.totalUnreadCount, + unreadChannels: response.channels.length, + unreadThreads: response.threads.length, + ), + ); return response; } /// Mutes a user - Future muteUser(String userId) => - _chatApi.moderation.muteUser(userId); + Future muteUser(String userId) => _chatApi.moderation.muteUser(userId); /// Unmutes a user - Future unmuteUser(String userId) => - _chatApi.moderation.unmuteUser(userId); + Future unmuteUser(String userId) => _chatApi.moderation.unmuteUser(userId); /// Flag a message - Future flagMessage(String messageId) => - _chatApi.moderation.flagMessage(messageId); + Future flagMessage(String messageId) => _chatApi.moderation.flagMessage(messageId); /// Unflag a message - Future unflagMessage(String messageId) => - _chatApi.moderation.unflagMessage(messageId); + Future unflagMessage(String messageId) => _chatApi.moderation.unflagMessage(messageId); /// Flag a user - Future flagUser(String userId) => - _chatApi.moderation.flagUser(userId); + Future flagUser(String userId) => _chatApi.moderation.flagUser(userId); /// Unflag a message - Future unflagUser(String userId) => - _chatApi.moderation.unflagUser(userId); + Future unflagUser(String userId) => _chatApi.moderation.unflagUser(userId); /// Mark all channels for this user as read Future markAllRead() => _chatApi.channel.markAllRead(); @@ -1720,44 +1742,34 @@ class StreamChatClient { String channelId, String channelType, Event event, - ) => - _chatApi.channel.sendEvent( - channelId, - channelType, - event, - ); + ) => _chatApi.channel.sendEvent( + channelId, + channelType, + event, + ); /// Send a [reactionType] for this [messageId] /// Set [enforceUnique] to true to remove the existing user reaction Future sendReaction( String messageId, - String reactionType, { - int score = 1, - Map extraData = const {}, + Reaction reaction, { + bool skipPush = false, bool enforceUnique = false, - }) { - final _extraData = { - 'score': score, - ...extraData, - }; - - return _chatApi.message.sendReaction( - messageId, - reactionType, - extraData: _extraData, - enforceUnique: enforceUnique, - ); - } + }) => _chatApi.message.sendReaction( + messageId, + reaction, + skipPush: skipPush, + enforceUnique: enforceUnique, + ); /// Delete a [reactionType] from this [messageId] Future deleteReaction( String messageId, String reactionType, - ) => - _chatApi.message.deleteReaction( - messageId, - reactionType, - ); + ) => _chatApi.message.deleteReaction( + messageId, + reactionType, + ); /// Sends the message to the given channel Future sendMessage( @@ -1766,46 +1778,59 @@ class StreamChatClient { String channelType, { bool skipPush = false, bool skipEnrichUrl = false, - }) => - _chatApi.message.sendMessage( - channelId, - channelType, - message, - skipPush: skipPush, - skipEnrichUrl: skipEnrichUrl, - ); + }) => _chatApi.message.sendMessage( + channelId, + channelType, + message, + skipPush: skipPush, + skipEnrichUrl: skipEnrichUrl, + ); /// Lists all the message replies for the [parentId] Future getReplies( String parentId, { PaginationParams? options, - }) => - _chatApi.message.getReplies( - parentId, - options: options, - ); + }) => _chatApi.message.getReplies( + parentId, + options: options, + ); /// Get all the reactions for a [messageId] Future getReactions( String messageId, { PaginationParams? pagination, - }) => - _chatApi.message.getReactions( - messageId, - pagination: pagination, - ); + }) => _chatApi.message.getReactions( + messageId, + pagination: pagination, + ); + + /// Queries reactions for a [messageId] with optional [filter], [sort], + /// and [pagination]. + /// + /// Unlike [getReactions], this method supports filtering by reaction type, + /// user ID, or creation date, sorting, and cursor-based pagination. + Future queryReactions( + String messageId, { + Filter? filter, + SortOrder? sort, + PaginationParams? pagination, + }) => _chatApi.message.queryReactions( + messageId, + filter: filter, + sort: sort, + pagination: pagination, + ); /// Update the given message Future updateMessage( Message message, { bool skipPush = false, bool skipEnrichUrl = false, - }) => - _chatApi.message.updateMessage( - message, - skipPush: skipPush, - skipEnrichUrl: skipEnrichUrl, - ); + }) => _chatApi.message.updateMessage( + message, + skipPush: skipPush, + skipEnrichUrl: skipEnrichUrl, + ); /// Partially update the given [messageId] /// Use [set] to define values to be set @@ -1815,34 +1840,40 @@ class StreamChatClient { Map? set, List? unset, bool skipEnrichUrl = false, - }) => - _chatApi.message.partialUpdateMessage( - messageId, - set: set, - unset: unset, - skipEnrichUrl: skipEnrichUrl, - ); + }) => _chatApi.message.partialUpdateMessage( + messageId, + set: set, + unset: unset, + skipEnrichUrl: skipEnrichUrl, + ); - /// Deletes the given message + /// Deletes the given message. + /// + /// If [hard] is true, the message is permanently deleted. Future deleteMessage( String messageId, { bool hard = false, - }) async { - final response = await _chatApi.message.deleteMessage( + }) { + return _chatApi.message.deleteMessage( messageId, hard: hard, ); + } - if (hard) { - await chatPersistenceClient?.deleteMessageById(messageId); - } - - return response; + /// Deletes the given message for the current user only. + /// + /// Note: This does not delete the message for other users in the channel. + Future deleteMessageForMe( + String messageId, + ) { + return _chatApi.message.deleteMessage( + messageId, + deleteForMe: true, + ); } /// Get a message by [messageId] - Future getMessage(String messageId) => - _chatApi.message.getMessage(messageId); + Future getMessage(String messageId) => _chatApi.message.getMessage(messageId); /// Retrieves a list of messages by [messageIDs] /// from the given [channelId] of type [channelType] @@ -1850,34 +1881,31 @@ class StreamChatClient { String channelId, String channelType, List messageIDs, - ) => - _chatApi.message.getMessagesById( - channelId, - channelType, - messageIDs, - ); + ) => _chatApi.message.getMessagesById( + channelId, + channelType, + messageIDs, + ); /// Translates the [messageId] in provided [language] Future translateMessage( String messageId, String language, - ) => - _chatApi.message.translateMessage( - messageId, - language, - ); + ) => _chatApi.message.translateMessage( + messageId, + language, + ); /// Creates a draft for the given [channelId] of type [channelType]. Future createDraft( DraftMessage draft, String channelId, String channelType, - ) => - _chatApi.message.createDraft( - channelId, - channelType, - draft, - ); + ) => _chatApi.message.createDraft( + channelId, + channelType, + draft, + ); /// Retrieves a draft for the given [channelId] of type [channelType]. /// @@ -1886,12 +1914,11 @@ class StreamChatClient { String channelId, String channelType, { String? parentId, - }) => - _chatApi.message.getDraft( - channelId, - channelType, - parentId: parentId, - ); + }) => _chatApi.message.getDraft( + channelId, + channelType, + parentId: parentId, + ); /// Deletes a draft for the given [channelId] of type [channelType]. /// @@ -1900,45 +1927,86 @@ class StreamChatClient { String channelId, String channelType, { String? parentId, - }) => - _chatApi.message.deleteDraft( - channelId, - channelType, - parentId: parentId, - ); + }) => _chatApi.message.deleteDraft( + channelId, + channelType, + parentId: parentId, + ); /// Queries drafts for the current user. Future queryDrafts({ Filter? filter, SortOrder? sort, PaginationParams? pagination, - }) => - _chatApi.message.queryDrafts( - sort: sort, - pagination: pagination, - ); + }) => _chatApi.message.queryDrafts( + sort: sort, + pagination: pagination, + ); + + /// Retrieves all the active live locations of the current user. + Future getActiveLiveLocations() async { + try { + final response = await _chatApi.user.getActiveLiveLocations(); + + // Update the active live locations in the state. + final activeLiveLocations = response.activeLiveLocations; + state.activeLiveLocations = activeLiveLocations; + + return response; + } catch (e, stk) { + logger.severe('Error getting active live locations', e, stk); + rethrow; + } + } + + /// Updates an existing live location created by the current user. + Future updateLiveLocation({ + required String messageId, + String? createdByDeviceId, + LocationCoordinates? location, + DateTime? endAt, + }) { + return _chatApi.user.updateLiveLocation( + messageId: messageId, + createdByDeviceId: createdByDeviceId, + location: location, + endAt: endAt, + ); + } + + /// Expire an existing live location created by the current user. + Future stopLiveLocation({ + required String messageId, + String? createdByDeviceId, + }) { + return updateLiveLocation( + messageId: messageId, + createdByDeviceId: createdByDeviceId, + // Passing the current time as endAt will mark the location as expired + // and make it inactive. + endAt: DateTime.timestamp(), + ); + } /// Enables slow mode Future enableSlowdown( String channelId, String channelType, int cooldown, - ) async => - _chatApi.channel.enableSlowdown( - channelId, - channelType, - cooldown, - ); + ) async => _chatApi.channel.enableSlowdown( + channelId, + channelType, + cooldown, + ); /// Disables slow mode Future disableSlowdown( String channelId, String channelType, - ) async => - _chatApi.channel.disableSlowdown( - channelId, - channelType, - ); + ) async => _chatApi.channel.disableSlowdown( + channelId, + channelType, + ); /// Pins provided message /// [timeoutOrExpirationDate] can either be a [DateTime] or a value in seconds @@ -1948,9 +2016,7 @@ class StreamChatClient { Object? /*num|DateTime*/ timeoutOrExpirationDate, }) { assert(() { - if (timeoutOrExpirationDate is! DateTime && - timeoutOrExpirationDate != null && - timeoutOrExpirationDate is! num) { + if (timeoutOrExpirationDate is! DateTime && timeoutOrExpirationDate != null && timeoutOrExpirationDate is! num) { throw ArgumentError('Invalid timeout or Expiration date'); } return true; @@ -1974,17 +2040,15 @@ class StreamChatClient { } /// Unpins provided message - Future unpinMessage(String messageId) => - partialUpdateMessage( - messageId, - set: { - 'pinned': false, - }, - ); + Future unpinMessage(String messageId) => partialUpdateMessage( + messageId, + set: { + 'pinned': false, + }, + ); /// Get OpenGraph data of the given [url]. - Future enrichUrl(String url) => - _chatApi.general.enrichUrl(url); + Future enrichUrl(String url) => _chatApi.general.enrichUrl(url); /// Queries threads with the given [options] and [pagination] params. /// @@ -1994,13 +2058,12 @@ class StreamChatClient { SortOrder? sort, ThreadOptions options = const ThreadOptions(), PaginationParams pagination = const PaginationParams(), - }) => - _chatApi.threads.queryThreads( - filter: filter, - sort: sort, - options: options, - pagination: pagination, - ); + }) => _chatApi.threads.queryThreads( + filter: filter, + sort: sort, + options: options, + pagination: pagination, + ); /// Retrieves a thread with the given [messageId]. /// @@ -2008,11 +2071,10 @@ class StreamChatClient { Future getThread( String messageId, { ThreadOptions options = const ThreadOptions(), - }) => - _chatApi.threads.getThread( - messageId, - options: options, - ); + }) => _chatApi.threads.getThread( + messageId, + options: options, + ); /// Partially updates the thread with the given [messageId]. /// @@ -2022,12 +2084,11 @@ class StreamChatClient { String messageId, { Map? set, List? unset, - }) => - _chatApi.threads.partialUpdateThread( - messageId, - set: set, - unset: unset, - ); + }) => _chatApi.threads.partialUpdateThread( + messageId, + set: set, + unset: unset, + ); /// Pins the channel for the current user. Future pinChannel({ @@ -2225,15 +2286,28 @@ class ClientState { }), ); + // region CHANNEL EVENTS _listenChannelLeft(); - _listenChannelDeleted(); - _listenChannelHidden(); + // endregion + // region USER EVENTS _listenUserUpdated(); + _listenUserMessagesDeleted(); + // endregion + // region READ EVENTS _listenAllChannelsRead(); + // endregion + + // region LOCATION EVENTS + _listenLocationShared(); + _listenLocationUpdated(); + _listenLocationExpired(); + // endregion + + _startCleaningExpiredLocations(); } /// Stops listening to the client events. @@ -2307,18 +2381,17 @@ class ClientState { _eventsSubscription?.add( _client .on( - EventType.memberRemoved, - EventType.notificationRemovedFromChannel, - ) + EventType.memberRemoved, + EventType.notificationRemovedFromChannel, + ) .listen((event) async { - final isCurrentUser = event.user!.id == currentUser!.id; - if (isCurrentUser && event.channel != null) { - final eventChannel = event.channel!; - await _client.chatPersistenceClient - ?.deleteChannels([eventChannel.cid]); - channels.remove(eventChannel.cid)?.dispose(); - } - }), + final isCurrentUser = event.user!.id == currentUser!.id; + if (isCurrentUser && event.channel != null) { + final eventChannel = event.channel!; + await _client.chatPersistenceClient?.deleteChannels([eventChannel.cid]); + channels.remove(eventChannel.cid)?.dispose(); + } + }), ); } @@ -2326,17 +2399,132 @@ class ClientState { _eventsSubscription?.add( _client .on( - EventType.channelDeleted, - EventType.notificationChannelDeleted, - ) + EventType.channelDeleted, + EventType.notificationChannelDeleted, + ) .listen((Event event) async { - final eventChannel = event.channel!; - await _client.chatPersistenceClient?.deleteChannels([eventChannel.cid]); - channels.remove(eventChannel.cid)?.dispose(); + final eventChannel = event.channel!; + await _client.chatPersistenceClient?.deleteChannels([eventChannel.cid]); + channels.remove(eventChannel.cid)?.dispose(); + }), + ); + } + + void _listenUserMessagesDeleted() { + _eventsSubscription?.add( + _client.on(EventType.userMessagesDeleted).listen((event) async { + final cid = event.cid; + // Only handle message deletions that are not channel specific + // (i.e. user banned globally from the app) + if (cid != null) return; + + // Iterate through all the available channels and send the event + // to be handled by the respective channel instances. + for (final cid in [...channels.keys]) { + final channelEvent = event.copyWith(cid: cid); + _client.handleEvent(channelEvent); + } }), ); } + void _listenLocationShared() { + _eventsSubscription?.add( + _client.on(EventType.locationShared).listen((event) { + final location = event.message?.sharedLocation; + if (location == null || location.isStatic) return; + + final currentUserId = currentUser?.id; + if (currentUserId == null) return; + if (location.userId != currentUserId) return; + + final newActiveLiveLocations = [ + ...activeLiveLocations.merge( + [location], + key: (it) => (it.userId, it.channelCid, it.createdByDeviceId), + update: (original, updated) => updated, + ), + ]; + + activeLiveLocations = newActiveLiveLocations; + }), + ); + } + + void _listenLocationUpdated() { + _eventsSubscription?.add( + _client.on(EventType.locationUpdated).listen((event) { + final location = event.message?.sharedLocation; + if (location == null || location.isStatic) return; + + final currentUserId = currentUser?.id; + if (currentUserId == null) return; + if (location.userId != currentUserId) return; + + final newActiveLiveLocations = [ + ...activeLiveLocations.merge( + [location], + key: (it) => (it.userId, it.channelCid, it.createdByDeviceId), + update: (original, updated) => updated, + ), + ]; + + activeLiveLocations = newActiveLiveLocations; + }), + ); + } + + void _listenLocationExpired() { + _eventsSubscription?.add( + _client.on(EventType.locationExpired).listen((event) { + final location = event.message?.sharedLocation; + if (location == null || location.isStatic) return; + + final currentUserId = currentUser?.id; + if (currentUserId == null) return; + if (location.userId != currentUserId) return; + + final newActiveLiveLocations = [ + ...activeLiveLocations.where( + (it) => it.messageId != location.messageId, + ), + ]; + + activeLiveLocations = newActiveLiveLocations; + }), + ); + } + + Timer? _staleLiveLocationsCleanerTimer; + void _startCleaningExpiredLocations() { + _staleLiveLocationsCleanerTimer?.cancel(); + _staleLiveLocationsCleanerTimer = Timer.periodic( + const Duration(seconds: 1), + (_) { + final expired = activeLiveLocations.where((it) => it.isExpired); + if (expired.isEmpty) return; + + for (final sharedLocation in expired) { + final lastUpdatedAt = DateTime.timestamp(); + + final locationExpiredEvent = Event( + type: EventType.locationExpired, + cid: sharedLocation.channelCid, + message: Message( + id: sharedLocation.messageId, + updatedAt: lastUpdatedAt, + sharedLocation: sharedLocation.copyWith( + updatedAt: lastUpdatedAt, + ), + ), + ); + + _client.handleEvent(locationExpiredEvent); + } + }, + ); + } + final StreamChatClient _client; /// Sets the user currently interacting with the client @@ -2371,6 +2559,23 @@ class ClientState { /// The current user as a stream Stream> get usersStream => _usersController.stream; + /// The current active live locations shared by the user. + List get activeLiveLocations { + return _activeLiveLocationsController.value; + } + + /// The current active live locations shared by the user as a stream. + Stream> get activeLiveLocationsStream { + return _activeLiveLocationsController.stream; + } + + /// Sets the active live locations. + set activeLiveLocations(List locations) { + // For safe-keeping, we filter out any inactive locations before update. + final activeLocations = [...locations.where((it) => it.isActive)]; + _activeLiveLocationsController.add(activeLocations); + } + /// The current unread channels count int get unreadChannels => _unreadChannelsController.value; @@ -2440,14 +2645,18 @@ class ClientState { final _unreadChannelsController = BehaviorSubject.seeded(0); final _unreadThreadsController = BehaviorSubject.seeded(0); final _totalUnreadCountController = BehaviorSubject.seeded(0); + final _activeLiveLocationsController = BehaviorSubject.seeded([]); /// Call this method to dispose this object void dispose() { cancelEventSubscription(); _currentUserController.close(); + _usersController.close(); _unreadChannelsController.close(); _unreadThreadsController.close(); _totalUnreadCountController.close(); + _activeLiveLocationsController.close(); + _staleLiveLocationsCleanerTimer?.cancel(); final channels = [...this.channels.keys]; for (final channel in channels) { diff --git a/packages/stream_chat/lib/src/client/event_resolvers.dart b/packages/stream_chat/lib/src/client/event_resolvers.dart new file mode 100644 index 0000000000..0bcd097499 --- /dev/null +++ b/packages/stream_chat/lib/src/client/event_resolvers.dart @@ -0,0 +1,129 @@ +import 'package:stream_chat/src/core/models/event.dart'; +import 'package:stream_chat/src/event_type.dart'; + +/// Resolves message new events into more specific `pollCreated` events +/// for easier downstream state handling. +/// +/// Applies when: +/// - `event.type` is `messageNew` or `notification.message_new, and +/// - `event.poll` is not null +/// +/// Returns a modified event with type `pollCreated`, +/// or `null` if not applicable. +Event? pollCreatedResolver(Event event) { + final validTypes = {EventType.messageNew, EventType.notificationMessageNew}; + if (!validTypes.contains(event.type)) return null; + + final poll = event.poll; + if (poll == null) return null; + + // If the event is a message new or notification message new and + // it contains a poll, we can resolve it to a poll created event. + return event.copyWith(type: EventType.pollCreated); +} + +/// Resolves casted or changed poll vote events into more specific +/// `pollAnswerCasted` events for easier downstream state handling. +/// +/// Applies when: +/// - `event.type` is `pollVoteCasted` or `pollVoteChanged`, and +/// - `event.pollVote?.isAnswer == true` +/// +/// Returns a modified event with type `pollAnswerCasted`, +/// or `null` if not applicable. +Event? pollAnswerCastedResolver(Event event) { + final validTypes = {EventType.pollVoteCasted, EventType.pollVoteChanged}; + if (!validTypes.contains(event.type)) return null; + + final pollVote = event.pollVote; + if (pollVote?.isAnswer != true) return null; + + // If the event is a poll vote casted or changed and it's an answer + // we can resolve it to a poll answer casted event. + return event.copyWith(type: EventType.pollAnswerCasted); +} + +/// Resolves removed poll vote events into more specific +/// `pollAnswerRemoved` events for easier downstream state handling. +/// +/// Applies when: +/// - `event.type` is `pollVoteRemoved`, and +/// - `event.pollVote?.isAnswer == true` +/// +/// Returns a modified event with type `pollAnswerRemoved`, +/// or `null` if not applicable. +Event? pollAnswerRemovedResolver(Event event) { + if (event.type != EventType.pollVoteRemoved) return null; + + final pollVote = event.pollVote; + if (pollVote?.isAnswer != true) return null; + + // If the event is a poll vote removed and it's an answer + // we can resolve it to a poll answer removed event. + return event.copyWith(type: EventType.pollAnswerRemoved); +} + +/// Resolves message new events into more specific `locationShared` events +/// for easier downstream state handling. +/// +/// Applies when: +/// - `event.type` is `messageNew` or `notification.message_new, and +/// - `event.message.sharedLocation` is not null +/// +/// Returns a modified event with type `locationShared`, +/// or `null` if not applicable. +Event? locationSharedResolver(Event event) { + final validTypes = {EventType.messageNew, EventType.notificationMessageNew}; + if (!validTypes.contains(event.type)) return null; + + final sharedLocation = event.message?.sharedLocation; + if (sharedLocation == null) return null; + + // If the event is a message new or notification message new and it + // contains a shared location, we can resolve it to a location shared event. + return event.copyWith(type: EventType.locationShared); +} + +/// Resolves message updated events into more specific `locationUpdated` +/// events for easier downstream state handling. +/// +/// Applies when: +/// - `event.type` is `messageUpdated`, and +/// - `event.message.sharedLocation` is not null and not expired +/// +/// Returns a modified event with type `locationUpdated`, +/// or `null` if not applicable. +Event? locationUpdatedResolver(Event event) { + if (event.type != EventType.messageUpdated) return null; + + final sharedLocation = event.message?.sharedLocation; + if (sharedLocation == null) return null; + + if (sharedLocation.isLive && sharedLocation.isExpired) return null; + + // If the location is static or still active, we can resolve it + // to a location updated event. + return event.copyWith(type: EventType.locationUpdated); +} + +/// Resolves message updated events into more specific `locationExpired` +/// events for easier downstream state handling. +/// +/// Applies when: +/// - `event.type` is `messageUpdated`, and +/// - `event.message.sharedLocation` is not null and expired +/// +/// Returns a modified event with type `locationExpired`, +/// or `null` if not applicable. +Event? locationExpiredResolver(Event event) { + if (event.type != EventType.messageUpdated) return null; + + final sharedLocation = event.message?.sharedLocation; + if (sharedLocation == null) return null; + + if (sharedLocation.isStatic || sharedLocation.isActive) return null; + + // If the location is live and expired, we can resolve it to a + // location expired event. + return event.copyWith(type: EventType.locationExpired); +} diff --git a/packages/stream_chat/lib/src/client/key_stroke_handler.dart b/packages/stream_chat/lib/src/client/key_stroke_handler.dart index 52878182fd..501c1f944d 100644 --- a/packages/stream_chat/lib/src/client/key_stroke_handler.dart +++ b/packages/stream_chat/lib/src/client/key_stroke_handler.dart @@ -87,13 +87,15 @@ class KeyStrokeHandler { _cancelKeyStrokeTimer(); _keyStrokeTimer = Timer(Duration(seconds: startTypingEventTimeout), () { - _stopTyping(parentId).then((_) { - if (completer.isCompleted) return; - completer.complete(); - }).onError((error, stackTrace) { - if (completer.isCompleted) return; - completer.completeError(error!, stackTrace); - }); + _stopTyping(parentId) + .then((_) { + if (completer.isCompleted) return; + completer.complete(); + }) + .onError((error, stackTrace) { + if (completer.isCompleted) return; + completer.completeError(error!, stackTrace); + }); }); // If the user is typing too long, it should call [onStartTyping] again. diff --git a/packages/stream_chat/lib/src/client/retry_policy.dart b/packages/stream_chat/lib/src/client/retry_policy.dart index b5d0804368..4c7d249e84 100644 --- a/packages/stream_chat/lib/src/client/retry_policy.dart +++ b/packages/stream_chat/lib/src/client/retry_policy.dart @@ -51,5 +51,6 @@ class RetryPolicy { StreamChatClient client, int attempt, StreamChatError? error, - ) shouldRetry; + ) + shouldRetry; } diff --git a/packages/stream_chat/lib/src/client/retry_queue.dart b/packages/stream_chat/lib/src/client/retry_queue.dart index 8675ee68b7..feb6b623f6 100644 --- a/packages/stream_chat/lib/src/client/retry_queue.dart +++ b/packages/stream_chat/lib/src/client/retry_queue.dart @@ -31,12 +31,16 @@ class RetryQueue { final _messageQueue = HeapPriorityQueue(_byDate); void _listenConnectionRecovered() { - client.on(EventType.connectionRecovered).distinct().listen((event) { - if (event.online == true) { - logger?.info('Connection recovered, retrying failed messages'); - channel.state?.retryFailedMessages(); - } - }).addTo(_compositeSubscription); + client + .on(EventType.connectionRecovered) + .distinct() + .listen((event) { + if (event.online == true) { + logger?.info('Connection recovered, retrying failed messages'); + channel.state?.retryFailedMessages(); + } + }) + .addTo(_compositeSubscription); } /// Add a list of messages. @@ -119,10 +123,11 @@ class RetryQueue { } static DateTime? _getMessageDate(Message message) { - return message.state.maybeWhen( - failed: (state, _) => state.when( - sendingFailed: () => message.createdAt, - updatingFailed: () => message.updatedAt, + return message.state.maybeMap( + failed: (it) => it.state.map( + sendingFailed: (_) => message.createdAt, + updatingFailed: (_) => message.updatedAt, + partialUpdatingFailed: (_) => message.updatedAt, deletingFailed: (_) => message.deletedAt, ), orElse: () => null, diff --git a/packages/stream_chat/lib/src/core/api/attachment_file_uploader.dart b/packages/stream_chat/lib/src/core/api/attachment_file_uploader.dart index c851d11472..9575dfa55d 100644 --- a/packages/stream_chat/lib/src/core/api/attachment_file_uploader.dart +++ b/packages/stream_chat/lib/src/core/api/attachment_file_uploader.dart @@ -4,9 +4,10 @@ import 'package:stream_chat/src/core/http/stream_http_client.dart'; import 'package:stream_chat/src/core/models/attachment_file.dart'; /// Signature for a function which provides instance of [AttachmentFileUploader] -typedef AttachmentFileUploaderProvider = AttachmentFileUploader Function( - StreamHttpClient httpClient, -); +typedef AttachmentFileUploaderProvider = + AttachmentFileUploader Function( + StreamHttpClient httpClient, + ); /// Class responsible for uploading images and files to a given channel abstract class AttachmentFileUploader { @@ -61,6 +62,54 @@ abstract class AttachmentFileUploader { CancelToken? cancelToken, Map? extraData, }); + + // region Standalone upload methods + + /// Uploads an image file to the CDN. + /// + /// Upload progress can be tracked using [onSendProgress], and the operation + /// can be cancelled using [cancelToken]. + /// + /// Returns a [UploadImageResponse] once uploaded successfully. + Future uploadImage( + AttachmentFile image, { + ProgressCallback? onSendProgress, + CancelToken? cancelToken, + }); + + /// Uploads a file to the CDN. + /// + /// Upload progress can be tracked using [onSendProgress], and the operation + /// can be cancelled using [cancelToken]. + /// + /// Returns a [UploadFileResponse] once uploaded successfully. + Future uploadFile( + AttachmentFile file, { + ProgressCallback? onSendProgress, + CancelToken? cancelToken, + }); + + /// Removes an image from the CDN using its [url]. + /// + /// The operation can be cancelled using [cancelToken] if needed. + /// + /// Returns a [EmptyResponse] once removed successfully. + Future removeImage( + String url, { + CancelToken? cancelToken, + }); + + /// Removes a file from the CDN using its [url]. + /// + /// The operation can be cancelled using [cancelToken] if needed. + /// + /// Returns a [EmptyResponse] once removed successfully. + Future removeFile( + String url, { + CancelToken? cancelToken, + }); + + // endregion } /// Stream's default implementation of [AttachmentFileUploader] @@ -139,4 +188,62 @@ class StreamAttachmentFileUploader implements AttachmentFileUploader { ); return EmptyResponse.fromJson(response.data); } + + @override + Future uploadImage( + AttachmentFile image, { + ProgressCallback? onSendProgress, + CancelToken? cancelToken, + }) async { + final multiPartFile = await image.toMultipartFile(); + final response = await _client.postFile( + '/uploads/image', + multiPartFile, + onSendProgress: onSendProgress, + cancelToken: cancelToken, + ); + return UploadImageResponse.fromJson(response.data); + } + + @override + Future uploadFile( + AttachmentFile file, { + ProgressCallback? onSendProgress, + CancelToken? cancelToken, + }) async { + final multiPartFile = await file.toMultipartFile(); + final response = await _client.postFile( + '/uploads/file', + multiPartFile, + onSendProgress: onSendProgress, + cancelToken: cancelToken, + ); + return UploadFileResponse.fromJson(response.data); + } + + @override + Future removeImage( + String url, { + CancelToken? cancelToken, + }) async { + final response = await _client.delete( + '/uploads/image', + queryParameters: {'url': url}, + cancelToken: cancelToken, + ); + return EmptyResponse.fromJson(response.data); + } + + @override + Future removeFile( + String url, { + CancelToken? cancelToken, + }) async { + final response = await _client.delete( + '/uploads/file', + queryParameters: {'url': url}, + cancelToken: cancelToken, + ); + return EmptyResponse.fromJson(response.data); + } } diff --git a/packages/stream_chat/lib/src/core/api/call_api.dart b/packages/stream_chat/lib/src/core/api/call_api.dart deleted file mode 100644 index 4981cb0a93..0000000000 --- a/packages/stream_chat/lib/src/core/api/call_api.dart +++ /dev/null @@ -1,42 +0,0 @@ -import 'package:stream_chat/src/core/api/responses.dart'; -import 'package:stream_chat/src/core/http/stream_http_client.dart'; - -/// Defines the api dedicated to call operations. -@Deprecated('Will be removed in the next major version') -class CallApi { - /// Initialize a new call api - CallApi(this._client); - - final StreamHttpClient _client; - - /// Returns a token dedicated to the [callId] - Future getCallToken(String callId) async { - final response = await _client.post( - '/calls/$callId', - data: {}, - ); - // return response.data; - return CallTokenPayload.fromJson(response.data); - } - - /// Creates a new call - Future createCall({ - required String callId, - required String callType, - required String channelType, - required String channelId, - }) async { - final response = await _client.post( - _getChannelUrl(channelId, channelType), - data: { - 'id': callId, - 'type': callType, - }, - ); - // return response.data; - return CreateCallPayload.fromJson(response.data); - } - - String _getChannelUrl(String channelId, String channelType) => - '/channels/$channelType/$channelId/call'; -} diff --git a/packages/stream_chat/lib/src/core/api/channel_api.dart b/packages/stream_chat/lib/src/core/api/channel_api.dart index 77c2425a55..b2addba7d6 100644 --- a/packages/stream_chat/lib/src/core/api/channel_api.dart +++ b/packages/stream_chat/lib/src/core/api/channel_api.dart @@ -17,8 +17,7 @@ class ChannelApi { final StreamHttpClient _client; - String _getChannelUrl(String channelId, String channelType) => - '/channels/$channelType/$channelId'; + String _getChannelUrl(String channelId, String channelType) => '/channels/$channelType/$channelId'; /// Query the API, get messages, members or other channel fields Future queryChannel( @@ -100,8 +99,7 @@ class ChannelApi { _getChannelUrl(channelId, channelType), data: { 'data': data, - if (message != null) - 'message': message.copyWith(updatedAt: DateTime.now()), + if (message != null) 'message': message.copyWith(updatedAt: DateTime.now()), }, ); return UpdateChannelResponse.fromJson(response.data); diff --git a/packages/stream_chat/lib/src/core/api/device_api.dart b/packages/stream_chat/lib/src/core/api/device_api.dart index b450ddc18e..1d75994036 100644 --- a/packages/stream_chat/lib/src/core/api/device_api.dart +++ b/packages/stream_chat/lib/src/core/api/device_api.dart @@ -37,8 +37,7 @@ class DeviceApi { data: { 'id': deviceId, 'push_provider': pushProvider.name, - if (pushProviderName != null && pushProviderName.isNotEmpty) - 'push_provider_name': pushProviderName, + if (pushProviderName != null && pushProviderName.isNotEmpty) 'push_provider_name': pushProviderName, }, ); return EmptyResponse.fromJson(response.data); diff --git a/packages/stream_chat/lib/src/core/api/general_api.dart b/packages/stream_chat/lib/src/core/api/general_api.dart index c364674463..16b4bdb9f8 100644 --- a/packages/stream_chat/lib/src/core/api/general_api.dart +++ b/packages/stream_chat/lib/src/core/api/general_api.dart @@ -60,8 +60,7 @@ class GeneralApi { 'filter_conditions': filter, if (sort != null) 'sort': sort, if (query != null) 'query': query, - if (messageFilters != null) - 'message_filter_conditions': messageFilters, + if (messageFilters != null) 'message_filter_conditions': messageFilters, if (pagination != null) ...pagination.toJson(), }), }, @@ -85,10 +84,7 @@ class GeneralApi { 'payload': jsonEncode({ 'type': channelType, 'filter_conditions': filter ?? {}, - if (channelId != null) - 'id': channelId - else if (members != null) - 'members': members, + if (channelId != null) 'id': channelId else if (members != null) 'members': members, if (sort != null) 'sort': sort, if (pagination != null) ...pagination.toJson(), }), diff --git a/packages/stream_chat/lib/src/core/api/message_api.dart b/packages/stream_chat/lib/src/core/api/message_api.dart index 442e39761f..5f3fe356cf 100644 --- a/packages/stream_chat/lib/src/core/api/message_api.dart +++ b/packages/stream_chat/lib/src/core/api/message_api.dart @@ -8,6 +8,7 @@ import 'package:stream_chat/src/core/models/draft.dart'; import 'package:stream_chat/src/core/models/draft_message.dart'; import 'package:stream_chat/src/core/models/filter.dart'; import 'package:stream_chat/src/core/models/message.dart'; +import 'package:stream_chat/src/core/models/reaction.dart'; /// Defines the api dedicated to messages operations class MessageApi { @@ -174,14 +175,20 @@ class MessageApi { Future deleteMessage( String messageId, { bool? hard, + bool? deleteForMe, }) async { + if (hard == true && deleteForMe == true) { + throw ArgumentError( + 'Both hard and deleteForMe cannot be set at the same time.', + ); + } + final response = await _client.delete( '/messages/$messageId', - queryParameters: hard != null - ? { - 'hard': hard, - } - : null, + queryParameters: { + if (hard != null) 'hard': hard, + if (deleteForMe != null) 'delete_for_me': deleteForMe, + }, ); return EmptyResponse.fromJson(response.data); } @@ -210,19 +217,17 @@ class MessageApi { /// Set [enforceUnique] to true to remove the existing user reaction Future sendReaction( String messageId, - String reactionType, { - Map extraData = const {}, + Reaction reaction, { + bool skipPush = false, bool enforceUnique = false, }) async { - final reaction = Map.from(extraData) - ..addAll({'type': reactionType}); - final response = await _client.post( '/messages/$messageId/reaction', - data: { - 'reaction': reaction, + data: json.encode({ + 'reaction': reaction.toJson(), + 'skip_push': skipPush, 'enforce_unique': enforceUnique, - }, + }), ); return SendReactionResponse.fromJson(response.data); } @@ -252,6 +257,28 @@ class MessageApi { return QueryReactionsResponse.fromJson(response.data); } + /// Queries reactions for a [messageId] with optional [filter], [sort], + /// and [pagination]. + /// + /// Unlike [getReactions], this method supports filtering by reaction type, + /// user ID, or creation date, sorting, and cursor-based pagination. + Future queryReactions( + String messageId, { + Filter? filter, + SortOrder? sort, + PaginationParams? pagination, + }) async { + final response = await _client.post( + '/messages/$messageId/reactions', + data: jsonEncode({ + if (filter != null) 'filter': filter.toJson(), + if (sort != null) 'sort': sort.map((e) => e.toJson()).toList(), + if (pagination != null) ...pagination.toJson(), + }), + ); + return QueryReactionsResponse.fromJson(response.data); + } + /// Translates the [messageId] in provided [language] Future translateMessage( String messageId, diff --git a/packages/stream_chat/lib/src/core/api/requests.dart b/packages/stream_chat/lib/src/core/api/requests.dart index 0b5a704608..c3953e3233 100644 --- a/packages/stream_chat/lib/src/core/api/requests.dart +++ b/packages/stream_chat/lib/src/core/api/requests.dart @@ -31,13 +31,12 @@ class PaginationParams extends Equatable { this.createdAtBefore, this.createdAtAround, }) : assert( - offset == null || offset == 0 || next == null, - 'Cannot specify non-zero `offset` with `next` parameter', - ); + offset == null || offset == 0 || next == null, + 'Cannot specify non-zero `offset` with `next` parameter', + ); /// Create a new instance from a json - factory PaginationParams.fromJson(Map json) => - _$PaginationParamsFromJson(json); + factory PaginationParams.fromJson(Map json) => _$PaginationParamsFromJson(json); /// The amount of items requested from the APIs. final int limit; @@ -108,41 +107,38 @@ class PaginationParams extends Equatable { DateTime? createdAtBeforeOrEqual, DateTime? createdAtBefore, DateTime? createdAtAround, - }) => - PaginationParams( - limit: limit ?? this.limit, - offset: offset ?? this.offset, - idAround: idAround ?? this.idAround, - next: next ?? this.next, - greaterThan: greaterThan ?? this.greaterThan, - greaterThanOrEqual: greaterThanOrEqual ?? this.greaterThanOrEqual, - lessThan: lessThan ?? this.lessThan, - lessThanOrEqual: lessThanOrEqual ?? this.lessThanOrEqual, - createdAtAfterOrEqual: - createdAtAfterOrEqual ?? this.createdAtAfterOrEqual, - createdAtAfter: createdAtAfter ?? this.createdAtAfter, - createdAtBeforeOrEqual: - createdAtBeforeOrEqual ?? this.createdAtBeforeOrEqual, - createdAtBefore: createdAtBefore ?? this.createdAtBefore, - createdAtAround: createdAtAround ?? this.createdAtAround, - ); + }) => PaginationParams( + limit: limit ?? this.limit, + offset: offset ?? this.offset, + idAround: idAround ?? this.idAround, + next: next ?? this.next, + greaterThan: greaterThan ?? this.greaterThan, + greaterThanOrEqual: greaterThanOrEqual ?? this.greaterThanOrEqual, + lessThan: lessThan ?? this.lessThan, + lessThanOrEqual: lessThanOrEqual ?? this.lessThanOrEqual, + createdAtAfterOrEqual: createdAtAfterOrEqual ?? this.createdAtAfterOrEqual, + createdAtAfter: createdAtAfter ?? this.createdAtAfter, + createdAtBeforeOrEqual: createdAtBeforeOrEqual ?? this.createdAtBeforeOrEqual, + createdAtBefore: createdAtBefore ?? this.createdAtBefore, + createdAtAround: createdAtAround ?? this.createdAtAround, + ); @override List get props => [ - limit, - offset, - next, - idAround, - greaterThan, - greaterThanOrEqual, - lessThan, - lessThanOrEqual, - createdAtAfterOrEqual, - createdAtAfter, - createdAtBeforeOrEqual, - createdAtBefore, - createdAtAround, - ]; + limit, + offset, + next, + idAround, + greaterThan, + greaterThanOrEqual, + lessThan, + lessThanOrEqual, + createdAtAfterOrEqual, + createdAtAfter, + createdAtBeforeOrEqual, + createdAtBefore, + createdAtAround, + ]; } /// Request model for the [client.partialUpdateUser] api call. diff --git a/packages/stream_chat/lib/src/core/api/requests.g.dart b/packages/stream_chat/lib/src/core/api/requests.g.dart index 7a77503534..b04bb02430 100644 --- a/packages/stream_chat/lib/src/core/api/requests.g.dart +++ b/packages/stream_chat/lib/src/core/api/requests.g.dart @@ -6,80 +6,62 @@ part of 'requests.dart'; // JsonSerializableGenerator // ************************************************************************** -PaginationParams _$PaginationParamsFromJson(Map json) => - PaginationParams( - limit: (json['limit'] as num?)?.toInt() ?? 10, - offset: (json['offset'] as num?)?.toInt(), - next: json['next'] as String?, - idAround: json['id_around'] as String?, - greaterThan: json['id_gt'] as String?, - greaterThanOrEqual: json['id_gte'] as String?, - lessThan: json['id_lt'] as String?, - lessThanOrEqual: json['id_lte'] as String?, - createdAtAfterOrEqual: json['created_at_after_or_equal'] == null - ? null - : DateTime.parse(json['created_at_after_or_equal'] as String), - createdAtAfter: json['created_at_after'] == null - ? null - : DateTime.parse(json['created_at_after'] as String), - createdAtBeforeOrEqual: json['created_at_before_or_equal'] == null - ? null - : DateTime.parse(json['created_at_before_or_equal'] as String), - createdAtBefore: json['created_at_before'] == null - ? null - : DateTime.parse(json['created_at_before'] as String), - createdAtAround: json['created_at_around'] == null - ? null - : DateTime.parse(json['created_at_around'] as String), - ); +PaginationParams _$PaginationParamsFromJson(Map json) => PaginationParams( + limit: (json['limit'] as num?)?.toInt() ?? 10, + offset: (json['offset'] as num?)?.toInt(), + next: json['next'] as String?, + idAround: json['id_around'] as String?, + greaterThan: json['id_gt'] as String?, + greaterThanOrEqual: json['id_gte'] as String?, + lessThan: json['id_lt'] as String?, + lessThanOrEqual: json['id_lte'] as String?, + createdAtAfterOrEqual: json['created_at_after_or_equal'] == null + ? null + : DateTime.parse(json['created_at_after_or_equal'] as String), + createdAtAfter: json['created_at_after'] == null ? null : DateTime.parse(json['created_at_after'] as String), + createdAtBeforeOrEqual: json['created_at_before_or_equal'] == null + ? null + : DateTime.parse(json['created_at_before_or_equal'] as String), + createdAtBefore: json['created_at_before'] == null ? null : DateTime.parse(json['created_at_before'] as String), + createdAtAround: json['created_at_around'] == null ? null : DateTime.parse(json['created_at_around'] as String), +); -Map _$PaginationParamsToJson(PaginationParams instance) => - { - 'limit': instance.limit, - if (instance.offset case final value?) 'offset': value, - if (instance.next case final value?) 'next': value, - if (instance.idAround case final value?) 'id_around': value, - if (instance.greaterThan case final value?) 'id_gt': value, - if (instance.greaterThanOrEqual case final value?) 'id_gte': value, - if (instance.lessThan case final value?) 'id_lt': value, - if (instance.lessThanOrEqual case final value?) 'id_lte': value, - if (instance.createdAtAfterOrEqual?.toIso8601String() case final value?) - 'created_at_after_or_equal': value, - if (instance.createdAtAfter?.toIso8601String() case final value?) - 'created_at_after': value, - if (instance.createdAtBeforeOrEqual?.toIso8601String() case final value?) - 'created_at_before_or_equal': value, - if (instance.createdAtBefore?.toIso8601String() case final value?) - 'created_at_before': value, - if (instance.createdAtAround?.toIso8601String() case final value?) - 'created_at_around': value, - }; +Map _$PaginationParamsToJson(PaginationParams instance) => { + 'limit': instance.limit, + if (instance.offset case final value?) 'offset': value, + if (instance.next case final value?) 'next': value, + if (instance.idAround case final value?) 'id_around': value, + if (instance.greaterThan case final value?) 'id_gt': value, + if (instance.greaterThanOrEqual case final value?) 'id_gte': value, + if (instance.lessThan case final value?) 'id_lt': value, + if (instance.lessThanOrEqual case final value?) 'id_lte': value, + if (instance.createdAtAfterOrEqual?.toIso8601String() case final value?) 'created_at_after_or_equal': value, + if (instance.createdAtAfter?.toIso8601String() case final value?) 'created_at_after': value, + if (instance.createdAtBeforeOrEqual?.toIso8601String() case final value?) 'created_at_before_or_equal': value, + if (instance.createdAtBefore?.toIso8601String() case final value?) 'created_at_before': value, + if (instance.createdAtAround?.toIso8601String() case final value?) 'created_at_around': value, +}; -Map _$PartialUpdateUserRequestToJson( - PartialUpdateUserRequest instance) => - { - 'stringify': instance.stringify, - 'hash_code': instance.hashCode, - 'id': instance.id, - 'set': instance.set, - 'unset': instance.unset, - 'props': instance.props, - }; +Map _$PartialUpdateUserRequestToJson(PartialUpdateUserRequest instance) => { + 'stringify': instance.stringify, + 'hash_code': instance.hashCode, + 'id': instance.id, + 'set': instance.set, + 'unset': instance.unset, + 'props': instance.props, +}; -Map _$ThreadOptionsToJson(ThreadOptions instance) => - { - 'stringify': instance.stringify, - 'hash_code': instance.hashCode, - 'watch': instance.watch, - 'reply_limit': instance.replyLimit, - 'participant_limit': instance.participantLimit, - 'member_limit': instance.memberLimit, - 'props': instance.props, - }; +Map _$ThreadOptionsToJson(ThreadOptions instance) => { + 'stringify': instance.stringify, + 'hash_code': instance.hashCode, + 'watch': instance.watch, + 'reply_limit': instance.replyLimit, + 'participant_limit': instance.participantLimit, + 'member_limit': instance.memberLimit, + 'props': instance.props, +}; -Map _$MemberUpdatePayloadToJson( - MemberUpdatePayload instance) => - { - if (instance.archived case final value?) 'archived': value, - if (instance.pinned case final value?) 'pinned': value, - }; +Map _$MemberUpdatePayloadToJson(MemberUpdatePayload instance) => { + if (instance.archived case final value?) 'archived': value, + if (instance.pinned case final value?) 'pinned': value, +}; diff --git a/packages/stream_chat/lib/src/core/api/responses.dart b/packages/stream_chat/lib/src/core/api/responses.dart index cbd4505678..c5bd2e92ab 100644 --- a/packages/stream_chat/lib/src/core/api/responses.dart +++ b/packages/stream_chat/lib/src/core/api/responses.dart @@ -1,14 +1,13 @@ import 'package:json_annotation/json_annotation.dart'; import 'package:stream_chat/src/client/client.dart'; -import 'package:stream_chat/src/core/api/call_api.dart'; import 'package:stream_chat/src/core/error/error.dart'; import 'package:stream_chat/src/core/models/banned_user.dart'; -import 'package:stream_chat/src/core/models/call_payload.dart'; import 'package:stream_chat/src/core/models/channel_model.dart'; import 'package:stream_chat/src/core/models/channel_state.dart'; import 'package:stream_chat/src/core/models/device.dart'; import 'package:stream_chat/src/core/models/draft.dart'; import 'package:stream_chat/src/core/models/event.dart'; +import 'package:stream_chat/src/core/models/location.dart'; import 'package:stream_chat/src/core/models/member.dart'; import 'package:stream_chat/src/core/models/message.dart'; import 'package:stream_chat/src/core/models/message_reminder.dart'; @@ -46,14 +45,14 @@ class ErrorResponse extends _BaseResponse { String? moreInfo; /// Create a new instance from a json - static ErrorResponse fromJson(Map json) => - _$ErrorResponseFromJson(json); + static ErrorResponse fromJson(Map json) => _$ErrorResponseFromJson(json); /// Serialize to json Map toJson() => _$ErrorResponseToJson(this); @override - String toString() => 'ErrorResponse(code: $code, ' + String toString() => + 'ErrorResponse(code: $code, ' 'message: $message, ' 'statusCode: $statusCode, ' 'moreInfo: $moreInfo)'; @@ -67,8 +66,7 @@ class SyncResponse extends _BaseResponse { late List events; /// Create a new instance from a json - static SyncResponse fromJson(Map json) => - _$SyncResponseFromJson(json); + static SyncResponse fromJson(Map json) => _$SyncResponseFromJson(json); } /// Model response for [StreamChatClient.queryChannels] api call @@ -79,16 +77,14 @@ class QueryChannelsResponse extends _BaseResponse { late List channels; /// Create a new instance from a json - static QueryChannelsResponse fromJson(Map json) => - _$QueryChannelsResponseFromJson(json); + static QueryChannelsResponse fromJson(Map json) => _$QueryChannelsResponseFromJson(json); } /// Model response for [StreamChatClient.queryChannels] api call @JsonSerializable(createToJson: false) class TranslateMessageResponse extends MessageResponse { /// Create a new instance from a json - static TranslateMessageResponse fromJson(Map json) => - _$TranslateMessageResponseFromJson(json); + static TranslateMessageResponse fromJson(Map json) => _$TranslateMessageResponseFromJson(json); } /// Model response for [StreamChatClient.queryChannels] api call @@ -99,8 +95,7 @@ class QueryMembersResponse extends _BaseResponse { late List members; /// Create a new instance from a json - static QueryMembersResponse fromJson(Map json) => - _$QueryMembersResponseFromJson(json); + static QueryMembersResponse fromJson(Map json) => _$QueryMembersResponseFromJson(json); } /// Model response for update member API calls, such as @@ -111,8 +106,7 @@ class PartialUpdateMemberResponse extends _BaseResponse { late Member channelMember; /// Create a new instance from a json - static PartialUpdateMemberResponse fromJson(Map json) => - _$PartialUpdateMemberResponseFromJson(json); + static PartialUpdateMemberResponse fromJson(Map json) => _$PartialUpdateMemberResponseFromJson(json); } /// Model response for [StreamChatClient.queryUsers] api call @@ -123,8 +117,7 @@ class QueryUsersResponse extends _BaseResponse { late List users; /// Create a new instance from a json - static QueryUsersResponse fromJson(Map json) => - _$QueryUsersResponseFromJson(json); + static QueryUsersResponse fromJson(Map json) => _$QueryUsersResponseFromJson(json); } /// Model response for [StreamChatClient.queryBannedUsers] api call @@ -135,20 +128,23 @@ class QueryBannedUsersResponse extends _BaseResponse { late List bans; /// Create a new instance from a json - static QueryBannedUsersResponse fromJson(Map json) => - _$QueryBannedUsersResponseFromJson(json); + static QueryBannedUsersResponse fromJson(Map json) => _$QueryBannedUsersResponseFromJson(json); } -/// Model response for [channel.getReactions] api call +/// Model response for [channel.getReactions] or [channel.queryReactions] api call @JsonSerializable(createToJson: false) class QueryReactionsResponse extends _BaseResponse { /// List of reactions returned by the query @JsonKey(defaultValue: []) late List reactions; + /// The cursor for the next page of results. + /// + /// Will be `null` if there are no more results. + late String? next; + /// Create a new instance from a json - static QueryReactionsResponse fromJson(Map json) => - _$QueryReactionsResponseFromJson(json); + static QueryReactionsResponse fromJson(Map json) => _$QueryReactionsResponseFromJson(json); } /// Model response for [Channel.getReplies] api call @@ -159,8 +155,7 @@ class QueryRepliesResponse extends _BaseResponse { late List messages; /// Create a new instance from a json - static QueryRepliesResponse fromJson(Map json) => - _$QueryRepliesResponseFromJson(json); + static QueryRepliesResponse fromJson(Map json) => _$QueryRepliesResponseFromJson(json); } /// Model response for [StreamChatClient.getDevices] api call @@ -171,8 +166,7 @@ class ListDevicesResponse extends _BaseResponse { late List devices; /// Create a new instance from a json - static ListDevicesResponse fromJson(Map json) => - _$ListDevicesResponseFromJson(json); + static ListDevicesResponse fromJson(Map json) => _$ListDevicesResponseFromJson(json); } /// Base Model response for [Channel.sendImage] and [Channel.sendFile] api call. @@ -182,8 +176,7 @@ class SendAttachmentResponse extends _BaseResponse { late String? file; /// Create a new instance from a json - static SendAttachmentResponse fromJson(Map json) => - _$SendAttachmentResponseFromJson(json); + static SendAttachmentResponse fromJson(Map json) => _$SendAttachmentResponseFromJson(json); } /// Model response for [Channel.sendFile] api call @@ -195,13 +188,18 @@ class SendFileResponse extends SendAttachmentResponse { String? thumbUrl; /// Create a new instance from a json - static SendFileResponse fromJson(Map json) => - _$SendFileResponseFromJson(json); + static SendFileResponse fromJson(Map json) => _$SendFileResponseFromJson(json); } /// Model response for [Channel.sendImage] api call typedef SendImageResponse = SendAttachmentResponse; +/// Model response for [StreamChatClient.uploadImage] api call +typedef UploadImageResponse = SendAttachmentResponse; + +/// Model response for [StreamChatClient.uploadFile] api call +typedef UploadFileResponse = SendAttachmentResponse; + /// Model response for [Channel.sendReaction] api call @JsonSerializable(createToJson: false) class SendReactionResponse extends MessageResponse { @@ -209,8 +207,7 @@ class SendReactionResponse extends MessageResponse { late Reaction reaction; /// Create a new instance from a json - static SendReactionResponse fromJson(Map json) => - _$SendReactionResponseFromJson(json); + static SendReactionResponse fromJson(Map json) => _$SendReactionResponseFromJson(json); } /// Model response for [StreamChatClient.connectGuestUser] api call @@ -223,8 +220,7 @@ class ConnectGuestUserResponse extends _BaseResponse { late User user; /// Create a new instance from a json - static ConnectGuestUserResponse fromJson(Map json) => - _$ConnectGuestUserResponseFromJson(json); + static ConnectGuestUserResponse fromJson(Map json) => _$ConnectGuestUserResponseFromJson(json); } /// Model response for [StreamChatClient.updateUser] api call @@ -235,8 +231,7 @@ class UpdateUsersResponse extends _BaseResponse { late Map users; /// Create a new instance from a json - static UpdateUsersResponse fromJson(Map json) => - _$UpdateUsersResponseFromJson(json); + static UpdateUsersResponse fromJson(Map json) => _$UpdateUsersResponseFromJson(json); } /// Base Model response for message based api calls. @@ -249,16 +244,14 @@ class MessageResponse extends _BaseResponse { @JsonSerializable(createToJson: false) class UpdateMessageResponse extends MessageResponse { /// Create a new instance from a json - static UpdateMessageResponse fromJson(Map json) => - _$UpdateMessageResponseFromJson(json); + static UpdateMessageResponse fromJson(Map json) => _$UpdateMessageResponseFromJson(json); } /// Model response for [Channel.sendMessage] api call @JsonSerializable(createToJson: false) class SendMessageResponse extends MessageResponse { /// Create a new instance from a json - static SendMessageResponse fromJson(Map json) => - _$SendMessageResponseFromJson(json); + static SendMessageResponse fromJson(Map json) => _$SendMessageResponseFromJson(json); } /// Model response for [StreamChatClient.getMessage] api call @@ -292,8 +285,7 @@ class SearchMessagesResponse extends _BaseResponse { late String? previous; /// Create a new instance from a json - static SearchMessagesResponse fromJson(Map json) => - _$SearchMessagesResponseFromJson(json); + static SearchMessagesResponse fromJson(Map json) => _$SearchMessagesResponseFromJson(json); } /// Model response for [Channel.getMessagesById] api call @@ -304,8 +296,7 @@ class GetMessagesByIdResponse extends _BaseResponse { late List messages; /// Create a new instance from a json - static GetMessagesByIdResponse fromJson(Map json) => - _$GetMessagesByIdResponseFromJson(json); + static GetMessagesByIdResponse fromJson(Map json) => _$GetMessagesByIdResponseFromJson(json); } /// Model response for [Channel.update] api call @@ -321,8 +312,7 @@ class UpdateChannelResponse extends _BaseResponse { Message? message; /// Create a new instance from a json - static UpdateChannelResponse fromJson(Map json) => - _$UpdateChannelResponseFromJson(json); + static UpdateChannelResponse fromJson(Map json) => _$UpdateChannelResponseFromJson(json); } /// Model response for [Channel.updatePartial] api call @@ -353,8 +343,7 @@ class InviteMembersResponse extends _BaseResponse { Message? message; /// Create a new instance from a json - static InviteMembersResponse fromJson(Map json) => - _$InviteMembersResponseFromJson(json); + static InviteMembersResponse fromJson(Map json) => _$InviteMembersResponseFromJson(json); } /// Model response for [Channel.removeMembers] api call @@ -371,8 +360,7 @@ class RemoveMembersResponse extends _BaseResponse { Message? message; /// Create a new instance from a json - static RemoveMembersResponse fromJson(Map json) => - _$RemoveMembersResponseFromJson(json); + static RemoveMembersResponse fromJson(Map json) => _$RemoveMembersResponseFromJson(json); } /// Model response for [Channel.sendAction] api call @@ -382,8 +370,7 @@ class SendActionResponse extends _BaseResponse { Message? message; /// Create a new instance from a json - static SendActionResponse fromJson(Map json) => - _$SendActionResponseFromJson(json); + static SendActionResponse fromJson(Map json) => _$SendActionResponseFromJson(json); } /// Model response for [Channel.addMembers] api call @@ -400,8 +387,7 @@ class AddMembersResponse extends _BaseResponse { Message? message; /// Create a new instance from a json - static AddMembersResponse fromJson(Map json) => - _$AddMembersResponseFromJson(json); + static AddMembersResponse fromJson(Map json) => _$AddMembersResponseFromJson(json); } /// Model response for [Channel.acceptInvite] api call @@ -418,8 +404,7 @@ class AcceptInviteResponse extends _BaseResponse { Message? message; /// Create a new instance from a json - static AcceptInviteResponse fromJson(Map json) => - _$AcceptInviteResponseFromJson(json); + static AcceptInviteResponse fromJson(Map json) => _$AcceptInviteResponseFromJson(json); } /// Model response for [Channel.rejectInvite] api call @@ -436,16 +421,14 @@ class RejectInviteResponse extends _BaseResponse { Message? message; /// Create a new instance from a json - static RejectInviteResponse fromJson(Map json) => - _$RejectInviteResponseFromJson(json); + static RejectInviteResponse fromJson(Map json) => _$RejectInviteResponseFromJson(json); } /// Model response for empty responses @JsonSerializable(createToJson: false) class EmptyResponse extends _BaseResponse { /// Create a new instance from a json - static EmptyResponse fromJson(Map json) => - _$EmptyResponseFromJson(json); + static EmptyResponse fromJson(Map json) => _$EmptyResponseFromJson(json); } /// Model response for [Channel.query] api call @@ -471,8 +454,7 @@ class ChannelStateResponse extends _BaseResponse { late List read; /// Create a new instance from a json - static ChannelStateResponse fromJson(Map json) => - _$ChannelStateResponseFromJson(json); + static ChannelStateResponse fromJson(Map json) => _$ChannelStateResponseFromJson(json); } /// Model response for [Client.enrichUrl] api call. @@ -511,38 +493,7 @@ class OGAttachmentResponse extends _BaseResponse { String? type; /// Create a new instance from a [json]. - static OGAttachmentResponse fromJson(Map json) => - _$OGAttachmentResponseFromJson(json); -} - -/// The response to [CallApi.getCallToken] -@Deprecated('Will be removed in the next major version') -@JsonSerializable(createToJson: false) -class CallTokenPayload extends _BaseResponse { - /// Create a new instance from a [json]. - static CallTokenPayload fromJson(Map json) => - _$CallTokenPayloadFromJson(json); - - /// The token to use for the call. - String? token; - - /// The user id specific to Agora. - int? agoraUid; - - /// The appId specific to Agora. - String? agoraAppId; -} - -/// The response to [CallApi.createCall] -@Deprecated('Will be removed in the next major version') -@JsonSerializable(createToJson: false) -class CreateCallPayload extends _BaseResponse { - /// Create a new instance from a [json]. - static CreateCallPayload fromJson(Map json) => - _$CreateCallPayloadFromJson(json); - - /// The call object. - CallPayload? call; + static OGAttachmentResponse fromJson(Map json) => _$OGAttachmentResponseFromJson(json); } /// Contains information about a [User] that was banned from a [Channel] or App. @@ -560,8 +511,7 @@ class UserBlockResponse extends _BaseResponse { late DateTime createdAt; /// Create a new instance from a json - static UserBlockResponse fromJson(Map json) => - _$UserBlockResponseFromJson(json); + static UserBlockResponse fromJson(Map json) => _$UserBlockResponseFromJson(json); } /// Model response for [StreamChatClient.queryBlockedUsers] api call @@ -572,8 +522,7 @@ class BlockedUsersResponse extends _BaseResponse { late List blocks; /// Create a new instance from a json - static BlockedUsersResponse fromJson(Map json) => - _$BlockedUsersResponseFromJson(json); + static BlockedUsersResponse fromJson(Map json) => _$BlockedUsersResponseFromJson(json); } /// Model response for [StreamChatClient.createPoll] api call @@ -583,8 +532,7 @@ class CreatePollResponse extends _BaseResponse { late Poll poll; /// Create a new instance from a json - static CreatePollResponse fromJson(Map json) => - _$CreatePollResponseFromJson(json); + static CreatePollResponse fromJson(Map json) => _$CreatePollResponseFromJson(json); } /// Model response for [StreamChatClient.getPoll] api call @@ -594,8 +542,7 @@ class GetPollResponse extends _BaseResponse { late Poll poll; /// Create a new instance from a json - static GetPollResponse fromJson(Map json) => - _$GetPollResponseFromJson(json); + static GetPollResponse fromJson(Map json) => _$GetPollResponseFromJson(json); } /// Model response for [StreamChatClient.updatePoll] api call @@ -605,8 +552,7 @@ class UpdatePollResponse extends _BaseResponse { late Poll poll; /// Create a new instance from a json - static UpdatePollResponse fromJson(Map json) => - _$UpdatePollResponseFromJson(json); + static UpdatePollResponse fromJson(Map json) => _$UpdatePollResponseFromJson(json); } /// Model response for [StreamChatClient.createPollOption] api call @@ -616,8 +562,7 @@ class CreatePollOptionResponse extends _BaseResponse { late PollOption pollOption; /// Create a new instance from a json - static CreatePollOptionResponse fromJson(Map json) => - _$CreatePollOptionResponseFromJson(json); + static CreatePollOptionResponse fromJson(Map json) => _$CreatePollOptionResponseFromJson(json); } /// Model response for [StreamChatClient.getPollOption] api call @@ -627,8 +572,7 @@ class GetPollOptionResponse extends _BaseResponse { late PollOption pollOption; /// Create a new instance from a json - static GetPollOptionResponse fromJson(Map json) => - _$GetPollOptionResponseFromJson(json); + static GetPollOptionResponse fromJson(Map json) => _$GetPollOptionResponseFromJson(json); } /// Model response for [StreamChatClient.updatePollOption] api call @@ -638,8 +582,7 @@ class UpdatePollOptionResponse extends _BaseResponse { late PollOption pollOption; /// Create a new instance from a json - static UpdatePollOptionResponse fromJson(Map json) => - _$UpdatePollOptionResponseFromJson(json); + static UpdatePollOptionResponse fromJson(Map json) => _$UpdatePollOptionResponseFromJson(json); } /// Model response for [StreamChatClient.castPollVote] api call @@ -649,8 +592,7 @@ class CastPollVoteResponse extends _BaseResponse { late PollVote vote; /// Create a new instance from a json - static CastPollVoteResponse fromJson(Map json) => - _$CastPollVoteResponseFromJson(json); + static CastPollVoteResponse fromJson(Map json) => _$CastPollVoteResponseFromJson(json); } /// Model response for [StreamChatClient.removePollVote] api call @@ -660,8 +602,7 @@ class RemovePollVoteResponse extends EmptyResponse { late PollVote vote; /// Create a new instance from a json - static RemovePollVoteResponse fromJson(Map json) => - _$RemovePollVoteResponseFromJson(json); + static RemovePollVoteResponse fromJson(Map json) => _$RemovePollVoteResponseFromJson(json); } /// Model response for [StreamChatClient.queryPolls] api call @@ -675,8 +616,7 @@ class QueryPollsResponse extends _BaseResponse { late String? next; /// Create a new instance from a json - static QueryPollsResponse fromJson(Map json) => - _$QueryPollsResponseFromJson(json); + static QueryPollsResponse fromJson(Map json) => _$QueryPollsResponseFromJson(json); } /// Model response for [StreamChatClient.queryPollVotes] api call @@ -690,8 +630,7 @@ class QueryPollVotesResponse extends _BaseResponse { late String? next; /// Create a new instance from a json - static QueryPollVotesResponse fromJson(Map json) => - _$QueryPollVotesResponseFromJson(json); + static QueryPollVotesResponse fromJson(Map json) => _$QueryPollVotesResponseFromJson(json); } /// Model response for [StreamChatClient.getThread] api call @@ -701,8 +640,7 @@ class GetThreadResponse extends _BaseResponse { late Thread thread; /// Create a new instance from a json - static GetThreadResponse fromJson(Map json) => - _$GetThreadResponseFromJson(json); + static GetThreadResponse fromJson(Map json) => _$GetThreadResponseFromJson(json); } /// Model response for [StreamChatClient.updateThread] api call @@ -712,8 +650,7 @@ class UpdateThreadResponse extends _BaseResponse { late Thread thread; /// Create a new instance from a json - static UpdateThreadResponse fromJson(Map json) => - _$UpdateThreadResponseFromJson(json); + static UpdateThreadResponse fromJson(Map json) => _$UpdateThreadResponseFromJson(json); } /// Model response for [StreamChatClient.queryThreads] api call @@ -727,8 +664,7 @@ class QueryThreadsResponse extends _BaseResponse { late String? next; /// Create a new instance from a json - static QueryThreadsResponse fromJson(Map json) => - _$QueryThreadsResponseFromJson(json); + static QueryThreadsResponse fromJson(Map json) => _$QueryThreadsResponseFromJson(json); } /// Base Model response for draft based api calls. @@ -741,16 +677,14 @@ class DraftResponse extends _BaseResponse { @JsonSerializable(createToJson: false) class CreateDraftResponse extends DraftResponse { /// Create a new instance from a json - static CreateDraftResponse fromJson(Map json) => - _$CreateDraftResponseFromJson(json); + static CreateDraftResponse fromJson(Map json) => _$CreateDraftResponseFromJson(json); } /// Model response for [StreamChatClient.getDraft] api call @JsonSerializable(createToJson: false) class GetDraftResponse extends DraftResponse { /// Create a new instance from a json - static GetDraftResponse fromJson(Map json) => - _$GetDraftResponseFromJson(json); + static GetDraftResponse fromJson(Map json) => _$GetDraftResponseFromJson(json); } /// Model response for [StreamChatClient.queryDrafts] api call @@ -764,8 +698,7 @@ class QueryDraftsResponse extends _BaseResponse { late String? next; /// Create a new instance from a json - static QueryDraftsResponse fromJson(Map json) => - _$QueryDraftsResponseFromJson(json); + static QueryDraftsResponse fromJson(Map json) => _$QueryDraftsResponseFromJson(json); } /// Base Model response for draft based api calls. @@ -778,16 +711,14 @@ class MessageReminderResponse extends _BaseResponse { @JsonSerializable(createToJson: false) class CreateReminderResponse extends MessageReminderResponse { /// Create a new instance from a json - static CreateReminderResponse fromJson(Map json) => - _$CreateReminderResponseFromJson(json); + static CreateReminderResponse fromJson(Map json) => _$CreateReminderResponseFromJson(json); } /// Model response for [StreamChatClient.updateReminder] api call @JsonSerializable(createToJson: false) class UpdateReminderResponse extends MessageReminderResponse { /// Create a new instance from a json - static UpdateReminderResponse fromJson(Map json) => - _$UpdateReminderResponseFromJson(json); + static UpdateReminderResponse fromJson(Map json) => _$UpdateReminderResponseFromJson(json); } /// Model response for [StreamChatClient.queryReminders] api call @@ -801,8 +732,7 @@ class QueryRemindersResponse extends _BaseResponse { late String? next; /// Create a new instance from a json - static QueryRemindersResponse fromJson(Map json) => - _$QueryRemindersResponseFromJson(json); + static QueryRemindersResponse fromJson(Map json) => _$QueryRemindersResponseFromJson(json); } /// Model response for [StreamChatClient.getUnreadCount] api call @@ -827,8 +757,7 @@ class GetUnreadCountResponse extends _BaseResponse { late List threads; /// Create a new instance from a json - static GetUnreadCountResponse fromJson(Map json) => - _$GetUnreadCountResponseFromJson(json); + static GetUnreadCountResponse fromJson(Map json) => _$GetUnreadCountResponseFromJson(json); } /// Model response for [StreamChatClient.setPushPreferences] api call @@ -846,3 +775,14 @@ class UpsertPushPreferencesResponse extends _BaseResponse { static UpsertPushPreferencesResponse fromJson(Map json) => _$UpsertPushPreferencesResponseFromJson(json); } + +/// Model response for [StreamChatClient.getActiveLiveLocations] api call +@JsonSerializable(createToJson: false) +class GetActiveLiveLocationsResponse extends _BaseResponse { + /// List of active live locations returned by the api call + late List activeLiveLocations; + + /// Create a new instance from a json + static GetActiveLiveLocationsResponse fromJson(Map json) => + _$GetActiveLiveLocationsResponseFromJson(json); +} diff --git a/packages/stream_chat/lib/src/core/api/responses.g.dart b/packages/stream_chat/lib/src/core/api/responses.g.dart index 30dd445960..4c54864009 100644 --- a/packages/stream_chat/lib/src/core/api/responses.g.dart +++ b/packages/stream_chat/lib/src/core/api/responses.g.dart @@ -6,509 +6,418 @@ part of 'responses.dart'; // JsonSerializableGenerator // ************************************************************************** -ErrorResponse _$ErrorResponseFromJson(Map json) => - ErrorResponse() - ..duration = json['duration'] as String? - ..code = (json['code'] as num?)?.toInt() - ..message = json['message'] as String? - ..statusCode = (json['StatusCode'] as num?)?.toInt() - ..moreInfo = json['more_info'] as String?; - -Map _$ErrorResponseToJson(ErrorResponse instance) => - { - 'duration': instance.duration, - 'code': instance.code, - 'message': instance.message, - 'StatusCode': instance.statusCode, - 'more_info': instance.moreInfo, - }; +ErrorResponse _$ErrorResponseFromJson(Map json) => ErrorResponse() + ..duration = json['duration'] as String? + ..code = (json['code'] as num?)?.toInt() + ..message = json['message'] as String? + ..statusCode = (json['StatusCode'] as num?)?.toInt() + ..moreInfo = json['more_info'] as String?; + +Map _$ErrorResponseToJson(ErrorResponse instance) => { + 'duration': instance.duration, + 'code': instance.code, + 'message': instance.message, + 'StatusCode': instance.statusCode, + 'more_info': instance.moreInfo, +}; SyncResponse _$SyncResponseFromJson(Map json) => SyncResponse() ..duration = json['duration'] as String? - ..events = (json['events'] as List?) - ?.map((e) => Event.fromJson(e as Map)) - .toList() ?? - []; + ..events = (json['events'] as List?)?.map((e) => Event.fromJson(e as Map)).toList() ?? []; QueryChannelsResponse _$QueryChannelsResponseFromJson( - Map json) => - QueryChannelsResponse() - ..duration = json['duration'] as String? - ..channels = (json['channels'] as List?) - ?.map((e) => ChannelState.fromJson(e as Map)) - .toList() ?? - []; + Map json, +) => QueryChannelsResponse() + ..duration = json['duration'] as String? + ..channels = + (json['channels'] as List?)?.map((e) => ChannelState.fromJson(e as Map)).toList() ?? []; TranslateMessageResponse _$TranslateMessageResponseFromJson( - Map json) => - TranslateMessageResponse() - ..duration = json['duration'] as String? - ..message = Message.fromJson(json['message'] as Map); + Map json, +) => TranslateMessageResponse() + ..duration = json['duration'] as String? + ..message = Message.fromJson(json['message'] as Map); QueryMembersResponse _$QueryMembersResponseFromJson( - Map json) => - QueryMembersResponse() - ..duration = json['duration'] as String? - ..members = (json['members'] as List?) - ?.map((e) => Member.fromJson(e as Map)) - .toList() ?? - []; + Map json, +) => QueryMembersResponse() + ..duration = json['duration'] as String? + ..members = + (json['members'] as List?)?.map((e) => Member.fromJson(e as Map)).toList() ?? []; PartialUpdateMemberResponse _$PartialUpdateMemberResponseFromJson( - Map json) => - PartialUpdateMemberResponse() - ..duration = json['duration'] as String? - ..channelMember = - Member.fromJson(json['channel_member'] as Map); - -QueryUsersResponse _$QueryUsersResponseFromJson(Map json) => - QueryUsersResponse() - ..duration = json['duration'] as String? - ..users = (json['users'] as List?) - ?.map((e) => User.fromJson(e as Map)) - .toList() ?? - []; + Map json, +) => PartialUpdateMemberResponse() + ..duration = json['duration'] as String? + ..channelMember = Member.fromJson( + json['channel_member'] as Map, + ); + +QueryUsersResponse _$QueryUsersResponseFromJson(Map json) => QueryUsersResponse() + ..duration = json['duration'] as String? + ..users = (json['users'] as List?)?.map((e) => User.fromJson(e as Map)).toList() ?? []; QueryBannedUsersResponse _$QueryBannedUsersResponseFromJson( - Map json) => - QueryBannedUsersResponse() - ..duration = json['duration'] as String? - ..bans = (json['bans'] as List?) - ?.map((e) => BannedUser.fromJson(e as Map)) - .toList() ?? - []; + Map json, +) => QueryBannedUsersResponse() + ..duration = json['duration'] as String? + ..bans = (json['bans'] as List?)?.map((e) => BannedUser.fromJson(e as Map)).toList() ?? []; QueryReactionsResponse _$QueryReactionsResponseFromJson( - Map json) => - QueryReactionsResponse() - ..duration = json['duration'] as String? - ..reactions = (json['reactions'] as List?) - ?.map((e) => Reaction.fromJson(e as Map)) - .toList() ?? - []; + Map json, +) => QueryReactionsResponse() + ..duration = json['duration'] as String? + ..reactions = + (json['reactions'] as List?)?.map((e) => Reaction.fromJson(e as Map)).toList() ?? [] + ..next = json['next'] as String?; QueryRepliesResponse _$QueryRepliesResponseFromJson( - Map json) => - QueryRepliesResponse() - ..duration = json['duration'] as String? - ..messages = (json['messages'] as List?) - ?.map((e) => Message.fromJson(e as Map)) - .toList() ?? - []; - -ListDevicesResponse _$ListDevicesResponseFromJson(Map json) => - ListDevicesResponse() - ..duration = json['duration'] as String? - ..devices = (json['devices'] as List?) - ?.map((e) => Device.fromJson(e as Map)) - .toList() ?? - []; + Map json, +) => QueryRepliesResponse() + ..duration = json['duration'] as String? + ..messages = + (json['messages'] as List?)?.map((e) => Message.fromJson(e as Map)).toList() ?? []; + +ListDevicesResponse _$ListDevicesResponseFromJson(Map json) => ListDevicesResponse() + ..duration = json['duration'] as String? + ..devices = + (json['devices'] as List?)?.map((e) => Device.fromJson(e as Map)).toList() ?? []; SendAttachmentResponse _$SendAttachmentResponseFromJson( - Map json) => - SendAttachmentResponse() - ..duration = json['duration'] as String? - ..file = json['file'] as String?; + Map json, +) => SendAttachmentResponse() + ..duration = json['duration'] as String? + ..file = json['file'] as String?; -SendFileResponse _$SendFileResponseFromJson(Map json) => - SendFileResponse() - ..duration = json['duration'] as String? - ..file = json['file'] as String? - ..thumbUrl = json['thumb_url'] as String?; +SendFileResponse _$SendFileResponseFromJson(Map json) => SendFileResponse() + ..duration = json['duration'] as String? + ..file = json['file'] as String? + ..thumbUrl = json['thumb_url'] as String?; SendReactionResponse _$SendReactionResponseFromJson( - Map json) => - SendReactionResponse() - ..duration = json['duration'] as String? - ..message = Message.fromJson(json['message'] as Map) - ..reaction = Reaction.fromJson(json['reaction'] as Map); + Map json, +) => SendReactionResponse() + ..duration = json['duration'] as String? + ..message = Message.fromJson(json['message'] as Map) + ..reaction = Reaction.fromJson(json['reaction'] as Map); ConnectGuestUserResponse _$ConnectGuestUserResponseFromJson( - Map json) => - ConnectGuestUserResponse() - ..duration = json['duration'] as String? - ..accessToken = json['access_token'] as String - ..user = User.fromJson(json['user'] as Map); - -UpdateUsersResponse _$UpdateUsersResponseFromJson(Map json) => - UpdateUsersResponse() - ..duration = json['duration'] as String? - ..users = (json['users'] as Map?)?.map( - (k, e) => MapEntry(k, User.fromJson(e as Map)), - ) ?? - {}; + Map json, +) => ConnectGuestUserResponse() + ..duration = json['duration'] as String? + ..accessToken = json['access_token'] as String + ..user = User.fromJson(json['user'] as Map); + +UpdateUsersResponse _$UpdateUsersResponseFromJson(Map json) => UpdateUsersResponse() + ..duration = json['duration'] as String? + ..users = + (json['users'] as Map?)?.map( + (k, e) => MapEntry(k, User.fromJson(e as Map)), + ) ?? + {}; UpdateMessageResponse _$UpdateMessageResponseFromJson( - Map json) => - UpdateMessageResponse() - ..duration = json['duration'] as String? - ..message = Message.fromJson(json['message'] as Map); - -SendMessageResponse _$SendMessageResponseFromJson(Map json) => - SendMessageResponse() - ..duration = json['duration'] as String? - ..message = Message.fromJson(json['message'] as Map); - -GetMessageResponse _$GetMessageResponseFromJson(Map json) => - GetMessageResponse() - ..duration = json['duration'] as String? - ..message = Message.fromJson(json['message'] as Map) - ..channel = json['channel'] == null - ? null - : ChannelModel.fromJson(json['channel'] as Map); + Map json, +) => UpdateMessageResponse() + ..duration = json['duration'] as String? + ..message = Message.fromJson(json['message'] as Map); + +SendMessageResponse _$SendMessageResponseFromJson(Map json) => SendMessageResponse() + ..duration = json['duration'] as String? + ..message = Message.fromJson(json['message'] as Map); + +GetMessageResponse _$GetMessageResponseFromJson(Map json) => GetMessageResponse() + ..duration = json['duration'] as String? + ..message = Message.fromJson(json['message'] as Map) + ..channel = json['channel'] == null ? null : ChannelModel.fromJson(json['channel'] as Map); SearchMessagesResponse _$SearchMessagesResponseFromJson( - Map json) => - SearchMessagesResponse() - ..duration = json['duration'] as String? - ..results = (json['results'] as List?) - ?.map( - (e) => GetMessageResponse.fromJson(e as Map)) - .toList() ?? - [] - ..next = json['next'] as String? - ..previous = json['previous'] as String?; + Map json, +) => SearchMessagesResponse() + ..duration = json['duration'] as String? + ..results = + (json['results'] as List?) + ?.map( + (e) => GetMessageResponse.fromJson(e as Map), + ) + .toList() ?? + [] + ..next = json['next'] as String? + ..previous = json['previous'] as String?; GetMessagesByIdResponse _$GetMessagesByIdResponseFromJson( - Map json) => - GetMessagesByIdResponse() - ..duration = json['duration'] as String? - ..messages = (json['messages'] as List?) - ?.map((e) => Message.fromJson(e as Map)) - .toList() ?? - []; + Map json, +) => GetMessagesByIdResponse() + ..duration = json['duration'] as String? + ..messages = + (json['messages'] as List?)?.map((e) => Message.fromJson(e as Map)).toList() ?? []; UpdateChannelResponse _$UpdateChannelResponseFromJson( - Map json) => - UpdateChannelResponse() - ..duration = json['duration'] as String? - ..channel = ChannelModel.fromJson(json['channel'] as Map) - ..members = (json['members'] as List?) - ?.map((e) => Member.fromJson(e as Map)) - .toList() - ..message = json['message'] == null - ? null - : Message.fromJson(json['message'] as Map); + Map json, +) => UpdateChannelResponse() + ..duration = json['duration'] as String? + ..channel = ChannelModel.fromJson(json['channel'] as Map) + ..members = (json['members'] as List?)?.map((e) => Member.fromJson(e as Map)).toList() + ..message = json['message'] == null ? null : Message.fromJson(json['message'] as Map); PartialUpdateChannelResponse _$PartialUpdateChannelResponseFromJson( - Map json) => - PartialUpdateChannelResponse() - ..duration = json['duration'] as String? - ..channel = ChannelModel.fromJson(json['channel'] as Map) - ..members = (json['members'] as List?) - ?.map((e) => Member.fromJson(e as Map)) - .toList(); + Map json, +) => PartialUpdateChannelResponse() + ..duration = json['duration'] as String? + ..channel = ChannelModel.fromJson(json['channel'] as Map) + ..members = (json['members'] as List?)?.map((e) => Member.fromJson(e as Map)).toList(); InviteMembersResponse _$InviteMembersResponseFromJson( - Map json) => - InviteMembersResponse() - ..duration = json['duration'] as String? - ..channel = ChannelModel.fromJson(json['channel'] as Map) - ..members = (json['members'] as List?) - ?.map((e) => Member.fromJson(e as Map)) - .toList() ?? - [] - ..message = json['message'] == null - ? null - : Message.fromJson(json['message'] as Map); + Map json, +) => InviteMembersResponse() + ..duration = json['duration'] as String? + ..channel = ChannelModel.fromJson(json['channel'] as Map) + ..members = (json['members'] as List?)?.map((e) => Member.fromJson(e as Map)).toList() ?? [] + ..message = json['message'] == null ? null : Message.fromJson(json['message'] as Map); RemoveMembersResponse _$RemoveMembersResponseFromJson( - Map json) => - RemoveMembersResponse() - ..duration = json['duration'] as String? - ..channel = ChannelModel.fromJson(json['channel'] as Map) - ..members = (json['members'] as List?) - ?.map((e) => Member.fromJson(e as Map)) - .toList() ?? - [] - ..message = json['message'] == null - ? null - : Message.fromJson(json['message'] as Map); - -SendActionResponse _$SendActionResponseFromJson(Map json) => - SendActionResponse() - ..duration = json['duration'] as String? - ..message = json['message'] == null - ? null - : Message.fromJson(json['message'] as Map); - -AddMembersResponse _$AddMembersResponseFromJson(Map json) => - AddMembersResponse() - ..duration = json['duration'] as String? - ..channel = ChannelModel.fromJson(json['channel'] as Map) - ..members = (json['members'] as List?) - ?.map((e) => Member.fromJson(e as Map)) - .toList() ?? - [] - ..message = json['message'] == null - ? null - : Message.fromJson(json['message'] as Map); + Map json, +) => RemoveMembersResponse() + ..duration = json['duration'] as String? + ..channel = ChannelModel.fromJson(json['channel'] as Map) + ..members = (json['members'] as List?)?.map((e) => Member.fromJson(e as Map)).toList() ?? [] + ..message = json['message'] == null ? null : Message.fromJson(json['message'] as Map); + +SendActionResponse _$SendActionResponseFromJson(Map json) => SendActionResponse() + ..duration = json['duration'] as String? + ..message = json['message'] == null ? null : Message.fromJson(json['message'] as Map); + +AddMembersResponse _$AddMembersResponseFromJson(Map json) => AddMembersResponse() + ..duration = json['duration'] as String? + ..channel = ChannelModel.fromJson(json['channel'] as Map) + ..members = (json['members'] as List?)?.map((e) => Member.fromJson(e as Map)).toList() ?? [] + ..message = json['message'] == null ? null : Message.fromJson(json['message'] as Map); AcceptInviteResponse _$AcceptInviteResponseFromJson( - Map json) => - AcceptInviteResponse() - ..duration = json['duration'] as String? - ..channel = ChannelModel.fromJson(json['channel'] as Map) - ..members = (json['members'] as List?) - ?.map((e) => Member.fromJson(e as Map)) - .toList() ?? - [] - ..message = json['message'] == null - ? null - : Message.fromJson(json['message'] as Map); + Map json, +) => AcceptInviteResponse() + ..duration = json['duration'] as String? + ..channel = ChannelModel.fromJson(json['channel'] as Map) + ..members = (json['members'] as List?)?.map((e) => Member.fromJson(e as Map)).toList() ?? [] + ..message = json['message'] == null ? null : Message.fromJson(json['message'] as Map); RejectInviteResponse _$RejectInviteResponseFromJson( - Map json) => - RejectInviteResponse() - ..duration = json['duration'] as String? - ..channel = ChannelModel.fromJson(json['channel'] as Map) - ..members = (json['members'] as List?) - ?.map((e) => Member.fromJson(e as Map)) - .toList() ?? - [] - ..message = json['message'] == null - ? null - : Message.fromJson(json['message'] as Map); + Map json, +) => RejectInviteResponse() + ..duration = json['duration'] as String? + ..channel = ChannelModel.fromJson(json['channel'] as Map) + ..members = (json['members'] as List?)?.map((e) => Member.fromJson(e as Map)).toList() ?? [] + ..message = json['message'] == null ? null : Message.fromJson(json['message'] as Map); EmptyResponse _$EmptyResponseFromJson(Map json) => EmptyResponse()..duration = json['duration'] as String?; ChannelStateResponse _$ChannelStateResponseFromJson( - Map json) => - ChannelStateResponse() - ..duration = json['duration'] as String? - ..channel = ChannelModel.fromJson(json['channel'] as Map) - ..messages = (json['messages'] as List?) - ?.map((e) => Message.fromJson(e as Map)) - .toList() ?? - [] - ..members = (json['members'] as List?) - ?.map((e) => Member.fromJson(e as Map)) - .toList() ?? - [] - ..watcherCount = (json['watcher_count'] as num?)?.toInt() ?? 0 - ..read = (json['read'] as List?) - ?.map((e) => Read.fromJson(e as Map)) - .toList() ?? - []; + Map json, +) => ChannelStateResponse() + ..duration = json['duration'] as String? + ..channel = ChannelModel.fromJson(json['channel'] as Map) + ..messages = + (json['messages'] as List?)?.map((e) => Message.fromJson(e as Map)).toList() ?? [] + ..members = (json['members'] as List?)?.map((e) => Member.fromJson(e as Map)).toList() ?? [] + ..watcherCount = (json['watcher_count'] as num?)?.toInt() ?? 0 + ..read = (json['read'] as List?)?.map((e) => Read.fromJson(e as Map)).toList() ?? []; OGAttachmentResponse _$OGAttachmentResponseFromJson( - Map json) => - OGAttachmentResponse() - ..duration = json['duration'] as String? - ..ogScrapeUrl = json['og_scrape_url'] as String - ..assetUrl = json['asset_url'] as String? - ..authorLink = json['author_link'] as String? - ..authorName = json['author_name'] as String? - ..imageUrl = json['image_url'] as String? - ..text = json['text'] as String? - ..thumbUrl = json['thumb_url'] as String? - ..title = json['title'] as String? - ..titleLink = json['title_link'] as String? - ..type = json['type'] as String?; - -CallTokenPayload _$CallTokenPayloadFromJson(Map json) => - CallTokenPayload() - ..duration = json['duration'] as String? - ..token = json['token'] as String? - ..agoraUid = (json['agora_uid'] as num?)?.toInt() - ..agoraAppId = json['agora_app_id'] as String?; - -CreateCallPayload _$CreateCallPayloadFromJson(Map json) => - CreateCallPayload() - ..duration = json['duration'] as String? - ..call = json['call'] == null - ? null - : CallPayload.fromJson(json['call'] as Map); - -UserBlockResponse _$UserBlockResponseFromJson(Map json) => - UserBlockResponse() - ..duration = json['duration'] as String? - ..blockedByUserId = json['blocked_by_user_id'] as String? ?? '' - ..blockedUserId = json['blocked_user_id'] as String? ?? '' - ..createdAt = DateTime.parse(json['created_at'] as String); + Map json, +) => OGAttachmentResponse() + ..duration = json['duration'] as String? + ..ogScrapeUrl = json['og_scrape_url'] as String + ..assetUrl = json['asset_url'] as String? + ..authorLink = json['author_link'] as String? + ..authorName = json['author_name'] as String? + ..imageUrl = json['image_url'] as String? + ..text = json['text'] as String? + ..thumbUrl = json['thumb_url'] as String? + ..title = json['title'] as String? + ..titleLink = json['title_link'] as String? + ..type = json['type'] as String?; + +UserBlockResponse _$UserBlockResponseFromJson(Map json) => UserBlockResponse() + ..duration = json['duration'] as String? + ..blockedByUserId = json['blocked_by_user_id'] as String? ?? '' + ..blockedUserId = json['blocked_user_id'] as String? ?? '' + ..createdAt = DateTime.parse(json['created_at'] as String); BlockedUsersResponse _$BlockedUsersResponseFromJson( - Map json) => - BlockedUsersResponse() - ..duration = json['duration'] as String? - ..blocks = (json['blocks'] as List?) - ?.map((e) => UserBlock.fromJson(e as Map)) - .toList() ?? - []; - -CreatePollResponse _$CreatePollResponseFromJson(Map json) => - CreatePollResponse() - ..duration = json['duration'] as String? - ..poll = Poll.fromJson(json['poll'] as Map); - -GetPollResponse _$GetPollResponseFromJson(Map json) => - GetPollResponse() - ..duration = json['duration'] as String? - ..poll = Poll.fromJson(json['poll'] as Map); - -UpdatePollResponse _$UpdatePollResponseFromJson(Map json) => - UpdatePollResponse() - ..duration = json['duration'] as String? - ..poll = Poll.fromJson(json['poll'] as Map); + Map json, +) => BlockedUsersResponse() + ..duration = json['duration'] as String? + ..blocks = + (json['blocks'] as List?)?.map((e) => UserBlock.fromJson(e as Map)).toList() ?? []; + +CreatePollResponse _$CreatePollResponseFromJson(Map json) => CreatePollResponse() + ..duration = json['duration'] as String? + ..poll = Poll.fromJson(json['poll'] as Map); + +GetPollResponse _$GetPollResponseFromJson(Map json) => GetPollResponse() + ..duration = json['duration'] as String? + ..poll = Poll.fromJson(json['poll'] as Map); + +UpdatePollResponse _$UpdatePollResponseFromJson(Map json) => UpdatePollResponse() + ..duration = json['duration'] as String? + ..poll = Poll.fromJson(json['poll'] as Map); CreatePollOptionResponse _$CreatePollOptionResponseFromJson( - Map json) => - CreatePollOptionResponse() - ..duration = json['duration'] as String? - ..pollOption = - PollOption.fromJson(json['poll_option'] as Map); + Map json, +) => CreatePollOptionResponse() + ..duration = json['duration'] as String? + ..pollOption = PollOption.fromJson( + json['poll_option'] as Map, + ); GetPollOptionResponse _$GetPollOptionResponseFromJson( - Map json) => - GetPollOptionResponse() - ..duration = json['duration'] as String? - ..pollOption = - PollOption.fromJson(json['poll_option'] as Map); + Map json, +) => GetPollOptionResponse() + ..duration = json['duration'] as String? + ..pollOption = PollOption.fromJson( + json['poll_option'] as Map, + ); UpdatePollOptionResponse _$UpdatePollOptionResponseFromJson( - Map json) => - UpdatePollOptionResponse() - ..duration = json['duration'] as String? - ..pollOption = - PollOption.fromJson(json['poll_option'] as Map); + Map json, +) => UpdatePollOptionResponse() + ..duration = json['duration'] as String? + ..pollOption = PollOption.fromJson( + json['poll_option'] as Map, + ); CastPollVoteResponse _$CastPollVoteResponseFromJson( - Map json) => - CastPollVoteResponse() - ..duration = json['duration'] as String? - ..vote = PollVote.fromJson(json['vote'] as Map); + Map json, +) => CastPollVoteResponse() + ..duration = json['duration'] as String? + ..vote = PollVote.fromJson(json['vote'] as Map); RemovePollVoteResponse _$RemovePollVoteResponseFromJson( - Map json) => - RemovePollVoteResponse() - ..duration = json['duration'] as String? - ..vote = PollVote.fromJson(json['vote'] as Map); - -QueryPollsResponse _$QueryPollsResponseFromJson(Map json) => - QueryPollsResponse() - ..duration = json['duration'] as String? - ..polls = (json['polls'] as List?) - ?.map((e) => Poll.fromJson(e as Map)) - .toList() ?? - [] - ..next = json['next'] as String?; + Map json, +) => RemovePollVoteResponse() + ..duration = json['duration'] as String? + ..vote = PollVote.fromJson(json['vote'] as Map); + +QueryPollsResponse _$QueryPollsResponseFromJson(Map json) => QueryPollsResponse() + ..duration = json['duration'] as String? + ..polls = (json['polls'] as List?)?.map((e) => Poll.fromJson(e as Map)).toList() ?? [] + ..next = json['next'] as String?; QueryPollVotesResponse _$QueryPollVotesResponseFromJson( - Map json) => - QueryPollVotesResponse() - ..duration = json['duration'] as String? - ..votes = (json['votes'] as List?) - ?.map((e) => PollVote.fromJson(e as Map)) - .toList() ?? - [] - ..next = json['next'] as String?; - -GetThreadResponse _$GetThreadResponseFromJson(Map json) => - GetThreadResponse() - ..duration = json['duration'] as String? - ..thread = Thread.fromJson(json['thread'] as Map); + Map json, +) => QueryPollVotesResponse() + ..duration = json['duration'] as String? + ..votes = (json['votes'] as List?)?.map((e) => PollVote.fromJson(e as Map)).toList() ?? [] + ..next = json['next'] as String?; + +GetThreadResponse _$GetThreadResponseFromJson(Map json) => GetThreadResponse() + ..duration = json['duration'] as String? + ..thread = Thread.fromJson(json['thread'] as Map); UpdateThreadResponse _$UpdateThreadResponseFromJson( - Map json) => - UpdateThreadResponse() - ..duration = json['duration'] as String? - ..thread = Thread.fromJson(json['thread'] as Map); + Map json, +) => UpdateThreadResponse() + ..duration = json['duration'] as String? + ..thread = Thread.fromJson(json['thread'] as Map); QueryThreadsResponse _$QueryThreadsResponseFromJson( - Map json) => - QueryThreadsResponse() - ..duration = json['duration'] as String? - ..threads = (json['threads'] as List?) - ?.map((e) => Thread.fromJson(e as Map)) - .toList() ?? - [] - ..next = json['next'] as String?; - -CreateDraftResponse _$CreateDraftResponseFromJson(Map json) => - CreateDraftResponse() - ..duration = json['duration'] as String? - ..draft = Draft.fromJson(json['draft'] as Map); - -GetDraftResponse _$GetDraftResponseFromJson(Map json) => - GetDraftResponse() - ..duration = json['duration'] as String? - ..draft = Draft.fromJson(json['draft'] as Map); - -QueryDraftsResponse _$QueryDraftsResponseFromJson(Map json) => - QueryDraftsResponse() - ..duration = json['duration'] as String? - ..drafts = (json['drafts'] as List?) - ?.map((e) => Draft.fromJson(e as Map)) - .toList() ?? - [] - ..next = json['next'] as String?; + Map json, +) => QueryThreadsResponse() + ..duration = json['duration'] as String? + ..threads = (json['threads'] as List?)?.map((e) => Thread.fromJson(e as Map)).toList() ?? [] + ..next = json['next'] as String?; + +CreateDraftResponse _$CreateDraftResponseFromJson(Map json) => CreateDraftResponse() + ..duration = json['duration'] as String? + ..draft = Draft.fromJson(json['draft'] as Map); + +GetDraftResponse _$GetDraftResponseFromJson(Map json) => GetDraftResponse() + ..duration = json['duration'] as String? + ..draft = Draft.fromJson(json['draft'] as Map); + +QueryDraftsResponse _$QueryDraftsResponseFromJson(Map json) => QueryDraftsResponse() + ..duration = json['duration'] as String? + ..drafts = (json['drafts'] as List?)?.map((e) => Draft.fromJson(e as Map)).toList() ?? [] + ..next = json['next'] as String?; CreateReminderResponse _$CreateReminderResponseFromJson( - Map json) => - CreateReminderResponse() - ..duration = json['duration'] as String? - ..reminder = - MessageReminder.fromJson(json['reminder'] as Map); + Map json, +) => CreateReminderResponse() + ..duration = json['duration'] as String? + ..reminder = MessageReminder.fromJson( + json['reminder'] as Map, + ); UpdateReminderResponse _$UpdateReminderResponseFromJson( - Map json) => - UpdateReminderResponse() - ..duration = json['duration'] as String? - ..reminder = - MessageReminder.fromJson(json['reminder'] as Map); + Map json, +) => UpdateReminderResponse() + ..duration = json['duration'] as String? + ..reminder = MessageReminder.fromJson( + json['reminder'] as Map, + ); QueryRemindersResponse _$QueryRemindersResponseFromJson( - Map json) => - QueryRemindersResponse() - ..duration = json['duration'] as String? - ..reminders = (json['reminders'] as List?) - ?.map((e) => MessageReminder.fromJson(e as Map)) - .toList() ?? - [] - ..next = json['next'] as String?; + Map json, +) => QueryRemindersResponse() + ..duration = json['duration'] as String? + ..reminders = + (json['reminders'] as List?)?.map((e) => MessageReminder.fromJson(e as Map)).toList() ?? + [] + ..next = json['next'] as String?; GetUnreadCountResponse _$GetUnreadCountResponseFromJson( - Map json) => - GetUnreadCountResponse() - ..duration = json['duration'] as String? - ..totalUnreadCount = (json['total_unread_count'] as num).toInt() - ..totalUnreadThreadsCount = - (json['total_unread_threads_count'] as num).toInt() - ..totalUnreadCountByTeam = - (json['total_unread_count_by_team'] as Map?)?.map( - (k, e) => MapEntry(k, (e as num).toInt()), + Map json, +) => GetUnreadCountResponse() + ..duration = json['duration'] as String? + ..totalUnreadCount = (json['total_unread_count'] as num).toInt() + ..totalUnreadThreadsCount = (json['total_unread_threads_count'] as num).toInt() + ..totalUnreadCountByTeam = (json['total_unread_count_by_team'] as Map?)?.map( + (k, e) => MapEntry(k, (e as num).toInt()), + ) + ..channels = (json['channels'] as List) + .map( + (e) => UnreadCountsChannel.fromJson(e as Map), ) - ..channels = (json['channels'] as List) - .map((e) => UnreadCountsChannel.fromJson(e as Map)) - .toList() - ..channelType = (json['channel_type'] as List) - .map((e) => - UnreadCountsChannelType.fromJson(e as Map)) - .toList() - ..threads = (json['threads'] as List) - .map((e) => UnreadCountsThread.fromJson(e as Map)) - .toList(); + .toList() + ..channelType = (json['channel_type'] as List) + .map( + (e) => UnreadCountsChannelType.fromJson(e as Map), + ) + .toList() + ..threads = (json['threads'] as List) + .map( + (e) => UnreadCountsThread.fromJson(e as Map), + ) + .toList(); UpsertPushPreferencesResponse _$UpsertPushPreferencesResponseFromJson( - Map json) => - UpsertPushPreferencesResponse() - ..duration = json['duration'] as String? - ..userPreferences = (json['user_preferences'] as Map?) - ?.map( - (k, e) => - MapEntry(k, PushPreference.fromJson(e as Map)), - ) ?? - {} - ..userChannelPreferences = - (json['user_channel_preferences'] as Map?)?.map( - (k, e) => MapEntry( - k, - (e as Map).map( - (k, e) => MapEntry( - k, - ChannelPushPreference.fromJson( - e as Map)), - )), - ) ?? - {}; + Map json, +) => UpsertPushPreferencesResponse() + ..duration = json['duration'] as String? + ..userPreferences = + (json['user_preferences'] as Map?)?.map( + (k, e) => MapEntry(k, PushPreference.fromJson(e as Map)), + ) ?? + {} + ..userChannelPreferences = + (json['user_channel_preferences'] as Map?)?.map( + (k, e) => MapEntry( + k, + (e as Map).map( + (k, e) => MapEntry( + k, + ChannelPushPreference.fromJson(e as Map), + ), + ), + ), + ) ?? + {}; + +GetActiveLiveLocationsResponse _$GetActiveLiveLocationsResponseFromJson( + Map json, +) => GetActiveLiveLocationsResponse() + ..duration = json['duration'] as String? + ..activeLiveLocations = (json['active_live_locations'] as List) + .map((e) => Location.fromJson(e as Map)) + .toList(); diff --git a/packages/stream_chat/lib/src/core/api/sort_order.dart b/packages/stream_chat/lib/src/core/api/sort_order.dart index e37fd445e3..2a1c7cad58 100644 --- a/packages/stream_chat/lib/src/core/api/sort_order.dart +++ b/packages/stream_chat/lib/src/core/api/sort_order.dart @@ -21,7 +21,7 @@ enum NullOrdering { /// Null values appear at the end of the sorted list, /// regardless of sort direction (ASC or DESC). - nullsLast; + nullsLast, } /// A sort specification for objects that implement [ComparableFieldProvider]. @@ -34,21 +34,8 @@ enum NullOrdering { /// // Sort channels by last message date in descending order /// final sort = SortOption("last_message_at"); /// ``` -@JsonSerializable(includeIfNull: false) +@JsonSerializable(createFactory: false, includeIfNull: false) class SortOption { - /// Creates a new SortOption instance with the specified field and direction. - /// - /// ```dart - /// final sorting = SortOption("last_message_at") // Default: descending order - /// ``` - @Deprecated('Use SortOption.desc or SortOption.asc instead') - const SortOption( - this.field, { - this.direction = SortOption.DESC, - this.nullOrdering = NullOrdering.nullsFirst, - Comparator? comparator, - }) : _comparator = comparator; - /// Creates a SortOption for descending order sorting by the specified field. /// /// Example: @@ -60,8 +47,8 @@ class SortOption { this.field, { this.nullOrdering = NullOrdering.nullsFirst, Comparator? comparator, - }) : direction = SortOption.DESC, - _comparator = comparator; + }) : direction = SortOption.DESC, + _comparator = comparator; /// Creates a SortOption for ascending order sorting by the specified field. /// @@ -74,12 +61,8 @@ class SortOption { this.field, { this.nullOrdering = NullOrdering.nullsLast, Comparator? comparator, - }) : direction = SortOption.ASC, - _comparator = comparator; - - /// Create a new instance from JSON. - factory SortOption.fromJson(Map json) => - _$SortOptionFromJson(json); + }) : direction = SortOption.ASC, + _comparator = comparator; /// Ascending order (1) static const ASC = 1; @@ -149,8 +132,7 @@ class SortOption { } /// Extension that allows a [SortOrder] to be used as a comparator function. -extension CompositeComparator - on SortOrder { +extension CompositeComparator on SortOrder { /// Compares two objects using all sort options in sequence. /// /// Returns the first non-zero comparison result, or 0 if all comparisons diff --git a/packages/stream_chat/lib/src/core/api/sort_order.g.dart b/packages/stream_chat/lib/src/core/api/sort_order.g.dart index 1a4e70f1fe..c54ab585ab 100644 --- a/packages/stream_chat/lib/src/core/api/sort_order.g.dart +++ b/packages/stream_chat/lib/src/core/api/sort_order.g.dart @@ -6,16 +6,7 @@ part of 'sort_order.dart'; // JsonSerializableGenerator // ************************************************************************** -SortOption _$SortOptionFromJson( - Map json) => - SortOption( - json['field'] as String, - direction: (json['direction'] as num?)?.toInt() ?? SortOption.DESC, - ); - -Map _$SortOptionToJson( - SortOption instance) => - { - 'field': instance.field, - 'direction': instance.direction, - }; +Map _$SortOptionToJson(SortOption instance) => { + 'field': instance.field, + 'direction': instance.direction, +}; diff --git a/packages/stream_chat/lib/src/core/api/stream_chat_api.dart b/packages/stream_chat/lib/src/core/api/stream_chat_api.dart index 1f94ffe77a..5ece808a93 100644 --- a/packages/stream_chat/lib/src/core/api/stream_chat_api.dart +++ b/packages/stream_chat/lib/src/core/api/stream_chat_api.dart @@ -1,7 +1,6 @@ import 'package:dio/dio.dart'; import 'package:logging/logging.dart'; import 'package:stream_chat/src/core/api/attachment_file_uploader.dart'; -import 'package:stream_chat/src/core/api/call_api.dart'; import 'package:stream_chat/src/core/api/channel_api.dart'; import 'package:stream_chat/src/core/api/device_api.dart'; import 'package:stream_chat/src/core/api/general_api.dart'; @@ -29,23 +28,23 @@ class StreamChatApi { TokenManager? tokenManager, ConnectionIdManager? connectionIdManager, SystemEnvironmentManager? systemEnvironmentManager, - AttachmentFileUploaderProvider attachmentFileUploaderProvider = - StreamAttachmentFileUploader.new, + AttachmentFileUploaderProvider attachmentFileUploaderProvider = StreamAttachmentFileUploader.new, Logger? logger, Iterable? interceptors, HttpClientAdapter? httpClientAdapter, - }) : _fileUploaderProvider = attachmentFileUploaderProvider, - _client = client ?? - StreamHttpClient( - apiKey, - options: options, - tokenManager: tokenManager, - connectionIdManager: connectionIdManager, - systemEnvironmentManager: systemEnvironmentManager, - logger: logger, - interceptors: interceptors, - httpClientAdapter: httpClientAdapter, - ); + }) : _fileUploaderProvider = attachmentFileUploaderProvider, + _client = + client ?? + StreamHttpClient( + apiKey, + options: options, + tokenManager: tokenManager, + connectionIdManager: connectionIdManager, + systemEnvironmentManager: systemEnvironmentManager, + logger: logger, + interceptors: interceptors, + httpClientAdapter: httpClientAdapter, + ); final StreamHttpClient _client; final AttachmentFileUploaderProvider _fileUploaderProvider; @@ -70,12 +69,6 @@ class StreamChatApi { ThreadsApi get threads => _threads ??= ThreadsApi(_client); ThreadsApi? _threads; - /// Api dedicated to call operations - @Deprecated('Will be removed in the next major version') - CallApi get call => _call ??= CallApi(_client); - @Deprecated('Will be removed in the next major version') - CallApi? _call; - /// Api dedicated to channel operations ChannelApi get channel => _channel ??= ChannelApi(_client); ChannelApi? _channel; @@ -97,7 +90,6 @@ class StreamChatApi { GeneralApi? _general; /// Class responsible for uploading images and files to a given channel - AttachmentFileUploader get fileUploader => - _fileUploader ??= _fileUploaderProvider.call(_client); + AttachmentFileUploader get fileUploader => _fileUploader ??= _fileUploaderProvider.call(_client); AttachmentFileUploader? _fileUploader; } diff --git a/packages/stream_chat/lib/src/core/api/user_api.dart b/packages/stream_chat/lib/src/core/api/user_api.dart index 064792bfe4..00a38f0ad4 100644 --- a/packages/stream_chat/lib/src/core/api/user_api.dart +++ b/packages/stream_chat/lib/src/core/api/user_api.dart @@ -5,6 +5,8 @@ import 'package:stream_chat/src/core/api/responses.dart'; import 'package:stream_chat/src/core/api/sort_order.dart'; import 'package:stream_chat/src/core/http/stream_http_client.dart'; import 'package:stream_chat/src/core/models/filter.dart'; +import 'package:stream_chat/src/core/models/location.dart'; +import 'package:stream_chat/src/core/models/location_coordinates.dart'; import 'package:stream_chat/src/core/models/user.dart'; /// Defines the api dedicated to users operations @@ -95,4 +97,34 @@ class UserApi { return GetUnreadCountResponse.fromJson(response.data); } + + /// Retrieves all the active live locations of the current user. + Future getActiveLiveLocations() async { + final response = await _client.get( + '/users/live_locations', + ); + + return GetActiveLiveLocationsResponse.fromJson(response.data); + } + + /// Updates an existing live location created by the current user. + Future updateLiveLocation({ + required String messageId, + String? createdByDeviceId, + LocationCoordinates? location, + DateTime? endAt, + }) async { + final response = await _client.put( + '/users/live_locations', + data: json.encode({ + 'message_id': messageId, + if (createdByDeviceId != null) 'created_by_device_id': createdByDeviceId, + if (location?.latitude case final latitude) 'latitude': latitude, + if (location?.longitude case final longitude) 'longitude': longitude, + if (endAt != null) 'end_at': endAt.toIso8601String(), + }), + ); + + return Location.fromJson(response.data); + } } diff --git a/packages/stream_chat/lib/src/core/error/chat_error_code.dart b/packages/stream_chat/lib/src/core/error/chat_error_code.dart index d597000ad6..b35c010da2 100644 --- a/packages/stream_chat/lib/src/core/error/chat_error_code.dart +++ b/packages/stream_chat/lib/src/core/error/chat_error_code.dart @@ -94,10 +94,8 @@ enum ChatErrorCode { } const _errorCodeWithDescription = { - ChatErrorCode.undefinedToken: - MapEntry(1000, 'Unauthorised, token not defined'), - ChatErrorCode.inputError: - MapEntry(4, 'Wrong data/parameter is sent to the API'), + ChatErrorCode.undefinedToken: MapEntry(1000, 'Unauthorised, token not defined'), + ChatErrorCode.inputError: MapEntry(4, 'Wrong data/parameter is sent to the API'), ChatErrorCode.duplicateUsername: MapEntry( 6, 'Duplicate username is sent while enforce_unique_usernames is enabled', @@ -112,36 +110,24 @@ const _errorCodeWithDescription = { 21, 'Multiple Levels Reply is not supported - the API only supports 1 level deep reply threads', ), - ChatErrorCode.customCommandEndpointCall: - MapEntry(45, 'Custom Command handler returned an error'), - ChatErrorCode.customCommandEndpointMissing: - MapEntry(44, 'App config does not have custom_action_handler_url'), - ChatErrorCode.authenticationError: - MapEntry(5, 'Unauthenticated, problem with authentication'), + ChatErrorCode.customCommandEndpointCall: MapEntry(45, 'Custom Command handler returned an error'), + ChatErrorCode.customCommandEndpointMissing: MapEntry(44, 'App config does not have custom_action_handler_url'), + ChatErrorCode.authenticationError: MapEntry(5, 'Unauthenticated, problem with authentication'), ChatErrorCode.tokenExpired: MapEntry(40, 'Unauthenticated, token expired'), - ChatErrorCode.tokenBeforeIssuedAt: - MapEntry(42, 'Unauthenticated, token date incorrect'), - ChatErrorCode.tokenNotValid: - MapEntry(41, 'Unauthenticated, token not valid yet'), - ChatErrorCode.tokenSignatureInvalid: - MapEntry(43, 'Unauthenticated, token signature invalid'), + ChatErrorCode.tokenBeforeIssuedAt: MapEntry(42, 'Unauthenticated, token date incorrect'), + ChatErrorCode.tokenNotValid: MapEntry(41, 'Unauthenticated, token not valid yet'), + ChatErrorCode.tokenSignatureInvalid: MapEntry(43, 'Unauthenticated, token signature invalid'), ChatErrorCode.accessKeyError: MapEntry(2, 'Access Key invalid'), - ChatErrorCode.notAllowed: - MapEntry(17, 'Unauthorised / forbidden to make request'), + ChatErrorCode.notAllowed: MapEntry(17, 'Unauthorised / forbidden to make request'), ChatErrorCode.appSuspended: MapEntry(99, 'App suspended'), - ChatErrorCode.cooldownError: - MapEntry(60, 'User tried to post a message during the cooldown period'), + ChatErrorCode.cooldownError: MapEntry(60, 'User tried to post a message during the cooldown period'), ChatErrorCode.doesNotExist: MapEntry(16, 'Resource not found'), ChatErrorCode.requestTimeout: MapEntry(23, 'Request timed out'), ChatErrorCode.payloadTooBig: MapEntry(22, 'Payload too big'), - ChatErrorCode.rateLimitError: - MapEntry(9, 'Too many requests in a certain time frame'), - ChatErrorCode.maximumHeaderSizeExceeded: - MapEntry(24, 'Request headers are too large'), - ChatErrorCode.internalSystemError: - MapEntry(-1, 'Something goes wrong in the system'), - ChatErrorCode.noAccessToChannels: - MapEntry(70, 'No access to requested channels'), + ChatErrorCode.rateLimitError: MapEntry(9, 'Too many requests in a certain time frame'), + ChatErrorCode.maximumHeaderSizeExceeded: MapEntry(24, 'Request headers are too large'), + ChatErrorCode.internalSystemError: MapEntry(-1, 'Something goes wrong in the system'), + ChatErrorCode.noAccessToChannels: MapEntry(70, 'No access to requested channels'), }; const _authenticationErrors = [ @@ -156,8 +142,8 @@ const _authenticationErrors = [ ]; /// -ChatErrorCode? chatErrorCodeFromCode(int code) => _errorCodeWithDescription.keys - .firstWhereOrNull((key) => _errorCodeWithDescription[key]!.key == code); +ChatErrorCode? chatErrorCodeFromCode(int code) => + _errorCodeWithDescription.keys.firstWhereOrNull((key) => _errorCodeWithDescription[key]!.key == code); /// extension ChatErrorCodeX on ChatErrorCode { diff --git a/packages/stream_chat/lib/src/core/error/stream_chat_error.dart b/packages/stream_chat/lib/src/core/error/stream_chat_error.dart index 5e88fb3bc9..b2925940e5 100644 --- a/packages/stream_chat/lib/src/core/error/stream_chat_error.dart +++ b/packages/stream_chat/lib/src/core/error/stream_chat_error.dart @@ -78,10 +78,10 @@ class StreamChatNetworkError extends StreamChatError { this.data, StackTrace? stacktrace, this.isRequestCancelledError = false, - }) : code = errorCode.code, - statusCode = statusCode ?? data?.statusCode, - stackTrace = stacktrace ?? StackTrace.current, - super(errorCode.message); + }) : code = errorCode.code, + statusCode = statusCode ?? data?.statusCode, + stackTrace = stacktrace ?? StackTrace.current, + super(errorCode.message); /// StreamChatNetworkError.raw({ @@ -91,8 +91,8 @@ class StreamChatNetworkError extends StreamChatError { this.data, StackTrace? stacktrace, this.isRequestCancelledError = false, - }) : stackTrace = stacktrace ?? StackTrace.current, - super(message); + }) : stackTrace = stacktrace ?? StackTrace.current, + super(message); /// factory StreamChatNetworkError.fromDioException(DioException exception) { @@ -106,10 +106,7 @@ class StreamChatNetworkError extends StreamChatError { } return StreamChatNetworkError.raw( code: errorResponse?.code ?? -1, - message: errorResponse?.message ?? - response?.statusMessage ?? - exception.message ?? - '', + message: errorResponse?.message ?? response?.statusMessage ?? exception.message ?? '', statusCode: errorResponse?.statusCode ?? response?.statusCode, data: errorResponse, stacktrace: exception.stackTrace, diff --git a/packages/stream_chat/lib/src/core/http/interceptor/logging_interceptor.dart b/packages/stream_chat/lib/src/core/http/interceptor/logging_interceptor.dart index 6d6f74c301..258489471e 100644 --- a/packages/stream_chat/lib/src/core/http/interceptor/logging_interceptor.dart +++ b/packages/stream_chat/lib/src/core/http/interceptor/logging_interceptor.dart @@ -125,8 +125,7 @@ class LoggingInterceptor extends Interceptor { final uri = exception.response?.requestOptions.uri; _printBoxed( _logPrintError, - header: - 'DioException ║ Status: ${exception.response?.statusCode} ${exception.response?.statusMessage}', + header: 'DioException ║ Status: ${exception.response?.statusCode} ${exception.response?.statusMessage}', text: uri.toString(), ); if (exception.response != null && exception.response?.data != null) { @@ -152,8 +151,7 @@ class LoggingInterceptor extends Interceptor { _printResponseHeader(_logPrintResponse, response); if (responseHeader) { final responseHeaders = {}; - response.headers - .forEach((k, list) => responseHeaders[k] = list.toString()); + response.headers.forEach((k, list) => responseHeaders[k] = list.toString()); _printMapAsTable(_logPrintResponse, responseHeaders, header: 'Headers'); } @@ -197,8 +195,7 @@ class LoggingInterceptor extends Interceptor { final method = response.requestOptions.method; _printBoxed( logPrint, - header: - 'Response ║ $method ║ Status: ${response.statusCode} ${response.statusMessage}', + header: 'Response ║ $method ║ Status: ${response.statusCode} ${response.statusMessage}', text: uri.toString(), ); } @@ -216,8 +213,7 @@ class LoggingInterceptor extends Interceptor { void Function(Object) logPrint, [ String pre = '', String suf = '╝', - ]) => - logPrint('$pre${'═' * maxWidth}$suf'); + ]) => logPrint('$pre${'═' * maxWidth}$suf'); void _printKV(void Function(Object) logPrint, String? key, Object? v) { final pre = '╟ $key: '; @@ -234,11 +230,13 @@ class LoggingInterceptor extends Interceptor { void _printBlock(void Function(Object) logPrint, String msg) { final lines = (msg.length / maxWidth).ceil(); for (var i = 0; i < lines; ++i) { - logPrint((i >= 0 ? '║ ' : '') + - msg.substring( - i * maxWidth, - math.min(i * maxWidth + maxWidth, msg.length), - )); + logPrint( + (i >= 0 ? '║ ' : '') + + msg.substring( + i * maxWidth, + math.min(i * maxWidth + maxWidth, msg.length), + ), + ); } } @@ -286,10 +284,12 @@ class LoggingInterceptor extends Interceptor { if (msg.length + indent.length > linWidth) { final lines = (msg.length / linWidth).ceil(); for (var i = 0; i < lines; ++i) { - logPrint('║${_indent(_tabs)} ${msg.substring( - i * linWidth, - math.min(i * linWidth + linWidth, msg.length), - )}'); + logPrint( + '║${_indent(_tabs)} ${msg.substring( + i * linWidth, + math.min(i * linWidth + linWidth, msg.length), + )}', + ); } } else { logPrint('║${_indent(_tabs)} $key: $msg${!isLast ? ',' : ''}'); @@ -332,16 +332,13 @@ class LoggingInterceptor extends Interceptor { }) { if (map == null || map.isEmpty) return; logPrint('╔ $header '); - map.forEach((dynamic key, dynamic value) => - _printKV(logPrint, key.toString(), value)); + map.forEach((dynamic key, dynamic value) => _printKV(logPrint, key.toString(), value)); _printLine(logPrint, '╚'); } - void _logPrintRequest(Object object) => - logPrint(InterceptStep.request, object); + void _logPrintRequest(Object object) => logPrint(InterceptStep.request, object); - void _logPrintResponse(Object object) => - logPrint(InterceptStep.response, object); + void _logPrintResponse(Object object) => logPrint(InterceptStep.response, object); void _logPrintError(Object object) => logPrint(InterceptStep.error, object); } diff --git a/packages/stream_chat/lib/src/core/http/stream_chat_dio_error.dart b/packages/stream_chat/lib/src/core/http/stream_chat_dio_error.dart index d59a032c63..25b75fd82e 100644 --- a/packages/stream_chat/lib/src/core/http/stream_chat_dio_error.dart +++ b/packages/stream_chat/lib/src/core/http/stream_chat_dio_error.dart @@ -12,9 +12,9 @@ class StreamChatDioError extends DioException { StackTrace? stackTrace, super.message, }) : super( - error: error, - stackTrace: stackTrace ?? StackTrace.current, - ); + error: error, + stackTrace: stackTrace ?? StackTrace.current, + ); @override final StreamChatNetworkError error; diff --git a/packages/stream_chat/lib/src/core/http/stream_http_client.dart b/packages/stream_chat/lib/src/core/http/stream_http_client.dart index 16141c0a26..7c887246e0 100644 --- a/packages/stream_chat/lib/src/core/http/stream_http_client.dart +++ b/packages/stream_chat/lib/src/core/http/stream_http_client.dart @@ -29,8 +29,8 @@ class StreamHttpClient { Logger? logger, Iterable? interceptors, HttpClientAdapter? httpClientAdapter, - }) : _options = options ?? const StreamHttpClientOptions(), - httpClient = dio ?? Dio() { + }) : _options = options ?? const StreamHttpClientOptions(), + httpClient = dio ?? Dio() { httpClient ..options.baseUrl = _options.baseUrl ..options.receiveTimeout = _options.receiveTimeout @@ -47,8 +47,7 @@ class StreamHttpClient { ..interceptors.addAll([ AdditionalHeadersInterceptor(systemEnvironmentManager), if (tokenManager != null) AuthInterceptor(this, tokenManager), - if (connectionIdManager != null) - ConnectionIdInterceptor(connectionIdManager), + if (connectionIdManager != null) ConnectionIdInterceptor(connectionIdManager), ...interceptors ?? [ // Add a default logging interceptor if no interceptors are diff --git a/packages/stream_chat/lib/src/core/http/stream_http_client_options.dart b/packages/stream_chat/lib/src/core/http/stream_http_client_options.dart index ad18c2544f..a98a7904c2 100644 --- a/packages/stream_chat/lib/src/core/http/stream_http_client_options.dart +++ b/packages/stream_chat/lib/src/core/http/stream_http_client_options.dart @@ -2,13 +2,19 @@ part of 'stream_http_client.dart'; const _defaultBaseURL = 'https://chat.stream-io-api.com'; +/// The default connect timeout for the api request +const kDefaultConnectTimeout = Duration(seconds: 30); + +/// The default receive timeout for the api request +const kDefaultReceiveTimeout = Duration(seconds: 30); + /// Client options to modify [StreamHttpClient] class StreamHttpClientOptions { /// Instantiates a new [StreamHttpClientOptions] const StreamHttpClientOptions({ String? baseUrl, - this.connectTimeout = const Duration(seconds: 30), - this.receiveTimeout = const Duration(seconds: 30), + this.connectTimeout = kDefaultConnectTimeout, + this.receiveTimeout = kDefaultReceiveTimeout, this.queryParameters = const {}, this.headers = const {}, }) : baseUrl = baseUrl ?? _defaultBaseURL; diff --git a/packages/stream_chat/lib/src/core/http/system_environment_manager.dart b/packages/stream_chat/lib/src/core/http/system_environment_manager.dart index 42bd01c3ea..bb4648e7d3 100644 --- a/packages/stream_chat/lib/src/core/http/system_environment_manager.dart +++ b/packages/stream_chat/lib/src/core/http/system_environment_manager.dart @@ -12,14 +12,14 @@ class SystemEnvironmentManager { SystemEnvironmentManager({ SystemEnvironment? environment, }) : _environment = switch (environment) { - final env? => env, - _ => SystemEnvironment( - sdkName: 'stream-chat', - sdkIdentifier: 'dart', - sdkVersion: PACKAGE_VERSION, - osName: CurrentPlatform.name, - ), - }; + final env? => env, + _ => SystemEnvironment( + sdkName: 'stream-chat', + sdkIdentifier: 'dart', + sdkVersion: PACKAGE_VERSION, + osName: CurrentPlatform.name, + ), + }; /// Returns the Stream client user agent string based on the current /// [environment] value. diff --git a/packages/stream_chat/lib/src/core/http/token.dart b/packages/stream_chat/lib/src/core/http/token.dart index 2472a1f7f5..df1114a0ca 100644 --- a/packages/stream_chat/lib/src/core/http/token.dart +++ b/packages/stream_chat/lib/src/core/http/token.dart @@ -29,10 +29,10 @@ class Token extends Equatable { /// The token that can be used when user is unknown. /// Is used by `anonymous` token provider. factory Token.anonymous({String? userId}) => Token._( - rawValue: '', - userId: userId ?? randomId(), - authType: AuthType.anonymous, - ); + rawValue: '', + userId: userId ?? randomId(), + authType: AuthType.anonymous, + ); /// Creates a [Token] instance from the provided [rawValue] if it's valid. factory Token.fromRawValue(String rawValue) { diff --git a/packages/stream_chat/lib/src/core/http/token_manager.dart b/packages/stream_chat/lib/src/core/http/token_manager.dart index e5af0ddc33..96b90bdcc0 100644 --- a/packages/stream_chat/lib/src/core/http/token_manager.dart +++ b/packages/stream_chat/lib/src/core/http/token_manager.dart @@ -12,9 +12,9 @@ class TokenManager { String? userId, Token? token, TokenProvider? tokenProvider, - }) : _userId = userId, - _token = token, - _provider = tokenProvider; + }) : _userId = userId, + _token = token, + _provider = tokenProvider; String? _type; Token? _token; diff --git a/packages/stream_chat/lib/src/core/models/action.g.dart b/packages/stream_chat/lib/src/core/models/action.g.dart index 2c9148d92b..ec604d553f 100644 --- a/packages/stream_chat/lib/src/core/models/action.g.dart +++ b/packages/stream_chat/lib/src/core/models/action.g.dart @@ -7,17 +7,17 @@ part of 'action.dart'; // ************************************************************************** Action _$ActionFromJson(Map json) => Action( - name: json['name'] as String, - style: json['style'] as String? ?? 'default', - text: json['text'] as String, - type: json['type'] as String, - value: json['value'] as String?, - ); + name: json['name'] as String, + style: json['style'] as String? ?? 'default', + text: json['text'] as String, + type: json['type'] as String, + value: json['value'] as String?, +); Map _$ActionToJson(Action instance) => { - 'name': instance.name, - 'style': instance.style, - 'text': instance.text, - 'type': instance.type, - 'value': instance.value, - }; + 'name': instance.name, + 'style': instance.style, + 'text': instance.text, + 'type': instance.type, + 'value': instance.value, +}; diff --git a/packages/stream_chat/lib/src/core/models/attachment.dart b/packages/stream_chat/lib/src/core/models/attachment.dart index a4e15241c8..486750cf45 100644 --- a/packages/stream_chat/lib/src/core/models/attachment.dart +++ b/packages/stream_chat/lib/src/core/models/attachment.dart @@ -39,49 +39,48 @@ class Attachment extends Equatable { Map extraData = const {}, this.file, this.uploadState = const UploadState.preparing(), - }) : id = id ?? const Uuid().v4(), - _type = switch (type) { - String() => AttachmentType(type), - _ => null, - }, - title = title ?? file?.name, - localUri = file?.path != null ? Uri.parse(file!.path!) : null, - // For backwards compatibility, - // set 'file_size', 'mime_type' in [extraData]. - extraData = { - ...extraData, - if (file?.size != null) 'file_size': file?.size, - if (file?.mediaType != null) 'mime_type': file?.mediaType?.mimeType, - }; + }) : id = id ?? const Uuid().v4(), + _type = switch (type) { + String() => AttachmentType(type), + _ => null, + }, + title = title ?? file?.name, + localUri = file?.path != null ? Uri.parse(file!.path!) : null, + // For backwards compatibility, + // set 'file_size', 'mime_type' in [extraData]. + extraData = { + ...extraData, + if (file?.size != null) 'file_size': file?.size, + if (file?.mediaType != null) 'mime_type': file?.mediaType?.mimeType, + }; /// Create a new instance from a json - factory Attachment.fromJson(Map json) => - _$AttachmentFromJson( - Serializer.moveToExtraDataFromRoot(json, topLevelFields), - ); + factory Attachment.fromJson(Map json) => _$AttachmentFromJson( + Serializer.moveToExtraDataFromRoot(json, topLevelFields), + ); /// Create a new instance from a db data - factory Attachment.fromData(Map json) => - _$AttachmentFromJson(Serializer.moveToExtraDataFromRoot( - json, - topLevelFields + dbSpecificTopLevelFields, - )); - - factory Attachment.fromOGAttachment(OGAttachmentResponse ogAttachment) => - Attachment( - // If the type is not specified, we default to urlPreview. - type: ogAttachment.type ?? AttachmentType.urlPreview, - title: ogAttachment.title, - titleLink: ogAttachment.titleLink, - text: ogAttachment.text, - imageUrl: ogAttachment.imageUrl, - thumbUrl: ogAttachment.thumbUrl, - authorName: ogAttachment.authorName, - authorLink: ogAttachment.authorLink, - assetUrl: ogAttachment.assetUrl, - ogScrapeUrl: ogAttachment.ogScrapeUrl, - uploadState: const UploadState.success(), - ); + factory Attachment.fromData(Map json) => _$AttachmentFromJson( + Serializer.moveToExtraDataFromRoot( + json, + topLevelFields + dbSpecificTopLevelFields, + ), + ); + + factory Attachment.fromOGAttachment(OGAttachmentResponse ogAttachment) => Attachment( + // If the type is not specified, we default to urlPreview. + type: ogAttachment.type ?? AttachmentType.urlPreview, + title: ogAttachment.title, + titleLink: ogAttachment.titleLink, + text: ogAttachment.text, + imageUrl: ogAttachment.imageUrl, + thumbUrl: ogAttachment.thumbUrl, + authorName: ogAttachment.authorName, + authorLink: ogAttachment.authorLink, + assetUrl: ogAttachment.assetUrl, + ogScrapeUrl: ogAttachment.ogScrapeUrl, + uploadState: const UploadState.success(), + ); ///The attachment type based on the URL resource. This can be: audio, ///image or video @@ -219,8 +218,7 @@ class Attachment extends Equatable { ..removeWhere((key, value) => dbSpecificTopLevelFields.contains(key)); /// Serialize to db data - Map toData() => - Serializer.moveFromExtraDataToRoot(_$AttachmentToJson(this)); + Map toData() => Serializer.moveFromExtraDataToRoot(_$AttachmentToJson(this)); Attachment copyWith({ String? id, @@ -247,33 +245,32 @@ class Attachment extends Equatable { AttachmentFile? file, UploadState? uploadState, Map? extraData, - }) => - Attachment( - id: id ?? this.id, - type: type ?? this.type, - titleLink: titleLink ?? this.titleLink, - title: title ?? this.title, - thumbUrl: thumbUrl ?? this.thumbUrl, - text: text ?? this.text, - pretext: pretext ?? this.pretext, - ogScrapeUrl: ogScrapeUrl ?? this.ogScrapeUrl, - imageUrl: imageUrl ?? this.imageUrl, - footerIcon: footerIcon ?? this.footerIcon, - footer: footer ?? this.footer, - fields: fields ?? this.fields, - fallback: fallback ?? this.fallback, - color: color ?? this.color, - authorName: authorName ?? this.authorName, - authorLink: authorLink ?? this.authorLink, - authorIcon: authorIcon ?? this.authorIcon, - assetUrl: assetUrl ?? this.assetUrl, - actions: actions ?? this.actions, - originalWidth: originalWidth ?? this.originalWidth, - originalHeight: originalHeight ?? this.originalHeight, - file: file ?? this.file, - uploadState: uploadState ?? this.uploadState, - extraData: extraData ?? this.extraData, - ); + }) => Attachment( + id: id ?? this.id, + type: type ?? this.type, + titleLink: titleLink ?? this.titleLink, + title: title ?? this.title, + thumbUrl: thumbUrl ?? this.thumbUrl, + text: text ?? this.text, + pretext: pretext ?? this.pretext, + ogScrapeUrl: ogScrapeUrl ?? this.ogScrapeUrl, + imageUrl: imageUrl ?? this.imageUrl, + footerIcon: footerIcon ?? this.footerIcon, + footer: footer ?? this.footer, + fields: fields ?? this.fields, + fallback: fallback ?? this.fallback, + color: color ?? this.color, + authorName: authorName ?? this.authorName, + authorLink: authorLink ?? this.authorLink, + authorIcon: authorIcon ?? this.authorIcon, + assetUrl: assetUrl ?? this.assetUrl, + actions: actions ?? this.actions, + originalWidth: originalWidth ?? this.originalWidth, + originalHeight: originalHeight ?? this.originalHeight, + file: file ?? this.file, + uploadState: uploadState ?? this.uploadState, + extraData: extraData ?? this.extraData, + ); Attachment merge(Attachment? other) { if (other == null) return this; @@ -306,31 +303,31 @@ class Attachment extends Equatable { @override List get props => [ - id, - type, - titleLink, - title, - thumbUrl, - text, - pretext, - ogScrapeUrl, - imageUrl, - footerIcon, - footer, - fields, - fallback, - color, - authorName, - authorLink, - authorIcon, - assetUrl, - actions, - originalWidth, - originalHeight, - file, - uploadState, - extraData, - ]; + id, + type, + titleLink, + title, + thumbUrl, + text, + pretext, + ogScrapeUrl, + imageUrl, + footerIcon, + footer, + fields, + fallback, + color, + authorName, + authorLink, + authorIcon, + assetUrl, + actions, + originalWidth, + originalHeight, + file, + uploadState, + extraData, + ]; } /// {@template attachmentType} diff --git a/packages/stream_chat/lib/src/core/models/attachment.g.dart b/packages/stream_chat/lib/src/core/models/attachment.g.dart index 8fcc4fe4c6..8dd8e75a8c 100644 --- a/packages/stream_chat/lib/src/core/models/attachment.g.dart +++ b/packages/stream_chat/lib/src/core/models/attachment.g.dart @@ -7,64 +7,58 @@ part of 'attachment.dart'; // ************************************************************************** Attachment _$AttachmentFromJson(Map json) => Attachment( - id: json['id'] as String?, - type: AttachmentType.fromJson(json['type'] as String?), - titleLink: json['title_link'] as String?, - title: json['title'] as String?, - thumbUrl: json['thumb_url'] as String?, - text: json['text'] as String?, - pretext: json['pretext'] as String?, - ogScrapeUrl: json['og_scrape_url'] as String?, - imageUrl: json['image_url'] as String?, - footerIcon: json['footer_icon'] as String?, - footer: json['footer'] as String?, - fields: json['fields'], - fallback: json['fallback'] as String?, - color: json['color'] as String?, - authorName: json['author_name'] as String?, - authorLink: json['author_link'] as String?, - authorIcon: json['author_icon'] as String?, - assetUrl: json['asset_url'] as String?, - actions: (json['actions'] as List?) - ?.map((e) => Action.fromJson(e as Map)) - .toList() ?? - const [], - originalWidth: (json['original_width'] as num?)?.toInt(), - originalHeight: (json['original_height'] as num?)?.toInt(), - extraData: json['extra_data'] as Map? ?? const {}, - file: json['file'] == null - ? null - : AttachmentFile.fromJson(json['file'] as Map), - uploadState: json['upload_state'] == null - ? const UploadState.success() - : UploadState.fromJson(json['upload_state'] as Map), - ); + id: json['id'] as String?, + type: AttachmentType.fromJson(json['type'] as String?), + titleLink: json['title_link'] as String?, + title: json['title'] as String?, + thumbUrl: json['thumb_url'] as String?, + text: json['text'] as String?, + pretext: json['pretext'] as String?, + ogScrapeUrl: json['og_scrape_url'] as String?, + imageUrl: json['image_url'] as String?, + footerIcon: json['footer_icon'] as String?, + footer: json['footer'] as String?, + fields: json['fields'], + fallback: json['fallback'] as String?, + color: json['color'] as String?, + authorName: json['author_name'] as String?, + authorLink: json['author_link'] as String?, + authorIcon: json['author_icon'] as String?, + assetUrl: json['asset_url'] as String?, + actions: + (json['actions'] as List?)?.map((e) => Action.fromJson(e as Map)).toList() ?? const [], + originalWidth: (json['original_width'] as num?)?.toInt(), + originalHeight: (json['original_height'] as num?)?.toInt(), + extraData: json['extra_data'] as Map? ?? const {}, + file: json['file'] == null ? null : AttachmentFile.fromJson(json['file'] as Map), + uploadState: json['upload_state'] == null + ? const UploadState.success() + : UploadState.fromJson(json['upload_state'] as Map), +); -Map _$AttachmentToJson(Attachment instance) => - { - if (AttachmentType.toJson(instance.type) case final value?) 'type': value, - if (instance.titleLink case final value?) 'title_link': value, - if (instance.title case final value?) 'title': value, - if (instance.thumbUrl case final value?) 'thumb_url': value, - if (instance.text case final value?) 'text': value, - if (instance.pretext case final value?) 'pretext': value, - if (instance.ogScrapeUrl case final value?) 'og_scrape_url': value, - if (instance.imageUrl case final value?) 'image_url': value, - if (instance.footerIcon case final value?) 'footer_icon': value, - if (instance.footer case final value?) 'footer': value, - if (instance.fields case final value?) 'fields': value, - if (instance.fallback case final value?) 'fallback': value, - if (instance.color case final value?) 'color': value, - if (instance.authorName case final value?) 'author_name': value, - if (instance.authorLink case final value?) 'author_link': value, - if (instance.authorIcon case final value?) 'author_icon': value, - if (instance.assetUrl case final value?) 'asset_url': value, - if (instance.actions?.map((e) => e.toJson()).toList() case final value?) - 'actions': value, - if (instance.originalWidth case final value?) 'original_width': value, - if (instance.originalHeight case final value?) 'original_height': value, - if (instance.file?.toJson() case final value?) 'file': value, - 'upload_state': instance.uploadState.toJson(), - 'extra_data': instance.extraData, - 'id': instance.id, - }; +Map _$AttachmentToJson(Attachment instance) => { + if (AttachmentType.toJson(instance.type) case final value?) 'type': value, + if (instance.titleLink case final value?) 'title_link': value, + if (instance.title case final value?) 'title': value, + if (instance.thumbUrl case final value?) 'thumb_url': value, + if (instance.text case final value?) 'text': value, + if (instance.pretext case final value?) 'pretext': value, + if (instance.ogScrapeUrl case final value?) 'og_scrape_url': value, + if (instance.imageUrl case final value?) 'image_url': value, + if (instance.footerIcon case final value?) 'footer_icon': value, + if (instance.footer case final value?) 'footer': value, + if (instance.fields case final value?) 'fields': value, + if (instance.fallback case final value?) 'fallback': value, + if (instance.color case final value?) 'color': value, + if (instance.authorName case final value?) 'author_name': value, + if (instance.authorLink case final value?) 'author_link': value, + if (instance.authorIcon case final value?) 'author_icon': value, + if (instance.assetUrl case final value?) 'asset_url': value, + if (instance.actions?.map((e) => e.toJson()).toList() case final value?) 'actions': value, + if (instance.originalWidth case final value?) 'original_width': value, + if (instance.originalHeight case final value?) 'original_height': value, + if (instance.file?.toJson() case final value?) 'file': value, + 'upload_state': instance.uploadState.toJson(), + 'extra_data': instance.extraData, + 'id': instance.id, +}; diff --git a/packages/stream_chat/lib/src/core/models/attachment_file.dart b/packages/stream_chat/lib/src/core/models/attachment_file.dart index b5bb007633..cbea9ca12a 100644 --- a/packages/stream_chat/lib/src/core/models/attachment_file.dart +++ b/packages/stream_chat/lib/src/core/models/attachment_file.dart @@ -19,23 +19,22 @@ class AttachmentFile { this.path, String? name, this.bytes, - }) : assert( - path != null || bytes != null, - 'Either path or bytes should be != null', - ), - assert( - !CurrentPlatform.isWeb || bytes != null, - 'File by path is not supported in web, Please provide bytes', - ), - assert( - name == null || name.isEmpty || name.contains('.'), - 'Invalid file name, should also contain file extension', - ), - _name = name; + }) : assert( + path != null || bytes != null, + 'Either path or bytes should be != null', + ), + assert( + !CurrentPlatform.isWeb || bytes != null, + 'File by path is not supported in web, Please provide bytes', + ), + assert( + name == null || name.isEmpty || name.contains('.'), + 'Invalid file name, should also contain file extension', + ), + _name = name; /// Create a new instance from a json - factory AttachmentFile.fromJson(Map json) => - _$AttachmentFileFromJson(json); + factory AttachmentFile.fromJson(Map json) => _$AttachmentFileFromJson(json); /// The absolute path for a cached copy of this file. It can be used to /// create a file instance with a descriptor for the given path. @@ -69,20 +68,34 @@ class AttachmentFile { /// Serialize to json Map toJson() => _$AttachmentFileToJson(this); - /// Converts this into a [MultipartFile] + /// Converts this [AttachmentFile] to a [MultipartFile]. + /// + /// Tries path-based creation first, which is more efficient for large files. + /// Falls back to byte-based creation when the path is inaccessible + /// (e.g. web platforms, or short-lived iOS photo library exports). Future toMultipartFile() async { - return switch (CurrentPlatform.type) { - PlatformType.web => MultipartFile.fromBytes( - bytes!, + if (path case final path?) { + try { + return await MultipartFile.fromFile( + path, filename: name, contentType: mediaType, - ), - _ => await MultipartFile.fromFile( - path!, - filename: name, - contentType: mediaType, - ), - }; + ); + } catch (_) {} // Path may no longer exist + } + + if (bytes case final bytes?) { + return MultipartFile.fromBytes( + bytes, + filename: name, + contentType: mediaType, + ); + } + + throw StateError( + 'Cannot create MultipartFile: both path and bytes are unavailable. ' + 'path: $path, bytes: $bytes', + ); } /// Creates a copy of this [AttachmentFile] but with the given fields @@ -124,8 +137,7 @@ sealed class UploadState with _$UploadState { const factory UploadState.failed({required String error}) = Failed; /// Creates a new instance from a json - factory UploadState.fromJson(Map json) => - _$UploadStateFromJson(json); + factory UploadState.fromJson(Map json) => _$UploadStateFromJson(json); /// Returns true if state is [Preparing] bool get isPreparing => this is Preparing; diff --git a/packages/stream_chat/lib/src/core/models/attachment_file.g.dart b/packages/stream_chat/lib/src/core/models/attachment_file.g.dart index 256713cae0..7ebe7ea111 100644 --- a/packages/stream_chat/lib/src/core/models/attachment_file.g.dart +++ b/packages/stream_chat/lib/src/core/models/attachment_file.g.dart @@ -6,55 +6,52 @@ part of 'attachment_file.dart'; // JsonSerializableGenerator // ************************************************************************** -AttachmentFile _$AttachmentFileFromJson(Map json) => - AttachmentFile( - size: (json['size'] as num?)?.toInt(), - path: json['path'] as String?, - name: json['name'] as String?, - ); - -Map _$AttachmentFileToJson(AttachmentFile instance) => - { - 'path': instance.path, - 'name': instance.name, - 'size': instance.size, - }; +AttachmentFile _$AttachmentFileFromJson(Map json) => AttachmentFile( + size: (json['size'] as num?)?.toInt(), + path: json['path'] as String?, + name: json['name'] as String?, +); + +Map _$AttachmentFileToJson(AttachmentFile instance) => { + 'path': instance.path, + 'name': instance.name, + 'size': instance.size, +}; Preparing _$PreparingFromJson(Map json) => Preparing( - $type: json['runtimeType'] as String?, - ); + $type: json['runtimeType'] as String?, +); Map _$PreparingToJson(Preparing instance) => { - 'runtimeType': instance.$type, - }; + 'runtimeType': instance.$type, +}; InProgress _$InProgressFromJson(Map json) => InProgress( - uploaded: (json['uploaded'] as num).toInt(), - total: (json['total'] as num).toInt(), - $type: json['runtimeType'] as String?, - ); - -Map _$InProgressToJson(InProgress instance) => - { - 'uploaded': instance.uploaded, - 'total': instance.total, - 'runtimeType': instance.$type, - }; + uploaded: (json['uploaded'] as num).toInt(), + total: (json['total'] as num).toInt(), + $type: json['runtimeType'] as String?, +); + +Map _$InProgressToJson(InProgress instance) => { + 'uploaded': instance.uploaded, + 'total': instance.total, + 'runtimeType': instance.$type, +}; Success _$SuccessFromJson(Map json) => Success( - $type: json['runtimeType'] as String?, - ); + $type: json['runtimeType'] as String?, +); Map _$SuccessToJson(Success instance) => { - 'runtimeType': instance.$type, - }; + 'runtimeType': instance.$type, +}; Failed _$FailedFromJson(Map json) => Failed( - error: json['error'] as String, - $type: json['runtimeType'] as String?, - ); + error: json['error'] as String, + $type: json['runtimeType'] as String?, +); Map _$FailedToJson(Failed instance) => { - 'error': instance.error, - 'runtimeType': instance.$type, - }; + 'error': instance.error, + 'runtimeType': instance.$type, +}; diff --git a/packages/stream_chat/lib/src/core/models/attachment_giphy_info.dart b/packages/stream_chat/lib/src/core/models/attachment_giphy_info.dart index bc5d59a326..26ffbb9bce 100644 --- a/packages/stream_chat/lib/src/core/models/attachment_giphy_info.dart +++ b/packages/stream_chat/lib/src/core/models/attachment_giphy_info.dart @@ -17,7 +17,8 @@ enum GiphyInfoType { /// Lower quality with a fixed height with width adjusted according to the /// aspect ratio and played at a lower frame rate. Significantly lower size, /// but visually less appealing. - fixedHeightDownsampled('fixed_height_downsampled'); + fixedHeightDownsampled('fixed_height_downsampled') + ; /// {@macro giphy_info_type} const GiphyInfoType(this.value); diff --git a/packages/stream_chat/lib/src/core/models/banned_user.dart b/packages/stream_chat/lib/src/core/models/banned_user.dart index 4f25ac75fe..7717593b1a 100644 --- a/packages/stream_chat/lib/src/core/models/banned_user.dart +++ b/packages/stream_chat/lib/src/core/models/banned_user.dart @@ -21,8 +21,7 @@ class BannedUser extends Equatable implements ComparableFieldProvider { }); /// Create a new instance from a json - factory BannedUser.fromJson(Map json) => - _$BannedUserFromJson(json); + factory BannedUser.fromJson(Map json) => _$BannedUserFromJson(json); /// Banned user. final User user; @@ -57,27 +56,26 @@ class BannedUser extends Equatable implements ComparableFieldProvider { DateTime? expires, bool? shadow, String? reason, - }) => - BannedUser( - user: user ?? this.user, - bannedBy: bannedBy ?? this.bannedBy, - channel: channel ?? this.channel, - createdAt: createdAt ?? this.createdAt, - expires: expires ?? this.expires, - shadow: shadow ?? this.shadow, - reason: reason ?? this.reason, - ); + }) => BannedUser( + user: user ?? this.user, + bannedBy: bannedBy ?? this.bannedBy, + channel: channel ?? this.channel, + createdAt: createdAt ?? this.createdAt, + expires: expires ?? this.expires, + shadow: shadow ?? this.shadow, + reason: reason ?? this.reason, + ); @override List get props => [ - user, - bannedBy, - channel, - createdAt, - expires, - shadow, - reason, - ]; + user, + bannedBy, + channel, + createdAt, + expires, + shadow, + reason, + ]; @override ComparableField? getComparableField(String sortKey) { diff --git a/packages/stream_chat/lib/src/core/models/banned_user.g.dart b/packages/stream_chat/lib/src/core/models/banned_user.g.dart index 1f2a335b34..024a8fd912 100644 --- a/packages/stream_chat/lib/src/core/models/banned_user.g.dart +++ b/packages/stream_chat/lib/src/core/models/banned_user.g.dart @@ -7,30 +7,21 @@ part of 'banned_user.dart'; // ************************************************************************** BannedUser _$BannedUserFromJson(Map json) => BannedUser( - user: User.fromJson(json['user'] as Map), - bannedBy: json['banned_by'] == null - ? null - : User.fromJson(json['banned_by'] as Map), - channel: json['channel'] == null - ? null - : ChannelModel.fromJson(json['channel'] as Map), - createdAt: json['created_at'] == null - ? null - : DateTime.parse(json['created_at'] as String), - expires: json['expires'] == null - ? null - : DateTime.parse(json['expires'] as String), - shadow: json['shadow'] as bool? ?? false, - reason: json['reason'] as String?, - ); + user: User.fromJson(json['user'] as Map), + bannedBy: json['banned_by'] == null ? null : User.fromJson(json['banned_by'] as Map), + channel: json['channel'] == null ? null : ChannelModel.fromJson(json['channel'] as Map), + createdAt: json['created_at'] == null ? null : DateTime.parse(json['created_at'] as String), + expires: json['expires'] == null ? null : DateTime.parse(json['expires'] as String), + shadow: json['shadow'] as bool? ?? false, + reason: json['reason'] as String?, +); -Map _$BannedUserToJson(BannedUser instance) => - { - 'user': instance.user.toJson(), - 'banned_by': instance.bannedBy?.toJson(), - 'channel': instance.channel?.toJson(), - 'created_at': instance.createdAt?.toIso8601String(), - 'expires': instance.expires?.toIso8601String(), - 'shadow': instance.shadow, - 'reason': instance.reason, - }; +Map _$BannedUserToJson(BannedUser instance) => { + 'user': instance.user.toJson(), + 'banned_by': instance.bannedBy?.toJson(), + 'channel': instance.channel?.toJson(), + 'created_at': instance.createdAt?.toIso8601String(), + 'expires': instance.expires?.toIso8601String(), + 'shadow': instance.shadow, + 'reason': instance.reason, +}; diff --git a/packages/stream_chat/lib/src/core/models/call_payload.dart b/packages/stream_chat/lib/src/core/models/call_payload.dart deleted file mode 100644 index 2f1fdf206b..0000000000 --- a/packages/stream_chat/lib/src/core/models/call_payload.dart +++ /dev/null @@ -1,73 +0,0 @@ -import 'package:equatable/equatable.dart'; -import 'package:json_annotation/json_annotation.dart'; - -part 'call_payload.g.dart'; - -/// Model containing the information about a call. -@JsonSerializable(createToJson: false) -@Deprecated('Will be removed in the next major version') -class CallPayload extends Equatable { - /// Create a new instance. - const CallPayload({ - required this.id, - required this.provider, - this.agora, - this.hms, - }); - - /// Create a new instance from a [json]. - factory CallPayload.fromJson(Map json) => - _$CallPayloadFromJson(json); - - /// The call id. - final String id; - - /// The call provider. - final String provider; - - /// The payload specific to Agora. - final AgoraPayload? agora; - - /// The payload specific to 100ms. - final HMSPayload? hms; - - @override - List get props => [id, provider, agora, hms]; -} - -/// Payload for Agora call. -@JsonSerializable(createToJson: false) -class AgoraPayload extends Equatable { - /// Create a new instance. - const AgoraPayload({required this.channel}); - - /// Create a new instance from a [json]. - factory AgoraPayload.fromJson(Map json) => - _$AgoraPayloadFromJson(json); - - /// The Agora channel. - final String channel; - - @override - List get props => [channel]; -} - -/// Payload for 100ms call. -@JsonSerializable(createToJson: false) -class HMSPayload extends Equatable { - /// Create a new instance. - const HMSPayload({required this.roomId, required this.roomName}); - - /// Create a new instance from a [json]. - factory HMSPayload.fromJson(Map json) => - _$HMSPayloadFromJson(json); - - /// The id of the 100ms room. - final String roomId; - - /// The name of the 100ms room. - final String roomName; - - @override - List get props => [roomId, roomName]; -} diff --git a/packages/stream_chat/lib/src/core/models/call_payload.g.dart b/packages/stream_chat/lib/src/core/models/call_payload.g.dart deleted file mode 100644 index bc786cc5db..0000000000 --- a/packages/stream_chat/lib/src/core/models/call_payload.g.dart +++ /dev/null @@ -1,27 +0,0 @@ -// GENERATED CODE - DO NOT MODIFY BY HAND - -part of 'call_payload.dart'; - -// ************************************************************************** -// JsonSerializableGenerator -// ************************************************************************** - -CallPayload _$CallPayloadFromJson(Map json) => CallPayload( - id: json['id'] as String, - provider: json['provider'] as String, - agora: json['agora'] == null - ? null - : AgoraPayload.fromJson(json['agora'] as Map), - hms: json['hms'] == null - ? null - : HMSPayload.fromJson(json['hms'] as Map), - ); - -AgoraPayload _$AgoraPayloadFromJson(Map json) => AgoraPayload( - channel: json['channel'] as String, - ); - -HMSPayload _$HMSPayloadFromJson(Map json) => HMSPayload( - roomId: json['room_id'] as String, - roomName: json['room_name'] as String, - ); diff --git a/packages/stream_chat/lib/src/core/models/channel_config.dart b/packages/stream_chat/lib/src/core/models/channel_config.dart index 40c11630ec..32e4760d6c 100644 --- a/packages/stream_chat/lib/src/core/models/channel_config.dart +++ b/packages/stream_chat/lib/src/core/models/channel_config.dart @@ -28,12 +28,12 @@ class ChannelConfig { this.userMessageReminders = false, this.markMessagesPending = false, this.deliveryEvents = false, - }) : createdAt = createdAt ?? DateTime.now(), - updatedAt = updatedAt ?? DateTime.now(); + this.sharedLocations = false, + }) : createdAt = createdAt ?? DateTime.now(), + updatedAt = updatedAt ?? DateTime.now(); /// Create a new instance from a json - factory ChannelConfig.fromJson(Map json) => - _$ChannelConfigFromJson(json); + factory ChannelConfig.fromJson(Map json) => _$ChannelConfigFromJson(json); /// Moderation configuration final String automod; @@ -99,6 +99,9 @@ class ChannelConfig { /// Whether delivery events are enabled for this channel. final bool deliveryEvents; + /// True if shared locations are enabled for this channel. + final bool sharedLocations; + /// Serialize to json Map toJson() => _$ChannelConfigToJson(this); } diff --git a/packages/stream_chat/lib/src/core/models/channel_config.g.dart b/packages/stream_chat/lib/src/core/models/channel_config.g.dart index 38b2d0d24f..9bae9b1ba1 100644 --- a/packages/stream_chat/lib/src/core/models/channel_config.g.dart +++ b/packages/stream_chat/lib/src/core/models/channel_config.g.dart @@ -6,59 +6,52 @@ part of 'channel_config.dart'; // JsonSerializableGenerator // ************************************************************************** -ChannelConfig _$ChannelConfigFromJson(Map json) => - ChannelConfig( - automod: json['automod'] as String? ?? 'flag', - commands: (json['commands'] as List?) - ?.map((e) => Command.fromJson(e as Map)) - .toList() ?? - const [], - connectEvents: json['connect_events'] as bool? ?? false, - createdAt: json['created_at'] == null - ? null - : DateTime.parse(json['created_at'] as String), - updatedAt: json['updated_at'] == null - ? null - : DateTime.parse(json['updated_at'] as String), - maxMessageLength: (json['max_message_length'] as num?)?.toInt() ?? 0, - messageRetention: json['message_retention'] as String? ?? '', - mutes: json['mutes'] as bool? ?? false, - reactions: json['reactions'] as bool? ?? false, - readEvents: json['read_events'] as bool? ?? false, - replies: json['replies'] as bool? ?? false, - search: json['search'] as bool? ?? false, - polls: json['polls'] as bool? ?? false, - typingEvents: json['typing_events'] as bool? ?? false, - uploads: json['uploads'] as bool? ?? false, - urlEnrichment: json['url_enrichment'] as bool? ?? false, - skipLastMsgUpdateForSystemMsgs: - json['skip_last_msg_update_for_system_msgs'] as bool? ?? false, - userMessageReminders: json['user_message_reminders'] as bool? ?? false, - markMessagesPending: json['mark_messages_pending'] as bool? ?? false, - deliveryEvents: json['delivery_events'] as bool? ?? false, - ); +ChannelConfig _$ChannelConfigFromJson(Map json) => ChannelConfig( + automod: json['automod'] as String? ?? 'flag', + commands: + (json['commands'] as List?)?.map((e) => Command.fromJson(e as Map)).toList() ?? + const [], + connectEvents: json['connect_events'] as bool? ?? false, + createdAt: json['created_at'] == null ? null : DateTime.parse(json['created_at'] as String), + updatedAt: json['updated_at'] == null ? null : DateTime.parse(json['updated_at'] as String), + maxMessageLength: (json['max_message_length'] as num?)?.toInt() ?? 0, + messageRetention: json['message_retention'] as String? ?? '', + mutes: json['mutes'] as bool? ?? false, + reactions: json['reactions'] as bool? ?? false, + readEvents: json['read_events'] as bool? ?? false, + replies: json['replies'] as bool? ?? false, + search: json['search'] as bool? ?? false, + polls: json['polls'] as bool? ?? false, + typingEvents: json['typing_events'] as bool? ?? false, + uploads: json['uploads'] as bool? ?? false, + urlEnrichment: json['url_enrichment'] as bool? ?? false, + skipLastMsgUpdateForSystemMsgs: json['skip_last_msg_update_for_system_msgs'] as bool? ?? false, + userMessageReminders: json['user_message_reminders'] as bool? ?? false, + markMessagesPending: json['mark_messages_pending'] as bool? ?? false, + deliveryEvents: json['delivery_events'] as bool? ?? false, + sharedLocations: json['shared_locations'] as bool? ?? false, +); -Map _$ChannelConfigToJson(ChannelConfig instance) => - { - 'automod': instance.automod, - 'commands': instance.commands.map((e) => e.toJson()).toList(), - 'connect_events': instance.connectEvents, - 'created_at': instance.createdAt.toIso8601String(), - 'updated_at': instance.updatedAt.toIso8601String(), - 'max_message_length': instance.maxMessageLength, - 'message_retention': instance.messageRetention, - 'mutes': instance.mutes, - 'reactions': instance.reactions, - 'read_events': instance.readEvents, - 'replies': instance.replies, - 'search': instance.search, - 'polls': instance.polls, - 'typing_events': instance.typingEvents, - 'uploads': instance.uploads, - 'url_enrichment': instance.urlEnrichment, - 'skip_last_msg_update_for_system_msgs': - instance.skipLastMsgUpdateForSystemMsgs, - 'user_message_reminders': instance.userMessageReminders, - 'mark_messages_pending': instance.markMessagesPending, - 'delivery_events': instance.deliveryEvents, - }; +Map _$ChannelConfigToJson(ChannelConfig instance) => { + 'automod': instance.automod, + 'commands': instance.commands.map((e) => e.toJson()).toList(), + 'connect_events': instance.connectEvents, + 'created_at': instance.createdAt.toIso8601String(), + 'updated_at': instance.updatedAt.toIso8601String(), + 'max_message_length': instance.maxMessageLength, + 'message_retention': instance.messageRetention, + 'mutes': instance.mutes, + 'reactions': instance.reactions, + 'read_events': instance.readEvents, + 'replies': instance.replies, + 'search': instance.search, + 'polls': instance.polls, + 'typing_events': instance.typingEvents, + 'uploads': instance.uploads, + 'url_enrichment': instance.urlEnrichment, + 'skip_last_msg_update_for_system_msgs': instance.skipLastMsgUpdateForSystemMsgs, + 'user_message_reminders': instance.userMessageReminders, + 'mark_messages_pending': instance.markMessagesPending, + 'delivery_events': instance.deliveryEvents, + 'shared_locations': instance.sharedLocations, +}; diff --git a/packages/stream_chat/lib/src/core/models/channel_model.dart b/packages/stream_chat/lib/src/core/models/channel_model.dart index 04b1096503..846b91958e 100644 --- a/packages/stream_chat/lib/src/core/models/channel_model.dart +++ b/packages/stream_chat/lib/src/core/models/channel_model.dart @@ -33,33 +33,31 @@ class ChannelModel { DateTime? truncatedAt, this.messageCount, this.filterTags, - }) : assert( - (cid != null && cid.contains(':')) || (id != null && type != null), - 'provide either a cid or an id and type', - ), - id = id ?? cid!.split(':')[1], - type = type ?? cid!.split(':')[0], - cid = cid ?? '$type:$id', - config = config ?? ChannelConfig(), - createdAt = createdAt ?? DateTime.now(), - updatedAt = updatedAt ?? DateTime.now(), - ownCapabilities = ownCapabilities?.map(ChannelCapability.new).toList(), - - // For backwards compatibility, set 'disabled', 'hidden' - // and 'truncated_at' in [extraData]. - extraData = { - ...extraData, - if (disabled != null) 'disabled': disabled, - if (hidden != null) 'hidden': hidden, - if (truncatedAt != null) - 'truncated_at': truncatedAt.toIso8601String(), - }; + }) : assert( + (cid != null && cid.contains(':')) || (id != null && type != null), + 'provide either a cid or an id and type', + ), + id = id ?? cid!.split(':')[1], + type = type ?? cid!.split(':')[0], + cid = cid ?? '$type:$id', + config = config ?? ChannelConfig(), + createdAt = createdAt ?? DateTime.now(), + updatedAt = updatedAt ?? DateTime.now(), + ownCapabilities = ownCapabilities?.map(ChannelCapability.new).toList(), + + // For backwards compatibility, set 'disabled', 'hidden' + // and 'truncated_at' in [extraData]. + extraData = { + ...extraData, + if (disabled != null) 'disabled': disabled, + if (hidden != null) 'hidden': hidden, + if (truncatedAt != null) 'truncated_at': truncatedAt.toIso8601String(), + }; /// Create a new instance from a json - factory ChannelModel.fromJson(Map json) => - _$ChannelModelFromJson( - Serializer.moveToExtraDataFromRoot(json, topLevelFields), - ); + factory ChannelModel.fromJson(Map json) => _$ChannelModelFromJson( + Serializer.moveToExtraDataFromRoot(json, topLevelFields), + ); /// The id of this channel final String id; @@ -190,8 +188,8 @@ class ChannelModel { /// Serialize to json Map toJson() => Serializer.moveFromExtraDataToRoot( - _$ChannelModelToJson(this), - ); + _$ChannelModelToJson(this), + ); /// Creates a copy of [ChannelModel] with specified attributes overridden. ChannelModel copyWith({ @@ -216,35 +214,35 @@ class ChannelModel { DateTime? truncatedAt, int? messageCount, List? filterTags, - }) => - ChannelModel( - id: id ?? this.id, - type: type ?? this.type, - cid: cid ?? this.cid, - ownCapabilities: ownCapabilities ?? this.ownCapabilities, - config: config ?? this.config, - createdBy: createdBy ?? this.createdBy, - frozen: frozen ?? this.frozen, - lastMessageAt: lastMessageAt ?? this.lastMessageAt, - createdAt: createdAt ?? this.createdAt, - updatedAt: updatedAt ?? this.updatedAt, - deletedAt: deletedAt ?? this.deletedAt, - memberCount: memberCount ?? this.memberCount, - members: members ?? this.members, - extraData: extraData ?? this.extraData, - team: team ?? this.team, - cooldown: cooldown ?? this.cooldown, - disabled: disabled ?? extraData?['disabled'] as bool? ?? this.disabled, - hidden: hidden ?? extraData?['hidden'] as bool? ?? this.hidden, - truncatedAt: truncatedAt ?? - (extraData?['truncated_at'] == null - ? null - // ignore: cast_nullable_to_non_nullable - : DateTime.parse(extraData?['truncated_at'] as String)) ?? - this.truncatedAt, - messageCount: messageCount ?? this.messageCount, - filterTags: filterTags ?? this.filterTags, - ); + }) => ChannelModel( + id: id ?? this.id, + type: type ?? this.type, + cid: cid ?? this.cid, + ownCapabilities: ownCapabilities ?? this.ownCapabilities, + config: config ?? this.config, + createdBy: createdBy ?? this.createdBy, + frozen: frozen ?? this.frozen, + lastMessageAt: lastMessageAt ?? this.lastMessageAt, + createdAt: createdAt ?? this.createdAt, + updatedAt: updatedAt ?? this.updatedAt, + deletedAt: deletedAt ?? this.deletedAt, + memberCount: memberCount ?? this.memberCount, + members: members ?? this.members, + extraData: extraData ?? this.extraData, + team: team ?? this.team, + cooldown: cooldown ?? this.cooldown, + disabled: disabled ?? extraData?['disabled'] as bool? ?? this.disabled, + hidden: hidden ?? extraData?['hidden'] as bool? ?? this.hidden, + truncatedAt: + truncatedAt ?? + (extraData?['truncated_at'] == null + ? null + // ignore: cast_nullable_to_non_nullable + : DateTime.parse(extraData?['truncated_at'] as String)) ?? + this.truncatedAt, + messageCount: messageCount ?? this.messageCount, + filterTags: filterTags ?? this.filterTags, + ); /// Returns a new [ChannelModel] that is a combination of this channelModel /// and the given [other] channelModel. @@ -393,4 +391,7 @@ extension type const ChannelCapability(String capability) implements String { /// Ability to query poll votes. static const queryPollVotes = ChannelCapability('query-poll-votes'); + + /// Ability to share location. + static const shareLocation = ChannelCapability('share-location'); } diff --git a/packages/stream_chat/lib/src/core/models/channel_model.g.dart b/packages/stream_chat/lib/src/core/models/channel_model.g.dart index cc64ccc034..d2c1ce62eb 100644 --- a/packages/stream_chat/lib/src/core/models/channel_model.g.dart +++ b/packages/stream_chat/lib/src/core/models/channel_model.g.dart @@ -7,49 +7,30 @@ part of 'channel_model.dart'; // ************************************************************************** ChannelModel _$ChannelModelFromJson(Map json) => ChannelModel( - id: json['id'] as String?, - type: json['type'] as String?, - cid: json['cid'] as String?, - ownCapabilities: (json['own_capabilities'] as List?) - ?.map((e) => e as String) - .toList(), - config: json['config'] == null - ? null - : ChannelConfig.fromJson(json['config'] as Map), - createdBy: json['created_by'] == null - ? null - : User.fromJson(json['created_by'] as Map), - frozen: json['frozen'] as bool? ?? false, - lastMessageAt: json['last_message_at'] == null - ? null - : DateTime.parse(json['last_message_at'] as String), - createdAt: json['created_at'] == null - ? null - : DateTime.parse(json['created_at'] as String), - updatedAt: json['updated_at'] == null - ? null - : DateTime.parse(json['updated_at'] as String), - deletedAt: json['deleted_at'] == null - ? null - : DateTime.parse(json['deleted_at'] as String), - memberCount: (json['member_count'] as num?)?.toInt() ?? 0, - members: (json['members'] as List?) - ?.map((e) => Member.fromJson(e as Map)) - .toList(), - extraData: json['extra_data'] as Map? ?? const {}, - team: json['team'] as String?, - cooldown: (json['cooldown'] as num?)?.toInt() ?? 0, - messageCount: (json['message_count'] as num?)?.toInt(), - filterTags: (json['filter_tags'] as List?) - ?.map((e) => e as String) - .toList(), - ); + id: json['id'] as String?, + type: json['type'] as String?, + cid: json['cid'] as String?, + ownCapabilities: (json['own_capabilities'] as List?)?.map((e) => e as String).toList(), + config: json['config'] == null ? null : ChannelConfig.fromJson(json['config'] as Map), + createdBy: json['created_by'] == null ? null : User.fromJson(json['created_by'] as Map), + frozen: json['frozen'] as bool? ?? false, + lastMessageAt: json['last_message_at'] == null ? null : DateTime.parse(json['last_message_at'] as String), + createdAt: json['created_at'] == null ? null : DateTime.parse(json['created_at'] as String), + updatedAt: json['updated_at'] == null ? null : DateTime.parse(json['updated_at'] as String), + deletedAt: json['deleted_at'] == null ? null : DateTime.parse(json['deleted_at'] as String), + memberCount: (json['member_count'] as num?)?.toInt() ?? 0, + members: (json['members'] as List?)?.map((e) => Member.fromJson(e as Map)).toList(), + extraData: json['extra_data'] as Map? ?? const {}, + team: json['team'] as String?, + cooldown: (json['cooldown'] as num?)?.toInt() ?? 0, + messageCount: (json['message_count'] as num?)?.toInt(), + filterTags: (json['filter_tags'] as List?)?.map((e) => e as String).toList(), +); -Map _$ChannelModelToJson(ChannelModel instance) => - { - 'id': instance.id, - 'type': instance.type, - 'frozen': instance.frozen, - 'cooldown': instance.cooldown, - 'extra_data': instance.extraData, - }; +Map _$ChannelModelToJson(ChannelModel instance) => { + 'id': instance.id, + 'type': instance.type, + 'frozen': instance.frozen, + 'cooldown': instance.cooldown, + 'extra_data': instance.extraData, +}; diff --git a/packages/stream_chat/lib/src/core/models/channel_mute.dart b/packages/stream_chat/lib/src/core/models/channel_mute.dart index c0d29a57c0..a393ae14da 100644 --- a/packages/stream_chat/lib/src/core/models/channel_mute.dart +++ b/packages/stream_chat/lib/src/core/models/channel_mute.dart @@ -17,8 +17,7 @@ class ChannelMute { }); /// Create a new instance from a json - factory ChannelMute.fromJson(Map json) => - _$ChannelMuteFromJson(json); + factory ChannelMute.fromJson(Map json) => _$ChannelMuteFromJson(json); /// The user that performed the muting action final User user; diff --git a/packages/stream_chat/lib/src/core/models/channel_mute.g.dart b/packages/stream_chat/lib/src/core/models/channel_mute.g.dart index f0b973fcbf..4b3576c0ae 100644 --- a/packages/stream_chat/lib/src/core/models/channel_mute.g.dart +++ b/packages/stream_chat/lib/src/core/models/channel_mute.g.dart @@ -7,20 +7,17 @@ part of 'channel_mute.dart'; // ************************************************************************** ChannelMute _$ChannelMuteFromJson(Map json) => ChannelMute( - user: User.fromJson(json['user'] as Map), - channel: ChannelModel.fromJson(json['channel'] as Map), - createdAt: DateTime.parse(json['created_at'] as String), - updatedAt: DateTime.parse(json['updated_at'] as String), - expires: json['expires'] == null - ? null - : DateTime.parse(json['expires'] as String), - ); + user: User.fromJson(json['user'] as Map), + channel: ChannelModel.fromJson(json['channel'] as Map), + createdAt: DateTime.parse(json['created_at'] as String), + updatedAt: DateTime.parse(json['updated_at'] as String), + expires: json['expires'] == null ? null : DateTime.parse(json['expires'] as String), +); -Map _$ChannelMuteToJson(ChannelMute instance) => - { - 'user': instance.user.toJson(), - 'channel': instance.channel.toJson(), - 'created_at': instance.createdAt.toIso8601String(), - 'updated_at': instance.updatedAt.toIso8601String(), - 'expires': instance.expires?.toIso8601String(), - }; +Map _$ChannelMuteToJson(ChannelMute instance) => { + 'user': instance.user.toJson(), + 'channel': instance.channel.toJson(), + 'created_at': instance.createdAt.toIso8601String(), + 'updated_at': instance.updatedAt.toIso8601String(), + 'expires': instance.expires?.toIso8601String(), +}; diff --git a/packages/stream_chat/lib/src/core/models/channel_state.dart b/packages/stream_chat/lib/src/core/models/channel_state.dart index e3dc81ad27..718e4b74f5 100644 --- a/packages/stream_chat/lib/src/core/models/channel_state.dart +++ b/packages/stream_chat/lib/src/core/models/channel_state.dart @@ -2,6 +2,7 @@ import 'package:json_annotation/json_annotation.dart'; import 'package:stream_chat/src/core/models/channel_model.dart'; import 'package:stream_chat/src/core/models/comparable_field.dart'; import 'package:stream_chat/src/core/models/draft.dart'; +import 'package:stream_chat/src/core/models/location.dart'; import 'package:stream_chat/src/core/models/member.dart'; import 'package:stream_chat/src/core/models/message.dart'; import 'package:stream_chat/src/core/models/push_preference.dart'; @@ -32,6 +33,7 @@ class ChannelState implements ComparableFieldProvider { this.draft, this.pendingMessages, this.pushPreferences, + this.activeLiveLocations, }); /// The channel to which this state belongs @@ -86,9 +88,11 @@ class ChannelState implements ComparableFieldProvider { /// The push preferences for this channel if it exists. final ChannelPushPreference? pushPreferences; + /// The list of active live locations in the channel. + final List? activeLiveLocations; + /// Create a new instance from a json - static ChannelState fromJson(Map json) => - _$ChannelStateFromJson(json); + static ChannelState fromJson(Map json) => _$ChannelStateFromJson(json); /// Serialize to json Map toJson() => _$ChannelStateToJson(this); @@ -106,20 +110,21 @@ class ChannelState implements ComparableFieldProvider { Object? draft = _nullConst, List? pendingMessages, ChannelPushPreference? pushPreferences, - }) => - ChannelState( - channel: channel ?? this.channel, - messages: messages ?? this.messages, - members: members ?? this.members, - pinnedMessages: pinnedMessages ?? this.pinnedMessages, - watcherCount: watcherCount ?? this.watcherCount, - watchers: watchers ?? this.watchers, - read: read ?? this.read, - membership: membership ?? this.membership, - draft: draft == _nullConst ? this.draft : draft as Draft?, - pendingMessages: pendingMessages ?? this.pendingMessages, - pushPreferences: pushPreferences ?? this.pushPreferences, - ); + List? activeLiveLocations, + }) => ChannelState( + channel: channel ?? this.channel, + messages: messages ?? this.messages, + members: members ?? this.members, + pinnedMessages: pinnedMessages ?? this.pinnedMessages, + watcherCount: watcherCount ?? this.watcherCount, + watchers: watchers ?? this.watchers, + read: read ?? this.read, + membership: membership ?? this.membership, + draft: draft == _nullConst ? this.draft : draft as Draft?, + pendingMessages: pendingMessages ?? this.pendingMessages, + pushPreferences: pushPreferences ?? this.pushPreferences, + activeLiveLocations: activeLiveLocations ?? this.activeLiveLocations, + ); @override ComparableField? getComparableField(String sortKey) { diff --git a/packages/stream_chat/lib/src/core/models/channel_state.g.dart b/packages/stream_chat/lib/src/core/models/channel_state.g.dart index b7499ba8b9..a3c6c2692d 100644 --- a/packages/stream_chat/lib/src/core/models/channel_state.g.dart +++ b/packages/stream_chat/lib/src/core/models/channel_state.g.dart @@ -7,55 +7,39 @@ part of 'channel_state.dart'; // ************************************************************************** ChannelState _$ChannelStateFromJson(Map json) => ChannelState( - channel: json['channel'] == null - ? null - : ChannelModel.fromJson(json['channel'] as Map), - messages: (json['messages'] as List?) - ?.map((e) => Message.fromJson(e as Map)) - .toList(), - members: (json['members'] as List?) - ?.map((e) => Member.fromJson(e as Map)) - .toList(), - pinnedMessages: (json['pinned_messages'] as List?) - ?.map((e) => Message.fromJson(e as Map)) - .toList(), - watcherCount: (json['watcher_count'] as num?)?.toInt(), - watchers: (json['watchers'] as List?) - ?.map((e) => User.fromJson(e as Map)) - .toList(), - read: (json['read'] as List?) - ?.map((e) => Read.fromJson(e as Map)) - .toList(), - membership: json['membership'] == null - ? null - : Member.fromJson(json['membership'] as Map), - draft: json['draft'] == null - ? null - : Draft.fromJson(json['draft'] as Map), - pendingMessages: - (ChannelState._pendingMessagesReadValue(json, 'pending_messages') - as List?) - ?.map((e) => Message.fromJson(e as Map)) - .toList(), - pushPreferences: json['push_preferences'] == null - ? null - : ChannelPushPreference.fromJson( - json['push_preferences'] as Map), - ); + channel: json['channel'] == null ? null : ChannelModel.fromJson(json['channel'] as Map), + messages: (json['messages'] as List?)?.map((e) => Message.fromJson(e as Map)).toList(), + members: (json['members'] as List?)?.map((e) => Member.fromJson(e as Map)).toList(), + pinnedMessages: (json['pinned_messages'] as List?) + ?.map((e) => Message.fromJson(e as Map)) + .toList(), + watcherCount: (json['watcher_count'] as num?)?.toInt(), + watchers: (json['watchers'] as List?)?.map((e) => User.fromJson(e as Map)).toList(), + read: (json['read'] as List?)?.map((e) => Read.fromJson(e as Map)).toList(), + membership: json['membership'] == null ? null : Member.fromJson(json['membership'] as Map), + draft: json['draft'] == null ? null : Draft.fromJson(json['draft'] as Map), + pendingMessages: (ChannelState._pendingMessagesReadValue(json, 'pending_messages') as List?) + ?.map((e) => Message.fromJson(e as Map)) + .toList(), + pushPreferences: json['push_preferences'] == null + ? null + : ChannelPushPreference.fromJson(json['push_preferences'] as Map), + activeLiveLocations: (json['active_live_locations'] as List?) + ?.map((e) => Location.fromJson(e as Map)) + .toList(), +); -Map _$ChannelStateToJson(ChannelState instance) => - { - 'channel': instance.channel?.toJson(), - 'messages': instance.messages?.map((e) => e.toJson()).toList(), - 'members': instance.members?.map((e) => e.toJson()).toList(), - 'pinned_messages': - instance.pinnedMessages?.map((e) => e.toJson()).toList(), - 'watcher_count': instance.watcherCount, - 'watchers': instance.watchers?.map((e) => e.toJson()).toList(), - 'read': instance.read?.map((e) => e.toJson()).toList(), - 'membership': instance.membership?.toJson(), - 'draft': instance.draft?.toJson(), - 'pending_messages': - instance.pendingMessages?.map((e) => e.toJson()).toList(), - 'push_preferences': instance.pushPreferences?.toJson(), - }; +Map _$ChannelStateToJson(ChannelState instance) => { + 'channel': instance.channel?.toJson(), + 'messages': instance.messages?.map((e) => e.toJson()).toList(), + 'members': instance.members?.map((e) => e.toJson()).toList(), + 'pinned_messages': instance.pinnedMessages?.map((e) => e.toJson()).toList(), + 'watcher_count': instance.watcherCount, + 'watchers': instance.watchers?.map((e) => e.toJson()).toList(), + 'read': instance.read?.map((e) => e.toJson()).toList(), + 'membership': instance.membership?.toJson(), + 'draft': instance.draft?.toJson(), + 'pending_messages': instance.pendingMessages?.map((e) => e.toJson()).toList(), + 'push_preferences': instance.pushPreferences?.toJson(), + 'active_live_locations': instance.activeLiveLocations?.map((e) => e.toJson()).toList(), +}; diff --git a/packages/stream_chat/lib/src/core/models/command.dart b/packages/stream_chat/lib/src/core/models/command.dart index 5ba0043c18..0247637560 100644 --- a/packages/stream_chat/lib/src/core/models/command.dart +++ b/packages/stream_chat/lib/src/core/models/command.dart @@ -13,8 +13,7 @@ class Command { }); /// Create a new instance from a json - factory Command.fromJson(Map json) => - _$CommandFromJson(json); + factory Command.fromJson(Map json) => _$CommandFromJson(json); /// The name of the command final String name; diff --git a/packages/stream_chat/lib/src/core/models/command.g.dart b/packages/stream_chat/lib/src/core/models/command.g.dart index 0fe9d54c6e..24c9362156 100644 --- a/packages/stream_chat/lib/src/core/models/command.g.dart +++ b/packages/stream_chat/lib/src/core/models/command.g.dart @@ -7,13 +7,13 @@ part of 'command.dart'; // ************************************************************************** Command _$CommandFromJson(Map json) => Command( - name: json['name'] as String, - description: json['description'] as String, - args: json['args'] as String, - ); + name: json['name'] as String, + description: json['description'] as String, + args: json['args'] as String, +); Map _$CommandToJson(Command instance) => { - 'name': instance.name, - 'description': instance.description, - 'args': instance.args, - }; + 'name': instance.name, + 'description': instance.description, + 'args': instance.args, +}; diff --git a/packages/stream_chat/lib/src/core/models/comparable_field.dart b/packages/stream_chat/lib/src/core/models/comparable_field.dart index f43df58a33..5b192541b2 100644 --- a/packages/stream_chat/lib/src/core/models/comparable_field.dart +++ b/packages/stream_chat/lib/src/core/models/comparable_field.dart @@ -28,7 +28,7 @@ class ComparableField implements Comparable> { (final DateTime a, final DateTime b) => a.compareTo(b), (final bool a, final bool b) when a == b => 0, (final bool a, final bool b) => a && !b ? 1 : -1, // true > false - _ => 0 // All comparisons were equal or incomparable types + _ => 0, // All comparisons were equal or incomparable types }; } } diff --git a/packages/stream_chat/lib/src/core/models/device.g.dart b/packages/stream_chat/lib/src/core/models/device.g.dart index 4de1f064af..2725a082f1 100644 --- a/packages/stream_chat/lib/src/core/models/device.g.dart +++ b/packages/stream_chat/lib/src/core/models/device.g.dart @@ -7,11 +7,11 @@ part of 'device.dart'; // ************************************************************************** Device _$DeviceFromJson(Map json) => Device( - id: json['id'] as String, - pushProvider: json['push_provider'] as String, - ); + id: json['id'] as String, + pushProvider: json['push_provider'] as String, +); Map _$DeviceToJson(Device instance) => { - 'id': instance.id, - 'push_provider': instance.pushProvider, - }; + 'id': instance.id, + 'push_provider': instance.pushProvider, +}; diff --git a/packages/stream_chat/lib/src/core/models/draft.dart b/packages/stream_chat/lib/src/core/models/draft.dart index fa4520114c..c6fb6139b2 100644 --- a/packages/stream_chat/lib/src/core/models/draft.dart +++ b/packages/stream_chat/lib/src/core/models/draft.dart @@ -73,14 +73,14 @@ class Draft extends Equatable implements ComparableFieldProvider { @override List get props => [ - channelCid, - createdAt, - message, - channel, - parentId, - parentMessage, - quotedMessage, - ]; + channelCid, + createdAt, + message, + channel, + parentId, + parentMessage, + quotedMessage, + ]; @override ComparableField? getComparableField(String sortKey) { diff --git a/packages/stream_chat/lib/src/core/models/draft.g.dart b/packages/stream_chat/lib/src/core/models/draft.g.dart index 9a7b7de9ce..1ea7feafc3 100644 --- a/packages/stream_chat/lib/src/core/models/draft.g.dart +++ b/packages/stream_chat/lib/src/core/models/draft.g.dart @@ -7,29 +7,25 @@ part of 'draft.dart'; // ************************************************************************** Draft _$DraftFromJson(Map json) => Draft( - channelCid: json['channel_cid'] as String, - createdAt: DateTime.parse(json['created_at'] as String), - message: DraftMessage.fromJson(json['message'] as Map), - channel: json['channel'] == null - ? null - : ChannelModel.fromJson(json['channel'] as Map), - parentId: json['parent_id'] as String?, - parentMessage: json['parent_message'] == null - ? null - : Message.fromJson(json['parent_message'] as Map), - quotedMessage: json['quoted_message'] == null - ? null - : Message.fromJson(json['quoted_message'] as Map), - ); + channelCid: json['channel_cid'] as String, + createdAt: DateTime.parse(json['created_at'] as String), + message: DraftMessage.fromJson(json['message'] as Map), + channel: json['channel'] == null ? null : ChannelModel.fromJson(json['channel'] as Map), + parentId: json['parent_id'] as String?, + parentMessage: json['parent_message'] == null + ? null + : Message.fromJson(json['parent_message'] as Map), + quotedMessage: json['quoted_message'] == null + ? null + : Message.fromJson(json['quoted_message'] as Map), +); Map _$DraftToJson(Draft instance) => { - 'channel_cid': instance.channelCid, - 'created_at': instance.createdAt.toIso8601String(), - 'message': instance.message.toJson(), - if (instance.channel?.toJson() case final value?) 'channel': value, - if (instance.parentId case final value?) 'parent_id': value, - if (instance.parentMessage?.toJson() case final value?) - 'parent_message': value, - if (instance.quotedMessage?.toJson() case final value?) - 'quoted_message': value, - }; + 'channel_cid': instance.channelCid, + 'created_at': instance.createdAt.toIso8601String(), + 'message': instance.message.toJson(), + if (instance.channel?.toJson() case final value?) 'channel': value, + if (instance.parentId case final value?) 'parent_id': value, + if (instance.parentMessage?.toJson() case final value?) 'parent_message': value, + if (instance.quotedMessage?.toJson() case final value?) 'quoted_message': value, +}; diff --git a/packages/stream_chat/lib/src/core/models/draft_message.dart b/packages/stream_chat/lib/src/core/models/draft_message.dart index 13c373475b..b6ae3dede0 100644 --- a/packages/stream_chat/lib/src/core/models/draft_message.dart +++ b/packages/stream_chat/lib/src/core/models/draft_message.dart @@ -28,16 +28,15 @@ class DraftMessage extends Equatable { this.poll, String? pollId, this.extraData = const {}, - }) : id = id ?? const Uuid().v4(), - type = MessageType(type), - _quotedMessageId = quotedMessageId, - _pollId = pollId; + }) : id = id ?? const Uuid().v4(), + type = MessageType(type), + _quotedMessageId = quotedMessageId, + _pollId = pollId; /// Create a new instance from JSON. - factory DraftMessage.fromJson(Map json) => - _$DraftMessageFromJson( - Serializer.moveToExtraDataFromRoot(json, topLevelFields), - ); + factory DraftMessage.fromJson(Map json) => _$DraftMessageFromJson( + Serializer.moveToExtraDataFromRoot(json, topLevelFields), + ); /// The message ID. This is either created by Stream or set client side when /// the message is added. @@ -167,19 +166,19 @@ class DraftMessage extends Equatable { @override List get props => [ - id, - text, - type, - attachments, - parentId, - showInChannel, - mentionedUsers, - quotedMessageId, - silent, - command, - pollId, - extraData, - ]; + id, + text, + type, + attachments, + parentId, + showInChannel, + mentionedUsers, + quotedMessageId, + silent, + command, + pollId, + extraData, + ]; } /// Extension on [Message] to convert it to a [DraftMessage]. diff --git a/packages/stream_chat/lib/src/core/models/draft_message.g.dart b/packages/stream_chat/lib/src/core/models/draft_message.g.dart index 910ca3b090..b1b96bf505 100644 --- a/packages/stream_chat/lib/src/core/models/draft_message.g.dart +++ b/packages/stream_chat/lib/src/core/models/draft_message.g.dart @@ -7,47 +7,38 @@ part of 'draft_message.dart'; // ************************************************************************** DraftMessage _$DraftMessageFromJson(Map json) => DraftMessage( - id: json['id'] as String?, - text: json['text'] as String?, - type: json['type'] == null - ? MessageType.regular - : MessageType.fromJson(json['type'] as String), - attachments: (json['attachments'] as List?) - ?.map((e) => Attachment.fromJson(e as Map)) - .toList() ?? - const [], - parentId: json['parent_id'] as String?, - showInChannel: json['show_in_channel'] as bool?, - mentionedUsers: (json['mentioned_users'] as List?) - ?.map((e) => User.fromJson(e as Map)) - .toList() ?? - const [], - quotedMessage: json['quoted_message'] == null - ? null - : Message.fromJson(json['quoted_message'] as Map), - quotedMessageId: json['quoted_message_id'] as String?, - silent: json['silent'] as bool? ?? false, - command: json['command'] as String?, - poll: json['poll'] == null - ? null - : Poll.fromJson(json['poll'] as Map), - pollId: json['poll_id'] as String?, - extraData: json['extra_data'] as Map? ?? const {}, - ); + id: json['id'] as String?, + text: json['text'] as String?, + type: json['type'] == null ? MessageType.regular : MessageType.fromJson(json['type'] as String), + attachments: + (json['attachments'] as List?)?.map((e) => Attachment.fromJson(e as Map)).toList() ?? + const [], + parentId: json['parent_id'] as String?, + showInChannel: json['show_in_channel'] as bool?, + mentionedUsers: + (json['mentioned_users'] as List?)?.map((e) => User.fromJson(e as Map)).toList() ?? + const [], + quotedMessage: json['quoted_message'] == null + ? null + : Message.fromJson(json['quoted_message'] as Map), + quotedMessageId: json['quoted_message_id'] as String?, + silent: json['silent'] as bool? ?? false, + command: json['command'] as String?, + poll: json['poll'] == null ? null : Poll.fromJson(json['poll'] as Map), + pollId: json['poll_id'] as String?, + extraData: json['extra_data'] as Map? ?? const {}, +); -Map _$DraftMessageToJson(DraftMessage instance) => - { - 'id': instance.id, - if (instance.text case final value?) 'text': value, - if (MessageType.toJson(instance.type) case final value?) 'type': value, - 'attachments': instance.attachments.map((e) => e.toJson()).toList(), - if (instance.parentId case final value?) 'parent_id': value, - if (instance.showInChannel case final value?) 'show_in_channel': value, - if (User.toIds(instance.mentionedUsers) case final value?) - 'mentioned_users': value, - if (instance.quotedMessageId case final value?) - 'quoted_message_id': value, - 'silent': instance.silent, - if (instance.pollId case final value?) 'poll_id': value, - 'extra_data': instance.extraData, - }; +Map _$DraftMessageToJson(DraftMessage instance) => { + 'id': instance.id, + if (instance.text case final value?) 'text': value, + if (MessageType.toJson(instance.type) case final value?) 'type': value, + 'attachments': instance.attachments.map((e) => e.toJson()).toList(), + if (instance.parentId case final value?) 'parent_id': value, + if (instance.showInChannel case final value?) 'show_in_channel': value, + if (User.toIds(instance.mentionedUsers) case final value?) 'mentioned_users': value, + if (instance.quotedMessageId case final value?) 'quoted_message_id': value, + 'silent': instance.silent, + if (instance.pollId case final value?) 'poll_id': value, + 'extra_data': instance.extraData, +}; diff --git a/packages/stream_chat/lib/src/core/models/event.dart b/packages/stream_chat/lib/src/core/models/event.dart index 57d2aaeec2..bc2eb27986 100644 --- a/packages/stream_chat/lib/src/core/models/event.dart +++ b/packages/stream_chat/lib/src/core/models/event.dart @@ -30,6 +30,7 @@ class Event { this.channelLastMessageAt, this.parentId, this.hardDelete, + this.deletedForMe, this.aiState, this.aiMessage, this.messageId, @@ -51,11 +52,12 @@ class Event { }) : createdAt = createdAt?.toUtc() ?? DateTime.now().toUtc(); /// Create a new instance from a json - factory Event.fromJson(Map json) => - _$EventFromJson(Serializer.moveToExtraDataFromRoot( - json, - topLevelFields, - )); + factory Event.fromJson(Map json) => _$EventFromJson( + Serializer.moveToExtraDataFromRoot( + json, + topLevelFields, + ), + ); /// The type of the event /// [EventType] contains some predefined constant types @@ -125,6 +127,9 @@ class Event { /// This is true if the message has been hard deleted final bool? hardDelete; + /// Whether the message was deleted only for the current user. + final bool? deletedForMe; + /// The current state of the AI assistant. @JsonKey(unknownEnumValue: AITypingState.idle) final AITypingState? aiState; @@ -201,6 +206,7 @@ class Event { 'channel_last_message_at', 'parent_id', 'hard_delete', + 'deleted_for_me', 'is_local', 'ai_state', 'ai_message', @@ -222,8 +228,8 @@ class Event { /// Serialize to json Map toJson() => Serializer.moveFromExtraDataToRoot( - _$EventToJson(this), - ); + _$EventToJson(this), + ); /// Creates a copy of [Event] with specified attributes overridden. Event copyWith({ @@ -248,6 +254,7 @@ class Event { bool? online, String? parentId, bool? hardDelete, + bool? deletedForMe, AITypingState? aiState, String? aiMessage, String? messageId, @@ -265,50 +272,48 @@ class Event { DateTime? lastDeliveredAt, String? lastDeliveredMessageId, Map? extraData, - }) => - Event( - type: type ?? this.type, - userId: userId ?? this.userId, - cid: cid ?? this.cid, - connectionId: connectionId ?? this.connectionId, - createdAt: createdAt ?? this.createdAt, - me: me ?? this.me, - user: user ?? this.user, - message: message ?? this.message, - poll: poll ?? this.poll, - pollVote: pollVote ?? this.pollVote, - totalUnreadCount: totalUnreadCount ?? this.totalUnreadCount, - unreadChannels: unreadChannels ?? this.unreadChannels, - reaction: reaction ?? this.reaction, - online: online ?? this.online, - channel: channel ?? this.channel, - member: member ?? this.member, - channelId: channelId ?? this.channelId, - channelType: channelType ?? this.channelType, - channelLastMessageAt: channelLastMessageAt ?? this.channelLastMessageAt, - parentId: parentId ?? this.parentId, - hardDelete: hardDelete ?? this.hardDelete, - aiState: aiState ?? this.aiState, - aiMessage: aiMessage ?? this.aiMessage, - messageId: messageId ?? this.messageId, - thread: thread ?? this.thread, - unreadThreadMessages: unreadThreadMessages ?? this.unreadThreadMessages, - unreadThreads: unreadThreads ?? this.unreadThreads, - lastReadAt: lastReadAt ?? this.lastReadAt, - unreadMessages: unreadMessages ?? this.unreadMessages, - lastReadMessageId: lastReadMessageId ?? this.lastReadMessageId, - draft: draft ?? this.draft, - reminder: reminder ?? this.reminder, - pushPreference: pushPreference ?? this.pushPreference, - channelPushPreference: - channelPushPreference ?? this.channelPushPreference, - channelMessageCount: channelMessageCount ?? this.channelMessageCount, - lastDeliveredAt: lastDeliveredAt ?? this.lastDeliveredAt, - lastDeliveredMessageId: - lastDeliveredMessageId ?? this.lastDeliveredMessageId, - isLocal: isLocal, - extraData: extraData ?? this.extraData, - ); + }) => Event( + type: type ?? this.type, + userId: userId ?? this.userId, + cid: cid ?? this.cid, + connectionId: connectionId ?? this.connectionId, + createdAt: createdAt ?? this.createdAt, + me: me ?? this.me, + user: user ?? this.user, + message: message ?? this.message, + poll: poll ?? this.poll, + pollVote: pollVote ?? this.pollVote, + totalUnreadCount: totalUnreadCount ?? this.totalUnreadCount, + unreadChannels: unreadChannels ?? this.unreadChannels, + reaction: reaction ?? this.reaction, + online: online ?? this.online, + channel: channel ?? this.channel, + member: member ?? this.member, + channelId: channelId ?? this.channelId, + channelType: channelType ?? this.channelType, + channelLastMessageAt: channelLastMessageAt ?? this.channelLastMessageAt, + parentId: parentId ?? this.parentId, + hardDelete: hardDelete ?? this.hardDelete, + deletedForMe: deletedForMe ?? this.deletedForMe, + aiState: aiState ?? this.aiState, + aiMessage: aiMessage ?? this.aiMessage, + messageId: messageId ?? this.messageId, + thread: thread ?? this.thread, + unreadThreadMessages: unreadThreadMessages ?? this.unreadThreadMessages, + unreadThreads: unreadThreads ?? this.unreadThreads, + lastReadAt: lastReadAt ?? this.lastReadAt, + unreadMessages: unreadMessages ?? this.unreadMessages, + lastReadMessageId: lastReadMessageId ?? this.lastReadMessageId, + draft: draft ?? this.draft, + reminder: reminder ?? this.reminder, + pushPreference: pushPreference ?? this.pushPreference, + channelPushPreference: channelPushPreference ?? this.channelPushPreference, + channelMessageCount: channelMessageCount ?? this.channelMessageCount, + lastDeliveredAt: lastDeliveredAt ?? this.lastDeliveredAt, + lastDeliveredMessageId: lastDeliveredMessageId ?? this.lastDeliveredMessageId, + isLocal: isLocal, + extraData: extraData ?? this.extraData, + ); } /// {@template aiState} diff --git a/packages/stream_chat/lib/src/core/models/event.g.dart b/packages/stream_chat/lib/src/core/models/event.g.dart index 7bc820b33e..c3aa215012 100644 --- a/packages/stream_chat/lib/src/core/models/event.g.dart +++ b/packages/stream_chat/lib/src/core/models/event.g.dart @@ -7,136 +7,96 @@ part of 'event.dart'; // ************************************************************************** Event _$EventFromJson(Map json) => Event( - type: json['type'] as String? ?? 'local.event', - userId: json['user_id'] as String?, - cid: json['cid'] as String?, - connectionId: json['connection_id'] as String?, - createdAt: json['created_at'] == null - ? null - : DateTime.parse(json['created_at'] as String), - me: json['me'] == null - ? null - : OwnUser.fromJson(json['me'] as Map), - user: json['user'] == null - ? null - : User.fromJson(json['user'] as Map), - message: json['message'] == null - ? null - : Message.fromJson(json['message'] as Map), - poll: json['poll'] == null - ? null - : Poll.fromJson(json['poll'] as Map), - pollVote: json['poll_vote'] == null - ? null - : PollVote.fromJson(json['poll_vote'] as Map), - totalUnreadCount: (json['total_unread_count'] as num?)?.toInt(), - unreadChannels: (json['unread_channels'] as num?)?.toInt(), - reaction: json['reaction'] == null - ? null - : Reaction.fromJson(json['reaction'] as Map), - online: json['online'] as bool?, - channel: json['channel'] == null - ? null - : ChannelModel.fromJson(json['channel'] as Map), - member: json['member'] == null - ? null - : Member.fromJson(json['member'] as Map), - channelId: json['channel_id'] as String?, - channelType: json['channel_type'] as String?, - channelLastMessageAt: json['channel_last_message_at'] == null - ? null - : DateTime.parse(json['channel_last_message_at'] as String), - parentId: json['parent_id'] as String?, - hardDelete: json['hard_delete'] as bool?, - aiState: $enumDecodeNullable(_$AITypingStateEnumMap, json['ai_state'], - unknownValue: AITypingState.idle), - aiMessage: json['ai_message'] as String?, - messageId: json['message_id'] as String?, - thread: json['thread'] == null - ? null - : Thread.fromJson(json['thread'] as Map), - unreadThreadMessages: (json['unread_thread_messages'] as num?)?.toInt(), - unreadThreads: (json['unread_threads'] as num?)?.toInt(), - lastReadAt: json['last_read_at'] == null - ? null - : DateTime.parse(json['last_read_at'] as String), - unreadMessages: (json['unread_messages'] as num?)?.toInt(), - lastReadMessageId: json['last_read_message_id'] as String?, - draft: json['draft'] == null - ? null - : Draft.fromJson(json['draft'] as Map), - reminder: json['reminder'] == null - ? null - : MessageReminder.fromJson(json['reminder'] as Map), - pushPreference: json['push_preference'] == null - ? null - : PushPreference.fromJson( - json['push_preference'] as Map), - channelPushPreference: json['channel_push_preference'] == null - ? null - : ChannelPushPreference.fromJson( - json['channel_push_preference'] as Map), - channelMessageCount: (json['channel_message_count'] as num?)?.toInt(), - lastDeliveredAt: json['last_delivered_at'] == null - ? null - : DateTime.parse(json['last_delivered_at'] as String), - lastDeliveredMessageId: json['last_delivered_message_id'] as String?, - extraData: json['extra_data'] as Map? ?? const {}, - isLocal: json['is_local'] as bool? ?? false, - ); + type: json['type'] as String? ?? 'local.event', + userId: json['user_id'] as String?, + cid: json['cid'] as String?, + connectionId: json['connection_id'] as String?, + createdAt: json['created_at'] == null ? null : DateTime.parse(json['created_at'] as String), + me: json['me'] == null ? null : OwnUser.fromJson(json['me'] as Map), + user: json['user'] == null ? null : User.fromJson(json['user'] as Map), + message: json['message'] == null ? null : Message.fromJson(json['message'] as Map), + poll: json['poll'] == null ? null : Poll.fromJson(json['poll'] as Map), + pollVote: json['poll_vote'] == null ? null : PollVote.fromJson(json['poll_vote'] as Map), + totalUnreadCount: (json['total_unread_count'] as num?)?.toInt(), + unreadChannels: (json['unread_channels'] as num?)?.toInt(), + reaction: json['reaction'] == null ? null : Reaction.fromJson(json['reaction'] as Map), + online: json['online'] as bool?, + channel: json['channel'] == null ? null : ChannelModel.fromJson(json['channel'] as Map), + member: json['member'] == null ? null : Member.fromJson(json['member'] as Map), + channelId: json['channel_id'] as String?, + channelType: json['channel_type'] as String?, + channelLastMessageAt: json['channel_last_message_at'] == null + ? null + : DateTime.parse(json['channel_last_message_at'] as String), + parentId: json['parent_id'] as String?, + hardDelete: json['hard_delete'] as bool?, + deletedForMe: json['deleted_for_me'] as bool?, + aiState: $enumDecodeNullable(_$AITypingStateEnumMap, json['ai_state'], unknownValue: AITypingState.idle), + aiMessage: json['ai_message'] as String?, + messageId: json['message_id'] as String?, + thread: json['thread'] == null ? null : Thread.fromJson(json['thread'] as Map), + unreadThreadMessages: (json['unread_thread_messages'] as num?)?.toInt(), + unreadThreads: (json['unread_threads'] as num?)?.toInt(), + lastReadAt: json['last_read_at'] == null ? null : DateTime.parse(json['last_read_at'] as String), + unreadMessages: (json['unread_messages'] as num?)?.toInt(), + lastReadMessageId: json['last_read_message_id'] as String?, + draft: json['draft'] == null ? null : Draft.fromJson(json['draft'] as Map), + reminder: json['reminder'] == null ? null : MessageReminder.fromJson(json['reminder'] as Map), + pushPreference: json['push_preference'] == null + ? null + : PushPreference.fromJson(json['push_preference'] as Map), + channelPushPreference: json['channel_push_preference'] == null + ? null + : ChannelPushPreference.fromJson(json['channel_push_preference'] as Map), + channelMessageCount: (json['channel_message_count'] as num?)?.toInt(), + lastDeliveredAt: json['last_delivered_at'] == null ? null : DateTime.parse(json['last_delivered_at'] as String), + lastDeliveredMessageId: json['last_delivered_message_id'] as String?, + extraData: json['extra_data'] as Map? ?? const {}, + isLocal: json['is_local'] as bool? ?? false, +); Map _$EventToJson(Event instance) => { - 'type': instance.type, - if (instance.userId case final value?) 'user_id': value, - if (instance.cid case final value?) 'cid': value, - if (instance.channelId case final value?) 'channel_id': value, - if (instance.channelType case final value?) 'channel_type': value, - if (instance.channelLastMessageAt?.toIso8601String() case final value?) - 'channel_last_message_at': value, - if (instance.connectionId case final value?) 'connection_id': value, - 'created_at': instance.createdAt.toIso8601String(), - if (instance.me?.toJson() case final value?) 'me': value, - if (instance.user?.toJson() case final value?) 'user': value, - if (instance.message?.toJson() case final value?) 'message': value, - if (instance.poll?.toJson() case final value?) 'poll': value, - if (instance.pollVote?.toJson() case final value?) 'poll_vote': value, - if (instance.channel?.toJson() case final value?) 'channel': value, - if (instance.member?.toJson() case final value?) 'member': value, - if (instance.reaction?.toJson() case final value?) 'reaction': value, - if (instance.totalUnreadCount case final value?) - 'total_unread_count': value, - if (instance.unreadChannels case final value?) 'unread_channels': value, - if (instance.online case final value?) 'online': value, - if (instance.parentId case final value?) 'parent_id': value, - 'is_local': instance.isLocal, - if (instance.hardDelete case final value?) 'hard_delete': value, - if (_$AITypingStateEnumMap[instance.aiState] case final value?) - 'ai_state': value, - if (instance.aiMessage case final value?) 'ai_message': value, - if (instance.messageId case final value?) 'message_id': value, - if (instance.thread?.toJson() case final value?) 'thread': value, - if (instance.unreadThreadMessages case final value?) - 'unread_thread_messages': value, - if (instance.unreadThreads case final value?) 'unread_threads': value, - if (instance.lastReadAt?.toIso8601String() case final value?) - 'last_read_at': value, - if (instance.unreadMessages case final value?) 'unread_messages': value, - if (instance.lastReadMessageId case final value?) - 'last_read_message_id': value, - if (instance.draft?.toJson() case final value?) 'draft': value, - if (instance.reminder?.toJson() case final value?) 'reminder': value, - if (instance.pushPreference?.toJson() case final value?) - 'push_preference': value, - if (instance.channelPushPreference?.toJson() case final value?) - 'channel_push_preference': value, - if (instance.channelMessageCount case final value?) - 'channel_message_count': value, - if (instance.lastDeliveredAt?.toIso8601String() case final value?) - 'last_delivered_at': value, - if (instance.lastDeliveredMessageId case final value?) - 'last_delivered_message_id': value, - 'extra_data': instance.extraData, - }; + 'type': instance.type, + if (instance.userId case final value?) 'user_id': value, + if (instance.cid case final value?) 'cid': value, + if (instance.channelId case final value?) 'channel_id': value, + if (instance.channelType case final value?) 'channel_type': value, + if (instance.channelLastMessageAt?.toIso8601String() case final value?) 'channel_last_message_at': value, + if (instance.connectionId case final value?) 'connection_id': value, + 'created_at': instance.createdAt.toIso8601String(), + if (instance.me?.toJson() case final value?) 'me': value, + if (instance.user?.toJson() case final value?) 'user': value, + if (instance.message?.toJson() case final value?) 'message': value, + if (instance.poll?.toJson() case final value?) 'poll': value, + if (instance.pollVote?.toJson() case final value?) 'poll_vote': value, + if (instance.channel?.toJson() case final value?) 'channel': value, + if (instance.member?.toJson() case final value?) 'member': value, + if (instance.reaction?.toJson() case final value?) 'reaction': value, + if (instance.totalUnreadCount case final value?) 'total_unread_count': value, + if (instance.unreadChannels case final value?) 'unread_channels': value, + if (instance.online case final value?) 'online': value, + if (instance.parentId case final value?) 'parent_id': value, + 'is_local': instance.isLocal, + if (instance.hardDelete case final value?) 'hard_delete': value, + if (instance.deletedForMe case final value?) 'deleted_for_me': value, + if (_$AITypingStateEnumMap[instance.aiState] case final value?) 'ai_state': value, + if (instance.aiMessage case final value?) 'ai_message': value, + if (instance.messageId case final value?) 'message_id': value, + if (instance.thread?.toJson() case final value?) 'thread': value, + if (instance.unreadThreadMessages case final value?) 'unread_thread_messages': value, + if (instance.unreadThreads case final value?) 'unread_threads': value, + if (instance.lastReadAt?.toIso8601String() case final value?) 'last_read_at': value, + if (instance.unreadMessages case final value?) 'unread_messages': value, + if (instance.lastReadMessageId case final value?) 'last_read_message_id': value, + if (instance.draft?.toJson() case final value?) 'draft': value, + if (instance.reminder?.toJson() case final value?) 'reminder': value, + if (instance.pushPreference?.toJson() case final value?) 'push_preference': value, + if (instance.channelPushPreference?.toJson() case final value?) 'channel_push_preference': value, + if (instance.channelMessageCount case final value?) 'channel_message_count': value, + if (instance.lastDeliveredAt?.toIso8601String() case final value?) 'last_delivered_at': value, + if (instance.lastDeliveredMessageId case final value?) 'last_delivered_message_id': value, + 'extra_data': instance.extraData, +}; const _$AITypingStateEnumMap = { AITypingState.idle: 'AI_STATE_IDLE', diff --git a/packages/stream_chat/lib/src/core/models/filter.dart b/packages/stream_chat/lib/src/core/models/filter.dart index 2122519d0f..fc8dbadd0c 100644 --- a/packages/stream_chat/lib/src/core/models/filter.dart +++ b/packages/stream_chat/lib/src/core/models/filter.dart @@ -53,7 +53,8 @@ enum FilterOperator { nor, /// Matches any list that contains the specified value - contains; + contains + ; @override String toString() { @@ -117,29 +118,22 @@ class Filter extends Equatable { }) : operator = '$operator'; /// An empty filter - const Filter.empty() - : value = const {}, - operator = null, - key = null; + const Filter.empty() : value = const {}, operator = null, key = null; /// Combines the provided filters and matches the values /// matched by all filters. - factory Filter.and(List filters) => - Filter._(operator: FilterOperator.and, value: filters); + factory Filter.and(List filters) => Filter._(operator: FilterOperator.and, value: filters); /// Combines the provided filters and matches the values /// matched by at least one of the filters. - factory Filter.or(List filters) => - Filter._(operator: FilterOperator.or, value: filters); + factory Filter.or(List filters) => Filter._(operator: FilterOperator.or, value: filters); /// Combines the provided filters and matches the values /// not matched by all the filters. - factory Filter.nor(List filters) => - Filter._(operator: FilterOperator.nor, value: filters); + factory Filter.nor(List filters) => Filter._(operator: FilterOperator.nor, value: filters); /// Matches values that are equal to a specified value. - factory Filter.equal(String key, Object value) => - Filter._(operator: FilterOperator.equal, key: key, value: value); + factory Filter.equal(String key, Object value) => Filter._(operator: FilterOperator.equal, key: key, value: value); /// Matches all values that are not equal to a specified value. factory Filter.notEqual(String key, Object value) => @@ -154,8 +148,7 @@ class Filter extends Equatable { Filter._(operator: FilterOperator.greaterOrEqual, key: key, value: value); /// Matches values that are less than a specified value. - factory Filter.less(String key, Object value) => - Filter._(operator: FilterOperator.less, key: key, value: value); + factory Filter.less(String key, Object value) => Filter._(operator: FilterOperator.less, key: key, value: value); /// Matches values that are less than or equal to a specified value. factory Filter.lessOrEqual(String key, Object value) => @@ -170,8 +163,7 @@ class Filter extends Equatable { Filter._(operator: FilterOperator.notIn, key: key, value: values); /// Matches values by performing text search with the specified value. - factory Filter.query(String key, String text) => - Filter._(operator: FilterOperator.query, key: key, value: text); + factory Filter.query(String key, String text) => Filter._(operator: FilterOperator.query, key: key, value: text); /// Matches values with the specified prefix. factory Filter.autoComplete(String key, String text) => diff --git a/packages/stream_chat/lib/src/core/models/location.dart b/packages/stream_chat/lib/src/core/models/location.dart new file mode 100644 index 0000000000..9b66746f24 --- /dev/null +++ b/packages/stream_chat/lib/src/core/models/location.dart @@ -0,0 +1,157 @@ +import 'package:equatable/equatable.dart'; +import 'package:json_annotation/json_annotation.dart'; +import 'package:stream_chat/src/core/models/channel_model.dart'; +import 'package:stream_chat/src/core/models/location_coordinates.dart'; +import 'package:stream_chat/src/core/models/message.dart'; + +part 'location.g.dart'; + +/// {@template location} +/// A model class representing a shared location. +/// +/// The [Location] represents a location shared in a channel message. +/// +/// It can be of two types: +/// 1. **Static Location**: A location that does not change over time and has +/// no end time. +/// 2. **Live Location**: A location that updates in real-time and has an +/// end time. +/// {@endtemplate} +@JsonSerializable() +class Location extends Equatable { + /// {@macro location} + Location({ + this.channelCid, + this.channel, + this.messageId, + this.message, + this.userId, + required this.latitude, + required this.longitude, + this.createdByDeviceId, + DateTime? endAt, + DateTime? createdAt, + DateTime? updatedAt, + }) : endAt = endAt?.toUtc(), + createdAt = createdAt ?? DateTime.timestamp(), + updatedAt = updatedAt ?? DateTime.timestamp(); + + /// Create a new instance from a json + factory Location.fromJson(Map json) => _$LocationFromJson(json); + + /// The channel CID where the message exists. + /// + /// This is only available if the location is coming from server response. + @JsonKey(includeToJson: false) + final String? channelCid; + + /// The channel where the message exists. + @JsonKey(includeToJson: false) + final ChannelModel? channel; + + /// The ID of the message that contains the shared location. + @JsonKey(includeToJson: false) + final String? messageId; + + /// The message that contains the shared location. + @JsonKey(includeToJson: false) + final Message? message; + + /// The ID of the user who shared the location. + @JsonKey(includeToJson: false) + final String? userId; + + /// The latitude of the shared location. + final double latitude; + + /// The longitude of the shared location. + final double longitude; + + /// The ID of the device that created the reminder. + @JsonKey(includeIfNull: false) + final String? createdByDeviceId; + + /// The date at which the shared location will end. + @JsonKey(includeIfNull: false) + final DateTime? endAt; + + /// The date at which the reminder was created. + @JsonKey(includeToJson: false) + final DateTime createdAt; + + /// The date at which the reminder was last updated. + @JsonKey(includeToJson: false) + final DateTime updatedAt; + + /// Returns true if the live location is still active (end_at > now) + bool get isActive { + final endAt = this.endAt; + if (endAt == null) return false; + + return endAt.isAfter(DateTime.now()); + } + + /// Returns true if the live location is expired (end_at <= now) + bool get isExpired => !isActive; + + /// Returns true if this is a live location (has end_at) + bool get isLive => endAt != null; + + /// Returns true if this is a static location (no end_at) + bool get isStatic => endAt == null; + + /// Returns the coordinates of the shared location. + LocationCoordinates get coordinates { + return LocationCoordinates( + latitude: latitude, + longitude: longitude, + ); + } + + /// Serialize to json + Map toJson() => _$LocationToJson(this); + + /// Creates a copy of [Location] with specified attributes overridden. + Location copyWith({ + String? channelCid, + ChannelModel? channel, + String? messageId, + Message? message, + String? userId, + double? latitude, + double? longitude, + String? createdByDeviceId, + DateTime? endAt, + DateTime? createdAt, + DateTime? updatedAt, + }) { + return Location( + channelCid: channelCid ?? this.channelCid, + channel: channel ?? this.channel, + messageId: messageId ?? this.messageId, + message: message ?? this.message, + userId: userId ?? this.userId, + latitude: latitude ?? this.latitude, + longitude: longitude ?? this.longitude, + createdByDeviceId: createdByDeviceId ?? this.createdByDeviceId, + endAt: endAt ?? this.endAt, + createdAt: createdAt ?? this.createdAt, + updatedAt: updatedAt ?? this.updatedAt, + ); + } + + @override + List get props => [ + channelCid, + channel, + messageId, + message, + userId, + latitude, + longitude, + createdByDeviceId, + endAt, + createdAt, + updatedAt, + ]; +} diff --git a/packages/stream_chat/lib/src/core/models/location.g.dart b/packages/stream_chat/lib/src/core/models/location.g.dart new file mode 100644 index 0000000000..662fc500d5 --- /dev/null +++ b/packages/stream_chat/lib/src/core/models/location.g.dart @@ -0,0 +1,28 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'location.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +Location _$LocationFromJson(Map json) => Location( + channelCid: json['channel_cid'] as String?, + channel: json['channel'] == null ? null : ChannelModel.fromJson(json['channel'] as Map), + messageId: json['message_id'] as String?, + message: json['message'] == null ? null : Message.fromJson(json['message'] as Map), + userId: json['user_id'] as String?, + latitude: (json['latitude'] as num).toDouble(), + longitude: (json['longitude'] as num).toDouble(), + createdByDeviceId: json['created_by_device_id'] as String?, + endAt: json['end_at'] == null ? null : DateTime.parse(json['end_at'] as String), + createdAt: json['created_at'] == null ? null : DateTime.parse(json['created_at'] as String), + updatedAt: json['updated_at'] == null ? null : DateTime.parse(json['updated_at'] as String), +); + +Map _$LocationToJson(Location instance) => { + 'latitude': instance.latitude, + 'longitude': instance.longitude, + if (instance.createdByDeviceId case final value?) 'created_by_device_id': value, + if (instance.endAt?.toIso8601String() case final value?) 'end_at': value, +}; diff --git a/packages/stream_chat/lib/src/core/models/location_coordinates.dart b/packages/stream_chat/lib/src/core/models/location_coordinates.dart new file mode 100644 index 0000000000..f23389d7e6 --- /dev/null +++ b/packages/stream_chat/lib/src/core/models/location_coordinates.dart @@ -0,0 +1,33 @@ +import 'package:equatable/equatable.dart'; + +/// {@template locationInfo} +/// A model class representing a location with latitude and longitude. +/// {@endtemplate} +class LocationCoordinates extends Equatable { + /// {@macro locationInfo} + const LocationCoordinates({ + required this.latitude, + required this.longitude, + }); + + /// The latitude of the location. + final double latitude; + + /// The longitude of the location. + final double longitude; + + /// Creates a copy of [LocationCoordinates] with specified attributes + /// overridden. + LocationCoordinates copyWith({ + double? latitude, + double? longitude, + }) { + return LocationCoordinates( + latitude: latitude ?? this.latitude, + longitude: longitude ?? this.longitude, + ); + } + + @override + List get props => [latitude, longitude]; +} diff --git a/packages/stream_chat/lib/src/core/models/member.dart b/packages/stream_chat/lib/src/core/models/member.dart index 66b51a287a..06c7185f1d 100644 --- a/packages/stream_chat/lib/src/core/models/member.dart +++ b/packages/stream_chat/lib/src/core/models/member.dart @@ -26,15 +26,16 @@ class Member extends Equatable implements ComparableFieldProvider { this.shadowBanned = false, this.pinnedAt, this.archivedAt, + this.deletedMessages = const [], this.extraData = const {}, - }) : userId = userId ?? user?.id, - createdAt = createdAt ?? DateTime.now(), - updatedAt = updatedAt ?? DateTime.now(); + }) : userId = userId ?? user?.id, + createdAt = createdAt ?? DateTime.now(), + updatedAt = updatedAt ?? DateTime.now(); /// Create a new instance from a json factory Member.fromJson(Map json) => _$MemberFromJson( - Serializer.moveToExtraDataFromRoot(json, _topLevelFields), - ); + Serializer.moveToExtraDataFromRoot(json, _topLevelFields), + ); /// Known top level fields. /// @@ -53,7 +54,8 @@ class Member extends Equatable implements ComparableFieldProvider { 'created_at', 'updated_at', 'pinned_at', - 'archived_at' + 'archived_at', + 'deleted_messages', ]; /// The interested user @@ -98,6 +100,12 @@ class Member extends Equatable implements ComparableFieldProvider { /// The last date of update final DateTime updatedAt; + /// List of message ids deleted by this member only for himself. + /// + /// These messages are not visible to this member anymore, but are still + /// visible to other channel members. + final List deletedMessages; + /// Map of custom member extraData. final Map extraData; @@ -118,49 +126,51 @@ class Member extends Equatable implements ComparableFieldProvider { bool? banned, DateTime? banExpires, bool? shadowBanned, + List? deletedMessages, Map? extraData, - }) => - Member( - user: user ?? this.user, - inviteAcceptedAt: inviteAcceptedAt ?? this.inviteAcceptedAt, - inviteRejectedAt: inviteRejectedAt ?? this.inviteRejectedAt, - invited: invited ?? this.invited, - banned: banned ?? this.banned, - banExpires: banExpires ?? this.banExpires, - shadowBanned: shadowBanned ?? this.shadowBanned, - channelRole: channelRole ?? this.channelRole, - userId: userId ?? this.userId, - isModerator: isModerator ?? this.isModerator, - pinnedAt: pinnedAt ?? this.pinnedAt, - archivedAt: archivedAt ?? this.archivedAt, - createdAt: createdAt ?? this.createdAt, - updatedAt: updatedAt ?? this.updatedAt, - extraData: extraData ?? this.extraData, - ); + }) => Member( + user: user ?? this.user, + inviteAcceptedAt: inviteAcceptedAt ?? this.inviteAcceptedAt, + inviteRejectedAt: inviteRejectedAt ?? this.inviteRejectedAt, + invited: invited ?? this.invited, + banned: banned ?? this.banned, + banExpires: banExpires ?? this.banExpires, + shadowBanned: shadowBanned ?? this.shadowBanned, + channelRole: channelRole ?? this.channelRole, + userId: userId ?? this.userId, + isModerator: isModerator ?? this.isModerator, + pinnedAt: pinnedAt ?? this.pinnedAt, + archivedAt: archivedAt ?? this.archivedAt, + createdAt: createdAt ?? this.createdAt, + updatedAt: updatedAt ?? this.updatedAt, + deletedMessages: deletedMessages ?? this.deletedMessages, + extraData: extraData ?? this.extraData, + ); /// Serialize to json Map toJson() => Serializer.moveFromExtraDataToRoot( - _$MemberToJson(this), - ); + _$MemberToJson(this), + ); @override List get props => [ - user, - inviteAcceptedAt, - inviteRejectedAt, - invited, - channelRole, - userId, - isModerator, - banned, - banExpires, - shadowBanned, - pinnedAt, - archivedAt, - createdAt, - updatedAt, - extraData, - ]; + user, + inviteAcceptedAt, + inviteRejectedAt, + invited, + channelRole, + userId, + isModerator, + banned, + banExpires, + shadowBanned, + pinnedAt, + archivedAt, + createdAt, + updatedAt, + deletedMessages, + extraData, + ]; @override ComparableField? getComparableField(String sortKey) { diff --git a/packages/stream_chat/lib/src/core/models/member.g.dart b/packages/stream_chat/lib/src/core/models/member.g.dart index 0cd677e72a..730051a2db 100644 --- a/packages/stream_chat/lib/src/core/models/member.g.dart +++ b/packages/stream_chat/lib/src/core/models/member.g.dart @@ -7,53 +7,39 @@ part of 'member.dart'; // ************************************************************************** Member _$MemberFromJson(Map json) => Member( - user: json['user'] == null - ? null - : User.fromJson(json['user'] as Map), - inviteAcceptedAt: json['invite_accepted_at'] == null - ? null - : DateTime.parse(json['invite_accepted_at'] as String), - inviteRejectedAt: json['invite_rejected_at'] == null - ? null - : DateTime.parse(json['invite_rejected_at'] as String), - invited: json['invited'] as bool? ?? false, - channelRole: json['channel_role'] as String?, - userId: json['user_id'] as String?, - isModerator: json['is_moderator'] as bool? ?? false, - createdAt: json['created_at'] == null - ? null - : DateTime.parse(json['created_at'] as String), - updatedAt: json['updated_at'] == null - ? null - : DateTime.parse(json['updated_at'] as String), - banned: json['banned'] as bool? ?? false, - banExpires: json['ban_expires'] == null - ? null - : DateTime.parse(json['ban_expires'] as String), - shadowBanned: json['shadow_banned'] as bool? ?? false, - pinnedAt: json['pinned_at'] == null - ? null - : DateTime.parse(json['pinned_at'] as String), - archivedAt: json['archived_at'] == null - ? null - : DateTime.parse(json['archived_at'] as String), - extraData: json['extra_data'] as Map? ?? const {}, - ); + user: json['user'] == null ? null : User.fromJson(json['user'] as Map), + inviteAcceptedAt: json['invite_accepted_at'] == null ? null : DateTime.parse(json['invite_accepted_at'] as String), + inviteRejectedAt: json['invite_rejected_at'] == null ? null : DateTime.parse(json['invite_rejected_at'] as String), + invited: json['invited'] as bool? ?? false, + channelRole: json['channel_role'] as String?, + userId: json['user_id'] as String?, + isModerator: json['is_moderator'] as bool? ?? false, + createdAt: json['created_at'] == null ? null : DateTime.parse(json['created_at'] as String), + updatedAt: json['updated_at'] == null ? null : DateTime.parse(json['updated_at'] as String), + banned: json['banned'] as bool? ?? false, + banExpires: json['ban_expires'] == null ? null : DateTime.parse(json['ban_expires'] as String), + shadowBanned: json['shadow_banned'] as bool? ?? false, + pinnedAt: json['pinned_at'] == null ? null : DateTime.parse(json['pinned_at'] as String), + archivedAt: json['archived_at'] == null ? null : DateTime.parse(json['archived_at'] as String), + deletedMessages: (json['deleted_messages'] as List?)?.map((e) => e as String).toList() ?? const [], + extraData: json['extra_data'] as Map? ?? const {}, +); Map _$MemberToJson(Member instance) => { - 'user': instance.user?.toJson(), - 'invite_accepted_at': instance.inviteAcceptedAt?.toIso8601String(), - 'invite_rejected_at': instance.inviteRejectedAt?.toIso8601String(), - 'invited': instance.invited, - 'channel_role': instance.channelRole, - 'user_id': instance.userId, - 'is_moderator': instance.isModerator, - 'banned': instance.banned, - 'ban_expires': instance.banExpires?.toIso8601String(), - 'shadow_banned': instance.shadowBanned, - 'pinned_at': instance.pinnedAt?.toIso8601String(), - 'archived_at': instance.archivedAt?.toIso8601String(), - 'created_at': instance.createdAt.toIso8601String(), - 'updated_at': instance.updatedAt.toIso8601String(), - 'extra_data': instance.extraData, - }; + 'user': instance.user?.toJson(), + 'invite_accepted_at': instance.inviteAcceptedAt?.toIso8601String(), + 'invite_rejected_at': instance.inviteRejectedAt?.toIso8601String(), + 'invited': instance.invited, + 'channel_role': instance.channelRole, + 'user_id': instance.userId, + 'is_moderator': instance.isModerator, + 'banned': instance.banned, + 'ban_expires': instance.banExpires?.toIso8601String(), + 'shadow_banned': instance.shadowBanned, + 'pinned_at': instance.pinnedAt?.toIso8601String(), + 'archived_at': instance.archivedAt?.toIso8601String(), + 'created_at': instance.createdAt.toIso8601String(), + 'updated_at': instance.updatedAt.toIso8601String(), + 'deleted_messages': instance.deletedMessages, + 'extra_data': instance.extraData, +}; diff --git a/packages/stream_chat/lib/src/core/models/message.dart b/packages/stream_chat/lib/src/core/models/message.dart index d36f3c7af6..66b09ef737 100644 --- a/packages/stream_chat/lib/src/core/models/message.dart +++ b/packages/stream_chat/lib/src/core/models/message.dart @@ -4,6 +4,7 @@ import 'package:json_annotation/json_annotation.dart'; import 'package:stream_chat/src/core/models/attachment.dart'; import 'package:stream_chat/src/core/models/comparable_field.dart'; import 'package:stream_chat/src/core/models/draft.dart'; +import 'package:stream_chat/src/core/models/location.dart'; import 'package:stream_chat/src/core/models/message_reminder.dart'; import 'package:stream_chat/src/core/models/message_state.dart'; import 'package:stream_chat/src/core/models/moderation.dart'; @@ -35,11 +36,7 @@ class Message extends Equatable implements ComparableFieldProvider { this.mentionedUsers = const [], this.silent = false, this.shadowed = false, - @Deprecated("Use 'reactionGroups' instead") - Map? reactionCounts, - @Deprecated("Use 'reactionGroups' instead") - Map? reactionScores, - Map? reactionGroups, + this.reactionGroups, this.latestReactions, this.ownReactions, this.parentId, @@ -55,6 +52,7 @@ class Message extends Equatable implements ComparableFieldProvider { this.localUpdatedAt, DateTime? deletedAt, this.localDeletedAt, + this.deletedForMe, this.messageTextUpdatedAt, this.user, this.pinned = false, @@ -71,19 +69,15 @@ class Message extends Equatable implements ComparableFieldProvider { this.draft, this.reminder, this.channelRole, - }) : id = id ?? const Uuid().v4(), - type = MessageType(type), - pinExpires = pinExpires?.toUtc(), - remoteCreatedAt = createdAt, - remoteUpdatedAt = updatedAt, - remoteDeletedAt = deletedAt, - reactionGroups = _maybeGetReactionGroups( - reactionGroups: reactionGroups, - reactionCounts: reactionCounts, - reactionScores: reactionScores, - ), - _quotedMessageId = quotedMessageId, - _pollId = pollId; + this.sharedLocation, + }) : id = id ?? const Uuid().v4(), + type = MessageType(type), + pinExpires = pinExpires?.toUtc(), + remoteCreatedAt = createdAt, + remoteUpdatedAt = updatedAt, + remoteDeletedAt = deletedAt, + _quotedMessageId = quotedMessageId, + _pollId = pollId; /// Create a new instance from JSON. factory Message.fromJson(Map json) { @@ -91,14 +85,17 @@ class Message extends Equatable implements ComparableFieldProvider { Serializer.moveToExtraDataFromRoot(json, topLevelFields), ); - var state = MessageState.sent; - if (message.deletedAt != null) { - state = MessageState.softDeleted; - } else if (message.updatedAt.isAfter(message.createdAt)) { - state = MessageState.updated; - } + final isDeletedForMe = message.deletedForMe ?? false; + // TODO: Remove this override once type is properly enriched on the backend. + final type = isDeletedForMe ? MessageType.deleted : message.type; + final state = switch (message) { + _ when isDeletedForMe => MessageState.deletedForMe, + _ when message.deletedAt != null => MessageState.softDeleted, + _ when message.updatedAt.isAfter(message.createdAt) => MessageState.updated, + _ => MessageState.sent, + }; - return message.copyWith(state: state); + return message.copyWith(type: type, state: state); } /// The message ID. This is either created by Stream or set client side when @@ -129,45 +126,40 @@ class Message extends Equatable implements ComparableFieldProvider { @JsonKey(toJson: User.toIds) final List mentionedUsers; - /// A map describing the count of number of every reaction. - @JsonKey(includeToJson: false) - @Deprecated("Use 'reactionGroups' instead") - Map? get reactionCounts { - return reactionGroups?.map((type, it) => MapEntry(type, it.count)); - } - - /// A map describing the count of score of every reaction. - @JsonKey(includeToJson: false) - @Deprecated("Use 'reactionGroups' instead") - Map? get reactionScores { - return reactionGroups?.map((type, it) => MapEntry(type, it.sumScores)); - } - - static Map? _maybeGetReactionGroups({ - Map? reactionGroups, - Map? reactionCounts, - Map? reactionScores, - }) { + static Object? _reactionGroupsReadValue( + Map json, + String key, + ) { + final reactionGroups = json[key] as Map?; if (reactionGroups != null) return reactionGroups; + + final reactionCounts = json['reaction_counts'] as Map?; + final reactionScores = json['reaction_scores'] as Map?; if (reactionCounts == null && reactionScores == null) return null; final reactionTypes = {...?reactionCounts?.keys, ...?reactionScores?.keys}; if (reactionTypes.isEmpty) return null; - final groups = {}; + final groups = {}; for (final type in reactionTypes) { final count = reactionCounts?[type] ?? 0; final sumScores = reactionScores?[type] ?? 0; if (count == 0 || sumScores == 0) continue; - groups[type] = ReactionGroup(count: count, sumScores: sumScores); + final now = DateTime.timestamp(); + groups[type] = { + 'count': count, + 'sum_scores': sumScores, + 'first_reaction_at': now.toIso8601String(), + 'last_reaction_at': now.toIso8601String(), + }; } return groups; } /// A map of reaction types and their corresponding reaction groups. - @JsonKey(includeToJson: false) + @JsonKey(includeToJson: false, readValue: _reactionGroupsReadValue) final Map? reactionGroups; /// The latest reactions to the message created by any user. @@ -309,11 +301,13 @@ class Message extends Equatable implements ComparableFieldProvider { /// Optional draft message linked to this message. /// /// This is present when the message is a thread i.e. contains replies. + @JsonKey(includeToJson: false) final Draft? draft; /// Optional reminder for this message. /// /// This is present when a user has set a reminder for this message. + @JsonKey(includeToJson: false) final MessageReminder? reminder; static Object? _channelRoleReadValue(Map json, String key) { @@ -329,6 +323,17 @@ class Message extends Equatable implements ComparableFieldProvider { @JsonKey(includeToJson: false, readValue: _channelRoleReadValue) final String? channelRole; + /// Optional shared location associated with this message. + /// + /// This is used to share a location in a message, allowing users to view the + /// location on a map. + @JsonKey(includeIfNull: false) + final Location? sharedLocation; + + /// Whether the message was deleted only for the current user. + @JsonKey(includeToJson: false) + final bool? deletedForMe; + /// Message custom extraData. final Map extraData; @@ -378,6 +383,8 @@ class Message extends Equatable implements ComparableFieldProvider { 'draft', 'reminder', 'member', + 'shared_location', + 'deleted_for_me', ]; /// Serialize to json. @@ -405,10 +412,6 @@ class Message extends Equatable implements ComparableFieldProvider { List? mentionedUsers, bool? silent, bool? shadowed, - @Deprecated("Use 'reactionGroups' instead") - Map? reactionCounts, - @Deprecated("Use 'reactionGroups' instead") - Map? reactionScores, Map? reactionGroups, List? latestReactions, List? ownReactions, @@ -441,20 +444,18 @@ class Message extends Equatable implements ComparableFieldProvider { Object? draft = _nullConst, Object? reminder = _nullConst, String? channelRole, + Location? sharedLocation, + bool? deletedForMe, }) { assert(() { - if (pinExpires is! DateTime && - pinExpires != null && - pinExpires is! _NullConst) { + if (pinExpires is! DateTime && pinExpires != null && pinExpires is! _NullConst) { throw ArgumentError('`pinExpires` can only be set as DateTime or null'); } return true; }(), 'Validate type for pinExpires'); assert(() { - if (quotedMessage is! Message && - quotedMessage != null && - quotedMessage is! _NullConst) { + if (quotedMessage is! Message && quotedMessage != null && quotedMessage is! _NullConst) { throw ArgumentError( '`quotedMessage` can only be set as Message or null', ); @@ -463,9 +464,7 @@ class Message extends Equatable implements ComparableFieldProvider { }(), 'Validate type for quotedMessage'); assert(() { - if (quotedMessageId is! String && - quotedMessageId != null && - quotedMessageId is! _NullConst) { + if (quotedMessageId is! String && quotedMessageId != null && quotedMessageId is! _NullConst) { throw ArgumentError( '`quotedMessage` can only be set as String or null', ); @@ -481,21 +480,12 @@ class Message extends Equatable implements ComparableFieldProvider { mentionedUsers: mentionedUsers ?? this.mentionedUsers, silent: silent ?? this.silent, shadowed: shadowed ?? this.shadowed, - reactionGroups: _maybeGetReactionGroups( - reactionGroups: reactionGroups, - reactionCounts: reactionCounts, - reactionScores: reactionScores, - ) ?? - this.reactionGroups, + reactionGroups: reactionGroups ?? this.reactionGroups, latestReactions: latestReactions ?? this.latestReactions, ownReactions: ownReactions ?? this.ownReactions, parentId: parentId ?? this.parentId, - quotedMessage: quotedMessage == _nullConst - ? this.quotedMessage - : quotedMessage as Message?, - quotedMessageId: quotedMessageId == _nullConst - ? _quotedMessageId - : quotedMessageId as String?, + quotedMessage: quotedMessage == _nullConst ? this.quotedMessage : quotedMessage as Message?, + quotedMessageId: quotedMessageId == _nullConst ? _quotedMessageId : quotedMessageId as String?, replyCount: replyCount ?? this.replyCount, threadParticipants: threadParticipants ?? this.threadParticipants, showInChannel: showInChannel ?? this.showInChannel, @@ -510,8 +500,7 @@ class Message extends Equatable implements ComparableFieldProvider { user: user ?? this.user, pinned: pinned ?? this.pinned, pinnedAt: pinnedAt ?? this.pinnedAt, - pinExpires: - pinExpires == _nullConst ? this.pinExpires : pinExpires as DateTime?, + pinExpires: pinExpires == _nullConst ? this.pinExpires : pinExpires as DateTime?, pinnedBy: pinnedBy ?? this.pinnedBy, poll: poll ?? this.poll, pollId: pollId ?? _pollId, @@ -521,9 +510,10 @@ class Message extends Equatable implements ComparableFieldProvider { restrictedVisibility: restrictedVisibility ?? this.restrictedVisibility, moderation: moderation ?? this.moderation, draft: draft == _nullConst ? this.draft : draft as Draft?, - reminder: - reminder == _nullConst ? this.reminder : reminder as MessageReminder?, + reminder: reminder == _nullConst ? this.reminder : reminder as MessageReminder?, channelRole: channelRole ?? this.channelRole, + sharedLocation: sharedLocation ?? this.sharedLocation, + deletedForMe: deletedForMe ?? this.deletedForMe, ); } @@ -570,73 +560,118 @@ class Message extends Equatable implements ComparableFieldProvider { draft: other.draft, reminder: other.reminder, channelRole: other.channelRole, + sharedLocation: other.sharedLocation, + deletedForMe: other.deletedForMe, ); } - /// Returns a new [Message] that is [other] with local changes applied to it. + /// Returns a new [Message] that is [other] reconciled with this message. /// - /// This ensures that the local sync changes are not lost when the message is - /// updated on the server. + /// `this` is the locally-known message and [other] is the incoming + /// payload. Local-only timestamps ([localCreatedAt], [localUpdatedAt], + /// [localDeletedAt]) are carried over from `this`. [poll], + /// [sharedLocation], and the nested [quotedMessage] fall back to the + /// local value when [other] omits them. The nested [quotedMessage] is + /// reconciled recursively. /// - /// For example, when a message is sent, it is immediately shown - /// optimistically in the UI. When the message is received from the server, - /// it will not contain the local changes. This method can be used to merge - /// the local changes back into the message. - /// - /// This also helps in maintaining the order of the messages in the channel - /// when the messages are sorted by the [createdAt] field. - Message syncWith(Message? other) { + /// If [other] is `null`, returns this message unchanged. + Message updateWith(Message? other) { if (other == null) return this; - return copyWith( - localCreatedAt: other.localCreatedAt, - localUpdatedAt: other.localUpdatedAt, - localDeletedAt: other.localDeletedAt, + // If we kept the local `deletedForMe` flag, the `type` and `state` on + // [other] were derived without it, so we re-promote them here to keep + // the message reading as deleted. + final preservedDeletedForMe = other.deletedForMe ?? deletedForMe; + final shouldPromoteDeletedForMe = preservedDeletedForMe == true && other.deletedForMe != true; + + return other.copyWith( + // Local-only timestamps — the server cannot know about these, + // so they are always taken from this instance. + localCreatedAt: localCreatedAt, + localUpdatedAt: localUpdatedAt, + localDeletedAt: localDeletedAt, + deletedForMe: preservedDeletedForMe, + type: shouldPromoteDeletedForMe ? MessageType.deleted : null, + state: shouldPromoteDeletedForMe ? MessageState.deletedForMe : null, + // Preserve enrichment from this instance when [other] omits these + // fields, as the backend may strip them on partial payloads. + poll: other.poll ?? poll, + sharedLocation: other.sharedLocation ?? sharedLocation, + ownReactions: other.ownReactions ?? ownReactions, + // Recursively merge so nested enrichment survives a stripped payload. + quotedMessage: switch ((other.quotedMessage, quotedMessage)) { + // Same target — merge to preserve local enrichment. + (final incoming?, final local?) when incoming.id == local.id => local.updateWith(incoming), + // Different target — trust the new payload. + (final incoming?, _) => incoming, + // Server omitted the nested quote — keep the locally-known copy. + (_, final local) => local, + }, ); } + /// Returns a new [Message] that is this message reconciled with the local + /// changes from [other]. + /// + /// The argument convention is the inverse of [updateWith]: here `this` is + /// the server payload and [other] is the locally-known message. + /// `serverResponse.syncWith(localMessage)` is equivalent to + /// `localMessage.updateWith(serverResponse)`. + /// + /// See also: + /// + /// * [updateWith], which performs the same reconciliation with the + /// arguments flipped. + @Deprecated('Use updateWith instead — note the arguments are flipped') + Message syncWith(Message? other) { + if (other == null) return this; + return other.updateWith(this); + } + @override List get props => [ - id, - text, - type, - attachments, - mentionedUsers, - reactionGroups, - latestReactions, - ownReactions, - parentId, - quotedMessage, - quotedMessageId, - replyCount, - threadParticipants, - showInChannel, - shadowed, - silent, - command, - localCreatedAt, - remoteCreatedAt, - localUpdatedAt, - remoteUpdatedAt, - localDeletedAt, - remoteDeletedAt, - messageTextUpdatedAt, - user, - pinned, - pinnedAt, - pinExpires, - pinnedBy, - poll, - pollId, - extraData, - state, - i18n, - restrictedVisibility, - moderation, - draft, - reminder, - channelRole, - ]; + id, + text, + type, + attachments, + mentionedUsers, + reactionGroups, + latestReactions, + ownReactions, + parentId, + quotedMessage, + quotedMessageId, + replyCount, + threadParticipants, + showInChannel, + shadowed, + silent, + command, + localCreatedAt, + remoteCreatedAt, + localUpdatedAt, + remoteUpdatedAt, + localDeletedAt, + remoteDeletedAt, + messageTextUpdatedAt, + user, + pinned, + pinnedAt, + pinExpires, + pinnedBy, + poll, + pollId, + extraData, + state, + i18n, + restrictedVisibility, + moderation, + draft, + reminder, + channelRole, + sharedLocation, + deletedForMe, + ]; @override ComparableField? getComparableField(String sortKey) { diff --git a/packages/stream_chat/lib/src/core/models/message.g.dart b/packages/stream_chat/lib/src/core/models/message.g.dart index 60e27988ca..fe9d4721fd 100644 --- a/packages/stream_chat/lib/src/core/models/message.g.dart +++ b/packages/stream_chat/lib/src/core/models/message.g.dart @@ -7,114 +7,81 @@ part of 'message.dart'; // ************************************************************************** Message _$MessageFromJson(Map json) => Message( - id: json['id'] as String?, - text: json['text'] as String?, - type: json['type'] == null - ? MessageType.regular - : MessageType.fromJson(json['type'] as String), - attachments: (json['attachments'] as List?) - ?.map((e) => Attachment.fromJson(e as Map)) - .toList() ?? - const [], - mentionedUsers: (json['mentioned_users'] as List?) - ?.map((e) => User.fromJson(e as Map)) - .toList() ?? - const [], - silent: json['silent'] as bool? ?? false, - shadowed: json['shadowed'] as bool? ?? false, - reactionCounts: (json['reaction_counts'] as Map?)?.map( - (k, e) => MapEntry(k, (e as num).toInt()), - ), - reactionScores: (json['reaction_scores'] as Map?)?.map( - (k, e) => MapEntry(k, (e as num).toInt()), - ), - reactionGroups: (json['reaction_groups'] as Map?)?.map( - (k, e) => - MapEntry(k, ReactionGroup.fromJson(e as Map)), - ), - latestReactions: (json['latest_reactions'] as List?) - ?.map((e) => Reaction.fromJson(e as Map)) - .toList(), - ownReactions: (json['own_reactions'] as List?) - ?.map((e) => Reaction.fromJson(e as Map)) - .toList(), - parentId: json['parent_id'] as String?, - quotedMessage: json['quoted_message'] == null - ? null - : Message.fromJson(json['quoted_message'] as Map), - quotedMessageId: json['quoted_message_id'] as String?, - replyCount: (json['reply_count'] as num?)?.toInt() ?? 0, - threadParticipants: (json['thread_participants'] as List?) - ?.map((e) => User.fromJson(e as Map)) - .toList(), - showInChannel: json['show_in_channel'] as bool?, - command: json['command'] as String?, - createdAt: json['created_at'] == null - ? null - : DateTime.parse(json['created_at'] as String), - updatedAt: json['updated_at'] == null - ? null - : DateTime.parse(json['updated_at'] as String), - deletedAt: json['deleted_at'] == null - ? null - : DateTime.parse(json['deleted_at'] as String), - messageTextUpdatedAt: json['message_text_updated_at'] == null - ? null - : DateTime.parse(json['message_text_updated_at'] as String), - user: json['user'] == null - ? null - : User.fromJson(json['user'] as Map), - pinned: json['pinned'] as bool? ?? false, - pinnedAt: json['pinned_at'] == null - ? null - : DateTime.parse(json['pinned_at'] as String), - pinExpires: json['pin_expires'] == null - ? null - : DateTime.parse(json['pin_expires'] as String), - pinnedBy: json['pinned_by'] == null - ? null - : User.fromJson(json['pinned_by'] as Map), - poll: json['poll'] == null - ? null - : Poll.fromJson(json['poll'] as Map), - pollId: json['poll_id'] as String?, - extraData: json['extra_data'] as Map? ?? const {}, - i18n: (json['i18n'] as Map?)?.map( - (k, e) => MapEntry(k, e as String), - ), - restrictedVisibility: (json['restricted_visibility'] as List?) - ?.map((e) => e as String) - .toList(), - moderation: Message._moderationReadValue(json, 'moderation') == null - ? null - : Moderation.fromJson(Message._moderationReadValue(json, 'moderation') - as Map), - draft: json['draft'] == null - ? null - : Draft.fromJson(json['draft'] as Map), - reminder: json['reminder'] == null - ? null - : MessageReminder.fromJson(json['reminder'] as Map), - channelRole: - Message._channelRoleReadValue(json, 'channel_role') as String?, - ); + id: json['id'] as String?, + text: json['text'] as String?, + type: json['type'] == null ? MessageType.regular : MessageType.fromJson(json['type'] as String), + attachments: + (json['attachments'] as List?)?.map((e) => Attachment.fromJson(e as Map)).toList() ?? + const [], + mentionedUsers: + (json['mentioned_users'] as List?)?.map((e) => User.fromJson(e as Map)).toList() ?? + const [], + silent: json['silent'] as bool? ?? false, + shadowed: json['shadowed'] as bool? ?? false, + reactionGroups: (Message._reactionGroupsReadValue(json, 'reaction_groups') as Map?)?.map( + (k, e) => MapEntry(k, ReactionGroup.fromJson(e as Map)), + ), + latestReactions: (json['latest_reactions'] as List?) + ?.map((e) => Reaction.fromJson(e as Map)) + .toList(), + ownReactions: (json['own_reactions'] as List?) + ?.map((e) => Reaction.fromJson(e as Map)) + .toList(), + parentId: json['parent_id'] as String?, + quotedMessage: json['quoted_message'] == null + ? null + : Message.fromJson(json['quoted_message'] as Map), + quotedMessageId: json['quoted_message_id'] as String?, + replyCount: (json['reply_count'] as num?)?.toInt() ?? 0, + threadParticipants: (json['thread_participants'] as List?) + ?.map((e) => User.fromJson(e as Map)) + .toList(), + showInChannel: json['show_in_channel'] as bool?, + command: json['command'] as String?, + createdAt: json['created_at'] == null ? null : DateTime.parse(json['created_at'] as String), + updatedAt: json['updated_at'] == null ? null : DateTime.parse(json['updated_at'] as String), + deletedAt: json['deleted_at'] == null ? null : DateTime.parse(json['deleted_at'] as String), + deletedForMe: json['deleted_for_me'] as bool?, + messageTextUpdatedAt: json['message_text_updated_at'] == null + ? null + : DateTime.parse(json['message_text_updated_at'] as String), + user: json['user'] == null ? null : User.fromJson(json['user'] as Map), + pinned: json['pinned'] as bool? ?? false, + pinnedAt: json['pinned_at'] == null ? null : DateTime.parse(json['pinned_at'] as String), + pinExpires: json['pin_expires'] == null ? null : DateTime.parse(json['pin_expires'] as String), + pinnedBy: json['pinned_by'] == null ? null : User.fromJson(json['pinned_by'] as Map), + poll: json['poll'] == null ? null : Poll.fromJson(json['poll'] as Map), + pollId: json['poll_id'] as String?, + extraData: json['extra_data'] as Map? ?? const {}, + i18n: (json['i18n'] as Map?)?.map( + (k, e) => MapEntry(k, e as String), + ), + restrictedVisibility: (json['restricted_visibility'] as List?)?.map((e) => e as String).toList(), + moderation: Message._moderationReadValue(json, 'moderation') == null + ? null + : Moderation.fromJson(Message._moderationReadValue(json, 'moderation') as Map), + draft: json['draft'] == null ? null : Draft.fromJson(json['draft'] as Map), + reminder: json['reminder'] == null ? null : MessageReminder.fromJson(json['reminder'] as Map), + channelRole: Message._channelRoleReadValue(json, 'channel_role') as String?, + sharedLocation: json['shared_location'] == null + ? null + : Location.fromJson(json['shared_location'] as Map), +); Map _$MessageToJson(Message instance) => { - 'id': instance.id, - 'text': instance.text, - if (MessageType.toJson(instance.type) case final value?) 'type': value, - 'attachments': instance.attachments.map((e) => e.toJson()).toList(), - 'mentioned_users': User.toIds(instance.mentionedUsers), - 'parent_id': instance.parentId, - 'quoted_message_id': instance.quotedMessageId, - 'show_in_channel': instance.showInChannel, - 'silent': instance.silent, - 'pinned': instance.pinned, - 'pin_expires': instance.pinExpires?.toIso8601String(), - 'poll_id': instance.pollId, - if (instance.restrictedVisibility case final value?) - 'restricted_visibility': value, - 'draft': instance.draft?.toJson(), - 'reminder': instance.reminder?.toJson(), - 'extra_data': instance.extraData, - }; + 'id': instance.id, + 'text': instance.text, + if (MessageType.toJson(instance.type) case final value?) 'type': value, + 'attachments': instance.attachments.map((e) => e.toJson()).toList(), + 'mentioned_users': User.toIds(instance.mentionedUsers), + 'parent_id': instance.parentId, + 'quoted_message_id': instance.quotedMessageId, + 'show_in_channel': instance.showInChannel, + 'silent': instance.silent, + 'pinned': instance.pinned, + 'pin_expires': instance.pinExpires?.toIso8601String(), + 'poll_id': instance.pollId, + if (instance.restrictedVisibility case final value?) 'restricted_visibility': value, + if (instance.sharedLocation?.toJson() case final value?) 'shared_location': value, + 'extra_data': instance.extraData, +}; diff --git a/packages/stream_chat/lib/src/core/models/message_delete_scope.dart b/packages/stream_chat/lib/src/core/models/message_delete_scope.dart new file mode 100644 index 0000000000..9a53a777bf --- /dev/null +++ b/packages/stream_chat/lib/src/core/models/message_delete_scope.dart @@ -0,0 +1,60 @@ +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'message_delete_scope.freezed.dart'; +part 'message_delete_scope.g.dart'; + +/// Represents the scope of deletion for a message. +/// +/// - [deleteForMe]: The message is deleted only for the current user. +/// - [deleteForAll]: The message is deleted for all users. The [hard] +/// parameter indicates whether the deletion is permanent (hard) or soft. +@freezed +sealed class MessageDeleteScope with _$MessageDeleteScope { + /// The message is deleted only for the current user. + /// + /// Note: This does not permanently delete the message, it will remain + /// visible to other channel members. + const factory MessageDeleteScope.deleteForMe() = DeleteForMe; + + /// The message is deleted for all users. + /// + /// If [hard] is true, the message is permanently deleted and cannot be + /// recovered. If false, the message is soft deleted and may be recoverable + /// by channel members with the appropriate permissions. + /// + /// Defaults to soft deletion (hard = false). + const factory MessageDeleteScope.deleteForAll({ + @Default(false) bool hard, + }) = DeleteForAll; + + /// Creates a instance of [MessageDeleteScope] from a JSON map. + factory MessageDeleteScope.fromJson(Map json) => _$MessageDeleteScopeFromJson(json); + + // region Predefined Scopes + + /// The message is soft deleted for all users. + /// + /// This is equivalent to `MessageDeleteScope.deleteForAll(hard: false)`. + static const softDeleteForAll = MessageDeleteScope.deleteForAll(); + + /// The message is permanently (hard) deleted for all users. + /// + /// This is equivalent to `MessageDeleteScope.deleteForAll(hard: true)`. + static const hardDeleteForAll = MessageDeleteScope.deleteForAll(hard: true); + + // endregion +} + +/// Extension methods for [MessageDeleteScope] to provide additional +/// functionality. +extension MessageDeleteScopeX on MessageDeleteScope { + /// Indicates whether the deletion is permanent (hard) or soft. + /// + /// For [DeleteForMe], this is always false. + bool get hard { + return switch (this) { + DeleteForMe() => false, + DeleteForAll(hard: final hard) => hard, + }; + } +} diff --git a/packages/stream_chat/lib/src/core/models/message_delete_scope.freezed.dart b/packages/stream_chat/lib/src/core/models/message_delete_scope.freezed.dart new file mode 100644 index 0000000000..84da7fb10b --- /dev/null +++ b/packages/stream_chat/lib/src/core/models/message_delete_scope.freezed.dart @@ -0,0 +1,166 @@ +// dart format width=80 +// coverage:ignore-file +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint +// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark + +part of 'message_delete_scope.dart'; + +// ************************************************************************** +// FreezedGenerator +// ************************************************************************** + +// dart format off +T _$identity(T value) => value; +MessageDeleteScope _$MessageDeleteScopeFromJson(Map json) { + switch (json['runtimeType']) { + case 'deleteForMe': + return DeleteForMe.fromJson(json); + case 'deleteForAll': + return DeleteForAll.fromJson(json); + + default: + throw CheckedFromJsonException(json, 'runtimeType', 'MessageDeleteScope', + 'Invalid union type "${json['runtimeType']}"!'); + } +} + +/// @nodoc +mixin _$MessageDeleteScope { + /// Serializes this MessageDeleteScope to a JSON map. + Map toJson(); + + @override + bool operator ==(Object other) { + return identical(this, other) || + (other.runtimeType == runtimeType && other is MessageDeleteScope); + } + + @JsonKey(includeFromJson: false, includeToJson: false) + @override + int get hashCode => runtimeType.hashCode; + + @override + String toString() { + return 'MessageDeleteScope()'; + } +} + +/// @nodoc +class $MessageDeleteScopeCopyWith<$Res> { + $MessageDeleteScopeCopyWith( + MessageDeleteScope _, $Res Function(MessageDeleteScope) __); +} + +/// @nodoc +@JsonSerializable() +class DeleteForMe implements MessageDeleteScope { + const DeleteForMe({final String? $type}) : $type = $type ?? 'deleteForMe'; + factory DeleteForMe.fromJson(Map json) => + _$DeleteForMeFromJson(json); + + @JsonKey(name: 'runtimeType') + final String $type; + + @override + Map toJson() { + return _$DeleteForMeToJson( + this, + ); + } + + @override + bool operator ==(Object other) { + return identical(this, other) || + (other.runtimeType == runtimeType && other is DeleteForMe); + } + + @JsonKey(includeFromJson: false, includeToJson: false) + @override + int get hashCode => runtimeType.hashCode; + + @override + String toString() { + return 'MessageDeleteScope.deleteForMe()'; + } +} + +/// @nodoc +@JsonSerializable() +class DeleteForAll implements MessageDeleteScope { + const DeleteForAll({this.hard = false, final String? $type}) + : $type = $type ?? 'deleteForAll'; + factory DeleteForAll.fromJson(Map json) => + _$DeleteForAllFromJson(json); + + @JsonKey() + final bool hard; + + @JsonKey(name: 'runtimeType') + final String $type; + + /// Create a copy of MessageDeleteScope + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + @pragma('vm:prefer-inline') + $DeleteForAllCopyWith get copyWith => + _$DeleteForAllCopyWithImpl(this, _$identity); + + @override + Map toJson() { + return _$DeleteForAllToJson( + this, + ); + } + + @override + bool operator ==(Object other) { + return identical(this, other) || + (other.runtimeType == runtimeType && + other is DeleteForAll && + (identical(other.hard, hard) || other.hard == hard)); + } + + @JsonKey(includeFromJson: false, includeToJson: false) + @override + int get hashCode => Object.hash(runtimeType, hard); + + @override + String toString() { + return 'MessageDeleteScope.deleteForAll(hard: $hard)'; + } +} + +/// @nodoc +abstract mixin class $DeleteForAllCopyWith<$Res> + implements $MessageDeleteScopeCopyWith<$Res> { + factory $DeleteForAllCopyWith( + DeleteForAll value, $Res Function(DeleteForAll) _then) = + _$DeleteForAllCopyWithImpl; + @useResult + $Res call({bool hard}); +} + +/// @nodoc +class _$DeleteForAllCopyWithImpl<$Res> implements $DeleteForAllCopyWith<$Res> { + _$DeleteForAllCopyWithImpl(this._self, this._then); + + final DeleteForAll _self; + final $Res Function(DeleteForAll) _then; + + /// Create a copy of MessageDeleteScope + /// with the given fields replaced by the non-null parameter values. + @pragma('vm:prefer-inline') + $Res call({ + Object? hard = null, + }) { + return _then(DeleteForAll( + hard: null == hard + ? _self.hard + : hard // ignore: cast_nullable_to_non_nullable + as bool, + )); + } +} + +// dart format on diff --git a/packages/stream_chat/lib/src/core/models/message_delete_scope.g.dart b/packages/stream_chat/lib/src/core/models/message_delete_scope.g.dart new file mode 100644 index 0000000000..75f4373b42 --- /dev/null +++ b/packages/stream_chat/lib/src/core/models/message_delete_scope.g.dart @@ -0,0 +1,25 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'message_delete_scope.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +DeleteForMe _$DeleteForMeFromJson(Map json) => DeleteForMe( + $type: json['runtimeType'] as String?, +); + +Map _$DeleteForMeToJson(DeleteForMe instance) => { + 'runtimeType': instance.$type, +}; + +DeleteForAll _$DeleteForAllFromJson(Map json) => DeleteForAll( + hard: json['hard'] as bool? ?? false, + $type: json['runtimeType'] as String?, +); + +Map _$DeleteForAllToJson(DeleteForAll instance) => { + 'hard': instance.hard, + 'runtimeType': instance.$type, +}; diff --git a/packages/stream_chat/lib/src/core/models/message_delivery.g.dart b/packages/stream_chat/lib/src/core/models/message_delivery.g.dart index 7a4f1431b9..96a3c10157 100644 --- a/packages/stream_chat/lib/src/core/models/message_delivery.g.dart +++ b/packages/stream_chat/lib/src/core/models/message_delivery.g.dart @@ -6,8 +6,7 @@ part of 'message_delivery.dart'; // JsonSerializableGenerator // ************************************************************************** -Map _$MessageDeliveryToJson(MessageDelivery instance) => - { - 'cid': instance.channelCid, - 'id': instance.messageId, - }; +Map _$MessageDeliveryToJson(MessageDelivery instance) => { + 'cid': instance.channelCid, + 'id': instance.messageId, +}; diff --git a/packages/stream_chat/lib/src/core/models/message_reminder.dart b/packages/stream_chat/lib/src/core/models/message_reminder.dart index d816e06148..23128f53aa 100644 --- a/packages/stream_chat/lib/src/core/models/message_reminder.dart +++ b/packages/stream_chat/lib/src/core/models/message_reminder.dart @@ -38,12 +38,11 @@ class MessageReminder extends Equatable implements ComparableFieldProvider { this.remindAt, DateTime? createdAt, DateTime? updatedAt, - }) : createdAt = createdAt ?? DateTime.now(), - updatedAt = updatedAt ?? DateTime.now(); + }) : createdAt = createdAt ?? DateTime.now(), + updatedAt = updatedAt ?? DateTime.now(); /// Create a new instance from a json - factory MessageReminder.fromJson(Map json) => - _$MessageReminderFromJson(json); + factory MessageReminder.fromJson(Map json) => _$MessageReminderFromJson(json); /// The channel CID where the message exists. final String channelCid; @@ -124,16 +123,16 @@ class MessageReminder extends Equatable implements ComparableFieldProvider { @override List get props => [ - channelCid, - channel, - messageId, - message, - userId, - user, - remindAt, - createdAt, - updatedAt, - ]; + channelCid, + channel, + messageId, + message, + userId, + user, + remindAt, + createdAt, + updatedAt, + ]; @override ComparableField? getComparableField(String sortKey) { diff --git a/packages/stream_chat/lib/src/core/models/message_reminder.g.dart b/packages/stream_chat/lib/src/core/models/message_reminder.g.dart index 1562ace757..2df37be652 100644 --- a/packages/stream_chat/lib/src/core/models/message_reminder.g.dart +++ b/packages/stream_chat/lib/src/core/models/message_reminder.g.dart @@ -6,37 +6,23 @@ part of 'message_reminder.dart'; // JsonSerializableGenerator // ************************************************************************** -MessageReminder _$MessageReminderFromJson(Map json) => - MessageReminder( - channelCid: json['channel_cid'] as String, - channel: json['channel'] == null - ? null - : ChannelModel.fromJson(json['channel'] as Map), - messageId: json['message_id'] as String, - message: json['message'] == null - ? null - : Message.fromJson(json['message'] as Map), - userId: json['user_id'] as String, - user: json['user'] == null - ? null - : User.fromJson(json['user'] as Map), - remindAt: json['remind_at'] == null - ? null - : DateTime.parse(json['remind_at'] as String), - createdAt: json['created_at'] == null - ? null - : DateTime.parse(json['created_at'] as String), - updatedAt: json['updated_at'] == null - ? null - : DateTime.parse(json['updated_at'] as String), - ); +MessageReminder _$MessageReminderFromJson(Map json) => MessageReminder( + channelCid: json['channel_cid'] as String, + channel: json['channel'] == null ? null : ChannelModel.fromJson(json['channel'] as Map), + messageId: json['message_id'] as String, + message: json['message'] == null ? null : Message.fromJson(json['message'] as Map), + userId: json['user_id'] as String, + user: json['user'] == null ? null : User.fromJson(json['user'] as Map), + remindAt: json['remind_at'] == null ? null : DateTime.parse(json['remind_at'] as String), + createdAt: json['created_at'] == null ? null : DateTime.parse(json['created_at'] as String), + updatedAt: json['updated_at'] == null ? null : DateTime.parse(json['updated_at'] as String), +); -Map _$MessageReminderToJson(MessageReminder instance) => - { - 'channel_cid': instance.channelCid, - 'message_id': instance.messageId, - 'user_id': instance.userId, - 'remind_at': instance.remindAt?.toIso8601String(), - 'created_at': instance.createdAt.toIso8601String(), - 'updated_at': instance.updatedAt.toIso8601String(), - }; +Map _$MessageReminderToJson(MessageReminder instance) => { + 'channel_cid': instance.channelCid, + 'message_id': instance.messageId, + 'user_id': instance.userId, + 'remind_at': instance.remindAt?.toIso8601String(), + 'created_at': instance.createdAt.toIso8601String(), + 'updated_at': instance.updatedAt.toIso8601String(), +}; diff --git a/packages/stream_chat/lib/src/core/models/message_state.dart b/packages/stream_chat/lib/src/core/models/message_state.dart index 0cbefec15b..bcd7f20550 100644 --- a/packages/stream_chat/lib/src/core/models/message_state.dart +++ b/packages/stream_chat/lib/src/core/models/message_state.dart @@ -1,9 +1,9 @@ // ignore_for_file: avoid_positional_boolean_parameters import 'package:freezed_annotation/freezed_annotation.dart'; +import 'package:stream_chat/src/core/models/message_delete_scope.dart'; part 'message_state.freezed.dart'; - part 'message_state.g.dart'; /// Helper extension for [MessageState]. @@ -33,7 +33,7 @@ extension MessageStateX on MessageState { } /// Returns true if the message is in outgoing deleting state. - bool get isDeleting => isSoftDeleting || isHardDeleting; + bool get isDeleting => isSoftDeleting || isHardDeleting || isDeletingForMe; /// Returns true if the message is in outgoing soft deleting state. bool get isSoftDeleting { @@ -43,7 +43,10 @@ extension MessageStateX on MessageState { final outgoingState = messageState.state; if (outgoingState is! Deleting) return false; - return !outgoingState.hard; + final deletingScope = outgoingState.scope; + if (deletingScope is! DeleteForAll) return false; + + return !deletingScope.hard; } /// Returns true if the message is in outgoing hard deleting state. @@ -54,7 +57,22 @@ extension MessageStateX on MessageState { final outgoingState = messageState.state; if (outgoingState is! Deleting) return false; - return outgoingState.hard; + final deletingScope = outgoingState.scope; + if (deletingScope is! DeleteForAll) return false; + + return deletingScope.hard; + } + + /// Returns true if the message is in outgoing deleting for me state. + bool get isDeletingForMe { + final messageState = this; + if (messageState is! MessageOutgoing) return false; + + final outgoingState = messageState.state; + if (outgoingState is! Deleting) return false; + + final deletingScope = outgoingState.scope; + return deletingScope is DeleteForMe; } /// Returns true if the message is in completed sent state. @@ -70,7 +88,7 @@ extension MessageStateX on MessageState { } /// Returns true if the message is in completed deleted state. - bool get isDeleted => isSoftDeleted || isHardDeleted; + bool get isDeleted => isSoftDeleted || isHardDeleted || isDeletedForMe; /// Returns true if the message is in completed soft deleted state. bool get isSoftDeleted { @@ -80,7 +98,10 @@ extension MessageStateX on MessageState { final completedState = messageState.state; if (completedState is! Deleted) return false; - return !completedState.hard; + final deletingScope = completedState.scope; + if (deletingScope is! DeleteForAll) return false; + + return !deletingScope.hard; } /// Returns true if the message is in completed hard deleted state. @@ -91,7 +112,22 @@ extension MessageStateX on MessageState { final completedState = messageState.state; if (completedState is! Deleted) return false; - return completedState.hard; + final deletingScope = completedState.scope; + if (deletingScope is! DeleteForAll) return false; + + return deletingScope.hard; + } + + /// Returns true if the message is in completed deleted for me state. + bool get isDeletedForMe { + final messageState = this; + if (messageState is! MessageCompleted) return false; + + final completedState = messageState.state; + if (completedState is! Deleted) return false; + + final deletingScope = completedState.scope; + return deletingScope is DeleteForMe; } /// Returns true if the message is in failed sending state. @@ -103,13 +139,15 @@ extension MessageStateX on MessageState { /// Returns true if the message is in failed updating state. bool get isUpdatingFailed { - final messageState = this; - if (messageState is! MessageFailed) return false; - return messageState.state is UpdatingFailed; + return switch (this) { + MessageFailed(state: UpdatingFailed()) => true, + MessageFailed(state: PartialUpdatingFailed()) => true, + _ => false, + }; } /// Returns true if the message is in failed deleting state. - bool get isDeletingFailed => isSoftDeletingFailed || isHardDeletingFailed; + bool get isDeletingFailed => isSoftDeletingFailed || isHardDeletingFailed || isDeletingForMeFailed; /// Returns true if the message is in failed soft deleting state. bool get isSoftDeletingFailed { @@ -119,7 +157,10 @@ extension MessageStateX on MessageState { final failedState = messageState.state; if (failedState is! DeletingFailed) return false; - return !failedState.hard; + final deletingScope = failedState.scope; + if (deletingScope is! DeleteForAll) return false; + + return !deletingScope.hard; } /// Returns true if the message is in failed hard deleting state. @@ -130,7 +171,22 @@ extension MessageStateX on MessageState { final failedState = messageState.state; if (failedState is! DeletingFailed) return false; - return failedState.hard; + final deletingScope = failedState.scope; + if (deletingScope is! DeleteForAll) return false; + + return deletingScope.hard; + } + + /// Returns true if the message is in failed deleting for me state. + bool get isDeletingForMeFailed { + final messageState = this; + if (messageState is! MessageFailed) return false; + + final failedState = messageState.state; + if (failedState is! DeletingFailed) return false; + + final deletingScope = failedState.scope; + return deletingScope is DeleteForMe; } } @@ -158,30 +214,81 @@ sealed class MessageState with _$MessageState { }) = MessageFailed; /// Creates a new instance from a json - factory MessageState.fromJson(Map json) => - _$MessageStateFromJson(json); + factory MessageState.fromJson(Map json) => _$MessageStateFromJson(json); + + // region Factory Constructors for Common States /// Deleting state when the message is being deleted. - factory MessageState.deleting({required bool hard}) { + factory MessageState.deleting({ + required MessageDeleteScope scope, + }) { return MessageState.outgoing( - state: OutgoingState.deleting(hard: hard), + state: OutgoingState.deleting(scope: scope), ); } /// Deleting state when the message has been successfully deleted. - factory MessageState.deleted({required bool hard}) { + factory MessageState.deleted({ + required MessageDeleteScope scope, + }) { return MessageState.completed( - state: CompletedState.deleted(hard: hard), + state: CompletedState.deleted(scope: scope), ); } /// Deleting failed state when the message fails to be deleted. - factory MessageState.deletingFailed({required bool hard}) { + factory MessageState.deletingFailed({ + required MessageDeleteScope scope, + }) { return MessageState.failed( - state: FailedState.deletingFailed(hard: hard), + state: FailedState.deletingFailed(scope: scope), ); } + /// Sending failed state when the message fails to be sent. + factory MessageState.sendingFailed({ + required bool skipPush, + required bool skipEnrichUrl, + }) { + return MessageState.failed( + state: FailedState.sendingFailed( + skipPush: skipPush, + skipEnrichUrl: skipEnrichUrl, + ), + ); + } + + /// Updating failed state when the message fails to be updated. + factory MessageState.updatingFailed({ + required bool skipPush, + required bool skipEnrichUrl, + }) { + return MessageState.failed( + state: FailedState.updatingFailed( + skipPush: skipPush, + skipEnrichUrl: skipEnrichUrl, + ), + ); + } + + factory MessageState.partialUpdatingFailed({ + Map? set, + List? unset, + required bool skipEnrichUrl, + }) { + return MessageState.failed( + state: FailedState.partialUpdatingFailed( + set: set, + unset: unset, + skipEnrichUrl: skipEnrichUrl, + ), + ); + } + + // endregion + + // region Common Static Instances + /// Sending state when the message is being sent. static const sending = MessageState.outgoing( state: OutgoingState.sending(), @@ -199,7 +306,16 @@ sealed class MessageState with _$MessageState { /// Hard deleting state when the message is being hard deleted. static const hardDeleting = MessageState.outgoing( - state: OutgoingState.deleting(hard: true), + state: OutgoingState.deleting( + scope: MessageDeleteScope.hardDeleteForAll, + ), + ); + + /// Deleting for me state when the message is being deleted only for me. + static const deletingForMe = MessageState.outgoing( + state: OutgoingState.deleting( + scope: MessageDeleteScope.deleteForMe(), + ), ); /// Sent state when the message has been successfully sent. @@ -219,17 +335,17 @@ sealed class MessageState with _$MessageState { /// Hard deleted state when the message has been successfully hard deleted. static const hardDeleted = MessageState.completed( - state: CompletedState.deleted(hard: true), + state: CompletedState.deleted( + scope: MessageDeleteScope.hardDeleteForAll, + ), ); - /// Sending failed state when the message fails to be sent. - static const sendingFailed = MessageState.failed( - state: FailedState.sendingFailed(), - ); - - /// Updating failed state when the message fails to be updated. - static const updatingFailed = MessageState.failed( - state: FailedState.updatingFailed(), + /// Deleted for me state when the message has been successfully deleted only + /// for me. + static const deletedForMe = MessageState.completed( + state: CompletedState.deleted( + scope: MessageDeleteScope.deleteForMe(), + ), ); /// Deleting failed state when the message fails to be soft deleted. @@ -239,8 +355,19 @@ sealed class MessageState with _$MessageState { /// Hard deleting failed state when the message fails to be hard deleted. static const hardDeletingFailed = MessageState.failed( - state: FailedState.deletingFailed(hard: true), + state: FailedState.deletingFailed( + scope: MessageDeleteScope.hardDeleteForAll, + ), ); + + /// Deleting for me failed state when the message fails to be deleted only + static const deletingForMeFailed = MessageState.failed( + state: FailedState.deletingFailed( + scope: MessageDeleteScope.deleteForMe(), + ), + ); + + // endregion } /// Represents the state of an outgoing message. @@ -254,12 +381,11 @@ sealed class OutgoingState with _$OutgoingState { /// Deleting state when the message is being deleted. const factory OutgoingState.deleting({ - @Default(false) bool hard, + @Default(MessageDeleteScope.softDeleteForAll) MessageDeleteScope scope, }) = Deleting; /// Creates a new instance from a json - factory OutgoingState.fromJson(Map json) => - _$OutgoingStateFromJson(json); + factory OutgoingState.fromJson(Map json) => _$OutgoingStateFromJson(json); } /// Represents the completed state of a message. @@ -273,31 +399,41 @@ sealed class CompletedState with _$CompletedState { /// Deleted state when the message has been successfully deleted. const factory CompletedState.deleted({ - @Default(false) bool hard, + @Default(MessageDeleteScope.softDeleteForAll) MessageDeleteScope scope, }) = Deleted; /// Creates a new instance from a json - factory CompletedState.fromJson(Map json) => - _$CompletedStateFromJson(json); + factory CompletedState.fromJson(Map json) => _$CompletedStateFromJson(json); } /// Represents the failed state of a message. @freezed sealed class FailedState with _$FailedState { /// Sending failed state when the message fails to be sent. - const factory FailedState.sendingFailed() = SendingFailed; + const factory FailedState.sendingFailed({ + @Default(false) bool skipPush, + @Default(false) bool skipEnrichUrl, + }) = SendingFailed; /// Updating failed state when the message fails to be updated. - const factory FailedState.updatingFailed() = UpdatingFailed; + const factory FailedState.updatingFailed({ + @Default(false) bool skipPush, + @Default(false) bool skipEnrichUrl, + }) = UpdatingFailed; + + const factory FailedState.partialUpdatingFailed({ + Map? set, + List? unset, + @Default(false) bool skipEnrichUrl, + }) = PartialUpdatingFailed; /// Deleting failed state when the message fails to be deleted. const factory FailedState.deletingFailed({ - @Default(false) bool hard, + @Default(MessageDeleteScope.softDeleteForAll) MessageDeleteScope scope, }) = DeletingFailed; /// Creates a new instance from a json - factory FailedState.fromJson(Map json) => - _$FailedStateFromJson(json); + factory FailedState.fromJson(Map json) => _$FailedStateFromJson(json); } // coverage:ignore-start @@ -420,13 +556,13 @@ extension OutgoingStatePatternMatching on OutgoingState { TResult when({ required TResult Function() sending, required TResult Function() updating, - required TResult Function(bool hard) deleting, + required TResult Function(MessageDeleteScope scope) deleting, }) { final outgoingState = this; return switch (outgoingState) { Sending() => sending(), Updating() => updating(), - Deleting() => deleting(outgoingState.hard), + Deleting() => deleting(outgoingState.scope), }; } @@ -435,13 +571,13 @@ extension OutgoingStatePatternMatching on OutgoingState { TResult? whenOrNull({ TResult? Function()? sending, TResult? Function()? updating, - TResult? Function(bool hard)? deleting, + TResult? Function(MessageDeleteScope scope)? deleting, }) { final outgoingState = this; return switch (outgoingState) { Sending() => sending?.call(), Updating() => updating?.call(), - Deleting() => deleting?.call(outgoingState.hard), + Deleting() => deleting?.call(outgoingState.scope), }; } @@ -450,14 +586,14 @@ extension OutgoingStatePatternMatching on OutgoingState { TResult maybeWhen({ TResult Function()? sending, TResult Function()? updating, - TResult Function(bool hard)? deleting, + TResult Function(MessageDeleteScope scope)? deleting, required TResult orElse(), }) { final outgoingState = this; final result = switch (outgoingState) { Sending() => sending?.call(), Updating() => updating?.call(), - Deleting() => deleting?.call(outgoingState.hard), + Deleting() => deleting?.call(outgoingState.scope), }; return result ?? orElse(); @@ -519,13 +655,13 @@ extension CompletedStatePatternMatching on CompletedState { TResult when({ required TResult Function() sent, required TResult Function() updated, - required TResult Function(bool hard) deleted, + required TResult Function(MessageDeleteScope scope) deleted, }) { final completedState = this; return switch (completedState) { Sent() => sent(), Updated() => updated(), - Deleted() => deleted(completedState.hard), + Deleted() => deleted(completedState.scope), }; } @@ -534,13 +670,13 @@ extension CompletedStatePatternMatching on CompletedState { TResult? whenOrNull({ TResult? Function()? sent, TResult? Function()? updated, - TResult? Function(bool hard)? deleted, + TResult? Function(MessageDeleteScope scope)? deleted, }) { final completedState = this; return switch (completedState) { Sent() => sent?.call(), Updated() => updated?.call(), - Deleted() => deleted?.call(completedState.hard), + Deleted() => deleted?.call(completedState.scope), }; } @@ -549,14 +685,14 @@ extension CompletedStatePatternMatching on CompletedState { TResult maybeWhen({ TResult Function()? sent, TResult Function()? updated, - TResult Function(bool hard)? deleted, + TResult Function(MessageDeleteScope scope)? deleted, required TResult orElse(), }) { final completedState = this; final result = switch (completedState) { Sent() => sent?.call(), Updated() => updated?.call(), - Deleted() => deleted?.call(completedState.hard), + Deleted() => deleted?.call(completedState.scope), }; return result ?? orElse(); @@ -616,46 +752,52 @@ extension FailedStatePatternMatching on FailedState { /// @nodoc @optionalTypeArgs TResult when({ - required TResult Function() sendingFailed, - required TResult Function() updatingFailed, - required TResult Function(bool hard) deletingFailed, + required TResult Function(bool skipPush, bool skipEnrichUrl) sendingFailed, + required TResult Function(bool skipPush, bool skipEnrichUrl) updatingFailed, + required TResult Function(Map? set, List? unset, bool skipEnrichUrl) partialUpdatingFailed, + required TResult Function(MessageDeleteScope scope) deletingFailed, }) { final failedState = this; return switch (failedState) { - SendingFailed() => sendingFailed(), - UpdatingFailed() => updatingFailed(), - DeletingFailed() => deletingFailed(failedState.hard), + SendingFailed() => sendingFailed(failedState.skipPush, failedState.skipEnrichUrl), + UpdatingFailed() => updatingFailed(failedState.skipPush, failedState.skipEnrichUrl), + PartialUpdatingFailed() => partialUpdatingFailed(failedState.set, failedState.unset, failedState.skipEnrichUrl), + DeletingFailed() => deletingFailed(failedState.scope), }; } /// @nodoc @optionalTypeArgs TResult? whenOrNull({ - TResult? Function()? sendingFailed, - TResult? Function()? updatingFailed, - TResult? Function(bool hard)? deletingFailed, + TResult? Function(bool skipPush, bool skipEnrichUrl)? sendingFailed, + TResult? Function(bool skipPush, bool skipEnrichUrl)? updatingFailed, + required TResult Function(Map? set, List? unset, bool skipEnrichUrl) partialUpdatingFailed, + TResult? Function(MessageDeleteScope scope)? deletingFailed, }) { final failedState = this; return switch (failedState) { - SendingFailed() => sendingFailed?.call(), - UpdatingFailed() => updatingFailed?.call(), - DeletingFailed() => deletingFailed?.call(failedState.hard), + SendingFailed() => sendingFailed?.call(failedState.skipPush, failedState.skipEnrichUrl), + UpdatingFailed() => updatingFailed?.call(failedState.skipPush, failedState.skipEnrichUrl), + PartialUpdatingFailed() => partialUpdatingFailed(failedState.set, failedState.unset, failedState.skipEnrichUrl), + DeletingFailed() => deletingFailed?.call(failedState.scope), }; } /// @nodoc @optionalTypeArgs TResult maybeWhen({ - TResult Function()? sendingFailed, - TResult Function()? updatingFailed, - TResult Function(bool hard)? deletingFailed, + TResult Function(bool skipPush, bool skipEnrichUrl)? sendingFailed, + TResult Function(bool skipPush, bool skipEnrichUrl)? updatingFailed, + required TResult Function(Map? set, List? unset, bool skipEnrichUrl) partialUpdatingFailed, + TResult Function(MessageDeleteScope scope)? deletingFailed, required TResult orElse(), }) { final failedState = this; final result = switch (failedState) { - SendingFailed() => sendingFailed?.call(), - UpdatingFailed() => updatingFailed?.call(), - DeletingFailed() => deletingFailed?.call(failedState.hard), + SendingFailed() => sendingFailed?.call(failedState.skipPush, failedState.skipEnrichUrl), + UpdatingFailed() => updatingFailed?.call(failedState.skipPush, failedState.skipEnrichUrl), + PartialUpdatingFailed() => partialUpdatingFailed(failedState.set, failedState.unset, failedState.skipEnrichUrl), + DeletingFailed() => deletingFailed?.call(failedState.scope), }; return result ?? orElse(); @@ -666,12 +808,14 @@ extension FailedStatePatternMatching on FailedState { TResult map({ required TResult Function(SendingFailed value) sendingFailed, required TResult Function(UpdatingFailed value) updatingFailed, + required TResult Function(PartialUpdatingFailed value) partialUpdatingFailed, required TResult Function(DeletingFailed value) deletingFailed, }) { final failedState = this; return switch (failedState) { SendingFailed() => sendingFailed(failedState), UpdatingFailed() => updatingFailed(failedState), + PartialUpdatingFailed() => partialUpdatingFailed(failedState), DeletingFailed() => deletingFailed(failedState), }; } @@ -681,12 +825,14 @@ extension FailedStatePatternMatching on FailedState { TResult? mapOrNull({ TResult? Function(SendingFailed value)? sendingFailed, TResult? Function(UpdatingFailed value)? updatingFailed, + TResult? Function(PartialUpdatingFailed value)? partialUpdatingFailed, TResult? Function(DeletingFailed value)? deletingFailed, }) { final failedState = this; return switch (failedState) { SendingFailed() => sendingFailed?.call(failedState), UpdatingFailed() => updatingFailed?.call(failedState), + PartialUpdatingFailed() => partialUpdatingFailed?.call(failedState), DeletingFailed() => deletingFailed?.call(failedState), }; } @@ -696,6 +842,7 @@ extension FailedStatePatternMatching on FailedState { TResult maybeMap({ TResult Function(SendingFailed value)? sendingFailed, TResult Function(UpdatingFailed value)? updatingFailed, + TResult Function(PartialUpdatingFailed value)? partialUpdatingFailed, TResult Function(DeletingFailed value)? deletingFailed, required TResult orElse(), }) { @@ -703,6 +850,7 @@ extension FailedStatePatternMatching on FailedState { final result = switch (failedState) { SendingFailed() => sendingFailed?.call(failedState), UpdatingFailed() => updatingFailed?.call(failedState), + PartialUpdatingFailed() => partialUpdatingFailed?.call(failedState), DeletingFailed() => deletingFailed?.call(failedState), }; diff --git a/packages/stream_chat/lib/src/core/models/message_state.freezed.dart b/packages/stream_chat/lib/src/core/models/message_state.freezed.dart index 079d8b3696..f896483f61 100644 --- a/packages/stream_chat/lib/src/core/models/message_state.freezed.dart +++ b/packages/stream_chat/lib/src/core/models/message_state.freezed.dart @@ -473,13 +473,14 @@ class Updating implements OutgoingState { /// @nodoc @JsonSerializable() class Deleting implements OutgoingState { - const Deleting({this.hard = false, final String? $type}) + const Deleting( + {this.scope = MessageDeleteScope.softDeleteForAll, final String? $type}) : $type = $type ?? 'deleting'; factory Deleting.fromJson(Map json) => _$DeletingFromJson(json); @JsonKey() - final bool hard; + final MessageDeleteScope scope; @JsonKey(name: 'runtimeType') final String $type; @@ -503,16 +504,16 @@ class Deleting implements OutgoingState { return identical(this, other) || (other.runtimeType == runtimeType && other is Deleting && - (identical(other.hard, hard) || other.hard == hard)); + (identical(other.scope, scope) || other.scope == scope)); } @JsonKey(includeFromJson: false, includeToJson: false) @override - int get hashCode => Object.hash(runtimeType, hard); + int get hashCode => Object.hash(runtimeType, scope); @override String toString() { - return 'OutgoingState.deleting(hard: $hard)'; + return 'OutgoingState.deleting(scope: $scope)'; } } @@ -522,7 +523,9 @@ abstract mixin class $DeletingCopyWith<$Res> factory $DeletingCopyWith(Deleting value, $Res Function(Deleting) _then) = _$DeletingCopyWithImpl; @useResult - $Res call({bool hard}); + $Res call({MessageDeleteScope scope}); + + $MessageDeleteScopeCopyWith<$Res> get scope; } /// @nodoc @@ -536,15 +539,25 @@ class _$DeletingCopyWithImpl<$Res> implements $DeletingCopyWith<$Res> { /// with the given fields replaced by the non-null parameter values. @pragma('vm:prefer-inline') $Res call({ - Object? hard = null, + Object? scope = null, }) { return _then(Deleting( - hard: null == hard - ? _self.hard - : hard // ignore: cast_nullable_to_non_nullable - as bool, + scope: null == scope + ? _self.scope + : scope // ignore: cast_nullable_to_non_nullable + as MessageDeleteScope, )); } + + /// Create a copy of OutgoingState + /// with the given fields replaced by the non-null parameter values. + @override + @pragma('vm:prefer-inline') + $MessageDeleteScopeCopyWith<$Res> get scope { + return $MessageDeleteScopeCopyWith<$Res>(_self.scope, (value) { + return _then(_self.copyWith(scope: value)); + }); + } } CompletedState _$CompletedStateFromJson(Map json) { @@ -656,13 +669,14 @@ class Updated implements CompletedState { /// @nodoc @JsonSerializable() class Deleted implements CompletedState { - const Deleted({this.hard = false, final String? $type}) + const Deleted( + {this.scope = MessageDeleteScope.softDeleteForAll, final String? $type}) : $type = $type ?? 'deleted'; factory Deleted.fromJson(Map json) => _$DeletedFromJson(json); @JsonKey() - final bool hard; + final MessageDeleteScope scope; @JsonKey(name: 'runtimeType') final String $type; @@ -686,16 +700,16 @@ class Deleted implements CompletedState { return identical(this, other) || (other.runtimeType == runtimeType && other is Deleted && - (identical(other.hard, hard) || other.hard == hard)); + (identical(other.scope, scope) || other.scope == scope)); } @JsonKey(includeFromJson: false, includeToJson: false) @override - int get hashCode => Object.hash(runtimeType, hard); + int get hashCode => Object.hash(runtimeType, scope); @override String toString() { - return 'CompletedState.deleted(hard: $hard)'; + return 'CompletedState.deleted(scope: $scope)'; } } @@ -705,7 +719,9 @@ abstract mixin class $DeletedCopyWith<$Res> factory $DeletedCopyWith(Deleted value, $Res Function(Deleted) _then) = _$DeletedCopyWithImpl; @useResult - $Res call({bool hard}); + $Res call({MessageDeleteScope scope}); + + $MessageDeleteScopeCopyWith<$Res> get scope; } /// @nodoc @@ -719,15 +735,25 @@ class _$DeletedCopyWithImpl<$Res> implements $DeletedCopyWith<$Res> { /// with the given fields replaced by the non-null parameter values. @pragma('vm:prefer-inline') $Res call({ - Object? hard = null, + Object? scope = null, }) { return _then(Deleted( - hard: null == hard - ? _self.hard - : hard // ignore: cast_nullable_to_non_nullable - as bool, + scope: null == scope + ? _self.scope + : scope // ignore: cast_nullable_to_non_nullable + as MessageDeleteScope, )); } + + /// Create a copy of CompletedState + /// with the given fields replaced by the non-null parameter values. + @override + @pragma('vm:prefer-inline') + $MessageDeleteScopeCopyWith<$Res> get scope { + return $MessageDeleteScopeCopyWith<$Res>(_self.scope, (value) { + return _then(_self.copyWith(scope: value)); + }); + } } FailedState _$FailedStateFromJson(Map json) { @@ -736,6 +762,8 @@ FailedState _$FailedStateFromJson(Map json) { return SendingFailed.fromJson(json); case 'updatingFailed': return UpdatingFailed.fromJson(json); + case 'partialUpdatingFailed': + return PartialUpdatingFailed.fromJson(json); case 'deletingFailed': return DeletingFailed.fromJson(json); @@ -774,13 +802,27 @@ class $FailedStateCopyWith<$Res> { /// @nodoc @JsonSerializable() class SendingFailed implements FailedState { - const SendingFailed({final String? $type}) : $type = $type ?? 'sendingFailed'; + const SendingFailed( + {this.skipPush = false, this.skipEnrichUrl = false, final String? $type}) + : $type = $type ?? 'sendingFailed'; factory SendingFailed.fromJson(Map json) => _$SendingFailedFromJson(json); + @JsonKey() + final bool skipPush; + @JsonKey() + final bool skipEnrichUrl; + @JsonKey(name: 'runtimeType') final String $type; + /// Create a copy of FailedState + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + @pragma('vm:prefer-inline') + $SendingFailedCopyWith get copyWith => + _$SendingFailedCopyWithImpl(this, _$identity); + @override Map toJson() { return _$SendingFailedToJson( @@ -791,30 +833,86 @@ class SendingFailed implements FailedState { @override bool operator ==(Object other) { return identical(this, other) || - (other.runtimeType == runtimeType && other is SendingFailed); + (other.runtimeType == runtimeType && + other is SendingFailed && + (identical(other.skipPush, skipPush) || + other.skipPush == skipPush) && + (identical(other.skipEnrichUrl, skipEnrichUrl) || + other.skipEnrichUrl == skipEnrichUrl)); } @JsonKey(includeFromJson: false, includeToJson: false) @override - int get hashCode => runtimeType.hashCode; + int get hashCode => Object.hash(runtimeType, skipPush, skipEnrichUrl); @override String toString() { - return 'FailedState.sendingFailed()'; + return 'FailedState.sendingFailed(skipPush: $skipPush, skipEnrichUrl: $skipEnrichUrl)'; + } +} + +/// @nodoc +abstract mixin class $SendingFailedCopyWith<$Res> + implements $FailedStateCopyWith<$Res> { + factory $SendingFailedCopyWith( + SendingFailed value, $Res Function(SendingFailed) _then) = + _$SendingFailedCopyWithImpl; + @useResult + $Res call({bool skipPush, bool skipEnrichUrl}); +} + +/// @nodoc +class _$SendingFailedCopyWithImpl<$Res> + implements $SendingFailedCopyWith<$Res> { + _$SendingFailedCopyWithImpl(this._self, this._then); + + final SendingFailed _self; + final $Res Function(SendingFailed) _then; + + /// Create a copy of FailedState + /// with the given fields replaced by the non-null parameter values. + @pragma('vm:prefer-inline') + $Res call({ + Object? skipPush = null, + Object? skipEnrichUrl = null, + }) { + return _then(SendingFailed( + skipPush: null == skipPush + ? _self.skipPush + : skipPush // ignore: cast_nullable_to_non_nullable + as bool, + skipEnrichUrl: null == skipEnrichUrl + ? _self.skipEnrichUrl + : skipEnrichUrl // ignore: cast_nullable_to_non_nullable + as bool, + )); } } /// @nodoc @JsonSerializable() class UpdatingFailed implements FailedState { - const UpdatingFailed({final String? $type}) + const UpdatingFailed( + {this.skipPush = false, this.skipEnrichUrl = false, final String? $type}) : $type = $type ?? 'updatingFailed'; factory UpdatingFailed.fromJson(Map json) => _$UpdatingFailedFromJson(json); + @JsonKey() + final bool skipPush; + @JsonKey() + final bool skipEnrichUrl; + @JsonKey(name: 'runtimeType') final String $type; + /// Create a copy of FailedState + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + @pragma('vm:prefer-inline') + $UpdatingFailedCopyWith get copyWith => + _$UpdatingFailedCopyWithImpl(this, _$identity); + @override Map toJson() { return _$UpdatingFailedToJson( @@ -825,29 +923,195 @@ class UpdatingFailed implements FailedState { @override bool operator ==(Object other) { return identical(this, other) || - (other.runtimeType == runtimeType && other is UpdatingFailed); + (other.runtimeType == runtimeType && + other is UpdatingFailed && + (identical(other.skipPush, skipPush) || + other.skipPush == skipPush) && + (identical(other.skipEnrichUrl, skipEnrichUrl) || + other.skipEnrichUrl == skipEnrichUrl)); } @JsonKey(includeFromJson: false, includeToJson: false) @override - int get hashCode => runtimeType.hashCode; + int get hashCode => Object.hash(runtimeType, skipPush, skipEnrichUrl); + + @override + String toString() { + return 'FailedState.updatingFailed(skipPush: $skipPush, skipEnrichUrl: $skipEnrichUrl)'; + } +} + +/// @nodoc +abstract mixin class $UpdatingFailedCopyWith<$Res> + implements $FailedStateCopyWith<$Res> { + factory $UpdatingFailedCopyWith( + UpdatingFailed value, $Res Function(UpdatingFailed) _then) = + _$UpdatingFailedCopyWithImpl; + @useResult + $Res call({bool skipPush, bool skipEnrichUrl}); +} + +/// @nodoc +class _$UpdatingFailedCopyWithImpl<$Res> + implements $UpdatingFailedCopyWith<$Res> { + _$UpdatingFailedCopyWithImpl(this._self, this._then); + + final UpdatingFailed _self; + final $Res Function(UpdatingFailed) _then; + + /// Create a copy of FailedState + /// with the given fields replaced by the non-null parameter values. + @pragma('vm:prefer-inline') + $Res call({ + Object? skipPush = null, + Object? skipEnrichUrl = null, + }) { + return _then(UpdatingFailed( + skipPush: null == skipPush + ? _self.skipPush + : skipPush // ignore: cast_nullable_to_non_nullable + as bool, + skipEnrichUrl: null == skipEnrichUrl + ? _self.skipEnrichUrl + : skipEnrichUrl // ignore: cast_nullable_to_non_nullable + as bool, + )); + } +} + +/// @nodoc +@JsonSerializable() +class PartialUpdatingFailed implements FailedState { + const PartialUpdatingFailed( + {final Map? set, + final List? unset, + this.skipEnrichUrl = false, + final String? $type}) + : _set = set, + _unset = unset, + $type = $type ?? 'partialUpdatingFailed'; + factory PartialUpdatingFailed.fromJson(Map json) => + _$PartialUpdatingFailedFromJson(json); + + final Map? _set; + Map? get set { + final value = _set; + if (value == null) return null; + if (_set is EqualUnmodifiableMapView) return _set; + // ignore: implicit_dynamic_type + return EqualUnmodifiableMapView(value); + } + + final List? _unset; + List? get unset { + final value = _unset; + if (value == null) return null; + if (_unset is EqualUnmodifiableListView) return _unset; + // ignore: implicit_dynamic_type + return EqualUnmodifiableListView(value); + } + + @JsonKey() + final bool skipEnrichUrl; + + @JsonKey(name: 'runtimeType') + final String $type; + + /// Create a copy of FailedState + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + @pragma('vm:prefer-inline') + $PartialUpdatingFailedCopyWith get copyWith => + _$PartialUpdatingFailedCopyWithImpl( + this, _$identity); + + @override + Map toJson() { + return _$PartialUpdatingFailedToJson( + this, + ); + } + + @override + bool operator ==(Object other) { + return identical(this, other) || + (other.runtimeType == runtimeType && + other is PartialUpdatingFailed && + const DeepCollectionEquality().equals(other._set, _set) && + const DeepCollectionEquality().equals(other._unset, _unset) && + (identical(other.skipEnrichUrl, skipEnrichUrl) || + other.skipEnrichUrl == skipEnrichUrl)); + } + + @JsonKey(includeFromJson: false, includeToJson: false) + @override + int get hashCode => Object.hash( + runtimeType, + const DeepCollectionEquality().hash(_set), + const DeepCollectionEquality().hash(_unset), + skipEnrichUrl); @override String toString() { - return 'FailedState.updatingFailed()'; + return 'FailedState.partialUpdatingFailed(set: $set, unset: $unset, skipEnrichUrl: $skipEnrichUrl)'; + } +} + +/// @nodoc +abstract mixin class $PartialUpdatingFailedCopyWith<$Res> + implements $FailedStateCopyWith<$Res> { + factory $PartialUpdatingFailedCopyWith(PartialUpdatingFailed value, + $Res Function(PartialUpdatingFailed) _then) = + _$PartialUpdatingFailedCopyWithImpl; + @useResult + $Res call( + {Map? set, List? unset, bool skipEnrichUrl}); +} + +/// @nodoc +class _$PartialUpdatingFailedCopyWithImpl<$Res> + implements $PartialUpdatingFailedCopyWith<$Res> { + _$PartialUpdatingFailedCopyWithImpl(this._self, this._then); + + final PartialUpdatingFailed _self; + final $Res Function(PartialUpdatingFailed) _then; + + /// Create a copy of FailedState + /// with the given fields replaced by the non-null parameter values. + @pragma('vm:prefer-inline') + $Res call({ + Object? set = freezed, + Object? unset = freezed, + Object? skipEnrichUrl = null, + }) { + return _then(PartialUpdatingFailed( + set: freezed == set + ? _self._set + : set // ignore: cast_nullable_to_non_nullable + as Map?, + unset: freezed == unset + ? _self._unset + : unset // ignore: cast_nullable_to_non_nullable + as List?, + skipEnrichUrl: null == skipEnrichUrl + ? _self.skipEnrichUrl + : skipEnrichUrl // ignore: cast_nullable_to_non_nullable + as bool, + )); } } /// @nodoc @JsonSerializable() class DeletingFailed implements FailedState { - const DeletingFailed({this.hard = false, final String? $type}) + const DeletingFailed( + {this.scope = MessageDeleteScope.softDeleteForAll, final String? $type}) : $type = $type ?? 'deletingFailed'; factory DeletingFailed.fromJson(Map json) => _$DeletingFailedFromJson(json); @JsonKey() - final bool hard; + final MessageDeleteScope scope; @JsonKey(name: 'runtimeType') final String $type; @@ -871,16 +1135,16 @@ class DeletingFailed implements FailedState { return identical(this, other) || (other.runtimeType == runtimeType && other is DeletingFailed && - (identical(other.hard, hard) || other.hard == hard)); + (identical(other.scope, scope) || other.scope == scope)); } @JsonKey(includeFromJson: false, includeToJson: false) @override - int get hashCode => Object.hash(runtimeType, hard); + int get hashCode => Object.hash(runtimeType, scope); @override String toString() { - return 'FailedState.deletingFailed(hard: $hard)'; + return 'FailedState.deletingFailed(scope: $scope)'; } } @@ -891,7 +1155,9 @@ abstract mixin class $DeletingFailedCopyWith<$Res> DeletingFailed value, $Res Function(DeletingFailed) _then) = _$DeletingFailedCopyWithImpl; @useResult - $Res call({bool hard}); + $Res call({MessageDeleteScope scope}); + + $MessageDeleteScopeCopyWith<$Res> get scope; } /// @nodoc @@ -906,15 +1172,25 @@ class _$DeletingFailedCopyWithImpl<$Res> /// with the given fields replaced by the non-null parameter values. @pragma('vm:prefer-inline') $Res call({ - Object? hard = null, + Object? scope = null, }) { return _then(DeletingFailed( - hard: null == hard - ? _self.hard - : hard // ignore: cast_nullable_to_non_nullable - as bool, + scope: null == scope + ? _self.scope + : scope // ignore: cast_nullable_to_non_nullable + as MessageDeleteScope, )); } + + /// Create a copy of FailedState + /// with the given fields replaced by the non-null parameter values. + @override + @pragma('vm:prefer-inline') + $MessageDeleteScopeCopyWith<$Res> get scope { + return $MessageDeleteScopeCopyWith<$Res>(_self.scope, (value) { + return _then(_self.copyWith(scope: value)); + }); + } } // dart format on diff --git a/packages/stream_chat/lib/src/core/models/message_state.g.dart b/packages/stream_chat/lib/src/core/models/message_state.g.dart index e39b40667b..6336fe611a 100644 --- a/packages/stream_chat/lib/src/core/models/message_state.g.dart +++ b/packages/stream_chat/lib/src/core/models/message_state.g.dart @@ -6,134 +6,148 @@ part of 'message_state.dart'; // JsonSerializableGenerator // ************************************************************************** -MessageInitial _$MessageInitialFromJson(Map json) => - MessageInitial( - $type: json['runtimeType'] as String?, - ); - -Map _$MessageInitialToJson(MessageInitial instance) => - { - 'runtimeType': instance.$type, - }; - -MessageOutgoing _$MessageOutgoingFromJson(Map json) => - MessageOutgoing( - state: OutgoingState.fromJson(json['state'] as Map), - $type: json['runtimeType'] as String?, - ); - -Map _$MessageOutgoingToJson(MessageOutgoing instance) => - { - 'state': instance.state.toJson(), - 'runtimeType': instance.$type, - }; - -MessageCompleted _$MessageCompletedFromJson(Map json) => - MessageCompleted( - state: CompletedState.fromJson(json['state'] as Map), - $type: json['runtimeType'] as String?, - ); - -Map _$MessageCompletedToJson(MessageCompleted instance) => - { - 'state': instance.state.toJson(), - 'runtimeType': instance.$type, - }; - -MessageFailed _$MessageFailedFromJson(Map json) => - MessageFailed( - state: FailedState.fromJson(json['state'] as Map), - reason: json['reason'], - $type: json['runtimeType'] as String?, - ); - -Map _$MessageFailedToJson(MessageFailed instance) => - { - 'state': instance.state.toJson(), - 'reason': instance.reason, - 'runtimeType': instance.$type, - }; +MessageInitial _$MessageInitialFromJson(Map json) => MessageInitial( + $type: json['runtimeType'] as String?, +); + +Map _$MessageInitialToJson(MessageInitial instance) => { + 'runtimeType': instance.$type, +}; + +MessageOutgoing _$MessageOutgoingFromJson(Map json) => MessageOutgoing( + state: OutgoingState.fromJson(json['state'] as Map), + $type: json['runtimeType'] as String?, +); + +Map _$MessageOutgoingToJson(MessageOutgoing instance) => { + 'state': instance.state.toJson(), + 'runtimeType': instance.$type, +}; + +MessageCompleted _$MessageCompletedFromJson(Map json) => MessageCompleted( + state: CompletedState.fromJson(json['state'] as Map), + $type: json['runtimeType'] as String?, +); + +Map _$MessageCompletedToJson(MessageCompleted instance) => { + 'state': instance.state.toJson(), + 'runtimeType': instance.$type, +}; + +MessageFailed _$MessageFailedFromJson(Map json) => MessageFailed( + state: FailedState.fromJson(json['state'] as Map), + reason: json['reason'], + $type: json['runtimeType'] as String?, +); + +Map _$MessageFailedToJson(MessageFailed instance) => { + 'state': instance.state.toJson(), + 'reason': instance.reason, + 'runtimeType': instance.$type, +}; Sending _$SendingFromJson(Map json) => Sending( - $type: json['runtimeType'] as String?, - ); + $type: json['runtimeType'] as String?, +); Map _$SendingToJson(Sending instance) => { - 'runtimeType': instance.$type, - }; + 'runtimeType': instance.$type, +}; Updating _$UpdatingFromJson(Map json) => Updating( - $type: json['runtimeType'] as String?, - ); + $type: json['runtimeType'] as String?, +); Map _$UpdatingToJson(Updating instance) => { - 'runtimeType': instance.$type, - }; + 'runtimeType': instance.$type, +}; Deleting _$DeletingFromJson(Map json) => Deleting( - hard: json['hard'] as bool? ?? false, - $type: json['runtimeType'] as String?, - ); + scope: json['scope'] == null + ? MessageDeleteScope.softDeleteForAll + : MessageDeleteScope.fromJson(json['scope'] as Map), + $type: json['runtimeType'] as String?, +); Map _$DeletingToJson(Deleting instance) => { - 'hard': instance.hard, - 'runtimeType': instance.$type, - }; + 'scope': instance.scope.toJson(), + 'runtimeType': instance.$type, +}; Sent _$SentFromJson(Map json) => Sent( - $type: json['runtimeType'] as String?, - ); + $type: json['runtimeType'] as String?, +); Map _$SentToJson(Sent instance) => { - 'runtimeType': instance.$type, - }; + 'runtimeType': instance.$type, +}; Updated _$UpdatedFromJson(Map json) => Updated( - $type: json['runtimeType'] as String?, - ); + $type: json['runtimeType'] as String?, +); Map _$UpdatedToJson(Updated instance) => { - 'runtimeType': instance.$type, - }; + 'runtimeType': instance.$type, +}; Deleted _$DeletedFromJson(Map json) => Deleted( - hard: json['hard'] as bool? ?? false, - $type: json['runtimeType'] as String?, - ); + scope: json['scope'] == null + ? MessageDeleteScope.softDeleteForAll + : MessageDeleteScope.fromJson(json['scope'] as Map), + $type: json['runtimeType'] as String?, +); Map _$DeletedToJson(Deleted instance) => { - 'hard': instance.hard, - 'runtimeType': instance.$type, - }; - -SendingFailed _$SendingFailedFromJson(Map json) => - SendingFailed( - $type: json['runtimeType'] as String?, - ); - -Map _$SendingFailedToJson(SendingFailed instance) => - { - 'runtimeType': instance.$type, - }; - -UpdatingFailed _$UpdatingFailedFromJson(Map json) => - UpdatingFailed( - $type: json['runtimeType'] as String?, - ); - -Map _$UpdatingFailedToJson(UpdatingFailed instance) => - { - 'runtimeType': instance.$type, - }; - -DeletingFailed _$DeletingFailedFromJson(Map json) => - DeletingFailed( - hard: json['hard'] as bool? ?? false, - $type: json['runtimeType'] as String?, - ); - -Map _$DeletingFailedToJson(DeletingFailed instance) => - { - 'hard': instance.hard, - 'runtimeType': instance.$type, - }; + 'scope': instance.scope.toJson(), + 'runtimeType': instance.$type, +}; + +SendingFailed _$SendingFailedFromJson(Map json) => SendingFailed( + skipPush: json['skip_push'] as bool? ?? false, + skipEnrichUrl: json['skip_enrich_url'] as bool? ?? false, + $type: json['runtimeType'] as String?, +); + +Map _$SendingFailedToJson(SendingFailed instance) => { + 'skip_push': instance.skipPush, + 'skip_enrich_url': instance.skipEnrichUrl, + 'runtimeType': instance.$type, +}; + +UpdatingFailed _$UpdatingFailedFromJson(Map json) => UpdatingFailed( + skipPush: json['skip_push'] as bool? ?? false, + skipEnrichUrl: json['skip_enrich_url'] as bool? ?? false, + $type: json['runtimeType'] as String?, +); + +Map _$UpdatingFailedToJson(UpdatingFailed instance) => { + 'skip_push': instance.skipPush, + 'skip_enrich_url': instance.skipEnrichUrl, + 'runtimeType': instance.$type, +}; + +PartialUpdatingFailed _$PartialUpdatingFailedFromJson(Map json) => PartialUpdatingFailed( + set: json['set'] as Map?, + unset: (json['unset'] as List?)?.map((e) => e as String).toList(), + skipEnrichUrl: json['skip_enrich_url'] as bool? ?? false, + $type: json['runtimeType'] as String?, +); + +Map _$PartialUpdatingFailedToJson(PartialUpdatingFailed instance) => { + 'set': instance.set, + 'unset': instance.unset, + 'skip_enrich_url': instance.skipEnrichUrl, + 'runtimeType': instance.$type, +}; + +DeletingFailed _$DeletingFailedFromJson(Map json) => DeletingFailed( + scope: json['scope'] == null + ? MessageDeleteScope.softDeleteForAll + : MessageDeleteScope.fromJson(json['scope'] as Map), + $type: json['runtimeType'] as String?, +); + +Map _$DeletingFailedToJson(DeletingFailed instance) => { + 'scope': instance.scope.toJson(), + 'runtimeType': instance.$type, +}; diff --git a/packages/stream_chat/lib/src/core/models/moderation.dart b/packages/stream_chat/lib/src/core/models/moderation.dart index e267afc7de..f3133a39a6 100644 --- a/packages/stream_chat/lib/src/core/models/moderation.dart +++ b/packages/stream_chat/lib/src/core/models/moderation.dart @@ -20,8 +20,7 @@ class Moderation extends Equatable { }); /// Create a new instance from a json - factory Moderation.fromJson(Map json) => - _$ModerationFromJson(json); + factory Moderation.fromJson(Map json) => _$ModerationFromJson(json); /// The action taken by the moderation system. @JsonKey( @@ -53,14 +52,14 @@ class Moderation extends Equatable { @override List get props => [ - action, - originalText, - textHarms, - imageHarms, - blocklistMatched, - semanticFilterMatched, - platformCircumvented, - ]; + action, + originalText, + textHarms, + imageHarms, + blocklistMatched, + semanticFilterMatched, + platformCircumvented, + ]; } /// The moderation action performed over the message. diff --git a/packages/stream_chat/lib/src/core/models/moderation.g.dart b/packages/stream_chat/lib/src/core/models/moderation.g.dart index 6f8ef841c1..f287d23d84 100644 --- a/packages/stream_chat/lib/src/core/models/moderation.g.dart +++ b/packages/stream_chat/lib/src/core/models/moderation.g.dart @@ -7,26 +7,21 @@ part of 'moderation.dart'; // ************************************************************************** Moderation _$ModerationFromJson(Map json) => Moderation( - action: ModerationAction.fromJson(json['action'] as String), - originalText: json['original_text'] as String, - textHarms: (json['text_harms'] as List?) - ?.map((e) => e as String) - .toList(), - imageHarms: (json['image_harms'] as List?) - ?.map((e) => e as String) - .toList(), - blocklistMatched: json['blocklist_matched'] as String?, - semanticFilterMatched: json['semantic_filter_matched'] as String?, - platformCircumvented: json['platform_circumvented'] as bool? ?? false, - ); + action: ModerationAction.fromJson(json['action'] as String), + originalText: json['original_text'] as String, + textHarms: (json['text_harms'] as List?)?.map((e) => e as String).toList(), + imageHarms: (json['image_harms'] as List?)?.map((e) => e as String).toList(), + blocklistMatched: json['blocklist_matched'] as String?, + semanticFilterMatched: json['semantic_filter_matched'] as String?, + platformCircumvented: json['platform_circumvented'] as bool? ?? false, +); -Map _$ModerationToJson(Moderation instance) => - { - 'action': ModerationAction.toJson(instance.action), - 'original_text': instance.originalText, - 'text_harms': instance.textHarms, - 'image_harms': instance.imageHarms, - 'blocklist_matched': instance.blocklistMatched, - 'semantic_filter_matched': instance.semanticFilterMatched, - 'platform_circumvented': instance.platformCircumvented, - }; +Map _$ModerationToJson(Moderation instance) => { + 'action': ModerationAction.toJson(instance.action), + 'original_text': instance.originalText, + 'text_harms': instance.textHarms, + 'image_harms': instance.imageHarms, + 'blocklist_matched': instance.blocklistMatched, + 'semantic_filter_matched': instance.semanticFilterMatched, + 'platform_circumvented': instance.platformCircumvented, +}; diff --git a/packages/stream_chat/lib/src/core/models/mute.g.dart b/packages/stream_chat/lib/src/core/models/mute.g.dart index 624df9cb70..b1e3dc7aac 100644 --- a/packages/stream_chat/lib/src/core/models/mute.g.dart +++ b/packages/stream_chat/lib/src/core/models/mute.g.dart @@ -7,19 +7,17 @@ part of 'mute.dart'; // ************************************************************************** Mute _$MuteFromJson(Map json) => Mute( - user: User.fromJson(json['user'] as Map), - target: User.fromJson(json['target'] as Map), - createdAt: DateTime.parse(json['created_at'] as String), - updatedAt: DateTime.parse(json['updated_at'] as String), - expires: json['expires'] == null - ? null - : DateTime.parse(json['expires'] as String), - ); + user: User.fromJson(json['user'] as Map), + target: User.fromJson(json['target'] as Map), + createdAt: DateTime.parse(json['created_at'] as String), + updatedAt: DateTime.parse(json['updated_at'] as String), + expires: json['expires'] == null ? null : DateTime.parse(json['expires'] as String), +); Map _$MuteToJson(Mute instance) => { - 'user': instance.user.toJson(), - 'target': instance.target.toJson(), - 'created_at': instance.createdAt.toIso8601String(), - 'updated_at': instance.updatedAt.toIso8601String(), - 'expires': instance.expires?.toIso8601String(), - }; + 'user': instance.user.toJson(), + 'target': instance.target.toJson(), + 'created_at': instance.createdAt.toIso8601String(), + 'updated_at': instance.updatedAt.toIso8601String(), + 'expires': instance.expires?.toIso8601String(), +}; diff --git a/packages/stream_chat/lib/src/core/models/own_user.dart b/packages/stream_chat/lib/src/core/models/own_user.dart index c685761937..e3e8b83b9b 100644 --- a/packages/stream_chat/lib/src/core/models/own_user.dart +++ b/packages/stream_chat/lib/src/core/models/own_user.dart @@ -40,8 +40,8 @@ class OwnUser extends User { /// Create a new instance from json. factory OwnUser.fromJson(Map json) => _$OwnUserFromJson( - Serializer.moveToExtraDataFromRoot(json, topLevelFields), - ); + Serializer.moveToExtraDataFromRoot(json, topLevelFields), + ); /// Create a new instance from [User] object. factory OwnUser.fromUser(User user) => OwnUser.fromJson(user.toJson()); @@ -74,37 +74,37 @@ class OwnUser extends User { int? avgResponseTime, PushPreference? pushPreferences, PrivacySettings? privacySettings, - }) => - OwnUser( - id: id ?? this.id, - role: role ?? this.role, - name: name ?? - extraData?['name'] as String? ?? - // Using extraData value in order to not use id as name. - this.extraData['name'] as String?, - image: image ?? extraData?['image'] as String? ?? this.image, - banned: banned ?? this.banned, - banExpires: banExpires ?? this.banExpires, - createdAt: createdAt ?? this.createdAt, - updatedAt: updatedAt ?? this.updatedAt, - lastActive: lastActive ?? this.lastActive, - online: online ?? this.online, - extraData: extraData ?? this.extraData, - teams: teams ?? this.teams, - channelMutes: channelMutes ?? this.channelMutes, - devices: devices ?? this.devices, - mutes: mutes ?? this.mutes, - totalUnreadCount: totalUnreadCount ?? this.totalUnreadCount, - unreadChannels: unreadChannels ?? this.unreadChannels, - unreadThreads: unreadThreads ?? this.unreadThreads, - blockedUserIds: blockedUserIds ?? this.blockedUserIds, - language: language ?? this.language, - invisible: invisible ?? this.invisible, - teamsRole: teamsRole ?? this.teamsRole, - avgResponseTime: avgResponseTime ?? this.avgResponseTime, - pushPreferences: pushPreferences ?? this.pushPreferences, - privacySettings: privacySettings ?? this.privacySettings, - ); + }) => OwnUser( + id: id ?? this.id, + role: role ?? this.role, + name: + name ?? + extraData?['name'] as String? ?? + // Using extraData value in order to not use id as name. + this.extraData['name'] as String?, + image: image ?? extraData?['image'] as String? ?? this.image, + banned: banned ?? this.banned, + banExpires: banExpires ?? this.banExpires, + createdAt: createdAt ?? this.createdAt, + updatedAt: updatedAt ?? this.updatedAt, + lastActive: lastActive ?? this.lastActive, + online: online ?? this.online, + extraData: extraData ?? this.extraData, + teams: teams ?? this.teams, + channelMutes: channelMutes ?? this.channelMutes, + devices: devices ?? this.devices, + mutes: mutes ?? this.mutes, + totalUnreadCount: totalUnreadCount ?? this.totalUnreadCount, + unreadChannels: unreadChannels ?? this.unreadChannels, + unreadThreads: unreadThreads ?? this.unreadThreads, + blockedUserIds: blockedUserIds ?? this.blockedUserIds, + language: language ?? this.language, + invisible: invisible ?? this.invisible, + teamsRole: teamsRole ?? this.teamsRole, + avgResponseTime: avgResponseTime ?? this.avgResponseTime, + pushPreferences: pushPreferences ?? this.pushPreferences, + privacySettings: privacySettings ?? this.privacySettings, + ); /// Returns a new [OwnUser] that is a combination of this ownUser /// and the given [other] ownUser. diff --git a/packages/stream_chat/lib/src/core/models/own_user.g.dart b/packages/stream_chat/lib/src/core/models/own_user.g.dart index 21fc564e29..d8e69df5dd 100644 --- a/packages/stream_chat/lib/src/core/models/own_user.g.dart +++ b/packages/stream_chat/lib/src/core/models/own_user.g.dart @@ -7,90 +7,62 @@ part of 'own_user.dart'; // ************************************************************************** OwnUser _$OwnUserFromJson(Map json) => OwnUser( - devices: (json['devices'] as List?) - ?.map((e) => Device.fromJson(e as Map)) - .toList() ?? - const [], - mutes: (json['mutes'] as List?) - ?.map((e) => Mute.fromJson(e as Map)) - .toList() ?? - const [], - totalUnreadCount: (json['total_unread_count'] as num?)?.toInt() ?? 0, - unreadChannels: (json['unread_channels'] as num?)?.toInt() ?? 0, - channelMutes: (json['channel_mutes'] as List?) - ?.map((e) => ChannelMute.fromJson(e as Map)) - .toList() ?? - const [], - unreadThreads: (json['unread_threads'] as num?)?.toInt() ?? 0, - blockedUserIds: (json['blocked_user_ids'] as List?) - ?.map((e) => e as String) - .toList() ?? - const [], - pushPreferences: json['push_preferences'] == null - ? null - : PushPreference.fromJson( - json['push_preferences'] as Map), - privacySettings: json['privacy_settings'] == null - ? null - : PrivacySettings.fromJson( - json['privacy_settings'] as Map), - id: json['id'] as String, - role: json['role'] as String?, - createdAt: json['created_at'] == null - ? null - : DateTime.parse(json['created_at'] as String), - updatedAt: json['updated_at'] == null - ? null - : DateTime.parse(json['updated_at'] as String), - lastActive: json['last_active'] == null - ? null - : DateTime.parse(json['last_active'] as String), - online: json['online'] as bool? ?? false, - extraData: json['extra_data'] as Map? ?? const {}, - banned: json['banned'] as bool? ?? false, - banExpires: json['ban_expires'] == null - ? null - : DateTime.parse(json['ban_expires'] as String), - teams: - (json['teams'] as List?)?.map((e) => e as String).toList() ?? - const [], - language: json['language'] as String?, - invisible: json['invisible'] as bool?, - teamsRole: (json['teams_role'] as Map?)?.map( - (k, e) => MapEntry(k, e as String), - ), - avgResponseTime: (json['avg_response_time'] as num?)?.toInt(), - ); + devices: + (json['devices'] as List?)?.map((e) => Device.fromJson(e as Map)).toList() ?? const [], + mutes: (json['mutes'] as List?)?.map((e) => Mute.fromJson(e as Map)).toList() ?? const [], + totalUnreadCount: (json['total_unread_count'] as num?)?.toInt() ?? 0, + unreadChannels: (json['unread_channels'] as num?)?.toInt() ?? 0, + channelMutes: + (json['channel_mutes'] as List?)?.map((e) => ChannelMute.fromJson(e as Map)).toList() ?? + const [], + unreadThreads: (json['unread_threads'] as num?)?.toInt() ?? 0, + blockedUserIds: (json['blocked_user_ids'] as List?)?.map((e) => e as String).toList() ?? const [], + pushPreferences: json['push_preferences'] == null + ? null + : PushPreference.fromJson(json['push_preferences'] as Map), + privacySettings: json['privacy_settings'] == null + ? null + : PrivacySettings.fromJson(json['privacy_settings'] as Map), + id: json['id'] as String, + role: json['role'] as String?, + createdAt: json['created_at'] == null ? null : DateTime.parse(json['created_at'] as String), + updatedAt: json['updated_at'] == null ? null : DateTime.parse(json['updated_at'] as String), + lastActive: json['last_active'] == null ? null : DateTime.parse(json['last_active'] as String), + online: json['online'] as bool? ?? false, + extraData: json['extra_data'] as Map? ?? const {}, + banned: json['banned'] as bool? ?? false, + banExpires: json['ban_expires'] == null ? null : DateTime.parse(json['ban_expires'] as String), + teams: (json['teams'] as List?)?.map((e) => e as String).toList() ?? const [], + language: json['language'] as String?, + invisible: json['invisible'] as bool?, + teamsRole: (json['teams_role'] as Map?)?.map( + (k, e) => MapEntry(k, e as String), + ), + avgResponseTime: (json['avg_response_time'] as num?)?.toInt(), +); Map _$OwnUserToJson(OwnUser instance) => { - 'id': instance.id, - if (instance.role case final value?) 'role': value, - 'teams': instance.teams, - if (instance.createdAt?.toIso8601String() case final value?) - 'created_at': value, - if (instance.updatedAt?.toIso8601String() case final value?) - 'updated_at': value, - if (instance.lastActive?.toIso8601String() case final value?) - 'last_active': value, - 'online': instance.online, - 'banned': instance.banned, - if (instance.banExpires?.toIso8601String() case final value?) - 'ban_expires': value, - if (instance.language case final value?) 'language': value, - if (instance.invisible case final value?) 'invisible': value, - if (instance.teamsRole case final value?) 'teams_role': value, - if (instance.avgResponseTime case final value?) - 'avg_response_time': value, - 'extra_data': instance.extraData, - 'devices': instance.devices.map((e) => e.toJson()).toList(), - 'mutes': instance.mutes.map((e) => e.toJson()).toList(), - 'channel_mutes': instance.channelMutes.map((e) => e.toJson()).toList(), - 'total_unread_count': instance.totalUnreadCount, - 'unread_channels': instance.unreadChannels, - 'unread_threads': instance.unreadThreads, - 'blocked_user_ids': instance.blockedUserIds, - if (instance.pushPreferences?.toJson() case final value?) - 'push_preferences': value, - if (instance.privacySettings?.toJson() case final value?) - 'privacy_settings': value, - }; + 'id': instance.id, + if (instance.role case final value?) 'role': value, + 'teams': instance.teams, + if (instance.createdAt?.toIso8601String() case final value?) 'created_at': value, + if (instance.updatedAt?.toIso8601String() case final value?) 'updated_at': value, + if (instance.lastActive?.toIso8601String() case final value?) 'last_active': value, + 'online': instance.online, + 'banned': instance.banned, + if (instance.banExpires?.toIso8601String() case final value?) 'ban_expires': value, + if (instance.language case final value?) 'language': value, + if (instance.invisible case final value?) 'invisible': value, + if (instance.teamsRole case final value?) 'teams_role': value, + if (instance.avgResponseTime case final value?) 'avg_response_time': value, + 'extra_data': instance.extraData, + 'devices': instance.devices.map((e) => e.toJson()).toList(), + 'mutes': instance.mutes.map((e) => e.toJson()).toList(), + 'channel_mutes': instance.channelMutes.map((e) => e.toJson()).toList(), + 'total_unread_count': instance.totalUnreadCount, + 'unread_channels': instance.unreadChannels, + 'unread_threads': instance.unreadThreads, + 'blocked_user_ids': instance.blockedUserIds, + if (instance.pushPreferences?.toJson() case final value?) 'push_preferences': value, + if (instance.privacySettings?.toJson() case final value?) 'privacy_settings': value, +}; diff --git a/packages/stream_chat/lib/src/core/models/poll.dart b/packages/stream_chat/lib/src/core/models/poll.dart index dd61feac2b..a0fb4bfc1b 100644 --- a/packages/stream_chat/lib/src/core/models/poll.dart +++ b/packages/stream_chat/lib/src/core/models/poll.dart @@ -57,9 +57,9 @@ class Poll extends Equatable implements ComparableFieldProvider { this.createdBy, this.ownVotesAndAnswers = const [], this.extraData = const {}, - }) : id = id ?? const Uuid().v4(), - createdAt = createdAt ?? DateTime.now(), - updatedAt = updatedAt ?? DateTime.now(); + }) : id = id ?? const Uuid().v4(), + createdAt = createdAt ?? DateTime.now(), + updatedAt = updatedAt ?? DateTime.now(); /// Create a new instance from a json factory Poll.fromJson(Map json) => @@ -167,8 +167,7 @@ class Poll extends Equatable implements ComparableFieldProvider { final Map extraData; /// Serialize to json - Map toJson() => - Serializer.moveFromExtraDataToRoot(_$PollToJson(this)); + Map toJson() => Serializer.moveFromExtraDataToRoot(_$PollToJson(this)); /// Creates a copy of [Poll] with specified attributes overridden. Poll copyWith({ @@ -193,33 +192,29 @@ class Poll extends Equatable implements ComparableFieldProvider { DateTime? createdAt, DateTime? updatedAt, Map? extraData, - }) => - Poll( - id: id ?? this.id, - name: name ?? this.name, - description: description ?? this.description, - options: options ?? this.options, - votingVisibility: votingVisibility ?? this.votingVisibility, - enforceUniqueVote: enforceUniqueVote ?? this.enforceUniqueVote, - maxVotesAllowed: maxVotesAllowed == _nullConst - ? this.maxVotesAllowed - : maxVotesAllowed as int?, - allowUserSuggestedOptions: - allowUserSuggestedOptions ?? this.allowUserSuggestedOptions, - allowAnswers: allowAnswers ?? this.allowAnswers, - isClosed: isClosed ?? this.isClosed, - voteCountsByOption: voteCountsByOption ?? this.voteCountsByOption, - ownVotesAndAnswers: ownVotesAndAnswers ?? this.ownVotesAndAnswers, - voteCount: voteCount ?? this.voteCount, - answersCount: answersCount ?? this.answersCount, - latestVotesByOption: latestVotesByOption ?? this.latestVotesByOption, - latestAnswers: latestAnswers ?? this.latestAnswers, - createdById: createdById ?? this.createdById, - createdBy: createdBy ?? this.createdBy, - createdAt: createdAt ?? this.createdAt, - updatedAt: updatedAt ?? this.updatedAt, - extraData: extraData ?? this.extraData, - ); + }) => Poll( + id: id ?? this.id, + name: name ?? this.name, + description: description ?? this.description, + options: options ?? this.options, + votingVisibility: votingVisibility ?? this.votingVisibility, + enforceUniqueVote: enforceUniqueVote ?? this.enforceUniqueVote, + maxVotesAllowed: maxVotesAllowed == _nullConst ? this.maxVotesAllowed : maxVotesAllowed as int?, + allowUserSuggestedOptions: allowUserSuggestedOptions ?? this.allowUserSuggestedOptions, + allowAnswers: allowAnswers ?? this.allowAnswers, + isClosed: isClosed ?? this.isClosed, + voteCountsByOption: voteCountsByOption ?? this.voteCountsByOption, + ownVotesAndAnswers: ownVotesAndAnswers ?? this.ownVotesAndAnswers, + voteCount: voteCount ?? this.voteCount, + answersCount: answersCount ?? this.answersCount, + latestVotesByOption: latestVotesByOption ?? this.latestVotesByOption, + latestAnswers: latestAnswers ?? this.latestAnswers, + createdById: createdById ?? this.createdById, + createdBy: createdBy ?? this.createdBy, + createdAt: createdAt ?? this.createdAt, + updatedAt: updatedAt ?? this.updatedAt, + extraData: extraData ?? this.extraData, + ); /// Known top level fields. /// @@ -250,27 +245,27 @@ class Poll extends Equatable implements ComparableFieldProvider { @override List get props => [ - id, - name, - description, - options, - votingVisibility, - enforceUniqueVote, - maxVotesAllowed, - allowUserSuggestedOptions, - allowAnswers, - isClosed, - voteCountsByOption, - ownVotesAndAnswers, - voteCount, - answersCount, - latestVotesByOption, - latestAnswers, - createdById, - createdBy, - createdAt, - updatedAt, - ]; + id, + name, + description, + options, + votingVisibility, + enforceUniqueVote, + maxVotesAllowed, + allowUserSuggestedOptions, + allowAnswers, + isClosed, + voteCountsByOption, + ownVotesAndAnswers, + voteCount, + answersCount, + latestVotesByOption, + latestAnswers, + createdById, + createdBy, + createdAt, + updatedAt, + ]; @override ComparableField? getComparableField(String sortKey) { @@ -319,31 +314,28 @@ extension PollX on Poll { /// Whether the poll is already closed and the provided option is the one, /// and **the only one** with the most votes. - bool isOptionWinner(PollOption option) => - isClosed && isOptionWithMostVotes(option); + bool isOptionWinner(PollOption option) => isClosed && isOptionWithMostVotes(option); /// Whether the poll is already closed and the provided option is one of that /// has the most votes. - bool isOptionOneOfTheWinners(PollOption option) => - isClosed && isOptionWithMaximumVotes(option); + bool isOptionOneOfTheWinners(PollOption option) => isClosed && isOptionWithMaximumVotes(option); /// Whether the provided option is the one, and **the only one** with the most /// votes. bool isOptionWithMostVotes(PollOption option) { final optionsWithMostVotes = { for (final entry in voteCountsByOption.entries) - if (entry.value == currentMaximumVoteCount) entry.key: entry.value + if (entry.value == currentMaximumVoteCount) entry.key: entry.value, }; - return optionsWithMostVotes.length == 1 && - optionsWithMostVotes[option.id] != null; + return optionsWithMostVotes.length == 1 && optionsWithMostVotes[option.id] != null; } /// Whether the provided option is one of that has the most votes. bool isOptionWithMaximumVotes(PollOption option) { final optionsWithMostVotes = { for (final entry in voteCountsByOption.entries) - if (entry.value == currentMaximumVoteCount) entry.key: entry.value + if (entry.value == currentMaximumVoteCount) entry.key: entry.value, }; return optionsWithMostVotes[option.id] != null; @@ -368,6 +360,5 @@ extension PollX on Poll { /// Returns a Boolean value indicating whether the current user has voted the /// given option. - bool hasCurrentUserVotedFor(PollOption option) => - ownVotesAndAnswers.any((it) => it.optionId == option.id); + bool hasCurrentUserVotedFor(PollOption option) => ownVotesAndAnswers.any((it) => it.optionId == option.id); } diff --git a/packages/stream_chat/lib/src/core/models/poll.g.dart b/packages/stream_chat/lib/src/core/models/poll.g.dart index 475232c4cc..3a4432041e 100644 --- a/packages/stream_chat/lib/src/core/models/poll.g.dart +++ b/packages/stream_chat/lib/src/core/models/poll.g.dart @@ -7,73 +7,55 @@ part of 'poll.dart'; // ************************************************************************** Poll _$PollFromJson(Map json) => Poll( - id: json['id'] as String?, - name: json['name'] as String, - description: json['description'] as String?, - options: (json['options'] as List) - .map((e) => PollOption.fromJson(e as Map)) - .toList(), - votingVisibility: $enumDecodeNullable( - _$VotingVisibilityEnumMap, json['voting_visibility']) ?? - VotingVisibility.public, - enforceUniqueVote: json['enforce_unique_vote'] as bool? ?? true, - maxVotesAllowed: (json['max_votes_allowed'] as num?)?.toInt(), - allowAnswers: json['allow_answers'] as bool? ?? false, - latestAnswers: (json['latest_answers'] as List?) - ?.map((e) => PollVote.fromJson(e as Map)) - .toList() ?? - const [], - answersCount: (json['answers_count'] as num?)?.toInt() ?? 0, - allowUserSuggestedOptions: - json['allow_user_suggested_options'] as bool? ?? false, - isClosed: json['is_closed'] as bool? ?? false, - createdAt: json['created_at'] == null - ? null - : DateTime.parse(json['created_at'] as String), - updatedAt: json['updated_at'] == null - ? null - : DateTime.parse(json['updated_at'] as String), - voteCountsByOption: - (json['vote_counts_by_option'] as Map?)?.map( - (k, e) => MapEntry(k, (e as num).toInt()), - ) ?? - const {}, - voteCount: (json['vote_count'] as num?)?.toInt() ?? 0, - latestVotesByOption: - (json['latest_votes_by_option'] as Map?)?.map( - (k, e) => MapEntry( - k, - (e as List) - .map( - (e) => PollVote.fromJson(e as Map)) - .toList()), - ) ?? - const {}, - createdById: json['created_by_id'] as String?, - createdBy: json['created_by'] == null - ? null - : User.fromJson(json['created_by'] as Map), - ownVotesAndAnswers: (json['own_votes'] as List?) - ?.map((e) => PollVote.fromJson(e as Map)) - .toList() ?? - const [], - extraData: json['extra_data'] as Map? ?? const {}, - ); + id: json['id'] as String?, + name: json['name'] as String, + description: json['description'] as String?, + options: (json['options'] as List).map((e) => PollOption.fromJson(e as Map)).toList(), + votingVisibility: + $enumDecodeNullable(_$VotingVisibilityEnumMap, json['voting_visibility']) ?? VotingVisibility.public, + enforceUniqueVote: json['enforce_unique_vote'] as bool? ?? true, + maxVotesAllowed: (json['max_votes_allowed'] as num?)?.toInt(), + allowAnswers: json['allow_answers'] as bool? ?? false, + latestAnswers: + (json['latest_answers'] as List?)?.map((e) => PollVote.fromJson(e as Map)).toList() ?? + const [], + answersCount: (json['answers_count'] as num?)?.toInt() ?? 0, + allowUserSuggestedOptions: json['allow_user_suggested_options'] as bool? ?? false, + isClosed: json['is_closed'] as bool? ?? false, + createdAt: json['created_at'] == null ? null : DateTime.parse(json['created_at'] as String), + updatedAt: json['updated_at'] == null ? null : DateTime.parse(json['updated_at'] as String), + voteCountsByOption: + (json['vote_counts_by_option'] as Map?)?.map( + (k, e) => MapEntry(k, (e as num).toInt()), + ) ?? + const {}, + voteCount: (json['vote_count'] as num?)?.toInt() ?? 0, + latestVotesByOption: + (json['latest_votes_by_option'] as Map?)?.map( + (k, e) => MapEntry(k, (e as List).map((e) => PollVote.fromJson(e as Map)).toList()), + ) ?? + const {}, + createdById: json['created_by_id'] as String?, + createdBy: json['created_by'] == null ? null : User.fromJson(json['created_by'] as Map), + ownVotesAndAnswers: + (json['own_votes'] as List?)?.map((e) => PollVote.fromJson(e as Map)).toList() ?? + const [], + extraData: json['extra_data'] as Map? ?? const {}, +); Map _$PollToJson(Poll instance) => { - 'id': instance.id, - 'name': instance.name, - 'description': instance.description, - 'options': instance.options.map((e) => e.toJson()).toList(), - 'voting_visibility': - _$VotingVisibilityEnumMap[instance.votingVisibility]!, - 'enforce_unique_vote': instance.enforceUniqueVote, - 'max_votes_allowed': instance.maxVotesAllowed, - 'allow_user_suggested_options': instance.allowUserSuggestedOptions, - 'allow_answers': instance.allowAnswers, - 'is_closed': instance.isClosed, - 'extra_data': instance.extraData, - }; + 'id': instance.id, + 'name': instance.name, + 'description': instance.description, + 'options': instance.options.map((e) => e.toJson()).toList(), + 'voting_visibility': _$VotingVisibilityEnumMap[instance.votingVisibility]!, + 'enforce_unique_vote': instance.enforceUniqueVote, + 'max_votes_allowed': instance.maxVotesAllowed, + 'allow_user_suggested_options': instance.allowUserSuggestedOptions, + 'allow_answers': instance.allowAnswers, + 'is_closed': instance.isClosed, + 'extra_data': instance.extraData, +}; const _$VotingVisibilityEnumMap = { VotingVisibility.anonymous: 'anonymous', diff --git a/packages/stream_chat/lib/src/core/models/poll_option.dart b/packages/stream_chat/lib/src/core/models/poll_option.dart index 9ef99d07e3..fb8448af05 100644 --- a/packages/stream_chat/lib/src/core/models/poll_option.dart +++ b/packages/stream_chat/lib/src/core/models/poll_option.dart @@ -23,10 +23,9 @@ class PollOption extends Equatable { }); /// Create a new instance from a json - factory PollOption.fromJson(Map json) => - _$PollOptionFromJson( - Serializer.moveToExtraDataFromRoot(json, topLevelFields), - ); + factory PollOption.fromJson(Map json) => _$PollOptionFromJson( + Serializer.moveToExtraDataFromRoot(json, topLevelFields), + ); /// The unique identifier of the poll option. @JsonKey(includeIfNull: false) @@ -39,20 +38,18 @@ class PollOption extends Equatable { final Map extraData; /// Serialize to json - Map toJson() => - Serializer.moveFromExtraDataToRoot(_$PollOptionToJson(this)); + Map toJson() => Serializer.moveFromExtraDataToRoot(_$PollOptionToJson(this)); /// Creates a copy of [PollOption] with specified attributes overridden. PollOption copyWith({ Object? id = _nullConst, String? text, Map? extraData, - }) => - PollOption( - id: id == _nullConst ? this.id : id as String?, - text: text ?? this.text, - extraData: extraData ?? this.extraData, - ); + }) => PollOption( + id: id == _nullConst ? this.id : id as String?, + text: text ?? this.text, + extraData: extraData ?? this.extraData, + ); /// Known top level fields. /// diff --git a/packages/stream_chat/lib/src/core/models/poll_option.g.dart b/packages/stream_chat/lib/src/core/models/poll_option.g.dart index b7db997113..1f8ba16aa2 100644 --- a/packages/stream_chat/lib/src/core/models/poll_option.g.dart +++ b/packages/stream_chat/lib/src/core/models/poll_option.g.dart @@ -7,14 +7,13 @@ part of 'poll_option.dart'; // ************************************************************************** PollOption _$PollOptionFromJson(Map json) => PollOption( - id: json['id'] as String?, - text: json['text'] as String, - extraData: json['extra_data'] as Map? ?? const {}, - ); + id: json['id'] as String?, + text: json['text'] as String, + extraData: json['extra_data'] as Map? ?? const {}, +); -Map _$PollOptionToJson(PollOption instance) => - { - if (instance.id case final value?) 'id': value, - 'text': instance.text, - 'extra_data': instance.extraData, - }; +Map _$PollOptionToJson(PollOption instance) => { + if (instance.id case final value?) 'id': value, + 'text': instance.text, + 'extra_data': instance.extraData, +}; diff --git a/packages/stream_chat/lib/src/core/models/poll_vote.dart b/packages/stream_chat/lib/src/core/models/poll_vote.dart index a48bb03d9e..3f979cc13c 100644 --- a/packages/stream_chat/lib/src/core/models/poll_vote.dart +++ b/packages/stream_chat/lib/src/core/models/poll_vote.dart @@ -20,17 +20,16 @@ class PollVote extends Equatable implements ComparableFieldProvider { DateTime? updatedAt, this.userId, this.user, - }) : assert( - optionId != null || answerText != null, - 'Either optionId or answerText must be provided', - ), - isAnswer = answerText != null, - createdAt = createdAt ?? DateTime.now(), - updatedAt = updatedAt ?? DateTime.now(); + }) : assert( + optionId != null || answerText != null, + 'Either optionId or answerText must be provided', + ), + isAnswer = answerText != null, + createdAt = createdAt ?? DateTime.now(), + updatedAt = updatedAt ?? DateTime.now(); /// Create a new instance from a json - factory PollVote.fromJson(Map json) => - _$PollVoteFromJson(json); + factory PollVote.fromJson(Map json) => _$PollVoteFromJson(json); /// The unique identifier of the poll vote. @JsonKey(includeIfNull: false) @@ -81,30 +80,29 @@ class PollVote extends Equatable implements ComparableFieldProvider { DateTime? updatedAt, String? userId, User? user, - }) => - PollVote( - id: id ?? this.id, - pollId: pollId ?? this.pollId, - optionId: optionId ?? this.optionId, - answerText: answerText ?? this.answerText, - createdAt: createdAt ?? this.createdAt, - updatedAt: updatedAt ?? this.updatedAt, - userId: userId ?? this.userId, - user: user ?? this.user, - ); + }) => PollVote( + id: id ?? this.id, + pollId: pollId ?? this.pollId, + optionId: optionId ?? this.optionId, + answerText: answerText ?? this.answerText, + createdAt: createdAt ?? this.createdAt, + updatedAt: updatedAt ?? this.updatedAt, + userId: userId ?? this.userId, + user: user ?? this.user, + ); @override List get props => [ - id, - pollId, - optionId, - isAnswer, - answerText, - createdAt, - updatedAt, - userId, - user, - ]; + id, + pollId, + optionId, + isAnswer, + answerText, + createdAt, + updatedAt, + userId, + user, + ]; @override ComparableField? getComparableField(String sortKey) { diff --git a/packages/stream_chat/lib/src/core/models/poll_vote.g.dart b/packages/stream_chat/lib/src/core/models/poll_vote.g.dart index 1ceb22c1d8..8eedbb3061 100644 --- a/packages/stream_chat/lib/src/core/models/poll_vote.g.dart +++ b/packages/stream_chat/lib/src/core/models/poll_vote.g.dart @@ -7,24 +7,18 @@ part of 'poll_vote.dart'; // ************************************************************************** PollVote _$PollVoteFromJson(Map json) => PollVote( - id: json['id'] as String?, - pollId: json['poll_id'] as String?, - optionId: json['option_id'] as String?, - answerText: json['answer_text'] as String?, - createdAt: json['created_at'] == null - ? null - : DateTime.parse(json['created_at'] as String), - updatedAt: json['updated_at'] == null - ? null - : DateTime.parse(json['updated_at'] as String), - userId: json['user_id'] as String?, - user: json['user'] == null - ? null - : User.fromJson(json['user'] as Map), - ); + id: json['id'] as String?, + pollId: json['poll_id'] as String?, + optionId: json['option_id'] as String?, + answerText: json['answer_text'] as String?, + createdAt: json['created_at'] == null ? null : DateTime.parse(json['created_at'] as String), + updatedAt: json['updated_at'] == null ? null : DateTime.parse(json['updated_at'] as String), + userId: json['user_id'] as String?, + user: json['user'] == null ? null : User.fromJson(json['user'] as Map), +); Map _$PollVoteToJson(PollVote instance) => { - if (instance.id case final value?) 'id': value, - if (instance.optionId case final value?) 'option_id': value, - if (instance.answerText case final value?) 'answer_text': value, - }; + if (instance.id case final value?) 'id': value, + if (instance.optionId case final value?) 'option_id': value, + if (instance.answerText case final value?) 'answer_text': value, +}; diff --git a/packages/stream_chat/lib/src/core/models/privacy_settings.g.dart b/packages/stream_chat/lib/src/core/models/privacy_settings.g.dart index e433675833..b888255ad6 100644 --- a/packages/stream_chat/lib/src/core/models/privacy_settings.g.dart +++ b/packages/stream_chat/lib/src/core/models/privacy_settings.g.dart @@ -6,57 +6,44 @@ part of 'privacy_settings.dart'; // JsonSerializableGenerator // ************************************************************************** -PrivacySettings _$PrivacySettingsFromJson(Map json) => - PrivacySettings( - typingIndicators: json['typing_indicators'] == null - ? null - : TypingIndicators.fromJson( - json['typing_indicators'] as Map), - readReceipts: json['read_receipts'] == null - ? null - : ReadReceipts.fromJson( - json['read_receipts'] as Map), - deliveryReceipts: json['delivery_receipts'] == null - ? null - : DeliveryReceipts.fromJson( - json['delivery_receipts'] as Map), - ); - -Map _$PrivacySettingsToJson(PrivacySettings instance) => - { - if (instance.typingIndicators?.toJson() case final value?) - 'typing_indicators': value, - if (instance.readReceipts?.toJson() case final value?) - 'read_receipts': value, - if (instance.deliveryReceipts?.toJson() case final value?) - 'delivery_receipts': value, - }; - -TypingIndicators _$TypingIndicatorsFromJson(Map json) => - TypingIndicators( - enabled: json['enabled'] as bool? ?? true, - ); - -Map _$TypingIndicatorsToJson(TypingIndicators instance) => - { - 'enabled': instance.enabled, - }; +PrivacySettings _$PrivacySettingsFromJson(Map json) => PrivacySettings( + typingIndicators: json['typing_indicators'] == null + ? null + : TypingIndicators.fromJson(json['typing_indicators'] as Map), + readReceipts: json['read_receipts'] == null + ? null + : ReadReceipts.fromJson(json['read_receipts'] as Map), + deliveryReceipts: json['delivery_receipts'] == null + ? null + : DeliveryReceipts.fromJson(json['delivery_receipts'] as Map), +); + +Map _$PrivacySettingsToJson(PrivacySettings instance) => { + if (instance.typingIndicators?.toJson() case final value?) 'typing_indicators': value, + if (instance.readReceipts?.toJson() case final value?) 'read_receipts': value, + if (instance.deliveryReceipts?.toJson() case final value?) 'delivery_receipts': value, +}; + +TypingIndicators _$TypingIndicatorsFromJson(Map json) => TypingIndicators( + enabled: json['enabled'] as bool? ?? true, +); + +Map _$TypingIndicatorsToJson(TypingIndicators instance) => { + 'enabled': instance.enabled, +}; ReadReceipts _$ReadReceiptsFromJson(Map json) => ReadReceipts( - enabled: json['enabled'] as bool? ?? true, - ); - -Map _$ReadReceiptsToJson(ReadReceipts instance) => - { - 'enabled': instance.enabled, - }; - -DeliveryReceipts _$DeliveryReceiptsFromJson(Map json) => - DeliveryReceipts( - enabled: json['enabled'] as bool? ?? true, - ); - -Map _$DeliveryReceiptsToJson(DeliveryReceipts instance) => - { - 'enabled': instance.enabled, - }; + enabled: json['enabled'] as bool? ?? true, +); + +Map _$ReadReceiptsToJson(ReadReceipts instance) => { + 'enabled': instance.enabled, +}; + +DeliveryReceipts _$DeliveryReceiptsFromJson(Map json) => DeliveryReceipts( + enabled: json['enabled'] as bool? ?? true, +); + +Map _$DeliveryReceiptsToJson(DeliveryReceipts instance) => { + 'enabled': instance.enabled, +}; diff --git a/packages/stream_chat/lib/src/core/models/push_preference.dart b/packages/stream_chat/lib/src/core/models/push_preference.dart index 00ba266a1d..a3de2f8b3f 100644 --- a/packages/stream_chat/lib/src/core/models/push_preference.dart +++ b/packages/stream_chat/lib/src/core/models/push_preference.dart @@ -82,8 +82,7 @@ class PushPreference extends Equatable { }); /// Create a new instance from a json - factory PushPreference.fromJson(Map json) => - _$PushPreferenceFromJson(json); + factory PushPreference.fromJson(Map json) => _$PushPreferenceFromJson(json); /// Push preference for calls final CallLevel? callLevel; @@ -111,8 +110,7 @@ class ChannelPushPreference extends Equatable { }); /// Create a new instance from a json - factory ChannelPushPreference.fromJson(Map json) => - _$ChannelPushPreferenceFromJson(json); + factory ChannelPushPreference.fromJson(Map json) => _$ChannelPushPreferenceFromJson(json); /// Push preference for chat messages final ChatLevel? chatLevel; diff --git a/packages/stream_chat/lib/src/core/models/push_preference.g.dart b/packages/stream_chat/lib/src/core/models/push_preference.g.dart index 971c0bd1f9..1cc2cc6cc1 100644 --- a/packages/stream_chat/lib/src/core/models/push_preference.g.dart +++ b/packages/stream_chat/lib/src/core/models/push_preference.g.dart @@ -6,47 +6,32 @@ part of 'push_preference.dart'; // JsonSerializableGenerator // ************************************************************************** -Map _$PushPreferenceInputToJson( - PushPreferenceInput instance) => - { - if (instance.channelCid case final value?) 'channel_cid': value, - if (instance.callLevel case final value?) 'call_level': value, - if (instance.chatLevel case final value?) 'chat_level': value, - if (instance.disabledUntil?.toIso8601String() case final value?) - 'disabled_until': value, - if (instance.removeDisable case final value?) 'remove_disable': value, - }; +Map _$PushPreferenceInputToJson(PushPreferenceInput instance) => { + if (instance.channelCid case final value?) 'channel_cid': value, + if (instance.callLevel case final value?) 'call_level': value, + if (instance.chatLevel case final value?) 'chat_level': value, + if (instance.disabledUntil?.toIso8601String() case final value?) 'disabled_until': value, + if (instance.removeDisable case final value?) 'remove_disable': value, +}; -PushPreference _$PushPreferenceFromJson(Map json) => - PushPreference( - callLevel: json['call_level'] as CallLevel?, - chatLevel: json['chat_level'] as ChatLevel?, - disabledUntil: json['disabled_until'] == null - ? null - : DateTime.parse(json['disabled_until'] as String), - ); +PushPreference _$PushPreferenceFromJson(Map json) => PushPreference( + callLevel: json['call_level'] as CallLevel?, + chatLevel: json['chat_level'] as ChatLevel?, + disabledUntil: json['disabled_until'] == null ? null : DateTime.parse(json['disabled_until'] as String), +); -Map _$PushPreferenceToJson(PushPreference instance) => - { - if (instance.callLevel case final value?) 'call_level': value, - if (instance.chatLevel case final value?) 'chat_level': value, - if (instance.disabledUntil?.toIso8601String() case final value?) - 'disabled_until': value, - }; +Map _$PushPreferenceToJson(PushPreference instance) => { + if (instance.callLevel case final value?) 'call_level': value, + if (instance.chatLevel case final value?) 'chat_level': value, + if (instance.disabledUntil?.toIso8601String() case final value?) 'disabled_until': value, +}; -ChannelPushPreference _$ChannelPushPreferenceFromJson( - Map json) => - ChannelPushPreference( - chatLevel: json['chat_level'] as ChatLevel?, - disabledUntil: json['disabled_until'] == null - ? null - : DateTime.parse(json['disabled_until'] as String), - ); +ChannelPushPreference _$ChannelPushPreferenceFromJson(Map json) => ChannelPushPreference( + chatLevel: json['chat_level'] as ChatLevel?, + disabledUntil: json['disabled_until'] == null ? null : DateTime.parse(json['disabled_until'] as String), +); -Map _$ChannelPushPreferenceToJson( - ChannelPushPreference instance) => - { - if (instance.chatLevel case final value?) 'chat_level': value, - if (instance.disabledUntil?.toIso8601String() case final value?) - 'disabled_until': value, - }; +Map _$ChannelPushPreferenceToJson(ChannelPushPreference instance) => { + if (instance.chatLevel case final value?) 'chat_level': value, + if (instance.disabledUntil?.toIso8601String() case final value?) 'disabled_until': value, +}; diff --git a/packages/stream_chat/lib/src/core/models/reaction.dart b/packages/stream_chat/lib/src/core/models/reaction.dart index 474b6dff6a..c60c7b85b8 100644 --- a/packages/stream_chat/lib/src/core/models/reaction.dart +++ b/packages/stream_chat/lib/src/core/models/reaction.dart @@ -1,4 +1,6 @@ +import 'package:equatable/equatable.dart'; import 'package:json_annotation/json_annotation.dart'; +import 'package:stream_chat/src/core/models/comparable_field.dart'; import 'package:stream_chat/src/core/models/user.dart'; import 'package:stream_chat/src/core/util/serializer.dart'; @@ -6,94 +8,148 @@ part 'reaction.g.dart'; /// The class that defines a reaction @JsonSerializable() -class Reaction { +class Reaction extends Equatable implements ComparableFieldProvider { /// Constructor used for json serialization Reaction({ this.messageId, - DateTime? createdAt, required this.type, this.user, String? userId, - this.score = 0, + this.score = 1, + this.emojiCode, + DateTime? createdAt, + DateTime? updatedAt, this.extraData = const {}, - }) : userId = userId ?? user?.id, - createdAt = createdAt ?? DateTime.now(); + }) : userId = userId ?? user?.id, + createdAt = createdAt ?? DateTime.timestamp(), + updatedAt = updatedAt ?? DateTime.timestamp(); /// Create a new instance from a json - factory Reaction.fromJson(Map json) => - _$ReactionFromJson(Serializer.moveToExtraDataFromRoot( - json, - topLevelFields, - )); + factory Reaction.fromJson(Map json) => _$ReactionFromJson( + Serializer.moveToExtraDataFromRoot( + json, + topLevelFields, + ), + ); /// The messageId to which the reaction belongs + @JsonKey(includeToJson: false) final String? messageId; /// The type of the reaction final String type; - /// The date of the reaction - @JsonKey(includeToJson: false) - final DateTime createdAt; + /// The score of the reaction (ie. number of reactions sent) + final int score; + + /// The emoji code of the reaction (used for notifications) + @JsonKey(includeIfNull: false) + final String? emojiCode; /// The user that sent the reaction @JsonKey(includeToJson: false) final User? user; - /// The score of the reaction (ie. number of reactions sent) - final int score; - /// The userId that sent the reaction @JsonKey(includeToJson: false) final String? userId; + /// The date of the reaction + @JsonKey(includeToJson: false) + final DateTime createdAt; + + /// The date of the reaction update + @JsonKey(includeToJson: false) + final DateTime updatedAt; + /// Reaction custom extraData final Map extraData; /// Map of custom user extraData static const topLevelFields = [ 'message_id', - 'created_at', 'type', 'user', 'user_id', 'score', + 'emoji_code', + 'created_at', + 'updated_at', ]; /// Serialize to json Map toJson() => Serializer.moveFromExtraDataToRoot( - _$ReactionToJson(this), - ); + _$ReactionToJson(this), + ); /// Creates a copy of [Reaction] with specified attributes overridden. Reaction copyWith({ String? messageId, - DateTime? createdAt, String? type, User? user, String? userId, int? score, + String? emojiCode, + DateTime? createdAt, + DateTime? updatedAt, Map? extraData, - }) => - Reaction( - messageId: messageId ?? this.messageId, - createdAt: createdAt ?? this.createdAt, - type: type ?? this.type, - user: user ?? this.user, - userId: userId ?? this.userId, - score: score ?? this.score, - extraData: extraData ?? this.extraData, - ); + }) => Reaction( + messageId: messageId ?? this.messageId, + type: type ?? this.type, + user: user ?? this.user, + userId: userId ?? this.userId, + score: score ?? this.score, + emojiCode: emojiCode ?? this.emojiCode, + createdAt: createdAt ?? this.createdAt, + updatedAt: updatedAt ?? this.updatedAt, + extraData: extraData ?? this.extraData, + ); /// Returns a new [Reaction] that is a combination of this reaction and the /// given [other] reaction. Reaction merge(Reaction other) => copyWith( - messageId: other.messageId, - createdAt: other.createdAt, - type: other.type, - user: other.user, - userId: other.userId, - score: other.score, - extraData: other.extraData, - ); + messageId: other.messageId, + type: other.type, + user: other.user, + userId: other.userId, + score: other.score, + emojiCode: other.emojiCode, + createdAt: other.createdAt, + updatedAt: other.updatedAt, + extraData: other.extraData, + ); + + @override + List get props => [ + messageId, + type, + user, + userId, + score, + emojiCode, + createdAt, + updatedAt, + extraData, + ]; + + @override + ComparableField? getComparableField(String sortKey) { + final value = switch (sortKey) { + ReactionSortKey.createdAt => createdAt, + _ => null, + }; + + return ComparableField.fromValue(value); + } +} + +/// Extension type representing sortable fields for [Reaction]. +/// +/// This type provides type-safe keys that can be used for sorting reactions +/// in queries. Each constant represents a field that can be sorted on. +extension type const ReactionSortKey(String key) implements String { + /// Sort reactions by their creation date. + /// + /// This is the default sort field (in ascending order). + static const createdAt = ReactionSortKey('created_at'); } diff --git a/packages/stream_chat/lib/src/core/models/reaction.g.dart b/packages/stream_chat/lib/src/core/models/reaction.g.dart index 3be01abc33..56436be596 100644 --- a/packages/stream_chat/lib/src/core/models/reaction.g.dart +++ b/packages/stream_chat/lib/src/core/models/reaction.g.dart @@ -7,22 +7,20 @@ part of 'reaction.dart'; // ************************************************************************** Reaction _$ReactionFromJson(Map json) => Reaction( - messageId: json['message_id'] as String?, - createdAt: json['created_at'] == null - ? null - : DateTime.parse(json['created_at'] as String), - type: json['type'] as String, - user: json['user'] == null - ? null - : User.fromJson(json['user'] as Map), - userId: json['user_id'] as String?, - score: (json['score'] as num?)?.toInt() ?? 0, - extraData: json['extra_data'] as Map? ?? const {}, - ); + messageId: json['message_id'] as String?, + type: json['type'] as String, + user: json['user'] == null ? null : User.fromJson(json['user'] as Map), + userId: json['user_id'] as String?, + score: (json['score'] as num?)?.toInt() ?? 1, + emojiCode: json['emoji_code'] as String?, + createdAt: json['created_at'] == null ? null : DateTime.parse(json['created_at'] as String), + updatedAt: json['updated_at'] == null ? null : DateTime.parse(json['updated_at'] as String), + extraData: json['extra_data'] as Map? ?? const {}, +); Map _$ReactionToJson(Reaction instance) => { - 'message_id': instance.messageId, - 'type': instance.type, - 'score': instance.score, - 'extra_data': instance.extraData, - }; + 'type': instance.type, + 'score': instance.score, + if (instance.emojiCode case final value?) 'emoji_code': value, + 'extra_data': instance.extraData, +}; diff --git a/packages/stream_chat/lib/src/core/models/reaction_group.dart b/packages/stream_chat/lib/src/core/models/reaction_group.dart index 4410500acf..9be6a5150b 100644 --- a/packages/stream_chat/lib/src/core/models/reaction_group.dart +++ b/packages/stream_chat/lib/src/core/models/reaction_group.dart @@ -12,12 +12,11 @@ class ReactionGroup extends Equatable { this.sumScores = 0, DateTime? firstReactionAt, DateTime? lastReactionAt, - }) : firstReactionAt = firstReactionAt ?? DateTime.timestamp(), - lastReactionAt = lastReactionAt ?? DateTime.timestamp(); + }) : firstReactionAt = firstReactionAt ?? DateTime.timestamp(), + lastReactionAt = lastReactionAt ?? DateTime.timestamp(); /// Create a new instance from a json - factory ReactionGroup.fromJson(Map json) => - _$ReactionGroupFromJson(json); + factory ReactionGroup.fromJson(Map json) => _$ReactionGroupFromJson(json); /// The number of users that reacted with this reaction. final int count; @@ -51,9 +50,32 @@ class ReactionGroup extends Equatable { @override List get props => [ - count, - sumScores, - firstReactionAt, - lastReactionAt, - ]; + count, + sumScores, + firstReactionAt, + lastReactionAt, + ]; +} + +/// A group of comparators for sorting [ReactionGroup]s. +final class ReactionSorting { + /// Sorts [ReactionGroup]s by the sum of their scores. + static int byScore(ReactionGroup a, ReactionGroup b) { + return a.sumScores.compareTo(b.sumScores); + } + + /// Sorts [ReactionGroup]s by the count of reactions. + static int byCount(ReactionGroup a, ReactionGroup b) { + return a.count.compareTo(b.count); + } + + /// Sorts [ReactionGroup]s by the date of their first reaction. + static int byFirstReactionAt(ReactionGroup a, ReactionGroup b) { + return a.firstReactionAt.compareTo(b.firstReactionAt); + } + + /// Sorts [ReactionGroup]s by the date of their last reaction. + static int byLastReactionAt(ReactionGroup a, ReactionGroup b) { + return a.lastReactionAt.compareTo(b.lastReactionAt); + } } diff --git a/packages/stream_chat/lib/src/core/models/reaction_group.g.dart b/packages/stream_chat/lib/src/core/models/reaction_group.g.dart index 65e1ceedd7..9c686e7f0a 100644 --- a/packages/stream_chat/lib/src/core/models/reaction_group.g.dart +++ b/packages/stream_chat/lib/src/core/models/reaction_group.g.dart @@ -6,22 +6,16 @@ part of 'reaction_group.dart'; // JsonSerializableGenerator // ************************************************************************** -ReactionGroup _$ReactionGroupFromJson(Map json) => - ReactionGroup( - count: (json['count'] as num?)?.toInt() ?? 0, - sumScores: (json['sum_scores'] as num?)?.toInt() ?? 0, - firstReactionAt: json['first_reaction_at'] == null - ? null - : DateTime.parse(json['first_reaction_at'] as String), - lastReactionAt: json['last_reaction_at'] == null - ? null - : DateTime.parse(json['last_reaction_at'] as String), - ); +ReactionGroup _$ReactionGroupFromJson(Map json) => ReactionGroup( + count: (json['count'] as num?)?.toInt() ?? 0, + sumScores: (json['sum_scores'] as num?)?.toInt() ?? 0, + firstReactionAt: json['first_reaction_at'] == null ? null : DateTime.parse(json['first_reaction_at'] as String), + lastReactionAt: json['last_reaction_at'] == null ? null : DateTime.parse(json['last_reaction_at'] as String), +); -Map _$ReactionGroupToJson(ReactionGroup instance) => - { - 'count': instance.count, - 'sum_scores': instance.sumScores, - 'first_reaction_at': instance.firstReactionAt.toIso8601String(), - 'last_reaction_at': instance.lastReactionAt.toIso8601String(), - }; +Map _$ReactionGroupToJson(ReactionGroup instance) => { + 'count': instance.count, + 'sum_scores': instance.sumScores, + 'first_reaction_at': instance.firstReactionAt.toIso8601String(), + 'last_reaction_at': instance.lastReactionAt.toIso8601String(), +}; diff --git a/packages/stream_chat/lib/src/core/models/read.dart b/packages/stream_chat/lib/src/core/models/read.dart index 165528946f..5952ad4b5c 100644 --- a/packages/stream_chat/lib/src/core/models/read.dart +++ b/packages/stream_chat/lib/src/core/models/read.dart @@ -58,8 +58,7 @@ class Read extends Equatable { user: user ?? this.user, unreadMessages: unreadMessages ?? this.unreadMessages, lastDeliveredAt: lastDeliveredAt ?? this.lastDeliveredAt, - lastDeliveredMessageId: - lastDeliveredMessageId ?? this.lastDeliveredMessageId, + lastDeliveredMessageId: lastDeliveredMessageId ?? this.lastDeliveredMessageId, ); } @@ -79,13 +78,13 @@ class Read extends Equatable { @override List get props => [ - lastRead, - lastReadMessageId, - user, - unreadMessages, - lastDeliveredAt, - lastDeliveredMessageId, - ]; + lastRead, + lastReadMessageId, + user, + unreadMessages, + lastDeliveredAt, + lastDeliveredMessageId, + ]; } /// Helper extension methods for [Iterable]<[Read]>. diff --git a/packages/stream_chat/lib/src/core/models/read.g.dart b/packages/stream_chat/lib/src/core/models/read.g.dart index 3e9d03fb0e..9f4358fcf5 100644 --- a/packages/stream_chat/lib/src/core/models/read.g.dart +++ b/packages/stream_chat/lib/src/core/models/read.g.dart @@ -7,21 +7,19 @@ part of 'read.dart'; // ************************************************************************** Read _$ReadFromJson(Map json) => Read( - lastRead: DateTime.parse(json['last_read'] as String), - user: User.fromJson(json['user'] as Map), - lastReadMessageId: json['last_read_message_id'] as String?, - unreadMessages: (json['unread_messages'] as num?)?.toInt(), - lastDeliveredAt: json['last_delivered_at'] == null - ? null - : DateTime.parse(json['last_delivered_at'] as String), - lastDeliveredMessageId: json['last_delivered_message_id'] as String?, - ); + lastRead: DateTime.parse(json['last_read'] as String), + user: User.fromJson(json['user'] as Map), + lastReadMessageId: json['last_read_message_id'] as String?, + unreadMessages: (json['unread_messages'] as num?)?.toInt(), + lastDeliveredAt: json['last_delivered_at'] == null ? null : DateTime.parse(json['last_delivered_at'] as String), + lastDeliveredMessageId: json['last_delivered_message_id'] as String?, +); Map _$ReadToJson(Read instance) => { - 'last_read': instance.lastRead.toIso8601String(), - 'user': instance.user.toJson(), - 'unread_messages': instance.unreadMessages, - 'last_read_message_id': instance.lastReadMessageId, - 'last_delivered_at': instance.lastDeliveredAt?.toIso8601String(), - 'last_delivered_message_id': instance.lastDeliveredMessageId, - }; + 'last_read': instance.lastRead.toIso8601String(), + 'user': instance.user.toJson(), + 'unread_messages': instance.unreadMessages, + 'last_read_message_id': instance.lastReadMessageId, + 'last_delivered_at': instance.lastDeliveredAt?.toIso8601String(), + 'last_delivered_message_id': instance.lastDeliveredMessageId, +}; diff --git a/packages/stream_chat/lib/src/core/models/thread.dart b/packages/stream_chat/lib/src/core/models/thread.dart index 3a26e15230..4074ca0158 100644 --- a/packages/stream_chat/lib/src/core/models/thread.dart +++ b/packages/stream_chat/lib/src/core/models/thread.dart @@ -44,12 +44,12 @@ class Thread extends Equatable implements ComparableFieldProvider { this.read = const [], this.draft, this.extraData = const {}, - }) : createdAt = createdAt ?? DateTime.now(), - updatedAt = updatedAt ?? DateTime.now(); + }) : createdAt = createdAt ?? DateTime.now(), + updatedAt = updatedAt ?? DateTime.now(); /// Create a new instance from a json - factory Thread.fromJson(Map json) => _$ThreadFromJson( - Serializer.moveToExtraDataFromRoot(json, topLevelFields)); + factory Thread.fromJson(Map json) => + _$ThreadFromJson(Serializer.moveToExtraDataFromRoot(json, topLevelFields)); /// The active participant count in the thread. final int? activeParticipantCount; @@ -109,8 +109,7 @@ class Thread extends Equatable implements ComparableFieldProvider { final Map extraData; /// Serialize to json - Map toJson() => - Serializer.moveFromExtraDataToRoot(_$ThreadToJson(this)); + Map toJson() => Serializer.moveFromExtraDataToRoot(_$ThreadToJson(this)); /// Creates a copy of [Thread] with specified attributes overridden. Thread copyWith({ @@ -133,29 +132,27 @@ class Thread extends Equatable implements ComparableFieldProvider { List? read, Object? draft = _nullConst, Map? extraData, - }) => - Thread( - activeParticipantCount: - activeParticipantCount ?? this.activeParticipantCount, - channel: channel ?? this.channel, - channelCid: channelCid ?? this.channelCid, - createdAt: createdAt ?? this.createdAt, - updatedAt: updatedAt ?? this.updatedAt, - deletedAt: deletedAt ?? this.deletedAt, - createdByUserId: createdByUserId ?? this.createdByUserId, - createdBy: createdBy ?? this.createdBy, - title: title ?? this.title, - parentMessageId: parentMessageId ?? this.parentMessageId, - parentMessage: parentMessage ?? this.parentMessage, - replyCount: replyCount ?? this.replyCount, - participantCount: participantCount ?? this.participantCount, - threadParticipants: threadParticipants ?? this.threadParticipants, - lastMessageAt: lastMessageAt ?? this.lastMessageAt, - latestReplies: latestReplies ?? this.latestReplies, - read: read ?? this.read, - draft: draft == _nullConst ? this.draft : draft as Draft?, - extraData: extraData ?? this.extraData, - ); + }) => Thread( + activeParticipantCount: activeParticipantCount ?? this.activeParticipantCount, + channel: channel ?? this.channel, + channelCid: channelCid ?? this.channelCid, + createdAt: createdAt ?? this.createdAt, + updatedAt: updatedAt ?? this.updatedAt, + deletedAt: deletedAt ?? this.deletedAt, + createdByUserId: createdByUserId ?? this.createdByUserId, + createdBy: createdBy ?? this.createdBy, + title: title ?? this.title, + parentMessageId: parentMessageId ?? this.parentMessageId, + parentMessage: parentMessage ?? this.parentMessage, + replyCount: replyCount ?? this.replyCount, + participantCount: participantCount ?? this.participantCount, + threadParticipants: threadParticipants ?? this.threadParticipants, + lastMessageAt: lastMessageAt ?? this.lastMessageAt, + latestReplies: latestReplies ?? this.latestReplies, + read: read ?? this.read, + draft: draft == _nullConst ? this.draft : draft as Draft?, + extraData: extraData ?? this.extraData, + ); /// Merge this thread with the [other] thread. Thread merge(Thread? other) { @@ -209,25 +206,25 @@ class Thread extends Equatable implements ComparableFieldProvider { @override List get props => [ - activeParticipantCount, - channelCid, - channel, - createdAt, - updatedAt, - deletedAt, - createdByUserId, - createdBy, - title, - parentMessageId, - parentMessage, - replyCount, - participantCount, - threadParticipants, - lastMessageAt, - latestReplies, - read, - draft, - ]; + activeParticipantCount, + channelCid, + channel, + createdAt, + updatedAt, + deletedAt, + createdByUserId, + createdBy, + title, + parentMessageId, + parentMessage, + replyCount, + participantCount, + threadParticipants, + lastMessageAt, + latestReplies, + read, + draft, + ]; @override ComparableField? getComparableField(String sortKey) { @@ -269,8 +266,7 @@ extension type const ThreadSortKey(String key) implements String { static const participantCount = ThreadSortKey('participant_count'); /// Sort threads by their active participant count. - static const activeParticipantCount = - ThreadSortKey('active_participant_count'); + static const activeParticipantCount = ThreadSortKey('active_participant_count'); /// Sort threads by their parent message id. static const parentMessageId = ThreadSortKey('parent_message_id'); diff --git a/packages/stream_chat/lib/src/core/models/thread.g.dart b/packages/stream_chat/lib/src/core/models/thread.g.dart index ed5b27b28b..7d5d46cd30 100644 --- a/packages/stream_chat/lib/src/core/models/thread.g.dart +++ b/packages/stream_chat/lib/src/core/models/thread.g.dart @@ -7,73 +7,53 @@ part of 'thread.dart'; // ************************************************************************** Thread _$ThreadFromJson(Map json) => Thread( - activeParticipantCount: - (json['active_participant_count'] as num?)?.toInt(), - channel: json['channel'] == null - ? null - : ChannelModel.fromJson(json['channel'] as Map), - channelCid: json['channel_cid'] as String, - parentMessageId: json['parent_message_id'] as String, - parentMessage: json['parent_message'] == null - ? null - : Message.fromJson(json['parent_message'] as Map), - createdByUserId: json['created_by_user_id'] as String, - createdBy: json['created_by'] == null - ? null - : User.fromJson(json['created_by'] as Map), - replyCount: (json['reply_count'] as num).toInt(), - participantCount: (json['participant_count'] as num).toInt(), - threadParticipants: (json['thread_participants'] as List?) - ?.map( - (e) => ThreadParticipant.fromJson(e as Map)) - .toList() ?? - const [], - lastMessageAt: json['last_message_at'] == null - ? null - : DateTime.parse(json['last_message_at'] as String), - createdAt: json['created_at'] == null - ? null - : DateTime.parse(json['created_at'] as String), - updatedAt: json['updated_at'] == null - ? null - : DateTime.parse(json['updated_at'] as String), - deletedAt: json['deleted_at'] == null - ? null - : DateTime.parse(json['deleted_at'] as String), - title: json['title'] as String?, - latestReplies: (json['latest_replies'] as List?) - ?.map((e) => Message.fromJson(e as Map)) - .toList() ?? - const [], - read: (json['read'] as List?) - ?.map((e) => Read.fromJson(e as Map)) - .toList() ?? - const [], - draft: json['draft'] == null - ? null - : Draft.fromJson(json['draft'] as Map), - extraData: json['extra_data'] as Map? ?? const {}, - ); + activeParticipantCount: (json['active_participant_count'] as num?)?.toInt(), + channel: json['channel'] == null ? null : ChannelModel.fromJson(json['channel'] as Map), + channelCid: json['channel_cid'] as String, + parentMessageId: json['parent_message_id'] as String, + parentMessage: json['parent_message'] == null + ? null + : Message.fromJson(json['parent_message'] as Map), + createdByUserId: json['created_by_user_id'] as String, + createdBy: json['created_by'] == null ? null : User.fromJson(json['created_by'] as Map), + replyCount: (json['reply_count'] as num).toInt(), + participantCount: (json['participant_count'] as num).toInt(), + threadParticipants: + (json['thread_participants'] as List?) + ?.map((e) => ThreadParticipant.fromJson(e as Map)) + .toList() ?? + const [], + lastMessageAt: json['last_message_at'] == null ? null : DateTime.parse(json['last_message_at'] as String), + createdAt: json['created_at'] == null ? null : DateTime.parse(json['created_at'] as String), + updatedAt: json['updated_at'] == null ? null : DateTime.parse(json['updated_at'] as String), + deletedAt: json['deleted_at'] == null ? null : DateTime.parse(json['deleted_at'] as String), + title: json['title'] as String?, + latestReplies: + (json['latest_replies'] as List?)?.map((e) => Message.fromJson(e as Map)).toList() ?? + const [], + read: (json['read'] as List?)?.map((e) => Read.fromJson(e as Map)).toList() ?? const [], + draft: json['draft'] == null ? null : Draft.fromJson(json['draft'] as Map), + extraData: json['extra_data'] as Map? ?? const {}, +); Map _$ThreadToJson(Thread instance) => { - 'active_participant_count': instance.activeParticipantCount, - 'channel_cid': instance.channelCid, - 'channel': instance.channel?.toJson(), - 'created_at': instance.createdAt.toIso8601String(), - 'updated_at': instance.updatedAt.toIso8601String(), - 'deleted_at': instance.deletedAt?.toIso8601String(), - 'created_by_user_id': instance.createdByUserId, - 'created_by': instance.createdBy?.toJson(), - 'title': instance.title, - 'parent_message_id': instance.parentMessageId, - 'parent_message': instance.parentMessage?.toJson(), - 'reply_count': instance.replyCount, - 'participant_count': instance.participantCount, - 'thread_participants': - instance.threadParticipants.map((e) => e.toJson()).toList(), - 'last_message_at': instance.lastMessageAt?.toIso8601String(), - 'latest_replies': instance.latestReplies.map((e) => e.toJson()).toList(), - 'read': instance.read?.map((e) => e.toJson()).toList(), - 'draft': instance.draft?.toJson(), - 'extra_data': instance.extraData, - }; + 'active_participant_count': instance.activeParticipantCount, + 'channel_cid': instance.channelCid, + 'channel': instance.channel?.toJson(), + 'created_at': instance.createdAt.toIso8601String(), + 'updated_at': instance.updatedAt.toIso8601String(), + 'deleted_at': instance.deletedAt?.toIso8601String(), + 'created_by_user_id': instance.createdByUserId, + 'created_by': instance.createdBy?.toJson(), + 'title': instance.title, + 'parent_message_id': instance.parentMessageId, + 'parent_message': instance.parentMessage?.toJson(), + 'reply_count': instance.replyCount, + 'participant_count': instance.participantCount, + 'thread_participants': instance.threadParticipants.map((e) => e.toJson()).toList(), + 'last_message_at': instance.lastMessageAt?.toIso8601String(), + 'latest_replies': instance.latestReplies.map((e) => e.toJson()).toList(), + 'read': instance.read?.map((e) => e.toJson()).toList(), + 'draft': instance.draft?.toJson(), + 'extra_data': instance.extraData, +}; diff --git a/packages/stream_chat/lib/src/core/models/thread_participant.dart b/packages/stream_chat/lib/src/core/models/thread_participant.dart index fece374619..96d5a7276c 100644 --- a/packages/stream_chat/lib/src/core/models/thread_participant.dart +++ b/packages/stream_chat/lib/src/core/models/thread_participant.dart @@ -22,8 +22,7 @@ class ThreadParticipant extends Equatable { }); /// Create a new instance from a json - factory ThreadParticipant.fromJson(Map json) => - _$ThreadParticipantFromJson(json); + factory ThreadParticipant.fromJson(Map json) => _$ThreadParticipantFromJson(json); /// The channel cid this thread participant belongs to. final String channelCid; @@ -63,27 +62,26 @@ class ThreadParticipant extends Equatable { String? threadId, String? userId, User? user, - }) => - ThreadParticipant( - channelCid: channelCid ?? this.channelCid, - createdAt: createdAt ?? this.createdAt, - lastReadAt: lastReadAt ?? this.lastReadAt, - lastThreadMessageAt: lastThreadMessageAt ?? this.lastThreadMessageAt, - leftThreadAt: leftThreadAt ?? this.leftThreadAt, - threadId: threadId ?? this.threadId, - userId: userId ?? this.userId, - user: user ?? this.user, - ); + }) => ThreadParticipant( + channelCid: channelCid ?? this.channelCid, + createdAt: createdAt ?? this.createdAt, + lastReadAt: lastReadAt ?? this.lastReadAt, + lastThreadMessageAt: lastThreadMessageAt ?? this.lastThreadMessageAt, + leftThreadAt: leftThreadAt ?? this.leftThreadAt, + threadId: threadId ?? this.threadId, + userId: userId ?? this.userId, + user: user ?? this.user, + ); @override List get props => [ - channelCid, - createdAt, - lastReadAt, - lastThreadMessageAt, - leftThreadAt, - threadId, - userId, - user, - ]; + channelCid, + createdAt, + lastReadAt, + lastThreadMessageAt, + leftThreadAt, + threadId, + userId, + user, + ]; } diff --git a/packages/stream_chat/lib/src/core/models/thread_participant.g.dart b/packages/stream_chat/lib/src/core/models/thread_participant.g.dart index 4a410c404b..ec1d860052 100644 --- a/packages/stream_chat/lib/src/core/models/thread_participant.g.dart +++ b/packages/stream_chat/lib/src/core/models/thread_participant.g.dart @@ -6,32 +6,26 @@ part of 'thread_participant.dart'; // JsonSerializableGenerator // ************************************************************************** -ThreadParticipant _$ThreadParticipantFromJson(Map json) => - ThreadParticipant( - channelCid: json['channel_cid'] as String, - createdAt: DateTime.parse(json['created_at'] as String), - lastReadAt: DateTime.parse(json['last_read_at'] as String), - lastThreadMessageAt: json['last_thread_message_at'] == null - ? null - : DateTime.parse(json['last_thread_message_at'] as String), - leftThreadAt: json['left_thread_at'] == null - ? null - : DateTime.parse(json['left_thread_at'] as String), - threadId: json['thread_id'] as String?, - userId: json['user_id'] as String?, - user: json['user'] == null - ? null - : User.fromJson(json['user'] as Map), - ); +ThreadParticipant _$ThreadParticipantFromJson(Map json) => ThreadParticipant( + channelCid: json['channel_cid'] as String, + createdAt: DateTime.parse(json['created_at'] as String), + lastReadAt: DateTime.parse(json['last_read_at'] as String), + lastThreadMessageAt: json['last_thread_message_at'] == null + ? null + : DateTime.parse(json['last_thread_message_at'] as String), + leftThreadAt: json['left_thread_at'] == null ? null : DateTime.parse(json['left_thread_at'] as String), + threadId: json['thread_id'] as String?, + userId: json['user_id'] as String?, + user: json['user'] == null ? null : User.fromJson(json['user'] as Map), +); -Map _$ThreadParticipantToJson(ThreadParticipant instance) => - { - 'channel_cid': instance.channelCid, - 'created_at': instance.createdAt.toIso8601String(), - 'last_read_at': instance.lastReadAt.toIso8601String(), - 'last_thread_message_at': instance.lastThreadMessageAt?.toIso8601String(), - 'left_thread_at': instance.leftThreadAt?.toIso8601String(), - 'thread_id': instance.threadId, - 'user_id': instance.userId, - 'user': instance.user?.toJson(), - }; +Map _$ThreadParticipantToJson(ThreadParticipant instance) => { + 'channel_cid': instance.channelCid, + 'created_at': instance.createdAt.toIso8601String(), + 'last_read_at': instance.lastReadAt.toIso8601String(), + 'last_thread_message_at': instance.lastThreadMessageAt?.toIso8601String(), + 'left_thread_at': instance.leftThreadAt?.toIso8601String(), + 'thread_id': instance.threadId, + 'user_id': instance.userId, + 'user': instance.user?.toJson(), +}; diff --git a/packages/stream_chat/lib/src/core/models/unread_counts.dart b/packages/stream_chat/lib/src/core/models/unread_counts.dart index d3e265fa76..91a0b7dfe5 100644 --- a/packages/stream_chat/lib/src/core/models/unread_counts.dart +++ b/packages/stream_chat/lib/src/core/models/unread_counts.dart @@ -15,8 +15,7 @@ class UnreadCountsChannel { }); /// Create a new instance from a json. - factory UnreadCountsChannel.fromJson(Map json) => - _$UnreadCountsChannelFromJson(json); + factory UnreadCountsChannel.fromJson(Map json) => _$UnreadCountsChannelFromJson(json); /// The unique identifier of the channel (format: "type:id"). final String channelId; @@ -45,8 +44,7 @@ class UnreadCountsThread { }); /// Create a new instance from a json. - factory UnreadCountsThread.fromJson(Map json) => - _$UnreadCountsThreadFromJson(json); + factory UnreadCountsThread.fromJson(Map json) => _$UnreadCountsThreadFromJson(json); /// Number of unread messages in this thread. final int unreadCount; @@ -78,8 +76,7 @@ class UnreadCountsChannelType { }); /// Create a new instance from a json. - factory UnreadCountsChannelType.fromJson(Map json) => - _$UnreadCountsChannelTypeFromJson(json); + factory UnreadCountsChannelType.fromJson(Map json) => _$UnreadCountsChannelTypeFromJson(json); /// The type of channel (e.g., "messaging", "livestream", "team"). final String channelType; diff --git a/packages/stream_chat/lib/src/core/models/unread_counts.g.dart b/packages/stream_chat/lib/src/core/models/unread_counts.g.dart index bd149b5a75..95ff1053b9 100644 --- a/packages/stream_chat/lib/src/core/models/unread_counts.g.dart +++ b/packages/stream_chat/lib/src/core/models/unread_counts.g.dart @@ -6,49 +6,40 @@ part of 'unread_counts.dart'; // JsonSerializableGenerator // ************************************************************************** -UnreadCountsChannel _$UnreadCountsChannelFromJson(Map json) => - UnreadCountsChannel( - channelId: json['channel_id'] as String, - unreadCount: (json['unread_count'] as num).toInt(), - lastRead: DateTime.parse(json['last_read'] as String), - ); - -Map _$UnreadCountsChannelToJson( - UnreadCountsChannel instance) => - { - 'channel_id': instance.channelId, - 'unread_count': instance.unreadCount, - 'last_read': instance.lastRead.toIso8601String(), - }; - -UnreadCountsThread _$UnreadCountsThreadFromJson(Map json) => - UnreadCountsThread( - unreadCount: (json['unread_count'] as num).toInt(), - lastRead: DateTime.parse(json['last_read'] as String), - lastReadMessageId: json['last_read_message_id'] as String, - parentMessageId: json['parent_message_id'] as String, - ); - -Map _$UnreadCountsThreadToJson(UnreadCountsThread instance) => - { - 'unread_count': instance.unreadCount, - 'last_read': instance.lastRead.toIso8601String(), - 'last_read_message_id': instance.lastReadMessageId, - 'parent_message_id': instance.parentMessageId, - }; - -UnreadCountsChannelType _$UnreadCountsChannelTypeFromJson( - Map json) => - UnreadCountsChannelType( - channelType: json['channel_type'] as String, - channelCount: (json['channel_count'] as num).toInt(), - unreadCount: (json['unread_count'] as num).toInt(), - ); - -Map _$UnreadCountsChannelTypeToJson( - UnreadCountsChannelType instance) => - { - 'channel_type': instance.channelType, - 'channel_count': instance.channelCount, - 'unread_count': instance.unreadCount, - }; +UnreadCountsChannel _$UnreadCountsChannelFromJson(Map json) => UnreadCountsChannel( + channelId: json['channel_id'] as String, + unreadCount: (json['unread_count'] as num).toInt(), + lastRead: DateTime.parse(json['last_read'] as String), +); + +Map _$UnreadCountsChannelToJson(UnreadCountsChannel instance) => { + 'channel_id': instance.channelId, + 'unread_count': instance.unreadCount, + 'last_read': instance.lastRead.toIso8601String(), +}; + +UnreadCountsThread _$UnreadCountsThreadFromJson(Map json) => UnreadCountsThread( + unreadCount: (json['unread_count'] as num).toInt(), + lastRead: DateTime.parse(json['last_read'] as String), + lastReadMessageId: json['last_read_message_id'] as String, + parentMessageId: json['parent_message_id'] as String, +); + +Map _$UnreadCountsThreadToJson(UnreadCountsThread instance) => { + 'unread_count': instance.unreadCount, + 'last_read': instance.lastRead.toIso8601String(), + 'last_read_message_id': instance.lastReadMessageId, + 'parent_message_id': instance.parentMessageId, +}; + +UnreadCountsChannelType _$UnreadCountsChannelTypeFromJson(Map json) => UnreadCountsChannelType( + channelType: json['channel_type'] as String, + channelCount: (json['channel_count'] as num).toInt(), + unreadCount: (json['unread_count'] as num).toInt(), +); + +Map _$UnreadCountsChannelTypeToJson(UnreadCountsChannelType instance) => { + 'channel_type': instance.channelType, + 'channel_count': instance.channelCount, + 'unread_count': instance.unreadCount, +}; diff --git a/packages/stream_chat/lib/src/core/models/user.dart b/packages/stream_chat/lib/src/core/models/user.dart index 3c45ee5a86..9d6e6e3207 100644 --- a/packages/stream_chat/lib/src/core/models/user.dart +++ b/packages/stream_chat/lib/src/core/models/user.dart @@ -49,13 +49,12 @@ class User extends Equatable implements ComparableFieldProvider { this.teamsRole, this.avgResponseTime, Map extraData = const {}, - }) : - // For backwards compatibility, set 'name', 'image' in [extraData]. - extraData = { - ...extraData, - if (name != null) 'name': name, - if (image != null) 'image': image, - }; + }) : // For backwards compatibility, set 'name', 'image' in [extraData]. + extraData = { + ...extraData, + if (name != null) 'name': name, + if (image != null) 'image': image, + }; /// Create a new instance from json. factory User.fromJson(Map json) => @@ -138,7 +137,7 @@ class User extends Equatable implements ComparableFieldProvider { /// The roles for the user in the teams. /// /// eg: `{'teamId': 'role', 'teamId2': 'role2'}` - final Map< /*Team*/ String, /*Role*/ String>? teamsRole; + final Map? teamsRole; /// The average response time of the user in seconds. final int? avgResponseTime; @@ -147,13 +146,12 @@ class User extends Equatable implements ComparableFieldProvider { final Map extraData; /// List of users to list of userIds. - static List? toIds(List? users) => - users?.map((u) => u.id).toList(); + static List? toIds(List? users) => users?.map((u) => u.id).toList(); /// Serialize to json. Map toJson() => Serializer.moveFromExtraDataToRoot( - _$UserToJson(this), - ); + _$UserToJson(this), + ); /// Creates a copy of [User] with specified attributes overridden. User copyWith({ @@ -173,44 +171,44 @@ class User extends Equatable implements ComparableFieldProvider { bool? invisible, Map? teamsRole, int? avgResponseTime, - }) => - User( - id: id ?? this.id, - role: role ?? this.role, - name: name ?? - extraData?['name'] as String? ?? - // Using extraData value in order to not use id as name. - this.extraData['name'] as String?, - image: image ?? extraData?['image'] as String? ?? this.image, - createdAt: createdAt ?? this.createdAt, - updatedAt: updatedAt ?? this.updatedAt, - lastActive: lastActive ?? this.lastActive, - online: online ?? this.online, - extraData: extraData ?? this.extraData, - banned: banned ?? this.banned, - banExpires: banExpires ?? this.banExpires, - teams: teams ?? this.teams, - language: language ?? this.language, - invisible: invisible ?? this.invisible, - teamsRole: teamsRole ?? this.teamsRole, - avgResponseTime: avgResponseTime ?? this.avgResponseTime, - ); + }) => User( + id: id ?? this.id, + role: role ?? this.role, + name: + name ?? + extraData?['name'] as String? ?? + // Using extraData value in order to not use id as name. + this.extraData['name'] as String?, + image: image ?? extraData?['image'] as String? ?? this.image, + createdAt: createdAt ?? this.createdAt, + updatedAt: updatedAt ?? this.updatedAt, + lastActive: lastActive ?? this.lastActive, + online: online ?? this.online, + extraData: extraData ?? this.extraData, + banned: banned ?? this.banned, + banExpires: banExpires ?? this.banExpires, + teams: teams ?? this.teams, + language: language ?? this.language, + invisible: invisible ?? this.invisible, + teamsRole: teamsRole ?? this.teamsRole, + avgResponseTime: avgResponseTime ?? this.avgResponseTime, + ); @override List get props => [ - id, - role, - lastActive, - online, - extraData, - banned, - banExpires, - teams, - language, - invisible, - teamsRole, - avgResponseTime, - ]; + id, + role, + lastActive, + online, + extraData, + banned, + banExpires, + teams, + language, + invisible, + teamsRole, + avgResponseTime, + ]; @override ComparableField? getComparableField(String sortKey) { diff --git a/packages/stream_chat/lib/src/core/models/user.g.dart b/packages/stream_chat/lib/src/core/models/user.g.dart index f3a8e5868d..03cb7553d0 100644 --- a/packages/stream_chat/lib/src/core/models/user.g.dart +++ b/packages/stream_chat/lib/src/core/models/user.g.dart @@ -7,52 +7,37 @@ part of 'user.dart'; // ************************************************************************** User _$UserFromJson(Map json) => User( - id: json['id'] as String, - role: json['role'] as String?, - createdAt: json['created_at'] == null - ? null - : DateTime.parse(json['created_at'] as String), - updatedAt: json['updated_at'] == null - ? null - : DateTime.parse(json['updated_at'] as String), - lastActive: json['last_active'] == null - ? null - : DateTime.parse(json['last_active'] as String), - online: json['online'] as bool? ?? false, - banned: json['banned'] as bool? ?? false, - banExpires: json['ban_expires'] == null - ? null - : DateTime.parse(json['ban_expires'] as String), - teams: - (json['teams'] as List?)?.map((e) => e as String).toList() ?? - const [], - language: json['language'] as String?, - invisible: json['invisible'] as bool?, - teamsRole: (json['teams_role'] as Map?)?.map( - (k, e) => MapEntry(k, e as String), - ), - avgResponseTime: (json['avg_response_time'] as num?)?.toInt(), - extraData: json['extra_data'] as Map? ?? const {}, - ); + id: json['id'] as String, + role: json['role'] as String?, + createdAt: json['created_at'] == null ? null : DateTime.parse(json['created_at'] as String), + updatedAt: json['updated_at'] == null ? null : DateTime.parse(json['updated_at'] as String), + lastActive: json['last_active'] == null ? null : DateTime.parse(json['last_active'] as String), + online: json['online'] as bool? ?? false, + banned: json['banned'] as bool? ?? false, + banExpires: json['ban_expires'] == null ? null : DateTime.parse(json['ban_expires'] as String), + teams: (json['teams'] as List?)?.map((e) => e as String).toList() ?? const [], + language: json['language'] as String?, + invisible: json['invisible'] as bool?, + teamsRole: (json['teams_role'] as Map?)?.map( + (k, e) => MapEntry(k, e as String), + ), + avgResponseTime: (json['avg_response_time'] as num?)?.toInt(), + extraData: json['extra_data'] as Map? ?? const {}, +); Map _$UserToJson(User instance) => { - 'id': instance.id, - if (instance.role case final value?) 'role': value, - 'teams': instance.teams, - if (instance.createdAt?.toIso8601String() case final value?) - 'created_at': value, - if (instance.updatedAt?.toIso8601String() case final value?) - 'updated_at': value, - if (instance.lastActive?.toIso8601String() case final value?) - 'last_active': value, - 'online': instance.online, - 'banned': instance.banned, - if (instance.banExpires?.toIso8601String() case final value?) - 'ban_expires': value, - if (instance.language case final value?) 'language': value, - if (instance.invisible case final value?) 'invisible': value, - if (instance.teamsRole case final value?) 'teams_role': value, - if (instance.avgResponseTime case final value?) - 'avg_response_time': value, - 'extra_data': instance.extraData, - }; + 'id': instance.id, + if (instance.role case final value?) 'role': value, + 'teams': instance.teams, + if (instance.createdAt?.toIso8601String() case final value?) 'created_at': value, + if (instance.updatedAt?.toIso8601String() case final value?) 'updated_at': value, + if (instance.lastActive?.toIso8601String() case final value?) 'last_active': value, + 'online': instance.online, + 'banned': instance.banned, + if (instance.banExpires?.toIso8601String() case final value?) 'ban_expires': value, + if (instance.language case final value?) 'language': value, + if (instance.invisible case final value?) 'invisible': value, + if (instance.teamsRole case final value?) 'teams_role': value, + if (instance.avgResponseTime case final value?) 'avg_response_time': value, + 'extra_data': instance.extraData, +}; diff --git a/packages/stream_chat/lib/src/core/models/user_block.dart b/packages/stream_chat/lib/src/core/models/user_block.dart index 247de32bb8..77cbfe04f4 100644 --- a/packages/stream_chat/lib/src/core/models/user_block.dart +++ b/packages/stream_chat/lib/src/core/models/user_block.dart @@ -17,8 +17,7 @@ class UserBlock extends Equatable { }); /// Create a new instance from a json - factory UserBlock.fromJson(Map json) => - _$UserBlockFromJson(json); + factory UserBlock.fromJson(Map json) => _$UserBlockFromJson(json); /// User that blocked the [blockedUser]. final User user; @@ -45,21 +44,20 @@ class UserBlock extends Equatable { String? userId, String? blockedUserId, DateTime? createdAt, - }) => - UserBlock( - user: user ?? this.user, - blockedUser: blockedUser ?? this.blockedUser, - userId: userId ?? this.userId, - blockedUserId: blockedUserId ?? this.blockedUserId, - createdAt: createdAt ?? this.createdAt, - ); + }) => UserBlock( + user: user ?? this.user, + blockedUser: blockedUser ?? this.blockedUser, + userId: userId ?? this.userId, + blockedUserId: blockedUserId ?? this.blockedUserId, + createdAt: createdAt ?? this.createdAt, + ); @override List get props => [ - user, - blockedUser, - userId, - blockedUserId, - createdAt, - ]; + user, + blockedUser, + userId, + blockedUserId, + createdAt, + ]; } diff --git a/packages/stream_chat/lib/src/core/models/user_block.g.dart b/packages/stream_chat/lib/src/core/models/user_block.g.dart index 00e138211b..6e36cb806f 100644 --- a/packages/stream_chat/lib/src/core/models/user_block.g.dart +++ b/packages/stream_chat/lib/src/core/models/user_block.g.dart @@ -7,21 +7,17 @@ part of 'user_block.dart'; // ************************************************************************** UserBlock _$UserBlockFromJson(Map json) => UserBlock( - user: User.fromJson(json['user'] as Map), - blockedUser: json['blocked_user'] == null - ? null - : User.fromJson(json['blocked_user'] as Map), - userId: json['user_id'] as String?, - blockedUserId: json['blocked_user_id'] as String?, - createdAt: json['created_at'] == null - ? null - : DateTime.parse(json['created_at'] as String), - ); + user: User.fromJson(json['user'] as Map), + blockedUser: json['blocked_user'] == null ? null : User.fromJson(json['blocked_user'] as Map), + userId: json['user_id'] as String?, + blockedUserId: json['blocked_user_id'] as String?, + createdAt: json['created_at'] == null ? null : DateTime.parse(json['created_at'] as String), +); Map _$UserBlockToJson(UserBlock instance) => { - 'user': instance.user.toJson(), - 'blocked_user': instance.blockedUser?.toJson(), - 'user_id': instance.userId, - 'blocked_user_id': instance.blockedUserId, - 'created_at': instance.createdAt?.toIso8601String(), - }; + 'user': instance.user.toJson(), + 'blocked_user': instance.blockedUser?.toJson(), + 'user_id': instance.userId, + 'blocked_user_id': instance.blockedUserId, + 'created_at': instance.createdAt?.toIso8601String(), +}; diff --git a/packages/stream_chat/lib/src/core/platform_detector/platform_detector.dart b/packages/stream_chat/lib/src/core/platform_detector/platform_detector.dart index 593a967f90..7e1da8c558 100644 --- a/packages/stream_chat/lib/src/core/platform_detector/platform_detector.dart +++ b/packages/stream_chat/lib/src/core/platform_detector/platform_detector.dart @@ -1,3 +1,4 @@ +import 'package:meta/meta.dart' show visibleForTesting; import 'package:stream_chat/src/core/platform_detector/platform_detector_stub.dart' if (dart.library.html) 'platform_detector_web.dart' if (dart.library.io) 'platform_detector_io.dart'; @@ -67,6 +68,30 @@ class CurrentPlatform { }; } + /// Override the value reported by [type] in tests. + /// + /// Setting this affects all reads of [type], [name], and the per-platform + /// flags ([isAndroid], [isWeb], …). Reset to `null` after each test (e.g. + /// in `tearDown`) to avoid leaking state. + /// + /// The override is honored only when asserts are enabled (debug, profile, + /// and tests); release builds tree-shake it away. Mirrors Flutter's + /// `debugDefaultTargetPlatformOverride`. + @visibleForTesting + static PlatformType? debugCurrentPlatformOverride; + /// Get current platform type - static PlatformType get type => currentPlatform; + static PlatformType get type { + var result = currentPlatform; + assert( + () { + if (debugCurrentPlatformOverride case final override?) { + result = override; + } + return true; + }(), + 'debugCurrentPlatformOverride applied', + ); + return result; + } } diff --git a/packages/stream_chat/lib/src/core/util/event_controller.dart b/packages/stream_chat/lib/src/core/util/event_controller.dart new file mode 100644 index 0000000000..4e98d33b9a --- /dev/null +++ b/packages/stream_chat/lib/src/core/util/event_controller.dart @@ -0,0 +1,77 @@ +import 'dart:async'; +import 'package:rxdart/rxdart.dart'; +import 'package:stream_chat/src/core/models/event.dart'; + +/// A function that inspects an event and optionally resolves it into a +/// more specific or refined version of the same type. +/// +/// If the resolver does not recognize or handle the event, +/// it returns `null`, allowing other resolvers to attempt resolution. +typedef EventResolver = T? Function(T event); + +/// {@template eventController} +/// A reactive event stream controller for [Event]s that supports conditional +/// resolution before emitting events to subscribers. +/// +/// When an event is added: +/// - Each resolver is evaluated in order. +/// - The first resolver that returns a non-null result is used to produce +/// the resolved event that gets emitted. +/// - If no resolver returns a result, the original event is emitted unchanged. +/// +/// This is useful for normalizing or refining generic events into more +/// specific ones (e.g. rewriting `pollVoteCasted` into `pollAnswerCasted`) +/// before they reach business logic or state layers. +/// {@endtemplate} +class EventController extends Subject { + /// {@macro eventController} + factory EventController({ + bool sync = false, + void Function()? onListen, + void Function()? onCancel, + List> resolvers = const [], + }) { + // ignore: close_sinks + final controller = StreamController.broadcast( + sync: sync, + onListen: onListen, + onCancel: onCancel, + ); + + return EventController._( + controller, + controller.stream, + resolvers, + ); + } + + EventController._( + super.controller, + super.stream, + this._resolvers, + ); + + /// The list of resolvers used to inspect and optionally resolve events + /// before they are emitted. + /// + /// Resolvers are evaluated in order. The first to return a non-null result + /// determines the event that will be emitted. If none apply, the original + /// event is emitted as-is. + final List> _resolvers; + + /// Adds an [event] to the stream. + /// + /// Each [EventResolver] is applied in order until one returns a non-null + /// result. That resolved event is emitted, and no further resolvers are + /// evaluated. If all resolvers return `null`, the original event is emitted. + @override + void add(T event) { + for (final resolver in _resolvers) { + final result = resolver(event); + if (result != null) return super.add(result); + } + + // No resolver matched — emit the event as-is. + return super.add(event); + } +} diff --git a/packages/stream_chat/lib/src/core/util/extension.dart b/packages/stream_chat/lib/src/core/util/extension.dart index 9d409dacc3..51bfe84639 100644 --- a/packages/stream_chat/lib/src/core/util/extension.dart +++ b/packages/stream_chat/lib/src/core/util/extension.dart @@ -14,8 +14,7 @@ extension IterableX on Iterable { extension MapX on Map { /// Returns a new map with null keys or values removed Map get nullProtected { - final nullProtected = {...this} - ..removeWhere((key, value) => key == null || value == null); + final nullProtected = {...this}..removeWhere((key, value) => key == null || value == null); return nullProtected.cast(); } } @@ -66,7 +65,7 @@ extension CompleterX on Completer { /// Extension providing merge functionality for any iterable. extension IterableMergeExtension on Iterable { - /// Merges this iterable with another iterable of the same type. + /// Merges this iterable with another iterable of the **same type**. /// /// This method allows merging two iterables by identifying items with the /// same key and using an update function to combine them. Items that exist @@ -93,12 +92,83 @@ extension IterableMergeExtension on Iterable { Iterable? other, { required K Function(T item) key, required T Function(T original, T updated) update, + }) { + return mergeFrom( + other, + key: key, + value: (item) => item, + update: update, + ); + } + + /// Merges this iterable with another iterable of **a different type**. + /// + /// This method generalizes [merge] to support merging items of type [V] + /// (for example, DTOs or partial updates) into an existing collection of + /// items of type [T]. + /// + /// The [value] function converts each [V] element into a corresponding [T] + /// instance (or returns `null` to skip the item). + /// + /// The [key] extractor identifies how to match existing and new elements. + /// When a matching key already exists, the [update] function determines how + /// to combine the original and new values. If no match exists, the new + /// element is added. + /// + /// Items that appear only in one iterable are preserved as-is. + /// + /// Example (merging DTOs into models): + /// ```dart + /// final users = [User(id: '1', name: 'John'), User(id: '2', name: 'Alice')]; + /// + /// final dtos = [ + /// UserDTO(id: '1', name: 'John Doe'), + /// UserDTO(id: '3', name: 'Bob'), + /// ]; + /// + /// final merged = users.mergeFrom( + /// dtos, + /// key: (user) => user.id, + /// value: (dto) => dto.toUser(), + /// update: (original, updated) => original.copyWith(name: updated.name), + /// ); + /// + /// // Result: + /// // [ + /// // User(id: '1', name: 'John Doe'), + /// // User(id: '2', name: 'Alice'), + /// // User(id: '3', name: 'Bob'), + /// // ] + /// ``` + /// + /// Example (skipping null conversions): + /// ```dart + /// final list = [Item(id: 1, name: 'A')]; + /// final updates = [ItemUpdate(id: 1, name: null)]; + /// + /// final merged = list.mergeFrom( + /// updates, + /// key: (item) => item.id, + /// value: (update) => update.toItemOrNull(), + /// update: (original, updated) => updated, + /// ); + /// + /// // The null return from `toItemOrNull()` causes the item to be skipped. + /// ``` + Iterable mergeFrom( + Iterable? other, { + required K Function(T item) key, + required T? Function(V item) value, + required T Function(T original, T updated) update, }) { if (other == null) return this; final itemMap = {for (final item in this) key(item): item}; - for (final item in other) { + for (final otherItem in other) { + final item = value.call(otherItem); + if (item == null) continue; + itemMap.update( key(item), (original) => update(original, item), diff --git a/packages/stream_chat/lib/src/core/util/message_rules.dart b/packages/stream_chat/lib/src/core/util/message_rules.dart index 6db2cfbf31..d3ac674eb0 100644 --- a/packages/stream_chat/lib/src/core/util/message_rules.dart +++ b/packages/stream_chat/lib/src/core/util/message_rules.dart @@ -19,11 +19,21 @@ class MessageRules { /// * A poll static bool canUpload(Message message) { final hasText = message.text?.trim().isNotEmpty == true; + if (hasText) return true; + final hasAttachments = message.attachments.isNotEmpty; + if (hasAttachments) return true; + final hasQuotedMessage = message.quotedMessageId != null; + if (hasQuotedMessage) return true; + + final hasSharedLocation = message.sharedLocation != null; + if (hasSharedLocation) return true; + final hasPoll = message.pollId != null; + if (hasPoll) return true; - return hasText || hasAttachments || hasQuotedMessage || hasPoll; + return false; } /// Whether the [message] can update the channel's last message timestamp. diff --git a/packages/stream_chat/lib/src/core/util/serializer.dart b/packages/stream_chat/lib/src/core/util/serializer.dart index 9c1cb71f67..5cd992bc11 100644 --- a/packages/stream_chat/lib/src/core/util/serializer.dart +++ b/packages/stream_chat/lib/src/core/util/serializer.dart @@ -11,12 +11,10 @@ class Serializer { ..removeWhere( (key, value) => topLevelFields.contains(key), ); - final rootFields = jsonClone - ..removeWhere((key, value) => extraDataMap.keys.contains(key)); - return rootFields - ..addAll({ - 'extra_data': extraDataMap, - }); + final rootFields = jsonClone..removeWhere((key, value) => extraDataMap.keys.contains(key)); + return rootFields..addAll({ + 'extra_data': extraDataMap, + }); } /// Takes values in `extra_data` key and puts them on the root level of diff --git a/packages/stream_chat/lib/src/core/util/utils.dart b/packages/stream_chat/lib/src/core/util/utils.dart index c220499182..23c8bc8923 100644 --- a/packages/stream_chat/lib/src/core/util/utils.dart +++ b/packages/stream_chat/lib/src/core/util/utils.dart @@ -3,8 +3,7 @@ import 'dart:math' as math; // This alphabet uses `A-Za-z0-9_-` symbols. The genetic algorithm helped // optimize the gzip compression for this alphabet. -const _alphabet = - 'ModuleSymbhasOwnPr-0123456789ABCDEFGHNRVfgctiUvz_KqYTJkLxpZXIjQW'; +const _alphabet = 'ModuleSymbhasOwnPr-0123456789ABCDEFGHNRVfgctiUvz_KqYTJkLxpZXIjQW'; /// Generates a random String id /// Adopted from: https://github.com/ai/nanoid/blob/main/non-secure/index.js diff --git a/packages/stream_chat/lib/src/db/chat_persistence_client.dart b/packages/stream_chat/lib/src/db/chat_persistence_client.dart index 80fe638758..4daf3f87f6 100644 --- a/packages/stream_chat/lib/src/db/chat_persistence_client.dart +++ b/packages/stream_chat/lib/src/db/chat_persistence_client.dart @@ -7,6 +7,7 @@ import 'package:stream_chat/src/core/models/channel_state.dart'; import 'package:stream_chat/src/core/models/draft.dart'; import 'package:stream_chat/src/core/models/event.dart'; import 'package:stream_chat/src/core/models/filter.dart'; +import 'package:stream_chat/src/core/models/location.dart'; import 'package:stream_chat/src/core/models/member.dart'; import 'package:stream_chat/src/core/models/message.dart'; import 'package:stream_chat/src/core/models/poll.dart'; @@ -86,6 +87,12 @@ abstract class ChatPersistenceClient { /// [parentId] for thread messages. Future getDraftMessageByCid(String cid, {String? parentId}); + /// Get stored [Location]s by providing channel [cid] + Future> getLocationsByCid(String cid); + + /// Get stored [Location] by providing [messageId] + Future getLocationByMessageId(String messageId); + /// Get [ChannelState] data by providing channel [cid] Future getChannelStateByCid( String cid, { @@ -117,13 +124,15 @@ abstract class ChatPersistenceClient { ); } - /// Get all the stored [ChannelState]s + /// Returns all stored channel states. /// - /// Optionally, pass [filter], [sort], [paginationParams] - /// for filtering out states. + /// Optionally provide [filter] to filter channels, [channelStateSort] to + /// sort results, [messageLimit] to limit messages per channel, and + /// [paginationParams] to paginate results. Future> getChannelStates({ Filter? filter, SortOrder? channelStateSort, + int? messageLimit, PaginationParams? paginationParams, }); @@ -138,12 +147,10 @@ abstract class ChatPersistenceClient { }); /// Remove a message by [messageId] - Future deleteMessageById(String messageId) => - deleteMessageByIds([messageId]); + Future deleteMessageById(String messageId) => deleteMessageByIds([messageId]); /// Remove a pinned message by [messageId] - Future deletePinnedMessageById(String messageId) => - deletePinnedMessageByIds([messageId]); + Future deletePinnedMessageById(String messageId) => deletePinnedMessageByIds([messageId]); /// Remove a message by [messageIds] Future deleteMessageByIds(List messageIds); @@ -155,8 +162,7 @@ abstract class ChatPersistenceClient { Future deleteMessageByCid(String cid) => deleteMessageByCids([cid]); /// Remove a pinned message by channel [cid] - Future deletePinnedMessageByCid(String cid) async => - deletePinnedMessageByCids([cid]); + Future deletePinnedMessageByCid(String cid) async => deletePinnedMessageByCids([cid]); /// Remove a message by message [cids] Future deleteMessageByCids(List cids); @@ -164,6 +170,24 @@ abstract class ChatPersistenceClient { /// Remove a pinned message by message [cids] Future deletePinnedMessageByCids(List cids); + /// Deletes all stored messages sent by a user with the given [userId]. + /// + /// If [hardDelete] is `true`, permanently removes messages from storage. + /// Otherwise, soft-deletes them by updating their type, deletion timestamp, + /// and state. + /// + /// If [cid] is provided, only deletes messages in that channel. Otherwise, + /// deletes messages across all channels. + /// + /// The [deletedAt] timestamp is used for soft deletes. Defaults to the + /// current time if not provided. + Future deleteMessagesFromUser({ + String? cid, + required String userId, + bool hardDelete = false, + DateTime? deletedAt, + }); + /// Remove a channel by [channelId] Future deleteChannels(List cids); @@ -171,18 +195,22 @@ abstract class ChatPersistenceClient { /// [DraftMessages.parentId]. Future deleteDraftMessageByCid(String cid, {String? parentId}); + /// Removes locations by channel [cid] + Future deleteLocationsByCid(String cid); + + /// Removes locations by message [messageIds] + Future deleteLocationsByMessageIds(List messageIds); + /// Updates the message data of a particular channel [cid] with /// the new [messages] data - Future updateMessages(String cid, List messages) => - bulkUpdateMessages({cid: messages}); + Future updateMessages(String cid, List messages) => bulkUpdateMessages({cid: messages}); /// Bulk updates the message data of multiple channels. Future bulkUpdateMessages(Map?> messages); /// Updates the pinned message data of a particular channel [cid] with /// the new [messages] data - Future updatePinnedMessages(String cid, List messages) => - bulkUpdatePinnedMessages({cid: messages}); + Future updatePinnedMessages(String cid, List messages) => bulkUpdatePinnedMessages({cid: messages}); /// Bulk updates the message data of multiple channels. Future bulkUpdatePinnedMessages(Map?> messages); @@ -202,16 +230,14 @@ abstract class ChatPersistenceClient { /// Updates all the members of a particular channle [cid] /// with the new [members] data - Future updateMembers(String cid, List members) => - bulkUpdateMembers({cid: members}); + Future updateMembers(String cid, List members) => bulkUpdateMembers({cid: members}); /// Bulk updates the members data of multiple channels. Future bulkUpdateMembers(Map?> members); /// Updates the read data of a particular channel [cid] with /// the new [reads] data - Future updateReads(String cid, List reads) => - bulkUpdateReads({cid: reads}); + Future updateReads(String cid, List reads) => bulkUpdateReads({cid: reads}); /// Bulk updates the read data of multiple channels. Future bulkUpdateReads(Map?> reads); @@ -231,6 +257,9 @@ abstract class ChatPersistenceClient { /// Updates the draft messages data with the new [draftMessages] data Future updateDraftMessages(List draftMessages); + /// Updates the locations data with the new [locations] data + Future updateLocations(List locations); + /// Deletes all the reactions by [messageIds] Future deleteReactionsByMessageId(List messageIds); @@ -278,8 +307,7 @@ abstract class ChatPersistenceClient { } /// Update the channel state data using [channelState] - Future updateChannelState(ChannelState channelState) => - updateChannelStates([channelState]); + Future updateChannelState(ChannelState channelState) => updateChannelStates([channelState]); /// Update list of channel states Future updateChannelStates(List channelStates) async { @@ -306,6 +334,8 @@ abstract class ChatPersistenceClient { final drafts = []; final draftsToDeleteCids = []; + final locations = []; + for (final state in channelStates) { final channel = state.channel; // Continue if channel is not available. @@ -317,10 +347,10 @@ abstract class ChatPersistenceClient { final members = state.members; final messages = switch (CurrentPlatform.isWeb) { true => state.messages?.where( - (it) => !it.attachments.any( - (it) => it.uploadState != const UploadState.success(), - ), + (it) => !it.attachments.any( + (it) => it.uploadState != const UploadState.success(), ), + ), _ => state.messages, }; @@ -341,32 +371,45 @@ abstract class ChatPersistenceClient { reactions.addAll(messages?.expand(_expandReactions) ?? []); pinnedReactions.addAll(pinnedMessages?.expand(_expandReactions) ?? []); - polls.addAll([ - ...?messages?.map((it) => it.poll), - ...?pinnedMessages?.map((it) => it.poll), - ].withNullifyer); + polls.addAll( + [ + ...?messages?.map((it) => it.poll), + ...?pinnedMessages?.map((it) => it.poll), + ].withNullifyer, + ); pollVotesToDelete.addAll(polls.map((it) => it.id)); pollVotes.addAll(polls.expand(_expandPollVotes)); - drafts.addAll([ - state.draft, - ...?messages?.map((it) => it.draft), - ...?pinnedMessages?.map((it) => it.draft), - ].nonNulls); - - users.addAll([ - channel.createdBy, - ...?messages?.map((it) => it.user), - ...?pinnedMessages?.map((it) => it.user), - ...?reads?.map((it) => it.user), - ...?members?.map((it) => it.user), - ...reactions.map((it) => it.user), - ...pinnedReactions.map((it) => it.user), - ...polls.map((it) => it.createdBy), - ...pollVotes.map((it) => it.user), - ].withNullifyer); + drafts.addAll( + [ + state.draft, + ...?messages?.map((it) => it.draft), + ...?pinnedMessages?.map((it) => it.draft), + ].nonNulls, + ); + + locations.addAll( + [ + ...?messages?.map((it) => it.sharedLocation), + ...?pinnedMessages?.map((it) => it.sharedLocation), + ].nonNulls, + ); + + users.addAll( + [ + channel.createdBy, + ...?messages?.map((it) => it.user), + ...?pinnedMessages?.map((it) => it.user), + ...?reads?.map((it) => it.user), + ...?members?.map((it) => it.user), + ...reactions.map((it) => it.user), + ...pinnedReactions.map((it) => it.user), + ...polls.map((it) => it.createdBy), + ...pollVotes.map((it) => it.user), + ].withNullifyer, + ); } // Removing old members and reactions data as they may have @@ -400,6 +443,7 @@ abstract class ChatPersistenceClient { updatePinnedMessageReactions(pinnedReactions), updatePollVotes(pollVotes), updateDraftMessages(drafts), + updateLocations(locations), ]); } diff --git a/packages/stream_chat/lib/src/event_type.dart b/packages/stream_chat/lib/src/event_type.dart index 12f0eb8f6f..7941395501 100644 --- a/packages/stream_chat/lib/src/event_type.dart +++ b/packages/stream_chat/lib/src/event_type.dart @@ -52,23 +52,19 @@ class EventType { static const String channelDeleted = 'channel.deleted'; /// Event sent when a channel is deleted - static const String notificationChannelDeleted = - 'notification.channel_deleted'; + static const String notificationChannelDeleted = 'notification.channel_deleted'; /// Event sent when a channel is truncated static const String channelTruncated = 'channel.truncated'; /// Event sent when a channel is truncated - static const String notificationChannelTruncated = - 'notification.channel_truncated'; + static const String notificationChannelTruncated = 'notification.channel_truncated'; /// Event sent when the user is added to a channel - static const String notificationAddedToChannel = - 'notification.added_to_channel'; + static const String notificationAddedToChannel = 'notification.added_to_channel'; /// Event sent when the user is removed from a channel - static const String notificationRemovedFromChannel = - 'notification.removed_from_channel'; + static const String notificationRemovedFromChannel = 'notification.removed_from_channel'; /// Event sent when a channel is updated static const String channelUpdated = 'channel.updated'; @@ -104,8 +100,7 @@ class EventType { static const String connectionRecovered = 'connection.recovered'; /// Event sent when the user is accepts an invite - static const String notificationInviteAccepted = - 'notification.invite_accepted'; + static const String notificationInviteAccepted = 'notification.invite_accepted'; /// Event sent when the user is invited static const String notificationInvited = 'notification.invited'; @@ -122,6 +117,9 @@ class EventType { /// Event sent when the AI indicator is cleared static const String aiIndicatorClear = 'ai_indicator.clear'; + /// Event sent when a new poll is created. + static const String pollCreated = 'poll.created'; + /// Event sent when a poll is updated. static const String pollUpdated = 'poll.updated'; @@ -150,8 +148,7 @@ class EventType { static const String threadUpdated = 'thread.updated'; /// Event sent when a new message is added to a thread. - static const String notificationThreadMessageNew = - 'notification.thread_message_new'; + static const String notificationThreadMessageNew = 'notification.thread_message_new'; /// Event sent when a draft message is either created or updated. static const String draftUpdated = 'draft.updated'; @@ -171,13 +168,24 @@ class EventType { /// Event sent when a message reminder is due. static const String notificationReminderDue = 'notification.reminder_due'; + /// Event sent when a new shared location is created. + static const String locationShared = 'location.shared'; + + /// Event sent when a live shared location is updated. + static const String locationUpdated = 'location.updated'; + + /// Event sent when a live shared location is expired. + static const String locationExpired = 'location.expired'; + /// Local event sent when push notification preference is updated. static const String pushPreferenceUpdated = 'push_preference.updated'; /// Local event sent when channel push notification preference is updated. - static const String channelPushPreferenceUpdated = - 'channel.push_preference.updated'; + static const String channelPushPreferenceUpdated = 'channel.push_preference.updated'; /// Event sent when a message is marked as delivered. static const String messageDelivered = 'message.delivered'; + + /// Event sent when all messages of a user are deleted. + static const String userMessagesDeleted = 'user.messages.deleted'; } diff --git a/packages/stream_chat/lib/src/permission_type.dart b/packages/stream_chat/lib/src/permission_type.dart deleted file mode 100644 index dcf0c5ef49..0000000000 --- a/packages/stream_chat/lib/src/permission_type.dart +++ /dev/null @@ -1,103 +0,0 @@ -/// Describes capabilities of a user vis-a-vis a channel -@Deprecated("Use 'ChannelCapability' instead") -class PermissionType { - /// Capability required to send a message in the channel - /// Channel is not frozen (or user has UseFrozenChannel permission) - /// and user has CreateMessage permission. - static const String sendMessage = 'send-message'; - - /// Capability required to receive connect events in the channel - static const String connectEvents = 'connect-events'; - - /// Capability required to send a message - /// Reactions are enabled for the channel, channel is not frozen - /// (or user has UseFrozenChannel permission) and user has - /// CreateReaction permission - static const String sendReaction = 'send-reaction'; - - /// Capability required to send links in a channel - /// send-message + user has AddLinks permission - static const String sendLinks = 'send-links'; - - /// Capability required to send thread reply - /// send-message + channel has replies enabled - static const String sendReply = 'send-reply'; - - /// Capability to freeze a channel - /// User has UpdateChannelFrozen permission. - /// The name implies freezing, - /// but unfreezing is also allowed when this capability is present - static const String freezeChannel = 'freeze-channel'; - - /// User has UpdateChannelCooldown permission. - /// Allows to enable/disable slow mode in the channel - static const String setChannelCooldown = 'set-channel-cooldown'; - - /// User has the ability to skip slow mode when it's active. - static const String skipSlowMode = 'skip-slow-mode'; - - /// User has RemoveOwnChannelMembership or UpdateChannelMembers permission - static const String leaveChannel = 'leave-channel'; - - /// User can mute channel - static const String muteChannel = 'mute-channel'; - - /// Ability to receive read events - static const String readEvents = 'read-events'; - - /// Capability required to pin a message in a channel - /// Corresponds to PinMessage permission - static const String pinMessage = 'pin-message'; - - /// Capability required to quote a message in a channel - static const String quoteMessage = 'quote-message'; - - /// Capability required to flag a message in a channel - static const String flagMessage = 'flag-message'; - - /// User has ability to delete any message in the channel - /// User has DeleteMessage permission - /// which applies to any message in the channel - static const String deleteAnyMessage = 'delete-any-message'; - - /// User has ability to delete their own message in the channel - /// User has DeleteMessage permission which applies only to owned messages - static const String deleteOwnMessage = 'delete-own-message'; - - /// User has ability to update/edit any message in the channel - /// User has UpdateMessage permission which - /// applies to any message in the channel - static const String updateAnyMessage = 'update-any-message'; - - /// User has ability to update/edit their own message in the channel - /// User has UpdateMessage permission which applies only to owned messages - static const String updateOwnMessage = 'update-own-message'; - - /// User can search for message in a channel - /// Search feature is enabled (it will also have - /// permission check in the future) - static const String searchMessages = 'search-messages'; - - /// Capability required to send typing events in a channel - /// (Typing events are enabled) - static const String sendTypingEvents = 'send-typing-events'; - - /// Capability required to upload a file in a channel - /// Uploads are enabled and user has UploadAttachment - static const String uploadFile = 'upload-file'; - - /// Capability required to delete channel - /// User has DeleteChannel permission - static const String deleteChannel = 'delete-channel'; - - /// Capability required update/edit channel info - /// User has UpdateChannel permission - static const String updateChannel = 'update-channel'; - - /// Capability required to update/edit channel members - /// Channel is not distinct and user has UpdateChannelMembers permission - static const String updateChannelMembers = 'update-channel-members'; - - /// Capability required to send a poll in a channel. - static const String sendPoll = 'send-poll'; -} diff --git a/packages/stream_chat/lib/src/ws/connect_user_details.g.dart b/packages/stream_chat/lib/src/ws/connect_user_details.g.dart index 435c4febfc..2a77459610 100644 --- a/packages/stream_chat/lib/src/ws/connect_user_details.g.dart +++ b/packages/stream_chat/lib/src/ws/connect_user_details.g.dart @@ -6,14 +6,12 @@ part of 'connect_user_details.dart'; // JsonSerializableGenerator // ************************************************************************** -Map _$ConnectUserDetailsToJson(ConnectUserDetails instance) => - { - 'id': instance.id, - if (instance.name case final value?) 'name': value, - if (instance.image case final value?) 'image': value, - if (instance.language case final value?) 'language': value, - if (instance.invisible case final value?) 'invisible': value, - if (instance.privacySettings?.toJson() case final value?) - 'privacy_settings': value, - if (instance.extraData case final value?) 'extra_data': value, - }; +Map _$ConnectUserDetailsToJson(ConnectUserDetails instance) => { + 'id': instance.id, + if (instance.name case final value?) 'name': value, + if (instance.image case final value?) 'image': value, + if (instance.language case final value?) 'language': value, + if (instance.invisible case final value?) 'invisible': value, + if (instance.privacySettings?.toJson() case final value?) 'privacy_settings': value, + if (instance.extraData case final value?) 'extra_data': value, +}; diff --git a/packages/stream_chat/lib/src/ws/websocket.dart b/packages/stream_chat/lib/src/ws/websocket.dart index d6cb82a716..40f18756e4 100644 --- a/packages/stream_chat/lib/src/ws/websocket.dart +++ b/packages/stream_chat/lib/src/ws/websocket.dart @@ -24,10 +24,11 @@ typedef EventHandler = void Function(Event); /// Typedef used for connecting to a websocket. Method returns a /// [WebSocketChannel] and accepts a connection [url] and an optional /// [Iterable] of `protocols`. -typedef WebSocketChannelProvider = WebSocketChannel Function( - Uri uri, { - Iterable? protocols, -}); +typedef WebSocketChannelProvider = + WebSocketChannel Function( + Uri uri, { + Iterable? protocols, + }); /// A WebSocket connection that reconnects upon failure. class WebSocket with TimerHelper { @@ -132,8 +133,7 @@ class WebSocket with TimerHelper { if (_webSocketChannel != null) { _closeWebSocketChannel(); } - _webSocketChannel = - webSocketChannelProvider?.call(uri) ?? WebSocketChannel.connect(uri); + _webSocketChannel = webSocketChannelProvider?.call(uri) ?? WebSocketChannel.connect(uri); _subscribeToWebSocketChannel(); } @@ -409,8 +409,7 @@ class WebSocket with TimerHelper { } void _onConnectionError(error, [stacktrace]) { - _logger?.warning( - '[onConnectionError] #ws; error occurred', error, stacktrace); + _logger?.warning('[onConnectionError] #ws; error occurred', error, stacktrace); StreamWebSocketError wsError; if (error is WebSocketChannelException) { diff --git a/packages/stream_chat/lib/stream_chat.dart b/packages/stream_chat/lib/stream_chat.dart index 457d0c48a5..b3f358c4c9 100644 --- a/packages/stream_chat/lib/stream_chat.dart +++ b/packages/stream_chat/lib/stream_chat.dart @@ -44,8 +44,11 @@ export 'src/core/models/draft.dart'; export 'src/core/models/draft_message.dart'; export 'src/core/models/event.dart'; export 'src/core/models/filter.dart' show Filter; +export 'src/core/models/location.dart'; +export 'src/core/models/location_coordinates.dart'; export 'src/core/models/member.dart'; export 'src/core/models/message.dart'; +export 'src/core/models/message_delete_scope.dart'; export 'src/core/models/message_delivery.dart'; export 'src/core/models/message_reminder.dart'; export 'src/core/models/message_state.dart'; @@ -71,6 +74,5 @@ export 'src/core/util/extension.dart'; export 'src/core/util/message_rules.dart'; export 'src/db/chat_persistence_client.dart'; export 'src/event_type.dart'; -export 'src/permission_type.dart'; export 'src/system_environment.dart'; export 'src/ws/connection_status.dart'; diff --git a/packages/stream_chat/lib/version.dart b/packages/stream_chat/lib/version.dart index 1adff547b0..14d653d358 100644 --- a/packages/stream_chat/lib/version.dart +++ b/packages/stream_chat/lib/version.dart @@ -9,4 +9,4 @@ /// Current package version /// Used in [SystemEnvironmentManager] to build the `x-stream-client` header -const PACKAGE_VERSION = '9.23.0'; +const PACKAGE_VERSION = '10.0.0-beta.13'; diff --git a/packages/stream_chat/pubspec.yaml b/packages/stream_chat/pubspec.yaml index 2ffdba917f..9ae75c72c5 100644 --- a/packages/stream_chat/pubspec.yaml +++ b/packages/stream_chat/pubspec.yaml @@ -1,7 +1,7 @@ name: stream_chat homepage: https://getstream.io/ description: The official Dart client for Stream Chat, a service for building chat applications. -version: 9.23.0 +version: 10.0.0-beta.13 repository: https://github.com/GetStream/stream-chat-flutter issue_tracker: https://github.com/GetStream/stream-chat-flutter/issues @@ -18,7 +18,7 @@ issue_tracker: https://github.com/GetStream/stream-chat-flutter/issues # 2. Add it to the melos.yaml file for future updates. environment: - sdk: ^3.6.2 + sdk: ^3.10.0 dependencies: async: ^2.11.0 diff --git a/packages/stream_chat/test/fixtures/channel_state_to_json.json b/packages/stream_chat/test/fixtures/channel_state_to_json.json index f497da7c95..95259b7eff 100644 --- a/packages/stream_chat/test/fixtures/channel_state_to_json.json +++ b/packages/stream_chat/test/fixtures/channel_state_to_json.json @@ -34,9 +34,7 @@ "poll_id": null, "restricted_visibility": [ "user-id-3" - ], - "draft": null, - "reminder": null + ] }, { "id": "dry-meadow-0-e8e74482-b4cd-48db-9d1e-30e6c191786f", @@ -51,9 +49,7 @@ "silent": false, "pinned": false, "pin_expires": null, - "poll_id": null, - "draft": null, - "reminder": null + "poll_id": null }, { "id": "dry-meadow-0-53e6299f-9b97-4a9c-a27e-7e2dde49b7e0", @@ -68,9 +64,7 @@ "silent": false, "pinned": false, "pin_expires": null, - "poll_id": null, - "draft": null, - "reminder": null + "poll_id": null }, { "id": "dry-meadow-0-80925be0-786e-40a5-b225-486518dafd35", @@ -85,9 +79,7 @@ "silent": false, "pinned": false, "pin_expires": null, - "poll_id": null, - "draft": null, - "reminder": null + "poll_id": null }, { "id": "dry-meadow-0-64d7970f-ede8-4b31-9738-1bc1756d2bfe", @@ -102,9 +94,7 @@ "silent": false, "pinned": false, "pin_expires": null, - "poll_id": null, - "draft": null, - "reminder": null + "poll_id": null }, { "id": "withered-cell-0-84cbd760-cf55-4f7e-9207-c5f66cccc6dc", @@ -119,9 +109,7 @@ "silent": false, "pinned": false, "pin_expires": null, - "poll_id": null, - "draft": null, - "reminder": null + "poll_id": null }, { "id": "dry-meadow-0-e9203588-43c3-40b1-91f7-f217fc42aa53", @@ -136,9 +124,7 @@ "silent": false, "pinned": false, "pin_expires": null, - "poll_id": null, - "draft": null, - "reminder": null + "poll_id": null }, { "id": "withered-cell-0-7e3552d7-7a0d-45f2-a856-e91b23a7e240", @@ -153,9 +139,7 @@ "silent": false, "pinned": false, "pin_expires": null, - "poll_id": null, - "draft": null, - "reminder": null + "poll_id": null }, { "id": "dry-meadow-0-1ffeafd4-e4fc-4c84-9394-9d7cb10fff42", @@ -170,9 +154,7 @@ "silent": false, "pinned": false, "pin_expires": null, - "poll_id": null, - "draft": null, - "reminder": null + "poll_id": null }, { "id": "dry-meadow-0-3f147324-12c8-4b41-9fb5-2db88d065efa", @@ -187,9 +169,7 @@ "silent": false, "pinned": false, "pin_expires": null, - "poll_id": null, - "draft": null, - "reminder": null + "poll_id": null }, { "id": "dry-meadow-0-51a348ae-0c0a-44de-a556-eac7891c0cf0", @@ -204,9 +184,7 @@ "silent": false, "pinned": false, "pin_expires": null, - "poll_id": null, - "draft": null, - "reminder": null + "poll_id": null }, { "id": "icy-recipe-7-a29e237b-8d81-4a97-9bc8-d42bca3f1356", @@ -221,9 +199,7 @@ "silent": false, "pinned": false, "pin_expires": null, - "poll_id": null, - "draft": null, - "reminder": null + "poll_id": null }, { "id": "icy-recipe-7-935c396e-ddf8-4a9a-951c-0a12fa5bf055", @@ -238,9 +214,7 @@ "silent": false, "pinned": false, "pin_expires": null, - "poll_id": null, - "draft": null, - "reminder": null + "poll_id": null }, { "id": "throbbing-boat-5-1e4d5730-5ff0-4d25-9948-9f34ffda43e4", @@ -255,9 +229,7 @@ "silent": false, "pinned": false, "pin_expires": null, - "poll_id": null, - "draft": null, - "reminder": null + "poll_id": null }, { "id": "snowy-credit-3-3e0c1a0d-d22f-42ee-b2a1-f9f49477bf21", @@ -272,9 +244,7 @@ "silent": false, "pinned": false, "pin_expires": null, - "poll_id": null, - "draft": null, - "reminder": null + "poll_id": null }, { "id": "snowy-credit-3-3319537e-2d0e-4876-8170-a54f046e4b7d", @@ -289,9 +259,7 @@ "silent": false, "pinned": false, "pin_expires": null, - "poll_id": null, - "draft": null, - "reminder": null + "poll_id": null }, { "id": "snowy-credit-3-cfaf0b46-1daa-49c5-947c-b16d6697487d", @@ -306,9 +274,7 @@ "silent": false, "pinned": false, "pin_expires": null, - "poll_id": null, - "draft": null, - "reminder": null + "poll_id": null }, { "id": "snowy-credit-3-cebe25a7-a3a3-49fc-9919-91c6725e81f3", @@ -323,9 +289,7 @@ "silent": false, "pinned": false, "pin_expires": null, - "poll_id": null, - "draft": null, - "reminder": null + "poll_id": null }, { "id": "divine-glade-9-0cea9262-5766-48e9-8b22-311870aed3bf", @@ -340,9 +304,7 @@ "silent": false, "pinned": false, "pin_expires": null, - "poll_id": null, - "draft": null, - "reminder": null + "poll_id": null }, { "id": "red-firefly-9-c4e9007b-bb7d-4238-ae08-5f8e3cd03d73", @@ -357,9 +319,7 @@ "silent": false, "pinned": false, "pin_expires": null, - "poll_id": null, - "draft": null, - "reminder": null + "poll_id": null }, { "id": "bitter-glade-2-02aee4eb-4093-4736-808b-2de75820e854", @@ -374,9 +334,7 @@ "silent": false, "pinned": false, "pin_expires": null, - "poll_id": null, - "draft": null, - "reminder": null + "poll_id": null }, { "id": "morning-sea-1-0c700bcb-46dd-4224-b590-e77bdbccc480", @@ -391,9 +349,7 @@ "silent": false, "pinned": false, "pin_expires": null, - "poll_id": null, - "draft": null, - "reminder": null + "poll_id": null }, { "id": "ancient-salad-0-53e8b4e6-5b7b-43ad-aeee-8bfb6a9ed0be", @@ -408,9 +364,7 @@ "silent": false, "pinned": false, "pin_expires": null, - "poll_id": null, - "draft": null, - "reminder": null + "poll_id": null }, { "id": "ancient-salad-0-8c225075-bd4c-42e2-8024-530aae13cd40", @@ -425,9 +379,7 @@ "silent": false, "pinned": false, "pin_expires": null, - "poll_id": null, - "draft": null, - "reminder": null + "poll_id": null }, { "id": "proud-sea-7-17802096-cbf8-4e3c-addd-4ee31f4c8b5c", @@ -442,12 +394,11 @@ "silent": false, "pinned": false, "pin_expires": null, - "poll_id": null, - "draft": null, - "reminder": null + "poll_id": null } ], "pinned_messages": [], "members": [], + "active_live_locations": [], "watcher_count": 5 } diff --git a/packages/stream_chat/test/fixtures/member.json b/packages/stream_chat/test/fixtures/member.json index 6d95d7ad02..63b7ddd175 100644 --- a/packages/stream_chat/test/fixtures/member.json +++ b/packages/stream_chat/test/fixtures/member.json @@ -12,5 +12,10 @@ "channel_role": "channel_member", "created_at": "2020-01-28T22:17:30.95443Z", "updated_at": "2020-01-28T22:17:30.95443Z", + "deleted_messages": [ + "msg-1", + "msg-2", + "msg-3" + ], "some_custom_field": "with_custom_data" } \ No newline at end of file diff --git a/packages/stream_chat/test/fixtures/message.json b/packages/stream_chat/test/fixtures/message.json index 30cb6f0426..ee08a54991 100644 --- a/packages/stream_chat/test/fixtures/message.json +++ b/packages/stream_chat/test/fixtures/message.json @@ -54,11 +54,13 @@ } ], "own_reactions": [], - "reaction_counts": { - "love": 1 - }, - "reaction_scores": { - "love": 1 + "reaction_groups": { + "love": { + "count": 1, + "sum_scores": 1, + "first_reaction_at": "2020-01-28T22:17:31.107978Z", + "last_reaction_at": "2020-01-28T22:17:31.107978Z" + } }, "pinned": false, "pinned_at": null, diff --git a/packages/stream_chat/test/fixtures/message_to_json.json b/packages/stream_chat/test/fixtures/message_to_json.json index 8803f1ed1d..c4568628d3 100644 --- a/packages/stream_chat/test/fixtures/message_to_json.json +++ b/packages/stream_chat/test/fixtures/message_to_json.json @@ -26,7 +26,5 @@ "restricted_visibility": [ "user-id-3" ], - "draft": null, - "reminder": null, "hey": "test" } \ No newline at end of file diff --git a/packages/stream_chat/test/fixtures/reaction.json b/packages/stream_chat/test/fixtures/reaction.json index ce87ca1d20..c0e8ef35c5 100644 --- a/packages/stream_chat/test/fixtures/reaction.json +++ b/packages/stream_chat/test/fixtures/reaction.json @@ -13,6 +13,7 @@ }, "type": "wow", "score": 1, + "emoji_code": "\uD83D\uDE2E", "created_at": "2020-01-28T22:17:31.108742Z", "updated_at": "2020-01-28T22:17:31.108742Z" } \ No newline at end of file diff --git a/packages/stream_chat/test/src/client/channel_delivery_reporter_test.dart b/packages/stream_chat/test/src/client/channel_delivery_reporter_test.dart index 424e00cec9..7f0a529f3e 100644 --- a/packages/stream_chat/test/src/client/channel_delivery_reporter_test.dart +++ b/packages/stream_chat/test/src/client/channel_delivery_reporter_test.dart @@ -59,15 +59,13 @@ void main() { expect(capturedDeliveries, hasLength(2)); expect( capturedDeliveries.any( - (d) => - d.channelCid == 'test:channel-1' && d.messageId == 'message-1', + (d) => d.channelCid == 'test:channel-1' && d.messageId == 'message-1', ), isTrue, ); expect( capturedDeliveries.any( - (d) => - d.channelCid == 'test:channel-2' && d.messageId == 'message-2', + (d) => d.channelCid == 'test:channel-2' && d.messageId == 'message-2', ), isTrue, ); diff --git a/packages/stream_chat/test/src/client/channel_test.dart b/packages/stream_chat/test/src/client/channel_test.dart index 9610356b00..50c0cdf8ac 100644 --- a/packages/stream_chat/test/src/client/channel_test.dart +++ b/packages/stream_chat/test/src/client/channel_test.dart @@ -91,8 +91,7 @@ void main() { expect(channel.extraData['image'], imageUrl); const newImage = 'https://getstream.io/new-image'; - final newChannelInstance = - Channel(client, channelType, channelId, image: newImage); + final newChannelInstance = Channel(client, channelType, channelId, image: newImage); expect(newChannelInstance.image, newImage); expect(newChannelInstance.extraData['image'], newImage); @@ -108,8 +107,7 @@ void main() { expect(channel.extraData['name'], name); const newName = 'New channel name'; - final newChannelInstance = - Channel(client, channelType, channelId, name: newName); + final newChannelInstance = Channel(client, channelType, channelId, name: newName); expect(newChannelInstance.name, newName); expect(newChannelInstance.extraData['name'], newName); @@ -147,13 +145,10 @@ void main() { // mock persistence client final channelThreads = >{}; - when(() => client.chatPersistenceClient.getChannelThreads(channelCid)) - .thenAnswer((_) async => channelThreads); + when(() => client.chatPersistenceClient.getChannelThreads(channelCid)).thenAnswer((_) async => channelThreads); final channelState = _generateChannelState(channelId, channelType); - when(() => client.chatPersistenceClient.getChannelStateByCid(channelCid)) - .thenAnswer((_) async => channelState); - when(() => client.chatPersistenceClient.updateMessages(channelCid, any())) - .thenAnswer((_) => Future.value()); + when(() => client.chatPersistenceClient.getChannelStateByCid(channelCid)).thenAnswer((_) async => channelState); + when(() => client.chatPersistenceClient.updateMessages(channelCid, any())).thenAnswer((_) => Future.value()); // client logger when(() => client.logger).thenReturn(_createLogger('mock-client-logger')); @@ -221,6 +216,7 @@ void main() { tearDown(() { channel.dispose(); + clearInteractions(client); }); test('should throw if trying to set `extraData`', () { @@ -255,14 +251,15 @@ void main() { user: client.state.currentUser, ); - final sendMessageResponse = SendMessageResponse() - ..message = message.copyWith(state: MessageState.sent); + final sendMessageResponse = SendMessageResponse()..message = message.copyWith(state: MessageState.sent); - when(() => client.sendMessage( - any(that: isSameMessageAs(message)), - channelId, - channelType, - )).thenAnswer((_) async => sendMessageResponse); + when( + () => client.sendMessage( + any(that: isSameMessageAs(message)), + channelId, + channelType, + ), + ).thenAnswer((_) async => sendMessageResponse); expectLater( // skipping first seed message list -> [] messages @@ -288,11 +285,292 @@ void main() { expect(res, isNotNull); expect(res.message.id, message.id); - verify(() => client.sendMessage( + verify( + () => client.sendMessage( + any(that: isSameMessageAs(message)), + channelId, + channelType, + ), + ).called(1); + }); + + test( + 'should handle StreamChatNetworkError by adding message to retry queue with skipPush: true, skipEnrichUrl: false', + () async { + final message = Message( + id: 'test-message-id', + text: 'Hello world!', + user: client.state.currentUser, + ); + + when( + () => client.sendMessage( + any(that: isSameMessageAs(message)), + channelId, + channelType, + skipPush: true, + ), + ).thenThrow(StreamChatNetworkError(ChatErrorCode.notAllowed)); + + expectLater( + // skipping first seed message list -> [] messages + channel.state?.messagesStream.skip(1), + emitsInOrder([ + [ + isSameMessageAs( + message.copyWith(state: MessageState.sending), + matchMessageState: true, + ), + ], + [ + isSameMessageAs( + message.copyWith( + state: MessageState.sendingFailed( + skipPush: true, + skipEnrichUrl: false, + ), + ), + matchMessageState: true, + ), + ], + ]), + ); + + try { + await channel.sendMessage( + message, + skipPush: true, + ); + } catch (e) { + expect(e, isA()); + + final networkError = e as StreamChatNetworkError; + expect(networkError.code, equals(ChatErrorCode.notAllowed.code)); + } + }, + ); + + test( + 'should handle StreamChatNetworkError by adding message to retry queue with skipPush: true, skipEnrichUrl: true', + () async { + final message = Message( + id: 'test-message-id-2', + text: 'Hello world!', + user: client.state.currentUser, + ); + + when( + () => client.sendMessage( + any(that: isSameMessageAs(message)), + channelId, + channelType, + skipPush: true, + skipEnrichUrl: true, + ), + ).thenThrow(StreamChatNetworkError(ChatErrorCode.notAllowed)); + + expectLater( + // skipping first seed message list -> [] messages + channel.state?.messagesStream.skip(1), + emitsInOrder([ + [ + isSameMessageAs( + message.copyWith(state: MessageState.sending), + matchMessageState: true, + ), + ], + [ + isSameMessageAs( + message.copyWith( + state: MessageState.sendingFailed( + skipPush: true, + skipEnrichUrl: true, + ), + ), + matchMessageState: true, + ), + ], + ]), + ); + + try { + await channel.sendMessage( + message, + skipPush: true, + skipEnrichUrl: true, + ); + } catch (e) { + expect(e, isA()); + + final networkError = e as StreamChatNetworkError; + expect(networkError.code, equals(ChatErrorCode.notAllowed.code)); + } + }, + ); + + test( + 'should handle StreamChatNetworkError by adding message to retry queue with skipPush: false, skipEnrichUrl: true', + () async { + final message = Message( + id: 'test-message-id-3', + text: 'Hello world!', + user: client.state.currentUser, + ); + + when( + () => client.sendMessage( + any(that: isSameMessageAs(message)), + channelId, + channelType, + skipEnrichUrl: true, + ), + ).thenThrow(StreamChatNetworkError(ChatErrorCode.notAllowed)); + + expectLater( + // skipping first seed message list -> [] messages + channel.state?.messagesStream.skip(1), + emitsInOrder([ + [ + isSameMessageAs( + message.copyWith(state: MessageState.sending), + matchMessageState: true, + ), + ], + [ + isSameMessageAs( + message.copyWith( + state: MessageState.sendingFailed( + skipPush: false, + skipEnrichUrl: true, + ), + ), + matchMessageState: true, + ), + ], + ]), + ); + + try { + await channel.sendMessage( + message, + skipEnrichUrl: true, + ); + } catch (e) { + expect(e, isA()); + + final networkError = e as StreamChatNetworkError; + expect(networkError.code, equals(ChatErrorCode.notAllowed.code)); + } + }, + ); + + test( + 'should handle StreamChatNetworkError by adding message to retry queue with skipPush: false, skipEnrichUrl: false', + () async { + final message = Message( + id: 'test-message-id-4', + text: 'Hello world!', + user: client.state.currentUser, + ); + + when( + () => client.sendMessage( any(that: isSameMessageAs(message)), channelId, channelType, - )).called(1); + ), + ).thenThrow(StreamChatNetworkError(ChatErrorCode.notAllowed)); + + expectLater( + // skipping first seed message list -> [] messages + channel.state?.messagesStream.skip(1), + emitsInOrder([ + [ + isSameMessageAs( + message.copyWith(state: MessageState.sending), + matchMessageState: true, + ), + ], + [ + isSameMessageAs( + message.copyWith( + state: MessageState.sendingFailed( + skipPush: false, + skipEnrichUrl: false, + ), + ), + matchMessageState: true, + ), + ], + ]), + ); + + try { + await channel.sendMessage( + message, + ); + } catch (e) { + expect(e, isA()); + + final networkError = e as StreamChatNetworkError; + expect(networkError.code, equals(ChatErrorCode.notAllowed.code)); + } + }, + ); + + test('should update message state even when non-retriable error occurs', () async { + final message = Message( + id: 'test-message-id', + text: 'Hello world!', + user: client.state.currentUser, + ); + + when( + () => client.sendMessage( + any(that: isSameMessageAs(message)), + channelId, + channelType, + ), + ).thenThrow( + StreamChatNetworkError.raw( + code: ChatErrorCode.inputError.code, + message: 'Input error', + data: ErrorResponse() + ..code = ChatErrorCode.inputError.code + ..message = 'Input error' + ..statusCode = 400, + ), + ); + + expectLater( + // skipping first seed message list -> [] messages + channel.state?.messagesStream.skip(1), + emitsInOrder([ + [ + isSameMessageAs( + message.copyWith(state: MessageState.sending), + matchMessageState: true, + ), + ], + [ + isSameMessageAs( + message.copyWith( + state: MessageState.sendingFailed( + skipPush: false, + skipEnrichUrl: false, + ), + ), + matchMessageState: true, + ), + ], + ]), + ); + + try { + await channel.sendMessage(message); + } catch (e) { + expect(e, isA()); + } }); test('with attachments should work just fine', () async { @@ -313,36 +591,43 @@ void main() { final sendImageResponse = SendImageResponse()..file = 'test-image-url'; final sendFileResponse = SendFileResponse()..file = 'test-file-url'; - when(() => client.sendImage( - any(), - channelId, - channelType, - onSendProgress: any(named: 'onSendProgress'), - cancelToken: any(named: 'cancelToken'), - extraData: any(named: 'extraData'), - )).thenAnswer((_) async => sendImageResponse); + when( + () => client.sendImage( + any(), + channelId, + channelType, + onSendProgress: any(named: 'onSendProgress'), + cancelToken: any(named: 'cancelToken'), + extraData: any(named: 'extraData'), + ), + ).thenAnswer((_) async => sendImageResponse); - when(() => client.sendFile( - any(), - channelId, - channelType, - onSendProgress: any(named: 'onSendProgress'), - cancelToken: any(named: 'cancelToken'), - extraData: any(named: 'extraData'), - )).thenAnswer((_) async => sendFileResponse); + when( + () => client.sendFile( + any(), + channelId, + channelType, + onSendProgress: any(named: 'onSendProgress'), + cancelToken: any(named: 'cancelToken'), + extraData: any(named: 'extraData'), + ), + ).thenAnswer((_) async => sendFileResponse); - when(() => client.sendMessage( - any(that: isSameMessageAs(message)), - channelId, - channelType, - )).thenAnswer((_) async => SendMessageResponse() - ..message = message.copyWith( - attachments: attachments - .map((it) => - it.copyWith(uploadState: const UploadState.success())) - .toList(growable: false), - state: MessageState.sent, - )); + when( + () => client.sendMessage( + any(that: isSameMessageAs(message)), + channelId, + channelType, + ), + ).thenAnswer( + (_) async => SendMessageResponse() + ..message = message.copyWith( + attachments: attachments + .map((it) => it.copyWith(uploadState: const UploadState.success())) + .toList(growable: false), + state: MessageState.sent, + ), + ); expectLater( // skipping first seed message list -> [] messages @@ -354,10 +639,7 @@ void main() { isSameMessageAs( message.copyWith( state: MessageState.sending, - attachments: [ - ...attachments.map((it) => it.copyWith( - uploadState: const UploadState.preparing())) - ], + attachments: [...attachments.map((it) => it.copyWith(uploadState: const UploadState.preparing()))], ), matchMessageState: true, matchAttachments: true, @@ -369,8 +651,8 @@ void main() { isSameMessageAs( message.copyWith( state: MessageState.sending, - attachments: [...attachments]..[0] = - attachments[0].copyWith( + attachments: [...attachments] + ..[0] = attachments[0].copyWith( uploadState: const UploadState.success(), ), ), @@ -402,10 +684,7 @@ void main() { isSameMessageAs( message.copyWith( state: MessageState.sending, - attachments: [ - ...attachments.map((it) => - it.copyWith(uploadState: const UploadState.success())) - ], + attachments: [...attachments.map((it) => it.copyWith(uploadState: const UploadState.success()))], ), matchMessageState: true, matchAttachments: true, @@ -416,10 +695,7 @@ void main() { isSameMessageAs( message.copyWith( state: MessageState.sent, - attachments: [ - ...attachments.map((it) => - it.copyWith(uploadState: const UploadState.success())) - ], + attachments: [...attachments.map((it) => it.copyWith(uploadState: const UploadState.success()))], ), matchMessageState: true, matchAttachments: true, @@ -442,29 +718,35 @@ void main() { isTrue, ); - verify(() => client.sendImage( - any(), - channelId, - channelType, - onSendProgress: any(named: 'onSendProgress'), - cancelToken: any(named: 'cancelToken'), - extraData: any(named: 'extraData'), - )).called(2); + verify( + () => client.sendImage( + any(), + channelId, + channelType, + onSendProgress: any(named: 'onSendProgress'), + cancelToken: any(named: 'cancelToken'), + extraData: any(named: 'extraData'), + ), + ).called(2); - verify(() => client.sendFile( - any(), - channelId, - channelType, - onSendProgress: any(named: 'onSendProgress'), - cancelToken: any(named: 'cancelToken'), - extraData: any(named: 'extraData'), - )).called(1); + verify( + () => client.sendFile( + any(), + channelId, + channelType, + onSendProgress: any(named: 'onSendProgress'), + cancelToken: any(named: 'cancelToken'), + extraData: any(named: 'extraData'), + ), + ).called(1); - verify(() => client.sendMessage( - any(that: isSameMessageAs(message)), - channelId, - channelType, - )).called(1); + verify( + () => client.sendMessage( + any(that: isSameMessageAs(message)), + channelId, + channelType, + ), + ).called(1); }); test('should not send if the message is invalid', () async { @@ -755,15 +1037,120 @@ void main() { ); }); + group('`.sendStaticLocation`', () { + const deviceId = 'test-device-id'; + const locationId = 'test-location-id'; + const coordinates = LocationCoordinates( + latitude: 40.7128, + longitude: -74.0060, + ); + + test('should create a static location and call sendMessage', () async { + when( + () => client.sendMessage(any(), channelId, channelType), + ).thenAnswer( + (_) async => SendMessageResponse() + ..message = Message( + id: locationId, + text: 'Location shared', + extraData: const {'custom': 'data'}, + sharedLocation: Location( + channelCid: channel.cid, + messageId: locationId, + userId: client.state.currentUser?.id, + latitude: coordinates.latitude, + longitude: coordinates.longitude, + createdByDeviceId: deviceId, + ), + ), + ); + + final response = await channel.sendStaticLocation( + id: locationId, + messageText: 'Location shared', + createdByDeviceId: deviceId, + location: coordinates, + extraData: {'custom': 'data'}, + ); + + expect(response, isNotNull); + expect(response.message.id, locationId); + expect(response.message.text, 'Location shared'); + expect(response.message.extraData['custom'], 'data'); + expect(response.message.sharedLocation, isNotNull); + + verify( + () => client.sendMessage(any(), channelId, channelType), + ).called(1); + }); + }); + + group('`.startLiveLocationSharing`', () { + const deviceId = 'test-device-id'; + const locationId = 'test-location-id'; + final endSharingAt = DateTime.timestamp().add(const Duration(hours: 1)); + const coordinates = LocationCoordinates( + latitude: 40.7128, + longitude: -74.0060, + ); + + test( + 'should create message with live location and call sendMessage', + () async { + when( + () => client.sendMessage(any(), channelId, channelType), + ).thenAnswer( + (_) async => SendMessageResponse() + ..message = Message( + id: locationId, + text: 'Location shared', + extraData: const {'custom': 'data'}, + sharedLocation: Location( + channelCid: channel.cid, + messageId: locationId, + userId: client.state.currentUser?.id, + latitude: coordinates.latitude, + longitude: coordinates.longitude, + createdByDeviceId: deviceId, + endAt: endSharingAt, + ), + ), + ); + + final response = await channel.startLiveLocationSharing( + id: locationId, + messageText: 'Location shared', + createdByDeviceId: deviceId, + location: coordinates, + endSharingAt: endSharingAt, + extraData: {'custom': 'data'}, + ); + + expect(response, isNotNull); + expect(response.message.id, locationId); + expect(response.message.text, 'Location shared'); + expect(response.message.extraData['custom'], 'data'); + expect(response.message.sharedLocation, isNotNull); + expect(response.message.sharedLocation?.endAt, endSharingAt); + + verify( + () => client.sendMessage(any(), channelId, channelType), + ).called(1); + }, + ); + }); + group('`.createDraft`', () { final draftMessage = DraftMessage(text: 'Draft message text'); setUp(() { - when(() => client.createDraft( - draftMessage, - channelId, - channelType, - )).thenAnswer( + when( + () => client.createDraft( + draftMessage, + channelId, + channelType, + ), + ).thenAnswer( (_) async => CreateDraftResponse() ..draft = Draft( channelCid: channelCid, @@ -779,11 +1166,13 @@ void main() { expect(res, isNotNull); expect(res.draft.message, draftMessage); - verify(() => channel.client.createDraft( - draftMessage, - channelId, - channelType, - )).called(1); + verify( + () => channel.client.createDraft( + draftMessage, + channelId, + channelType, + ), + ).called(1); }); }); @@ -791,11 +1180,13 @@ void main() { final draftMessage = DraftMessage(text: 'Draft message text'); setUp(() { - when(() => client.getDraft( - channelId, - channelType, - parentId: any(named: 'parentId'), - )).thenAnswer( + when( + () => client.getDraft( + channelId, + channelType, + parentId: any(named: 'parentId'), + ), + ).thenAnswer( (_) async => GetDraftResponse() ..draft = Draft( channelCid: channelCid, @@ -811,10 +1202,12 @@ void main() { expect(res, isNotNull); expect(res.draft.message, draftMessage); - verify(() => channel.client.getDraft( - channelId, - channelType, - )).called(1); + verify( + () => channel.client.getDraft( + channelId, + channelType, + ), + ).called(1); }); test('with parentId should pass parentId to client', () async { @@ -824,21 +1217,25 @@ void main() { expect(res, isNotNull); expect(res.draft.message, draftMessage); - verify(() => channel.client.getDraft( - channelId, - channelType, - parentId: parentId, - )).called(1); + verify( + () => channel.client.getDraft( + channelId, + channelType, + parentId: parentId, + ), + ).called(1); }); }); group('`.deleteDraft`', () { setUp(() { - when(() => client.deleteDraft( - channelId, - channelType, - parentId: any(named: 'parentId'), - )).thenAnswer((_) async => EmptyResponse()); + when( + () => client.deleteDraft( + channelId, + channelType, + parentId: any(named: 'parentId'), + ), + ).thenAnswer((_) async => EmptyResponse()); }); test('should call client.deleteDraft', () async { @@ -846,10 +1243,12 @@ void main() { expect(res, isNotNull); - verify(() => channel.client.deleteDraft( - channelId, - channelType, - )).called(1); + verify( + () => channel.client.deleteDraft( + channelId, + channelType, + ), + ).called(1); }); test('with parentId should pass parentId to client', () async { @@ -858,11 +1257,13 @@ void main() { expect(res, isNotNull); - verify(() => channel.client.deleteDraft( - channelId, - channelType, - parentId: parentId, - )).called(1); + verify( + () => channel.client.deleteDraft( + channelId, + channelType, + parentId: parentId, + ), + ).called(1); }); }); @@ -870,10 +1271,12 @@ void main() { const messageId = 'test-message-id'; setUp(() { - when(() => client.createReminder( - messageId, - remindAt: any(named: 'remindAt'), - )).thenAnswer( + when( + () => client.createReminder( + messageId, + remindAt: any(named: 'remindAt'), + ), + ).thenAnswer( (_) async => CreateReminderResponse() ..reminder = MessageReminder( messageId: messageId, @@ -903,10 +1306,12 @@ void main() { expect(res.reminder.messageId, messageId); expect(res.reminder.remindAt, remindAt); - verify(() => channel.client.createReminder( - messageId, - remindAt: remindAt, - )).called(1); + verify( + () => channel.client.createReminder( + messageId, + remindAt: remindAt, + ), + ).called(1); }); }); @@ -914,10 +1319,12 @@ void main() { const messageId = 'test-message-id'; setUp(() { - when(() => client.updateReminder( - messageId, - remindAt: any(named: 'remindAt'), - )).thenAnswer( + when( + () => client.updateReminder( + messageId, + remindAt: any(named: 'remindAt'), + ), + ).thenAnswer( (_) async => UpdateReminderResponse() ..reminder = MessageReminder( messageId: messageId, @@ -947,10 +1354,12 @@ void main() { expect(res.reminder.messageId, messageId); expect(res.reminder.remindAt, remindAt); - verify(() => channel.client.updateReminder( - messageId, - remindAt: remindAt, - )).called(1); + verify( + () => channel.client.updateReminder( + messageId, + remindAt: remindAt, + ), + ).called(1); }); }); @@ -979,11 +1388,11 @@ void main() { state: MessageState.sent, ); - final updateMessageResponse = UpdateMessageResponse() - ..message = message; + final updateMessageResponse = UpdateMessageResponse()..message = message; - when(() => client.updateMessage(any(that: isSameMessageAs(message)))) - .thenAnswer((_) async => updateMessageResponse); + when( + () => client.updateMessage(any(that: isSameMessageAs(message))), + ).thenAnswer((_) async => updateMessageResponse); expectLater( // skipping first seed message list -> [] messages @@ -1009,9 +1418,11 @@ void main() { expect(res, isNotNull); expect(res.message.id, message.id); - verify(() => client.updateMessage( - any(that: isSameMessageAs(message)), - )).called(1); + verify( + () => client.updateMessage( + any(that: isSameMessageAs(message)), + ), + ).called(1); }); test('with attachments should work just fine', () async { @@ -1032,34 +1443,41 @@ void main() { final sendImageResponse = SendImageResponse()..file = 'test-image-url'; final sendFileResponse = SendFileResponse()..file = 'test-file-url'; - when(() => client.sendImage( - any(), - channelId, - channelType, - onSendProgress: any(named: 'onSendProgress'), - cancelToken: any(named: 'cancelToken'), - extraData: any(named: 'extraData'), - )).thenAnswer((_) async => sendImageResponse); + when( + () => client.sendImage( + any(), + channelId, + channelType, + onSendProgress: any(named: 'onSendProgress'), + cancelToken: any(named: 'cancelToken'), + extraData: any(named: 'extraData'), + ), + ).thenAnswer((_) async => sendImageResponse); - when(() => client.sendFile( - any(), - channelId, - channelType, - onSendProgress: any(named: 'onSendProgress'), - cancelToken: any(named: 'cancelToken'), - extraData: any(named: 'extraData'), - )).thenAnswer((_) async => sendFileResponse); + when( + () => client.sendFile( + any(), + channelId, + channelType, + onSendProgress: any(named: 'onSendProgress'), + cancelToken: any(named: 'cancelToken'), + extraData: any(named: 'extraData'), + ), + ).thenAnswer((_) async => sendFileResponse); - when(() => client.updateMessage( - any(that: isSameMessageAs(message)), - )).thenAnswer((_) async => UpdateMessageResponse() - ..message = message.copyWith( - state: MessageState.sent, - attachments: attachments - .map((it) => - it.copyWith(uploadState: const UploadState.success())) - .toList(growable: false), - )); + when( + () => client.updateMessage( + any(that: isSameMessageAs(message)), + ), + ).thenAnswer( + (_) async => UpdateMessageResponse() + ..message = message.copyWith( + state: MessageState.sent, + attachments: attachments + .map((it) => it.copyWith(uploadState: const UploadState.success())) + .toList(growable: false), + ), + ); expectLater( // skipping first seed message list -> [] messages @@ -1071,10 +1489,7 @@ void main() { isSameMessageAs( message.copyWith( state: MessageState.updating, - attachments: [ - ...attachments.map((it) => it.copyWith( - uploadState: const UploadState.preparing())) - ], + attachments: [...attachments.map((it) => it.copyWith(uploadState: const UploadState.preparing()))], ), matchMessageState: true, matchAttachments: true, @@ -1086,8 +1501,8 @@ void main() { isSameMessageAs( message.copyWith( state: MessageState.updating, - attachments: [...attachments]..[0] = - attachments[0].copyWith( + attachments: [...attachments] + ..[0] = attachments[0].copyWith( uploadState: const UploadState.success(), ), ), @@ -1119,10 +1534,7 @@ void main() { isSameMessageAs( message.copyWith( state: MessageState.updating, - attachments: [ - ...attachments.map((it) => - it.copyWith(uploadState: const UploadState.success())) - ], + attachments: [...attachments.map((it) => it.copyWith(uploadState: const UploadState.success()))], ), matchMessageState: true, matchAttachments: true, @@ -1133,10 +1545,7 @@ void main() { isSameMessageAs( message.copyWith( state: MessageState.updated, - attachments: [ - ...attachments.map((it) => - it.copyWith(uploadState: const UploadState.success())) - ], + attachments: [...attachments.map((it) => it.copyWith(uploadState: const UploadState.success()))], ), matchMessageState: true, matchAttachments: true, @@ -1159,261 +1568,100 @@ void main() { isTrue, ); - verify(() => client.sendImage( - any(), - channelId, - channelType, - onSendProgress: any(named: 'onSendProgress'), - cancelToken: any(named: 'cancelToken'), - extraData: any(named: 'extraData'), - )).called(2); + verify( + () => client.sendImage( + any(), + channelId, + channelType, + onSendProgress: any(named: 'onSendProgress'), + cancelToken: any(named: 'cancelToken'), + extraData: any(named: 'extraData'), + ), + ).called(2); - verify(() => client.sendFile( - any(), - channelId, - channelType, - onSendProgress: any(named: 'onSendProgress'), - cancelToken: any(named: 'cancelToken'), - extraData: any(named: 'extraData'), - )).called(1); + verify( + () => client.sendFile( + any(), + channelId, + channelType, + onSendProgress: any(named: 'onSendProgress'), + cancelToken: any(named: 'cancelToken'), + extraData: any(named: 'extraData'), + ), + ).called(1); - verify(() => client.updateMessage( - any(that: isSameMessageAs(message)), - )).called(1); + verify( + () => client.updateMessage( + any(that: isSameMessageAs(message)), + ), + ).called(1); }); - }); - - test('`.partialUpdateMessage`', () async { - final message = Message( - id: 'test-message-id', - state: MessageState.sent, - ); - const set = {'text': 'Update Message text'}; - const unset = ['pinExpires']; - - final updateMessageResponse = UpdateMessageResponse() - ..message = message.copyWith(text: set['text'], pinExpires: null); + test('should update message state even when error is not StreamChatNetworkError', () async { + final message = Message( + id: 'test-message-id-error-1', + state: MessageState.sent, + ); - when( - () => client.partialUpdateMessage(message.id, set: set, unset: unset), - ).thenAnswer((_) async => updateMessageResponse); + when( + () => client.updateMessage( + any(that: isSameMessageAs(message)), + skipEnrichUrl: true, + ), + ).thenThrow(ArgumentError('Invalid argument')); - expectLater( - // skipping first seed message list -> [] messages - channel.state?.messagesStream.skip(1), - emitsInOrder([ - [ - isSameMessageAs( - message.copyWith( - state: MessageState.updating, + expectLater( + // skipping first seed message list -> [] messages + channel.state?.messagesStream.skip(1), + emitsInOrder([ + [ + isSameMessageAs( + message.copyWith(state: MessageState.updating), + matchMessageState: true, ), - matchText: true, - matchMessageState: true, - ), - ], - [ - isSameMessageAs( - updateMessageResponse.message.copyWith( - state: MessageState.updated, - ), - matchText: true, - matchMessageState: true, - ), - ], - ]), - ); - - final res = await channel.partialUpdateMessage( - message, - set: set, - unset: unset, - ); - - expect(res, isNotNull); - expect(res.message.id, message.id); - expect(res.message.id, message.id); - expect(res.message.text, set['text']); - expect(res.message.pinExpires, isNull); - - verify( - () => client.partialUpdateMessage(message.id, set: set, unset: unset), - ).called(1); - }); - - group('`.deleteMessage`', () { - test('should work fine', () async { - const messageId = 'test-message-id'; - final message = Message( - id: messageId, - createdAt: DateTime.now(), - state: MessageState.sent, - ); - - when(() => client.deleteMessage(messageId)) - .thenAnswer((_) async => EmptyResponse()); - - expectLater( - // skipping first seed message list -> [] messages - channel.state?.messagesStream.skip(1), - emitsInOrder([ - [ - isSameMessageAs( - message.copyWith(state: MessageState.softDeleting), - matchMessageState: true, - ), - ], - [ - isSameMessageAs( - message.copyWith(state: MessageState.softDeleted), - matchMessageState: true, + ], + [ + isSameMessageAs( + message.copyWith( + state: MessageState.updatingFailed( + skipPush: false, + skipEnrichUrl: true, + ), + ), + matchMessageState: true, ), ], ]), ); - final res = await channel.deleteMessage(message); - - expect(res, isNotNull); - - verify(() => client.deleteMessage(messageId)).called(1); - }); - - test('should delete attachments for hard delete', () async { - final attachments = List.generate( - 3, - (index) => Attachment( - id: 'test-attachment-id-$index', - type: index.isEven ? 'image' : 'file', - file: AttachmentFile(size: index * 33, path: 'test-file-path'), - imageUrl: index.isEven ? 'test-image-url-$index' : null, - assetUrl: index.isOdd ? 'test-asset-url-$index' : null, - uploadState: const UploadState.success(), - ), - ); - const messageId = 'test-message-id'; - final message = Message( - attachments: attachments, - id: messageId, - createdAt: DateTime.now(), - state: MessageState.sent, - ); - - when(() => client.deleteMessage(messageId, hard: true)) - .thenAnswer((_) async => EmptyResponse()); - - when(() => client.deleteImage(any(), channelId, channelType)) - .thenAnswer((_) async => EmptyResponse()); - - when(() => client.deleteFile(any(), channelId, channelType)) - .thenAnswer((_) async => EmptyResponse()); - - final res = await channel.deleteMessage(message, hard: true); - - expect(res, isNotNull); - - verify(() => client.deleteMessage(messageId, hard: true)).called(1); - verify(() => client.deleteImage( - any(), - channelId, - channelType, - )).called(2); + try { + await channel.updateMessage(message, skipEnrichUrl: true); + } catch (e) { + expect(e, isA()); + } }); test( - '''should directly update the state with message as deleted if the state is sending or failed''', + 'should add message to retry queue when retriable StreamChatNetworkError occurs with skipPush: false, skipEnrichUrl: true', () async { - const messageId = 'test-message-id'; final message = Message( - id: messageId, + id: 'test-message-id-retry-1', + state: MessageState.sent, ); - expectLater( - // skipping first seed message list -> [] messages - channel.state?.messagesStream.skip(1), - emitsInOrder([ - [ - isSameMessageAs( - message.copyWith(state: MessageState.softDeleted), - matchMessageState: true, - ), - ], - ]), + // Create a retriable error (data == null) + when( + () => client.updateMessage( + any(that: isSameMessageAs(message)), + skipEnrichUrl: true, + ), + ).thenThrow( + StreamChatNetworkError.raw( + code: ChatErrorCode.requestTimeout.code, + message: 'Request timed out', + ), ); - final res = await channel.deleteMessage(message); - - expect(res, isNotNull); - verifyNever(() => client.deleteMessage(messageId)); - }, - ); - }); - - group('`.pinMessage`', () { - test('should work fine without passing timeoutOrExpirationDate', - () async { - final message = Message(id: 'test-message-id'); - - when(() => client.partialUpdateMessage( - message.id, - set: any(named: 'set'), - unset: any(named: 'unset'), - )).thenAnswer((_) async => UpdateMessageResponse() - ..message = message.copyWith( - pinned: true, - pinExpires: null, - )); - - expectLater( - // skipping first seed message list -> [] messages - channel.state?.messagesStream.skip(1), - emitsInOrder([ - [ - isSameMessageAs( - message.copyWith(state: MessageState.updating), - matchMessageState: true, - ), - ], - [ - isSameMessageAs( - message.copyWith(state: MessageState.updated), - matchMessageState: true, - ), - ], - ]), - ); - - final res = await channel.pinMessage(message); - - expect(res, isNotNull); - expect(res.message.pinned, isTrue); - expect(res.message.pinExpires, isNull); - - verify(() => client.partialUpdateMessage( - message.id, - set: any(named: 'set'), - unset: any(named: 'unset'), - )).called(1); - }); - - test( - 'should work fine if passed timeoutOrExpirationDate as num(seconds)', - () async { - final message = Message(id: 'test-message-id'); - const timeoutOrExpirationDate = 300; // 300 seconds - - when(() => client.partialUpdateMessage( - message.id, - set: any(named: 'set'), - unset: any(named: 'unset'), - )).thenAnswer((_) async => UpdateMessageResponse() - ..message = message.copyWith( - pinned: true, - pinExpires: DateTime.now().add( - const Duration(seconds: timeoutOrExpirationDate), - ), - )); - expectLater( // skipping first seed message list -> [] messages channel.state?.messagesStream.skip(1), @@ -1426,46 +1674,50 @@ void main() { ], [ isSameMessageAs( - message.copyWith(state: MessageState.updated), + message.copyWith( + state: MessageState.updatingFailed( + skipPush: false, + skipEnrichUrl: true, + ), + ), matchMessageState: true, ), ], ]), ); - final res = await channel.pinMessage( - message, - timeoutOrExpirationDate: timeoutOrExpirationDate, - ); - - expect(res, isNotNull); - expect(res.message.pinned, isTrue); - expect(res.message.pinExpires, isNotNull); + try { + await channel.updateMessage(message, skipEnrichUrl: true); + } catch (e) { + expect(e, isA()); - verify(() => client.partialUpdateMessage( - message.id, - set: any(named: 'set'), - unset: any(named: 'unset'), - )).called(1); + final networkError = e as StreamChatNetworkError; + expect(networkError.code, equals(ChatErrorCode.requestTimeout.code)); + expect(networkError.isRetriable, isTrue); + } }, ); test( - 'should work fine if passed timeoutOrExpirationDate as DateTime', + 'should add message to retry queue when retriable StreamChatNetworkError occurs with skipPush: true, skipEnrichUrl: false', () async { - final message = Message(id: 'test-message-id'); - final timeoutOrExpirationDate = - DateTime.now().add(const Duration(days: 3)); // 3 days - - when(() => client.partialUpdateMessage( - message.id, - set: any(named: 'set'), - unset: any(named: 'unset'), - )).thenAnswer((_) async => UpdateMessageResponse() - ..message = message.copyWith( - pinned: true, - pinExpires: timeoutOrExpirationDate, - )); + final message = Message( + id: 'test-message-id-retry-2', + state: MessageState.sent, + ); + + // Create a retriable error (data == null) + when( + () => client.updateMessage( + any(that: isSameMessageAs(message)), + skipPush: true, + ), + ).thenThrow( + StreamChatNetworkError.raw( + code: ChatErrorCode.internalSystemError.code, + message: 'Internal system error', + ), + ); expectLater( // skipping first seed message list -> [] messages @@ -1479,360 +1731,207 @@ void main() { ], [ isSameMessageAs( - message.copyWith(state: MessageState.updated), + message.copyWith( + state: MessageState.updatingFailed( + skipPush: true, + skipEnrichUrl: false, + ), + ), matchMessageState: true, ), ], ]), ); - final res = await channel.pinMessage( - message, - timeoutOrExpirationDate: timeoutOrExpirationDate, - ); - - expect(res, isNotNull); - expect(res.message.pinned, isTrue); - expect(res.message.pinExpires, isNotNull); - expect(res.message.pinExpires, timeoutOrExpirationDate.toUtc()); - - verify(() => client.partialUpdateMessage( - message.id, - set: any(named: 'set'), - unset: any(named: 'unset'), - )).called(1); - }, - ); - - test( - 'should throw if invalid timeoutOrExpirationDate is passed', - () async { - final message = Message(id: 'test-message-id'); - const timeoutOrExpirationDate = 'invalid-value'; - try { - await channel.pinMessage( - message, - timeoutOrExpirationDate: timeoutOrExpirationDate, - ); + await channel.updateMessage(message, skipPush: true); } catch (e) { - expect(e, isA()); + expect(e, isA()); + + final networkError = e as StreamChatNetworkError; + expect(networkError.code, equals(ChatErrorCode.internalSystemError.code)); + expect(networkError.isRetriable, isTrue); } }, ); - }); - - test('`.unpinMessage`', () async { - final message = Message(id: 'test-message-id', pinned: true); - - when(() => client.partialUpdateMessage( - message.id, - set: {'pinned': false}, - )).thenAnswer((_) async => UpdateMessageResponse() - ..message = message.copyWith(pinned: false)); - - expectLater( - // skipping first seed message list -> [] messages - channel.state?.messagesStream.skip(1), - emitsInOrder([ - [ - isSameMessageAs( - message.copyWith(state: MessageState.updating), - matchMessageState: true, - ), - ], - [ - isSameMessageAs( - message.copyWith(state: MessageState.updated), - matchMessageState: true, - ), - ], - ]), - ); - - final res = await channel.unpinMessage(message); - expect(res, isNotNull); - expect(res.message.pinned, isFalse); - - verify(() => client.partialUpdateMessage( - message.id, - set: {'pinned': false}, - )).called(1); - }); + test('should handle non-retriable StreamChatNetworkError with skipPush: true, skipEnrichUrl: true', () async { + final message = Message( + id: 'test-message-id-error-2', + state: MessageState.sent, + ); - group('`.search`', () { - final filter = Filter.in_('cid', const [channelCid]); - - test('should work fine with `query`', () async { - const query = 'test-search-query'; - const sort = [SortOption.asc('test-sort-field')]; - const pagination = PaginationParams(); - - final results = List.generate(3, (index) => GetMessageResponse()); - - when(() => client.search( - filter, - query: query, - sort: any(named: 'sort'), - paginationParams: any(named: 'paginationParams'), - )).thenAnswer( - (_) async => SearchMessagesResponse()..results = results, - ); - - final res = await channel.search( - query: query, - sort: sort, - paginationParams: pagination, - ); - - expect(res, isNotNull); - expect(res.results.length, results.length); - - verify(() => client.search( - filter, - query: query, - sort: any(named: 'sort'), - paginationParams: any(named: 'paginationParams'), - )).called(1); - }); - - test('should work fine with `messageFilters`', () async { - final messageFilters = Filter.query('key', 'text'); - const sort = [SortOption.desc('test-sort-field')]; - const pagination = PaginationParams(); - - final results = List.generate(3, (index) => GetMessageResponse()); - - when(() => client.search( - filter, - messageFilters: messageFilters, - sort: any(named: 'sort'), - paginationParams: any(named: 'paginationParams'), - )).thenAnswer( - (_) async => SearchMessagesResponse()..results = results, - ); - - final res = await channel.search( - sort: sort, - paginationParams: pagination, - messageFilters: messageFilters, - ); - - expect(res, isNotNull); - expect(res.results.length, results.length); - - verify(() => client.search( - filter, - messageFilters: messageFilters, - sort: any(named: 'sort'), - paginationParams: any(named: 'paginationParams'), - )).called(1); - }); - }); - - test('`.deleteFile`', () async { - const url = 'test-file-url'; - - when(() => client.deleteFile(url, channelId, channelType, - cancelToken: any(named: 'cancelToken'))) - .thenAnswer((_) async => EmptyResponse()); - - final res = await channel.deleteFile(url); - - expect(res, isNotNull); - - verify(() => client.deleteFile(url, channelId, channelType, - cancelToken: any(named: 'cancelToken'))).called(1); - }); - - test('`.deleteImage`', () async { - const url = 'test-image-url'; - - when(() => client.deleteImage(url, channelId, channelType, - cancelToken: any(named: 'cancelToken'))) - .thenAnswer((_) async => EmptyResponse()); - - final res = await channel.deleteImage(url); - - expect(res, isNotNull); - - verify(() => client.deleteImage(url, channelId, channelType, - cancelToken: any(named: 'cancelToken'))).called(1); - }); - - test('`.stopAIResponse`', () async { - final stopAIEvent = Event(type: EventType.aiIndicatorStop); - - when(() => client.sendEvent( - channelId, - channelType, - any(that: isSameEventAs(stopAIEvent)), - )).thenAnswer((_) async => EmptyResponse()); - - final res = await channel.stopAIResponse(); - - expect(res, isNotNull); - - verify(() => client.sendEvent( - channelId, - channelType, - any(that: isSameEventAs(stopAIEvent)), - )).called(1); - }); - - test('`.sendEvent`', () async { - final event = Event(type: 'event.local'); - - when(() => client.sendEvent(channelId, channelType, event)) - .thenAnswer((_) async => EmptyResponse()); - - final res = await channel.sendEvent(event); - - expect(res, isNotNull); - - verify(() => client.sendEvent(channelId, channelType, event)).called(1); - }); - - group('`.sendReaction`', () { - test('should work fine', () async { - const type = 'test-reaction-type'; - final message = Message( - id: 'test-message-id', - state: MessageState.sent, - ); - - final reaction = Reaction(type: type, messageId: message.id); - - when(() => client.sendReaction(message.id, type)).thenAnswer( - (_) async => SendReactionResponse() - ..message = message - ..reaction = reaction, - ); + when( + () => client.updateMessage( + any(that: isSameMessageAs(message)), + skipPush: true, + skipEnrichUrl: true, + ), + ).thenThrow(StreamChatNetworkError(ChatErrorCode.notAllowed)); expectLater( // skipping first seed message list -> [] messages channel.state?.messagesStream.skip(1), emitsInOrder([ + [ + isSameMessageAs( + message.copyWith(state: MessageState.updating), + matchMessageState: true, + ), + ], [ isSameMessageAs( message.copyWith( - state: MessageState.sent, - reactionGroups: {type: ReactionGroup(count: 1, sumScores: 1)}, - latestReactions: [reaction], - ownReactions: [reaction], + state: MessageState.updatingFailed( + skipPush: true, + skipEnrichUrl: true, + ), ), - matchReactions: true, matchMessageState: true, ), ], ]), ); - final res = await channel.sendReaction(message, type); - - expect(res, isNotNull); - expect(res.reaction.type, type); - expect(res.reaction.messageId, message.id); + try { + await channel.updateMessage( + message, + skipPush: true, + skipEnrichUrl: true, + ); + } catch (e) { + expect(e, isA()); - verify(() => client.sendReaction(message.id, type)).called(1); + final networkError = e as StreamChatNetworkError; + expect(networkError.code, equals(ChatErrorCode.notAllowed.code)); + } }); - test('should work fine with score passed explicitly', () async { - const type = 'test-reaction-type'; + test('should handle non-retriable StreamChatNetworkError with skipPush: false, skipEnrichUrl: false', () async { final message = Message( - id: 'test-message-id', + id: 'test-message-id-error-3', state: MessageState.sent, ); - const score = 5; - final reaction = Reaction( - type: type, - messageId: message.id, - score: score, - ); - - when(() => client.sendReaction( - message.id, - type, - score: score, - )).thenAnswer( - (_) async => SendReactionResponse() - ..message = message - ..reaction = reaction, - ); + when( + () => client.updateMessage( + any(that: isSameMessageAs(message)), + ), + ).thenThrow(StreamChatNetworkError(ChatErrorCode.notAllowed)); expectLater( // skipping first seed message list -> [] messages channel.state?.messagesStream.skip(1), emitsInOrder([ + [ + isSameMessageAs( + message.copyWith(state: MessageState.updating), + matchMessageState: true, + ), + ], [ isSameMessageAs( message.copyWith( - state: MessageState.sent, - reactionGroups: { - type: ReactionGroup( - count: 1, - sumScores: score, - ) - }, - latestReactions: [reaction], - ownReactions: [reaction], + state: MessageState.updatingFailed( + skipPush: false, + skipEnrichUrl: false, + ), ), - matchReactions: true, matchMessageState: true, ), ], ]), ); - final res = await channel.sendReaction( - message, - type, - score: score, - ); - - expect(res, isNotNull); - expect(res.reaction.type, type); - expect(res.reaction.messageId, message.id); - expect(res.reaction.score, score); + try { + await channel.updateMessage(message); + } catch (e) { + expect(e, isA()); - verify(() => client.sendReaction( - message.id, - type, - score: score, - )).called(1); + final networkError = e as StreamChatNetworkError; + expect(networkError.code, equals(ChatErrorCode.notAllowed.code)); + } }); + }); - test('should work fine with score passed explicitly and in extraData', - () async { - const type = 'test-reaction-type'; + test('`.partialUpdateMessage`', () async { + final message = Message( + id: 'test-message-id', + state: MessageState.sent, + ); + + const set = {'text': 'Update Message text'}; + const unset = ['pinExpires']; + + final updateMessageResponse = UpdateMessageResponse() + ..message = message.copyWith(text: set['text'], pinExpires: null); + + when( + () => client.partialUpdateMessage(message.id, set: set, unset: unset), + ).thenAnswer((_) async => updateMessageResponse); + + expectLater( + // skipping first seed message list -> [] messages + channel.state?.messagesStream.skip(1), + emitsInOrder([ + [ + isSameMessageAs( + message.copyWith( + state: MessageState.updating, + ), + matchText: true, + matchMessageState: true, + ), + ], + [ + isSameMessageAs( + updateMessageResponse.message.copyWith( + state: MessageState.updated, + ), + matchText: true, + matchMessageState: true, + ), + ], + ]), + ); + + final res = await channel.partialUpdateMessage( + message, + set: set, + unset: unset, + ); + + expect(res, isNotNull); + expect(res.message.id, message.id); + expect(res.message.id, message.id); + expect(res.message.text, set['text']); + expect(res.message.pinExpires, isNull); + + verify( + () => client.partialUpdateMessage(message.id, set: set, unset: unset), + ).called(1); + }); + + group('`.partialUpdateMessage` error handling', () { + test('should update message state even when error is not StreamChatNetworkError', () async { final message = Message( - id: 'test-message-id', + id: 'test-message-id-error-partial-1', state: MessageState.sent, ); - const score = 5; - const extraDataScore = 3; - const extraData = { - 'score': extraDataScore, - }; - final reaction = Reaction( - type: type, - messageId: message.id, - score: extraDataScore, - ); + // Add message to channel state first + channel.state?.updateMessage(message); - when(() => client.sendReaction( - message.id, - type, - score: score, - extraData: extraData, - )).thenAnswer( - (_) async => SendReactionResponse() - ..message = message - ..reaction = reaction, - ); + const set = {'text': 'Update Message text'}; + const unset = ['pinExpires']; + + when( + () => client.partialUpdateMessage( + message.id, + set: set, + unset: unset, + ), + ).thenThrow(ArgumentError('Invalid argument')); expectLater( // skipping first seed message list -> [] messages @@ -1841,59 +1940,67 @@ void main() { [ isSameMessageAs( message.copyWith( - state: MessageState.sent, - reactionGroups: { - type: ReactionGroup( - count: 1, - sumScores: extraDataScore, - ) - }, - latestReactions: [reaction], - ownReactions: [reaction], + state: MessageState.updating, ), - matchReactions: true, + matchText: true, + matchMessageState: true, + ), + ], + [ + isSameMessageAs( + message.copyWith( + state: MessageState.partialUpdatingFailed( + set: set, + unset: unset, + skipEnrichUrl: false, + ), + ), + matchText: true, matchMessageState: true, ), ], ]), ); - final res = await channel.sendReaction( - message, - type, - score: score, - extraData: extraData, - ); - - expect(res, isNotNull); - expect(res.reaction.type, type); - expect(res.reaction.messageId, message.id); - expect( - res.reaction.score, - extraDataScore, - ); - - verify(() => client.sendReaction( - message.id, - type, - score: score, - extraData: extraData, - )).called(1); + try { + await channel.partialUpdateMessage( + message, + set: set, + unset: unset, + ); + } catch (e) { + expect(e, isA()); + } }); test( - 'should restore previous message if `client.sendReaction` throws', + 'should add message to retry queue when retriable StreamChatNetworkError occurs with skipEnrichUrl: true', () async { - const type = 'test-reaction-type'; final message = Message( - id: 'test-message-id', + id: 'test-message-id-retry-partial-1', state: MessageState.sent, ); - final reaction = Reaction(type: type, messageId: message.id); + // Add message to channel state first + channel.state?.updateMessage(message); - when(() => client.sendReaction(message.id, type)) - .thenThrow(StreamChatNetworkError(ChatErrorCode.inputError)); + const set = {'text': 'Update Message text'}; + const unset = ['pinExpires']; + + // Create a retriable error (data == null) + when( + () => client.partialUpdateMessage( + message.id, + set: set, + unset: unset, + skipEnrichUrl: true, + ), + ).thenThrow( + StreamChatNetworkError.raw( + code: ChatErrorCode.requestTimeout.code, + message: 'Request timed out', + ), + ); expectLater( // skipping first seed message list -> [] messages @@ -1902,24 +2009,22 @@ void main() { [ isSameMessageAs( message.copyWith( - state: MessageState.sent, - reactionGroups: { - type: ReactionGroup( - count: 1, - sumScores: 1, - ) - }, - latestReactions: [reaction], - ownReactions: [reaction], + state: MessageState.updating, ), - matchReactions: true, + matchText: true, matchMessageState: true, ), ], [ isSameMessageAs( - message, - matchReactions: true, + message.copyWith( + state: MessageState.partialUpdatingFailed( + set: set, + unset: unset, + skipEnrichUrl: true, + ), + ), + matchText: true, matchMessageState: true, ), ], @@ -1927,60 +2032,48 @@ void main() { ); try { - await channel.sendReaction(message, type); + await channel.partialUpdateMessage( + message, + set: set, + unset: unset, + skipEnrichUrl: true, + ); } catch (e) { expect(e, isA()); - } - verify(() => client.sendReaction(message.id, type)).called(1); + final networkError = e as StreamChatNetworkError; + expect(networkError.code, equals(ChatErrorCode.requestTimeout.code)); + expect(networkError.isRetriable, isTrue); + } }, ); test( - '''should override previous reaction if present and `enforceUnique` is true''', + 'should add message to retry queue when retriable StreamChatNetworkError occurs with skipEnrichUrl: false', () async { - const userId = 'test-user-id'; - const messageId = 'test-message-id'; - const prevType = 'test-reaction-type'; - final prevReaction = Reaction( - type: prevType, - messageId: messageId, - userId: userId, - ); final message = Message( - id: messageId, - ownReactions: [prevReaction], - latestReactions: [prevReaction], - reactionGroups: { - prevType: ReactionGroup( - count: 1, - sumScores: 1, - ) - }, + id: 'test-message-id-retry-partial-2', state: MessageState.sent, ); - const type = 'test-reaction-type-2'; - final newReaction = Reaction( - type: type, - messageId: messageId, - userId: userId, - ); - final newMessage = message.copyWith( - ownReactions: [newReaction], - latestReactions: [newReaction], - ); + // Add message to channel state first + channel.state?.updateMessage(message); - const enforceUnique = true; + const set = {'text': 'Update Message text'}; + const unset = ['pinExpires']; - when(() => client.sendReaction( - messageId, - type, - enforceUnique: enforceUnique, - )).thenAnswer( - (_) async => SendReactionResponse() - ..message = newMessage - ..reaction = newReaction, + // Create a retriable error (data == null) + when( + () => client.partialUpdateMessage( + message.id, + set: set, + unset: unset, + ), + ).thenThrow( + StreamChatNetworkError.raw( + code: ChatErrorCode.internalSystemError.code, + message: 'Internal system error', + ), ); expectLater( @@ -1989,256 +2082,299 @@ void main() { emitsInOrder([ [ isSameMessageAs( - newMessage, - matchReactions: true, + message.copyWith( + state: MessageState.updating, + ), + matchText: true, + matchMessageState: true, + ), + ], + [ + isSameMessageAs( + message.copyWith( + state: MessageState.partialUpdatingFailed( + set: set, + unset: unset, + skipEnrichUrl: false, + ), + ), + matchText: true, matchMessageState: true, ), ], ]), ); - final res = await channel.sendReaction( - message, - type, - enforceUnique: enforceUnique, - ); - - expect(res, isNotNull); - expect(res.reaction.type, type); - expect(res.reaction.messageId, messageId); + try { + await channel.partialUpdateMessage( + message, + set: set, + unset: unset, + ); + } catch (e) { + expect(e, isA()); - verify(() => client.sendReaction( - messageId, - type, - enforceUnique: enforceUnique, - )).called(1); + final networkError = e as StreamChatNetworkError; + expect(networkError.code, equals(ChatErrorCode.internalSystemError.code)); + expect(networkError.isRetriable, isTrue); + } }, ); - }); - group('`.sendReaction in thread`', () { - test('should work fine', () async { - const type = 'test-reaction-type'; + test('should handle non-retriable StreamChatNetworkError with skipEnrichUrl: true', () async { final message = Message( - id: 'test-message-id', - parentId: 'test-parent-id', // is thread message + id: 'test-message-id-error-partial-2', state: MessageState.sent, ); - final reaction = Reaction(type: type, messageId: message.id); + // Add message to channel state first + channel.state?.updateMessage(message); - when(() => client.sendReaction(message.id, type)).thenAnswer( - (_) async => SendReactionResponse() - ..message = message - ..reaction = reaction, - ); + const set = {'text': 'Update Message text'}; + const unset = ['pinExpires']; + + when( + () => client.partialUpdateMessage( + message.id, + set: set, + unset: unset, + skipEnrichUrl: true, + ), + ).thenThrow(StreamChatNetworkError(ChatErrorCode.notAllowed)); expectLater( - channel.state?.threadsStream - // skipping first seed message list -> [] messages - .skip(1) - .map((event) => event['test-parent-id']), + // skipping first seed message list -> [] messages + channel.state?.messagesStream.skip(1), emitsInOrder([ [ isSameMessageAs( message.copyWith( - state: MessageState.sent, - reactionGroups: { - type: ReactionGroup( - count: 1, - sumScores: 1, - ) - }, - latestReactions: [reaction], - ownReactions: [reaction], + state: MessageState.updating, ), - matchReactions: true, + matchText: true, + matchMessageState: true, + ), + ], + [ + isSameMessageAs( + message.copyWith( + state: MessageState.partialUpdatingFailed( + set: set, + unset: unset, + skipEnrichUrl: true, + ), + ), + matchText: true, matchMessageState: true, - matchParentId: true, ), ], ]), ); - final res = await channel.sendReaction(message, type); - - expect(res, isNotNull); - expect(res.reaction.type, type); - expect(res.reaction.messageId, message.id); + try { + await channel.partialUpdateMessage( + message, + set: set, + unset: unset, + skipEnrichUrl: true, + ); + } catch (e) { + expect(e, isA()); - verify(() => client.sendReaction(message.id, type)).called(1); + final networkError = e as StreamChatNetworkError; + expect(networkError.code, equals(ChatErrorCode.notAllowed.code)); + } }); - test( - '''should restore previous thread message if `client.sendReaction` throws''', - () async { - const type = 'test-reaction-type'; - final message = Message( - id: 'test-message-id', - parentId: 'test-parent-id', // is thread message - state: MessageState.sent, - ); + test('should handle non-retriable StreamChatNetworkError with skipEnrichUrl: false', () async { + final message = Message( + id: 'test-message-id-error-partial-3', + state: MessageState.sent, + ); - final reaction = Reaction(type: type, messageId: message.id); + // Add message to channel state first + channel.state?.updateMessage(message); - when(() => client.sendReaction(message.id, type)) - .thenThrow(StreamChatNetworkError(ChatErrorCode.inputError)); + const set = {'text': 'Update Message text'}; + const unset = ['pinExpires']; - expectLater( - // skipping first seed message list -> [] messages - channel.state?.threadsStream - .skip(1) - .map((event) => event['test-parent-id']), - emitsInOrder([ - [ - isSameMessageAs( - message.copyWith( - state: MessageState.sent, - reactionGroups: { - type: ReactionGroup( - count: 1, - sumScores: 1, - ) - }, - latestReactions: [reaction], - ownReactions: [reaction], - ), - matchReactions: true, - matchMessageState: true, - matchParentId: true, + when( + () => client.partialUpdateMessage( + message.id, + set: set, + unset: unset, + ), + ).thenThrow(StreamChatNetworkError(ChatErrorCode.notAllowed)); + + expectLater( + // skipping first seed message list -> [] messages + channel.state?.messagesStream.skip(1), + emitsInOrder([ + [ + isSameMessageAs( + message.copyWith( + state: MessageState.updating, ), - ], - [ - isSameMessageAs( - message, - matchReactions: true, - matchMessageState: true, - matchParentId: true, + matchText: true, + matchMessageState: true, + ), + ], + [ + isSameMessageAs( + message.copyWith( + state: MessageState.partialUpdatingFailed( + set: set, + unset: unset, + skipEnrichUrl: false, + ), ), - ], - ]), + matchText: true, + matchMessageState: true, + ), + ], + ]), + ); + + try { + await channel.partialUpdateMessage( + message, + set: set, + unset: unset, ); + } catch (e) { + expect(e, isA()); - try { - await channel.sendReaction(message, type); - } catch (e) { - expect(e, isA()); - } + final networkError = e as StreamChatNetworkError; + expect(networkError.code, equals(ChatErrorCode.notAllowed.code)); + } + }); + }); - verify(() => client.sendReaction(message.id, type)).called(1); - }, - ); + group('`.deleteMessage`', () { + test('should work fine', () async { + const messageId = 'test-message-id'; + final message = Message( + id: messageId, + createdAt: DateTime.now(), + state: MessageState.sent, + ); - test( - '''should override previous thread reaction if present and `enforceUnique` is true''', - () async { - const userId = 'test-user-id'; - const messageId = 'test-message-id'; - const parentId = 'test-parent-id'; - const prevType = 'test-reaction-type'; - final prevReaction = Reaction( - type: prevType, - messageId: messageId, - userId: userId, - ); - final message = Message( - id: messageId, - parentId: parentId, - ownReactions: [prevReaction], - latestReactions: [prevReaction], - reactionGroups: { - prevType: ReactionGroup( - count: 1, - sumScores: 1, - ) - }, - state: MessageState.sent, - ); + when(() => client.deleteMessage(messageId)).thenAnswer((_) async => EmptyResponse()); - const type = 'test-reaction-type-2'; - final newReaction = Reaction( - type: type, - messageId: messageId, - userId: userId, - ); - final newMessage = message.copyWith( - ownReactions: [newReaction], - latestReactions: [newReaction], - ); + expectLater( + // skipping first seed message list -> [] messages + channel.state?.messagesStream.skip(1), + emitsInOrder([ + [ + isSameMessageAs( + message.copyWith(state: MessageState.softDeleting), + matchMessageState: true, + ), + ], + [ + isSameMessageAs( + message.copyWith(state: MessageState.softDeleted), + matchMessageState: true, + ), + ], + ]), + ); - const enforceUnique = true; + final res = await channel.deleteMessage(message); - when(() => client.sendReaction( - messageId, - type, - enforceUnique: enforceUnique, - )).thenAnswer( - (_) async => SendReactionResponse() - ..message = newMessage - ..reaction = newReaction, + expect(res, isNotNull); + + verify(() => client.deleteMessage(messageId)).called(1); + }); + + test('should delete attachments for hard delete', () async { + final attachments = List.generate( + 3, + (index) => Attachment( + id: 'test-attachment-id-$index', + type: index.isEven ? 'image' : 'file', + file: AttachmentFile(size: index * 33, path: 'test-file-path'), + imageUrl: index.isEven ? 'test-image-url-$index' : null, + assetUrl: index.isOdd ? 'test-asset-url-$index' : null, + uploadState: const UploadState.success(), + ), + ); + + const messageId = 'test-message-id'; + final message = Message( + id: messageId, + attachments: attachments, + createdAt: DateTime.now(), + state: MessageState.sent, + ); + + when( + () => client.deleteMessage(messageId, hard: true), + ).thenAnswer((_) async => EmptyResponse()); + + when( + () => client.deleteImage(any(), channelId, channelType), + ).thenAnswer((_) async => EmptyResponse()); + + when( + () => client.deleteFile(any(), channelId, channelType), + ).thenAnswer((_) async => EmptyResponse()); + + final res = await channel.deleteMessage(message, hard: true); + expect(res, isNotNull); + + verify(() => client.deleteMessage(messageId, hard: true)).called(1); + + verify(() => client.deleteImage(any(), channelId, channelType)).called(2); + + verify(() => client.deleteFile(any(), channelId, channelType)).called(1); + }); + + test( + 'should hard delete the message if the state is sending or failed', + () async { + const messageId = 'test-message-id'; + final message = Message( + id: messageId, + text: 'Hello World!', + state: MessageState.sending, ); expectLater( // skipping first seed message list -> [] messages - channel.state?.threadsStream - .skip(1) - .map((event) => event['test-parent-id']), + channel.state?.messagesStream.skip(1), emitsInOrder([ [ isSameMessageAs( - newMessage.copyWith(state: MessageState.sent), - matchReactions: true, + message.copyWith(state: MessageState.sending), matchMessageState: true, - matchParentId: true, ), ], + const [], // message is hard deleted from state ]), ); - final res = await channel.sendReaction( - message, - type, - enforceUnique: enforceUnique, - ); + // Add message to channel state first + channel.state?.addNewMessage(message); - expect(res, isNotNull); - expect(res.reaction.type, type); - expect(res.reaction.messageId, messageId); + final res = await channel.deleteMessage(message); - verify(() => client.sendReaction( - messageId, - type, - enforceUnique: enforceUnique, - )).called(1); + expect(res, isNotNull); + verifyNever(() => client.deleteMessage(messageId)); }, ); }); - group('`.deleteReaction`', () { + group('`.deleteMessageForMe`', () { test('should work fine', () async { - const userId = 'test-user-id'; const messageId = 'test-message-id'; - const type = 'test-reaction-type'; - final reaction = Reaction( - type: type, - messageId: messageId, - userId: userId, - ); final message = Message( id: messageId, - ownReactions: [reaction], - latestReactions: [reaction], - reactionGroups: { - type: ReactionGroup( - count: 1, - sumScores: 1, - ) - }, + createdAt: DateTime.now(), state: MessageState.sent, ); - when(() => client.deleteReaction(messageId, type)) - .thenAnswer((_) async => EmptyResponse()); + when(() => client.deleteMessageForMe(messageId)).thenAnswer((_) async => EmptyResponse()); expectLater( // skipping first seed message list -> [] messages @@ -2246,584 +2382,458 @@ void main() { emitsInOrder([ [ isSameMessageAs( - message.copyWith( - state: MessageState.sent, - latestReactions: [], - ownReactions: [], - ), - matchReactions: true, + message.copyWith(state: MessageState.deletingForMe), + matchMessageState: true, + ), + ], + [ + isSameMessageAs( + message.copyWith(state: MessageState.deletedForMe), matchMessageState: true, ), ], ]), ); - final res = await channel.deleteReaction(message, reaction); + final res = await channel.deleteMessageForMe(message); expect(res, isNotNull); - verify(() => client.deleteReaction(messageId, type)).called(1); + verify(() => client.deleteMessageForMe(messageId)).called(1); }); test( - 'should restore prev message state if `client.deleteReaction` throws', + 'should hard delete the message if the state is sending or failed', () async { - const userId = 'test-user-id'; const messageId = 'test-message-id'; - const type = 'test-reaction-type'; - final reaction = Reaction( - type: type, - messageId: messageId, - userId: userId, - ); final message = Message( id: messageId, - ownReactions: [reaction], - latestReactions: [reaction], - reactionGroups: { - type: ReactionGroup( - count: 1, - sumScores: 1, - ) - }, - state: MessageState.sent, + text: 'Hello World!', + state: MessageState.sending, ); - when(() => client.deleteReaction(messageId, type)) - .thenThrow(StreamChatNetworkError(ChatErrorCode.inputError)); - expectLater( // skipping first seed message list -> [] messages channel.state?.messagesStream.skip(1), emitsInOrder([ [ isSameMessageAs( - message.copyWith( - state: MessageState.sent, - latestReactions: [], - ownReactions: [], - ), - matchReactions: true, - matchMessageState: true, - ), - ], - [ - isSameMessageAs( - message, - matchReactions: true, + message.copyWith(state: MessageState.sending), matchMessageState: true, ), ], + const [], // message is hard deleted from state ]), ); - try { - await channel.deleteReaction(message, reaction); - } catch (e) { - expect(e, isA()); - } + // Add message to channel state first + channel.state?.addNewMessage(message); - verify(() => client.deleteReaction(messageId, type)).called(1); + final res = await channel.deleteMessageForMe(message); + + expect(res, isNotNull); + verifyNever(() => client.deleteMessageForMe(messageId)); }, ); }); - group('`.deleteReaction in thread`', () { - test('should work fine', () async { - const userId = 'test-user-id'; - const messageId = 'test-message-id'; - const parentId = 'test-parent-id'; - const type = 'test-reaction-type'; - final reaction = Reaction( - type: type, - messageId: messageId, - userId: userId, - ); - final message = Message( - id: messageId, - parentId: parentId, - // is thread - ownReactions: [reaction], - latestReactions: [reaction], - reactionGroups: { - type: ReactionGroup( - count: 1, - sumScores: 1, - ) - }, - state: MessageState.sent, - ); + group('`.pinMessage`', () { + test('should work fine without passing timeoutOrExpirationDate', () async { + final message = Message(id: 'test-message-id'); - when(() => client.deleteReaction(messageId, type)) - .thenAnswer((_) async => EmptyResponse()); + when( + () => client.partialUpdateMessage( + message.id, + set: any(named: 'set'), + unset: any(named: 'unset'), + ), + ).thenAnswer( + (_) async => UpdateMessageResponse() + ..message = message.copyWith( + pinned: true, + pinExpires: null, + ), + ); expectLater( // skipping first seed message list -> [] messages - channel.state?.threadsStream - .skip(1) - .map((event) => event['test-parent-id']), + channel.state?.messagesStream.skip(1), emitsInOrder([ [ isSameMessageAs( - message.copyWith( - state: MessageState.sent, - latestReactions: [], - ownReactions: [], - ), - matchReactions: true, + message.copyWith(state: MessageState.updating), + matchMessageState: true, + ), + ], + [ + isSameMessageAs( + message.copyWith(state: MessageState.updated), matchMessageState: true, - matchParentId: true, ), ], ]), ); - final res = await channel.deleteReaction(message, reaction); + final res = await channel.pinMessage(message); expect(res, isNotNull); + expect(res.message.pinned, isTrue); + expect(res.message.pinExpires, isNull); - verify(() => client.deleteReaction(messageId, type)).called(1); + verify( + () => client.partialUpdateMessage( + message.id, + set: any(named: 'set'), + unset: any(named: 'unset'), + ), + ).called(1); }); test( - 'should restore prev message state if `client.deleteReaction` throws', + 'should work fine if passed timeoutOrExpirationDate as num(seconds)', () async { - const userId = 'test-user-id'; - const messageId = 'test-message-id'; - const parentId = 'test-parent-id'; - const type = 'test-reaction-type'; - final reaction = Reaction( - type: type, - messageId: messageId, - userId: userId, - ); - final message = Message( - id: messageId, - parentId: parentId, - ownReactions: [reaction], - latestReactions: [reaction], - reactionGroups: { - type: ReactionGroup( - count: 1, - sumScores: 1, - ) - }, - state: MessageState.sent, - ); + final message = Message(id: 'test-message-id'); + const timeoutOrExpirationDate = 300; // 300 seconds - when(() => client.deleteReaction(messageId, type)) - .thenThrow(StreamChatNetworkError(ChatErrorCode.inputError)); + when( + () => client.partialUpdateMessage( + message.id, + set: any(named: 'set'), + unset: any(named: 'unset'), + ), + ).thenAnswer( + (_) async => UpdateMessageResponse() + ..message = message.copyWith( + pinned: true, + pinExpires: DateTime.now().add( + const Duration(seconds: timeoutOrExpirationDate), + ), + ), + ); expectLater( // skipping first seed message list -> [] messages - channel.state?.threadsStream - .skip(1) - .map((event) => event['test-parent-id']), + channel.state?.messagesStream.skip(1), emitsInOrder([ [ isSameMessageAs( - message.copyWith( - state: MessageState.sent, - latestReactions: [], - ownReactions: [], - ), - matchReactions: true, + message.copyWith(state: MessageState.updating), matchMessageState: true, - matchParentId: true, ), ], [ isSameMessageAs( - message, - matchReactions: true, + message.copyWith(state: MessageState.updated), matchMessageState: true, - matchParentId: true, ), ], ]), ); - try { - await channel.deleteReaction(message, reaction); - } catch (e) { - expect(e, isA()); - } + final res = await channel.pinMessage( + message, + timeoutOrExpirationDate: timeoutOrExpirationDate, + ); - verify(() => client.deleteReaction(messageId, type)).called(1); + expect(res, isNotNull); + expect(res.message.pinned, isTrue); + expect(res.message.pinExpires, isNotNull); + + verify( + () => client.partialUpdateMessage( + message.id, + set: any(named: 'set'), + unset: any(named: 'unset'), + ), + ).called(1); }, ); - }); - test('`.update`', () async { - const channelData = { - 'name': 'Stream Team', - 'profile_image': 'test-profile-image', - }; - final updateMessage = Message( - id: 'test-message-id', - text: 'updated channel', - ); + test( + 'should work fine if passed timeoutOrExpirationDate as DateTime', + () async { + final message = Message(id: 'test-message-id'); + final timeoutOrExpirationDate = DateTime.now().add(const Duration(days: 3)); // 3 days - final channelModel = ChannelModel( - cid: channelCid, - extraData: channelData, - ); + when( + () => client.partialUpdateMessage( + message.id, + set: any(named: 'set'), + unset: any(named: 'unset'), + ), + ).thenAnswer( + (_) async => UpdateMessageResponse() + ..message = message.copyWith( + pinned: true, + pinExpires: timeoutOrExpirationDate, + ), + ); - when(() => client.updateChannel(channelId, channelType, channelData, - message: any(named: 'message'))).thenAnswer( - (_) async => UpdateChannelResponse() - ..channel = channelModel - ..message = updateMessage, - ); + expectLater( + // skipping first seed message list -> [] messages + channel.state?.messagesStream.skip(1), + emitsInOrder([ + [ + isSameMessageAs( + message.copyWith(state: MessageState.updating), + matchMessageState: true, + ), + ], + [ + isSameMessageAs( + message.copyWith(state: MessageState.updated), + matchMessageState: true, + ), + ], + ]), + ); - final res = await channel.update( - channelData, - updateMessage: updateMessage, + final res = await channel.pinMessage( + message, + timeoutOrExpirationDate: timeoutOrExpirationDate, + ); + + expect(res, isNotNull); + expect(res.message.pinned, isTrue); + expect(res.message.pinExpires, isNotNull); + expect(res.message.pinExpires, timeoutOrExpirationDate.toUtc()); + + verify( + () => client.partialUpdateMessage( + message.id, + set: any(named: 'set'), + unset: any(named: 'unset'), + ), + ).called(1); + }, ); - expect(res, isNotNull); - expect(res.channel.cid, channelModel.cid); - expect(res.channel.extraData, channelData); - expect(res.message?.id, updateMessage.id); + test( + 'should throw if invalid timeoutOrExpirationDate is passed', + () async { + final message = Message(id: 'test-message-id'); + const timeoutOrExpirationDate = 'invalid-value'; - verify(() => client.updateChannel(channelId, channelType, channelData, - message: any(named: 'message'))).called(1); + try { + await channel.pinMessage( + message, + timeoutOrExpirationDate: timeoutOrExpirationDate, + ); + } catch (e) { + expect(e, isA()); + } + }, + ); }); - test('`.updateImage`', () async { - const image = 'https://getstream.io/new-image'; + test('`.unpinMessage`', () async { + final message = Message(id: 'test-message-id', pinned: true); - final channelModel = ChannelModel( - cid: channelCid, - extraData: {'image': image}, - ); + when( + () => client.partialUpdateMessage( + message.id, + set: {'pinned': false}, + ), + ).thenAnswer((_) async => UpdateMessageResponse()..message = message.copyWith(pinned: false)); - when(() => client.updateChannelPartial( - channelId, - channelType, - set: {'image': image}, - )).thenAnswer( - (_) async => PartialUpdateChannelResponse()..channel = channelModel, + expectLater( + // skipping first seed message list -> [] messages + channel.state?.messagesStream.skip(1), + emitsInOrder([ + [ + isSameMessageAs( + message.copyWith(state: MessageState.updating), + matchMessageState: true, + ), + ], + [ + isSameMessageAs( + message.copyWith(state: MessageState.updated), + matchMessageState: true, + ), + ], + ]), ); - final res = await channel.updateImage(image); + final res = await channel.unpinMessage(message); expect(res, isNotNull); - expect(res.channel.extraData['image'], image); + expect(res.message.pinned, isFalse); - verify(() => client.updateChannelPartial( - channelId, - channelType, - set: {'image': image}, - )).called(1); + verify( + () => client.partialUpdateMessage( + message.id, + set: {'pinned': false}, + ), + ).called(1); }); - test('`.updateName`', () async { - const name = 'Name'; + group('`.search`', () { + final filter = Filter.in_('cid', const [channelCid]); - final channelModel = ChannelModel( - cid: channelCid, - extraData: {'name': name}, - ); + test('should work fine with `query`', () async { + const query = 'test-search-query'; + const sort = [SortOption.asc('test-sort-field')]; + const pagination = PaginationParams(); - when(() => client.updateChannelPartial( - channelId, - channelType, - set: {'name': name}, - )).thenAnswer( - (_) async => PartialUpdateChannelResponse()..channel = channelModel, - ); + final results = List.generate(3, (index) => GetMessageResponse()); - final res = await channel.updateName(name); + when( + () => client.search( + filter, + query: query, + sort: any(named: 'sort'), + paginationParams: any(named: 'paginationParams'), + ), + ).thenAnswer( + (_) async => SearchMessagesResponse()..results = results, + ); - expect(res, isNotNull); - expect(res.channel.extraData['name'], name); + final res = await channel.search( + query: query, + sort: sort, + paginationParams: pagination, + ); - verify(() => client.updateChannelPartial( - channelId, - channelType, - set: {'name': name}, - )).called(1); - }); + expect(res, isNotNull); + expect(res.results.length, results.length); - test('`.updatePartial`', () async { - const set = { - 'name': 'Stream Team', - 'profile_image': 'test-profile-image', - }; + verify( + () => client.search( + filter, + query: query, + sort: any(named: 'sort'), + paginationParams: any(named: 'paginationParams'), + ), + ).called(1); + }); - const unset = ['tag', 'last_name']; + test('should work fine with `messageFilters`', () async { + final messageFilters = Filter.query('key', 'text'); + const sort = [SortOption.desc('test-sort-field')]; + const pagination = PaginationParams(); - final channelModel = ChannelModel( - cid: channelCid, - extraData: { - 'coolness': 999, - ...set, - }, - ); + final results = List.generate(3, (index) => GetMessageResponse()); - when(() => client.updateChannelPartial( - channelId, - channelType, - set: set, - unset: unset, - )).thenAnswer( - (_) async => PartialUpdateChannelResponse()..channel = channelModel, - ); + when( + () => client.search( + filter, + messageFilters: messageFilters, + sort: any(named: 'sort'), + paginationParams: any(named: 'paginationParams'), + ), + ).thenAnswer( + (_) async => SearchMessagesResponse()..results = results, + ); - final res = await channel.updatePartial(set: set, unset: unset); + final res = await channel.search( + sort: sort, + paginationParams: pagination, + messageFilters: messageFilters, + ); - expect(res, isNotNull); - expect(res.channel.cid, channelModel.cid); - expect( - res.channel.extraData, - {'coolness': 999, ...set}, - ); - - verify(() => client.updateChannelPartial( - channelId, - channelType, - set: set, - unset: unset, - )).called(1); - }); - - test('`.delete`', () async { - when(() => client.deleteChannel(channelId, channelType)) - .thenAnswer((_) async => EmptyResponse()); - - final res = await channel.delete(); - - expect(res, isNotNull); - - verify(() => client.deleteChannel(channelId, channelType)).called(1); - }); - - test('`.truncate`', () async { - when(() => client.truncateChannel(channelId, channelType)) - .thenAnswer((_) async => EmptyResponse()); - - final res = await channel.truncate(); - - expect(res, isNotNull); - - verify(() => client.truncateChannel(channelId, channelType)).called(1); - }); - - test('`.acceptInvite`', () async { - final message = Message(id: 'test-message-id', text: 'Invite Accepted'); - - final channelModel = ChannelModel(cid: channelCid); - - when(() => client.acceptChannelInvite(channelId, channelType, - message: any(named: 'message'))).thenAnswer( - (_) async => AcceptInviteResponse() - ..channel = channelModel - ..message = message, - ); - - final res = await channel.acceptInvite(message); - - expect(res, isNotNull); - expect(res.channel.cid, channelModel.cid); - expect(res.message?.id, message.id); - - verify(() => client.acceptChannelInvite(channelId, channelType, - message: any(named: 'message'))).called(1); - }); - - test('`.rejectInvite`', () async { - final message = Message(id: 'test-message-id', text: 'Invite Rejected'); - - final channelModel = ChannelModel(cid: channelCid); - - when(() => client.rejectChannelInvite(channelId, channelType, - message: any(named: 'message'))).thenAnswer( - (_) async => RejectInviteResponse() - ..channel = channelModel - ..message = message, - ); - - final res = await channel.rejectInvite(message); - - expect(res, isNotNull); - expect(res.channel.cid, channelModel.cid); - expect(res.message?.id, message.id); + expect(res, isNotNull); + expect(res.results.length, results.length); - verify(() => client.rejectChannelInvite(channelId, channelType, - message: any(named: 'message'))).called(1); + verify( + () => client.search( + filter, + messageFilters: messageFilters, + sort: any(named: 'sort'), + paginationParams: any(named: 'paginationParams'), + ), + ).called(1); + }); }); - test('`.addMembers`', () async { - final members = List.generate( - 3, - (index) => Member(userId: 'test-member-id-$index'), - ); - final memberIds = members - .map((it) => it.userId) - .whereType() - .toList(growable: false); - final message = Message(id: 'test-message-id', text: 'Members Added'); - - final channelModel = ChannelModel(cid: channelCid); + test('`.deleteFile`', () async { + const url = 'test-file-url'; - when(() => client.addChannelMembers(channelId, channelType, memberIds, - message: any(named: 'message'))).thenAnswer( - (_) async => AddMembersResponse() - ..channel = channelModel - ..members = members - ..message = message, - ); + when( + () => client.deleteFile(url, channelId, channelType, cancelToken: any(named: 'cancelToken')), + ).thenAnswer((_) async => EmptyResponse()); - final res = await channel.addMembers(memberIds, message: message); + final res = await channel.deleteFile(url); expect(res, isNotNull); - expect(res.channel.cid, channelModel.cid); - expect(res.members.length, members.length); - expect(res.message?.id, message.id); - verify(() => client.addChannelMembers(channelId, channelType, memberIds, - message: any(named: 'message'))).called(1); + verify(() => client.deleteFile(url, channelId, channelType, cancelToken: any(named: 'cancelToken'))).called(1); }); - test('`.addMembers` with hideHistoryBefore', () async { - final members = List.generate( - 3, - (index) => Member(userId: 'test-member-id-$index'), - ); - final memberIds = members - .map((it) => it.userId) - .whereType() - .toList(growable: false); - final message = Message(id: 'test-message-id', text: 'Members Added'); - final hideHistoryBefore = DateTime.parse('2024-01-01T00:00:00Z'); - - final channelModel = ChannelModel(cid: channelCid); + test('`.deleteImage`', () async { + const url = 'test-image-url'; - when(() => client.addChannelMembers( - channelId, - channelType, - memberIds, - message: message, - hideHistoryBefore: hideHistoryBefore, - )).thenAnswer( - (_) async => AddMembersResponse() - ..channel = channelModel - ..members = members - ..message = message, - ); + when( + () => client.deleteImage(url, channelId, channelType, cancelToken: any(named: 'cancelToken')), + ).thenAnswer((_) async => EmptyResponse()); - final res = await channel.addMembers( - memberIds, - message: message, - hideHistoryBefore: hideHistoryBefore, - ); + final res = await channel.deleteImage(url); expect(res, isNotNull); - expect(res.channel.cid, channelModel.cid); - expect(res.members.length, members.length); - expect(res.message?.id, message.id); - verify(() => client.addChannelMembers( - channelId, - channelType, - memberIds, - message: message, - hideHistoryBefore: hideHistoryBefore, - )).called(1); + verify(() => client.deleteImage(url, channelId, channelType, cancelToken: any(named: 'cancelToken'))).called(1); }); - test('`.inviteMembers`', () async { - final members = List.generate( - 3, - (index) => Member(userId: 'test-member-id-$index'), - ); - final memberIds = members - .map((it) => it.userId) - .whereType() - .toList(growable: false); - final message = Message(id: 'test-message-id', text: 'Members Invited'); - - final channelModel = ChannelModel(cid: channelCid); + test('`.stopAIResponse`', () async { + final stopAIEvent = Event(type: EventType.aiIndicatorStop); - when(() => client.inviteChannelMembers(channelId, channelType, memberIds, - message: any(named: 'message'))).thenAnswer( - (_) async => InviteMembersResponse() - ..channel = channelModel - ..members = members - ..message = message, - ); + when( + () => client.sendEvent( + channelId, + channelType, + any(that: isSameEventAs(stopAIEvent)), + ), + ).thenAnswer((_) async => EmptyResponse()); - final res = await channel.inviteMembers(memberIds, message: message); + final res = await channel.stopAIResponse(); expect(res, isNotNull); - expect(res.channel.cid, channelModel.cid); - expect(res.members.length, members.length); - expect(res.message?.id, message.id); - verify(() => client.inviteChannelMembers( - channelId, channelType, memberIds, - message: any(named: 'message'))).called(1); + verify( + () => client.sendEvent( + channelId, + channelType, + any(that: isSameEventAs(stopAIEvent)), + ), + ).called(1); }); - test('`.removeMembers`', () async { - final members = List.generate( - 3, - (index) => Member(userId: 'test-member-id-$index'), - ); - final memberIds = members - .map((it) => it.userId) - .whereType() - .toList(growable: false); - final message = Message(id: 'test-message-id', text: 'Members Removed'); - - final channelModel = ChannelModel(cid: channelCid); + test('`.sendEvent`', () async { + final event = Event(type: 'event.local'); - when(() => client.removeChannelMembers(channelId, channelType, memberIds, - message: any(named: 'message'))).thenAnswer( - (_) async => RemoveMembersResponse() - ..channel = channelModel - ..members = members - ..message = message, - ); + when(() => client.sendEvent(channelId, channelType, event)).thenAnswer((_) async => EmptyResponse()); - final res = await channel.removeMembers(memberIds, message: message); + final res = await channel.sendEvent(event); expect(res, isNotNull); - expect(res.channel.cid, channelModel.cid); - expect(res.members.length, members.length); - expect(res.message?.id, message.id); - verify(() => client.removeChannelMembers( - channelId, channelType, memberIds, - message: any(named: 'message'))).called(1); + verify(() => client.sendEvent(channelId, channelType, event)).called(1); }); - group('`.sendAction`', () { + group('`.sendReaction`', () { test('should work fine', () async { - final message = Message(id: 'test-message-id', text: 'Action Sent'); - const formData = {'key': 'value'}; - - when( - () => client.sendAction(channelId, channelType, message.id, formData), - ).thenAnswer((_) async => SendActionResponse()); - - final res = await channel.sendAction(message, formData); - - expect(res, isNotNull); + final message = Message( + id: 'test-message-id', + state: MessageState.sent, + ); - verify( - () => client.sendAction(channelId, channelType, message.id, formData), - ).called(1); - }); + const type = 'like'; + const emojiCode = '👍'; + const score = 4; - test('should emit received message if not null', () async { - final message = Message(id: 'test-message-id', text: 'Action Sent'); - const formData = {'key': 'value'}; + final reaction = Reaction( + type: type, + messageId: message.id, + emojiCode: emojiCode, + score: score, + user: client.state.currentUser, + ); - when( - () => client.sendAction(channelId, channelType, message.id, formData), - ).thenAnswer((_) async => SendActionResponse()..message = message); + when(() => client.sendReaction(message.id, reaction)).thenAnswer( + (_) async => SendReactionResponse() + ..message = message + ..reaction = reaction, + ); expectLater( // skipping first seed message list -> [] messages @@ -2831,1965 +2841,4040 @@ void main() { emitsInOrder([ [ isSameMessageAs( - message, + message.copyWith( + state: MessageState.sent, + reactionGroups: {type: ReactionGroup(count: 1, sumScores: 1)}, + latestReactions: [reaction], + ownReactions: [reaction], + ), + matchReactions: true, matchMessageState: true, ), ], ]), ); - final res = await channel.sendAction(message, formData); + final res = await channel.sendReaction(message, reaction); expect(res, isNotNull); - expect(res.message?.id, message.id); + expect(res.reaction.type, type); + expect(res.reaction.messageId, message.id); + expect(res.reaction.emojiCode, emojiCode); + expect(res.reaction.score, score); - verify( - () => client.sendAction(channelId, channelType, message.id, formData), - ).called(1); + verify(() => client.sendReaction(message.id, reaction)).called(1); }); - }); - group('`.watch`', () { - test('should work fine', () async { - when(() => client.queryChannel( - channelType, - channelId: channelId, - watch: true, - channelData: any(named: 'channelData'), - messagesPagination: any(named: 'messagesPagination'), - membersPagination: any(named: 'membersPagination'), - watchersPagination: any(named: 'watchersPagination'), - )).thenAnswer( - (_) async => _generateChannelState(channelId, channelType), - ); + test( + 'should restore previous message if `client.sendReaction` throws', + () async { + const type = 'test-reaction-type'; + final message = Message( + id: 'test-message-id', + state: MessageState.sent, + ); - final res = await channel.watch(); + final reaction = Reaction( + type: type, + messageId: message.id, + user: client.state.currentUser, + ); - expect(res, isNotNull); - expect(res.channel, isNotNull); - expect(res.channel?.cid, channelCid); + when( + () => client.sendReaction(message.id, reaction), + ).thenThrow(StreamChatNetworkError(ChatErrorCode.inputError)); - verify(() => client.queryChannel( - channelType, - channelId: channelId, - watch: true, - channelData: any(named: 'channelData'), - messagesPagination: any(named: 'messagesPagination'), - membersPagination: any(named: 'membersPagination'), - watchersPagination: any(named: 'watchersPagination'), - )).called(1); - }); + expectLater( + // skipping first seed message list -> [] messages + channel.state?.messagesStream.skip(1), + emitsInOrder([ + [ + isSameMessageAs( + message.copyWith( + state: MessageState.sent, + reactionGroups: { + type: ReactionGroup( + count: 1, + sumScores: 1, + ), + }, + latestReactions: [reaction], + ownReactions: [reaction], + ), + matchReactions: true, + matchMessageState: true, + ), + ], + [ + isSameMessageAs( + message, + matchReactions: true, + matchMessageState: true, + ), + ], + ]), + ); - test('should rethrow if `.query` throws', () async { - when(() => client.queryChannel( - channelType, - channelId: channelId, - watch: true, - channelData: any(named: 'channelData'), - messagesPagination: any(named: 'messagesPagination'), - membersPagination: any(named: 'membersPagination'), - watchersPagination: any(named: 'watchersPagination'), - )).thenThrow(StreamChatNetworkError(ChatErrorCode.inputError)); + try { + await channel.sendReaction(message, reaction); + } catch (e) { + expect(e, isA()); + } - try { - await channel.watch(); - } catch (e) { - expect(e, isA()); - } + verify(() => client.sendReaction(message.id, reaction)).called(1); + }, + ); - verify(() => client.queryChannel( - channelType, - channelId: channelId, - watch: true, - channelData: any(named: 'channelData'), - messagesPagination: any(named: 'messagesPagination'), - membersPagination: any(named: 'membersPagination'), - watchersPagination: any(named: 'watchersPagination'), - )).called(1); + test( + '''should override previous reaction if present and `enforceUnique` is true''', + () async { + const messageId = 'test-message-id'; + const prevType = 'test-reaction-type'; + final prevReaction = Reaction( + type: prevType, + messageId: messageId, + user: client.state.currentUser, + ); + final message = Message( + id: messageId, + ownReactions: [prevReaction], + latestReactions: [prevReaction], + reactionGroups: { + prevType: ReactionGroup( + count: 1, + sumScores: 1, + ), + }, + state: MessageState.sent, + ); + + const type = 'test-reaction-type-2'; + final newReaction = Reaction( + type: type, + messageId: messageId, + user: client.state.currentUser, + ); + final newMessage = message.copyWith( + ownReactions: [newReaction], + latestReactions: [newReaction], + ); + + const enforceUnique = true; + + when( + () => client.sendReaction( + messageId, + newReaction, + enforceUnique: enforceUnique, + ), + ).thenAnswer( + (_) async => SendReactionResponse() + ..message = newMessage + ..reaction = newReaction, + ); + + expectLater( + // skipping first seed message list -> [] messages + channel.state?.messagesStream.skip(1), + emitsInOrder([ + [ + isSameMessageAs( + newMessage, + matchReactions: true, + matchMessageState: true, + ), + ], + ]), + ); + + final res = await channel.sendReaction( + message, + newReaction, + enforceUnique: enforceUnique, + ); + + expect(res, isNotNull); + expect(res.reaction.type, type); + expect(res.reaction.messageId, messageId); + + verify( + () => client.sendReaction( + messageId, + newReaction, + enforceUnique: enforceUnique, + ), + ).called(1); + }, + ); + }); + + group('`.sendReaction in thread`', () { + test('should work fine', () async { + const type = 'test-reaction-type'; + final message = Message( + id: 'test-message-id', + parentId: 'test-parent-id', // is thread message + state: MessageState.sent, + ); + + final reaction = Reaction( + type: type, + messageId: message.id, + user: client.state.currentUser, + ); + + when(() => client.sendReaction(message.id, reaction)).thenAnswer( + (_) async => SendReactionResponse() + ..message = message + ..reaction = reaction, + ); + + expectLater( + channel.state?.threadsStream + // skipping first seed message list -> [] messages + .skip(1) + .map((event) => event['test-parent-id']), + emitsInOrder([ + [ + isSameMessageAs( + message.copyWith( + state: MessageState.sent, + reactionGroups: { + type: ReactionGroup( + count: 1, + sumScores: 1, + ), + }, + latestReactions: [reaction], + ownReactions: [reaction], + ), + matchReactions: true, + matchMessageState: true, + matchParentId: true, + ), + ], + ]), + ); + + final res = await channel.sendReaction(message, reaction); + + expect(res, isNotNull); + expect(res.reaction.type, type); + expect(res.reaction.messageId, message.id); + + verify(() => client.sendReaction(message.id, reaction)).called(1); }); + + test( + '''should restore previous thread message if `client.sendReaction` throws''', + () async { + const type = 'test-reaction-type'; + final message = Message( + id: 'test-message-id', + parentId: 'test-parent-id', // is thread message + state: MessageState.sent, + ); + + final reaction = Reaction( + type: type, + messageId: message.id, + user: client.state.currentUser, + ); + + when( + () => client.sendReaction(message.id, reaction), + ).thenThrow(StreamChatNetworkError(ChatErrorCode.inputError)); + + expectLater( + // skipping first seed message list -> [] messages + channel.state?.threadsStream.skip(1).map((event) => event['test-parent-id']), + emitsInOrder([ + [ + isSameMessageAs( + message.copyWith( + state: MessageState.sent, + reactionGroups: { + type: ReactionGroup( + count: 1, + sumScores: 1, + ), + }, + latestReactions: [reaction], + ownReactions: [reaction], + ), + matchReactions: true, + matchMessageState: true, + matchParentId: true, + ), + ], + [ + isSameMessageAs( + message, + matchReactions: true, + matchMessageState: true, + matchParentId: true, + ), + ], + ]), + ); + + try { + await channel.sendReaction(message, reaction); + } catch (e) { + expect(e, isA()); + } + + verify(() => client.sendReaction(message.id, reaction)).called(1); + }, + ); + + test( + '''should override previous thread reaction if present and `enforceUnique` is true''', + () async { + const messageId = 'test-message-id'; + const parentId = 'test-parent-id'; + const prevType = 'test-reaction-type'; + final prevReaction = Reaction( + type: prevType, + messageId: messageId, + user: client.state.currentUser, + ); + final message = Message( + id: messageId, + parentId: parentId, + ownReactions: [prevReaction], + latestReactions: [prevReaction], + reactionGroups: { + prevType: ReactionGroup( + count: 1, + sumScores: 1, + ), + }, + state: MessageState.sent, + ); + + const type = 'test-reaction-type-2'; + final newReaction = Reaction( + type: type, + messageId: messageId, + user: client.state.currentUser, + ); + final newMessage = message.copyWith( + ownReactions: [newReaction], + latestReactions: [newReaction], + ); + + const enforceUnique = true; + + when( + () => client.sendReaction( + messageId, + newReaction, + enforceUnique: enforceUnique, + ), + ).thenAnswer( + (_) async => SendReactionResponse() + ..message = newMessage + ..reaction = newReaction, + ); + + expectLater( + // skipping first seed message list -> [] messages + channel.state?.threadsStream.skip(1).map((event) => event['test-parent-id']), + emitsInOrder([ + [ + isSameMessageAs( + newMessage.copyWith(state: MessageState.sent), + matchReactions: true, + matchMessageState: true, + matchParentId: true, + ), + ], + ]), + ); + + final res = await channel.sendReaction( + message, + newReaction, + enforceUnique: enforceUnique, + ); + + expect(res, isNotNull); + expect(res.reaction.type, type); + expect(res.reaction.messageId, messageId); + + verify( + () => client.sendReaction( + messageId, + newReaction, + enforceUnique: enforceUnique, + ), + ).called(1); + }, + ); + }); + + group('`.deleteReaction`', () { + test('should work fine', () async { + const userId = 'test-user-id'; + const messageId = 'test-message-id'; + const type = 'test-reaction-type'; + final reaction = Reaction( + type: type, + messageId: messageId, + userId: userId, + ); + final message = Message( + id: messageId, + ownReactions: [reaction], + latestReactions: [reaction], + reactionGroups: { + type: ReactionGroup( + count: 1, + sumScores: 1, + ), + }, + state: MessageState.sent, + ); + + when(() => client.deleteReaction(messageId, type)).thenAnswer((_) async => EmptyResponse()); + + expectLater( + // skipping first seed message list -> [] messages + channel.state?.messagesStream.skip(1), + emitsInOrder([ + [ + isSameMessageAs( + message.copyWith( + state: MessageState.sent, + latestReactions: [], + ownReactions: [], + ), + matchReactions: true, + matchMessageState: true, + ), + ], + ]), + ); + + final res = await channel.deleteReaction(message, reaction); + + expect(res, isNotNull); + + verify(() => client.deleteReaction(messageId, type)).called(1); + }); + + test( + 'should restore prev message state if `client.deleteReaction` throws', + () async { + const userId = 'test-user-id'; + const messageId = 'test-message-id'; + const type = 'test-reaction-type'; + final reaction = Reaction( + type: type, + messageId: messageId, + userId: userId, + ); + final message = Message( + id: messageId, + ownReactions: [reaction], + latestReactions: [reaction], + reactionGroups: { + type: ReactionGroup( + count: 1, + sumScores: 1, + ), + }, + state: MessageState.sent, + ); + + when( + () => client.deleteReaction(messageId, type), + ).thenThrow(StreamChatNetworkError(ChatErrorCode.inputError)); + + expectLater( + // skipping first seed message list -> [] messages + channel.state?.messagesStream.skip(1), + emitsInOrder([ + [ + isSameMessageAs( + message.copyWith( + state: MessageState.sent, + latestReactions: [], + ownReactions: [], + ), + matchReactions: true, + matchMessageState: true, + ), + ], + [ + isSameMessageAs( + message, + matchReactions: true, + matchMessageState: true, + ), + ], + ]), + ); + + try { + await channel.deleteReaction(message, reaction); + } catch (e) { + expect(e, isA()); + } + + verify(() => client.deleteReaction(messageId, type)).called(1); + }, + ); + }); + + group('`.deleteReaction in thread`', () { + test('should work fine', () async { + const userId = 'test-user-id'; + const messageId = 'test-message-id'; + const parentId = 'test-parent-id'; + const type = 'test-reaction-type'; + final reaction = Reaction( + type: type, + messageId: messageId, + userId: userId, + ); + final message = Message( + id: messageId, + parentId: parentId, + // is thread + ownReactions: [reaction], + latestReactions: [reaction], + reactionGroups: { + type: ReactionGroup( + count: 1, + sumScores: 1, + ), + }, + state: MessageState.sent, + ); + + when(() => client.deleteReaction(messageId, type)).thenAnswer((_) async => EmptyResponse()); + + expectLater( + // skipping first seed message list -> [] messages + channel.state?.threadsStream.skip(1).map((event) => event['test-parent-id']), + emitsInOrder([ + [ + isSameMessageAs( + message.copyWith( + state: MessageState.sent, + latestReactions: [], + ownReactions: [], + ), + matchReactions: true, + matchMessageState: true, + matchParentId: true, + ), + ], + ]), + ); + + final res = await channel.deleteReaction(message, reaction); + + expect(res, isNotNull); + + verify(() => client.deleteReaction(messageId, type)).called(1); + }); + + test( + 'should restore prev message state if `client.deleteReaction` throws', + () async { + const userId = 'test-user-id'; + const messageId = 'test-message-id'; + const parentId = 'test-parent-id'; + const type = 'test-reaction-type'; + final reaction = Reaction( + type: type, + messageId: messageId, + userId: userId, + ); + final message = Message( + id: messageId, + parentId: parentId, + ownReactions: [reaction], + latestReactions: [reaction], + reactionGroups: { + type: ReactionGroup( + count: 1, + sumScores: 1, + ), + }, + state: MessageState.sent, + ); + + when( + () => client.deleteReaction(messageId, type), + ).thenThrow(StreamChatNetworkError(ChatErrorCode.inputError)); + + expectLater( + // skipping first seed message list -> [] messages + channel.state?.threadsStream.skip(1).map((event) => event['test-parent-id']), + emitsInOrder([ + [ + isSameMessageAs( + message.copyWith( + state: MessageState.sent, + latestReactions: [], + ownReactions: [], + ), + matchReactions: true, + matchMessageState: true, + matchParentId: true, + ), + ], + [ + isSameMessageAs( + message, + matchReactions: true, + matchMessageState: true, + matchParentId: true, + ), + ], + ]), + ); + + try { + await channel.deleteReaction(message, reaction); + } catch (e) { + expect(e, isA()); + } + + verify(() => client.deleteReaction(messageId, type)).called(1); + }, + ); + }); + + test('`.update`', () async { + const channelData = { + 'name': 'Stream Team', + 'profile_image': 'test-profile-image', + }; + final updateMessage = Message( + id: 'test-message-id', + text: 'updated channel', + ); + + final channelModel = ChannelModel( + cid: channelCid, + extraData: channelData, + ); + + when(() => client.updateChannel(channelId, channelType, channelData, message: any(named: 'message'))).thenAnswer( + (_) async => UpdateChannelResponse() + ..channel = channelModel + ..message = updateMessage, + ); + + final res = await channel.update( + channelData, + updateMessage: updateMessage, + ); + + expect(res, isNotNull); + expect(res.channel.cid, channelModel.cid); + expect(res.channel.extraData, channelData); + expect(res.message?.id, updateMessage.id); + + verify(() => client.updateChannel(channelId, channelType, channelData, message: any(named: 'message'))).called(1); + }); + + test('`.updateImage`', () async { + const image = 'https://getstream.io/new-image'; + + final channelModel = ChannelModel( + cid: channelCid, + extraData: {'image': image}, + ); + + when( + () => client.updateChannelPartial( + channelId, + channelType, + set: {'image': image}, + ), + ).thenAnswer( + (_) async => PartialUpdateChannelResponse()..channel = channelModel, + ); + + final res = await channel.updateImage(image); + + expect(res, isNotNull); + expect(res.channel.extraData['image'], image); + + verify( + () => client.updateChannelPartial( + channelId, + channelType, + set: {'image': image}, + ), + ).called(1); + }); + + test('`.updateName`', () async { + const name = 'Name'; + + final channelModel = ChannelModel( + cid: channelCid, + extraData: {'name': name}, + ); + + when( + () => client.updateChannelPartial( + channelId, + channelType, + set: {'name': name}, + ), + ).thenAnswer( + (_) async => PartialUpdateChannelResponse()..channel = channelModel, + ); + + final res = await channel.updateName(name); + + expect(res, isNotNull); + expect(res.channel.extraData['name'], name); + + verify( + () => client.updateChannelPartial( + channelId, + channelType, + set: {'name': name}, + ), + ).called(1); + }); + + test('`.updatePartial`', () async { + const set = { + 'name': 'Stream Team', + 'profile_image': 'test-profile-image', + }; + + const unset = ['tag', 'last_name']; + + final channelModel = ChannelModel( + cid: channelCid, + extraData: { + 'coolness': 999, + ...set, + }, + ); + + when( + () => client.updateChannelPartial( + channelId, + channelType, + set: set, + unset: unset, + ), + ).thenAnswer( + (_) async => PartialUpdateChannelResponse()..channel = channelModel, + ); + + final res = await channel.updatePartial(set: set, unset: unset); + + expect(res, isNotNull); + expect(res.channel.cid, channelModel.cid); + expect( + res.channel.extraData, + {'coolness': 999, ...set}, + ); + + verify( + () => client.updateChannelPartial( + channelId, + channelType, + set: set, + unset: unset, + ), + ).called(1); + }); + + test('`.delete`', () async { + when(() => client.deleteChannel(channelId, channelType)).thenAnswer((_) async => EmptyResponse()); + + final res = await channel.delete(); + + expect(res, isNotNull); + + verify(() => client.deleteChannel(channelId, channelType)).called(1); + }); + + test('`.truncate`', () async { + when(() => client.truncateChannel(channelId, channelType)).thenAnswer((_) async => EmptyResponse()); + + final res = await channel.truncate(); + + expect(res, isNotNull); + + verify(() => client.truncateChannel(channelId, channelType)).called(1); + }); + + test('`.acceptInvite`', () async { + final message = Message(id: 'test-message-id', text: 'Invite Accepted'); + + final channelModel = ChannelModel(cid: channelCid); + + when(() => client.acceptChannelInvite(channelId, channelType, message: any(named: 'message'))).thenAnswer( + (_) async => AcceptInviteResponse() + ..channel = channelModel + ..message = message, + ); + + final res = await channel.acceptInvite(message); + + expect(res, isNotNull); + expect(res.channel.cid, channelModel.cid); + expect(res.message?.id, message.id); + + verify(() => client.acceptChannelInvite(channelId, channelType, message: any(named: 'message'))).called(1); + }); + + test('`.rejectInvite`', () async { + final message = Message(id: 'test-message-id', text: 'Invite Rejected'); + + final channelModel = ChannelModel(cid: channelCid); + + when(() => client.rejectChannelInvite(channelId, channelType, message: any(named: 'message'))).thenAnswer( + (_) async => RejectInviteResponse() + ..channel = channelModel + ..message = message, + ); + + final res = await channel.rejectInvite(message); + + expect(res, isNotNull); + expect(res.channel.cid, channelModel.cid); + expect(res.message?.id, message.id); + + verify(() => client.rejectChannelInvite(channelId, channelType, message: any(named: 'message'))).called(1); + }); + + test('`.addMembers`', () async { + final members = List.generate( + 3, + (index) => Member(userId: 'test-member-id-$index'), + ); + final memberIds = members.map((it) => it.userId).whereType().toList(growable: false); + final message = Message(id: 'test-message-id', text: 'Members Added'); + + final channelModel = ChannelModel(cid: channelCid); + + when( + () => client.addChannelMembers(channelId, channelType, memberIds, message: any(named: 'message')), + ).thenAnswer( + (_) async => AddMembersResponse() + ..channel = channelModel + ..members = members + ..message = message, + ); + + final res = await channel.addMembers(memberIds, message: message); + + expect(res, isNotNull); + expect(res.channel.cid, channelModel.cid); + expect(res.members.length, members.length); + expect(res.message?.id, message.id); + + verify( + () => client.addChannelMembers(channelId, channelType, memberIds, message: any(named: 'message')), + ).called(1); + }); + + test('`.addMembers` with hideHistoryBefore', () async { + final members = List.generate( + 3, + (index) => Member(userId: 'test-member-id-$index'), + ); + final memberIds = members.map((it) => it.userId).whereType().toList(growable: false); + final message = Message(id: 'test-message-id', text: 'Members Added'); + final hideHistoryBefore = DateTime.parse('2024-01-01T00:00:00Z'); + + final channelModel = ChannelModel(cid: channelCid); + + when( + () => client.addChannelMembers( + channelId, + channelType, + memberIds, + message: message, + hideHistoryBefore: hideHistoryBefore, + ), + ).thenAnswer( + (_) async => AddMembersResponse() + ..channel = channelModel + ..members = members + ..message = message, + ); + + final res = await channel.addMembers( + memberIds, + message: message, + hideHistoryBefore: hideHistoryBefore, + ); + + expect(res, isNotNull); + expect(res.channel.cid, channelModel.cid); + expect(res.members.length, members.length); + expect(res.message?.id, message.id); + + verify( + () => client.addChannelMembers( + channelId, + channelType, + memberIds, + message: message, + hideHistoryBefore: hideHistoryBefore, + ), + ).called(1); + }); + + test('`.inviteMembers`', () async { + final members = List.generate( + 3, + (index) => Member(userId: 'test-member-id-$index'), + ); + final memberIds = members.map((it) => it.userId).whereType().toList(growable: false); + final message = Message(id: 'test-message-id', text: 'Members Invited'); + + final channelModel = ChannelModel(cid: channelCid); + + when( + () => client.inviteChannelMembers(channelId, channelType, memberIds, message: any(named: 'message')), + ).thenAnswer( + (_) async => InviteMembersResponse() + ..channel = channelModel + ..members = members + ..message = message, + ); + + final res = await channel.inviteMembers(memberIds, message: message); + + expect(res, isNotNull); + expect(res.channel.cid, channelModel.cid); + expect(res.members.length, members.length); + expect(res.message?.id, message.id); + + verify( + () => client.inviteChannelMembers(channelId, channelType, memberIds, message: any(named: 'message')), + ).called(1); + }); + + test('`.removeMembers`', () async { + final members = List.generate( + 3, + (index) => Member(userId: 'test-member-id-$index'), + ); + final memberIds = members.map((it) => it.userId).whereType().toList(growable: false); + final message = Message(id: 'test-message-id', text: 'Members Removed'); + + final channelModel = ChannelModel(cid: channelCid); + + when( + () => client.removeChannelMembers(channelId, channelType, memberIds, message: any(named: 'message')), + ).thenAnswer( + (_) async => RemoveMembersResponse() + ..channel = channelModel + ..members = members + ..message = message, + ); + + final res = await channel.removeMembers(memberIds, message: message); + + expect(res, isNotNull); + expect(res.channel.cid, channelModel.cid); + expect(res.members.length, members.length); + expect(res.message?.id, message.id); + + verify( + () => client.removeChannelMembers(channelId, channelType, memberIds, message: any(named: 'message')), + ).called(1); + }); + + group('`.sendAction`', () { + test('should work fine', () async { + final message = Message(id: 'test-message-id', text: 'Action Sent'); + const formData = {'key': 'value'}; + + when( + () => client.sendAction(channelId, channelType, message.id, formData), + ).thenAnswer((_) async => SendActionResponse()); + + final res = await channel.sendAction(message, formData); + + expect(res, isNotNull); + + verify( + () => client.sendAction(channelId, channelType, message.id, formData), + ).called(1); + }); + + test('should emit received message if not null', () async { + final message = Message(id: 'test-message-id', text: 'Action Sent'); + const formData = {'key': 'value'}; + + when( + () => client.sendAction(channelId, channelType, message.id, formData), + ).thenAnswer((_) async => SendActionResponse()..message = message); + + expectLater( + // skipping first seed message list -> [] messages + channel.state?.messagesStream.skip(1), + emitsInOrder([ + [ + isSameMessageAs( + message, + matchMessageState: true, + ), + ], + ]), + ); + + final res = await channel.sendAction(message, formData); + + expect(res, isNotNull); + expect(res.message?.id, message.id); + + verify( + () => client.sendAction(channelId, channelType, message.id, formData), + ).called(1); + }); + }); + + group('`.watch`', () { + test('should work fine', () async { + when( + () => client.queryChannel( + channelType, + channelId: channelId, + watch: true, + channelData: any(named: 'channelData'), + messagesPagination: any(named: 'messagesPagination'), + membersPagination: any(named: 'membersPagination'), + watchersPagination: any(named: 'watchersPagination'), + ), + ).thenAnswer( + (_) async => _generateChannelState(channelId, channelType), + ); + + final res = await channel.watch(); + + expect(res, isNotNull); + expect(res.channel, isNotNull); + expect(res.channel?.cid, channelCid); + + verify( + () => client.queryChannel( + channelType, + channelId: channelId, + watch: true, + channelData: any(named: 'channelData'), + messagesPagination: any(named: 'messagesPagination'), + membersPagination: any(named: 'membersPagination'), + watchersPagination: any(named: 'watchersPagination'), + ), + ).called(1); + }); + + test('should rethrow if `.query` throws', () async { + when( + () => client.queryChannel( + channelType, + channelId: channelId, + watch: true, + channelData: any(named: 'channelData'), + messagesPagination: any(named: 'messagesPagination'), + membersPagination: any(named: 'membersPagination'), + watchersPagination: any(named: 'watchersPagination'), + ), + ).thenThrow(StreamChatNetworkError(ChatErrorCode.inputError)); + + try { + await channel.watch(); + } catch (e) { + expect(e, isA()); + } + + verify( + () => client.queryChannel( + channelType, + channelId: channelId, + watch: true, + channelData: any(named: 'channelData'), + messagesPagination: any(named: 'messagesPagination'), + membersPagination: any(named: 'membersPagination'), + watchersPagination: any(named: 'watchersPagination'), + ), + ).called(1); + }); + }); + + test('`.stopWatching`', () async { + when(() => client.stopChannelWatching(channelId, channelType)).thenAnswer((_) async => EmptyResponse()); + + final res = await channel.stopWatching(); + + expect(res, isNotNull); + + verify(() => client.stopChannelWatching(channelId, channelType)).called(1); + }); + + test('`.getReplies`', () async { + const parentId = 'test-parent-id'; + + final messages = List.generate( + 3, + (index) => Message( + id: 'test-message-id-$index', + parentId: parentId, + ), + ); + + when(() => client.getReplies(parentId)).thenAnswer( + (_) async => QueryRepliesResponse()..messages = messages, + ); + + final res = await channel.getReplies(parentId); + + expect(res, isNotNull); + expect(res.messages.length, messages.length); + expect(res.messages.every((it) => it.parentId == parentId), isTrue); + + verify(() => client.getReplies(parentId)).called(1); + }); + + test('`.getReactions`', () async { + const messageId = 'test-message-id'; + + final reactions = List.generate( + 3, + (index) => Reaction( + type: 'test-reaction-type-$index', + messageId: messageId, + ), + ); + + when(() => client.getReactions(messageId)).thenAnswer( + (_) async => QueryReactionsResponse()..reactions = reactions, + ); + + final res = await channel.getReactions(messageId); + + expect(res, isNotNull); + expect(res.reactions.length, reactions.length); + expect(res.reactions.every((it) => it.messageId == messageId), isTrue); + + verify(() => client.getReactions(messageId)).called(1); + }); + + test('`.getMessagesById`', () async { + final messages = List.generate( + 3, + (index) => Message(id: 'test-message-id-$index'), + ); + + final messageIds = messages.map((it) => it.id).toList(growable: false); + + when(() => client.getMessagesById(channelId, channelType, messageIds)).thenAnswer( + (_) async => GetMessagesByIdResponse()..messages = messages, + ); + + final res = await channel.getMessagesById(messageIds); + + expect(res, isNotNull); + expect(res.messages.length, messageIds.length); + + verify( + () => client.getMessagesById(channelId, channelType, messageIds), + ).called(1); + }); + + test('`.translateMessage`', () async { + const messageId = 'test-message-id'; + const language = 'hi'; // Hindi + const translatedMessageText = 'नमस्ते'; + + final translatedMessage = Message( + i18n: const { + language: translatedMessageText, + }, + ); + + when(() => client.translateMessage(messageId, language)).thenAnswer( + (_) async => TranslateMessageResponse()..message = translatedMessage, + ); + + final res = await channel.translateMessage(messageId, language); + + expect(res, isNotNull); + expect(res.message.i18n, translatedMessage.i18n); + + verify(() => client.translateMessage(messageId, language)).called(1); + }); + + group('`.query`', () { + test('should work fine', () async { + final channelState = _generateChannelState(channelId, channelType); + + when( + () => client.queryChannel( + channelType, + channelId: channelId, + channelData: any(named: 'channelData'), + messagesPagination: any(named: 'messagesPagination'), + membersPagination: any(named: 'membersPagination'), + watchersPagination: any(named: 'watchersPagination'), + ), + ).thenAnswer((_) async => channelState); + + final res = await channel.query(); + + expect(res, isNotNull); + + verify( + () => client.queryChannel( + channelType, + channelId: channelId, + channelData: any(named: 'channelData'), + messagesPagination: any(named: 'messagesPagination'), + membersPagination: any(named: 'membersPagination'), + watchersPagination: any(named: 'watchersPagination'), + ), + ).called(1); + }); + + test('should rethrow if `client.queryChannel` throws', () async { + when( + () => client.queryChannel( + channelType, + channelId: channelId, + channelData: any(named: 'channelData'), + messagesPagination: any(named: 'messagesPagination'), + membersPagination: any(named: 'membersPagination'), + watchersPagination: any(named: 'watchersPagination'), + ), + ).thenThrow(StreamChatNetworkError(ChatErrorCode.inputError)); + + try { + await channel.query(); + } catch (e) { + expect(e, isA()); + } + + verify( + () => client.queryChannel( + channelType, + channelId: channelId, + channelData: any(named: 'channelData'), + messagesPagination: any(named: 'messagesPagination'), + membersPagination: any(named: 'membersPagination'), + watchersPagination: any(named: 'watchersPagination'), + ), + ).called(1); + }); + + test('should truncate state when querying around message id', () async { + final initialMessages = [ + Message(id: 'msg1', text: 'Hello 1'), + Message(id: 'msg2', text: 'Hello 2'), + Message(id: 'msg3', text: 'Hello 3'), + ]; + + final stateWithMessages = _generateChannelState( + channelId, + channelType, + ).copyWith(messages: initialMessages); + + channel.state!.updateChannelState(stateWithMessages); + expect(channel.state!.messages, hasLength(3)); + + final newState = + _generateChannelState( + channelId, + channelType, + ).copyWith( + messages: [ + Message(id: 'msg-before-1', text: 'Message before 1'), + Message(id: 'msg-before-2', text: 'Message before 2'), + Message(id: 'target-message-id', text: 'Target message'), + Message(id: 'msg-after-1', text: 'Message after 1'), + Message(id: 'msg-after-2', text: 'Message after 2'), + ], + ); + + when( + () => client.queryChannel( + channelType, + channelId: channelId, + channelData: any(named: 'channelData'), + messagesPagination: any(named: 'messagesPagination'), + membersPagination: any(named: 'membersPagination'), + watchersPagination: any(named: 'watchersPagination'), + ), + ).thenAnswer((_) async => newState); + + const pagination = PaginationParams(idAround: 'target-message-id'); + + final res = await channel.query(messagesPagination: pagination); + + expect(res, isNotNull); + expect(channel.state!.messages, hasLength(5)); + expect(channel.state!.messages[2].id, 'target-message-id'); + + verify( + () => client.queryChannel( + channelType, + channelId: channelId, + channelData: any(named: 'channelData'), + messagesPagination: pagination, + membersPagination: any(named: 'membersPagination'), + watchersPagination: any(named: 'watchersPagination'), + ), + ).called(1); + }); + + test('should truncate state when querying around created date', () async { + final initialMessages = [ + Message(id: 'msg1', text: 'Hello 1'), + Message(id: 'msg2', text: 'Hello 2'), + Message(id: 'msg3', text: 'Hello 3'), + ]; + + final stateWithMessages = _generateChannelState( + channelId, + channelType, + ).copyWith(messages: initialMessages); + + channel.state!.updateChannelState(stateWithMessages); + expect(channel.state!.messages, hasLength(3)); + + final targetDate = DateTime.now(); + final newState = + _generateChannelState( + channelId, + channelType, + ).copyWith( + messages: [ + Message(id: 'msg-before-1', text: 'Message before 1'), + Message(id: 'msg-before-2', text: 'Message before 2'), + Message(id: 'target-message', text: 'Target message'), + Message(id: 'msg-after-1', text: 'Message after 1'), + Message(id: 'msg-after-2', text: 'Message after 2'), + ], + ); + + when( + () => client.queryChannel( + channelType, + channelId: channelId, + channelData: any(named: 'channelData'), + messagesPagination: any(named: 'messagesPagination'), + membersPagination: any(named: 'membersPagination'), + watchersPagination: any(named: 'watchersPagination'), + ), + ).thenAnswer((_) async => newState); + + final pagination = PaginationParams(createdAtAround: targetDate); + + final res = await channel.query(messagesPagination: pagination); + + expect(res, isNotNull); + expect(channel.state!.messages, hasLength(5)); + expect(channel.state!.messages[2].id, 'target-message'); + + verify( + () => client.queryChannel( + channelType, + channelId: channelId, + channelData: any(named: 'channelData'), + messagesPagination: pagination, + membersPagination: any(named: 'membersPagination'), + watchersPagination: any(named: 'watchersPagination'), + ), + ).called(1); + }); + + test( + 'should submit for delivery when querying latest messages (no pagination)', + () async { + final channelState = _generateChannelState(channelId, channelType); + + when( + () => client.queryChannel( + channelType, + channelId: channelId, + channelData: any(named: 'channelData'), + messagesPagination: any(named: 'messagesPagination'), + membersPagination: any(named: 'membersPagination'), + watchersPagination: any(named: 'watchersPagination'), + ), + ).thenAnswer((_) async => channelState); + + // Query without pagination params (fetching latest messages) + await channel.query(); + + // Verify submitForDelivery was called + verify( + () => client.channelDeliveryReporter.submitForDelivery([channel]), + ).called(1); + }, + ); + + test( + 'should NOT submit for delivery when querying with pagination (older messages)', + () async { + final channelState = _generateChannelState(channelId, channelType); + + when( + () => client.queryChannel( + channelType, + channelId: channelId, + channelData: any(named: 'channelData'), + messagesPagination: any(named: 'messagesPagination'), + membersPagination: any(named: 'membersPagination'), + watchersPagination: any(named: 'watchersPagination'), + ), + ).thenAnswer((_) async => channelState); + + // Query with pagination params (fetching older messages) + await channel.query( + messagesPagination: const PaginationParams( + limit: 20, + lessThan: 'some-message-id', + ), + ); + + // Verify submitForDelivery was NOT called + verifyNever( + () => client.channelDeliveryReporter.submitForDelivery([channel]), + ); + }, + ); + }); + + test('`.queryMembers`', () async { + final filter = Filter.in_('cid', const [channelCid]); + + final members = List.generate( + 3, + (index) => Member(userId: 'test-user-id-$index'), + ); + + when( + () => client.queryMembers( + channelType, + channelId: channelId, + filter: filter, + members: any(named: 'members'), + sort: any(named: 'sort'), + pagination: any(named: 'pagination'), + ), + ).thenAnswer((_) async => QueryMembersResponse()..members = members); + + final res = await channel.queryMembers(filter: filter); + + expect(res, isNotNull); + expect(res.members.length, members.length); + + verify( + () => client.queryMembers( + channelType, + channelId: channelId, + filter: filter, + members: any(named: 'members'), + sort: any(named: 'sort'), + pagination: any(named: 'pagination'), + ), + ).called(1); + }); + + test('`.queryBannedUsers`', () async { + final filter = Filter.equal('channel_cid', channelCid); + + final bans = List.generate( + 3, + (index) => BannedUser( + user: User(id: 'test-user-id-$index'), + bannedBy: User(id: 'test-user-id-${index + 1}'), + ), + ); + + when( + () => client.queryBannedUsers( + filter: filter, + sort: any(named: 'sort'), + pagination: any(named: 'pagination'), + ), + ).thenAnswer((_) async => QueryBannedUsersResponse()..bans = bans); + + final res = await channel.queryBannedUsers(); + + expect(res, isNotNull); + expect(res.bans.length, bans.length); + + verify( + () => client.queryBannedUsers( + filter: filter, + sort: any(named: 'sort'), + pagination: any(named: 'pagination'), + ), + ).called(1); + }); + + test('`.mute`', () async { + when( + () => client.muteChannel( + channelCid, + expiration: any(named: 'expiration'), + ), + ).thenAnswer((_) async => EmptyResponse()); + + final res = await channel.mute(); + + expect(res, isNotNull); + + verify( + () => client.muteChannel( + channelCid, + expiration: any(named: 'expiration'), + ), + ).called(1); + }); + + test('`.mute with expiration`', () async { + const expiration = Duration(seconds: 3); + + when( + () => client.muteChannel( + channelCid, + expiration: expiration, + ), + ).thenAnswer((_) async => EmptyResponse()); + + when(() => client.unmuteChannel(channelCid)).thenAnswer((_) async => EmptyResponse()); + + final res = await channel.mute(expiration: expiration); + + expect(res, isNotNull); + + verify( + () => client.muteChannel( + channelCid, + expiration: expiration, + ), + ).called(1); + + // wait for expiration + await Future.delayed(expiration); + verify(() => client.unmuteChannel(channelCid)).called(1); + }); + + test('`.unmute`', () async { + when( + () => client.unmuteChannel(channelCid), + ).thenAnswer((_) async => EmptyResponse()); + + final res = await channel.unmute(); + + expect(res, isNotNull); + + verify( + () => client.unmuteChannel(channelCid), + ).called(1); + }); + + test('`.enableSlowMode`', () async { + const cooldown = 10; + + final channelModel = ChannelModel( + cid: channelCid, + cooldown: cooldown, + ); + + when( + () => client.enableSlowdown( + channelId, + channelType, + cooldown, + ), + ).thenAnswer((_) async => PartialUpdateChannelResponse()..channel = channelModel); + + final res = await channel.enableSlowMode(cooldownInterval: 10); + + expect(res, isNotNull); + + verify( + () => client.enableSlowdown( + channelId, + channelType, + cooldown, + ), + ).called(1); + }); + + test('`.disableSlowMode`', () async { + final channelModel = ChannelModel( + cid: channelCid, + ); + + when( + () => client.disableSlowdown( + channelId, + channelType, + ), + ).thenAnswer((_) async => PartialUpdateChannelResponse()..channel = channelModel); + + final res = await channel.disableSlowMode(); + + expect(res, isNotNull); + + verify(() => client.disableSlowdown(channelId, channelType)).called(1); + }); + + test('`.banUser`', () async { + const userId = 'test-user-id'; + const options = {'key': 'value'}; + + when( + () => client.banUser( + userId, + {'type': channelType, 'id': channelId, ...options}, + ), + ).thenAnswer((_) async => EmptyResponse()); + + final res = await channel.banMember(userId, options); + + expect(res, isNotNull); + + verify( + () => client.banUser( + userId, + {'type': channelType, 'id': channelId, ...options}, + ), + ).called(1); + }); + + test('`.unbanUser`', () async { + const userId = 'test-user-id'; + + when(() => client.unbanUser(userId, any())).thenAnswer((_) async => EmptyResponse()); + + final res = await channel.unbanMember(userId); + + expect(res, isNotNull); + + verify(() => client.unbanUser(userId, any())).called(1); + }); + + test('`.shadowBan`', () async { + const userId = 'test-user-id'; + const options = {'key': 'value'}; + + when( + () => client.shadowBan( + userId, + {'type': channelType, 'id': channelId, ...options}, + ), + ).thenAnswer((_) async => EmptyResponse()); + + final res = await channel.shadowBan(userId, options); + + expect(res, isNotNull); + + verify( + () => client.shadowBan( + userId, + {'type': channelType, 'id': channelId, ...options}, + ), + ).called(1); + }); + + test('`.removeShadowBan`', () async { + const userId = 'test-user-id'; + + when(() => client.removeShadowBan(userId, any())).thenAnswer((_) async => EmptyResponse()); + + final res = await channel.removeShadowBan(userId); + + expect(res, isNotNull); + + verify(() => client.removeShadowBan(userId, any())).called(1); + }); + + test('`.hide`', () async { + const clearHistory = true; + + when( + () => client.hideChannel( + channelId, + channelType, + clearHistory: clearHistory, + ), + ).thenAnswer((_) async => EmptyResponse()); + + final res = await channel.hide(clearHistory: clearHistory); + + expect(res, isNotNull); + + verify( + () => client.hideChannel( + channelId, + channelType, + clearHistory: clearHistory, + ), + ).called(1); + }); + + test('`.show`', () async { + when(() => client.showChannel(channelId, channelType)).thenAnswer((_) async => EmptyResponse()); + + final res = await channel.show(); + + expect(res, isNotNull); + + verify(() => client.showChannel(channelId, channelType)).called(1); + }); + + // testing archiving + test('`.archive`', () async { + when(() => client.archiveChannel(channelId: channelId, channelType: channelType)).thenAnswer( + (_) async => FakePartialUpdateMemberResponse(), + ); + + final res = await channel.archive(); + + expect(res, isNotNull); + + verify(() => client.archiveChannel(channelId: channelId, channelType: channelType)).called(1); + }); + + test('`.unarchive`', () async { + when(() => client.unarchiveChannel(channelId: channelId, channelType: channelType)).thenAnswer( + (_) async => FakePartialUpdateMemberResponse(), + ); + + final res = await channel.unarchive(); + + expect(res, isNotNull); + + verify(() => client.unarchiveChannel(channelId: channelId, channelType: channelType)).called(1); + }); + + // testing pinning + test('`.pin`', () async { + when( + () => client.pinChannel(channelId: channelId, channelType: channelType), + ).thenAnswer((_) async => FakePartialUpdateMemberResponse()); + + final res = await channel.pin(); + + expect(res, isNotNull); + + verify(() => client.pinChannel(channelId: channelId, channelType: channelType)).called(1); }); - test('`.stopWatching`', () async { - when(() => client.stopChannelWatching(channelId, channelType)) - .thenAnswer((_) async => EmptyResponse()); + test('`.unpin`', () async { + when( + () => client.unpinChannel(channelId: channelId, channelType: channelType), + ).thenAnswer((_) async => FakePartialUpdateMemberResponse()); - final res = await channel.stopWatching(); + final res = await channel.unpin(); expect(res, isNotNull); - verify(() => client.stopChannelWatching(channelId, channelType)) - .called(1); + verify(() => client.unpinChannel(channelId: channelId, channelType: channelType)).called(1); }); - test('`.getReplies`', () async { - const parentId = 'test-parent-id'; + test('`.on`', () async { + const eventType = 'test.event'; + final event = Event(type: eventType, cid: channelCid); - final messages = List.generate( - 3, - (index) => Message( - id: 'test-message-id-$index', - parentId: parentId, + Future.microtask(() => client.addEvent(event)); + + return expectLater(channel.on(eventType), emitsInOrder([event])); + }); + + group('stale error message cleanup', () { + final channelState = _generateChannelState(channelId, channelType); + + final errorMessage = Message(type: MessageType.error); + final bouncedErrorMessage = Message( + type: MessageType.error, + moderation: const Moderation( + action: ModerationAction.bounce, + originalText: 'original text', ), ); - when(() => client.getReplies(parentId)).thenAnswer( - (_) async => QueryRepliesResponse()..messages = messages, - ); + // Test case: sending a message cleans up stale error messages + test('when sending a new message', () async { + // Channel with 2 error messages + final channel = Channel.fromState( + client, + channelState.copyWith( + messages: [errorMessage, bouncedErrorMessage], + ), + ); - final res = await channel.getReplies(parentId); + // Set up the mock response for sending message + final newMessage = Message(text: 'New message'); - expect(res, isNotNull); - expect(res.messages.length, messages.length); - expect(res.messages.every((it) => it.parentId == parentId), isTrue); + when( + () => client.sendMessage(any(), channelId, channelType), + ).thenAnswer((_) async => SendMessageResponse()..message = newMessage.copyWith(state: MessageState.sent)); - verify(() => client.getReplies(parentId)).called(1); + // Send a new message + await channel.sendMessage(newMessage); + final messages = channel.state!.messages; + + // Verify the cleanup + expect(messages.length, 2); + expect(messages.any((m) => m.id == errorMessage.id), false); + expect(messages.any((m) => m.id == bouncedErrorMessage.id), true); + expect(messages.any((m) => m.id == newMessage.id), true); + + verify(() => client.sendMessage(any(), channelId, channelType)); + }); }); + }); - test('`.getReactions`', () async { - const messageId = 'test-message-id'; + group('WS events', () { + late final client = MockStreamChatClient(); - final reactions = List.generate( - 3, - (index) => Reaction( - type: 'test-reaction-type-$index', - messageId: messageId, - ), - ); + setUpAll(() { + // Fallback values + registerFallbackValue(FakeMessage()); + registerFallbackValue(FakeAttachmentFile()); + registerFallbackValue(FakeEvent()); - when(() => client.getReactions(messageId)).thenAnswer( - (_) async => QueryReactionsResponse()..reactions = reactions, + // detached loggers + when(() => client.detachedLogger(any())).thenAnswer((invocation) { + final name = invocation.positionalArguments.first; + return _createLogger(name); + }); + + final retryPolicy = RetryPolicy( + shouldRetry: (_, __, ___) => false, + delayFactor: Duration.zero, ); + when(() => client.retryPolicy).thenReturn(retryPolicy); - final res = await channel.getReactions(messageId); + // fake clientState + final clientState = FakeClientState(); + when(() => client.state).thenReturn(clientState); - expect(res, isNotNull); - expect(res.reactions.length, reactions.length); - expect(res.reactions.every((it) => it.messageId == messageId), isTrue); + // client logger + when(() => client.logger).thenReturn(_createLogger('mock-client-logger')); - verify(() => client.getReactions(messageId)).called(1); + // mock channel delivery reporter + when( + () => client.channelDeliveryReporter.submitForDelivery(any()), + ).thenAnswer((_) async {}); }); - test('`.getMessagesById`', () async { - final messages = List.generate( - 3, - (index) => Message(id: 'test-message-id-$index'), - ); + group( + '${EventType.messageNew} or ${EventType.notificationMessageNew}', + () { + final initialLastMessageAt = DateTime.now(); + const channelId = 'test-channel-id'; + const channelType = 'test-channel-type'; + late Channel channel; - final messageIds = messages.map((it) => it.id).toList(growable: false); + setUp(() { + final channelState = _generateChannelState( + channelId, + channelType, + mockChannelConfig: true, + ownCapabilities: const [ChannelCapability.readEvents], + lastMessageAt: initialLastMessageAt, + ); - when(() => client.getMessagesById(channelId, channelType, messageIds)) - .thenAnswer( - (_) async => GetMessagesByIdResponse()..messages = messages, - ); + channel = Channel.fromState(client, channelState); + }); - final res = await channel.getMessagesById(messageIds); + tearDown(() => channel.dispose()); - expect(res, isNotNull); - expect(res.messages.length, messageIds.length); + Event createNewMessageEvent(Message message) { + return Event( + cid: channel.cid, + type: EventType.messageNew, + message: message, + ); + } - verify( - () => client.getMessagesById(channelId, channelType, messageIds), - ).called(1); - }); + test( + "should update 'channel.lastMessageAt'", + () async { + expect(channel.lastMessageAt, equals(initialLastMessageAt)); - test('`.translateMessage`', () async { - const messageId = 'test-message-id'; - const language = 'hi'; // Hindi - const translatedMessageText = 'नमस्ते'; + final message = Message( + id: 'test-message-id', + user: client.state.currentUser, + createdAt: initialLastMessageAt.add(const Duration(seconds: 3)), + ); - final translatedMessage = Message( - i18n: const { - language: translatedMessageText, - }, - ); + final newMessageEvent = createNewMessageEvent(message); + client.addEvent(newMessageEvent); - when(() => client.translateMessage(messageId, language)).thenAnswer( - (_) async => TranslateMessageResponse()..message = translatedMessage, - ); + // Wait for the event to get processed + await Future.delayed(Duration.zero); - final res = await channel.translateMessage(messageId, language); + expect(channel.lastMessageAt, equals(message.createdAt)); + expect(channel.lastMessageAt, isNot(initialLastMessageAt)); + }, + ); - expect(res, isNotNull); - expect(res.message.i18n, translatedMessage.i18n); + test( + "should update 'channel.lastMessageAt' when Message has restricted visibility only for the current user", + () async { + expect(channel.lastMessageAt, equals(initialLastMessageAt)); - verify(() => client.translateMessage(messageId, language)).called(1); - }); + final message = Message( + id: 'test-message-id', + user: client.state.currentUser, + // Message is visible to the current user. + restrictedVisibility: [client.state.currentUser!.id], + createdAt: initialLastMessageAt.add(const Duration(seconds: 3)), + ); - group('`.query`', () { - test('should work fine', () async { - final channelState = _generateChannelState(channelId, channelType); + final newMessageEvent = createNewMessageEvent(message); + client.addEvent(newMessageEvent); - when( - () => client.queryChannel( - channelType, - channelId: channelId, - channelData: any(named: 'channelData'), - messagesPagination: any(named: 'messagesPagination'), - membersPagination: any(named: 'membersPagination'), - watchersPagination: any(named: 'watchersPagination'), - ), - ).thenAnswer((_) async => channelState); + // Wait for the event to get processed + await Future.delayed(Duration.zero); - final res = await channel.query(); + expect(channel.lastMessageAt, equals(message.createdAt)); + expect(channel.lastMessageAt, isNot(initialLastMessageAt)); + }, + ); - expect(res, isNotNull); + test( + "should not update 'channel.lastMessageAt' when 'message.createdAt' is older", + () async { + expect(channel.lastMessageAt, equals(initialLastMessageAt)); - verify( - () => client.queryChannel( - channelType, - channelId: channelId, - channelData: any(named: 'channelData'), - messagesPagination: any(named: 'messagesPagination'), - membersPagination: any(named: 'membersPagination'), - watchersPagination: any(named: 'watchersPagination'), - ), - ).called(1); - }); + final message = Message( + id: 'test-message-id', + user: client.state.currentUser, + // Older than the current 'channel.lastMessageAt'. + createdAt: initialLastMessageAt.subtract(const Duration(days: 1)), + ); - test('should rethrow if `client.queryChannel` throws', () async { - when( - () => client.queryChannel( - channelType, - channelId: channelId, - channelData: any(named: 'channelData'), - messagesPagination: any(named: 'messagesPagination'), - membersPagination: any(named: 'membersPagination'), - watchersPagination: any(named: 'watchersPagination'), - ), - ).thenThrow(StreamChatNetworkError(ChatErrorCode.inputError)); + final newMessageEvent = createNewMessageEvent(message); + client.addEvent(newMessageEvent); + + // Wait for the event to get processed + await Future.delayed(Duration.zero); + + expect(channel.lastMessageAt, isNot(message.createdAt)); + expect(channel.lastMessageAt, equals(initialLastMessageAt)); + }, + ); - try { - await channel.query(); - } catch (e) { - expect(e, isA()); - } + test( + "should not update 'channel.lastMessageAt' when Message is shadowed", + () async { + expect(channel.lastMessageAt, equals(initialLastMessageAt)); - verify( - () => client.queryChannel( - channelType, - channelId: channelId, - channelData: any(named: 'channelData'), - messagesPagination: any(named: 'messagesPagination'), - membersPagination: any(named: 'membersPagination'), - watchersPagination: any(named: 'watchersPagination'), - ), - ).called(1); - }); + final message = Message( + id: 'test-message-id', + user: client.state.currentUser, + shadowed: true, + createdAt: initialLastMessageAt.add(const Duration(seconds: 3)), + ); - test('should truncate state when querying around message id', () async { - final initialMessages = [ - Message(id: 'msg1', text: 'Hello 1'), - Message(id: 'msg2', text: 'Hello 2'), - Message(id: 'msg3', text: 'Hello 3'), - ]; + final newMessageEvent = createNewMessageEvent(message); + client.addEvent(newMessageEvent); - final stateWithMessages = _generateChannelState( - channelId, - channelType, - ).copyWith(messages: initialMessages); + // Wait for the event to get processed + await Future.delayed(Duration.zero); - channel.state!.updateChannelState(stateWithMessages); - expect(channel.state!.messages, hasLength(3)); + expect(channel.lastMessageAt, isNot(message.createdAt)); + expect(channel.lastMessageAt, equals(initialLastMessageAt)); + }, + ); - final newState = _generateChannelState( - channelId, - channelType, - ).copyWith(messages: [ - Message(id: 'msg-before-1', text: 'Message before 1'), - Message(id: 'msg-before-2', text: 'Message before 2'), - Message(id: 'target-message-id', text: 'Target message'), - Message(id: 'msg-after-1', text: 'Message after 1'), - Message(id: 'msg-after-2', text: 'Message after 2'), - ]); + test( + "should not update 'channel.lastMessageAt' when Message is ephemeral", + () async { + expect(channel.lastMessageAt, equals(initialLastMessageAt)); - when( - () => client.queryChannel( - channelType, - channelId: channelId, - channelData: any(named: 'channelData'), - messagesPagination: any(named: 'messagesPagination'), - membersPagination: any(named: 'membersPagination'), - watchersPagination: any(named: 'watchersPagination'), - ), - ).thenAnswer((_) async => newState); + final message = Message( + type: MessageType.ephemeral, + id: 'test-message-id', + user: client.state.currentUser, + createdAt: initialLastMessageAt.add(const Duration(seconds: 3)), + ); - const pagination = PaginationParams(idAround: 'target-message-id'); + final newMessageEvent = createNewMessageEvent(message); + client.addEvent(newMessageEvent); - final res = await channel.query(messagesPagination: pagination); + // Wait for the event to get processed + await Future.delayed(Duration.zero); - expect(res, isNotNull); - expect(channel.state!.messages, hasLength(5)); - expect(channel.state!.messages[2].id, 'target-message-id'); + expect(channel.lastMessageAt, isNot(message.createdAt)); + expect(channel.lastMessageAt, equals(initialLastMessageAt)); + }, + ); - verify( - () => client.queryChannel( - channelType, - channelId: channelId, - channelData: any(named: 'channelData'), - messagesPagination: pagination, - membersPagination: any(named: 'membersPagination'), - watchersPagination: any(named: 'watchersPagination'), - ), - ).called(1); - }); + test( + "should not update 'channel.lastMessageAt' when Message has restricted visibility but not for the current user", + () async { + expect(channel.lastMessageAt, equals(initialLastMessageAt)); - test('should truncate state when querying around created date', () async { - final initialMessages = [ - Message(id: 'msg1', text: 'Hello 1'), - Message(id: 'msg2', text: 'Hello 2'), - Message(id: 'msg3', text: 'Hello 3'), - ]; + final message = Message( + id: 'test-message-id', + user: client.state.currentUser, + // Message is only visible to user-1 not the current user. + restrictedVisibility: const ['user-1'], + createdAt: initialLastMessageAt.add(const Duration(seconds: 3)), + ); - final stateWithMessages = _generateChannelState( - channelId, - channelType, - ).copyWith(messages: initialMessages); + final newMessageEvent = createNewMessageEvent(message); + client.addEvent(newMessageEvent); - channel.state!.updateChannelState(stateWithMessages); - expect(channel.state!.messages, hasLength(3)); + // Wait for the event to get processed + await Future.delayed(Duration.zero); - final targetDate = DateTime.now(); - final newState = _generateChannelState( - channelId, - channelType, - ).copyWith(messages: [ - Message(id: 'msg-before-1', text: 'Message before 1'), - Message(id: 'msg-before-2', text: 'Message before 2'), - Message(id: 'target-message', text: 'Target message'), - Message(id: 'msg-after-1', text: 'Message after 1'), - Message(id: 'msg-after-2', text: 'Message after 2'), - ]); + expect(channel.lastMessageAt, isNot(message.createdAt)); + expect(channel.lastMessageAt, equals(initialLastMessageAt)); + }, + ); - when( - () => client.queryChannel( - channelType, - channelId: channelId, - channelData: any(named: 'channelData'), - messagesPagination: any(named: 'messagesPagination'), - membersPagination: any(named: 'membersPagination'), - watchersPagination: any(named: 'watchersPagination'), - ), - ).thenAnswer((_) async => newState); + test( + "should not update 'channel.lastMessageAt' when Message is system and skip is enabled", + () async { + expect(channel.lastMessageAt, equals(initialLastMessageAt)); - final pagination = PaginationParams(createdAtAround: targetDate); + when( + () => channel.config?.skipLastMsgUpdateForSystemMsgs, + ).thenReturn(true); - final res = await channel.query(messagesPagination: pagination); + final message = Message( + type: MessageType.system, + id: 'test-message-id', + user: client.state.currentUser, + createdAt: initialLastMessageAt.add(const Duration(seconds: 3)), + ); - expect(res, isNotNull); - expect(channel.state!.messages, hasLength(5)); - expect(channel.state!.messages[2].id, 'target-message'); + final newMessageEvent = createNewMessageEvent(message); + client.addEvent(newMessageEvent); - verify( - () => client.queryChannel( - channelType, - channelId: channelId, - channelData: any(named: 'channelData'), - messagesPagination: pagination, - membersPagination: any(named: 'membersPagination'), - watchersPagination: any(named: 'watchersPagination'), - ), - ).called(1); - }); + // Wait for the event to get processed + await Future.delayed(Duration.zero); - test( - 'should submit for delivery when querying latest messages (no pagination)', - () async { - final channelState = _generateChannelState(channelId, channelType); + expect(channel.lastMessageAt, isNot(message.createdAt)); + expect(channel.lastMessageAt, equals(initialLastMessageAt)); + }, + ); - when( - () => client.queryChannel( - channelType, - channelId: channelId, - channelData: any(named: 'channelData'), - messagesPagination: any(named: 'messagesPagination'), - membersPagination: any(named: 'membersPagination'), - watchersPagination: any(named: 'watchersPagination'), - ), - ).thenAnswer((_) async => channelState); + test("should update 'unreadCount'", () async { + expect(channel.state?.unreadCount, equals(0)); - // Query without pagination params (fetching latest messages) - await channel.query(); + final message = Message( + id: 'test-message-id', + user: User(id: 'other-user'), + createdAt: initialLastMessageAt.add(const Duration(seconds: 3)), + ); - // Verify submitForDelivery was called - verify( - () => client.channelDeliveryReporter.submitForDelivery([channel]), - ).called(1); - }, - ); + final newMessageEvent = createNewMessageEvent(message); + client.addEvent(newMessageEvent); - test( - 'should NOT submit for delivery when querying with pagination (older messages)', - () async { - final channelState = _generateChannelState(channelId, channelType); + // Wait for the event to get processed + await Future.delayed(Duration.zero); - when( - () => client.queryChannel( - channelType, - channelId: channelId, - channelData: any(named: 'channelData'), - messagesPagination: any(named: 'messagesPagination'), - membersPagination: any(named: 'membersPagination'), - watchersPagination: any(named: 'watchersPagination'), - ), - ).thenAnswer((_) async => channelState); + expect(channel.state?.unreadCount, equals(1)); - // Query with pagination params (fetching older messages) - await channel.query( - messagesPagination: const PaginationParams( - limit: 20, - lessThan: 'some-message-id', - ), + final message2 = Message( + id: 'test-message-id-2', + user: User(id: 'other-user'), + createdAt: message.createdAt.add(const Duration(seconds: 3)), ); - // Verify submitForDelivery was NOT called - verifyNever( - () => client.channelDeliveryReporter.submitForDelivery([channel]), - ); - }, - ); - }); + final newMessage2Event = createNewMessageEvent(message2); + client.addEvent(newMessage2Event); - test('`.queryMembers`', () async { - final filter = Filter.in_('cid', const [channelCid]); + // Wait for the event to get processed + await Future.delayed(Duration.zero); - final members = List.generate( - 3, - (index) => Member(userId: 'test-user-id-$index'), - ); + expect(channel.state?.unreadCount, equals(2)); + }); + + group("should not update 'unreadCount'", () { + test( + 'when the message is silent', + () async { + expect(channel.state?.unreadCount, equals(0)); - when(() => client.queryMembers( - channelType, - channelId: channelId, - filter: filter, - members: any(named: 'members'), - sort: any(named: 'sort'), - pagination: any(named: 'pagination'), - )).thenAnswer((_) async => QueryMembersResponse()..members = members); + final message = Message( + id: 'test-message-id', + silent: true, + user: User(id: 'other-user'), + createdAt: initialLastMessageAt.add(const Duration(seconds: 3)), + ); - final res = await channel.queryMembers(filter: filter); + final newMessageEvent = createNewMessageEvent(message); + client.addEvent(newMessageEvent); - expect(res, isNotNull); - expect(res.members.length, members.length); + // Wait for the event to get processed + await Future.delayed(Duration.zero); - verify(() => client.queryMembers( - channelType, - channelId: channelId, - filter: filter, - members: any(named: 'members'), - sort: any(named: 'sort'), - pagination: any(named: 'pagination'), - )).called(1); - }); + expect(channel.state?.unreadCount, equals(0)); + }, + ); - test('`.queryBannedUsers`', () async { - final filter = Filter.equal('channel_cid', channelCid); + test( + 'when the message is shadowed', + () async { + expect(channel.state?.unreadCount, equals(0)); - final bans = List.generate( - 3, - (index) => BannedUser( - user: User(id: 'test-user-id-$index'), - bannedBy: User(id: 'test-user-id-${index + 1}'), - ), - ); + final message = Message( + id: 'test-message-id', + shadowed: true, + user: User(id: 'other-user'), + createdAt: initialLastMessageAt.add(const Duration(seconds: 3)), + ); - when(() => client.queryBannedUsers( - filter: filter, - sort: any(named: 'sort'), - pagination: any(named: 'pagination'), - )).thenAnswer((_) async => QueryBannedUsersResponse()..bans = bans); + final newMessageEvent = createNewMessageEvent(message); + client.addEvent(newMessageEvent); - final res = await channel.queryBannedUsers(); + // Wait for the event to get processed + await Future.delayed(Duration.zero); - expect(res, isNotNull); - expect(res.bans.length, bans.length); + expect(channel.state?.unreadCount, equals(0)); + }, + ); - verify(() => client.queryBannedUsers( - filter: filter, - sort: any(named: 'sort'), - pagination: any(named: 'pagination'), - )).called(1); - }); + test( + 'when the message type is ephemeral', + () async { + expect(channel.state?.unreadCount, equals(0)); - test('`.mute`', () async { - when(() => client.muteChannel( - channelCid, - expiration: any(named: 'expiration'), - )).thenAnswer((_) async => EmptyResponse()); + final message = Message( + id: 'test-message-id', + type: MessageType.ephemeral, + user: User(id: 'other-user'), + createdAt: initialLastMessageAt.add(const Duration(seconds: 3)), + ); - final res = await channel.mute(); + final newMessageEvent = createNewMessageEvent(message); + client.addEvent(newMessageEvent); - expect(res, isNotNull); + // Wait for the event to get processed + await Future.delayed(Duration.zero); - verify(() => client.muteChannel( - channelCid, - expiration: any(named: 'expiration'), - )).called(1); - }); + expect(channel.state?.unreadCount, equals(0)); + }, + ); - test('`.mute with expiration`', () async { - const expiration = Duration(seconds: 3); + test( + 'when the message is a thread reply', + () async { + expect(channel.state?.unreadCount, equals(0)); - when(() => client.muteChannel( - channelCid, - expiration: expiration, - )).thenAnswer((_) async => EmptyResponse()); + final message = Message( + id: 'test-message-id', + parentId: 'test-parent-id', + showInChannel: false, + user: User(id: 'other-user'), + createdAt: initialLastMessageAt.add(const Duration(seconds: 3)), + ); - when(() => client.unmuteChannel(channelCid)) - .thenAnswer((_) async => EmptyResponse()); + final newMessageEvent = createNewMessageEvent(message); + client.addEvent(newMessageEvent); - final res = await channel.mute(expiration: expiration); + // Wait for the event to get processed + await Future.delayed(Duration.zero); - expect(res, isNotNull); + expect(channel.state?.unreadCount, equals(0)); + }, + ); - verify(() => client.muteChannel( - channelCid, - expiration: expiration, - )).called(1); + test( + 'when the message is a thread reply', + () async { + expect(channel.state?.unreadCount, equals(0)); - // wait for expiration - await Future.delayed(expiration); - verify(() => client.unmuteChannel(channelCid)).called(1); - }); + final message = Message( + id: 'test-message-id', + parentId: 'test-parent-id', + showInChannel: false, + user: User(id: 'other-user'), + createdAt: initialLastMessageAt.add(const Duration(seconds: 3)), + ); - test('`.unmute`', () async { - when( - () => client.unmuteChannel(channelCid), - ).thenAnswer((_) async => EmptyResponse()); + final newMessageEvent = createNewMessageEvent(message); + client.addEvent(newMessageEvent); - final res = await channel.unmute(); + // Wait for the event to get processed + await Future.delayed(Duration.zero); - expect(res, isNotNull); + expect(channel.state?.unreadCount, equals(0)); + }, + ); - verify( - () => client.unmuteChannel(channelCid), - ).called(1); - }); + test( + 'when the message is from the current user', + () async { + expect(channel.state?.unreadCount, equals(0)); - test('`.enableSlowMode`', () async { - const cooldown = 10; + final message = Message( + id: 'test-message-id', + user: client.state.currentUser, + createdAt: initialLastMessageAt.add(const Duration(seconds: 3)), + ); - final channelModel = ChannelModel( - cid: channelCid, - cooldown: cooldown, - ); + final newMessageEvent = createNewMessageEvent(message); + client.addEvent(newMessageEvent); - when(() => client.enableSlowdown( - channelId, - channelType, - cooldown, - )).thenAnswer((_) async => PartialUpdateChannelResponse() - ..channel = channelModel); + // Wait for the event to get processed + await Future.delayed(Duration.zero); - final res = await channel.enableSlowMode(cooldownInterval: 10); + expect(channel.state?.unreadCount, equals(0)); + }, + ); - expect(res, isNotNull); + test( + 'when the message is not restricted for the current user', + () async { + expect(channel.state?.unreadCount, equals(0)); - verify(() => client.enableSlowdown( - channelId, - channelType, - cooldown, - )).called(1); - }); + final message = Message( + id: 'test-message-id', + user: User(id: 'other-user'), + createdAt: initialLastMessageAt.add(const Duration(seconds: 3)), + restrictedVisibility: const ['other-user-2'], + ); - test('`.disableSlowMode`', () async { - final channelModel = ChannelModel( - cid: channelCid, - ); + final newMessageEvent = createNewMessageEvent(message); + client.addEvent(newMessageEvent); - when(() => client.disableSlowdown( - channelId, - channelType, - )).thenAnswer((_) async => PartialUpdateChannelResponse() - ..channel = channelModel); + // Wait for the event to get processed + await Future.delayed(Duration.zero); - final res = await channel.disableSlowMode(); + expect(channel.state?.unreadCount, equals(0)); + }, + ); + }); - expect(res, isNotNull); + test( + 'should submit channel for delivery when message is received', + () async { + final message = Message( + id: 'test-message-id', + user: User(id: 'other-user'), + createdAt: initialLastMessageAt.add(const Duration(seconds: 3)), + ); - verify(() => client.disableSlowdown(channelId, channelType)).called(1); - }); + final newMessageEvent = createNewMessageEvent(message); + client.addEvent(newMessageEvent); - test('`.banUser`', () async { - const userId = 'test-user-id'; - const options = {'key': 'value'}; + // Wait for the event to get processed + await Future.delayed(Duration.zero); - when(() => client.banUser( - userId, - {'type': channelType, 'id': channelId, ...options}, - )).thenAnswer((_) async => EmptyResponse()); + // Verify submitForDelivery was called + verify( + () => client.channelDeliveryReporter.submitForDelivery([channel]), + ).called(1); + }, + ); + }, + ); - final res = await channel.banMember(userId, options); + group( + EventType.messageUpdated, + () { + const channelId = 'test-channel-id'; + const channelType = 'test-channel-type'; + late Channel channel; - expect(res, isNotNull); + setUp(() { + final channelState = _generateChannelState( + channelId, + channelType, + mockChannelConfig: true, + ownCapabilities: const [ChannelCapability.readEvents], + ); - verify(() => client.banUser( - userId, - {'type': channelType, 'id': channelId, ...options}, - )).called(1); - }); + channel = Channel.fromState(client, channelState); + }); - test('`.unbanUser`', () async { - const userId = 'test-user-id'; + tearDown(() => channel.dispose()); - when(() => client.unbanUser(userId, any())) - .thenAnswer((_) async => EmptyResponse()); + Event createUpdateMessageEvent(Message message) { + return Event( + cid: channel.cid, + type: EventType.messageUpdated, + message: message, + ); + } - final res = await channel.unbanMember(userId); + test( + "should update 'channel.state.pinnedMessages' and should add message to pinned messages only once if updatedMessage.pinned is true", + () async { + const messageId = 'test-message-id'; + final message = Message( + id: messageId, + user: client.state.currentUser, + pinned: true, + ); - expect(res, isNotNull); + final newMessageEvent = createUpdateMessageEvent(message); + client.addEvent(newMessageEvent); - verify(() => client.unbanUser(userId, any())).called(1); - }); + // Wait for the event to get processed + await Future.delayed(Duration.zero); + + expect(channel.state?.pinnedMessages.length, equals(1)); + expect(channel.state?.pinnedMessages.first.id, equals(messageId)); + }, + ); - test('`.shadowBan`', () async { - const userId = 'test-user-id'; - const options = {'key': 'value'}; + test( + 'should update pinned message itself if updatedMessage.pinned is true and message is already pinned', + () async { + const messageId = 'test-message-id'; + const oldText = 'Old text'; + const newText = 'New text'; + final message = Message( + id: messageId, + user: client.state.currentUser, + text: oldText, + pinned: true, + ); - when(() => client.shadowBan( - userId, - {'type': channelType, 'id': channelId, ...options}, - )).thenAnswer((_) async => EmptyResponse()); + final firstUpdateEvent = createUpdateMessageEvent(message); + client.addEvent(firstUpdateEvent); - final res = await channel.shadowBan(userId, options); + // Wait for the first event to get processed + await Future.delayed(Duration.zero); - expect(res, isNotNull); + expect(channel.state?.pinnedMessages.length, equals(1)); + expect(channel.state?.pinnedMessages.first.id, equals(messageId)); + expect(channel.state?.pinnedMessages.first.text, equals(oldText)); - verify(() => client.shadowBan( - userId, - {'type': channelType, 'id': channelId, ...options}, - )).called(1); - }); + final updatedMessage = message.copyWith(text: newText); + final secondUpdateEvent = createUpdateMessageEvent(updatedMessage); + client.addEvent(secondUpdateEvent); - test('`.removeShadowBan`', () async { - const userId = 'test-user-id'; + // Wait for the second event to get processed + await Future.delayed(Duration.zero); - when(() => client.removeShadowBan(userId, any())) - .thenAnswer((_) async => EmptyResponse()); + expect(channel.state?.pinnedMessages.length, equals(1)); + expect(channel.state?.pinnedMessages.first.id, equals(messageId)); + expect(channel.state?.pinnedMessages.first.text, equals(newText)); + }, + ); - final res = await channel.removeShadowBan(userId); + test( + "should update 'channel.state.pinnedMessages' and should add message to pinned messages " + 'and not unpin previous pinned message if updatedMessage.pinned is true and there is already another pinned message', + () async { + const firstMessageId = 'first-test-message-id'; + const secondMessageId = 'second-test-message-id'; + final firstMessage = Message( + id: firstMessageId, + user: client.state.currentUser, + pinned: true, + ); + final secondMessage = firstMessage.copyWith(id: secondMessageId); - expect(res, isNotNull); + final firstUpdateEvent = createUpdateMessageEvent(firstMessage); + client.addEvent(firstUpdateEvent); - verify(() => client.removeShadowBan(userId, any())).called(1); - }); + // Wait for the first event to get processed + await Future.delayed(Duration.zero); - test('`.hide`', () async { - const clearHistory = true; + expect(channel.state?.pinnedMessages.length, equals(1)); + expect( + channel.state?.pinnedMessages.first.id, + equals(firstMessageId), + ); - when(() => client.hideChannel( - channelId, - channelType, - clearHistory: clearHistory, - )).thenAnswer((_) async => EmptyResponse()); + final secondUpdateEvent = createUpdateMessageEvent(secondMessage); + client.addEvent(secondUpdateEvent); - final res = await channel.hide(clearHistory: clearHistory); + // Wait for the second event to get processed + await Future.delayed(Duration.zero); - expect(res, isNotNull); + expect(channel.state?.pinnedMessages.length, equals(2)); + expect( + channel.state?.pinnedMessages.first.id, + equals(firstMessageId), + ); + expect( + channel.state?.pinnedMessages[1].id, + equals(secondMessageId), + ); + }, + ); - verify(() => client.hideChannel( - channelId, - channelType, - clearHistory: clearHistory, - )).called(1); - }); + test( + "should update 'channel.state.pinnedMessages' and should remove message from pinned messages if updatedMessage.pinned is false", + () async { + const messageId = 'test-message-id'; + final pinnedMessage = Message( + id: messageId, + user: client.state.currentUser, + pinned: true, + ); - test('`.show`', () async { - when(() => client.showChannel(channelId, channelType)) - .thenAnswer((_) async => EmptyResponse()); + final pinEvent = createUpdateMessageEvent(pinnedMessage); + client.addEvent(pinEvent); - final res = await channel.show(); + // Wait for the pin event to get processed + await Future.delayed(Duration.zero); - expect(res, isNotNull); + expect(channel.state?.pinnedMessages.length, equals(1)); + expect(channel.state?.pinnedMessages.first.id, equals(messageId)); - verify(() => client.showChannel(channelId, channelType)).called(1); - }); + final unpinnedMessage = pinnedMessage.copyWith(pinned: false); + final unpinEvent = createUpdateMessageEvent(unpinnedMessage); + client.addEvent(unpinEvent); - // testing archiving - test('`.archive`', () async { - when(() => client.archiveChannel( - channelId: channelId, channelType: channelType)).thenAnswer( - (_) async => FakePartialUpdateMemberResponse(), - ); + // Wait for the unpin event to get processed + await Future.delayed(Duration.zero); - final res = await channel.archive(); + expect(channel.state?.pinnedMessages, isEmpty); + }, + ); + }, + ); - expect(res, isNotNull); + group('Member Events', () { + const channelId = 'test-channel-id'; + const channelType = 'test-channel-type'; + late Channel channel; - verify(() => client.archiveChannel( - channelId: channelId, channelType: channelType)).called(1); - }); + setUp(() { + final channelState = _generateChannelState(channelId, channelType); + channel = Channel.fromState(client, channelState); + }); - test('`.unarchive`', () async { - when(() => client.unarchiveChannel( - channelId: channelId, channelType: channelType)).thenAnswer( - (_) async => FakePartialUpdateMemberResponse(), - ); + tearDown(() { + channel.dispose(); + }); - final res = await channel.unarchive(); + test( + 'should update membership when member is updated and is current user', + () async { + final currentUser = client.state.currentUser; + final currentMember = Member(user: currentUser); + final now = DateTime.now(); - expect(res, isNotNull); + // Setup initial membership + channel.state?.updateChannelState( + channel.state!.channelState.copyWith( + members: [currentMember], + membership: currentMember, + ), + ); - verify(() => client.unarchiveChannel( - channelId: channelId, channelType: channelType)).called(1); - }); + // Verify initial state + expect(channel.membership, isNotNull); + expect(channel.membership?.channelRole, isNull); + expect(channel.membership?.isModerator, false); + expect(channel.isPinned, isFalse); + expect(channel.isArchived, isFalse); - // testing pinning - test('`.pin`', () async { - when(() => - client.pinChannel(channelId: channelId, channelType: channelType)) - .thenAnswer((_) async => FakePartialUpdateMemberResponse()); + // Create updated member with same userId but updated properties + final updatedMember = currentMember.copyWith( + channelRole: 'moderator', + isModerator: true, + pinnedAt: now, + archivedAt: now, + ); - final res = await channel.pin(); + // Create member updated event + final memberUpdatedEvent = Event( + cid: channel.cid, + type: EventType.memberUpdated, + user: currentUser, + member: updatedMember, + ); - expect(res, isNotNull); + // Dispatch event + client.addEvent(memberUpdatedEvent); - verify(() => - client.pinChannel(channelId: channelId, channelType: channelType)) - .called(1); - }); + // Wait for the event to be processed + await Future.delayed(Duration.zero); - test('`.unpin`', () async { - when(() => client.unpinChannel( - channelId: channelId, channelType: channelType)) - .thenAnswer((_) async => FakePartialUpdateMemberResponse()); + // Verify membership is updated with new properties + expect(channel.membership, isNotNull); + expect(channel.membership?.userId, equals(currentUser?.id)); + expect(channel.membership?.channelRole, equals('moderator')); + expect(channel.membership?.isModerator, isTrue); + expect(channel.isPinned, isTrue); + expect(channel.isArchived, isTrue); + }, + ); - final res = await channel.unpin(); + test( + 'should update membership user when any event containing user is updated', + () async { + final currentUser = client.state.currentUser; + final currentMember = Member(user: currentUser); - expect(res, isNotNull); + // Setup initial membership + channel.state?.updateChannelState( + channel.state!.channelState.copyWith( + members: [currentMember], + membership: currentMember, + ), + ); - verify(() => client.unpinChannel( - channelId: channelId, channelType: channelType)).called(1); - }); + // Verify initial state + expect(channel.membership, isNotNull); + expect(channel.membership?.user?.id, equals(currentUser?.id)); + expect(channel.membership?.user?.role, equals(currentUser?.role)); - test('`.on`', () async { - const eventType = 'test.event'; - final event = Event(type: eventType, cid: channelCid); + // Create updated user with same userId but updated properties + final updatedUser = currentUser?.copyWith(role: 'moderator'); - Future.microtask(() => client.addEvent(event)); + // Create any event with same updated user as membership. + final anyEvent = Event( + cid: channel.cid, + type: EventType.any, + user: updatedUser, + ); - return expectLater(channel.on(eventType), emitsInOrder([event])); - }); + // Dispatch event + client.addEvent(anyEvent); - group('stale error message cleanup', () { - final channelState = _generateChannelState(channelId, channelType); + // Wait for the event to be processed + await Future.delayed(Duration.zero); - final errorMessage = Message(type: MessageType.error); - final bouncedErrorMessage = Message( - type: MessageType.error, - moderation: const Moderation( - action: ModerationAction.bounce, - originalText: 'original text', - ), + // Verify membership is updated with new properties + expect(channel.membership, isNotNull); + expect(channel.membership?.user?.id, equals(updatedUser?.id)); + expect(channel.membership?.user?.role, equals(updatedUser?.role)); + }, ); + }); - // Test case: sending a message cleans up stale error messages - test('when sending a new message', () async { - // Channel with 2 error messages - final channel = Channel.fromState( - client, - channelState.copyWith( - messages: [errorMessage, bouncedErrorMessage], - ), + group('Read Events', () { + const channelId = 'test-channel-id'; + const channelType = 'test-channel-type'; + late Channel channel; + + setUp(() { + final channelState = _generateChannelState( + channelId, + channelType, + mockChannelConfig: true, ); - // Set up the mock response for sending message - final newMessage = Message(text: 'New message'); + channel = Channel.fromState(client, channelState); + }); - when(() => client.sendMessage(any(), channelId, channelType)) - .thenAnswer((_) async => SendMessageResponse() - ..message = newMessage.copyWith(state: MessageState.sent)); + tearDown(() { + channel.dispose(); + }); - // Send a new message - await channel.sendMessage(newMessage); - final messages = channel.state!.messages; + test('should update read state on message read event', () async { + final currentUser = User(id: 'test-user'); + final currentRead = Read( + user: currentUser, + lastRead: DateTime(2020), + unreadMessages: 10, + ); - // Verify the cleanup - expect(messages.length, 2); - expect(messages.any((m) => m.id == errorMessage.id), false); - expect(messages.any((m) => m.id == bouncedErrorMessage.id), true); - expect(messages.any((m) => m.id == newMessage.id), true); + // Setup initial read state + channel.state?.updateChannelState( + channel.state!.channelState.copyWith( + read: [currentRead], + ), + ); - verify(() => client.sendMessage(any(), channelId, channelType)); - }); - }); - }); + // Verify initial state + final read = channel.state?.read.first; + expect(read?.user.id, 'test-user'); + expect(read?.unreadMessages, 10); + expect(read?.lastReadMessageId, isNull); + expect(read?.lastRead.isAtSameMomentAs(DateTime(2020)), isTrue); - group('WS events', () { - late final client = MockStreamChatClient(); + // Create message read event + final messageReadEvent = Event( + cid: channel.cid, + type: EventType.messageRead, + user: currentUser, + createdAt: DateTime(2022), + unreadMessages: 0, + lastReadMessageId: 'message-123', + ); - setUpAll(() { - // Fallback values - registerFallbackValue(FakeMessage()); - registerFallbackValue(FakeAttachmentFile()); - registerFallbackValue(FakeEvent()); + // Dispatch event + client.addEvent(messageReadEvent); - // detached loggers - when(() => client.detachedLogger(any())).thenAnswer((invocation) { - final name = invocation.positionalArguments.first; - return _createLogger(name); + // Wait for event to be processed + await Future.delayed(Duration.zero); + + // Verify read state is updated + final updatedRead = channel.state?.read.first; + expect(updatedRead?.user.id, 'test-user'); + expect(updatedRead?.unreadMessages, 0); + expect(updatedRead?.lastReadMessageId, 'message-123'); + expect(updatedRead?.lastRead.isAtSameMomentAs(DateTime(2022)), isTrue); }); - final retryPolicy = RetryPolicy( - shouldRetry: (_, __, ___) => false, - delayFactor: Duration.zero, - ); - when(() => client.retryPolicy).thenReturn(retryPolicy); + test( + 'should add a new read state if not exist on message read event', + () async { + // Create the current read state + final currentUser = User(id: 'test-user'); - // fake clientState - final clientState = FakeClientState(); - when(() => client.state).thenReturn(clientState); + // Verify initial state + final read = channel.state?.read; + expect(read, isEmpty); - // client logger - when(() => client.logger).thenReturn(_createLogger('mock-client-logger')); + // Create mark read notification event + final markReadEvent = Event( + cid: channel.cid, + type: EventType.messageRead, + user: currentUser, + createdAt: DateTime(2022), + unreadMessages: 0, + lastReadMessageId: 'message-123', + ); - // mock channel delivery reporter - when( - () => client.channelDeliveryReporter.submitForDelivery(any()), - ).thenAnswer((_) async {}); - }); + // Dispatch event + client.addEvent(markReadEvent); - group( - '${EventType.messageNew} or ${EventType.notificationMessageNew}', - () { - final initialLastMessageAt = DateTime.now(); - const channelId = 'test-channel-id'; - const channelType = 'test-channel-type'; - late Channel channel; + // Wait for event to be processed + await Future.delayed(Duration.zero); - setUp(() { - final channelState = _generateChannelState( - channelId, - channelType, - mockChannelConfig: true, - ownCapabilities: const [ChannelCapability.readEvents], - lastMessageAt: initialLastMessageAt, - ); + // Verify read list has not changed + final updated = channel.state?.read; + expect(updated?.length, 1); + expect(updated?.any((r) => r.user.id == currentUser.id), isTrue); + }, + ); - channel = Channel.fromState(client, channelState); - }); + test('should update read state on notification mark unread event', () async { + // Create the current read state + final currentUser = User(id: 'test-user'); + final currentRead = Read( + user: currentUser, + lastRead: DateTime(2020), + unreadMessages: 10, + ); - tearDown(() => channel.dispose()); + // Setup initial read state + channel.state?.updateChannelState( + channel.state!.channelState.copyWith( + read: [currentRead], + ), + ); - Event createNewMessageEvent(Message message) { - return Event( - cid: channel.cid, - type: EventType.messageNew, - message: message, - ); - } + // Verify initial state + final read = channel.state?.read.first; + expect(read?.user.id, 'test-user'); + expect(read?.unreadMessages, 10); + expect(read?.lastReadMessageId, isNull); + expect(read?.lastRead.isAtSameMomentAs(DateTime(2020)), isTrue); - test( - "should update 'channel.lastMessageAt'", - () async { - expect(channel.lastMessageAt, equals(initialLastMessageAt)); + // Create mark unread notification event + final markUnreadEvent = Event( + cid: channel.cid, + type: EventType.notificationMarkUnread, + user: currentUser, + lastReadAt: DateTime(2019), + unreadMessages: 15, + lastReadMessageId: 'message-100', + ); - final message = Message( - id: 'test-message-id', - user: client.state.currentUser, - createdAt: initialLastMessageAt.add(const Duration(seconds: 3)), - ); + // Dispatch event + client.addEvent(markUnreadEvent); - final newMessageEvent = createNewMessageEvent(message); - client.addEvent(newMessageEvent); + // Wait for event to be processed + await Future.delayed(Duration.zero); - // Wait for the event to get processed - await Future.delayed(Duration.zero); + // Verify read state is updated + final updatedRead = channel.state?.read.first; + expect(updatedRead?.user.id, 'test-user'); + expect(updatedRead?.unreadMessages, 15); + expect(updatedRead?.lastReadMessageId, 'message-100'); + expect(updatedRead?.lastRead.isAtSameMomentAs(DateTime(2019)), isTrue); + }); - expect(channel.lastMessageAt, equals(message.createdAt)); - expect(channel.lastMessageAt, isNot(initialLastMessageAt)); - }, - ); + test( + 'should add a new read state if not exist on notification mark unread', + () async { + // Verify initial state + final read = channel.state?.read; + expect(read, isEmpty); - test( - "should update 'channel.lastMessageAt' when Message has restricted visibility only for the current user", - () async { - expect(channel.lastMessageAt, equals(initialLastMessageAt)); + // Create event for non-existing user + final markUnreadEvent = Event( + cid: channel.cid, + type: EventType.notificationMarkUnread, + user: User(id: 'non-existing-user'), + lastReadAt: DateTime(2019), + unreadMessages: 15, + lastReadMessageId: 'message-100', + ); - final message = Message( - id: 'test-message-id', - user: client.state.currentUser, - // Message is visible to the current user. - restrictedVisibility: [client.state.currentUser!.id], - createdAt: initialLastMessageAt.add(const Duration(seconds: 3)), - ); + // Dispatch event + client.addEvent(markUnreadEvent); - final newMessageEvent = createNewMessageEvent(message); - client.addEvent(newMessageEvent); + // Wait for event to be processed + await Future.delayed(Duration.zero); - // Wait for the event to get processed - await Future.delayed(Duration.zero); + // Verify read list has not changed + final updated = channel.state?.read; + expect(updated?.length, 1); + expect(updated?.any((r) => r.user.id == 'non-existing-user'), isTrue); + }, + ); - expect(channel.lastMessageAt, equals(message.createdAt)); - expect(channel.lastMessageAt, isNot(initialLastMessageAt)); - }, - ); + test( + 'should preserve delivery info on message read event', + () async { + final currentUser = User(id: 'test-user'); + final currentRead = Read( + user: currentUser, + lastRead: DateTime(2020), + unreadMessages: 10, + lastDeliveredAt: DateTime(2021), + lastDeliveredMessageId: 'delivered-msg-456', + ); - test( - "should not update 'channel.lastMessageAt' when 'message.createdAt' is older", - () async { - expect(channel.lastMessageAt, equals(initialLastMessageAt)); + // Setup initial read state with delivery info + channel.state?.updateChannelState( + channel.state!.channelState.copyWith( + read: [currentRead], + ), + ); - final message = Message( - id: 'test-message-id', - user: client.state.currentUser, - // Older than the current 'channel.lastMessageAt'. - createdAt: initialLastMessageAt.subtract(const Duration(days: 1)), - ); + // Verify initial state + final read = channel.state?.read.first; + expect(read?.lastDeliveredAt, isNotNull); + expect( + read?.lastDeliveredAt?.isAtSameMomentAs(DateTime(2021)), + isTrue, + ); + expect(read?.lastDeliveredMessageId, 'delivered-msg-456'); - final newMessageEvent = createNewMessageEvent(message); - client.addEvent(newMessageEvent); + // Create message read event (doesn't include delivery info) + final messageReadEvent = Event( + cid: channel.cid, + type: EventType.messageRead, + user: currentUser, + createdAt: DateTime(2022), + unreadMessages: 0, + lastReadMessageId: 'message-123', + ); - // Wait for the event to get processed - await Future.delayed(Duration.zero); + // Dispatch event + client.addEvent(messageReadEvent); - expect(channel.lastMessageAt, isNot(message.createdAt)); - expect(channel.lastMessageAt, equals(initialLastMessageAt)); - }, - ); + // Wait for event to be processed + await Future.delayed(Duration.zero); - test( - "should not update 'channel.lastMessageAt' when Message is shadowed", - () async { - expect(channel.lastMessageAt, equals(initialLastMessageAt)); + // Verify read state is updated but delivery info is preserved + final updatedRead = channel.state?.read.first; + expect(updatedRead?.user.id, 'test-user'); + expect(updatedRead?.unreadMessages, 0); + expect(updatedRead?.lastReadMessageId, 'message-123'); + expect( + updatedRead?.lastRead.isAtSameMomentAs(DateTime(2022)), + isTrue, + ); + // Delivery info should be preserved + expect(updatedRead?.lastDeliveredAt, isNotNull); + expect( + updatedRead?.lastDeliveredAt?.isAtSameMomentAs(DateTime(2021)), + isTrue, + ); + expect(updatedRead?.lastDeliveredMessageId, 'delivered-msg-456'); + }, + ); - final message = Message( - id: 'test-message-id', - user: client.state.currentUser, - shadowed: true, - createdAt: initialLastMessageAt.add(const Duration(seconds: 3)), - ); + test( + 'should reconcile delivery when message read event is from current user', + () async { + final currentUser = client.state.currentUser; + final updatedUser = currentUser?.copyWith(id: 'current-user-id'); - final newMessageEvent = createNewMessageEvent(message); - client.addEvent(newMessageEvent); + client.state.updateUser(updatedUser); + addTearDown(() => client.state.updateUser(currentUser)); - // Wait for the event to get processed - await Future.delayed(Duration.zero); + when( + () => client.channelDeliveryReporter.reconcileDelivery([channel]), + ).thenAnswer((_) => Future.value()); - expect(channel.lastMessageAt, isNot(message.createdAt)); - expect(channel.lastMessageAt, equals(initialLastMessageAt)); - }, - ); + // Create message read event from current user + final messageReadEvent = Event( + cid: channel.cid, + type: EventType.messageRead, + user: currentUser, + createdAt: DateTime(2022), + unreadMessages: 0, + lastReadMessageId: 'message-123', + ); - test( - "should not update 'channel.lastMessageAt' when Message is ephemeral", - () async { - expect(channel.lastMessageAt, equals(initialLastMessageAt)); + // Dispatch event + client.addEvent(messageReadEvent); - final message = Message( - type: MessageType.ephemeral, - id: 'test-message-id', - user: client.state.currentUser, - createdAt: initialLastMessageAt.add(const Duration(seconds: 3)), - ); + // Wait for event to be processed + await Future.delayed(Duration.zero); - final newMessageEvent = createNewMessageEvent(message); - client.addEvent(newMessageEvent); + // Verify reconcileDelivery was called + verify( + () => client.channelDeliveryReporter.reconcileDelivery([channel]), + ).called(1); + }, + ); - // Wait for the event to get processed - await Future.delayed(Duration.zero); + test('should update read state on message delivered event', () async { + final currentUser = User(id: 'test-user'); + final distantPast = DateTime.fromMillisecondsSinceEpoch(0, isUtc: true); + final currentRead = Read( + user: currentUser, + lastRead: distantPast, + unreadMessages: 5, + ); - expect(channel.lastMessageAt, isNot(message.createdAt)); - expect(channel.lastMessageAt, equals(initialLastMessageAt)); - }, + // Setup initial read state + channel.state?.updateChannelState( + channel.state!.channelState.copyWith( + read: [currentRead], + ), ); - test( - "should not update 'channel.lastMessageAt' when Message has restricted visibility but not for the current user", - () async { - expect(channel.lastMessageAt, equals(initialLastMessageAt)); + // Verify initial state has no delivery info + final read = channel.state?.read.first; + expect(read?.user.id, 'test-user'); + expect(read?.lastDeliveredAt, isNull); + expect(read?.lastDeliveredMessageId, isNull); - final message = Message( - id: 'test-message-id', - user: client.state.currentUser, - // Message is only visible to user-1 not the current user. - restrictedVisibility: const ['user-1'], - createdAt: initialLastMessageAt.add(const Duration(seconds: 3)), - ); + // Create message delivered event + final messageDeliveredEvent = Event( + cid: channel.cid, + type: EventType.messageDelivered, + user: currentUser, + lastDeliveredAt: DateTime(2022), + lastDeliveredMessageId: 'message-456', + ); - final newMessageEvent = createNewMessageEvent(message); - client.addEvent(newMessageEvent); + // Dispatch event + client.addEvent(messageDeliveredEvent); - // Wait for the event to get processed - await Future.delayed(Duration.zero); + // Wait for event to be processed + await Future.delayed(Duration.zero); - expect(channel.lastMessageAt, isNot(message.createdAt)); - expect(channel.lastMessageAt, equals(initialLastMessageAt)); - }, + // Verify delivery state is updated + final updatedRead = channel.state?.read.first; + expect(updatedRead?.user.id, 'test-user'); + expect(updatedRead?.lastDeliveredAt, isNotNull); + expect( + updatedRead?.lastDeliveredAt?.isAtSameMomentAs(DateTime(2022)), + isTrue, ); + expect(updatedRead?.lastDeliveredMessageId, 'message-456'); + }); - test( - "should not update 'channel.lastMessageAt' when Message is system and skip is enabled", - () async { - expect(channel.lastMessageAt, equals(initialLastMessageAt)); + test( + 'should add a new read state if not exist on message delivered event', + () async { + final newUser = User(id: 'new-user'); + final distantPast = DateTime.fromMillisecondsSinceEpoch(0, isUtc: true); - when( - () => channel.config?.skipLastMsgUpdateForSystemMsgs, - ).thenReturn(true); + // Verify initial state + final read = channel.state?.read; + expect(read, isEmpty); - final message = Message( - type: MessageType.system, - id: 'test-message-id', - user: client.state.currentUser, - createdAt: initialLastMessageAt.add(const Duration(seconds: 3)), - ); + // Create message delivered event for new user + final messageDeliveredEvent = Event( + cid: channel.cid, + type: EventType.messageDelivered, + user: newUser, + lastDeliveredAt: DateTime(2022), + lastDeliveredMessageId: 'message-789', + ); - final newMessageEvent = createNewMessageEvent(message); - client.addEvent(newMessageEvent); + // Dispatch event + client.addEvent(messageDeliveredEvent); - // Wait for the event to get processed - await Future.delayed(Duration.zero); + // Wait for event to be processed + await Future.delayed(Duration.zero); - expect(channel.lastMessageAt, isNot(message.createdAt)); - expect(channel.lastMessageAt, equals(initialLastMessageAt)); - }, - ); + // Verify read state was created with delivery info + final updated = channel.state?.read; + expect(updated?.length, 1); + final newRead = updated?.first; + expect(newRead?.user.id, 'new-user'); + expect(newRead?.lastDeliveredAt, isNotNull); + expect( + newRead?.lastDeliveredAt?.isAtSameMomentAs(DateTime(2022)), + isTrue, + ); + expect(newRead?.lastDeliveredMessageId, 'message-789'); + // lastRead should default to distantPast + expect( + newRead?.lastRead.isAtSameMomentAs(distantPast), + isTrue, + ); + }, + ); + + test( + 'should preserve read info on message delivered event', + () async { + final currentUser = User(id: 'test-user'); + final currentRead = Read( + user: currentUser, + lastRead: DateTime(2020), + unreadMessages: 10, + lastReadMessageId: 'read-msg-123', + ); + + // Setup initial read state + channel.state?.updateChannelState( + channel.state!.channelState.copyWith( + read: [currentRead], + ), + ); - test("should update 'unreadCount'", () async { - expect(channel.state?.unreadCount, equals(0)); + // Verify initial state + final read = channel.state?.read.first; + expect(read?.lastRead.isAtSameMomentAs(DateTime(2020)), isTrue); + expect(read?.unreadMessages, 10); + expect(read?.lastReadMessageId, 'read-msg-123'); - final message = Message( - id: 'test-message-id', - user: User(id: 'other-user'), - createdAt: initialLastMessageAt.add(const Duration(seconds: 3)), + // Create message delivered event (doesn't include read info) + final messageDeliveredEvent = Event( + cid: channel.cid, + type: EventType.messageDelivered, + user: currentUser, + lastDeliveredAt: DateTime(2022), + lastDeliveredMessageId: 'delivered-msg-456', ); - final newMessageEvent = createNewMessageEvent(message); - client.addEvent(newMessageEvent); + // Dispatch event + client.addEvent(messageDeliveredEvent); - // Wait for the event to get processed + // Wait for event to be processed await Future.delayed(Duration.zero); - expect(channel.state?.unreadCount, equals(1)); - - final message2 = Message( - id: 'test-message-id-2', - user: User(id: 'other-user'), - createdAt: message.createdAt.add(const Duration(seconds: 3)), + // Verify delivery state is updated but read info is preserved + final updatedRead = channel.state?.read.first; + expect(updatedRead?.user.id, 'test-user'); + expect( + updatedRead?.lastDeliveredAt?.isAtSameMomentAs(DateTime(2022)), + isTrue, + ); + expect(updatedRead?.lastDeliveredMessageId, 'delivered-msg-456'); + // Read info should be preserved + expect( + updatedRead?.lastRead.isAtSameMomentAs(DateTime(2020)), + isTrue, ); + expect(updatedRead?.unreadMessages, 10); + expect(updatedRead?.lastReadMessageId, 'read-msg-123'); + }, + ); - final newMessage2Event = createNewMessageEvent(message2); - client.addEvent(newMessage2Event); + test( + 'should reconcile delivery when message delivered event is from current user', + () async { + final currentUser = client.state.currentUser; + final updatedUser = currentUser?.copyWith(id: 'current-user-id'); - // Wait for the event to get processed - await Future.delayed(Duration.zero); + client.state.updateUser(updatedUser); + addTearDown(() => client.state.updateUser(currentUser)); - expect(channel.state?.unreadCount, equals(2)); - }); + when( + () => client.channelDeliveryReporter.reconcileDelivery([channel]), + ).thenAnswer((_) => Future.value()); - group("should not update 'unreadCount'", () { - test( - 'when the message is silent', - () async { - expect(channel.state?.unreadCount, equals(0)); + // Create message delivered event from current user + final messageDeliveredEvent = Event( + cid: channel.cid, + type: EventType.messageDelivered, + user: currentUser, + lastDeliveredAt: DateTime(2022), + lastDeliveredMessageId: 'message-456', + ); - final message = Message( - id: 'test-message-id', - silent: true, - user: User(id: 'other-user'), - createdAt: initialLastMessageAt.add(const Duration(seconds: 3)), - ); + // Dispatch event + client.addEvent(messageDeliveredEvent); - final newMessageEvent = createNewMessageEvent(message); - client.addEvent(newMessageEvent); + // Wait for event to be processed + await Future.delayed(Duration.zero); - // Wait for the event to get processed - await Future.delayed(Duration.zero); + // Verify reconcileDelivery was called + verify( + () => client.channelDeliveryReporter.reconcileDelivery([channel]), + ).called(1); + }, + ); + }); - expect(channel.state?.unreadCount, equals(0)); - }, - ); + group('Draft events', () { + const channelId = 'test-channel-id'; + const channelType = 'test-channel-type'; + late Channel channel; - test( - 'when the message is shadowed', - () async { - expect(channel.state?.unreadCount, equals(0)); + setUp(() { + final channelState = _generateChannelState(channelId, channelType); + channel = Channel.fromState(client, channelState); + }); - final message = Message( - id: 'test-message-id', - shadowed: true, - user: User(id: 'other-user'), - createdAt: initialLastMessageAt.add(const Duration(seconds: 3)), - ); + tearDown(() { + channel.dispose(); + }); - final newMessageEvent = createNewMessageEvent(message); - client.addEvent(newMessageEvent); + test('should handle draft.updated event for channel drafts', () async { + // Verify initial state + expect(channel.state?.draft, isNull); - // Wait for the event to get processed - await Future.delayed(Duration.zero); + // Create Draft + final draft = Draft( + channelCid: channel.cid!, + createdAt: DateTime.now(), + message: DraftMessage(text: 'test message'), + ); - expect(channel.state?.unreadCount, equals(0)); - }, - ); + // Create draft.updated event + final draftUpdatedEvent = Event( + cid: channel.cid, + type: EventType.draftUpdated, + draft: draft, + ); - test( - 'when the message type is ephemeral', - () async { - expect(channel.state?.unreadCount, equals(0)); + // Dispatch event + client.addEvent(draftUpdatedEvent); - final message = Message( - id: 'test-message-id', - type: MessageType.ephemeral, - user: User(id: 'other-user'), - createdAt: initialLastMessageAt.add(const Duration(seconds: 3)), - ); + // Wait for the event to be processed + await Future.delayed(Duration.zero); - final newMessageEvent = createNewMessageEvent(message); - client.addEvent(newMessageEvent); + // Verify channel draft was updated + expect(channel.state?.draft, isNotNull); + expect(channel.state?.draft?.message.text, 'test message'); + }); - // Wait for the event to get processed - await Future.delayed(Duration.zero); + test('should handle draft.updated event for thread drafts', () async { + const threadParentMessageId = 'thread-parent-id'; - expect(channel.state?.unreadCount, equals(0)); - }, - ); + // Setup initial state with a regular message + channel.state?.updateMessage( + Message( + id: threadParentMessageId, + user: client.state.currentUser, + ), + ); - test( - 'when the message is a thread reply', - () async { - expect(channel.state?.unreadCount, equals(0)); + // Verify initial state + expect(channel.state?.threadDraft(threadParentMessageId), isNull); - final message = Message( - id: 'test-message-id', - parentId: 'test-parent-id', - showInChannel: false, - user: User(id: 'other-user'), - createdAt: initialLastMessageAt.add(const Duration(seconds: 3)), - ); + // Create thread Draft + final draft = Draft( + channelCid: channel.cid!, + createdAt: DateTime.now(), + parentId: threadParentMessageId, + message: DraftMessage(text: 'thread reply'), + ); - final newMessageEvent = createNewMessageEvent(message); - client.addEvent(newMessageEvent); + // Create draft.updated event + final draftUpdatedEvent = Event( + cid: channel.cid, + type: EventType.draftUpdated, + draft: draft, + ); - // Wait for the event to get processed - await Future.delayed(Duration.zero); + // Dispatch event + client.addEvent(draftUpdatedEvent); - expect(channel.state?.unreadCount, equals(0)); - }, - ); + // Wait for the event to be processed + await Future.delayed(Duration.zero); - test( - 'when the message is a thread reply', - () async { - expect(channel.state?.unreadCount, equals(0)); + // Verify thread draft was updated + final threadDraft = channel.state?.threadDraft(threadParentMessageId); + expect(threadDraft, isNotNull); + expect(threadDraft?.message.text, 'thread reply'); + }); - final message = Message( - id: 'test-message-id', - parentId: 'test-parent-id', - showInChannel: false, - user: User(id: 'other-user'), - createdAt: initialLastMessageAt.add(const Duration(seconds: 3)), - ); + test('should handle draft.deleted event for channel drafts', () async { + // Setup initial state with a draft + channel.state?.updateChannelState( + channel.state!.channelState.copyWith( + draft: Draft( + channelCid: channel.cid!, + createdAt: DateTime.now(), + message: DraftMessage(text: 'test message'), + ), + ), + ); - final newMessageEvent = createNewMessageEvent(message); - client.addEvent(newMessageEvent); + // Verify initial state + final draft = channel.state?.draft; + expect(draft, isNotNull); + expect(draft?.message.text, 'test message'); - // Wait for the event to get processed - await Future.delayed(Duration.zero); + // Create draft.deleted event + final draftUpdatedEvent = Event( + cid: channel.cid, + type: EventType.draftDeleted, + draft: draft, + ); - expect(channel.state?.unreadCount, equals(0)); - }, - ); + // Dispatch event + client.addEvent(draftUpdatedEvent); - test( - 'when the message is from the current user', - () async { - expect(channel.state?.unreadCount, equals(0)); + // Wait for the event to be processed + await Future.delayed(Duration.zero); - final message = Message( - id: 'test-message-id', - user: client.state.currentUser, - createdAt: initialLastMessageAt.add(const Duration(seconds: 3)), - ); + // Verify channel draft was updated + expect(channel.state?.draft, isNull); + }); - final newMessageEvent = createNewMessageEvent(message); - client.addEvent(newMessageEvent); + test('should handle draft.deleted event for thread drafts', () async { + const threadParentMessageId = 'thread-parent-id'; - // Wait for the event to get processed - await Future.delayed(Duration.zero); + // Setup initial state with a thread draft + channel.state?.updateMessage( + Message( + id: threadParentMessageId, + user: client.state.currentUser, + draft: Draft( + channelCid: channel.cid!, + createdAt: DateTime.now(), + parentId: threadParentMessageId, + message: DraftMessage(text: 'thread reply'), + ), + ), + ); - expect(channel.state?.unreadCount, equals(0)); - }, - ); + // Verify initial state + final threadDraft = channel.state?.threadDraft(threadParentMessageId); + expect(threadDraft, isNotNull); + expect(threadDraft?.message.text, 'thread reply'); - test( - 'when the message is not restricted for the current user', - () async { - expect(channel.state?.unreadCount, equals(0)); + // Create draft.deleted event + final draftDeletedEvent = Event( + cid: channel.cid, + type: EventType.draftDeleted, + draft: threadDraft, + ); - final message = Message( - id: 'test-message-id', - user: User(id: 'other-user'), - createdAt: initialLastMessageAt.add(const Duration(seconds: 3)), - restrictedVisibility: const ['other-user-2'], - ); + // Dispatch event + client.addEvent(draftDeletedEvent); - final newMessageEvent = createNewMessageEvent(message); - client.addEvent(newMessageEvent); + // Allow event to be processed + await Future.delayed(Duration.zero); - // Wait for the event to get processed - await Future.delayed(Duration.zero); + // Verify thread draft was removed + expect(channel.state?.threadDraft(threadParentMessageId), isNull); + }); - expect(channel.state?.unreadCount, equals(0)); - }, + test( + 'should update current channel draft if draft.updated event is emitted', + () async { + // Setup initial state with a draft + final initialDraft = Draft( + channelCid: channel.cid!, + createdAt: DateTime.now(), + message: DraftMessage(text: 'test message'), ); - }); - test( - 'should submit channel for delivery when message is received', - () async { - final message = Message( - id: 'test-message-id', - user: User(id: 'other-user'), - createdAt: initialLastMessageAt.add(const Duration(seconds: 3)), - ); + channel.state?.updateChannelState( + channel.state!.channelState.copyWith( + draft: initialDraft, + ), + ); - final newMessageEvent = createNewMessageEvent(message); - client.addEvent(newMessageEvent); + // Verify initial state + expect(channel.state?.draft, isNotNull); + expect(channel.state?.draft?.message.text, 'test message'); - // Wait for the event to get processed - await Future.delayed(Duration.zero); + // Create Draft + final updatedDraft = initialDraft.copyWith( + message: DraftMessage(text: 'updated message'), + ); - // Verify submitForDelivery was called - verify( - () => client.channelDeliveryReporter.submitForDelivery([channel]), - ).called(1); - }, - ); - }, - ); + // Create draft.updated event + final draftUpdatedEvent = Event( + cid: channel.cid, + type: EventType.draftUpdated, + draft: updatedDraft, + ); - group( - EventType.messageUpdated, - () { - const channelId = 'test-channel-id'; - const channelType = 'test-channel-type'; - late Channel channel; + // Dispatch event + client.addEvent(draftUpdatedEvent); - setUp(() { - final channelState = _generateChannelState( - channelId, - channelType, - mockChannelConfig: true, - ownCapabilities: const [ChannelCapability.readEvents], - ); + // Wait for the event to be processed + await Future.delayed(Duration.zero); - channel = Channel.fromState(client, channelState); - }); + // Verify channel draft was updated + expect(channel.state?.draft, isNotNull); + expect(channel.state?.draft?.message.text, 'updated message'); + }, + ); - tearDown(() => channel.dispose()); + test( + 'should update current thread draft if draft.updated event is emitted', + () async { + const threadParentMessageId = 'thread-parent-id'; - Event createUpdateMessageEvent(Message message) { - return Event( - cid: channel.cid, - type: EventType.messageUpdated, - message: message, + // Setup initial state with a thread draft + final initialDraft = Draft( + channelCid: channel.cid!, + createdAt: DateTime.now(), + parentId: threadParentMessageId, + message: DraftMessage(text: 'thread reply'), ); - } - test( - "should update 'channel.state.pinnedMessages' and should add message to pinned messages only once if updatedMessage.pinned is true", - () async { - const messageId = 'test-message-id'; - final message = Message( - id: messageId, + channel.state?.updateMessage( + Message( + id: threadParentMessageId, user: client.state.currentUser, - pinned: true, - ); + draft: initialDraft, + ), + ); - final newMessageEvent = createUpdateMessageEvent(message); - client.addEvent(newMessageEvent); + // Verify initial state + final draft = channel.state?.threadDraft(threadParentMessageId); + expect(draft, isNotNull); + expect(draft?.message.text, 'thread reply'); - // Wait for the event to get processed - await Future.delayed(Duration.zero); + // Create Draft + final updatedDraft = initialDraft.copyWith( + message: DraftMessage(text: 'updated thread reply'), + ); - expect(channel.state?.pinnedMessages.length, equals(1)); - expect(channel.state?.pinnedMessages.first.id, equals(messageId)); - }, - ); + // Create draft.updated event + final draftUpdatedEvent = Event( + cid: channel.cid, + type: EventType.draftUpdated, + draft: updatedDraft, + ); - test( - 'should update pinned message itself if updatedMessage.pinned is true and message is already pinned', - () async { - const messageId = 'test-message-id'; - const oldText = 'Old text'; - const newText = 'New text'; - final message = Message( - id: messageId, - user: client.state.currentUser, - text: oldText, - pinned: true, - ); + // Dispatch event + client.addEvent(draftUpdatedEvent); - final firstUpdateEvent = createUpdateMessageEvent(message); - client.addEvent(firstUpdateEvent); + // Wait for the event to be processed + await Future.delayed(Duration.zero); - // Wait for the first event to get processed - await Future.delayed(Duration.zero); + // Verify thread draft was updated + final threadDraft = channel.state?.threadDraft(threadParentMessageId); + expect(threadDraft, isNotNull); + expect(threadDraft?.message.text, 'updated thread reply'); + }, + ); + }); - expect(channel.state?.pinnedMessages.length, equals(1)); - expect(channel.state?.pinnedMessages.first.id, equals(messageId)); - expect(channel.state?.pinnedMessages.first.text, equals(oldText)); + group('Reminder events', () { + const channelId = 'test-channel-id'; + const channelType = 'test-channel-type'; + late Channel channel; - final updatedMessage = message.copyWith(text: newText); - final secondUpdateEvent = createUpdateMessageEvent(updatedMessage); - client.addEvent(secondUpdateEvent); + setUp(() { + final channelState = _generateChannelState(channelId, channelType); + channel = Channel.fromState(client, channelState); + }); - // Wait for the second event to get processed - await Future.delayed(Duration.zero); + tearDown(() { + channel.dispose(); + }); - expect(channel.state?.pinnedMessages.length, equals(1)); - expect(channel.state?.pinnedMessages.first.id, equals(messageId)); - expect(channel.state?.pinnedMessages.first.text, equals(newText)); - }, + test('should handle reminder.created event', () async { + const messageId = 'test-message-id'; + + // Setup initial state with a message without reminder + final message = Message( + id: messageId, + user: client.state.currentUser, + text: 'Test message', ); - test( - "should update 'channel.state.pinnedMessages' and should add message to pinned messages " - 'and not unpin previous pinned message if updatedMessage.pinned is true and there is already another pinned message', - () async { - const firstMessageId = 'first-test-message-id'; - const secondMessageId = 'second-test-message-id'; - final firstMessage = Message( - id: firstMessageId, - user: client.state.currentUser, - pinned: true, - ); - final secondMessage = firstMessage.copyWith(id: secondMessageId); + channel.state?.updateMessage(message); - final firstUpdateEvent = createUpdateMessageEvent(firstMessage); - client.addEvent(firstUpdateEvent); + // Verify initial state - no reminder + final initialMessage = channel.state?.messages.firstWhere( + (m) => m.id == messageId, + ); + expect(initialMessage?.reminder, isNull); - // Wait for the first event to get processed - await Future.delayed(Duration.zero); + // Create reminder + final reminder = MessageReminder( + messageId: messageId, + channelCid: channel.cid!, + userId: 'test-user-id', + remindAt: DateTime.now().add(const Duration(days: 30)), + ); - expect(channel.state?.pinnedMessages.length, equals(1)); - expect( - channel.state?.pinnedMessages.first.id, - equals(firstMessageId), - ); + // Create reminder.created event + final reminderCreatedEvent = Event( + cid: channel.cid, + type: EventType.reminderCreated, + reminder: reminder, + ); - final secondUpdateEvent = createUpdateMessageEvent(secondMessage); - client.addEvent(secondUpdateEvent); + // Dispatch event + client.addEvent(reminderCreatedEvent); - // Wait for the second event to get processed - await Future.delayed(Duration.zero); + // Wait for the event to be processed + await Future.delayed(Duration.zero); - expect(channel.state?.pinnedMessages.length, equals(2)); - expect( - channel.state?.pinnedMessages.first.id, - equals(firstMessageId), - ); - expect( - channel.state?.pinnedMessages[1].id, - equals(secondMessageId), - ); - }, + // Verify message reminder was added + final updatedMessage = channel.state?.messages.firstWhere( + (m) => m.id == messageId, ); + expect(updatedMessage?.reminder, isNotNull); + expect(updatedMessage?.reminder?.messageId, messageId); + expect(updatedMessage?.reminder?.remindAt, reminder.remindAt); + }); - test( - "should update 'channel.state.pinnedMessages' and should remove message from pinned messages if updatedMessage.pinned is false", - () async { - const messageId = 'test-message-id'; - final pinnedMessage = Message( - id: messageId, - user: client.state.currentUser, - pinned: true, - ); + test('should handle reminder.updated event', () async { + const messageId = 'test-message-id'; - final pinEvent = createUpdateMessageEvent(pinnedMessage); - client.addEvent(pinEvent); + // Setup initial state with a message with existing reminder + final remindAt = DateTime.now().add(const Duration(days: 30)); + final initialReminder = MessageReminder( + messageId: messageId, + channelCid: channel.cid!, + userId: 'test-user-id', + remindAt: remindAt, + ); - // Wait for the pin event to get processed - await Future.delayed(Duration.zero); + final message = Message( + id: messageId, + user: client.state.currentUser, + text: 'Test message', + reminder: initialReminder, + ); - expect(channel.state?.pinnedMessages.length, equals(1)); - expect(channel.state?.pinnedMessages.first.id, equals(messageId)); + channel.state?.updateMessage(message); - final unpinnedMessage = pinnedMessage.copyWith(pinned: false); - final unpinEvent = createUpdateMessageEvent(unpinnedMessage); - client.addEvent(unpinEvent); + // Verify initial state + final initialMessage = channel.state?.messages.firstWhere( + (m) => m.id == messageId, + ); + expect(initialMessage?.reminder, isNotNull); + expect(initialMessage?.reminder?.remindAt, remindAt); - // Wait for the unpin event to get processed - await Future.delayed(Duration.zero); + // Create updated reminder + final updatedRemindAt = remindAt.add(const Duration(days: 15)); + final updatedReminder = initialReminder.copyWith( + remindAt: updatedRemindAt, + updatedAt: DateTime.now(), + ); - expect(channel.state?.pinnedMessages, isEmpty); - }, + // Create reminder.updated event + final reminderUpdatedEvent = Event( + cid: channel.cid, + type: EventType.reminderUpdated, + reminder: updatedReminder, ); - }, - ); - group('Member Events', () { - const channelId = 'test-channel-id'; - const channelType = 'test-channel-type'; - late Channel channel; + // Dispatch event + client.addEvent(reminderUpdatedEvent); - setUp(() { - final channelState = _generateChannelState(channelId, channelType); - channel = Channel.fromState(client, channelState); - }); + // Wait for the event to be processed + await Future.delayed(Duration.zero); - tearDown(() { - channel.dispose(); + // Verify message reminder was updated + final updatedMessage = channel.state?.messages.firstWhere( + (m) => m.id == messageId, + ); + expect(updatedMessage?.reminder, isNotNull); + expect(updatedMessage?.reminder?.messageId, messageId); + expect(updatedMessage?.reminder?.remindAt, updatedRemindAt); }); - test( - 'should update membership when member is updated and is current user', - () async { - final currentUser = client.state.currentUser; - final currentMember = Member(user: currentUser); - final now = DateTime.now(); + test('should handle reminder.deleted event', () async { + const messageId = 'test-message-id'; - // Setup initial membership - channel.state?.updateChannelState( - channel.state!.channelState.copyWith( - members: [currentMember], - membership: currentMember, - ), - ); + // Setup initial state with a message with existing reminder + final remindAt = DateTime.now().add(const Duration(days: 30)); + final initialReminder = MessageReminder( + messageId: messageId, + channelCid: channel.cid!, + userId: 'test-user-id', + remindAt: remindAt, + ); - // Verify initial state - expect(channel.membership, isNotNull); - expect(channel.membership?.channelRole, isNull); - expect(channel.membership?.isModerator, false); - expect(channel.isPinned, isFalse); - expect(channel.isArchived, isFalse); + final message = Message( + id: messageId, + user: client.state.currentUser, + text: 'Test message', + reminder: initialReminder, + ); - // Create updated member with same userId but updated properties - final updatedMember = currentMember.copyWith( - channelRole: 'moderator', - isModerator: true, - pinnedAt: now, - archivedAt: now, - ); + channel.state?.updateMessage(message); - // Create member updated event - final memberUpdatedEvent = Event( - cid: channel.cid, - type: EventType.memberUpdated, - user: currentUser, - member: updatedMember, - ); + // Verify initial state + final initialMessage = channel.state?.messages.firstWhere( + (m) => m.id == messageId, + ); + expect(initialMessage?.reminder, isNotNull); - // Dispatch event - client.addEvent(memberUpdatedEvent); + // Create reminder.deleted event + final reminderDeletedEvent = Event( + cid: channel.cid, + type: EventType.reminderDeleted, + reminder: initialReminder, + ); - // Wait for the event to be processed - await Future.delayed(Duration.zero); + // Dispatch event + client.addEvent(reminderDeletedEvent); - // Verify membership is updated with new properties - expect(channel.membership, isNotNull); - expect(channel.membership?.userId, equals(currentUser?.id)); - expect(channel.membership?.channelRole, equals('moderator')); - expect(channel.membership?.isModerator, isTrue); - expect(channel.isPinned, isTrue); - expect(channel.isArchived, isTrue); - }, - ); + // Wait for the event to be processed + await Future.delayed(Duration.zero); - test( - 'should update membership user when any event containing user is updated', - () async { - final currentUser = client.state.currentUser; - final currentMember = Member(user: currentUser); + // Verify message reminder was removed + final updatedMessage = channel.state?.messages.firstWhere( + (m) => m.id == messageId, + ); + expect(updatedMessage?.reminder, isNull); + }); - // Setup initial membership - channel.state?.updateChannelState( - channel.state!.channelState.copyWith( - members: [currentMember], - membership: currentMember, - ), - ); + test('should handle reminder.created event for thread messages', () async { + const messageId = 'test-message-id'; + const parentId = 'test-parent-id'; - // Verify initial state - expect(channel.membership, isNotNull); - expect(channel.membership?.user?.id, equals(currentUser?.id)); - expect(channel.membership?.user?.role, equals(currentUser?.role)); + // Setup initial state with a thread message without reminder + final threadMessage = Message( + id: messageId, + parentId: parentId, + user: client.state.currentUser, + text: 'Thread message', + ); - // Create updated user with same userId but updated properties - final updatedUser = currentUser?.copyWith(role: 'moderator'); + channel.state?.updateMessage(threadMessage); - // Create any event with same updated user as membership. - final anyEvent = Event( - cid: channel.cid, - type: EventType.any, - user: updatedUser, - ); + // Verify initial state - no reminder + final initialMessage = channel.state?.threads[parentId]?.firstWhere( + (m) => m.id == messageId, + ); + expect(initialMessage?.reminder, isNull); - // Dispatch event - client.addEvent(anyEvent); + // Create reminder + final remindAt = DateTime.now().add(const Duration(days: 30)); + final reminder = MessageReminder( + messageId: messageId, + channelCid: channel.cid!, + userId: 'test-user-id', + remindAt: remindAt, + ); - // Wait for the event to be processed - await Future.delayed(Duration.zero); + // Create reminder.created event + final reminderCreatedEvent = Event( + cid: channel.cid, + type: EventType.reminderCreated, + reminder: reminder, + ); - // Verify membership is updated with new properties - expect(channel.membership, isNotNull); - expect(channel.membership?.user?.id, equals(updatedUser?.id)); - expect(channel.membership?.user?.role, equals(updatedUser?.role)); - }, - ); - }); + // Dispatch event + client.addEvent(reminderCreatedEvent); - group('Read Events', () { - const channelId = 'test-channel-id'; - const channelType = 'test-channel-type'; - late Channel channel; + // Wait for the event to be processed + await Future.delayed(Duration.zero); - setUp(() { - final channelState = _generateChannelState( - channelId, - channelType, - mockChannelConfig: true, + // Verify thread message reminder was added + final updatedMessage = channel.state?.threads[parentId]?.firstWhere( + (m) => m.id == messageId, ); - - channel = Channel.fromState(client, channelState); + expect(updatedMessage?.reminder, isNotNull); + expect(updatedMessage?.reminder?.messageId, messageId); + expect(updatedMessage?.reminder?.remindAt, reminder.remindAt); }); - tearDown(() { - channel.dispose(); - }); + test('should handle reminder.updated event for thread messages', () async { + const messageId = 'test-message-id'; + const parentId = 'test-parent-id'; - test('should update read state on message read event', () async { - final currentUser = User(id: 'test-user'); - final currentRead = Read( - user: currentUser, - lastRead: DateTime(2020), - unreadMessages: 10, + // Setup initial state with a thread message with existing reminder + final remindAt = DateTime.now().add(const Duration(days: 30)); + final initialReminder = MessageReminder( + messageId: messageId, + channelCid: channel.cid!, + userId: 'test-user-id', + remindAt: remindAt, ); - // Setup initial read state - channel.state?.updateChannelState( - channel.state!.channelState.copyWith( - read: [currentRead], - ), + final threadMessage = Message( + id: messageId, + parentId: parentId, + user: client.state.currentUser, + text: 'Thread message', + reminder: initialReminder, ); + channel.state?.updateMessage(threadMessage); + // Verify initial state - final read = channel.state?.read.first; - expect(read?.user.id, 'test-user'); - expect(read?.unreadMessages, 10); - expect(read?.lastReadMessageId, isNull); - expect(read?.lastRead.isAtSameMomentAs(DateTime(2020)), isTrue); + final initialMessage = channel.state?.threads[parentId]?.firstWhere( + (m) => m.id == messageId, + ); + expect(initialMessage?.reminder, isNotNull); + expect(initialMessage?.reminder?.remindAt, remindAt); - // Create message read event - final messageReadEvent = Event( + // Create updated reminder + final updatedRemindAt = remindAt.add(const Duration(days: 15)); + final updatedReminder = initialReminder.copyWith( + remindAt: updatedRemindAt, + updatedAt: DateTime.now(), + ); + + // Create reminder.updated event + final reminderUpdatedEvent = Event( cid: channel.cid, - type: EventType.messageRead, - user: currentUser, - createdAt: DateTime(2022), - unreadMessages: 0, - lastReadMessageId: 'message-123', + type: EventType.reminderUpdated, + reminder: updatedReminder, ); // Dispatch event - client.addEvent(messageReadEvent); + client.addEvent(reminderUpdatedEvent); - // Wait for event to be processed + // Wait for the event to be processed await Future.delayed(Duration.zero); - // Verify read state is updated - final updatedRead = channel.state?.read.first; - expect(updatedRead?.user.id, 'test-user'); - expect(updatedRead?.unreadMessages, 0); - expect(updatedRead?.lastReadMessageId, 'message-123'); - expect(updatedRead?.lastRead.isAtSameMomentAs(DateTime(2022)), isTrue); + // Verify thread message reminder was updated + final updatedMessage = channel.state?.threads[parentId]?.firstWhere( + (m) => m.id == messageId, + ); + expect(updatedMessage?.reminder, isNotNull); + expect(updatedMessage?.reminder?.messageId, messageId); + expect(updatedMessage?.reminder?.remindAt, updatedRemindAt); }); - test( - 'should add a new read state if not exist on message read event', - () async { - // Create the current read state - final currentUser = User(id: 'test-user'); - - // Verify initial state - final read = channel.state?.read; - expect(read, isEmpty); - - // Create mark read notification event - final markReadEvent = Event( - cid: channel.cid, - type: EventType.messageRead, - user: currentUser, - createdAt: DateTime(2022), - unreadMessages: 0, - lastReadMessageId: 'message-123', - ); - - // Dispatch event - client.addEvent(markReadEvent); - - // Wait for event to be processed - await Future.delayed(Duration.zero); - - // Verify read list has not changed - final updated = channel.state?.read; - expect(updated?.length, 1); - expect(updated?.any((r) => r.user.id == currentUser.id), isTrue); - }, - ); + test('should handle reminder.deleted event for thread messages', () async { + const messageId = 'test-message-id'; + const parentId = 'test-parent-id'; - test('should update read state on notification mark unread event', - () async { - // Create the current read state - final currentUser = User(id: 'test-user'); - final currentRead = Read( - user: currentUser, - lastRead: DateTime(2020), - unreadMessages: 10, + // Setup initial state with a thread message with existing reminder + final remindAt = DateTime.now().add(const Duration(days: 30)); + final initialReminder = MessageReminder( + messageId: messageId, + channelCid: channel.cid!, + userId: 'test-user-id', + remindAt: remindAt, ); - // Setup initial read state - channel.state?.updateChannelState( - channel.state!.channelState.copyWith( - read: [currentRead], - ), + final threadMessage = Message( + id: messageId, + parentId: parentId, + user: client.state.currentUser, + text: 'Thread message', + reminder: initialReminder, ); + channel.state?.updateMessage(threadMessage); + // Verify initial state - final read = channel.state?.read.first; - expect(read?.user.id, 'test-user'); - expect(read?.unreadMessages, 10); - expect(read?.lastReadMessageId, isNull); - expect(read?.lastRead.isAtSameMomentAs(DateTime(2020)), isTrue); + final initialMessage = channel.state?.threads[parentId]?.firstWhere( + (m) => m.id == messageId, + ); + expect(initialMessage?.reminder, isNotNull); - // Create mark unread notification event - final markUnreadEvent = Event( + // Create reminder.deleted event + final reminderDeletedEvent = Event( cid: channel.cid, - type: EventType.notificationMarkUnread, - user: currentUser, - lastReadAt: DateTime(2019), - unreadMessages: 15, - lastReadMessageId: 'message-100', + type: EventType.reminderDeleted, + reminder: initialReminder, ); // Dispatch event - client.addEvent(markUnreadEvent); + client.addEvent(reminderDeletedEvent); - // Wait for event to be processed + // Wait for the event to be processed await Future.delayed(Duration.zero); - // Verify read state is updated - final updatedRead = channel.state?.read.first; - expect(updatedRead?.user.id, 'test-user'); - expect(updatedRead?.unreadMessages, 15); - expect(updatedRead?.lastReadMessageId, 'message-100'); - expect(updatedRead?.lastRead.isAtSameMomentAs(DateTime(2019)), isTrue); + // Verify thread message reminder was removed + final updatedMessage = channel.state?.threads[parentId]?.firstWhere( + (m) => m.id == messageId, + ); + expect(updatedMessage?.reminder, isNull); }); + }); - test( - 'should add a new read state if not exist on notification mark unread', - () async { - // Verify initial state - final read = channel.state?.read; - expect(read, isEmpty); + group('Location events', () { + const channelId = 'test-channel-id'; + const channelType = 'test-channel-type'; + late Channel channel; - // Create event for non-existing user - final markUnreadEvent = Event( - cid: channel.cid, - type: EventType.notificationMarkUnread, - user: User(id: 'non-existing-user'), - lastReadAt: DateTime(2019), - unreadMessages: 15, - lastReadMessageId: 'message-100', - ); + setUp(() { + final channelState = _generateChannelState(channelId, channelType); + channel = Channel.fromState(client, channelState); + }); + + tearDown(() { + channel.dispose(); + }); - // Dispatch event - client.addEvent(markUnreadEvent); + test('should handle location.shared event', () async { + // Verify initial state + expect(channel.state?.activeLiveLocations, isEmpty); - // Wait for event to be processed - await Future.delayed(Duration.zero); + // Create live location + final liveLocation = Location( + channelCid: channel.cid, + userId: 'user1', + messageId: 'msg1', + latitude: 40.7128, + longitude: -74.0060, + createdByDeviceId: 'device1', + endAt: DateTime.now().add(const Duration(hours: 1)), + ); - // Verify read list has not changed - final updated = channel.state?.read; - expect(updated?.length, 1); - expect(updated?.any((r) => r.user.id == 'non-existing-user'), isTrue); - }, - ); + final locationMessage = Message( + id: 'msg1', + text: 'Live location shared', + sharedLocation: liveLocation, + ); - test( - 'should preserve delivery info on message read event', - () async { - final currentUser = User(id: 'test-user'); - final currentRead = Read( - user: currentUser, - lastRead: DateTime(2020), - unreadMessages: 10, - lastDeliveredAt: DateTime(2021), - lastDeliveredMessageId: 'delivered-msg-456', - ); + // Create location.shared event + final locationSharedEvent = Event( + cid: channel.cid, + type: EventType.locationShared, + message: locationMessage, + ); - // Setup initial read state with delivery info - channel.state?.updateChannelState( - channel.state!.channelState.copyWith( - read: [currentRead], - ), - ); + // Dispatch event + client.addEvent(locationSharedEvent); - // Verify initial state - final read = channel.state?.read.first; - expect(read?.lastDeliveredAt, isNotNull); - expect( - read?.lastDeliveredAt?.isAtSameMomentAs(DateTime(2021)), - isTrue, - ); - expect(read?.lastDeliveredMessageId, 'delivered-msg-456'); + // Wait for the event to be processed + await Future.delayed(Duration.zero); - // Create message read event (doesn't include delivery info) - final messageReadEvent = Event( - cid: channel.cid, - type: EventType.messageRead, - user: currentUser, - createdAt: DateTime(2022), - unreadMessages: 0, - lastReadMessageId: 'message-123', - ); + // Check if message was added + final messages = channel.state?.messages; + final message = messages?.firstWhere((m) => m.id == 'msg1'); + expect(message, isNotNull); - // Dispatch event - client.addEvent(messageReadEvent); + // Check if active live location was updated + final activeLiveLocations = channel.state?.activeLiveLocations; + expect(activeLiveLocations, hasLength(1)); + expect(activeLiveLocations?.first.messageId, equals('msg1')); + }); - // Wait for event to be processed - await Future.delayed(Duration.zero); + test('should handle location.updated event', () async { + // Setup initial state with location message + final liveLocation = Location( + channelCid: channel.cid, + userId: 'user1', + messageId: 'msg1', + latitude: 40.7128, + longitude: -74.0060, + createdByDeviceId: 'device1', + endAt: DateTime.now().add(const Duration(hours: 1)), + ); - // Verify read state is updated but delivery info is preserved - final updatedRead = channel.state?.read.first; - expect(updatedRead?.user.id, 'test-user'); - expect(updatedRead?.unreadMessages, 0); - expect(updatedRead?.lastReadMessageId, 'message-123'); - expect( - updatedRead?.lastRead.isAtSameMomentAs(DateTime(2022)), - isTrue, - ); - // Delivery info should be preserved - expect(updatedRead?.lastDeliveredAt, isNotNull); - expect( - updatedRead?.lastDeliveredAt?.isAtSameMomentAs(DateTime(2021)), - isTrue, - ); - expect(updatedRead?.lastDeliveredMessageId, 'delivered-msg-456'); - }, - ); + final locationMessage = Message( + id: 'msg1', + text: 'Live location shared', + sharedLocation: liveLocation, + ); - test( - 'should reconcile delivery when message read event is from current user', - () async { - final currentUser = client.state.currentUser; - final updatedUser = currentUser?.copyWith(id: 'current-user-id'); + // Add initial message + channel.state?.addNewMessage(locationMessage); - client.state.updateUser(updatedUser); - addTearDown(() => client.state.updateUser(currentUser)); + // Create updated location + final updatedLocation = liveLocation.copyWith( + latitude: 40.7500, // Updated latitude + longitude: -74.1000, // Updated longitude + ); - when( - () => client.channelDeliveryReporter.reconcileDelivery([channel]), - ).thenAnswer((_) => Future.value()); + final updatedMessage = locationMessage.copyWith( + sharedLocation: updatedLocation, + ); - // Create message read event from current user - final messageReadEvent = Event( - cid: channel.cid, - type: EventType.messageRead, - user: currentUser, - createdAt: DateTime(2022), - unreadMessages: 0, - lastReadMessageId: 'message-123', - ); + // Create location.updated event + final locationUpdatedEvent = Event( + cid: channel.cid, + type: EventType.locationUpdated, + message: updatedMessage, + ); - // Dispatch event - client.addEvent(messageReadEvent); + // Dispatch event + client.addEvent(locationUpdatedEvent); - // Wait for event to be processed - await Future.delayed(Duration.zero); + // Wait for the event to be processed + await Future.delayed(Duration.zero); - // Verify reconcileDelivery was called - verify( - () => client.channelDeliveryReporter.reconcileDelivery([channel]), - ).called(1); - }, - ); + // Check if message was updated + final messages = channel.state?.messages; + final message = messages?.firstWhere((m) => m.id == 'msg1'); + expect(message?.sharedLocation?.latitude, equals(40.7500)); + expect(message?.sharedLocation?.longitude, equals(-74.1000)); + + // Check if active live location was updated + final activeLiveLocations = channel.state?.activeLiveLocations; + expect(activeLiveLocations, hasLength(1)); + expect(activeLiveLocations?.first.latitude, equals(40.7500)); + expect(activeLiveLocations?.first.longitude, equals(-74.1000)); + }); - test('should update read state on message delivered event', () async { - final currentUser = User(id: 'test-user'); - final distantPast = DateTime.fromMillisecondsSinceEpoch(0, isUtc: true); - final currentRead = Read( - user: currentUser, - lastRead: distantPast, - unreadMessages: 5, + test('should handle location.expired event', () async { + // Setup initial state with location message + final liveLocation = Location( + channelCid: channel.cid, + userId: 'user1', + messageId: 'msg1', + latitude: 40.7128, + longitude: -74.0060, + createdByDeviceId: 'device1', + endAt: DateTime.now().add(const Duration(hours: 1)), ); - // Setup initial read state - channel.state?.updateChannelState( - channel.state!.channelState.copyWith( - read: [currentRead], - ), + final locationMessage = Message( + id: 'msg1', + text: 'Live location shared', + sharedLocation: liveLocation, ); - // Verify initial state has no delivery info - final read = channel.state?.read.first; - expect(read?.user.id, 'test-user'); - expect(read?.lastDeliveredAt, isNull); - expect(read?.lastDeliveredMessageId, isNull); + // Add initial message + channel.state?.addNewMessage(locationMessage); + expect(channel.state?.activeLiveLocations, hasLength(1)); - // Create message delivered event - final messageDeliveredEvent = Event( + // Create expired location + final expiredLocation = liveLocation.copyWith( + endAt: DateTime.now().subtract(const Duration(hours: 1)), + ); + + final expiredMessage = locationMessage.copyWith( + sharedLocation: expiredLocation, + ); + + // Create location.expired event + final locationExpiredEvent = Event( cid: channel.cid, - type: EventType.messageDelivered, - user: currentUser, - lastDeliveredAt: DateTime(2022), - lastDeliveredMessageId: 'message-456', + type: EventType.locationExpired, + message: expiredMessage, ); // Dispatch event - client.addEvent(messageDeliveredEvent); + client.addEvent(locationExpiredEvent); - // Wait for event to be processed + // Wait for the event to be processed await Future.delayed(Duration.zero); - // Verify delivery state is updated - final updatedRead = channel.state?.read.first; - expect(updatedRead?.user.id, 'test-user'); - expect(updatedRead?.lastDeliveredAt, isNotNull); - expect( - updatedRead?.lastDeliveredAt?.isAtSameMomentAs(DateTime(2022)), - isTrue, - ); - expect(updatedRead?.lastDeliveredMessageId, 'message-456'); + // Check if message was updated + final messages = channel.state?.messages; + final message = messages?.firstWhere((m) => m.id == 'msg1'); + expect(message?.sharedLocation?.isExpired, isTrue); + + // Check if active live location was removed + expect(channel.state?.activeLiveLocations, isEmpty); }); - test( - 'should add a new read state if not exist on message delivered event', - () async { - final newUser = User(id: 'new-user'); - final distantPast = - DateTime.fromMillisecondsSinceEpoch(0, isUtc: true); + test('should not add static location to active locations', () async { + final staticLocation = Location( + channelCid: channel.cid, + userId: 'user1', + messageId: 'msg1', + latitude: 40.7128, + longitude: -74.0060, + createdByDeviceId: 'device1', + // No endAt - static location + ); - // Verify initial state - final read = channel.state?.read; - expect(read, isEmpty); + final staticMessage = Message( + id: 'msg1', + text: 'Static location shared', + sharedLocation: staticLocation, + ); - // Create message delivered event for new user - final messageDeliveredEvent = Event( - cid: channel.cid, - type: EventType.messageDelivered, - user: newUser, - lastDeliveredAt: DateTime(2022), - lastDeliveredMessageId: 'message-789', - ); + // Create location.shared event + final locationSharedEvent = Event( + cid: channel.cid, + type: EventType.locationShared, + message: staticMessage, + ); - // Dispatch event - client.addEvent(messageDeliveredEvent); + // Dispatch event + client.addEvent(locationSharedEvent); - // Wait for event to be processed - await Future.delayed(Duration.zero); + // Wait for the event to be processed + await Future.delayed(Duration.zero); - // Verify read state was created with delivery info - final updated = channel.state?.read; - expect(updated?.length, 1); - final newRead = updated?.first; - expect(newRead?.user.id, 'new-user'); - expect(newRead?.lastDeliveredAt, isNotNull); - expect( - newRead?.lastDeliveredAt?.isAtSameMomentAs(DateTime(2022)), - isTrue, - ); - expect(newRead?.lastDeliveredMessageId, 'message-789'); - // lastRead should default to distantPast - expect( - newRead?.lastRead.isAtSameMomentAs(distantPast), - isTrue, - ); - }, - ); + // Check if message was added + final messages = channel.state?.messages; + final message = messages?.firstWhere((m) => m.id == 'msg1'); + expect(message?.sharedLocation, isNotNull); + + // Check if active live location was NOT updated (should remain empty) + expect(channel.state?.activeLiveLocations, isEmpty); + }); test( - 'should preserve read info on message delivered event', + 'should update active locations when location message is deleted', () async { - final currentUser = User(id: 'test-user'); - final currentRead = Read( - user: currentUser, - lastRead: DateTime(2020), - unreadMessages: 10, - lastReadMessageId: 'read-msg-123', + final liveLocation = Location( + channelCid: channel.cid, + userId: 'user1', + messageId: 'msg1', + latitude: 40.7128, + longitude: -74.0060, + createdByDeviceId: 'device1', + endAt: DateTime.now().add(const Duration(hours: 1)), ); - // Setup initial read state - channel.state?.updateChannelState( - channel.state!.channelState.copyWith( - read: [currentRead], - ), + final locationMessage = Message( + id: 'msg1', + text: 'Live location shared', + sharedLocation: liveLocation, ); // Verify initial state - final read = channel.state?.read.first; - expect(read?.lastRead.isAtSameMomentAs(DateTime(2020)), isTrue); - expect(read?.unreadMessages, 10); - expect(read?.lastReadMessageId, 'read-msg-123'); + channel.state?.addNewMessage(locationMessage); + expect(channel.state?.activeLiveLocations, hasLength(1)); - // Create message delivered event (doesn't include read info) - final messageDeliveredEvent = Event( + final messageDeletedEvent = Event( + type: EventType.messageDeleted, cid: channel.cid, - type: EventType.messageDelivered, - user: currentUser, - lastDeliveredAt: DateTime(2022), - lastDeliveredMessageId: 'delivered-msg-456', + message: locationMessage.copyWith( + type: MessageType.deleted, + deletedAt: DateTime.timestamp(), + ), ); // Dispatch event - client.addEvent(messageDeliveredEvent); + client.addEvent(messageDeletedEvent); - // Wait for event to be processed + // Wait for the event to be processed await Future.delayed(Duration.zero); - // Verify delivery state is updated but read info is preserved - final updatedRead = channel.state?.read.first; - expect(updatedRead?.user.id, 'test-user'); - expect( - updatedRead?.lastDeliveredAt?.isAtSameMomentAs(DateTime(2022)), - isTrue, - ); - expect(updatedRead?.lastDeliveredMessageId, 'delivered-msg-456'); - // Read info should be preserved - expect( - updatedRead?.lastRead.isAtSameMomentAs(DateTime(2020)), - isTrue, - ); - expect(updatedRead?.unreadMessages, 10); - expect(updatedRead?.lastReadMessageId, 'read-msg-123'); + // Verify active locations are updated + expect(channel.state?.activeLiveLocations, isEmpty); }, ); + test('should merge locations with same key', () async { + final liveLocation = Location( + channelCid: channel.cid, + userId: 'user1', + messageId: 'msg1', + latitude: 40.7128, + longitude: -74.0060, + createdByDeviceId: 'device1', + endAt: DateTime.now().add(const Duration(hours: 1)), + ); + + final locationMessage = Message( + id: 'msg1', + text: 'Live location shared', + sharedLocation: liveLocation, + ); + + // Add initial location for setup + channel.state?.addNewMessage(locationMessage); + expect(channel.state?.activeLiveLocations, hasLength(1)); + + // Create new location with same user, channel, and device + final newLocation = Location( + channelCid: channel.cid, + userId: 'user1', // Same user + messageId: 'msg2', // Different message + latitude: 40.7500, + longitude: -74.1000, + createdByDeviceId: 'device1', // Same device + endAt: DateTime.now().add(const Duration(hours: 2)), + ); + + final newMessage = Message( + id: 'msg2', + text: 'Updated location', + sharedLocation: newLocation, + ); + + // Create location.shared event for the new message + final locationSharedEvent = Event( + cid: channel.cid, + type: EventType.locationShared, + message: newMessage, + ); + + // Dispatch event + client.addEvent(locationSharedEvent); + + // Wait for the event to be processed + await Future.delayed(Duration.zero); + + // Should still have only one active location (merged) + final activeLiveLocations = channel.state?.activeLiveLocations; + expect(activeLiveLocations, hasLength(1)); + expect(activeLiveLocations?.first.messageId, equals('msg2')); + expect(activeLiveLocations?.first.latitude, equals(40.7500)); + }); + test( - 'should reconcile delivery when message delivered event is from current user', + 'should handle multiple active locations from different devices', () async { - final currentUser = client.state.currentUser; - final updatedUser = currentUser?.copyWith(id: 'current-user-id'); + final liveLocation = Location( + channelCid: channel.cid, + userId: 'user1', + messageId: 'msg1', + latitude: 40.7128, + longitude: -74.0060, + createdByDeviceId: 'device1', + endAt: DateTime.now().add(const Duration(hours: 1)), + ); - client.state.updateUser(updatedUser); - addTearDown(() => client.state.updateUser(currentUser)); + final locationMessage = Message( + id: 'msg1', + text: 'Live location shared', + sharedLocation: liveLocation, + ); - when( - () => client.channelDeliveryReporter.reconcileDelivery([channel]), - ).thenAnswer((_) => Future.value()); + // Add first location for setup + channel.state?.addNewMessage(locationMessage); + expect(channel.state?.activeLiveLocations, hasLength(1)); + + // Create location from different device + final location2 = Location( + channelCid: channel.cid, + userId: 'user1', // Same user + messageId: 'msg2', + latitude: 34.0522, + longitude: -118.2437, + createdByDeviceId: 'device2', // Different device + endAt: DateTime.now().add(const Duration(hours: 1)), + ); - // Create message delivered event from current user - final messageDeliveredEvent = Event( + final message2 = Message( + id: 'msg2', + text: 'Location from device 2', + sharedLocation: location2, + ); + + // Create location.shared event for the second message + final locationSharedEvent = Event( cid: channel.cid, - type: EventType.messageDelivered, - user: currentUser, - lastDeliveredAt: DateTime(2022), - lastDeliveredMessageId: 'message-456', + type: EventType.locationShared, + message: message2, ); // Dispatch event - client.addEvent(messageDeliveredEvent); + client.addEvent(locationSharedEvent); - // Wait for event to be processed + // Wait for the event to be processed await Future.delayed(Duration.zero); - // Verify reconcileDelivery was called - verify( - () => client.channelDeliveryReporter.reconcileDelivery([channel]), - ).called(1); + // Should have two active locations + expect(channel.state?.activeLiveLocations, hasLength(2)); }, ); + + test('should handle location messages in threads', () async { + final parentMessage = Message( + id: 'parent1', + text: 'Thread parent', + ); + + // Add parent message first for setup + channel.state?.addNewMessage(parentMessage); + + final liveLocation = Location( + channelCid: channel.cid, + userId: 'user1', + messageId: 'thread-msg1', + latitude: 40.7128, + longitude: -74.0060, + createdByDeviceId: 'device1', + endAt: DateTime.now().add(const Duration(hours: 1)), + ); + + final threadLocationMessage = Message( + id: 'thread-msg1', + text: 'Live location in thread', + parentId: 'parent1', + sharedLocation: liveLocation, + ); + + // Create location.shared event for the thread message + final locationSharedEvent = Event( + cid: channel.cid, + type: EventType.locationShared, + message: threadLocationMessage, + ); + + // Dispatch event + client.addEvent(locationSharedEvent); + + // Wait for the event to be processed + await Future.delayed(Duration.zero); + + // Check if thread message was added + final thread = channel.state?.threads['parent1']; + expect(thread, contains(threadLocationMessage)); + + // Check if location was added to active locations + final activeLiveLocations = channel.state?.activeLiveLocations; + expect(activeLiveLocations, hasLength(1)); + expect(activeLiveLocations?.first.messageId, equals('thread-msg1')); + }); + + test('should update thread location messages', () async { + final parentMessage = Message( + id: 'parent1', + text: 'Thread parent', + ); + + final liveLocation = Location( + channelCid: channel.cid, + userId: 'user1', + messageId: 'thread-msg1', + latitude: 40.7128, + longitude: -74.0060, + createdByDeviceId: 'device1', + endAt: DateTime.now().add(const Duration(hours: 1)), + ); + + final threadLocationMessage = Message( + id: 'thread-msg1', + text: 'Live location in thread', + parentId: 'parent1', + sharedLocation: liveLocation, + ); + + // Add messages + channel.state?.addNewMessage(parentMessage); + channel.state?.addNewMessage(threadLocationMessage); + + // Update the location + final updatedLocation = liveLocation.copyWith( + latitude: 40.7500, + longitude: -74.1000, + ); + + final updatedThreadMessage = threadLocationMessage.copyWith( + sharedLocation: updatedLocation, + ); + + // Create location.updated event for the thread message + final locationUpdatedEvent = Event( + cid: channel.cid, + type: EventType.locationUpdated, + message: updatedThreadMessage, + ); + + // Dispatch event + client.addEvent(locationUpdatedEvent); + + // Wait for the event to be processed + await Future.delayed(Duration.zero); + + // Check if thread message was updated + final thread = channel.state?.threads['parent1']; + final threadMessage = thread?.firstWhere((m) => m.id == 'thread-msg1'); + expect(threadMessage?.sharedLocation?.latitude, equals(40.7500)); + expect(threadMessage?.sharedLocation?.longitude, equals(-74.1000)); + + // Check if active location was updated + final activeLiveLocations = channel.state?.activeLiveLocations; + expect(activeLiveLocations, hasLength(1)); + expect(activeLiveLocations?.first.latitude, equals(40.7500)); + expect(activeLiveLocations?.first.longitude, equals(-74.1000)); + }); }); - group('Draft events', () { + group('Channel push preference events', () { const channelId = 'test-channel-id'; const channelType = 'test-channel-type'; late Channel channel; @@ -4803,1239 +6888,1533 @@ void main() { channel.dispose(); }); - test('should handle draft.updated event for channel drafts', () async { + test('should handle channel.push_preference.updated event', () async { // Verify initial state - expect(channel.state?.draft, isNull); + expect(channel.state?.channelState.pushPreferences, isNull); - // Create Draft - final draft = Draft( - channelCid: channel.cid!, - createdAt: DateTime.now(), - message: DraftMessage(text: 'test message'), + // Create channel push preference + final channelPushPreference = ChannelPushPreference( + chatLevel: ChatLevel.mentions, + disabledUntil: DateTime.now().add(const Duration(hours: 1)), ); - // Create draft.updated event - final draftUpdatedEvent = Event( + // Create channel.push_preference.updated event + final channelPushPreferenceUpdatedEvent = Event( cid: channel.cid, - type: EventType.draftUpdated, - draft: draft, + type: EventType.channelPushPreferenceUpdated, + channelPushPreference: channelPushPreference, ); // Dispatch event - client.addEvent(draftUpdatedEvent); + client.addEvent(channelPushPreferenceUpdatedEvent); // Wait for the event to be processed await Future.delayed(Duration.zero); - // Verify channel draft was updated - expect(channel.state?.draft, isNotNull); - expect(channel.state?.draft?.message.text, 'test message'); + // Verify channel push preferences were updated + final updatedPreferences = channel.state?.channelState.pushPreferences; + expect(updatedPreferences, isNotNull); + expect(updatedPreferences?.chatLevel, ChatLevel.mentions); + expect( + updatedPreferences?.disabledUntil, + channelPushPreference.disabledUntil, + ); }); - test('should handle draft.updated event for thread drafts', () async { - const threadParentMessageId = 'thread-parent-id'; + test('should update existing channel push preferences', () async { + // Set initial push preferences + const initialPushPreference = ChannelPushPreference( + chatLevel: ChatLevel.all, + ); - // Setup initial state with a regular message - channel.state?.updateMessage( - Message( - id: threadParentMessageId, - user: client.state.currentUser, + channel.state?.updateChannelState( + channel.state!.channelState.copyWith( + pushPreferences: initialPushPreference, ), ); // Verify initial state - expect(channel.state?.threadDraft(threadParentMessageId), isNull); + final pushPreferences = channel.state?.channelState.pushPreferences; + expect(pushPreferences?.chatLevel, ChatLevel.all); + expect(pushPreferences?.disabledUntil, isNull); - // Create thread Draft - final draft = Draft( - channelCid: channel.cid!, - createdAt: DateTime.now(), - parentId: threadParentMessageId, - message: DraftMessage(text: 'thread reply'), + // Create updated channel push preference + final updatedPushPreference = ChannelPushPreference( + chatLevel: ChatLevel.none, + disabledUntil: DateTime.now().add(const Duration(hours: 2)), ); - // Create draft.updated event - final draftUpdatedEvent = Event( + // Create channel.push_preference.updated event + final channelPushPreferenceUpdatedEvent = Event( cid: channel.cid, - type: EventType.draftUpdated, - draft: draft, + type: EventType.channelPushPreferenceUpdated, + channelPushPreference: updatedPushPreference, ); // Dispatch event - client.addEvent(draftUpdatedEvent); + client.addEvent(channelPushPreferenceUpdatedEvent); // Wait for the event to be processed await Future.delayed(Duration.zero); - // Verify thread draft was updated - final threadDraft = channel.state?.threadDraft(threadParentMessageId); - expect(threadDraft, isNotNull); - expect(threadDraft?.message.text, 'thread reply'); + // Verify channel push preferences were updated + final updatedPreferences = channel.state?.channelState.pushPreferences; + expect(updatedPreferences?.chatLevel, ChatLevel.none); + expect( + updatedPreferences?.disabledUntil, + updatedPushPreference.disabledUntil, + ); }); + }); - test('should handle draft.deleted event for channel drafts', () async { - // Setup initial state with a draft - channel.state?.updateChannelState( - channel.state!.channelState.copyWith( - draft: Draft( - channelCid: channel.cid!, - createdAt: DateTime.now(), - message: DraftMessage(text: 'test message'), - ), + group('User messages deleted event', () { + const channelId = 'test-channel-id'; + const channelType = 'test-channel-type'; + late Channel channel; + late MockPersistenceClient persistenceClient; + + setUp(() { + persistenceClient = MockPersistenceClient(); + when(() => client.chatPersistenceClient).thenReturn(persistenceClient); + when( + () => persistenceClient.deleteMessagesFromUser( + cid: any(named: 'cid'), + userId: any(named: 'userId'), + hardDelete: any(named: 'hardDelete'), + deletedAt: any(named: 'deletedAt'), ), - ); + ).thenAnswer((_) async {}); + when(() => persistenceClient.deleteMessageByIds(any())).thenAnswer((_) async {}); + when(() => persistenceClient.deletePinnedMessageByIds(any())).thenAnswer((_) async {}); + when(() => persistenceClient.getChannelThreads(any())).thenAnswer((_) async => >{}); - // Verify initial state - final draft = channel.state?.draft; - expect(draft, isNotNull); - expect(draft?.message.text, 'test message'); + final channelState = _generateChannelState(channelId, channelType); + channel = Channel.fromState(client, channelState); + }); - // Create draft.deleted event - final draftUpdatedEvent = Event( - cid: channel.cid, - type: EventType.draftDeleted, - draft: draft, - ); + tearDown(() { + channel.dispose(); + }); - // Dispatch event - client.addEvent(draftUpdatedEvent); + test( + 'should soft delete all messages from user when hardDelete is false', + () async { + // Setup: Add messages from different users + final user1 = User(id: 'user-1', name: 'User 1'); + final user2 = User(id: 'user-2', name: 'User 2'); + + final message1 = Message( + id: 'msg-1', + text: 'Message from user 1', + user: user1, + ); + final message2 = Message( + id: 'msg-2', + text: 'Another message from user 1', + user: user1, + ); + final message3 = Message( + id: 'msg-3', + text: 'Message from user 2', + user: user2, + ); - // Wait for the event to be processed - await Future.delayed(Duration.zero); + channel.state?.addNewMessage(message1); + channel.state?.addNewMessage(message2); + channel.state?.addNewMessage(message3); - // Verify channel draft was updated - expect(channel.state?.draft, isNull); - }); + // Verify initial state + expect(channel.state?.messages.length, equals(3)); + expect( + channel.state?.messages.where((m) => m.user?.id == 'user-1').length, + equals(2), + ); + expect( + channel.state?.messages.where((m) => m.user?.id == 'user-2').length, + equals(1), + ); + + // Create user.messages.deleted event (soft delete) + final deletedAt = DateTime.now(); + final userMessagesDeletedEvent = Event( + cid: channel.cid, + type: EventType.userMessagesDeleted, + user: user1, + hardDelete: false, + createdAt: deletedAt, + ); + + // Dispatch event + client.addEvent(userMessagesDeletedEvent); + + // Wait for the event to be processed + await Future.delayed(Duration.zero); + + // Verify user1's messages are soft deleted + expect(channel.state?.messages.length, equals(3)); + final deletedMessages = channel.state?.messages.where((m) => m.user?.id == 'user-1').toList(); + expect(deletedMessages?.length, equals(2)); + for (final message in deletedMessages!) { + expect(message.type, equals(MessageType.deleted)); + expect(message.deletedAt, isNotNull); + expect(message.state.isDeleted, isTrue); + } + + // Verify user2's message is unaffected + final user2Message = channel.state?.messages.firstWhere((m) => m.id == 'msg-3'); + expect(user2Message?.type, isNot(MessageType.deleted)); + expect(user2Message?.deletedAt, isNull); + }, + ); + + test( + 'should hard delete all messages from user when hardDelete is true', + () async { + // Setup: Add messages from different users + final user1 = User(id: 'user-1', name: 'User 1'); + final user2 = User(id: 'user-2', name: 'User 2'); + + final message1 = Message( + id: 'msg-1', + text: 'Message from user 1', + user: user1, + ); + final message2 = Message( + id: 'msg-2', + text: 'Another message from user 1', + user: user1, + ); + final message3 = Message( + id: 'msg-3', + text: 'Message from user 2', + user: user2, + ); + + channel.state?.addNewMessage(message1); + channel.state?.addNewMessage(message2); + channel.state?.addNewMessage(message3); + + // Verify initial state + expect(channel.state?.messages.length, equals(3)); + + // Create user.messages.deleted event (hard delete) + final userMessagesDeletedEvent = Event( + cid: channel.cid, + type: EventType.userMessagesDeleted, + user: user1, + hardDelete: true, + ); + + // Dispatch event + client.addEvent(userMessagesDeletedEvent); + + // Wait for the event to be processed + await Future.delayed(Duration.zero); + + // Verify user1's messages are removed + expect(channel.state?.messages.length, equals(1)); + expect( + channel.state?.messages.any((m) => m.user?.id == 'user-1'), + isFalse, + ); + + // Verify user2's message still exists + final user2Message = channel.state?.messages.firstWhere((m) => m.id == 'msg-3'); + expect(user2Message, isNotNull); + expect(user2Message?.user?.id, equals('user-2')); + }, + ); + + test( + 'should handle thread messages from user', + () async { + // Setup: Add parent and thread messages + final user1 = User(id: 'user-1', name: 'User 1'); + final user2 = User(id: 'user-2', name: 'User 2'); + + final parentMessage = Message( + id: 'parent-msg', + text: 'Parent message', + user: user2, + ); + final threadMessage1 = Message( + id: 'thread-msg-1', + text: 'Thread message from user 1', + user: user1, + parentId: 'parent-msg', + ); + final threadMessage2 = Message( + id: 'thread-msg-2', + text: 'Another thread message from user 1', + user: user1, + parentId: 'parent-msg', + ); - test('should handle draft.deleted event for thread drafts', () async { - const threadParentMessageId = 'thread-parent-id'; + channel.state?.addNewMessage(parentMessage); + channel.state?.addNewMessage(threadMessage1); + channel.state?.addNewMessage(threadMessage2); - // Setup initial state with a thread draft - channel.state?.updateMessage( - Message( - id: threadParentMessageId, - user: client.state.currentUser, - draft: Draft( - channelCid: channel.cid!, - createdAt: DateTime.now(), - parentId: threadParentMessageId, - message: DraftMessage(text: 'thread reply'), - ), - ), - ); + // Verify initial state + expect(channel.state?.messages.length, equals(1)); + expect(channel.state?.threads['parent-msg']?.length, equals(2)); - // Verify initial state - final threadDraft = channel.state?.threadDraft(threadParentMessageId); - expect(threadDraft, isNotNull); - expect(threadDraft?.message.text, 'thread reply'); + // Create user.messages.deleted event (soft delete) + final userMessagesDeletedEvent = Event( + cid: channel.cid, + type: EventType.userMessagesDeleted, + user: user1, + hardDelete: false, + ); - // Create draft.deleted event - final draftDeletedEvent = Event( - cid: channel.cid, - type: EventType.draftDeleted, - draft: threadDraft, - ); + // Dispatch event + client.addEvent(userMessagesDeletedEvent); - // Dispatch event - client.addEvent(draftDeletedEvent); + // Wait for the event to be processed + await Future.delayed(Duration.zero); - // Allow event to be processed - await Future.delayed(Duration.zero); + // Verify thread messages are soft deleted + final threadMessages = channel.state?.threads['parent-msg']; + expect(threadMessages?.length, equals(2)); + for (final message in threadMessages!) { + expect(message.type, equals(MessageType.deleted)); + expect(message.state.isDeleted, isTrue); + } - // Verify thread draft was removed - expect(channel.state?.threadDraft(threadParentMessageId), isNull); - }); + // Verify parent message is unaffected + final parent = channel.state?.messages.first; + expect(parent?.type, isNot(MessageType.deleted)); + }, + ); test( - 'should update current channel draft if draft.updated event is emitted', + 'should do nothing when user is null', () async { - // Setup initial state with a draft - final initialDraft = Draft( - channelCid: channel.cid!, - createdAt: DateTime.now(), - message: DraftMessage(text: 'test message'), + // Setup: Add messages + final user1 = User(id: 'user-1', name: 'User 1'); + final message1 = Message( + id: 'msg-1', + text: 'Message from user 1', + user: user1, ); - channel.state?.updateChannelState( - channel.state!.channelState.copyWith( - draft: initialDraft, - ), - ); + channel.state?.addNewMessage(message1); // Verify initial state - expect(channel.state?.draft, isNotNull); - expect(channel.state?.draft?.message.text, 'test message'); - - // Create Draft - final updatedDraft = initialDraft.copyWith( - message: DraftMessage(text: 'updated message'), - ); + expect(channel.state?.messages.length, equals(1)); - // Create draft.updated event - final draftUpdatedEvent = Event( + // Create user.messages.deleted event without user + final userMessagesDeletedEvent = Event( cid: channel.cid, - type: EventType.draftUpdated, - draft: updatedDraft, + type: EventType.userMessagesDeleted, + hardDelete: false, ); // Dispatch event - client.addEvent(draftUpdatedEvent); + client.addEvent(userMessagesDeletedEvent); // Wait for the event to be processed await Future.delayed(Duration.zero); - // Verify channel draft was updated - expect(channel.state?.draft, isNotNull); - expect(channel.state?.draft?.message.text, 'updated message'); + // Verify messages are unaffected + expect(channel.state?.messages.length, equals(1)); + expect( + channel.state?.messages.first.type, + isNot(MessageType.deleted), + ); }, ); test( - 'should update current thread draft if draft.updated event is emitted', + 'should handle empty message list', () async { - const threadParentMessageId = 'thread-parent-id'; + // Setup: Empty channel + expect(channel.state?.messages.length, equals(0)); - // Setup initial state with a thread draft - final initialDraft = Draft( - channelCid: channel.cid!, - createdAt: DateTime.now(), - parentId: threadParentMessageId, - message: DraftMessage(text: 'thread reply'), + // Create user.messages.deleted event + final userMessagesDeletedEvent = Event( + cid: channel.cid, + type: EventType.userMessagesDeleted, + user: User(id: 'user-1'), + hardDelete: false, ); - channel.state?.updateMessage( - Message( - id: threadParentMessageId, - user: client.state.currentUser, - draft: initialDraft, - ), - ); + // Dispatch event - should not throw + client.addEvent(userMessagesDeletedEvent); - // Verify initial state - final draft = channel.state?.threadDraft(threadParentMessageId); - expect(draft, isNotNull); - expect(draft?.message.text, 'thread reply'); + // Wait for the event to be processed + await Future.delayed(Duration.zero); - // Create Draft - final updatedDraft = initialDraft.copyWith( - message: DraftMessage(text: 'updated thread reply'), + // Verify state is still empty + expect(channel.state?.messages.length, equals(0)); + }, + ); + + test( + 'should delete messages from persistence when hardDelete is true', + () async { + // Setup: Add messages from different users + final user1 = User(id: 'user-1', name: 'User 1'); + final user2 = User(id: 'user-2', name: 'User 2'); + + final message1 = Message( + id: 'msg-1', + text: 'Message from user 1', + user: user1, + ); + final message2 = Message( + id: 'msg-2', + text: 'Another message from user 1', + user: user1, + ); + final message3 = Message( + id: 'msg-3', + text: 'Message from user 2', + user: user2, ); - // Create draft.updated event - final draftUpdatedEvent = Event( + channel.state?.addNewMessage(message1); + channel.state?.addNewMessage(message2); + channel.state?.addNewMessage(message3); + + // Verify initial state + expect(channel.state?.messages.length, equals(3)); + + // Create user.messages.deleted event (hard delete) + final userMessagesDeletedEvent = Event( cid: channel.cid, - type: EventType.draftUpdated, - draft: updatedDraft, + type: EventType.userMessagesDeleted, + user: user1, + hardDelete: true, ); // Dispatch event - client.addEvent(draftUpdatedEvent); + client.addEvent(userMessagesDeletedEvent); // Wait for the event to be processed await Future.delayed(Duration.zero); - // Verify thread draft was updated - final threadDraft = channel.state?.threadDraft(threadParentMessageId); - expect(threadDraft, isNotNull); - expect(threadDraft?.message.text, 'updated thread reply'); + // Verify messages are removed from persistence + verify( + () => persistenceClient.deleteMessageByIds(['msg-1', 'msg-2']), + ).called(1); + verify( + () => persistenceClient.deletePinnedMessageByIds(['msg-1', 'msg-2']), + ).called(1); + + // Verify user1's messages are removed from state + expect(channel.state?.messages.length, equals(1)); + expect( + channel.state?.messages.any((m) => m.user?.id == 'user-1'), + isFalse, + ); }, ); - }); - group('Reminder events', () { - const channelId = 'test-channel-id'; - const channelType = 'test-channel-type'; - late Channel channel; + test( + 'should not delete from persistence when hardDelete is false', + () async { + // Setup: Add messages + final user1 = User(id: 'user-1', name: 'User 1'); + final message1 = Message( + id: 'msg-1', + text: 'Message from user 1', + user: user1, + ); - setUp(() { - final channelState = _generateChannelState(channelId, channelType); - channel = Channel.fromState(client, channelState); - }); + channel.state?.addNewMessage(message1); - tearDown(() { - channel.dispose(); - }); + // Create user.messages.deleted event (soft delete) + final userMessagesDeletedEvent = Event( + cid: channel.cid, + type: EventType.userMessagesDeleted, + user: user1, + hardDelete: false, + ); - test('should handle reminder.created event', () async { - const messageId = 'test-message-id'; + // Dispatch event + client.addEvent(userMessagesDeletedEvent); - // Setup initial state with a message without reminder - final message = Message( - id: messageId, - user: client.state.currentUser, - text: 'Test message', - ); + // Wait for the event to be processed + await Future.delayed(Duration.zero); - channel.state?.updateMessage(message); + // Verify persistence deletion methods were NOT called + verifyNever(() => persistenceClient.deleteMessageByIds(any())); + verifyNever(() => persistenceClient.deletePinnedMessageByIds(any())); - // Verify initial state - no reminder - final initialMessage = channel.state?.messages.firstWhere( - (m) => m.id == messageId, - ); - expect(initialMessage?.reminder, isNull); + // Verify message is soft deleted (still in state) + expect(channel.state?.messages.length, equals(1)); + expect(channel.state?.messages.first.type, equals(MessageType.deleted)); + }, + ); - // Create reminder - final reminder = MessageReminder( - messageId: messageId, - channelCid: channel.cid!, - userId: 'test-user-id', - remindAt: DateTime.now().add(const Duration(days: 30)), - ); + test( + 'should delete all user messages including those only in storage', + () async { + final user1 = User(id: 'user-1', name: 'User 1'); + final user2 = User(id: 'user-2', name: 'User 2'); - // Create reminder.created event - final reminderCreatedEvent = Event( - cid: channel.cid, - type: EventType.reminderCreated, - reminder: reminder, - ); + final stateMessage1 = Message( + id: 'msg-1', + text: 'Message from user 1 in state', + user: user1, + pinned: true, + ); + final stateMessage2 = Message( + id: 'msg-2', + text: 'Message from user 2 in state', + user: user2, + ); + final stateThreadMessage1 = Message( + id: 'thread-msg-1', + text: 'Thread message from user 1 in state', + user: user1, + parentId: 'msg-1', + ); + final stateThreadMessage2 = Message( + id: 'thread-msg-2', + text: 'Another thread message from user 2 in state', + user: user2, + parentId: 'msg-1', + ); - // Dispatch event - client.addEvent(reminderCreatedEvent); + // Load the state with only 2 messages and 1 thread with 2 replies. + // Note: In reality, storage may contain many more user1 messages + // (e.g., older messages not loaded into state yet), but the delete + // operation should remove ALL of them from storage. + channel.state?.addNewMessage(stateMessage1); + channel.state?.addNewMessage(stateMessage2); + channel.state?.addNewMessage(stateThreadMessage1); + channel.state?.addNewMessage(stateThreadMessage2); + + // Verify initial state has only 2 messages and 1 thread with 2 replies + expect(channel.state?.messages.length, equals(2)); + expect(channel.state?.threads['msg-1']?.length, equals(2)); + + // Create user.messages.deleted event (hard delete) + final userMessagesDeletedEvent = Event( + cid: channel.cid, + type: EventType.userMessagesDeleted, + user: user1, + hardDelete: true, + ); - // Wait for the event to be processed - await Future.delayed(Duration.zero); + // Dispatch event + client.addEvent(userMessagesDeletedEvent); - // Verify message reminder was added - final updatedMessage = channel.state?.messages.firstWhere( - (m) => m.id == messageId, - ); - expect(updatedMessage?.reminder, isNotNull); - expect(updatedMessage?.reminder?.messageId, messageId); - expect(updatedMessage?.reminder?.remindAt, reminder.remindAt); - }); + // Wait for the event to be processed + await Future.delayed(Duration.zero); - test('should handle reminder.updated event', () async { - const messageId = 'test-message-id'; + // Verify user1's messages are removed from state + expect(channel.state?.messages.length, equals(1)); + expect(channel.state?.threads['msg-1']?.length, equals(1)); - // Setup initial state with a message with existing reminder - final remindAt = DateTime.now().add(const Duration(days: 30)); - final initialReminder = MessageReminder( - messageId: messageId, - channelCid: channel.cid!, - userId: 'test-user-id', - remindAt: remindAt, - ); + expect( + channel.state?.messages.any((m) => m.user?.id == 'user-1'), + isFalse, + ); - final message = Message( - id: messageId, - user: client.state.currentUser, - text: 'Test message', - reminder: initialReminder, - ); + expect( + channel.state?.threads['msg-1']?.any((m) => m.user?.id == 'user-1'), + isFalse, + ); - channel.state?.updateMessage(message); + // Verify persistence delete was called - this handles ALL messages + // in storage (both those in state AND those only in storage) + verify( + () => persistenceClient.deleteMessagesFromUser( + cid: channel.cid, + userId: user1.id, + hardDelete: true, + deletedAt: any(named: 'deletedAt'), + ), + ).called(1); - // Verify initial state - final initialMessage = channel.state?.messages.firstWhere( - (m) => m.id == messageId, - ); - expect(initialMessage?.reminder, isNotNull); - expect(initialMessage?.reminder?.remindAt, remindAt); + // Verify in-state messages were also removed from state's persistence + final capturedIds = + verify( + () => persistenceClient.deleteMessageByIds(captureAny()), + ).captured.first + as List; - // Create updated reminder - final updatedRemindAt = remindAt.add(const Duration(days: 15)); - final updatedReminder = initialReminder.copyWith( - remindAt: updatedRemindAt, - updatedAt: DateTime.now(), - ); + expect( + capturedIds, + containsAll([ + 'msg-1', // state message + 'thread-msg-1', // state thread message + ]), + ); + }, + ); + }); + }); - // Create reminder.updated event - final reminderUpdatedEvent = Event( - cid: channel.cid, - type: EventType.reminderUpdated, - reminder: updatedReminder, - ); + group('ChannelReadHelper', () { + const channelId = 'test-channel-id'; + const channelType = 'test-channel-type'; + late final client = MockStreamChatClient(); + + // A date in the distant past (Unix epoch), useful for representing old dates + final distantPast = DateTime.fromMillisecondsSinceEpoch(0, isUtc: true); + + setUpAll(() { + // detached loggers + when(() => client.detachedLogger(any())).thenAnswer((invocation) { + final name = invocation.positionalArguments.first; + return _createLogger(name); + }); - // Dispatch event - client.addEvent(reminderUpdatedEvent); + final retryPolicy = RetryPolicy( + shouldRetry: (_, __, ___) => false, + delayFactor: Duration.zero, + ); + when(() => client.retryPolicy).thenReturn(retryPolicy); - // Wait for the event to be processed - await Future.delayed(Duration.zero); + // fake clientState + final clientState = FakeClientState(); + when(() => client.state).thenReturn(clientState); - // Verify message reminder was updated - final updatedMessage = channel.state?.messages.firstWhere( - (m) => m.id == messageId, - ); - expect(updatedMessage?.reminder, isNotNull); - expect(updatedMessage?.reminder?.messageId, messageId); - expect(updatedMessage?.reminder?.remindAt, updatedRemindAt); - }); + // client logger + when(() => client.logger).thenReturn(_createLogger('mock-client-logger')); + }); - test('should handle reminder.deleted event', () async { - const messageId = 'test-message-id'; + test('userReadOf should return read for specific user', () { + final now = DateTime.now(); + final user1 = User(id: 'user-1', name: 'User 1'); + final user2 = User(id: 'user-2', name: 'User 2'); - // Setup initial state with a message with existing reminder - final remindAt = DateTime.now().add(const Duration(days: 30)); - final initialReminder = MessageReminder( - messageId: messageId, - channelCid: channel.cid!, - userId: 'test-user-id', - remindAt: remindAt, - ); + final reads = [ + Read(user: user1, lastRead: now), + Read(user: user2, lastRead: now.add(const Duration(minutes: 1))), + ]; - final message = Message( - id: messageId, - user: client.state.currentUser, - text: 'Test message', - reminder: initialReminder, - ); + final channelState = _generateChannelState(channelId, channelType); + final channel = Channel.fromState(client, channelState); + addTearDown(channel.dispose); - channel.state?.updateMessage(message); + channel.state!.updateChannelState( + ChannelState(channel: channelState.channel, read: reads), + ); - // Verify initial state - final initialMessage = channel.state?.messages.firstWhere( - (m) => m.id == messageId, - ); - expect(initialMessage?.reminder, isNotNull); + final user1Read = channel.state!.userReadOf(userId: 'user-1'); + expect(user1Read, isNotNull); + expect(user1Read!.user.id, 'user-1'); + expect(user1Read.lastRead, now); - // Create reminder.deleted event - final reminderDeletedEvent = Event( - cid: channel.cid, - type: EventType.reminderDeleted, - reminder: initialReminder, - ); + final user2Read = channel.state!.userReadOf(userId: 'user-2'); + expect(user2Read, isNotNull); + expect(user2Read!.user.id, 'user-2'); - // Dispatch event - client.addEvent(reminderDeletedEvent); + final nonExistentRead = channel.state!.userReadOf(userId: 'user-3'); + expect(nonExistentRead, isNull); + }); - // Wait for the event to be processed - await Future.delayed(Duration.zero); + test('userReadOf should return null when userId is null', () { + final channelState = _generateChannelState(channelId, channelType); + final channel = Channel.fromState(client, channelState); + addTearDown(channel.dispose); - // Verify message reminder was removed - final updatedMessage = channel.state?.messages.firstWhere( - (m) => m.id == messageId, - ); - expect(updatedMessage?.reminder, isNull); - }); + final read = channel.state!.userReadOf(userId: null); + expect(read, isNull); + }); - test('should handle reminder.created event for thread messages', - () async { - const messageId = 'test-message-id'; - const parentId = 'test-parent-id'; + test( + 'userReadStreamOf should emit read updates for specific user', + () async { + final now = DateTime.now(); + final user1 = User(id: 'user-1', name: 'User 1'); - // Setup initial state with a thread message without reminder - final threadMessage = Message( - id: messageId, - parentId: parentId, - user: client.state.currentUser, - text: 'Thread message', - ); + final channelState = _generateChannelState(channelId, channelType); + final channel = Channel.fromState(client, channelState); + addTearDown(channel.dispose); - channel.state?.updateMessage(threadMessage); + final readStream = channel.state!.userReadStreamOf(userId: 'user-1'); - // Verify initial state - no reminder - final initialMessage = channel.state?.threads[parentId]?.firstWhere( - (m) => m.id == messageId, + expectLater( + readStream, + emitsInOrder([ + isNull, // initial state + isA().having((r) => r.user.id, 'userId', 'user-1'), + ]), ); - expect(initialMessage?.reminder, isNull); - // Create reminder - final remindAt = DateTime.now().add(const Duration(days: 30)); - final reminder = MessageReminder( - messageId: messageId, - channelCid: channel.cid!, - userId: 'test-user-id', - remindAt: remindAt, + // Update with read + channel.state!.updateChannelState( + ChannelState( + channel: channelState.channel, + read: [Read(user: user1, lastRead: now)], + ), ); + }, + ); - // Create reminder.created event - final reminderCreatedEvent = Event( - cid: channel.cid, - type: EventType.reminderCreated, - reminder: reminder, - ); + test('readsOf should return reads that have marked message as read', () { + final now = DateTime.now(); + final sender = User(id: 'sender-id', name: 'Sender'); + final user1 = User(id: 'user-1', name: 'User 1'); + final user2 = User(id: 'user-2', name: 'User 2'); + final user3 = User(id: 'user-3', name: 'User 3'); - // Dispatch event - client.addEvent(reminderCreatedEvent); + final message = Message( + id: 'msg-1', + text: 'Test message', + user: sender, + createdAt: now, + ); - // Wait for the event to be processed - await Future.delayed(Duration.zero); + final reads = [ + // user1 has read the message + Read(user: user1, lastRead: now.add(const Duration(seconds: 1))), + // user2 has not read the message yet + Read(user: user2, lastRead: distantPast), + // user3 has read the message + Read(user: user3, lastRead: now.add(const Duration(seconds: 2))), + // sender should be excluded + Read(user: sender, lastRead: now.add(const Duration(seconds: 10))), + ]; - // Verify thread message reminder was added - final updatedMessage = channel.state?.threads[parentId]?.firstWhere( - (m) => m.id == messageId, - ); - expect(updatedMessage?.reminder, isNotNull); - expect(updatedMessage?.reminder?.messageId, messageId); - expect(updatedMessage?.reminder?.remindAt, reminder.remindAt); - }); + final channelState = _generateChannelState(channelId, channelType); + final channel = Channel.fromState(client, channelState); + addTearDown(channel.dispose); - test('should handle reminder.updated event for thread messages', - () async { - const messageId = 'test-message-id'; - const parentId = 'test-parent-id'; + channel.state!.updateChannelState( + ChannelState(channel: channelState.channel, read: reads), + ); - // Setup initial state with a thread message with existing reminder - final remindAt = DateTime.now().add(const Duration(days: 30)); - final initialReminder = MessageReminder( - messageId: messageId, - channelCid: channel.cid!, - userId: 'test-user-id', - remindAt: remindAt, - ); + final messageReads = channel.state!.readsOf(message: message); + expect(messageReads.length, 2); + expect(messageReads.map((r) => r.user.id), containsAll(['user-1', 'user-3'])); + expect(messageReads.map((r) => r.user.id), isNot(contains('user-2'))); + expect(messageReads.map((r) => r.user.id), isNot(contains('sender-id'))); + }); - final threadMessage = Message( - id: messageId, - parentId: parentId, - user: client.state.currentUser, - text: 'Thread message', - reminder: initialReminder, - ); + test('readsOfStream should emit read updates for a message', () async { + final now = DateTime.now(); + final sender = User(id: 'sender-id', name: 'Sender'); + final user1 = User(id: 'user-1', name: 'User 1'); - channel.state?.updateMessage(threadMessage); + final message = Message( + id: 'msg-1', + text: 'Test message', + user: sender, + createdAt: now, + ); - // Verify initial state - final initialMessage = channel.state?.threads[parentId]?.firstWhere( - (m) => m.id == messageId, - ); - expect(initialMessage?.reminder, isNotNull); - expect(initialMessage?.reminder?.remindAt, remindAt); + final channelState = _generateChannelState(channelId, channelType); + final channel = Channel.fromState(client, channelState); + addTearDown(channel.dispose); - // Create updated reminder - final updatedRemindAt = remindAt.add(const Duration(days: 15)); - final updatedReminder = initialReminder.copyWith( - remindAt: updatedRemindAt, - updatedAt: DateTime.now(), - ); + final readsStream = channel.state!.readsOfStream(message: message); - // Create reminder.updated event - final reminderUpdatedEvent = Event( - cid: channel.cid, - type: EventType.reminderUpdated, - reminder: updatedReminder, - ); + expectLater( + readsStream, + emitsInOrder([ + isEmpty, // initial state + hasLength(1), // after adding read + ]), + ); + + // Update with read + channel.state!.updateChannelState( + ChannelState( + channel: channelState.channel, + read: [Read(user: user1, lastRead: now.add(const Duration(seconds: 1)))], + ), + ); + }); - // Dispatch event - client.addEvent(reminderUpdatedEvent); + test('deliveriesOf should return reads that have delivered the message', () { + final now = DateTime.now(); + final sender = User(id: 'sender-id', name: 'Sender'); + final user1 = User(id: 'user-1', name: 'User 1'); + final user2 = User(id: 'user-2', name: 'User 2'); + final user3 = User(id: 'user-3', name: 'User 3'); + final user4 = User(id: 'user-4', name: 'User 4'); - // Wait for the event to be processed - await Future.delayed(Duration.zero); + final message = Message( + id: 'msg-1', + text: 'Test message', + user: sender, + createdAt: now, + ); - // Verify thread message reminder was updated - final updatedMessage = channel.state?.threads[parentId]?.firstWhere( - (m) => m.id == messageId, - ); - expect(updatedMessage?.reminder, isNotNull); - expect(updatedMessage?.reminder?.messageId, messageId); - expect(updatedMessage?.reminder?.remindAt, updatedRemindAt); - }); + final reads = [ + // user1 has delivered the message + Read( + user: user1, + lastRead: distantPast, + lastDeliveredAt: now.add(const Duration(seconds: 1)), + ), + // user2 has not delivered the message yet (lastDeliveredAt is before message) + Read( + user: user2, + lastRead: distantPast, + lastDeliveredAt: distantPast, + ), + // user3 has no lastDeliveredAt + Read( + user: user3, + lastRead: distantPast, + ), + // user4 has read the message (implicitly delivered) + Read( + user: user4, + lastRead: now.add(const Duration(seconds: 1)), + ), + // sender should be excluded + Read( + user: sender, + lastRead: now.add(const Duration(seconds: 10)), + lastDeliveredAt: now.add(const Duration(seconds: 10)), + ), + ]; - test('should handle reminder.deleted event for thread messages', - () async { - const messageId = 'test-message-id'; - const parentId = 'test-parent-id'; + final channelState = _generateChannelState(channelId, channelType); + final channel = Channel.fromState(client, channelState); + addTearDown(channel.dispose); - // Setup initial state with a thread message with existing reminder - final remindAt = DateTime.now().add(const Duration(days: 30)); - final initialReminder = MessageReminder( - messageId: messageId, - channelCid: channel.cid!, - userId: 'test-user-id', - remindAt: remindAt, - ); + channel.state!.updateChannelState( + ChannelState(channel: channelState.channel, read: reads), + ); - final threadMessage = Message( - id: messageId, - parentId: parentId, - user: client.state.currentUser, - text: 'Thread message', - reminder: initialReminder, - ); + final deliveries = channel.state!.deliveriesOf(message: message); + expect(deliveries.length, 2); + expect(deliveries.map((r) => r.user.id), containsAll(['user-1', 'user-4'])); + expect(deliveries.map((r) => r.user.id), isNot(contains('user-2'))); + expect(deliveries.map((r) => r.user.id), isNot(contains('user-3'))); + expect(deliveries.map((r) => r.user.id), isNot(contains('sender-id'))); + }); - channel.state?.updateMessage(threadMessage); + test('deliveriesOfStream should emit delivery updates for a message', () async { + final now = DateTime.now(); + final sender = User(id: 'sender-id', name: 'Sender'); + final user1 = User(id: 'user-1', name: 'User 1'); - // Verify initial state - final initialMessage = channel.state?.threads[parentId]?.firstWhere( - (m) => m.id == messageId, - ); - expect(initialMessage?.reminder, isNotNull); + final message = Message( + id: 'msg-1', + text: 'Test message', + user: sender, + createdAt: now, + ); - // Create reminder.deleted event - final reminderDeletedEvent = Event( - cid: channel.cid, - type: EventType.reminderDeleted, - reminder: initialReminder, - ); + final channelState = _generateChannelState(channelId, channelType); + final channel = Channel.fromState(client, channelState); + addTearDown(channel.dispose); - // Dispatch event - client.addEvent(reminderDeletedEvent); + final deliveriesStream = channel.state!.deliveriesOfStream(message: message); - // Wait for the event to be processed - await Future.delayed(Duration.zero); + expectLater( + deliveriesStream, + emitsInOrder([ + isEmpty, // initial state + hasLength(1), // after adding delivery + ]), + ); - // Verify thread message reminder was removed - final updatedMessage = channel.state?.threads[parentId]?.firstWhere( - (m) => m.id == messageId, - ); - expect(updatedMessage?.reminder, isNull); - }); + // Update with delivery + channel.state!.updateChannelState( + ChannelState( + channel: channelState.channel, + read: [ + Read( + user: user1, + lastRead: distantPast, + lastDeliveredAt: now.add(const Duration(seconds: 1)), + ), + ], + ), + ); }); + }); - group('Channel push preference events', () { - const channelId = 'test-channel-id'; - const channelType = 'test-channel-type'; - late Channel channel; - - setUp(() { - final channelState = _generateChannelState(channelId, channelType); - channel = Channel.fromState(client, channelState); - }); + group('ChannelCapabilityCheck', () { + const channelId = 'test-channel-id'; + const channelType = 'test-channel-type'; + late final client = MockStreamChatClient(); - tearDown(() { - channel.dispose(); + setUpAll(() { + // detached loggers + when(() => client.detachedLogger(any())).thenAnswer((invocation) { + final name = invocation.positionalArguments.first; + return _createLogger(name); }); - test('should handle channel.push_preference.updated event', () async { - // Verify initial state - expect(channel.state?.channelState.pushPreferences, isNull); - - // Create channel push preference - final channelPushPreference = ChannelPushPreference( - chatLevel: ChatLevel.mentions, - disabledUntil: DateTime.now().add(const Duration(hours: 1)), - ); + final retryPolicy = RetryPolicy( + shouldRetry: (_, __, ___) => false, + delayFactor: Duration.zero, + ); + when(() => client.retryPolicy).thenReturn(retryPolicy); - // Create channel.push_preference.updated event - final channelPushPreferenceUpdatedEvent = Event( - cid: channel.cid, - type: EventType.channelPushPreferenceUpdated, - channelPushPreference: channelPushPreference, - ); + // fake clientState + final clientState = FakeClientState(); + when(() => client.state).thenReturn(clientState); - // Dispatch event - client.addEvent(channelPushPreferenceUpdatedEvent); + // client logger + when(() => client.logger).thenReturn(_createLogger('mock-client-logger')); + }); - // Wait for the event to be processed - await Future.delayed(Duration.zero); + /// Parameterized test for channel capability extension properties + void testCapability( + String capabilityName, + ChannelCapability capability, + bool Function(Channel) getterMethod, + ) { + test('can$capabilityName returns false when capability is absent', () { + final channelState = _generateChannelState(channelId, channelType); + final channel = Channel.fromState(client, channelState); + expect(getterMethod(channel), false); + }); - // Verify channel push preferences were updated - final updatedPreferences = channel.state?.channelState.pushPreferences; - expect(updatedPreferences, isNotNull); - expect(updatedPreferences?.chatLevel, ChatLevel.mentions); - expect( - updatedPreferences?.disabledUntil, - channelPushPreference.disabledUntil, + test('can$capabilityName returns true when capability is present', () { + final channelState = _generateChannelState( + channelId, + channelType, + ownCapabilities: [capability], ); + final channel = Channel.fromState(client, channelState); + expect(getterMethod(channel), true); }); + } - test('should update existing channel push preferences', () async { - // Set initial push preferences - const initialPushPreference = ChannelPushPreference( - chatLevel: ChatLevel.all, - ); + // Test all channel capabilities using the parameterized function + testCapability( + 'SendMessage', + ChannelCapability.sendMessage, + (channel) => channel.canSendMessage, + ); - channel.state?.updateChannelState( - channel.state!.channelState.copyWith( - pushPreferences: initialPushPreference, - ), - ); + testCapability( + 'SendReply', + ChannelCapability.sendReply, + (channel) => channel.canSendReply, + ); - // Verify initial state - final pushPreferences = channel.state?.channelState.pushPreferences; - expect(pushPreferences?.chatLevel, ChatLevel.all); - expect(pushPreferences?.disabledUntil, isNull); + testCapability( + 'SendRestrictedVisibilityMessage', + ChannelCapability.sendRestrictedVisibilityMessage, + (channel) => channel.canSendRestrictedVisibilityMessage, + ); + + testCapability( + 'SendReaction', + ChannelCapability.sendReaction, + (channel) => channel.canSendReaction, + ); + + testCapability( + 'SendLinks', + ChannelCapability.sendLinks, + (channel) => channel.canSendLinks, + ); - // Create updated channel push preference - final updatedPushPreference = ChannelPushPreference( - chatLevel: ChatLevel.none, - disabledUntil: DateTime.now().add(const Duration(hours: 2)), - ); + testCapability( + 'CreateAttachment', + ChannelCapability.createAttachment, + (channel) => channel.canCreateAttachment, + ); - // Create channel.push_preference.updated event - final channelPushPreferenceUpdatedEvent = Event( - cid: channel.cid, - type: EventType.channelPushPreferenceUpdated, - channelPushPreference: updatedPushPreference, - ); + testCapability( + 'FreezeChannel', + ChannelCapability.freezeChannel, + (channel) => channel.canFreezeChannel, + ); - // Dispatch event - client.addEvent(channelPushPreferenceUpdatedEvent); + testCapability( + 'SetChannelCooldown', + ChannelCapability.setChannelCooldown, + (channel) => channel.canSetChannelCooldown, + ); - // Wait for the event to be processed - await Future.delayed(Duration.zero); + testCapability( + 'LeaveChannel', + ChannelCapability.leaveChannel, + (channel) => channel.canLeaveChannel, + ); - // Verify channel push preferences were updated - final updatedPreferences = channel.state?.channelState.pushPreferences; - expect(updatedPreferences?.chatLevel, ChatLevel.none); - expect( - updatedPreferences?.disabledUntil, - updatedPushPreference.disabledUntil, - ); - }); - }); - }); + testCapability( + 'JoinChannel', + ChannelCapability.joinChannel, + (channel) => channel.canJoinChannel, + ); - group('ChannelReadHelper', () { - const channelId = 'test-channel-id'; - const channelType = 'test-channel-type'; - late final client = MockStreamChatClient(); + testCapability( + 'PinMessage', + ChannelCapability.pinMessage, + (channel) => channel.canPinMessage, + ); - // A date in the distant past (Unix epoch), useful for representing old dates - final distantPast = DateTime.fromMillisecondsSinceEpoch(0, isUtc: true); + testCapability( + 'DeleteAnyMessage', + ChannelCapability.deleteAnyMessage, + (channel) => channel.canDeleteAnyMessage, + ); - setUpAll(() { - // detached loggers - when(() => client.detachedLogger(any())).thenAnswer((invocation) { - final name = invocation.positionalArguments.first; - return _createLogger(name); - }); + testCapability( + 'DeleteOwnMessage', + ChannelCapability.deleteOwnMessage, + (channel) => channel.canDeleteOwnMessage, + ); - final retryPolicy = RetryPolicy( - shouldRetry: (_, __, ___) => false, - delayFactor: Duration.zero, - ); - when(() => client.retryPolicy).thenReturn(retryPolicy); + testCapability( + 'UpdateAnyMessage', + ChannelCapability.updateAnyMessage, + (channel) => channel.canUpdateAnyMessage, + ); - // fake clientState - final clientState = FakeClientState(); - when(() => client.state).thenReturn(clientState); + testCapability( + 'UpdateOwnMessage', + ChannelCapability.updateOwnMessage, + (channel) => channel.canUpdateOwnMessage, + ); - // client logger - when(() => client.logger).thenReturn(_createLogger('mock-client-logger')); - }); + testCapability( + 'SearchMessages', + ChannelCapability.searchMessages, + (channel) => channel.canSearchMessages, + ); - test('userReadOf should return read for specific user', () { - final now = DateTime.now(); - final user1 = User(id: 'user-1', name: 'User 1'); - final user2 = User(id: 'user-2', name: 'User 2'); + testCapability( + 'SendTypingEvents', + ChannelCapability.sendTypingEvents, + (channel) => channel.canSendTypingEvents, + ); - final reads = [ - Read(user: user1, lastRead: now), - Read(user: user2, lastRead: now.add(const Duration(minutes: 1))), - ]; + testCapability( + 'UploadFile', + ChannelCapability.uploadFile, + (channel) => channel.canUploadFile, + ); - final channelState = _generateChannelState(channelId, channelType); - final channel = Channel.fromState(client, channelState); - addTearDown(channel.dispose); + testCapability( + 'DeleteChannel', + ChannelCapability.deleteChannel, + (channel) => channel.canDeleteChannel, + ); - channel.state!.updateChannelState( - ChannelState(channel: channelState.channel, read: reads), - ); + testCapability( + 'UpdateChannel', + ChannelCapability.updateChannel, + (channel) => channel.canUpdateChannel, + ); - final user1Read = channel.state!.userReadOf(userId: 'user-1'); - expect(user1Read, isNotNull); - expect(user1Read!.user.id, 'user-1'); - expect(user1Read.lastRead, now); + testCapability( + 'UpdateChannelMembers', + ChannelCapability.updateChannelMembers, + (channel) => channel.canUpdateChannelMembers, + ); - final user2Read = channel.state!.userReadOf(userId: 'user-2'); - expect(user2Read, isNotNull); - expect(user2Read!.user.id, 'user-2'); + testCapability( + 'UpdateThread', + ChannelCapability.updateThread, + (channel) => channel.canUpdateThread, + ); - final nonExistentRead = channel.state!.userReadOf(userId: 'user-3'); - expect(nonExistentRead, isNull); - }); + testCapability( + 'QuoteMessage', + ChannelCapability.quoteMessage, + (channel) => channel.canQuoteMessage, + ); - test('userReadOf should return null when userId is null', () { - final channelState = _generateChannelState(channelId, channelType); - final channel = Channel.fromState(client, channelState); - addTearDown(channel.dispose); + testCapability( + 'BanChannelMembers', + ChannelCapability.banChannelMembers, + (channel) => channel.canBanChannelMembers, + ); - final read = channel.state!.userReadOf(userId: null); - expect(read, isNull); - }); + testCapability( + 'FlagMessage', + ChannelCapability.flagMessage, + (channel) => channel.canFlagMessage, + ); - test( - 'userReadStreamOf should emit read updates for specific user', - () async { - final now = DateTime.now(); - final user1 = User(id: 'user-1', name: 'User 1'); + testCapability( + 'MuteChannel', + ChannelCapability.muteChannel, + (channel) => channel.canMuteChannel, + ); - final channelState = _generateChannelState(channelId, channelType); - final channel = Channel.fromState(client, channelState); - addTearDown(channel.dispose); + testCapability( + 'SendCustomEvents', + ChannelCapability.sendCustomEvents, + (channel) => channel.canSendCustomEvents, + ); - final readStream = channel.state!.userReadStreamOf(userId: 'user-1'); + testCapability( + 'ReceiveReadEvents', + ChannelCapability.readEvents, + (channel) => channel.canReceiveReadEvents, + ); - expectLater( - readStream, - emitsInOrder([ - isNull, // initial state - isA().having((r) => r.user.id, 'userId', 'user-1'), - ]), - ); + testCapability( + 'ReceiveConnectEvents', + ChannelCapability.connectEvents, + (channel) => channel.canReceiveConnectEvents, + ); - // Update with read - channel.state!.updateChannelState( - ChannelState( - channel: channelState.channel, - read: [Read(user: user1, lastRead: now)], - ), - ); - }, + testCapability( + 'UseTypingEvents', + ChannelCapability.typingEvents, + (channel) => channel.canUseTypingEvents, ); - test('readsOf should return reads that have marked message as read', () { - final now = DateTime.now(); - final sender = User(id: 'sender-id', name: 'Sender'); - final user1 = User(id: 'user-1', name: 'User 1'); - final user2 = User(id: 'user-2', name: 'User 2'); - final user3 = User(id: 'user-3', name: 'User 3'); + testCapability( + 'InSlowMode', + ChannelCapability.slowMode, + (channel) => channel.isInSlowMode, + ); - final message = Message( - id: 'msg-1', - text: 'Test message', - user: sender, - createdAt: now, - ); + testCapability( + 'SkipSlowMode', + ChannelCapability.skipSlowMode, + (channel) => channel.canSkipSlowMode, + ); + + testCapability( + 'SendPoll', + ChannelCapability.sendPoll, + (channel) => channel.canSendPoll, + ); - final reads = [ - // user1 has read the message - Read(user: user1, lastRead: now.add(const Duration(seconds: 1))), - // user2 has not read the message yet - Read(user: user2, lastRead: distantPast), - // user3 has read the message - Read(user: user3, lastRead: now.add(const Duration(seconds: 2))), - // sender should be excluded - Read(user: sender, lastRead: now.add(const Duration(seconds: 10))), - ]; + testCapability( + 'CastPollVote', + ChannelCapability.castPollVote, + (channel) => channel.canCastPollVote, + ); - final channelState = _generateChannelState(channelId, channelType); - final channel = Channel.fromState(client, channelState); - addTearDown(channel.dispose); + testCapability( + 'QueryPollVotes', + ChannelCapability.queryPollVotes, + (channel) => channel.canQueryPollVotes, + ); - channel.state!.updateChannelState( - ChannelState(channel: channelState.channel, read: reads), + testCapability( + 'ShareLocation', + ChannelCapability.shareLocation, + (channel) => channel.canShareLocation, + ); + + test('returns correct values with multiple capabilities', () { + final channelState = _generateChannelState( + channelId, + channelType, + ownCapabilities: [ + ChannelCapability.sendMessage, + ChannelCapability.sendReply, + ChannelCapability.deleteOwnMessage, + ], ); - final messageReads = channel.state!.readsOf(message: message); - expect(messageReads.length, 2); - expect(messageReads.map((r) => r.user.id), - containsAll(['user-1', 'user-3'])); - expect(messageReads.map((r) => r.user.id), isNot(contains('user-2'))); - expect(messageReads.map((r) => r.user.id), isNot(contains('sender-id'))); + final channel = Channel.fromState(client, channelState); + expect(channel.canSendMessage, true); + expect(channel.canSendReply, true); + expect(channel.canDeleteOwnMessage, true); + expect(channel.canDeleteAnyMessage, false); + expect(channel.canUpdateChannel, false); }); + }); - test('readsOfStream should emit read updates for a message', () async { - final now = DateTime.now(); - final sender = User(id: 'sender-id', name: 'Sender'); - final user1 = User(id: 'user-1', name: 'User 1'); + group('Channel State Validation and Cooldown', () { + late final client = MockStreamChatClient(); + const channelId = 'test-channel-id'; + const channelType = 'test-channel-type'; - final message = Message( - id: 'msg-1', - text: 'Test message', - user: sender, - createdAt: now, + setUpAll(() { + // detached loggers + when(() => client.detachedLogger(any())).thenAnswer((invocation) { + final name = invocation.positionalArguments.first; + return _createLogger(name); + }); + + final retryPolicy = RetryPolicy( + shouldRetry: (_, __, ___) => false, + delayFactor: Duration.zero, ); + when(() => client.retryPolicy).thenReturn(retryPolicy); - final channelState = _generateChannelState(channelId, channelType); - final channel = Channel.fromState(client, channelState); - addTearDown(channel.dispose); + // fake clientState + final clientState = FakeClientState(); + when(() => client.state).thenReturn(clientState); - final readsStream = channel.state!.readsOfStream(message: message); + // client logger + when(() => client.logger).thenReturn(_createLogger('mock-client-logger')); - expectLater( - readsStream, - emitsInOrder([ - isEmpty, // initial state - hasLength(1), // after adding read - ]), - ); + // mock channel delivery reporter + when( + () => client.channelDeliveryReporter.submitForDelivery(any()), + ).thenAnswer((_) async {}); + }); - // Update with read - channel.state!.updateChannelState( - ChannelState( - channel: channelState.channel, - read: [ - Read(user: user1, lastRead: now.add(const Duration(seconds: 1))) - ], - ), + group('Non-initialized channel state validation', () { + test( + 'should throw StateError when accessing cooldown on non-initialized channel', + () { + final channel = Channel(client, channelType, channelId); + expect(() => channel.cooldown, throwsA(isA())); + }, ); - }); - test('deliveriesOf should return reads that have delivered the message', + test( + 'should throw StateError when accessing getRemainingCooldown on non-initialized channel', () { - final now = DateTime.now(); - final sender = User(id: 'sender-id', name: 'Sender'); - final user1 = User(id: 'user-1', name: 'User 1'); - final user2 = User(id: 'user-2', name: 'User 2'); - final user3 = User(id: 'user-3', name: 'User 3'); - final user4 = User(id: 'user-4', name: 'User 4'); + final channel = Channel(client, channelType, channelId); + expect(channel.getRemainingCooldown, throwsA(isA())); + }, + ); - final message = Message( - id: 'msg-1', - text: 'Test message', - user: sender, - createdAt: now, + test( + 'should throw StateError when accessing cooldownStream on non-initialized channel', + () { + final channel = Channel(client, channelType, channelId); + expect(() => channel.cooldownStream, throwsA(isA())); + }, ); + }); - final reads = [ - // user1 has delivered the message - Read( - user: user1, - lastRead: distantPast, - lastDeliveredAt: now.add(const Duration(seconds: 1)), - ), - // user2 has not delivered the message yet (lastDeliveredAt is before message) - Read( - user: user2, - lastRead: distantPast, - lastDeliveredAt: distantPast, - ), - // user3 has no lastDeliveredAt - Read( - user: user3, - lastRead: distantPast, - ), - // user4 has read the message (implicitly delivered) - Read( - user: user4, - lastRead: now.add(const Duration(seconds: 1)), - ), - // sender should be excluded - Read( - user: sender, - lastRead: now.add(const Duration(seconds: 10)), - lastDeliveredAt: now.add(const Duration(seconds: 10)), - ), - ]; + group('Initialized channel cooldown functionality', () { + late Channel channel; - final channelState = _generateChannelState(channelId, channelType); - final channel = Channel.fromState(client, channelState); - addTearDown(channel.dispose); + setUp(() { + final channelState = _generateChannelState(channelId, channelType); + channel = Channel.fromState(client, channelState); + }); - channel.state!.updateChannelState( - ChannelState(channel: channelState.channel, read: reads), + tearDown(() => channel.dispose()); + + test( + 'should return default cooldown value of 0 for initialized channel', + () => expect(channel.cooldown, equals(0)), ); - final deliveries = channel.state!.deliveriesOf(message: message); - expect(deliveries.length, 2); - expect( - deliveries.map((r) => r.user.id), containsAll(['user-1', 'user-4'])); - expect(deliveries.map((r) => r.user.id), isNot(contains('user-2'))); - expect(deliveries.map((r) => r.user.id), isNot(contains('user-3'))); - expect(deliveries.map((r) => r.user.id), isNot(contains('sender-id'))); + test('should return custom cooldown value when set in channel model', () { + final channelWithCooldown = ChannelModel( + id: channelId, + type: channelType, + cooldown: 30, + ); + + final stateWithCooldown = ChannelState(channel: channelWithCooldown); + final testChannel = Channel.fromState(client, stateWithCooldown); + addTearDown(testChannel.dispose); + + expect(testChannel.cooldown, equals(30)); + }); + + test('should return 0 remaining cooldown when no cooldown is set', () { + expect(channel.getRemainingCooldown(), equals(0)); + }); + + test('should return cooldown stream with default value', () { + expectLater(channel.cooldownStream.take(1), emits(0)); + }); }); - test('deliveriesOfStream should emit delivery updates for a message', - () async { - final now = DateTime.now(); - final sender = User(id: 'sender-id', name: 'Sender'); - final user1 = User(id: 'user-1', name: 'User 1'); + group('Disposed channel state validation', () { + late Channel channel; - final message = Message( - id: 'msg-1', - text: 'Test message', - user: sender, - createdAt: now, - ); + setUp(() { + final channelState = _generateChannelState(channelId, channelType); + channel = Channel.fromState(client, channelState); + }); - final channelState = _generateChannelState(channelId, channelType); - final channel = Channel.fromState(client, channelState); - addTearDown(channel.dispose); + test( + 'should throw StateError when accessing cooldown after disposal', + () { + // First verify it works when initialized + expect(channel.cooldown, equals(0)); - final deliveriesStream = - channel.state!.deliveriesOfStream(message: message); + // Dispose the channel + channel.dispose(); - expectLater( - deliveriesStream, - emitsInOrder([ - isEmpty, // initial state - hasLength(1), // after adding delivery - ]), + // Now accessing cooldown should throw + expect(() => channel.cooldown, throwsA(isA())); + }, ); - // Update with delivery - channel.state!.updateChannelState( - ChannelState( - channel: channelState.channel, - read: [ - Read( - user: user1, - lastRead: distantPast, - lastDeliveredAt: now.add(const Duration(seconds: 1)), - ), - ], - ), + test( + 'should throw StateError when accessing getRemainingCooldown after disposal', + () { + // First verify it works when initialized + expect(channel.getRemainingCooldown(), equals(0)); + + // Dispose the channel + channel.dispose(); + + // Now accessing getRemainingCooldown should throw + expect(channel.getRemainingCooldown, throwsA(isA())); + }, ); - }); - }); - group('ChannelCapabilityCheck', () { - const channelId = 'test-channel-id'; - const channelType = 'test-channel-type'; - late final client = MockStreamChatClient(); + test( + 'should throw StateError when accessing cooldownStream after disposal', + () { + // First verify it works when initialized + expectLater(channel.cooldownStream.take(1), emits(0)); - setUpAll(() { - // detached loggers - when(() => client.detachedLogger(any())).thenAnswer((invocation) { - final name = invocation.positionalArguments.first; - return _createLogger(name); - }); + // Dispose the channel + channel.dispose(); - final retryPolicy = RetryPolicy( - shouldRetry: (_, __, ___) => false, - delayFactor: Duration.zero, + // Now accessing cooldownStream should throw + expect(() => channel.cooldownStream, throwsA(isA())); + }, ); - when(() => client.retryPolicy).thenReturn(retryPolicy); - // fake clientState - final clientState = FakeClientState(); - when(() => client.state).thenReturn(clientState); + test( + 'should handle race condition scenario - initialization then quick disposal', + () { + // This test simulates the race condition that was causing the production crash + final channelState = _generateChannelState(channelId, channelType); + final raceChannel = Channel.fromState(client, channelState); - // client logger - when(() => client.logger).thenReturn(_createLogger('mock-client-logger')); + // Verify it works initially + expect(raceChannel.cooldown, equals(0)); + + // Simulate quick disposal (like what happens with rapid navigation) + raceChannel.dispose(); + + // This should throw StateError instead of crashing with null check operator + expect(() => raceChannel.cooldown, throwsA(isA())); + + expect(raceChannel.getRemainingCooldown, throwsA(isA())); + }, + ); }); - /// Parameterized test for channel capability extension properties - void testCapability( - String capabilityName, - ChannelCapability capability, - bool Function(Channel) getterMethod, - ) { - test('can$capabilityName returns false when capability is absent', () { + group('Channel message count events', () { + const channelId = 'test-channel-id'; + const channelType = 'test-channel-type'; + late Channel channel; + + setUp(() { final channelState = _generateChannelState(channelId, channelType); - final channel = Channel.fromState(client, channelState); - expect(getterMethod(channel), false); + channel = Channel.fromState(client, channelState); }); - test('can$capabilityName returns true when capability is present', () { - final channelState = _generateChannelState( - channelId, - channelType, - ownCapabilities: [capability], - ); - final channel = Channel.fromState(client, channelState); - expect(getterMethod(channel), true); + tearDown(() { + channel.dispose(); }); - } - - // Test all channel capabilities using the parameterized function - testCapability( - 'SendMessage', - ChannelCapability.sendMessage, - (channel) => channel.canSendMessage, - ); - - testCapability( - 'SendReply', - ChannelCapability.sendReply, - (channel) => channel.canSendReply, - ); - - testCapability( - 'SendRestrictedVisibilityMessage', - ChannelCapability.sendRestrictedVisibilityMessage, - (channel) => channel.canSendRestrictedVisibilityMessage, - ); - testCapability( - 'SendReaction', - ChannelCapability.sendReaction, - (channel) => channel.canSendReaction, - ); + test( + 'should update channel messageCount when event contains channelMessageCount', + () async { + // Verify initial state - no messageCount + expect(channel.messageCount, isNull); - testCapability( - 'SendLinks', - ChannelCapability.sendLinks, - (channel) => channel.canSendLinks, - ); + // Create event with channelMessageCount + final messageCountEvent = Event( + cid: channel.cid, + type: EventType.messageNew, + channelMessageCount: 42, + ); - testCapability( - 'CreateAttachment', - ChannelCapability.createAttachment, - (channel) => channel.canCreateAttachment, - ); + // Dispatch event + client.addEvent(messageCountEvent); - testCapability( - 'FreezeChannel', - ChannelCapability.freezeChannel, - (channel) => channel.canFreezeChannel, - ); + // Wait for the event to be processed + await Future.delayed(Duration.zero); - testCapability( - 'SetChannelCooldown', - ChannelCapability.setChannelCooldown, - (channel) => channel.canSetChannelCooldown, - ); + // Verify channel messageCount was updated + expect(channel.messageCount, equals(42)); + }, + ); - testCapability( - 'LeaveChannel', - ChannelCapability.leaveChannel, - (channel) => channel.canLeaveChannel, - ); + test( + 'should update channel messageCount from message.new and message.deleted events', + () async { + // Test with message.new event - count increases + final messageNewEvent = Event( + cid: channel.cid, + type: EventType.messageNew, + message: Message( + id: 'new-message-1', + text: 'Hello world!', + user: User(id: 'user-1'), + ), + channelMessageCount: 1, + ); - testCapability( - 'JoinChannel', - ChannelCapability.joinChannel, - (channel) => channel.canJoinChannel, - ); + client.addEvent(messageNewEvent); + await Future.delayed(Duration.zero); + expect(channel.messageCount, equals(1)); - testCapability( - 'PinMessage', - ChannelCapability.pinMessage, - (channel) => channel.canPinMessage, - ); + // Test with another message.new event - count increases + final messageNewEvent2 = Event( + cid: channel.cid, + type: EventType.messageNew, + message: Message( + id: 'new-message-2', + text: 'Second message', + user: User(id: 'user-2'), + ), + channelMessageCount: 2, + ); - testCapability( - 'DeleteAnyMessage', - ChannelCapability.deleteAnyMessage, - (channel) => channel.canDeleteAnyMessage, - ); + client.addEvent(messageNewEvent2); + await Future.delayed(Duration.zero); + expect(channel.messageCount, equals(2)); - testCapability( - 'DeleteOwnMessage', - ChannelCapability.deleteOwnMessage, - (channel) => channel.canDeleteOwnMessage, - ); + // Test with message.deleted event - count decreases + final messageDeletedEvent = Event( + cid: channel.cid, + type: EventType.messageDeleted, + message: Message( + id: 'new-message-1', + text: 'Hello world!', + user: User(id: 'user-1'), + ), + channelMessageCount: 1, + ); - testCapability( - 'UpdateAnyMessage', - ChannelCapability.updateAnyMessage, - (channel) => channel.canUpdateAnyMessage, - ); + client.addEvent(messageDeletedEvent); + await Future.delayed(Duration.zero); + expect(channel.messageCount, equals(1)); + }, + ); - testCapability( - 'UpdateOwnMessage', - ChannelCapability.updateOwnMessage, - (channel) => channel.canUpdateOwnMessage, - ); + test( + 'should preserve other channel properties when updating messageCount', + () async { + // Set initial channel state with some properties + final initialChannel = channel.state?.channelState.channel?.copyWith( + extraData: {'name': 'Test Channel'}, + memberCount: 5, + frozen: true, + ); - testCapability( - 'SearchMessages', - ChannelCapability.searchMessages, - (channel) => channel.canSearchMessages, - ); + if (initialChannel != null) { + channel.state?.updateChannelState( + channel.state!.channelState.copyWith(channel: initialChannel), + ); + } - testCapability( - 'SendTypingEvents', - ChannelCapability.sendTypingEvents, - (channel) => channel.canSendTypingEvents, - ); + // Verify initial state + expect(channel.name, 'Test Channel'); + expect(channel.memberCount, equals(5)); + expect(channel.frozen, equals(true)); + expect(channel.messageCount, isNull); - testCapability( - 'UploadFile', - ChannelCapability.uploadFile, - (channel) => channel.canUploadFile, - ); + // Update messageCount via event + final messageCountEvent = Event( + cid: channel.cid, + type: EventType.messageNew, + channelMessageCount: 100, + ); - testCapability( - 'DeleteChannel', - ChannelCapability.deleteChannel, - (channel) => channel.canDeleteChannel, - ); + client.addEvent(messageCountEvent); + await Future.delayed(Duration.zero); - testCapability( - 'UpdateChannel', - ChannelCapability.updateChannel, - (channel) => channel.canUpdateChannel, - ); + // Verify messageCount was updated while preserving other properties + expect(channel.messageCount, equals(100)); + expect(channel.name, 'Test Channel'); + expect(channel.memberCount, equals(5)); + expect(channel.frozen, equals(true)); + }, + ); - testCapability( - 'UpdateChannelMembers', - ChannelCapability.updateChannelMembers, - (channel) => channel.canUpdateChannelMembers, - ); + test( + 'should provide messageCountStream for reactive updates', + () async { + expectLater( + channel.messageCountStream.distinct(), + emitsInOrder([null, 1, 5, 10]), + ); - testCapability( - 'UpdateThread', - ChannelCapability.updateThread, - (channel) => channel.canUpdateThread, - ); + // Update messageCount multiple times + final counts = [1, 5, 10]; + for (final count in counts) { + final event = Event( + cid: channel.cid, + type: EventType.messageNew, + message: Message( + id: 'msg-$count', + text: 'Message $count', + user: User(id: 'user-1'), + ), + channelMessageCount: count, + ); - testCapability( - 'QuoteMessage', - ChannelCapability.quoteMessage, - (channel) => channel.canQuoteMessage, - ); + client.addEvent(event); + await Future.delayed(Duration.zero); + } + }, + ); + }); + }); - testCapability( - 'BanChannelMembers', - ChannelCapability.banChannelMembers, - (channel) => channel.canBanChannelMembers, - ); + group('Channel filterTags', () { + late final client = MockStreamChatClient(); + const channelId = 'test-channel-id'; + const channelType = 'test-channel-type'; - testCapability( - 'FlagMessage', - ChannelCapability.flagMessage, - (channel) => channel.canFlagMessage, - ); + setUpAll(() { + // detached loggers + when(() => client.detachedLogger(any())).thenAnswer((invocation) { + final name = invocation.positionalArguments.first; + return _createLogger(name); + }); - testCapability( - 'MuteChannel', - ChannelCapability.muteChannel, - (channel) => channel.canMuteChannel, - ); + final retryPolicy = RetryPolicy( + shouldRetry: (_, __, ___) => false, + delayFactor: Duration.zero, + ); + when(() => client.retryPolicy).thenReturn(retryPolicy); - testCapability( - 'SendCustomEvents', - ChannelCapability.sendCustomEvents, - (channel) => channel.canSendCustomEvents, - ); + // fake clientState + final clientState = FakeClientState(); + when(() => client.state).thenReturn(clientState); - testCapability( - 'ReceiveReadEvents', - ChannelCapability.readEvents, - (channel) => channel.canReceiveReadEvents, - ); + // client logger + when(() => client.logger).thenReturn(_createLogger('mock-client-logger')); + }); - testCapability( - 'ReceiveConnectEvents', - ChannelCapability.connectEvents, - (channel) => channel.canReceiveConnectEvents, - ); + test('should return filterTags from channel state', () { + final channelModel = ChannelModel( + id: channelId, + type: channelType, + filterTags: ['tag1', 'tag2'], + ); - testCapability( - 'UseTypingEvents', - ChannelCapability.typingEvents, - (channel) => channel.canUseTypingEvents, - ); + final channelState = ChannelState(channel: channelModel); + final testChannel = Channel.fromState(client, channelState); + addTearDown(testChannel.dispose); - testCapability( - 'InSlowMode', - ChannelCapability.slowMode, - (channel) => channel.isInSlowMode, - ); + expect(testChannel.filterTags, equals(['tag1', 'tag2'])); + }); - testCapability( - 'SkipSlowMode', - ChannelCapability.skipSlowMode, - (channel) => channel.canSkipSlowMode, - ); + test('should update filterTags when channel state is updated', () { + final channelModel = ChannelModel( + id: channelId, + type: channelType, + filterTags: ['tag1', 'tag2'], + ); - testCapability( - 'SendPoll', - ChannelCapability.sendPoll, - (channel) => channel.canSendPoll, - ); + final channelState = ChannelState(channel: channelModel); + final testChannel = Channel.fromState(client, channelState); + addTearDown(testChannel.dispose); - testCapability( - 'CastPollVote', - ChannelCapability.castPollVote, - (channel) => channel.canCastPollVote, - ); + expect(testChannel.filterTags, equals(['tag1', 'tag2'])); - testCapability( - 'QueryPollVotes', - ChannelCapability.queryPollVotes, - (channel) => channel.canQueryPollVotes, - ); + final updatedChannel = channelModel.copyWith( + filterTags: ['tag3', 'tag4', 'tag5'], + ); - test('returns correct values with multiple capabilities', () { - final channelState = _generateChannelState( - channelId, - channelType, - ownCapabilities: [ - ChannelCapability.sendMessage, - ChannelCapability.sendReply, - ChannelCapability.deleteOwnMessage, - ], + testChannel.state?.updateChannelState( + testChannel.state!.channelState.copyWith(channel: updatedChannel), ); - final channel = Channel.fromState(client, channelState); - expect(channel.canSendMessage, true); - expect(channel.canSendReply, true); - expect(channel.canDeleteOwnMessage, true); - expect(channel.canDeleteAnyMessage, false); - expect(channel.canUpdateChannel, false); + expect(testChannel.filterTags, equals(['tag3', 'tag4', 'tag5'])); }); }); - group('Channel State Validation and Cooldown', () { - late final client = MockStreamChatClient(); + group('Typing Indicator', () { const channelId = 'test-channel-id'; const channelType = 'test-channel-type'; + late final client = MockStreamChatClient(); setUpAll(() { + // Fallback values + registerFallbackValue(FakeMessage()); + registerFallbackValue(FakeAttachmentFile()); + registerFallbackValue(FakeEvent()); + // detached loggers when(() => client.detachedLogger(any())).thenAnswer((invocation) { final name = invocation.positionalArguments.first; @@ -6054,313 +8433,309 @@ void main() { // client logger when(() => client.logger).thenReturn(_createLogger('mock-client-logger')); - - // mock channel delivery reporter - when( - () => client.channelDeliveryReporter.submitForDelivery(any()), - ).thenAnswer((_) async {}); }); - group('Non-initialized channel state validation', () { - test( - 'should throw StateError when accessing cooldown on non-initialized channel', - () { - final channel = Channel(client, channelType, channelId); - expect(() => channel.cooldown, throwsA(isA())); - }, - ); + test( + ".keystore should return if we don't have the capability", + () async { + final channelState = _generateChannelState( + channelId, + channelType, + ownCapabilities: [], // no typingEvents capability + ); - test( - 'should throw StateError when accessing getRemainingCooldown on non-initialized channel', - () { - final channel = Channel(client, channelType, channelId); - expect(channel.getRemainingCooldown, throwsA(isA())); - }, - ); + final channel = Channel.fromState(client, channelState); + addTearDown(channel.dispose); + + final typingEvent = Event(type: EventType.typingStart); + + await expectLater(channel.keyStroke(), completes); + + verifyNever( + () => client.sendEvent( + channelId, + channelType, + any(that: isSameEventAs(typingEvent)), + ), + ); + }, + ); + + test( + '.keystore should return when user privacy settings is disabled', + () async { + final currentUser = client.state.currentUser; + final updatedUser = currentUser?.copyWith( + privacySettings: const PrivacySettings( + typingIndicators: TypingIndicators(enabled: false), + ), + ); - test( - 'should throw StateError when accessing cooldownStream on non-initialized channel', - () { - final channel = Channel(client, channelType, channelId); - expect(() => channel.cooldownStream, throwsA(isA())); - }, - ); - }); + client.state.updateUser(updatedUser); + addTearDown(() => client.state.updateUser(currentUser)); - group('Initialized channel cooldown functionality', () { - late Channel channel; + final channelState = _generateChannelState( + channelId, + channelType, + ownCapabilities: [ChannelCapability.typingEvents], + ); - setUp(() { - final channelState = _generateChannelState(channelId, channelType); - channel = Channel.fromState(client, channelState); - }); + final channel = Channel.fromState(client, channelState); + addTearDown(channel.dispose); - tearDown(() => channel.dispose()); + final typingEvent = Event(type: EventType.typingStart); - test( - 'should return default cooldown value of 0 for initialized channel', - () => expect(channel.cooldown, equals(0)), - ); + await expectLater(channel.keyStroke(), completes); - test('should return custom cooldown value when set in channel model', () { - final channelWithCooldown = ChannelModel( - id: channelId, - type: channelType, - cooldown: 30, + verifyNever( + () => client.sendEvent( + channelId, + channelType, + any(that: isSameEventAs(typingEvent)), + ), ); + }, + ); - final stateWithCooldown = ChannelState(channel: channelWithCooldown); - final testChannel = Channel.fromState(client, stateWithCooldown); - addTearDown(testChannel.dispose); - - expect(testChannel.cooldown, equals(30)); - }); + test( + ".keystore should send 'typingStart' event if there is not already a typingEvent or the difference between the two is > 3 seconds", + () async { + final channelState = _generateChannelState( + channelId, + channelType, + ownCapabilities: [ChannelCapability.typingEvents], + ); - test('should return 0 remaining cooldown when no cooldown is set', () { - expect(channel.getRemainingCooldown(), equals(0)); - }); + final channel = Channel.fromState(client, channelState); + addTearDown(channel.dispose); - test('should return cooldown stream with default value', () { - expectLater(channel.cooldownStream.take(1), emits(0)); - }); - }); + final startTypingEvent = Event(type: EventType.typingStart); + final stopTypingEvent = Event(type: EventType.typingStop); - group('Disposed channel state validation', () { - late Channel channel; + when( + () => client.sendEvent( + channelId, + channelType, + any(that: isSameEventAs(startTypingEvent)), + ), + ).thenAnswer((_) async => EmptyResponse()); - setUp(() { - final channelState = _generateChannelState(channelId, channelType); - channel = Channel.fromState(client, channelState); - }); + when( + () => client.sendEvent( + channelId, + channelType, + any(that: isSameEventAs(stopTypingEvent)), + ), + ).thenAnswer((_) async => EmptyResponse()); - test( - 'should throw StateError when accessing cooldown after disposal', - () { - // First verify it works when initialized - expect(channel.cooldown, equals(0)); + await expectLater(channel.keyStroke(), completes); - // Dispose the channel - channel.dispose(); + verify( + () => client.sendEvent( + channelId, + channelType, + any(that: isSameEventAs(startTypingEvent)), + ), + ).called(1); - // Now accessing cooldown should throw - expect(() => channel.cooldown, throwsA(isA())); - }, - ); + verify( + () => client.sendEvent( + channelId, + channelType, + any(that: isSameEventAs(stopTypingEvent)), + ), + ).called(1); + }, + ); - test( - 'should throw StateError when accessing getRemainingCooldown after disposal', - () { - // First verify it works when initialized - expect(channel.getRemainingCooldown(), equals(0)); + test( + ".startTyping should return if we don't have the capability", + () async { + final channelState = _generateChannelState( + channelId, + channelType, + ownCapabilities: [], // no typingEvents capability + ); - // Dispose the channel - channel.dispose(); + final channel = Channel.fromState(client, channelState); + addTearDown(channel.dispose); - // Now accessing getRemainingCooldown should throw - expect(channel.getRemainingCooldown, throwsA(isA())); - }, - ); + final typingStartEvent = Event(type: EventType.typingStart); - test( - 'should throw StateError when accessing cooldownStream after disposal', - () { - // First verify it works when initialized - expectLater(channel.cooldownStream.take(1), emits(0)); + await expectLater(channel.startTyping(), completes); - // Dispose the channel - channel.dispose(); + verifyNever( + () => client.sendEvent( + channelId, + channelType, + any(that: isSameEventAs(typingStartEvent)), + ), + ); + }, + ); - // Now accessing cooldownStream should throw - expect(() => channel.cooldownStream, throwsA(isA())); - }, - ); + test( + '.startTyping should return when user privacy settings is disabled', + () async { + final currentUser = client.state.currentUser; + final updatedUser = currentUser?.copyWith( + privacySettings: const PrivacySettings( + typingIndicators: TypingIndicators(enabled: false), + ), + ); - test( - 'should handle race condition scenario - initialization then quick disposal', - () { - // This test simulates the race condition that was causing the production crash - final channelState = _generateChannelState(channelId, channelType); - final raceChannel = Channel.fromState(client, channelState); + client.state.updateUser(updatedUser); + addTearDown(() => client.state.updateUser(currentUser)); - // Verify it works initially - expect(raceChannel.cooldown, equals(0)); + final channelState = _generateChannelState( + channelId, + channelType, + ownCapabilities: [ChannelCapability.typingEvents], + ); - // Simulate quick disposal (like what happens with rapid navigation) - raceChannel.dispose(); + final channel = Channel.fromState(client, channelState); + addTearDown(channel.dispose); - // This should throw StateError instead of crashing with null check operator - expect(() => raceChannel.cooldown, throwsA(isA())); + final typingStartEvent = Event(type: EventType.typingStart); - expect(raceChannel.getRemainingCooldown, throwsA(isA())); - }, - ); - }); + await expectLater(channel.startTyping(), completes); - group('Channel message count events', () { - const channelId = 'test-channel-id'; - const channelType = 'test-channel-type'; - late Channel channel; + verifyNever( + () => client.sendEvent( + channelId, + channelType, + any(that: isSameEventAs(typingStartEvent)), + ), + ); + }, + ); - setUp(() { - final channelState = _generateChannelState(channelId, channelType); - channel = Channel.fromState(client, channelState); - }); + test(".startTyping should send 'typingStart' successfully", () async { + final channelState = _generateChannelState( + channelId, + channelType, + ownCapabilities: [ChannelCapability.typingEvents], + ); - tearDown(() { - channel.dispose(); - }); + final channel = Channel.fromState(client, channelState); + addTearDown(channel.dispose); - test( - 'should update channel messageCount when event contains channelMessageCount', - () async { - // Verify initial state - no messageCount - expect(channel.messageCount, isNull); + final typingStartEvent = Event(type: EventType.typingStart); - // Create event with channelMessageCount - final messageCountEvent = Event( - cid: channel.cid, - type: EventType.messageNew, - channelMessageCount: 42, - ); + when( + () => client.sendEvent( + channelId, + channelType, + any(that: isSameEventAs(typingStartEvent)), + ), + ).thenAnswer((_) async => EmptyResponse()); - // Dispatch event - client.addEvent(messageCountEvent); + await expectLater(channel.startTyping(), completes); - // Wait for the event to be processed - await Future.delayed(Duration.zero); + verify( + () => client.sendEvent( + channelId, + channelType, + any(that: isSameEventAs(typingStartEvent)), + ), + ).called(1); + }); - // Verify channel messageCount was updated - expect(channel.messageCount, equals(42)); - }, + test(".stopTyping should return if we don't have the capability", () async { + final channelState = _generateChannelState( + channelId, + channelType, + ownCapabilities: [], // no typingEvents capability ); - test( - 'should update channel messageCount from message.new and message.deleted events', - () async { - // Test with message.new event - count increases - final messageNewEvent = Event( - cid: channel.cid, - type: EventType.messageNew, - message: Message( - id: 'new-message-1', - text: 'Hello world!', - user: User(id: 'user-1'), - ), - channelMessageCount: 1, - ); + final channel = Channel.fromState(client, channelState); + addTearDown(channel.dispose); - client.addEvent(messageNewEvent); - await Future.delayed(Duration.zero); - expect(channel.messageCount, equals(1)); + final typingStopEvent = Event(type: EventType.typingStop); - // Test with another message.new event - count increases - final messageNewEvent2 = Event( - cid: channel.cid, - type: EventType.messageNew, - message: Message( - id: 'new-message-2', - text: 'Second message', - user: User(id: 'user-2'), - ), - channelMessageCount: 2, - ); + await expectLater(channel.stopTyping(), completes); - client.addEvent(messageNewEvent2); - await Future.delayed(Duration.zero); - expect(channel.messageCount, equals(2)); + verifyNever( + () => client.sendEvent( + channelId, + channelType, + any(that: isSameEventAs(typingStopEvent)), + ), + ); + }); - // Test with message.deleted event - count decreases - final messageDeletedEvent = Event( - cid: channel.cid, - type: EventType.messageDeleted, - message: Message( - id: 'new-message-1', - text: 'Hello world!', - user: User(id: 'user-1'), - ), - channelMessageCount: 1, - ); + test( + '.stopTyping should return when user privacy settings is disabled', + () async { + final currentUser = client.state.currentUser; + final updatedUser = currentUser?.copyWith( + privacySettings: const PrivacySettings( + typingIndicators: TypingIndicators(enabled: false), + ), + ); - client.addEvent(messageDeletedEvent); - await Future.delayed(Duration.zero); - expect(channel.messageCount, equals(1)); - }, - ); + client.state.updateUser(updatedUser); + addTearDown(() => client.state.updateUser(currentUser)); - test( - 'should preserve other channel properties when updating messageCount', - () async { - // Set initial channel state with some properties - final initialChannel = channel.state?.channelState.channel?.copyWith( - extraData: {'name': 'Test Channel'}, - memberCount: 5, - frozen: true, - ); + final channelState = _generateChannelState( + channelId, + channelType, + ownCapabilities: [ChannelCapability.typingEvents], + ); - if (initialChannel != null) { - channel.state?.updateChannelState( - channel.state!.channelState.copyWith(channel: initialChannel), - ); - } + final channel = Channel.fromState(client, channelState); + addTearDown(channel.dispose); - // Verify initial state - expect(channel.name, 'Test Channel'); - expect(channel.memberCount, equals(5)); - expect(channel.frozen, equals(true)); - expect(channel.messageCount, isNull); + final typingStopEvent = Event(type: EventType.typingStop); - // Update messageCount via event - final messageCountEvent = Event( - cid: channel.cid, - type: EventType.messageNew, - channelMessageCount: 100, - ); + await expectLater(channel.stopTyping(), completes); - client.addEvent(messageCountEvent); - await Future.delayed(Duration.zero); + verifyNever( + () => client.sendEvent( + channelId, + channelType, + any(that: isSameEventAs(typingStopEvent)), + ), + ); + }, + ); - // Verify messageCount was updated while preserving other properties - expect(channel.messageCount, equals(100)); - expect(channel.name, 'Test Channel'); - expect(channel.memberCount, equals(5)); - expect(channel.frozen, equals(true)); - }, + test(".stopTyping should send 'typingStop' successfully", () async { + final channelState = _generateChannelState( + channelId, + channelType, + ownCapabilities: [ChannelCapability.typingEvents], ); - test( - 'should provide messageCountStream for reactive updates', - () async { - expectLater( - channel.messageCountStream.distinct(), - emitsInOrder([null, 1, 5, 10]), - ); + final channel = Channel.fromState(client, channelState); + addTearDown(channel.dispose); - // Update messageCount multiple times - final counts = [1, 5, 10]; - for (final count in counts) { - final event = Event( - cid: channel.cid, - type: EventType.messageNew, - message: Message( - id: 'msg-$count', - text: 'Message $count', - user: User(id: 'user-1'), - ), - channelMessageCount: count, - ); + final typingStopEvent = Event(type: EventType.typingStop); - client.addEvent(event); - await Future.delayed(Duration.zero); - } - }, - ); + when( + () => client.sendEvent( + channelId, + channelType, + any(that: isSameEventAs(typingStopEvent)), + ), + ).thenAnswer((_) async => EmptyResponse()); + + await expectLater(channel.stopTyping(), completes); + + verify( + () => client.sendEvent( + channelId, + channelType, + any(that: isSameEventAs(typingStopEvent)), + ), + ).called(1); }); }); - group('Channel filterTags', () { - late final client = MockStreamChatClient(); + group('Read Receipts', () { const channelId = 'test-channel-id'; const channelType = 'test-channel-type'; + late final client = MockStreamChatClient(); setUpAll(() { // detached loggers @@ -6383,670 +8758,985 @@ void main() { when(() => client.logger).thenReturn(_createLogger('mock-client-logger')); }); - test('should return filterTags from channel state', () { - final channelModel = ChannelModel( - id: channelId, - type: channelType, - filterTags: ['tag1', 'tag2'], - ); + test( + ".markRead should throw if we don't have the capability", + () async { + final channelState = _generateChannelState( + channelId, + channelType, + ownCapabilities: [], // no readEvents capability + ); - final channelState = ChannelState(channel: channelModel); - final testChannel = Channel.fromState(client, channelState); - addTearDown(testChannel.dispose); + final channel = Channel.fromState(client, channelState); + addTearDown(channel.dispose); - expect(testChannel.filterTags, equals(['tag1', 'tag2'])); - }); + await expectLater( + channel.markRead(messageId: 'message-id-123'), + throwsA(isA()), + ); + }, + ); - test('should update filterTags when channel state is updated', () { - final channelModel = ChannelModel( - id: channelId, - type: channelType, - filterTags: ['tag1', 'tag2'], - ); + test( + '.markRead should succeed if we have the capability', + () async { + final channelState = _generateChannelState( + channelId, + channelType, + ownCapabilities: [ChannelCapability.readEvents], + ); + + final channel = Channel.fromState(client, channelState); + addTearDown(channel.dispose); + + when( + () => client.markChannelRead( + channelId, + channelType, + messageId: 'message-id-123', + ), + ).thenAnswer((_) async => EmptyResponse()); + + await expectLater( + channel.markRead(messageId: 'message-id-123'), + completes, + ); + + verify( + () => client.markChannelRead( + channelId, + channelType, + messageId: 'message-id-123', + ), + ).called(1); + }, + ); + + test( + ".markUnread should throw if we don't have the capability", + () async { + final channelState = _generateChannelState( + channelId, + channelType, + ownCapabilities: [], // no readEvents capability + ); - final channelState = ChannelState(channel: channelModel); - final testChannel = Channel.fromState(client, channelState); - addTearDown(testChannel.dispose); + final channel = Channel.fromState(client, channelState); + addTearDown(channel.dispose); - expect(testChannel.filterTags, equals(['tag1', 'tag2'])); + await expectLater( + channel.markUnread('message-id-123'), + throwsA(isA()), + ); + }, + ); - final updatedChannel = channelModel.copyWith( - filterTags: ['tag3', 'tag4', 'tag5'], - ); + test( + '.markUnread should succeed if we have the capability', + () async { + final channelState = _generateChannelState( + channelId, + channelType, + ownCapabilities: [ChannelCapability.readEvents], + ); - testChannel.state?.updateChannelState( - testChannel.state!.channelState.copyWith(channel: updatedChannel), - ); + final channel = Channel.fromState(client, channelState); + addTearDown(channel.dispose); - expect(testChannel.filterTags, equals(['tag3', 'tag4', 'tag5'])); - }); - }); + when( + () => client.markChannelUnread( + channelId, + channelType, + 'message-id-123', + ), + ).thenAnswer((_) async => EmptyResponse()); - group('Typing Indicator', () { - const channelId = 'test-channel-id'; - const channelType = 'test-channel-type'; - late final client = MockStreamChatClient(); + await expectLater( + channel.markUnread('message-id-123'), + completes, + ); - setUpAll(() { - // Fallback values - registerFallbackValue(FakeMessage()); - registerFallbackValue(FakeAttachmentFile()); - registerFallbackValue(FakeEvent()); + verify( + () => client.markChannelUnread( + channelId, + channelType, + 'message-id-123', + ), + ).called(1); + }, + ); - // detached loggers - when(() => client.detachedLogger(any())).thenAnswer((invocation) { - final name = invocation.positionalArguments.first; - return _createLogger(name); - }); + test( + ".markUnreadByTimestamp should throw if we don't have the capability", + () async { + final channelState = _generateChannelState( + channelId, + channelType, + ownCapabilities: [], // no readEvents capability + ); - final retryPolicy = RetryPolicy( - shouldRetry: (_, __, ___) => false, - delayFactor: Duration.zero, - ); - when(() => client.retryPolicy).thenReturn(retryPolicy); + final channel = Channel.fromState(client, channelState); + addTearDown(channel.dispose); - // fake clientState - final clientState = FakeClientState(); - when(() => client.state).thenReturn(clientState); + final timestamp = DateTime.parse('2024-01-01T00:00:00Z'); - // client logger - when(() => client.logger).thenReturn(_createLogger('mock-client-logger')); - }); + await expectLater( + channel.markUnreadByTimestamp(timestamp), + throwsA(isA()), + ); + }, + ); test( - ".keystore should return if we don't have the capability", + '.markUnreadByTimestamp should succeed if we have the capability', () async { final channelState = _generateChannelState( channelId, channelType, - ownCapabilities: [], // no typingEvents capability + ownCapabilities: [ChannelCapability.readEvents], ); final channel = Channel.fromState(client, channelState); addTearDown(channel.dispose); - final typingEvent = Event(type: EventType.typingStart); - - await expectLater(channel.keyStroke(), completes); + final timestamp = DateTime.parse('2024-01-01T00:00:00Z'); - verifyNever( - () => client.sendEvent( + when( + () => client.markChannelUnreadByTimestamp( channelId, channelType, - any(that: isSameEventAs(typingEvent)), + timestamp, ), + ).thenAnswer((_) async => EmptyResponse()); + + await expectLater( + channel.markUnreadByTimestamp(timestamp), + completes, ); + + verify( + () => client.markChannelUnreadByTimestamp( + channelId, + channelType, + timestamp, + ), + ).called(1); }, ); test( - '.keystore should return when user privacy settings is disabled', + ".markThreadRead should throw if we don't have the capability", () async { - final currentUser = client.state.currentUser; - final updatedUser = currentUser?.copyWith( - privacySettings: const PrivacySettings( - typingIndicators: TypingIndicators(enabled: false), - ), + final channelState = _generateChannelState( + channelId, + channelType, + ownCapabilities: [], // no readEvents capability ); - client.state.updateUser(updatedUser); - addTearDown(() => client.state.updateUser(currentUser)); + final channel = Channel.fromState(client, channelState); + addTearDown(channel.dispose); + + await expectLater( + channel.markThreadRead('thread-id-123'), + throwsA(isA()), + ); + }, + ); + test( + '.markThreadRead should succeed if we have the capability', + () async { final channelState = _generateChannelState( channelId, channelType, - ownCapabilities: [ChannelCapability.typingEvents], + ownCapabilities: [ChannelCapability.readEvents], ); final channel = Channel.fromState(client, channelState); addTearDown(channel.dispose); - final typingEvent = Event(type: EventType.typingStart); + when( + () => client.markThreadRead( + channelId, + channelType, + 'thread-id-123', + ), + ).thenAnswer((_) async => EmptyResponse()); - await expectLater(channel.keyStroke(), completes); + await expectLater( + channel.markThreadRead('thread-id-123'), + completes, + ); - verifyNever( - () => client.sendEvent( + verify( + () => client.markThreadRead( channelId, channelType, - any(that: isSameEventAs(typingEvent)), + 'thread-id-123', ), + ).called(1); + }, + ); + + test( + ".markThreadUnread should throw if we don't have the capability", + () async { + final channelState = _generateChannelState( + channelId, + channelType, + ownCapabilities: [], // no readEvents capability + ); + + final channel = Channel.fromState(client, channelState); + addTearDown(channel.dispose); + + await expectLater( + channel.markThreadUnread('thread-id-123'), + throwsA(isA()), ); }, ); test( - ".keystore should send 'typingStart' event if there is not already a typingEvent or the difference between the two is > 3 seconds", + '.markThreadUnread should succeed if we have the capability', () async { final channelState = _generateChannelState( channelId, channelType, - ownCapabilities: [ChannelCapability.typingEvents], + ownCapabilities: [ChannelCapability.readEvents], + ); + + final channel = Channel.fromState(client, channelState); + addTearDown(channel.dispose); + + when( + () => client.markThreadUnread( + channelId, + channelType, + 'thread-id-123', + ), + ).thenAnswer((_) async => EmptyResponse()); + + await expectLater( + channel.markThreadUnread('thread-id-123'), + completes, ); - final channel = Channel.fromState(client, channelState); - addTearDown(channel.dispose); + verify( + () => client.markThreadUnread( + channelId, + channelType, + 'thread-id-123', + ), + ).called(1); + }, + ); + }); + + group('Retry functionality with parameter preservation', () { + late final client = MockStreamChatClient(); + const channelId = 'test-channel-id'; + const channelType = 'test-channel-type'; + late Channel channel; + + setUpAll(() { + registerFallbackValue(FakeMessage()); + registerFallbackValue([]); + registerFallbackValue(FakeAttachmentFile()); + + when(() => client.detachedLogger(any())).thenAnswer((invocation) { + final name = invocation.positionalArguments.first; + return _createLogger(name); + }); + + when(() => client.logger).thenReturn(_createLogger('mock-client-logger')); + + final clientState = FakeClientState(); + when(() => client.state).thenReturn(clientState); + + final retryPolicy = RetryPolicy( + shouldRetry: (_, __, error) { + return error is StreamChatNetworkError && error.isRetriable; + }, + ); + when(() => client.retryPolicy).thenReturn(retryPolicy); + }); + + setUp(() { + final channelState = _generateChannelState(channelId, channelType); + channel = Channel.fromState(client, channelState); + }); + + tearDown(() { + channel.dispose(); + }); + + group('retryMessage method', () { + test('should call sendMessage with preserved skipPush and skipEnrichUrl parameters', () async { + final message = Message( + id: 'test-message-id', + text: 'Hello, World!', + state: MessageState.sendingFailed( + skipPush: true, + skipEnrichUrl: true, + ), + ); - final startTypingEvent = Event(type: EventType.typingStart); - final stopTypingEvent = Event(type: EventType.typingStop); + final sendMessageResponse = SendMessageResponse()..message = message.copyWith(state: MessageState.sent); when( - () => client.sendEvent( + () => client.sendMessage( + any(that: isSameMessageAs(message)), channelId, channelType, - any(that: isSameEventAs(startTypingEvent)), + skipPush: true, + skipEnrichUrl: true, ), - ).thenAnswer((_) async => EmptyResponse()); + ).thenAnswer((_) async => sendMessageResponse); - when( - () => client.sendEvent( + final result = await channel.retryMessage(message); + + expect(result, isNotNull); + expect(result, isA()); + + verify( + () => client.sendMessage( + any(that: isSameMessageAs(message)), channelId, channelType, - any(that: isSameEventAs(stopTypingEvent)), + skipPush: true, + skipEnrichUrl: true, ), - ).thenAnswer((_) async => EmptyResponse()); + ).called(1); + }); - await expectLater(channel.keyStroke(), completes); + test('should call sendMessage with preserved skipPush parameter', () async { + final message = Message( + id: 'test-message-id', + text: 'Hello, World!', + state: MessageState.sendingFailed( + skipPush: true, + skipEnrichUrl: false, + ), + ); - verify( - () => client.sendEvent( + final sendMessageResponse = SendMessageResponse()..message = message.copyWith(state: MessageState.sent); + + when( + () => client.sendMessage( + any(that: isSameMessageAs(message)), channelId, channelType, - any(that: isSameEventAs(startTypingEvent)), + skipPush: true, ), - ).called(1); + ).thenAnswer((_) async => sendMessageResponse); + + final result = await channel.retryMessage(message); + + expect(result, isNotNull); + expect(result, isA()); verify( - () => client.sendEvent( + () => client.sendMessage( + any(that: isSameMessageAs(message)), channelId, channelType, - any(that: isSameEventAs(stopTypingEvent)), + skipPush: true, ), ).called(1); - }, - ); + }); - test( - ".startTyping should return if we don't have the capability", - () async { - final channelState = _generateChannelState( - channelId, - channelType, - ownCapabilities: [], // no typingEvents capability + test('should call sendMessage with preserved skipEnrichUrl parameter', () async { + final message = Message( + id: 'test-message-id', + text: 'Hello, World!', + state: MessageState.sendingFailed( + skipPush: false, + skipEnrichUrl: true, + ), ); - final channel = Channel.fromState(client, channelState); - addTearDown(channel.dispose); + final sendMessageResponse = SendMessageResponse()..message = message.copyWith(state: MessageState.sent); - final typingStartEvent = Event(type: EventType.typingStart); + when( + () => client.sendMessage( + any(that: isSameMessageAs(message)), + channelId, + channelType, + skipEnrichUrl: true, + ), + ).thenAnswer((_) async => sendMessageResponse); - await expectLater(channel.startTyping(), completes); + final result = await channel.retryMessage(message); - verifyNever( - () => client.sendEvent( + expect(result, isNotNull); + expect(result, isA()); + + verify( + () => client.sendMessage( + any(that: isSameMessageAs(message)), channelId, channelType, - any(that: isSameEventAs(typingStartEvent)), + skipEnrichUrl: true, ), - ); - }, - ); + ).called(1); + }); - test( - '.startTyping should return when user privacy settings is disabled', - () async { - final currentUser = client.state.currentUser; - final updatedUser = currentUser?.copyWith( - privacySettings: const PrivacySettings( - typingIndicators: TypingIndicators(enabled: false), + test('should call sendMessage with preserved false skipPush and skipEnrichUrl parameters', () async { + final message = Message( + id: 'test-message-id', + text: 'Hello, World!', + state: MessageState.sendingFailed( + skipPush: false, + skipEnrichUrl: false, ), ); - client.state.updateUser(updatedUser); - addTearDown(() => client.state.updateUser(currentUser)); - - final channelState = _generateChannelState( - channelId, - channelType, - ownCapabilities: [ChannelCapability.typingEvents], - ); + final sendMessageResponse = SendMessageResponse()..message = message.copyWith(state: MessageState.sent); - final channel = Channel.fromState(client, channelState); - addTearDown(channel.dispose); + when( + () => client.sendMessage( + any(that: isSameMessageAs(message)), + channelId, + channelType, + ), + ).thenAnswer((_) async => sendMessageResponse); - final typingStartEvent = Event(type: EventType.typingStart); + final result = await channel.retryMessage(message); - await expectLater(channel.startTyping(), completes); + expect(result, isNotNull); + expect(result, isA()); - verifyNever( - () => client.sendEvent( + verify( + () => client.sendMessage( + any(that: isSameMessageAs(message)), channelId, channelType, - any(that: isSameEventAs(typingStartEvent)), ), - ); - }, - ); + ).called(1); + }); - test(".startTyping should send 'typingStart' successfully", () async { - final channelState = _generateChannelState( - channelId, - channelType, - ownCapabilities: [ChannelCapability.typingEvents], - ); + test('should call updateMessage with preserved skipPush, skipEnrichUrl parameter', () async { + final message = Message( + id: 'test-message-id', + text: 'Hello, World!', + state: MessageState.updatingFailed( + skipPush: true, + skipEnrichUrl: true, + ), + ); - final channel = Channel.fromState(client, channelState); - addTearDown(channel.dispose); + final updateMessageResponse = UpdateMessageResponse()..message = message.copyWith(state: MessageState.updated); - final typingStartEvent = Event(type: EventType.typingStart); + when( + () => client.updateMessage( + any(that: isSameMessageAs(message)), + skipPush: true, + skipEnrichUrl: true, + ), + ).thenAnswer((_) async => updateMessageResponse); - when( - () => client.sendEvent( - channelId, - channelType, - any(that: isSameEventAs(typingStartEvent)), - ), - ).thenAnswer((_) async => EmptyResponse()); + final result = await channel.retryMessage(message); - await expectLater(channel.startTyping(), completes); + expect(result, isNotNull); + expect(result, isA()); - verify( - () => client.sendEvent( - channelId, - channelType, - any(that: isSameEventAs(typingStartEvent)), - ), - ).called(1); - }); + verify( + () => client.updateMessage( + any(that: isSameMessageAs(message)), + skipPush: true, + skipEnrichUrl: true, + ), + ).called(1); + }); - test(".stopTyping should return if we don't have the capability", () async { - final channelState = _generateChannelState( - channelId, - channelType, - ownCapabilities: [], // no typingEvents capability - ); + test('should call updateMessage with preserved false skipPush, skipEnrichUrl parameter', () async { + final message = Message( + id: 'test-message-id', + state: MessageState.updatingFailed( + skipPush: false, + skipEnrichUrl: false, + ), + ); - final channel = Channel.fromState(client, channelState); - addTearDown(channel.dispose); + final updateMessageResponse = UpdateMessageResponse()..message = message.copyWith(state: MessageState.updated); - final typingStopEvent = Event(type: EventType.typingStop); + when( + () => client.updateMessage( + any(that: isSameMessageAs(message)), + ), + ).thenAnswer((_) async => updateMessageResponse); - await expectLater(channel.stopTyping(), completes); + final result = await channel.retryMessage(message); - verifyNever( - () => client.sendEvent( - channelId, - channelType, - any(that: isSameEventAs(typingStopEvent)), - ), - ); - }); + expect(result, isNotNull); + expect(result, isA()); - test( - '.stopTyping should return when user privacy settings is disabled', - () async { - final currentUser = client.state.currentUser; - final updatedUser = currentUser?.copyWith( - privacySettings: const PrivacySettings( - typingIndicators: TypingIndicators(enabled: false), + verify( + () => client.updateMessage( + any(that: isSameMessageAs(message)), ), + ).called(1); + }); + + test('should call deleteMessage with preserved hard parameter', () async { + final message = Message( + id: 'test-message-id', + createdAt: DateTime.now(), + state: MessageState.hardDeletingFailed, ); - client.state.updateUser(updatedUser); - addTearDown(() => client.state.updateUser(currentUser)); + when( + () => client.deleteMessage( + message.id, + hard: true, + ), + ).thenAnswer((_) async => EmptyResponse()); - final channelState = _generateChannelState( - channelId, - channelType, - ownCapabilities: [ChannelCapability.typingEvents], + final result = await channel.retryMessage(message); + + expect(result, isNotNull); + expect(result, isA()); + + verify( + () => client.deleteMessage( + message.id, + hard: true, + ), + ).called(1); + }); + + test('should call deleteMessage with preserved false hard parameter', () async { + final message = Message( + id: 'test-message-id', + createdAt: DateTime.now(), + state: MessageState.softDeletingFailed, ); - final channel = Channel.fromState(client, channelState); - addTearDown(channel.dispose); + when( + () => client.deleteMessage( + message.id, + ), + ).thenAnswer((_) async => EmptyResponse()); - final typingStopEvent = Event(type: EventType.typingStop); + final result = await channel.retryMessage(message); - await expectLater(channel.stopTyping(), completes); + expect(result, isNotNull); + expect(result, isA()); - verifyNever( - () => client.sendEvent( - channelId, - channelType, - any(that: isSameEventAs(typingStopEvent)), + verify( + () => client.deleteMessage( + message.id, ), + ).called(1); + }); + + test('should call deleteMessageForMe for deletingForMeFailed state', () async { + final message = Message( + id: 'test-message-id', + createdAt: DateTime.now(), + state: MessageState.deletingForMeFailed, ); - }, - ); - test(".stopTyping should send 'typingStop' successfully", () async { - final channelState = _generateChannelState( - channelId, - channelType, - ownCapabilities: [ChannelCapability.typingEvents], - ); + when(() => client.deleteMessageForMe(message.id)).thenAnswer((_) async => EmptyResponse()); - final channel = Channel.fromState(client, channelState); - addTearDown(channel.dispose); + final result = await channel.retryMessage(message); - final typingStopEvent = Event(type: EventType.typingStop); + expect(result, isNotNull); + expect(result, isA()); - when( - () => client.sendEvent( - channelId, - channelType, - any(that: isSameEventAs(typingStopEvent)), - ), - ).thenAnswer((_) async => EmptyResponse()); + verify(() => client.deleteMessageForMe(message.id)).called(1); + }); - await expectLater(channel.stopTyping(), completes); + test('should throw AssertionError when message state is not failed', () async { + final message = Message( + id: 'test-message-id', + state: MessageState.sent, + ); - verify( - () => client.sendEvent( - channelId, - channelType, - any(that: isSameEventAs(typingStopEvent)), - ), - ).called(1); + expect(() => channel.retryMessage(message), throwsA(isA())); + }); }); }); - group('Read Receipts', () { + group('Message enrichment preservation on merge', () { + late final client = MockStreamChatClient(); const channelId = 'test-channel-id'; const channelType = 'test-channel-type'; - late final client = MockStreamChatClient(); + late Channel channel; + + setUpAll(() { + registerFallbackValue(FakeMessage()); + registerFallbackValue([]); - setUpAll(() { - // detached loggers when(() => client.detachedLogger(any())).thenAnswer((invocation) { final name = invocation.positionalArguments.first; return _createLogger(name); }); + when(() => client.logger).thenReturn(_createLogger('mock-client-logger')); + + final clientState = FakeClientState(); + when(() => client.state).thenReturn(clientState); + final retryPolicy = RetryPolicy( shouldRetry: (_, __, ___) => false, delayFactor: Duration.zero, ); when(() => client.retryPolicy).thenReturn(retryPolicy); + }); - // fake clientState - final clientState = FakeClientState(); - when(() => client.state).thenReturn(clientState); + setUp(() { + final channelState = _generateChannelState(channelId, channelType); + channel = Channel.fromState(client, channelState); + }); - // client logger - when(() => client.logger).thenReturn(_createLogger('mock-client-logger')); + tearDown(() { + channel.dispose(); + clearInteractions(client); }); test( - ".markRead should throw if we don't have the capability", + 'preserves the `poll` on a quotedMessage when the server omits it during ' + 're-sync (regression: poll quote disappears after foregrounding)', () async { - final channelState = _generateChannelState( - channelId, - channelType, - ownCapabilities: [], // no readEvents capability + final pollUser = User(id: 'poll-author'); + final poll = Poll( + id: 'poll-1', + name: 'Pizza or pasta?', + options: const [ + PollOption(id: 'opt-1', text: 'Pizza'), + PollOption(id: 'opt-2', text: 'Pasta'), + ], + createdById: pollUser.id, ); - final channel = Channel.fromState(client, channelState); - addTearDown(channel.dispose); - - await expectLater( - channel.markRead(messageId: 'message-id-123'), - throwsA(isA()), + final pollMessage = Message( + id: 'poll-msg-1', + poll: poll, + pollId: poll.id, + user: pollUser, + createdAt: DateTime.utc(2026, 4, 29, 10), ); - }, - ); - test( - '.markRead should succeed if we have the capability', - () async { - final channelState = _generateChannelState( - channelId, - channelType, - ownCapabilities: [ChannelCapability.readEvents], + final replyToPoll = Message( + id: 'reply-1', + text: 'Voting now', + quotedMessageId: pollMessage.id, + quotedMessage: pollMessage, + user: User(id: 'reply-user'), + createdAt: DateTime.utc(2026, 4, 29, 11), ); - final channel = Channel.fromState(client, channelState); - addTearDown(channel.dispose); - - when( - () => client.markChannelRead( - channelId, - channelType, - messageId: 'message-id-123', + // Seed channel state with the fully-enriched messages (mirrors what + // the local DB load produces). + channel.state?.updateChannelState( + channel.state!.channelState.copyWith( + messages: [pollMessage, replyToPoll], ), - ).thenAnswer((_) async => EmptyResponse()); + ); - await expectLater( - channel.markRead(messageId: 'message-id-123'), - completes, + // Simulate a re-sync from the API: the server echoes the reply with + // a `quoted_message` that has only `poll_id` (no `poll` object). + // Constructed directly (not via copyWith) because copyWith cannot + // clear `poll` — see Message.copyWith. + final strippedPollSnapshot = Message( + id: pollMessage.id, + pollId: pollMessage.pollId, + user: pollUser, + createdAt: pollMessage.createdAt, ); + final reSyncedReply = replyToPoll.copyWith(quotedMessage: strippedPollSnapshot); - verify( - () => client.markChannelRead( - channelId, - channelType, - messageId: 'message-id-123', + channel.state?.updateChannelState( + channel.state!.channelState.copyWith( + messages: [reSyncedReply], ), - ).called(1); + ); + + final mergedReply = channel.state?.messages.firstWhere((it) => it.id == replyToPoll.id); + + expect(mergedReply, isNotNull); + expect(mergedReply!.quotedMessage, isNotNull); + expect(mergedReply.quotedMessage!.id, pollMessage.id); + expect(mergedReply.quotedMessage!.poll, isNotNull); + expect(mergedReply.quotedMessage!.poll!.id, poll.id); + expect(mergedReply.quotedMessage!.poll!.name, poll.name); }, ); test( - ".markUnread should throw if we don't have the capability", + 'preserves a nested quotedMessage (poll) two levels deep when the ' + 'server omits it during re-sync (regression: quote-of-quote of a poll ' + 'disappears completely after foregrounding)', () async { - final channelState = _generateChannelState( - channelId, - channelType, - ownCapabilities: [], // no readEvents capability + final pollUser = User(id: 'poll-author'); + final poll = Poll( + id: 'poll-2', + name: 'Coffee or tea?', + options: const [ + PollOption(id: 'opt-a', text: 'Coffee'), + PollOption(id: 'opt-b', text: 'Tea'), + ], + createdById: pollUser.id, ); - final channel = Channel.fromState(client, channelState); - addTearDown(channel.dispose); - - await expectLater( - channel.markUnread('message-id-123'), - throwsA(isA()), + final pollMessage = Message( + id: 'poll-msg-2', + poll: poll, + pollId: poll.id, + user: pollUser, + createdAt: DateTime.utc(2026, 4, 29, 10), ); - }, - ); - test( - '.markUnread should succeed if we have the capability', - () async { - final channelState = _generateChannelState( - channelId, - channelType, - ownCapabilities: [ChannelCapability.readEvents], + final replyToPoll = Message( + id: 'reply-A', + text: 'My pick', + quotedMessageId: pollMessage.id, + quotedMessage: pollMessage, + user: User(id: 'user-a'), + createdAt: DateTime.utc(2026, 4, 29, 11), ); - final channel = Channel.fromState(client, channelState); - addTearDown(channel.dispose); + final replyToReply = Message( + id: 'reply-B', + text: 'Same here', + quotedMessageId: replyToPoll.id, + quotedMessage: replyToPoll, + user: User(id: 'user-b'), + createdAt: DateTime.utc(2026, 4, 29, 12), + ); - when( - () => client.markChannelUnread( - channelId, - channelType, - 'message-id-123', + channel.state?.updateChannelState( + channel.state!.channelState.copyWith( + messages: [pollMessage, replyToPoll, replyToReply], ), - ).thenAnswer((_) async => EmptyResponse()); + ); - await expectLater( - channel.markUnread('message-id-123'), - completes, + // Simulate the server response where: + // - replyA's nested quoted poll is missing the `poll` object. + // - replyB's nested quoted replyA is missing its own `quoted_message` + // (the server typically does not nest two levels deep). + // Stripped poll snapshot is constructed directly because copyWith + // cannot clear `poll` — see Message.copyWith. + final strippedPollSnapshot = Message( + id: pollMessage.id, + pollId: pollMessage.pollId, + user: pollUser, + createdAt: pollMessage.createdAt, ); + final strippedReplyA = replyToPoll.copyWith(quotedMessage: null); - verify( - () => client.markChannelUnread( - channelId, - channelType, - 'message-id-123', - ), - ).called(1); - }, - ); + final reSyncedReplyA = replyToPoll.copyWith(quotedMessage: strippedPollSnapshot); + final reSyncedReplyB = replyToReply.copyWith(quotedMessage: strippedReplyA); - test( - ".markUnreadByTimestamp should throw if we don't have the capability", - () async { - final channelState = _generateChannelState( - channelId, - channelType, - ownCapabilities: [], // no readEvents capability + channel.state?.updateChannelState( + channel.state!.channelState.copyWith( + messages: [pollMessage, reSyncedReplyA, reSyncedReplyB], + ), ); - final channel = Channel.fromState(client, channelState); - addTearDown(channel.dispose); + final mergedReplyA = channel.state?.messages.firstWhere((it) => it.id == replyToPoll.id); + final mergedReplyB = channel.state?.messages.firstWhere((it) => it.id == replyToReply.id); - final timestamp = DateTime.parse('2024-01-01T00:00:00Z'); + // First-level quote (reply A's quote of the poll) must keep the poll. + expect(mergedReplyA?.quotedMessage?.poll, isNotNull); + expect(mergedReplyA?.quotedMessage?.poll?.id, poll.id); - await expectLater( - channel.markUnreadByTimestamp(timestamp), - throwsA(isA()), - ); + // Second-level quote (reply B's quote of reply A) must keep reply A's + // own nested quotedMessage so the poll preview still resolves. + expect(mergedReplyB?.quotedMessage, isNotNull); + expect(mergedReplyB?.quotedMessage?.id, replyToPoll.id); + expect(mergedReplyB?.quotedMessage?.quotedMessage, isNotNull); + expect(mergedReplyB?.quotedMessage?.quotedMessage?.id, pollMessage.id); + expect(mergedReplyB?.quotedMessage?.quotedMessage?.poll, isNotNull); + expect(mergedReplyB?.quotedMessage?.quotedMessage?.poll?.id, poll.id); }, ); test( - '.markUnreadByTimestamp should succeed if we have the capability', + 'still preserves quotedMessage when the updated payload has no ' + 'quoted_message at all (existing behavior should not regress)', () async { - final channelState = _generateChannelState( - channelId, - channelType, - ownCapabilities: [ChannelCapability.readEvents], + final pollUser = User(id: 'poll-author'); + final poll = Poll( + id: 'poll-3', + name: 'Beach or mountains?', + options: const [ + PollOption(id: 'opt-x', text: 'Beach'), + PollOption(id: 'opt-y', text: 'Mountains'), + ], + createdById: pollUser.id, ); - final channel = Channel.fromState(client, channelState); - addTearDown(channel.dispose); + final pollMessage = Message( + id: 'poll-msg-3', + poll: poll, + pollId: poll.id, + user: pollUser, + createdAt: DateTime.utc(2026, 4, 29, 10), + ); - final timestamp = DateTime.parse('2024-01-01T00:00:00Z'); + final replyToPoll = Message( + id: 'reply-3', + text: 'Definitely beach', + quotedMessageId: pollMessage.id, + quotedMessage: pollMessage, + user: User(id: 'reply-user'), + createdAt: DateTime.utc(2026, 4, 29, 11), + ); - when( - () => client.markChannelUnreadByTimestamp( - channelId, - channelType, - timestamp, + channel.state?.updateChannelState( + channel.state!.channelState.copyWith( + messages: [pollMessage, replyToPoll], ), - ).thenAnswer((_) async => EmptyResponse()); + ); - await expectLater( - channel.markUnreadByTimestamp(timestamp), - completes, + // Simulate an update event that touches the reply but doesn't echo + // the nested quoted_message at all (only quotedMessageId is set). + final reSyncedReply = Message( + id: replyToPoll.id, + text: 'Definitely beach (edited)', + quotedMessageId: pollMessage.id, + user: replyToPoll.user, + createdAt: replyToPoll.createdAt, ); - verify( - () => client.markChannelUnreadByTimestamp( - channelId, - channelType, - timestamp, + channel.state?.updateChannelState( + channel.state!.channelState.copyWith( + messages: [reSyncedReply], ), - ).called(1); - }, - ); - - test( - ".markThreadRead should throw if we don't have the capability", - () async { - final channelState = _generateChannelState( - channelId, - channelType, - ownCapabilities: [], // no readEvents capability ); - final channel = Channel.fromState(client, channelState); - addTearDown(channel.dispose); + final mergedReply = channel.state?.messages.firstWhere((it) => it.id == replyToPoll.id); - await expectLater( - channel.markThreadRead('thread-id-123'), - throwsA(isA()), - ); + expect(mergedReply, isNotNull); + expect(mergedReply!.text, 'Definitely beach (edited)'); + expect(mergedReply.quotedMessage, isNotNull); + expect(mergedReply.quotedMessage!.poll?.id, poll.id); }, ); test( - '.markThreadRead should succeed if we have the capability', + 'preserves the top-level `poll` when the server emits a `message.updated`' + ' that omits the `poll` object (regression: poll disappears from the ' + 'parent message after a thread reply is added)', () async { - final channelState = _generateChannelState( - channelId, - channelType, - ownCapabilities: [ChannelCapability.readEvents], + final pollUser = User(id: 'poll-author'); + final poll = Poll( + id: 'poll-thread', + name: 'What is for lunch?', + options: const [ + PollOption(id: 'opt-1', text: 'Burgers'), + PollOption(id: 'opt-2', text: 'Salads'), + ], + createdById: pollUser.id, ); - final channel = Channel.fromState(client, channelState); - addTearDown(channel.dispose); + final pollMessage = Message( + id: 'parent-poll-msg', + poll: poll, + pollId: poll.id, + user: pollUser, + createdAt: DateTime.utc(2026, 4, 29, 10), + replyCount: 0, + ); - when( - () => client.markThreadRead( - channelId, - channelType, - 'thread-id-123', + // Seed channel state with the fully-enriched parent poll message. + channel.state?.updateChannelState( + channel.state!.channelState.copyWith( + messages: [pollMessage], ), - ).thenAnswer((_) async => EmptyResponse()); + ); - await expectLater( - channel.markThreadRead('thread-id-123'), - completes, + // Simulate the `message.updated` event the backend fires for the + // parent after a thread reply is added: bookkeeping fields are bumped + // (`reply_count`, `updated_at`) but the `poll` object is omitted from + // the payload — only `pollId` is set. Constructed directly because + // copyWith cannot clear `poll` — see Message.copyWith. + final strippedParentUpdate = Message( + id: pollMessage.id, + pollId: pollMessage.pollId, + user: pollUser, + createdAt: pollMessage.createdAt, + replyCount: 1, + updatedAt: DateTime.utc(2026, 4, 29, 11), ); - verify( - () => client.markThreadRead( - channelId, - channelType, - 'thread-id-123', + client.addEvent( + Event( + cid: channel.cid, + type: EventType.messageUpdated, + message: strippedParentUpdate, ), - ).called(1); - }, - ); - - test( - ".markThreadUnread should throw if we don't have the capability", - () async { - final channelState = _generateChannelState( - channelId, - channelType, - ownCapabilities: [], // no readEvents capability ); - final channel = Channel.fromState(client, channelState); - addTearDown(channel.dispose); + // Wait for the event to be processed. + await Future.delayed(Duration.zero); - await expectLater( - channel.markThreadUnread('thread-id-123'), - throwsA(isA()), - ); + final merged = channel.state?.messages.firstWhere((it) => it.id == pollMessage.id); + + // Parent poll message must remain in the channel state after a thread reply. + expect(merged, isNotNull); + // Bookkeeping fields from the event should still apply. + expect(merged!.replyCount, 1); + // Locally-known poll must be preserved when the server omits it from a + // `message.updated` payload (e.g. when a thread reply bumps reply_count). + expect(merged.poll, isNotNull); + expect(merged.poll!.id, poll.id); + expect(merged.poll!.name, poll.name); + expect(merged.pollId, poll.id); }, ); test( - '.markThreadUnread should succeed if we have the capability', + 'still uses the updated `poll` when the server includes one in ' + '`message.updated` (poll edits should not be reverted to the locally ' + 'cached version)', () async { - final channelState = _generateChannelState( - channelId, - channelType, - ownCapabilities: [ChannelCapability.readEvents], + final pollUser = User(id: 'poll-author'); + final poll = Poll( + id: 'poll-edit', + name: 'Initial name', + options: const [ + PollOption(id: 'opt-1', text: 'Original A'), + ], + createdById: pollUser.id, ); - final channel = Channel.fromState(client, channelState); - addTearDown(channel.dispose); + final pollMessage = Message( + id: 'edit-parent', + poll: poll, + pollId: poll.id, + user: pollUser, + createdAt: DateTime.utc(2026, 4, 29, 10), + ); - when( - () => client.markThreadUnread( - channelId, - channelType, - 'thread-id-123', + channel.state?.updateChannelState( + channel.state!.channelState.copyWith( + messages: [pollMessage], ), - ).thenAnswer((_) async => EmptyResponse()); - - await expectLater( - channel.markThreadUnread('thread-id-123'), - completes, ); - verify( - () => client.markThreadUnread( - channelId, - channelType, - 'thread-id-123', + final updatedPoll = poll.copyWith(name: 'Edited name'); + final updatedParent = pollMessage.copyWith(poll: updatedPoll, updatedAt: DateTime.utc(2026, 4, 29, 12)); + + client.addEvent( + Event( + cid: channel.cid, + type: EventType.messageUpdated, + message: updatedParent, ), - ).called(1); + ); + + await Future.delayed(Duration.zero); + + final merged = channel.state?.messages.firstWhere((it) => it.id == pollMessage.id); + + // Server-echoed poll must override the locally cached one — poll edits + // should not be reverted by the local-fallback merge. + expect(merged?.poll, isNotNull); + expect(merged?.poll?.name, 'Edited name'); }, ); }); diff --git a/packages/stream_chat/test/src/client/client_test.dart b/packages/stream_chat/test/src/client/client_test.dart index 932398336d..807ada9983 100644 --- a/packages/stream_chat/test/src/client/client_test.dart +++ b/packages/stream_chat/test/src/client/client_test.dart @@ -1,4 +1,4 @@ -// ignore_for_file: avoid_redundant_argument_values +// ignore_for_file: avoid_redundant_argument_values, lines_longer_than_80_chars import 'package:mocktail/mocktail.dart'; import 'package:stream_chat/src/core/http/token.dart'; @@ -75,8 +75,7 @@ void main() { final user = User(id: 'test-user-id'); final token = Token.development(user.id).rawValue; - when(() => api.guest.getGuestUser(any(that: isSameUserAs(user)))) - .thenAnswer( + when(() => api.guest.getGuestUser(any(that: isSameUserAs(user)))).thenAnswer( (_) async => ConnectGuestUserResponse() ..user = user ..accessToken = token, @@ -103,8 +102,9 @@ void main() { test('should throw if `.getGuestUser` fails', () async { final user = User(id: 'test-user-id'); - when(() => api.guest.getGuestUser(any(that: isSameUserAs(user)))) - .thenThrow(StreamChatNetworkError(ChatErrorCode.inputError)); + when( + () => api.guest.getGuestUser(any(that: isSameUserAs(user))), + ).thenThrow(StreamChatNetworkError(ChatErrorCode.inputError)); expectLater( client.wsConnectionStatusStream, @@ -247,8 +247,7 @@ void main() { final user = User(id: 'test-user-id'); final token = Token.development(user.id).rawValue; - when(() => api.guest.getGuestUser(any(that: isSameUserAs(user)))) - .thenAnswer( + when(() => api.guest.getGuestUser(any(that: isSameUserAs(user)))).thenAnswer( (_) async => ConnectGuestUserResponse() ..user = user ..accessToken = token, @@ -331,8 +330,7 @@ void main() { final user = User(id: 'test-user-id'); final token = Token.development(user.id).rawValue; - when(() => api.guest.getGuestUser(any(that: isSameUserAs(user)))) - .thenAnswer( + when(() => api.guest.getGuestUser(any(that: isSameUserAs(user)))).thenAnswer( (_) async => ConnectGuestUserResponse() ..user = user ..accessToken = token, @@ -377,8 +375,7 @@ void main() { setUp(() { final ws = FakeWebSocketWithConnectionError(); - client = StreamChatClient(apiKey, chatApi: api, ws: ws) - ..chatPersistenceClient = persistence; + client = StreamChatClient(apiKey, chatApi: api, ws: ws)..chatPersistenceClient = persistence; }); tearDown(() { @@ -392,9 +389,10 @@ void main() { final token = Token.development(user.id).rawValue; final event = Event( - type: EventType.healthCheck, - connectionId: 'test-connection-id', - me: OwnUser.fromUser(user)); + type: EventType.healthCheck, + connectionId: 'test-connection-id', + me: OwnUser.fromUser(user), + ); when(persistence.getConnectionInfo).thenAnswer((_) async => event); final res = await client.connectUser(user, token); @@ -416,9 +414,10 @@ void main() { } final event = Event( - type: EventType.healthCheck, - connectionId: 'test-connection-id', - me: OwnUser.fromUser(user)); + type: EventType.healthCheck, + connectionId: 'test-connection-id', + me: OwnUser.fromUser(user), + ); when(persistence.getConnectionInfo).thenAnswer((_) async => event); final res = await client.connectUserWithProvider(user, tokenProvider); @@ -437,13 +436,13 @@ void main() { final token = Token.development(user.id).rawValue; final event = Event( - type: EventType.healthCheck, - connectionId: 'test-connection-id', - me: OwnUser.fromUser(user)); + type: EventType.healthCheck, + connectionId: 'test-connection-id', + me: OwnUser.fromUser(user), + ); when(persistence.getConnectionInfo).thenAnswer((_) async => event); - when(() => api.guest.getGuestUser(any(that: isSameUserAs(user)))) - .thenAnswer( + when(() => api.guest.getGuestUser(any(that: isSameUserAs(user)))).thenAnswer( (_) async => ConnectGuestUserResponse() ..user = user ..accessToken = token, @@ -455,8 +454,7 @@ void main() { verify(persistence.getConnectionInfo).called(1); verifyNoMoreInteractions(persistence); - verify(() => api.guest.getGuestUser(any(that: isSameUserAs(user)))) - .called(1); + verify(() => api.guest.getGuestUser(any(that: isSameUserAs(user)))).called(1); verifyNoMoreInteractions(api.guest); }, ); @@ -501,12 +499,10 @@ void main() { }); setUp(() async { - when(() => persistence.updateLastSyncAt(any())) - .thenAnswer((_) => Future.value()); + when(() => persistence.updateLastSyncAt(any())).thenAnswer((_) => Future.value()); when(persistence.getLastSyncAt).thenAnswer((_) async => null); final ws = FakeWebSocket(); - client = StreamChatClient(apiKey, chatApi: api, ws: ws) - ..chatPersistenceClient = persistence; + client = StreamChatClient(apiKey, chatApi: api, ws: ws)..chatPersistenceClient = persistence; await client.connectUser(user, token); await delay(300); expect(client.persistenceEnabled, isTrue); @@ -527,26 +523,25 @@ void main() { reset(persistence); const cids = ['test-cid-1', 'test-cid-2', 'test-cid-3']; final lastSyncAt = DateTime.now(); - when(() => api.general.sync(cids, lastSyncAt)) - .thenAnswer((_) async => SyncResponse() - ..events = [ - Event( - isLocal: false, - type: EventType.healthCheck, - connectionId: 'test-connection-id', - me: OwnUser.fromUser(user), - ), - Event( - isLocal: false, - type: EventType.messageDeleted, - message: Message(id: 'test-message-id'), - ), - ]); - - when(() => persistence.updateConnectionInfo(any())) - .thenAnswer((_) => Future.value()); - when(() => persistence.updateLastSyncAt(any())) - .thenAnswer((_) => Future.value()); + when(() => api.general.sync(cids, lastSyncAt)).thenAnswer( + (_) async => SyncResponse() + ..events = [ + Event( + isLocal: false, + type: EventType.healthCheck, + connectionId: 'test-connection-id', + me: OwnUser.fromUser(user), + ), + Event( + isLocal: false, + type: EventType.messageDeleted, + message: Message(id: 'test-message-id'), + ), + ], + ); + + when(() => persistence.updateConnectionInfo(any())).thenAnswer((_) => Future.value()); + when(() => persistence.updateLastSyncAt(any())).thenAnswer((_) => Future.value()); await client.sync(cids: cids, lastSyncAt: lastSyncAt); @@ -569,26 +564,25 @@ void main() { when(persistence.getChannelCids).thenAnswer((_) async => cids); when(persistence.getLastSyncAt).thenAnswer((_) async => lastSyncAt); - when(() => api.general.sync(cids, lastSyncAt)) - .thenAnswer((_) async => SyncResponse() - ..events = [ - Event( - isLocal: false, - type: EventType.healthCheck, - connectionId: 'test-connection-id', - me: OwnUser.fromUser(user), - ), - Event( - isLocal: false, - type: EventType.messageDeleted, - message: Message(id: 'test-message-id', text: 'Hey!'), - ), - ]); - - when(() => persistence.updateConnectionInfo(any())) - .thenAnswer((_) => Future.value()); - when(() => persistence.updateLastSyncAt(any())) - .thenAnswer((_) => Future.value()); + when(() => api.general.sync(cids, lastSyncAt)).thenAnswer( + (_) async => SyncResponse() + ..events = [ + Event( + isLocal: false, + type: EventType.healthCheck, + connectionId: 'test-connection-id', + me: OwnUser.fromUser(user), + ), + Event( + isLocal: false, + type: EventType.messageDeleted, + message: Message(id: 'test-message-id', text: 'Hey!'), + ), + ], + ); + + when(() => persistence.updateConnectionInfo(any())).thenAnswer((_) => Future.value()); + when(() => persistence.updateLastSyncAt(any())).thenAnswer((_) => Future.value()); await client.sync(); @@ -612,11 +606,14 @@ void main() { ), ); - when(() => persistence.getChannelStates( - filter: any(named: 'filter'), - channelStateSort: any(named: 'channelStateSort'), - paginationParams: any(named: 'paginationParams'), - )).thenAnswer((_) async => persistentChannelStates); + when( + () => persistence.getChannelStates( + filter: any(named: 'filter'), + messageLimit: any(named: 'messageLimit'), + channelStateSort: any(named: 'channelStateSort'), + paginationParams: any(named: 'paginationParams'), + ), + ).thenAnswer((_) async => persistentChannelStates); final channelStates = List.generate( 3, @@ -625,35 +622,33 @@ void main() { ), ); - when(() => api.channel.queryChannels( - filter: any(named: 'filter'), - sort: any(named: 'sort'), - state: any(named: 'state'), - watch: any(named: 'watch'), - presence: any(named: 'presence'), - memberLimit: any(named: 'memberLimit'), - messageLimit: any(named: 'messageLimit'), - paginationParams: any(named: 'paginationParams'), - )).thenAnswer( + when( + () => api.channel.queryChannels( + filter: any(named: 'filter'), + sort: any(named: 'sort'), + state: any(named: 'state'), + watch: any(named: 'watch'), + presence: any(named: 'presence'), + memberLimit: any(named: 'memberLimit'), + messageLimit: any(named: 'messageLimit'), + paginationParams: any(named: 'paginationParams'), + ), + ).thenAnswer( (_) async => QueryChannelsResponse()..channels = channelStates, ); when(() => persistence.getChannelThreads(any())).thenAnswer( (_) async => >{ for (final channelState in channelStates) - channelState.channel!.cid: [ - Message(id: 'test-message-id', text: 'Test message') - ], + channelState.channel!.cid: [Message(id: 'test-message-id', text: 'Test message')], }, ); - when(() => persistence.updateChannelState(any())) - .thenAnswer((_) async {}); - when(() => persistence.updateChannelThreads(any(), any())) - .thenAnswer((_) async {}); - when(() => persistence.updateChannelQueries(any(), any(), - clearQueryCache: any(named: 'clearQueryCache'))) - .thenAnswer((_) => Future.value()); + when(() => persistence.updateChannelState(any())).thenAnswer((_) async {}); + when(() => persistence.updateChannelThreads(any(), any())).thenAnswer((_) async {}); + when( + () => persistence.updateChannelQueries(any(), any(), clearQueryCache: any(named: 'clearQueryCache')), + ).thenAnswer((_) => Future.value()); expectLater( client.queryChannels(), @@ -669,31 +664,34 @@ void main() { // before our stream starts emitting data await delay(1050); - verify(() => persistence.getChannelStates( - filter: any(named: 'filter'), - channelStateSort: any(named: 'channelStateSort'), - paginationParams: any(named: 'paginationParams'), - )).called(1); - - verify(() => api.channel.queryChannels( - filter: any(named: 'filter'), - sort: any(named: 'sort'), - state: any(named: 'state'), - watch: any(named: 'watch'), - presence: any(named: 'presence'), - memberLimit: any(named: 'memberLimit'), - messageLimit: any(named: 'messageLimit'), - paginationParams: any(named: 'paginationParams'), - )).called(1); - - verify(() => persistence.getChannelThreads(any())) - .called(channelStates.length); - verify(() => persistence.updateChannelState(any())) - .called(channelStates.length); - verify(() => persistence.updateChannelThreads(any(), any())) - .called(channelStates.length); - verify(() => persistence.updateChannelQueries(any(), any(), - clearQueryCache: any(named: 'clearQueryCache'))).called(1); + verify( + () => persistence.getChannelStates( + filter: any(named: 'filter'), + messageLimit: any(named: 'messageLimit'), + channelStateSort: any(named: 'channelStateSort'), + paginationParams: any(named: 'paginationParams'), + ), + ).called(1); + + verify( + () => api.channel.queryChannels( + filter: any(named: 'filter'), + sort: any(named: 'sort'), + state: any(named: 'state'), + watch: any(named: 'watch'), + presence: any(named: 'presence'), + memberLimit: any(named: 'memberLimit'), + messageLimit: any(named: 'messageLimit'), + paginationParams: any(named: 'paginationParams'), + ), + ).called(1); + + verify(() => persistence.getChannelThreads(any())).called(channelStates.length); + verify(() => persistence.updateChannelState(any())).called(channelStates.length); + verify(() => persistence.updateChannelThreads(any(), any())).called(channelStates.length); + verify( + () => persistence.updateChannelQueries(any(), any(), clearQueryCache: any(named: 'clearQueryCache')), + ).called(1); }, ); @@ -707,36 +705,37 @@ void main() { ), ); - when(() => persistence.getChannelStates( - filter: any(named: 'filter'), - channelStateSort: any(named: 'channelStateSort'), - paginationParams: any(named: 'paginationParams'), - )).thenAnswer((_) async => persistentChannelStates); - - when(() => api.channel.queryChannels( - filter: any(named: 'filter'), - sort: any(named: 'sort'), - state: any(named: 'state'), - watch: any(named: 'watch'), - presence: any(named: 'presence'), - memberLimit: any(named: 'memberLimit'), - messageLimit: any(named: 'messageLimit'), - paginationParams: any(named: 'paginationParams'), - )).thenThrow(StreamChatNetworkError(ChatErrorCode.inputError)); + when( + () => persistence.getChannelStates( + filter: any(named: 'filter'), + messageLimit: any(named: 'messageLimit'), + channelStateSort: any(named: 'channelStateSort'), + paginationParams: any(named: 'paginationParams'), + ), + ).thenAnswer((_) async => persistentChannelStates); + + when( + () => api.channel.queryChannels( + filter: any(named: 'filter'), + sort: any(named: 'sort'), + state: any(named: 'state'), + watch: any(named: 'watch'), + presence: any(named: 'presence'), + memberLimit: any(named: 'memberLimit'), + messageLimit: any(named: 'messageLimit'), + paginationParams: any(named: 'paginationParams'), + ), + ).thenThrow(StreamChatNetworkError(ChatErrorCode.inputError)); when(() => persistence.getChannelThreads(any())).thenAnswer( (_) async => >{ for (final channelState in persistentChannelStates) - channelState.channel!.cid: [ - Message(id: 'test-message-id', text: 'Test message') - ], + channelState.channel!.cid: [Message(id: 'test-message-id', text: 'Test message')], }, ); - when(() => persistence.updateChannelState(any())) - .thenAnswer((_) async => {}); - when(() => persistence.updateChannelThreads(any(), any())) - .thenAnswer((_) async => {}); + when(() => persistence.updateChannelState(any())).thenAnswer((_) async => {}); + when(() => persistence.updateChannelThreads(any(), any())).thenAnswer((_) async => {}); expectLater( client.queryChannels(), @@ -750,29 +749,31 @@ void main() { // before our stream starts emitting data await delay(1050); - verify(() => persistence.getChannelStates( - filter: any(named: 'filter'), - channelStateSort: any(named: 'channelStateSort'), - paginationParams: any(named: 'paginationParams'), - )).called(1); - - verify(() => api.channel.queryChannels( - filter: any(named: 'filter'), - sort: any(named: 'sort'), - state: any(named: 'state'), - watch: any(named: 'watch'), - presence: any(named: 'presence'), - memberLimit: any(named: 'memberLimit'), - messageLimit: any(named: 'messageLimit'), - paginationParams: any(named: 'paginationParams'), - )).called(1); - - verify(() => persistence.getChannelThreads(any())) - .called(persistentChannelStates.length); - verify(() => persistence.updateChannelState(any())) - .called(persistentChannelStates.length); - verify(() => persistence.updateChannelThreads(any(), any())) - .called(persistentChannelStates.length); + verify( + () => persistence.getChannelStates( + filter: any(named: 'filter'), + messageLimit: any(named: 'messageLimit'), + channelStateSort: any(named: 'channelStateSort'), + paginationParams: any(named: 'paginationParams'), + ), + ).called(1); + + verify( + () => api.channel.queryChannels( + filter: any(named: 'filter'), + sort: any(named: 'sort'), + state: any(named: 'state'), + watch: any(named: 'watch'), + presence: any(named: 'presence'), + memberLimit: any(named: 'memberLimit'), + messageLimit: any(named: 'messageLimit'), + paginationParams: any(named: 'paginationParams'), + ), + ).called(1); + + verify(() => persistence.getChannelThreads(any())).called(persistentChannelStates.length); + verify(() => persistence.updateChannelState(any())).called(persistentChannelStates.length); + verify(() => persistence.updateChannelThreads(any(), any())).called(persistentChannelStates.length); }, ); }); @@ -831,21 +832,22 @@ void main() { const cids = ['test-cid-1', 'test-cid-2', 'test-cid-3']; final lastSyncAt = DateTime.now(); - when(() => api.general.sync(cids, lastSyncAt)) - .thenAnswer((_) async => SyncResponse() - ..events = [ - Event( - isLocal: false, - type: EventType.healthCheck, - connectionId: 'test-connection-id', - me: OwnUser.fromUser(user), - ), - Event( - isLocal: false, - type: EventType.messageDeleted, - message: Message(id: 'test-message-id'), - ), - ]); + when(() => api.general.sync(cids, lastSyncAt)).thenAnswer( + (_) async => SyncResponse() + ..events = [ + Event( + isLocal: false, + type: EventType.healthCheck, + connectionId: 'test-connection-id', + me: OwnUser.fromUser(user), + ), + Event( + isLocal: false, + type: EventType.messageDeleted, + message: Message(id: 'test-message-id'), + ), + ], + ); await client.sync(cids: cids, lastSyncAt: lastSyncAt); @@ -872,16 +874,18 @@ void main() { ), ); - when(() => api.channel.queryChannels( - filter: any(named: 'filter'), - sort: any(named: 'sort'), - state: any(named: 'state'), - watch: any(named: 'watch'), - presence: any(named: 'presence'), - memberLimit: any(named: 'memberLimit'), - messageLimit: any(named: 'messageLimit'), - paginationParams: any(named: 'paginationParams'), - )).thenAnswer( + when( + () => api.channel.queryChannels( + filter: any(named: 'filter'), + sort: any(named: 'sort'), + state: any(named: 'state'), + watch: any(named: 'watch'), + presence: any(named: 'presence'), + memberLimit: any(named: 'memberLimit'), + messageLimit: any(named: 'messageLimit'), + paginationParams: any(named: 'paginationParams'), + ), + ).thenAnswer( (_) async => QueryChannelsResponse()..channels = channelStates, ); @@ -894,7 +898,25 @@ void main() { // before our stream starts emitting data await delay(300); - verify(() => api.channel.queryChannels( + verify( + () => api.channel.queryChannels( + filter: any(named: 'filter'), + sort: any(named: 'sort'), + state: any(named: 'state'), + watch: any(named: 'watch'), + presence: any(named: 'presence'), + memberLimit: any(named: 'memberLimit'), + messageLimit: any(named: 'messageLimit'), + paginationParams: any(named: 'paginationParams'), + ), + ).called(1); + }); + + test( + '''should rethrow if `.queryChannelsOnline` throws and persistence channels are empty''', + () async { + when( + () => api.channel.queryChannels( filter: any(named: 'filter'), sort: any(named: 'sort'), state: any(named: 'state'), @@ -903,22 +925,8 @@ void main() { memberLimit: any(named: 'memberLimit'), messageLimit: any(named: 'messageLimit'), paginationParams: any(named: 'paginationParams'), - )).called(1); - }); - - test( - '''should rethrow if `.queryChannelsOnline` throws and persistence channels are empty''', - () async { - when(() => api.channel.queryChannels( - filter: any(named: 'filter'), - sort: any(named: 'sort'), - state: any(named: 'state'), - watch: any(named: 'watch'), - presence: any(named: 'presence'), - memberLimit: any(named: 'memberLimit'), - messageLimit: any(named: 'messageLimit'), - paginationParams: any(named: 'paginationParams'), - )).thenThrow(StreamChatNetworkError(ChatErrorCode.inputError)); + ), + ).thenThrow(StreamChatNetworkError(ChatErrorCode.inputError)); expectLater( client.queryChannels(), @@ -929,16 +937,18 @@ void main() { // before our stream starts emitting data await delay(300); - verify(() => api.channel.queryChannels( - filter: any(named: 'filter'), - sort: any(named: 'sort'), - state: any(named: 'state'), - watch: any(named: 'watch'), - presence: any(named: 'presence'), - memberLimit: any(named: 'memberLimit'), - messageLimit: any(named: 'messageLimit'), - paginationParams: any(named: 'paginationParams'), - )).called(1); + verify( + () => api.channel.queryChannels( + filter: any(named: 'filter'), + sort: any(named: 'sort'), + state: any(named: 'state'), + watch: any(named: 'watch'), + presence: any(named: 'presence'), + memberLimit: any(named: 'memberLimit'), + messageLimit: any(named: 'messageLimit'), + paginationParams: any(named: 'paginationParams'), + ), + ).called(1); }, ); }); @@ -949,12 +959,14 @@ void main() { (index) => User(id: 'test-user-id-$index'), ); - when(() => api.user.queryUsers( - presence: any(named: 'presence'), - filter: any(named: 'filter'), - sort: any(named: 'sort'), - pagination: any(named: 'pagination'), - )).thenAnswer((_) async => QueryUsersResponse()..users = users); + when( + () => api.user.queryUsers( + presence: any(named: 'presence'), + filter: any(named: 'filter'), + sort: any(named: 'sort'), + pagination: any(named: 'pagination'), + ), + ).thenAnswer((_) async => QueryUsersResponse()..users = users); expectLater( // skipping initial seed event -> {} users @@ -968,12 +980,14 @@ void main() { expect(res, isNotNull); expect(res.users.length, users.length); - verify(() => api.user.queryUsers( - presence: any(named: 'presence'), - filter: any(named: 'filter'), - sort: any(named: 'sort'), - pagination: any(named: 'pagination'), - )).called(1); + verify( + () => api.user.queryUsers( + presence: any(named: 'presence'), + filter: any(named: 'filter'), + sort: any(named: 'sort'), + pagination: any(named: 'pagination'), + ), + ).called(1); verifyNoMoreInteractions(api.user); }); @@ -989,21 +1003,25 @@ void main() { const cid = 'message:nice-channel'; final filter = Filter.equal('channel_cid', cid); - when(() => api.moderation.queryBannedUsers( - filter: filter, - sort: any(named: 'sort'), - pagination: any(named: 'pagination'), - )).thenAnswer((_) async => QueryBannedUsersResponse()..bans = bans); + when( + () => api.moderation.queryBannedUsers( + filter: filter, + sort: any(named: 'sort'), + pagination: any(named: 'pagination'), + ), + ).thenAnswer((_) async => QueryBannedUsersResponse()..bans = bans); final res = await client.queryBannedUsers(filter: filter); expect(res, isNotNull); expect(res.bans.length, bans.length); - verify(() => api.moderation.queryBannedUsers( - filter: filter, - sort: any(named: 'sort'), - pagination: any(named: 'pagination'), - )).called(1); + verify( + () => api.moderation.queryBannedUsers( + filter: filter, + sort: any(named: 'sort'), + pagination: any(named: 'pagination'), + ), + ).called(1); verifyNoMoreInteractions(api.moderation); }); @@ -1018,23 +1036,29 @@ void main() { ..message = Message(id: 'test-message-id-$index'), ); - when(() => api.general.searchMessages(filter, - query: any(named: 'query'), - sort: any(named: 'sort'), - pagination: any(named: 'pagination'), - messageFilters: any(named: 'messageFilters'))) - .thenAnswer( - (_) async => SearchMessagesResponse()..results = messages); + when( + () => api.general.searchMessages( + filter, + query: any(named: 'query'), + sort: any(named: 'sort'), + pagination: any(named: 'pagination'), + messageFilters: any(named: 'messageFilters'), + ), + ).thenAnswer((_) async => SearchMessagesResponse()..results = messages); final res = await client.search(filter); expect(res, isNotNull); expect(res.results.length, messages.length); - verify(() => api.general.searchMessages(filter, + verify( + () => api.general.searchMessages( + filter, query: any(named: 'query'), sort: any(named: 'sort'), pagination: any(named: 'pagination'), - messageFilters: any(named: 'messageFilters'))).called(1); + messageFilters: any(named: 'messageFilters'), + ), + ).called(1); verifyNoMoreInteractions(api.general); }); @@ -1045,15 +1069,15 @@ void main() { const fileUrl = 'test-file-url'; - when(() => api.fileUploader.sendFile(file, channelId, channelType)) - .thenAnswer((_) async => SendFileResponse()..file = fileUrl); + when( + () => api.fileUploader.sendFile(file, channelId, channelType), + ).thenAnswer((_) async => SendFileResponse()..file = fileUrl); final res = await client.sendFile(file, channelId, channelType); expect(res, isNotNull); expect(res.file, fileUrl); - verify(() => api.fileUploader.sendFile(file, channelId, channelType)) - .called(1); + verify(() => api.fileUploader.sendFile(file, channelId, channelType)).called(1); verifyNoMoreInteractions(api.fileUploader); }); @@ -1064,15 +1088,15 @@ void main() { const fileUrl = 'test-image-url'; - when(() => api.fileUploader.sendImage(image, channelId, channelType)) - .thenAnswer((_) async => SendImageResponse()..file = fileUrl); + when( + () => api.fileUploader.sendImage(image, channelId, channelType), + ).thenAnswer((_) async => SendImageResponse()..file = fileUrl); final res = await client.sendImage(image, channelId, channelType); expect(res, isNotNull); expect(res.file, fileUrl); - verify(() => api.fileUploader.sendImage(image, channelId, channelType)) - .called(1); + verify(() => api.fileUploader.sendImage(image, channelId, channelType)).called(1); verifyNoMoreInteractions(api.fileUploader); }); @@ -1081,14 +1105,12 @@ void main() { const channelType = 'test-channel-type'; const fileUrl = 'test-file-url'; - when(() => api.fileUploader.deleteFile(fileUrl, channelId, channelType)) - .thenAnswer((_) async => EmptyResponse()); + when(() => api.fileUploader.deleteFile(fileUrl, channelId, channelType)).thenAnswer((_) async => EmptyResponse()); final res = await client.deleteFile(fileUrl, channelId, channelType); expect(res, isNotNull); - verify(() => api.fileUploader.deleteFile(fileUrl, channelId, channelType)) - .called(1); + verify(() => api.fileUploader.deleteFile(fileUrl, channelId, channelType)).called(1); verifyNoMoreInteractions(api.fileUploader); }); @@ -1097,8 +1119,9 @@ void main() { const channelType = 'test-channel-type'; const imageUrl = 'test-image-url'; - when(() => api.fileUploader.deleteImage(imageUrl, channelId, channelType)) - .thenAnswer((_) async => EmptyResponse()); + when( + () => api.fileUploader.deleteImage(imageUrl, channelId, channelType), + ).thenAnswer((_) async => EmptyResponse()); final res = await client.deleteImage(imageUrl, channelId, channelType); expect(res, isNotNull); @@ -1109,26 +1132,78 @@ void main() { verifyNoMoreInteractions(api.fileUploader); }); + test('`.uploadImage`', () async { + final image = AttachmentFile(size: 33, path: 'test-image-path'); + const fileUrl = 'test-image-url'; + + when(() => api.fileUploader.uploadImage(image)).thenAnswer((_) async => UploadImageResponse()..file = fileUrl); + + final res = await client.uploadImage(image); + expect(res, isNotNull); + expect(res.file, fileUrl); + + verify(() => api.fileUploader.uploadImage(image)).called(1); + verifyNoMoreInteractions(api.fileUploader); + }); + + test('`.uploadFile`', () async { + final file = AttachmentFile(size: 33, path: 'test-file-path'); + const fileUrl = 'test-file-url'; + + when(() => api.fileUploader.uploadFile(file)).thenAnswer((_) async => UploadFileResponse()..file = fileUrl); + + final res = await client.uploadFile(file); + expect(res, isNotNull); + expect(res.file, fileUrl); + + verify(() => api.fileUploader.uploadFile(file)).called(1); + verifyNoMoreInteractions(api.fileUploader); + }); + + test('`.removeImage`', () async { + const imageUrl = 'test-image-url'; + + when(() => api.fileUploader.removeImage(imageUrl)).thenAnswer((_) async => EmptyResponse()); + + final res = await client.removeImage(imageUrl); + expect(res, isNotNull); + + verify(() => api.fileUploader.removeImage(imageUrl)).called(1); + verifyNoMoreInteractions(api.fileUploader); + }); + + test('`.removeFile`', () async { + const fileUrl = 'test-file-url'; + + when(() => api.fileUploader.removeFile(fileUrl)).thenAnswer((_) async => EmptyResponse()); + + final res = await client.removeFile(fileUrl); + expect(res, isNotNull); + + verify(() => api.fileUploader.removeFile(fileUrl)).called(1); + verifyNoMoreInteractions(api.fileUploader); + }); + test('`.updateChannel`', () async { const channelId = 'test-channel-id'; const channelType = 'test-channel-type'; const data = {'name': 'test-channel'}; - when(() => api.channel.updateChannel(channelId, channelType, data)) - .thenAnswer((invocation) async => UpdateChannelResponse() - ..channel = ChannelModel( - id: channelId, - type: channelType, - extraData: {...data}, - )); + when(() => api.channel.updateChannel(channelId, channelType, data)).thenAnswer( + (invocation) async => UpdateChannelResponse() + ..channel = ChannelModel( + id: channelId, + type: channelType, + extraData: {...data}, + ), + ); final res = await client.updateChannel(channelId, channelType, data); expect(res, isNotNull); expect(res.channel.cid, '$channelType:$channelId'); expect(res.channel.extraData['name'], 'test-channel'); - verify(() => api.channel.updateChannel(channelId, channelType, data)) - .called(1); + verify(() => api.channel.updateChannel(channelId, channelType, data)).called(1); verifyNoMoreInteractions(api.channel); }); @@ -1141,14 +1216,14 @@ void main() { }; const unset = ['tag', 'last_name']; - when(() => api.channel.updateChannelPartial(channelId, channelType, - set: set, unset: unset)) - .thenAnswer((invocation) async => PartialUpdateChannelResponse() - ..channel = ChannelModel( - id: channelId, - type: channelType, - extraData: {...set}, - )); + when(() => api.channel.updateChannelPartial(channelId, channelType, set: set, unset: unset)).thenAnswer( + (invocation) async => PartialUpdateChannelResponse() + ..channel = ChannelModel( + id: channelId, + type: channelType, + extraData: {...set}, + ), + ); final res = await client.updateChannelPartial( channelId, @@ -1160,8 +1235,7 @@ void main() { expect(res.channel.cid, '$channelType:$channelId'); expect(res.channel.extraData, set); - verify(() => api.channel.updateChannelPartial(channelId, channelType, - set: set, unset: unset)).called(1); + verify(() => api.channel.updateChannelPartial(channelId, channelType, set: set, unset: unset)).called(1); verifyNoMoreInteractions(api.channel); }); @@ -1169,8 +1243,7 @@ void main() { const id = 'test-device-id'; const provider = PushProvider.firebase; - when(() => api.device.addDevice(id, provider)) - .thenAnswer((_) async => EmptyResponse()); + when(() => api.device.addDevice(id, provider)).thenAnswer((_) async => EmptyResponse()); final res = await client.addDevice(id, provider); expect(res, isNotNull); @@ -1199,11 +1272,13 @@ void main() { ); expect(res, isNotNull); - verify(() => api.device.addDevice( - id, - provider, - pushProviderName: pushProviderName, - )).called(1); + verify( + () => api.device.addDevice( + id, + provider, + pushProviderName: pushProviderName, + ), + ).called(1); verifyNoMoreInteractions(api.device); }); @@ -1216,8 +1291,7 @@ void main() { ), ); - when(() => api.device.getDevices()) - .thenAnswer((_) async => ListDevicesResponse()..devices = devices); + when(() => api.device.getDevices()).thenAnswer((_) async => ListDevicesResponse()..devices = devices); final res = await client.getDevices(); expect(res, isNotNull); @@ -1230,8 +1304,7 @@ void main() { test('`.removeDevice`', () async { const deviceId = 'test-device-id'; - when(() => api.device.removeDevice(deviceId)) - .thenAnswer((_) async => EmptyResponse()); + when(() => api.device.removeDevice(deviceId)).thenAnswer((_) async => EmptyResponse()); final res = await client.removeDevice(deviceId); expect(res, isNotNull); @@ -1351,8 +1424,7 @@ void main() { expect(channel.extraData, channelData); }); - test('should return back in memory channel instance if available', - () async { + test('should return back in memory channel instance if available', () async { const channelType = 'test-channel-type'; const channelId = 'test-channel-id'; const channelData = {'name': 'test-channel-name'}; @@ -1368,22 +1440,24 @@ void main() { channel: ChannelModel(cid: channelCid), ); - when(() => api.channel.queryChannel( - channelType, - channelId: channelId, - channelData: channelData, - state: any(named: 'state'), - watch: any(named: 'watch'), - presence: any(named: 'presence'), - messagesPagination: any(named: 'messagesPagination'), - membersPagination: any(named: 'membersPagination'), - watchersPagination: any(named: 'watchersPagination'), - )).thenAnswer((_) async => channelState); + when( + () => api.channel.queryChannel( + channelType, + channelId: channelId, + channelData: channelData, + state: any(named: 'state'), + watch: any(named: 'watch'), + presence: any(named: 'presence'), + messagesPagination: any(named: 'messagesPagination'), + membersPagination: any(named: 'membersPagination'), + watchersPagination: any(named: 'watchersPagination'), + ), + ).thenAnswer((_) async => channelState); expectLater( client.state.channelsStream.skip(1), emitsInOrder([ - {channelCid: isCorrectChannelFor(channelState)} + {channelCid: isCorrectChannelFor(channelState)}, ]), ); @@ -1392,17 +1466,19 @@ void main() { final newChannel = client.channel(channelType, id: channelId); expect(newChannel, channel); - verify(() => api.channel.queryChannel( - channelType, - channelId: channelId, - channelData: channelData, - state: any(named: 'state'), - watch: any(named: 'watch'), - presence: any(named: 'presence'), - messagesPagination: any(named: 'messagesPagination'), - membersPagination: any(named: 'membersPagination'), - watchersPagination: any(named: 'watchersPagination'), - )).called(1); + verify( + () => api.channel.queryChannel( + channelType, + channelId: channelId, + channelData: channelData, + state: any(named: 'state'), + watch: any(named: 'watch'), + presence: any(named: 'presence'), + messagesPagination: any(named: 'messagesPagination'), + membersPagination: any(named: 'membersPagination'), + watchersPagination: any(named: 'watchersPagination'), + ), + ).called(1); }); }); @@ -1416,17 +1492,19 @@ void main() { channel: ChannelModel(cid: channelCid, extraData: channelData), ); - when(() => api.channel.queryChannel( - channelType, - channelId: channelId, - channelData: channelData, - state: any(named: 'state'), - watch: any(named: 'watch'), - presence: any(named: 'presence'), - messagesPagination: any(named: 'messagesPagination'), - membersPagination: any(named: 'membersPagination'), - watchersPagination: any(named: 'watchersPagination'), - )).thenAnswer((_) async => channelState); + when( + () => api.channel.queryChannel( + channelType, + channelId: channelId, + channelData: channelData, + state: any(named: 'state'), + watch: any(named: 'watch'), + presence: any(named: 'presence'), + messagesPagination: any(named: 'messagesPagination'), + membersPagination: any(named: 'membersPagination'), + watchersPagination: any(named: 'watchersPagination'), + ), + ).thenAnswer((_) async => channelState); final res = await client.createChannel( channelType, @@ -1442,17 +1520,19 @@ void main() { expect(channel.cid, '$channelType:$channelId'); expect(channel.extraData, channelData); - verify(() => api.channel.queryChannel( - channelType, - channelId: channelId, - channelData: channelData, - state: any(named: 'state'), - watch: any(named: 'watch'), - presence: any(named: 'presence'), - messagesPagination: any(named: 'messagesPagination'), - membersPagination: any(named: 'membersPagination'), - watchersPagination: any(named: 'watchersPagination'), - )).called(1); + verify( + () => api.channel.queryChannel( + channelType, + channelId: channelId, + channelData: channelData, + state: any(named: 'state'), + watch: any(named: 'watch'), + presence: any(named: 'presence'), + messagesPagination: any(named: 'messagesPagination'), + membersPagination: any(named: 'membersPagination'), + watchersPagination: any(named: 'watchersPagination'), + ), + ).called(1); verifyNoMoreInteractions(api.channel); }); @@ -1466,17 +1546,19 @@ void main() { channel: ChannelModel(cid: channelCid, extraData: channelData), ); - when(() => api.channel.queryChannel( - channelType, - channelId: channelId, - channelData: channelData, - state: any(named: 'state'), - watch: any(named: 'watch'), - presence: any(named: 'presence'), - messagesPagination: any(named: 'messagesPagination'), - membersPagination: any(named: 'membersPagination'), - watchersPagination: any(named: 'watchersPagination'), - )).thenAnswer((_) async => channelState); + when( + () => api.channel.queryChannel( + channelType, + channelId: channelId, + channelData: channelData, + state: any(named: 'state'), + watch: any(named: 'watch'), + presence: any(named: 'presence'), + messagesPagination: any(named: 'messagesPagination'), + membersPagination: any(named: 'membersPagination'), + watchersPagination: any(named: 'watchersPagination'), + ), + ).thenAnswer((_) async => channelState); final res = await client.watchChannel( channelType, @@ -1492,17 +1574,19 @@ void main() { expect(channel.cid, '$channelType:$channelId'); expect(channel.extraData, channelData); - verify(() => api.channel.queryChannel( - channelType, - channelId: channelId, - channelData: channelData, - state: any(named: 'state'), - watch: any(named: 'watch'), - presence: any(named: 'presence'), - messagesPagination: any(named: 'messagesPagination'), - membersPagination: any(named: 'membersPagination'), - watchersPagination: any(named: 'watchersPagination'), - )).called(1); + verify( + () => api.channel.queryChannel( + channelType, + channelId: channelId, + channelData: channelData, + state: any(named: 'state'), + watch: any(named: 'watch'), + presence: any(named: 'presence'), + messagesPagination: any(named: 'messagesPagination'), + membersPagination: any(named: 'membersPagination'), + watchersPagination: any(named: 'watchersPagination'), + ), + ).called(1); verifyNoMoreInteractions(api.channel); }); @@ -1516,17 +1600,19 @@ void main() { channel: ChannelModel(cid: channelCid, extraData: channelData), ); - when(() => api.channel.queryChannel( - channelType, - channelId: channelId, - channelData: channelData, - state: any(named: 'state'), - watch: any(named: 'watch'), - presence: any(named: 'presence'), - messagesPagination: any(named: 'messagesPagination'), - membersPagination: any(named: 'membersPagination'), - watchersPagination: any(named: 'watchersPagination'), - )).thenAnswer((_) async => channelState); + when( + () => api.channel.queryChannel( + channelType, + channelId: channelId, + channelData: channelData, + state: any(named: 'state'), + watch: any(named: 'watch'), + presence: any(named: 'presence'), + messagesPagination: any(named: 'messagesPagination'), + membersPagination: any(named: 'membersPagination'), + watchersPagination: any(named: 'watchersPagination'), + ), + ).thenAnswer((_) async => channelState); final res = await client.queryChannel( channelType, @@ -1542,17 +1628,19 @@ void main() { expect(channel.cid, '$channelType:$channelId'); expect(channel.extraData, channelData); - verify(() => api.channel.queryChannel( - channelType, - channelId: channelId, - channelData: channelData, - state: any(named: 'state'), - watch: any(named: 'watch'), - presence: any(named: 'presence'), - messagesPagination: any(named: 'messagesPagination'), - membersPagination: any(named: 'membersPagination'), - watchersPagination: any(named: 'watchersPagination'), - )).called(1); + verify( + () => api.channel.queryChannel( + channelType, + channelId: channelId, + channelData: channelData, + state: any(named: 'state'), + watch: any(named: 'watch'), + presence: any(named: 'presence'), + messagesPagination: any(named: 'messagesPagination'), + membersPagination: any(named: 'membersPagination'), + watchersPagination: any(named: 'watchersPagination'), + ), + ).called(1); verifyNoMoreInteractions(api.channel); }); @@ -1580,8 +1668,7 @@ void main() { const channelType = 'test-channel-type'; const channelId = 'test-channel-id'; - when(() => api.channel.hideChannel(channelId, channelType)) - .thenAnswer((_) async => EmptyResponse()); + when(() => api.channel.hideChannel(channelId, channelType)).thenAnswer((_) async => EmptyResponse()); final res = await client.hideChannel(channelId, channelType); @@ -1595,8 +1682,7 @@ void main() { const channelType = 'test-channel-type'; const channelId = 'test-channel-id'; - when(() => api.channel.showChannel(channelId, channelType)) - .thenAnswer((_) async => EmptyResponse()); + when(() => api.channel.showChannel(channelId, channelType)).thenAnswer((_) async => EmptyResponse()); final res = await client.showChannel(channelId, channelType); @@ -1610,8 +1696,7 @@ void main() { const channelType = 'test-channel-type'; const channelId = 'test-channel-id'; - when(() => api.channel.deleteChannel(channelId, channelType)) - .thenAnswer((_) async => EmptyResponse()); + when(() => api.channel.deleteChannel(channelId, channelType)).thenAnswer((_) async => EmptyResponse()); final res = await client.deleteChannel(channelId, channelType); @@ -1625,8 +1710,7 @@ void main() { const channelType = 'test-channel-type'; const channelId = 'test-channel-id'; - when(() => api.channel.truncateChannel(channelId, channelType)) - .thenAnswer((_) async => EmptyResponse()); + when(() => api.channel.truncateChannel(channelId, channelType)).thenAnswer((_) async => EmptyResponse()); final res = await client.truncateChannel(channelId, channelType); @@ -1643,8 +1727,7 @@ void main() { const channelId = 'test-channel-id'; const channelCid = '$channelType:$channelId'; - when(() => api.moderation.muteChannel(channelCid)) - .thenAnswer((_) async => EmptyResponse()); + when(() => api.moderation.muteChannel(channelCid)).thenAnswer((_) async => EmptyResponse()); final res = await client.muteChannel(channelCid); @@ -1659,8 +1742,7 @@ void main() { const channelId = 'test-channel-id'; const channelCid = '$channelType:$channelId'; - when(() => api.moderation.unmuteChannel(channelCid)) - .thenAnswer((_) async => EmptyResponse()); + when(() => api.moderation.unmuteChannel(channelCid)).thenAnswer((_) async => EmptyResponse()); final res = await client.unmuteChannel(channelCid); @@ -1677,14 +1759,18 @@ void main() { const set = {'pinned': true}; const unset = ['pinned']; - when(() => api.channel.updateMemberPartial( - channelId: channelId, - channelType: channelType, - set: set, - unset: unset, - )).thenAnswer((_) async => FakePartialUpdateMemberResponse( - channelMember: Member(userId: otherUserId), - )); + when( + () => api.channel.updateMemberPartial( + channelId: channelId, + channelType: channelType, + set: set, + unset: unset, + ), + ).thenAnswer( + (_) async => FakePartialUpdateMemberResponse( + channelMember: Member(userId: otherUserId), + ), + ); final res = await client.partialMemberUpdate( channelId: channelId, @@ -1696,12 +1782,14 @@ void main() { expect(res, isNotNull); expect(res.channelMember.userId, otherUserId); - verify(() => api.channel.updateMemberPartial( - channelId: channelId, - channelType: channelType, - set: set, - unset: unset, - )).called(1); + verify( + () => api.channel.updateMemberPartial( + channelId: channelId, + channelType: channelType, + set: set, + unset: unset, + ), + ).called(1); verifyNoMoreInteractions(api.channel); }); @@ -1711,14 +1799,18 @@ void main() { const set = {'pinned': true}; const unset = ['pinned']; - when(() => api.channel.updateMemberPartial( - channelId: channelId, - channelType: channelType, - set: set, - unset: unset, - )).thenAnswer((_) async => FakePartialUpdateMemberResponse( - channelMember: Member(userId: userId), - )); + when( + () => api.channel.updateMemberPartial( + channelId: channelId, + channelType: channelType, + set: set, + unset: unset, + ), + ).thenAnswer( + (_) async => FakePartialUpdateMemberResponse( + channelMember: Member(userId: userId), + ), + ); final res = await client.partialMemberUpdate( channelId: channelId, @@ -1729,12 +1821,14 @@ void main() { expect(res, isNotNull); expect(res.channelMember.userId, userId); - verify(() => api.channel.updateMemberPartial( - channelId: channelId, - channelType: channelType, - set: set, - unset: unset, - )).called(1); + verify( + () => api.channel.updateMemberPartial( + channelId: channelId, + channelType: channelType, + set: set, + unset: unset, + ), + ).called(1); verifyNoMoreInteractions(api.channel); }); @@ -1742,13 +1836,17 @@ void main() { const channelType = 'test-channel-type'; const channelId = 'test-channel-id'; - when(() => api.channel.updateMemberPartial( - channelId: channelId, - channelType: channelType, - set: const MemberUpdatePayload(pinned: true).toJson(), - )).thenAnswer((_) async => FakePartialUpdateMemberResponse( - channelMember: Member(userId: userId, pinnedAt: DateTime.now()), - )); + when( + () => api.channel.updateMemberPartial( + channelId: channelId, + channelType: channelType, + set: const MemberUpdatePayload(pinned: true).toJson(), + ), + ).thenAnswer( + (_) async => FakePartialUpdateMemberResponse( + channelMember: Member(userId: userId, pinnedAt: DateTime.now()), + ), + ); final res = await client.pinChannel( channelId: channelId, @@ -1757,11 +1855,13 @@ void main() { expect(res, isNotNull); - verify(() => api.channel.updateMemberPartial( - channelId: channelId, - channelType: channelType, - set: const MemberUpdatePayload(pinned: true).toJson(), - )).called(1); + verify( + () => api.channel.updateMemberPartial( + channelId: channelId, + channelType: channelType, + set: const MemberUpdatePayload(pinned: true).toJson(), + ), + ).called(1); verifyNoMoreInteractions(api.channel); }); @@ -1769,13 +1869,17 @@ void main() { const channelType = 'test-channel-type'; const channelId = 'test-channel-id'; - when(() => api.channel.updateMemberPartial( - channelId: channelId, - channelType: channelType, - unset: [MemberUpdateType.pinned.name], - )).thenAnswer((_) async => FakePartialUpdateMemberResponse( - channelMember: Member(userId: userId, pinnedAt: DateTime.now()), - )); + when( + () => api.channel.updateMemberPartial( + channelId: channelId, + channelType: channelType, + unset: [MemberUpdateType.pinned.name], + ), + ).thenAnswer( + (_) async => FakePartialUpdateMemberResponse( + channelMember: Member(userId: userId, pinnedAt: DateTime.now()), + ), + ); final res = await client.unpinChannel( channelId: channelId, @@ -1784,11 +1888,13 @@ void main() { expect(res, isNotNull); - verify(() => api.channel.updateMemberPartial( - channelId: channelId, - channelType: channelType, - unset: [MemberUpdateType.pinned.name], - )).called(1); + verify( + () => api.channel.updateMemberPartial( + channelId: channelId, + channelType: channelType, + unset: [MemberUpdateType.pinned.name], + ), + ).called(1); verifyNoMoreInteractions(api.channel); }); @@ -1796,13 +1902,17 @@ void main() { const channelType = 'test-channel-type'; const channelId = 'test-channel-id'; - when(() => api.channel.updateMemberPartial( - channelId: channelId, - channelType: channelType, - set: const MemberUpdatePayload(archived: true).toJson(), - )).thenAnswer((_) async => FakePartialUpdateMemberResponse( - channelMember: Member(userId: userId, archivedAt: DateTime.now()), - )); + when( + () => api.channel.updateMemberPartial( + channelId: channelId, + channelType: channelType, + set: const MemberUpdatePayload(archived: true).toJson(), + ), + ).thenAnswer( + (_) async => FakePartialUpdateMemberResponse( + channelMember: Member(userId: userId, archivedAt: DateTime.now()), + ), + ); final res = await client.archiveChannel( channelId: channelId, @@ -1811,11 +1921,13 @@ void main() { expect(res, isNotNull); - verify(() => api.channel.updateMemberPartial( - channelId: channelId, - channelType: channelType, - set: const MemberUpdatePayload(archived: true).toJson(), - )).called(1); + verify( + () => api.channel.updateMemberPartial( + channelId: channelId, + channelType: channelType, + set: const MemberUpdatePayload(archived: true).toJson(), + ), + ).called(1); verifyNoMoreInteractions(api.channel); }); @@ -1823,13 +1935,17 @@ void main() { const channelType = 'test-channel-type'; const channelId = 'test-channel-id'; - when(() => api.channel.updateMemberPartial( - channelId: channelId, - channelType: channelType, - unset: [MemberUpdateType.archived.name], - )).thenAnswer((_) async => FakePartialUpdateMemberResponse( - channelMember: Member(userId: userId, pinnedAt: DateTime.now()), - )); + when( + () => api.channel.updateMemberPartial( + channelId: channelId, + channelType: channelType, + unset: [MemberUpdateType.archived.name], + ), + ).thenAnswer( + (_) async => FakePartialUpdateMemberResponse( + channelMember: Member(userId: userId, pinnedAt: DateTime.now()), + ), + ); final res = await client.unarchiveChannel( channelId: channelId, @@ -1838,11 +1954,13 @@ void main() { expect(res, isNotNull); - verify(() => api.channel.updateMemberPartial( - channelId: channelId, - channelType: channelType, - unset: [MemberUpdateType.archived.name], - )).called(1); + verify( + () => api.channel.updateMemberPartial( + channelId: channelId, + channelType: channelType, + unset: [MemberUpdateType.archived.name], + ), + ).called(1); verifyNoMoreInteractions(api.channel); }); @@ -1851,16 +1969,15 @@ void main() { const channelId = 'test-channel-id'; const channelCid = '$channelType:$channelId'; - when(() => api.channel.acceptChannelInvite(channelId, channelType)) - .thenAnswer((_) async => - AcceptInviteResponse()..channel = ChannelModel(cid: channelCid)); + when( + () => api.channel.acceptChannelInvite(channelId, channelType), + ).thenAnswer((_) async => AcceptInviteResponse()..channel = ChannelModel(cid: channelCid)); final res = await client.acceptChannelInvite(channelId, channelType); expect(res, isNotNull); expect(res.channel.cid, channelCid); - verify(() => api.channel.acceptChannelInvite(channelId, channelType)) - .called(1); + verify(() => api.channel.acceptChannelInvite(channelId, channelType)).called(1); verifyNoMoreInteractions(api.channel); }); @@ -1869,16 +1986,15 @@ void main() { const channelId = 'test-channel-id'; const channelCid = '$channelType:$channelId'; - when(() => api.channel.rejectChannelInvite(channelId, channelType)) - .thenAnswer((_) async => - RejectInviteResponse()..channel = ChannelModel(cid: channelCid)); + when( + () => api.channel.rejectChannelInvite(channelId, channelType), + ).thenAnswer((_) async => RejectInviteResponse()..channel = ChannelModel(cid: channelCid)); final res = await client.rejectChannelInvite(channelId, channelType); expect(res, isNotNull); expect(res.channel.cid, channelCid); - verify(() => api.channel.rejectChannelInvite(channelId, channelType)) - .called(1); + verify(() => api.channel.rejectChannelInvite(channelId, channelType)).called(1); verifyNoMoreInteractions(api.channel); }); @@ -1894,10 +2010,11 @@ void main() { final memberIds = members.map((e) => e.userId!).toList(growable: false); - when(() => api.channel.addMembers(channelId, channelType, memberIds)) - .thenAnswer((_) async => AddMembersResponse() - ..channel = ChannelModel(cid: channelCid) - ..members = members); + when(() => api.channel.addMembers(channelId, channelType, memberIds)).thenAnswer( + (_) async => AddMembersResponse() + ..channel = ChannelModel(cid: channelCid) + ..members = members, + ); final res = await client.addChannelMembers( channelId, @@ -1928,14 +2045,18 @@ void main() { final memberIds = members.map((e) => e.userId!).toList(growable: false); final hideHistoryBefore = DateTime.parse('2024-01-01T00:00:00Z'); - when(() => api.channel.addMembers( - channelId, - channelType, - memberIds, - hideHistoryBefore: hideHistoryBefore, - )).thenAnswer((_) async => AddMembersResponse() - ..channel = ChannelModel(cid: channelCid) - ..members = members); + when( + () => api.channel.addMembers( + channelId, + channelType, + memberIds, + hideHistoryBefore: hideHistoryBefore, + ), + ).thenAnswer( + (_) async => AddMembersResponse() + ..channel = ChannelModel(cid: channelCid) + ..members = members, + ); final res = await client.addChannelMembers( channelId, @@ -1971,10 +2092,11 @@ void main() { final memberIds = members.map((e) => e.userId!).toList(growable: false); - when(() => api.channel.removeMembers(channelId, channelType, memberIds)) - .thenAnswer((_) async => RemoveMembersResponse() - ..channel = ChannelModel(cid: channelCid) - ..members = members); + when(() => api.channel.removeMembers(channelId, channelType, memberIds)).thenAnswer( + (_) async => RemoveMembersResponse() + ..channel = ChannelModel(cid: channelCid) + ..members = members, + ); final res = await client.removeChannelMembers( channelId, @@ -2004,11 +2126,11 @@ void main() { final memberIds = members.map((e) => e.userId!).toList(growable: false); - when(() => api.channel - .inviteChannelMembers(channelId, channelType, memberIds)) - .thenAnswer((_) async => InviteMembersResponse() - ..channel = ChannelModel(cid: channelCid) - ..members = members); + when(() => api.channel.inviteChannelMembers(channelId, channelType, memberIds)).thenAnswer( + (_) async => InviteMembersResponse() + ..channel = ChannelModel(cid: channelCid) + ..members = members, + ); final res = await client.inviteChannelMembers( channelId, @@ -2020,8 +2142,7 @@ void main() { expect(res.channel.cid, channelCid); expect(res.members.length, memberIds.length); - verify(() => api.channel - .inviteChannelMembers(channelId, channelType, memberIds)).called(1); + verify(() => api.channel.inviteChannelMembers(channelId, channelType, memberIds)).called(1); verifyNoMoreInteractions(api.channel); }); @@ -2029,8 +2150,7 @@ void main() { const channelType = 'test-channel-type'; const channelId = 'test-channel-id'; - when(() => api.channel.stopWatching(channelId, channelType)) - .thenAnswer((_) async => EmptyResponse()); + when(() => api.channel.stopWatching(channelId, channelType)).thenAnswer((_) async => EmptyResponse()); final res = await client.stopChannelWatching(channelId, channelType); expect(res, isNotNull); @@ -2045,9 +2165,9 @@ void main() { const messageId = 'test-message-id'; const formData = {'key': 'value'}; - when(() => api.message - .sendAction(channelId, channelType, messageId, formData)) - .thenAnswer((_) async => SendActionResponse()); + when( + () => api.message.sendAction(channelId, channelType, messageId, formData), + ).thenAnswer((_) async => SendActionResponse()); final res = await client.sendAction( channelId, @@ -2058,8 +2178,7 @@ void main() { expect(res, isNotNull); - verify(() => api.message - .sendAction(channelId, channelType, messageId, formData)).called(1); + verify(() => api.message.sendAction(channelId, channelType, messageId, formData)).called(1); verifyNoMoreInteractions(api.message); }); @@ -2067,8 +2186,7 @@ void main() { const channelType = 'test-channel-type'; const channelId = 'test-channel-id'; - when(() => api.channel.markRead(channelId, channelType)) - .thenAnswer((_) async => EmptyResponse()); + when(() => api.channel.markRead(channelId, channelType)).thenAnswer((_) async => EmptyResponse()); final res = await client.markChannelRead(channelId, channelType); @@ -2083,8 +2201,7 @@ void main() { const channelId = 'test-channel-id'; const messageId = 'test-message-id'; - when(() => api.channel.markUnread(channelId, channelType, messageId)) - .thenAnswer((_) async => EmptyResponse()); + when(() => api.channel.markUnread(channelId, channelType, messageId)).thenAnswer((_) async => EmptyResponse()); final res = await client.markChannelUnread( channelId, @@ -2094,8 +2211,7 @@ void main() { expect(res, isNotNull); - verify(() => api.channel.markUnread(channelId, channelType, messageId)) - .called(1); + verify(() => api.channel.markUnread(channelId, channelType, messageId)).called(1); verifyNoMoreInteractions(api.channel); }); @@ -2104,11 +2220,13 @@ void main() { const channelId = 'test-channel-id'; final timestamp = DateTime.parse('2024-01-01T00:00:00Z'); - when(() => api.channel.markUnreadByTimestamp( - channelId, - channelType, - timestamp, - )).thenAnswer((_) async => EmptyResponse()); + when( + () => api.channel.markUnreadByTimestamp( + channelId, + channelType, + timestamp, + ), + ).thenAnswer((_) async => EmptyResponse()); final res = await client.markChannelUnreadByTimestamp( channelId, @@ -2118,11 +2236,13 @@ void main() { expect(res, isNotNull); - verify(() => api.channel.markUnreadByTimestamp( - channelId, - channelType, - timestamp, - )).called(1); + verify( + () => api.channel.markUnreadByTimestamp( + channelId, + channelType, + timestamp, + ), + ).called(1); verifyNoMoreInteractions(api.channel); }); @@ -2206,25 +2326,23 @@ void main() { ], ); - when(() => api.polls.partialUpdatePoll(pollId, set: set, unset: unset)) - .thenAnswer((_) async => UpdatePollResponse()..poll = poll); + when( + () => api.polls.partialUpdatePoll(pollId, set: set, unset: unset), + ).thenAnswer((_) async => UpdatePollResponse()..poll = poll); - final res = - await client.partialUpdatePoll(pollId, set: set, unset: unset); + final res = await client.partialUpdatePoll(pollId, set: set, unset: unset); expect(res, isNotNull); expect(res.poll.id, pollId); expect(res.poll.name, set['name']); - verify(() => api.polls.partialUpdatePoll(pollId, set: set, unset: unset)) - .called(1); + verify(() => api.polls.partialUpdatePoll(pollId, set: set, unset: unset)).called(1); verifyNoMoreInteractions(api.polls); }); test('`.deletePoll`', () async { const pollId = 'test-poll-id'; - when(() => api.polls.deletePoll(pollId)) - .thenAnswer((_) async => EmptyResponse()); + when(() => api.polls.deletePoll(pollId)).thenAnswer((_) async => EmptyResponse()); final res = await client.deletePoll(pollId); expect(res, isNotNull); @@ -2236,15 +2354,14 @@ void main() { test('`.closePoll`', () async { const pollId = 'test-poll-id'; - when(() => api.polls.partialUpdatePoll(pollId, set: {'is_closed': true})) - .thenAnswer((_) async => UpdatePollResponse()); + when( + () => api.polls.partialUpdatePoll(pollId, set: {'is_closed': true}), + ).thenAnswer((_) async => UpdatePollResponse()); final res = await client.closePoll(pollId); expect(res, isNotNull); - verify(() => - api.polls.partialUpdatePoll(pollId, set: {'is_closed': true})) - .called(1); + verify(() => api.polls.partialUpdatePoll(pollId, set: {'is_closed': true})).called(1); verifyNoMoreInteractions(api.polls); }); @@ -2252,8 +2369,9 @@ void main() { const pollId = 'test-poll-id'; const option = PollOption(text: 'Red'); - when(() => api.polls.createPollOption(pollId, option)).thenAnswer( - (_) async => CreatePollOptionResponse()..pollOption = option); + when( + () => api.polls.createPollOption(pollId, option), + ).thenAnswer((_) async => CreatePollOptionResponse()..pollOption = option); final res = await client.createPollOption(pollId, option); expect(res, isNotNull); @@ -2268,8 +2386,9 @@ void main() { const optionId = 'test-option-id'; const option = PollOption(id: optionId, text: 'Red'); - when(() => api.polls.getPollOption(pollId, optionId)).thenAnswer( - (_) async => GetPollOptionResponse()..pollOption = option); + when( + () => api.polls.getPollOption(pollId, optionId), + ).thenAnswer((_) async => GetPollOptionResponse()..pollOption = option); final res = await client.getPollOption(pollId, optionId); expect(res, isNotNull); @@ -2283,8 +2402,9 @@ void main() { const pollId = 'test-poll-id'; const option = PollOption(id: 'test-option-id', text: 'Red'); - when(() => api.polls.updatePollOption(pollId, option)).thenAnswer( - (_) async => UpdatePollOptionResponse()..pollOption = option); + when( + () => api.polls.updatePollOption(pollId, option), + ).thenAnswer((_) async => UpdatePollOptionResponse()..pollOption = option); final res = await client.updatePollOption(pollId, option); expect(res, isNotNull); @@ -2298,8 +2418,7 @@ void main() { const pollId = 'test-poll-id'; const optionId = 'test-option-id'; - when(() => api.polls.deletePollOption(pollId, optionId)) - .thenAnswer((_) async => EmptyResponse()); + when(() => api.polls.deletePollOption(pollId, optionId)).thenAnswer((_) async => EmptyResponse()); final res = await client.deletePollOption(pollId, optionId); expect(res, isNotNull); @@ -2316,21 +2435,19 @@ void main() { // Custom matcher to check if the Vote object has the specified id Matcher matchesVoteOption(String expected) => predicate( - (vote) => vote.optionId == expected, - 'Vote with option $expected', - ); + (vote) => vote.optionId == expected, + 'Vote with option $expected', + ); - when(() => api.polls.castPollVote( - messageId, pollId, any(that: matchesVoteOption(optionId)))) - .thenAnswer((_) async => CastPollVoteResponse()..vote = vote); + when( + () => api.polls.castPollVote(messageId, pollId, any(that: matchesVoteOption(optionId))), + ).thenAnswer((_) async => CastPollVoteResponse()..vote = vote); - final res = - await client.castPollVote(messageId, pollId, optionId: optionId); + final res = await client.castPollVote(messageId, pollId, optionId: optionId); expect(res, isNotNull); expect(res.vote, vote); - verify(() => api.polls.castPollVote( - messageId, pollId, any(that: matchesVoteOption(optionId)))).called(1); + verify(() => api.polls.castPollVote(messageId, pollId, any(that: matchesVoteOption(optionId)))).called(1); verifyNoMoreInteractions(api.polls); }); @@ -2342,22 +2459,19 @@ void main() { // Custom matcher to check if the Vote object has the specified id Matcher matchesVoteAnswer(String expected) => predicate( - (vote) => vote.answerText == expected, - 'Vote with answer $expected', - ); + (vote) => vote.answerText == expected, + 'Vote with answer $expected', + ); - when(() => api.polls.castPollVote( - messageId, pollId, any(that: matchesVoteAnswer(answerText)))) - .thenAnswer((_) async => CastPollVoteResponse()..vote = vote); + when( + () => api.polls.castPollVote(messageId, pollId, any(that: matchesVoteAnswer(answerText))), + ).thenAnswer((_) async => CastPollVoteResponse()..vote = vote); - final res = - await client.addPollAnswer(messageId, pollId, answerText: answerText); + final res = await client.addPollAnswer(messageId, pollId, answerText: answerText); expect(res, isNotNull); expect(res.vote, vote); - verify(() => api.polls.castPollVote( - messageId, pollId, any(that: matchesVoteAnswer(answerText)))) - .called(1); + verify(() => api.polls.castPollVote(messageId, pollId, any(that: matchesVoteAnswer(answerText)))).called(1); verifyNoMoreInteractions(api.polls); }); @@ -2366,14 +2480,12 @@ void main() { const pollId = 'test-poll-id'; const voteId = 'test-vote-id'; - when(() => api.polls.removePollVote(messageId, pollId, voteId)) - .thenAnswer((_) async => RemovePollVoteResponse()); + when(() => api.polls.removePollVote(messageId, pollId, voteId)).thenAnswer((_) async => RemovePollVoteResponse()); final res = await client.removePollVote(messageId, pollId, voteId); expect(res, isNotNull); - verify(() => api.polls.removePollVote(messageId, pollId, voteId)) - .called(1); + verify(() => api.polls.removePollVote(messageId, pollId, voteId)).called(1); verifyNoMoreInteractions(api.polls); }); @@ -2394,11 +2506,13 @@ void main() { ), ); - when(() => api.polls.queryPolls( - filter: filter, - sort: sort, - pagination: pagination, - )).thenAnswer( + when( + () => api.polls.queryPolls( + filter: filter, + sort: sort, + pagination: pagination, + ), + ).thenAnswer( (_) async => QueryPollsResponse()..polls = polls, ); @@ -2410,11 +2524,13 @@ void main() { expect(res, isNotNull); expect(res.polls.length, polls.length); - verify(() => api.polls.queryPolls( - filter: filter, - sort: sort, - pagination: pagination, - )).called(1); + verify( + () => api.polls.queryPolls( + filter: filter, + sort: sort, + pagination: pagination, + ), + ).called(1); verifyNoMoreInteractions(api.polls); }); @@ -2429,12 +2545,14 @@ void main() { (index) => PollVote(id: 'test-vote-id-$index', answerText: 'Red'), ); - when(() => api.polls.queryPollVotes( - pollId, - filter: filter, - sort: sort, - pagination: pagination, - )).thenAnswer( + when( + () => api.polls.queryPollVotes( + pollId, + filter: filter, + sort: sort, + pagination: pagination, + ), + ).thenAnswer( (_) async => QueryPollVotesResponse()..votes = votes, ); @@ -2447,12 +2565,14 @@ void main() { expect(res, isNotNull); expect(res.votes.length, votes.length); - verify(() => api.polls.queryPollVotes( - pollId, - filter: filter, - sort: sort, - pagination: pagination, - )).called(1); + verify( + () => api.polls.queryPollVotes( + pollId, + filter: filter, + sort: sort, + pagination: pagination, + ), + ).called(1); verifyNoMoreInteractions(api.polls); }); @@ -2462,8 +2582,7 @@ void main() { extraData: const {'name': 'test-user'}, ); - when(() => api.user.updateUsers([user])).thenAnswer( - (_) async => UpdateUsersResponse()..users = {user.id: user}); + when(() => api.user.updateUsers([user])).thenAnswer((_) async => UpdateUsersResponse()..users = {user.id: user}); final res = await client.updateUser(user); @@ -2491,8 +2610,7 @@ void main() { extraData: {'color': set['color']}, ); - when(() => api.user.partialUpdateUsers([partialUpdateRequest])) - .thenAnswer( + when(() => api.user.partialUpdateUsers([partialUpdateRequest])).thenAnswer( (_) async => UpdateUsersResponse() ..users = { updatedUser.id: updatedUser, @@ -2517,8 +2635,9 @@ void main() { test('`.banUser`', () async { const userId = 'test-user-id'; - when(() => api.moderation.banUser(userId, options: any(named: 'options'))) - .thenAnswer((_) async => EmptyResponse()); + when( + () => api.moderation.banUser(userId, options: any(named: 'options')), + ).thenAnswer((_) async => EmptyResponse()); final res = await client.banUser(userId); @@ -2533,9 +2652,9 @@ void main() { test('`.unbanUser`', () async { const userId = 'test-user-id'; - when(() => - api.moderation.unbanUser(userId, options: any(named: 'options'))) - .thenAnswer((_) async => EmptyResponse()); + when( + () => api.moderation.unbanUser(userId, options: any(named: 'options')), + ).thenAnswer((_) async => EmptyResponse()); final res = await client.unbanUser(userId); @@ -2571,8 +2690,7 @@ void main() { test('`.unblockUser`', () async { const userId = 'test-user-id'; - when(() => api.user.unblockUser(userId)) - .thenAnswer((_) async => EmptyResponse()); + when(() => api.user.unblockUser(userId)).thenAnswer((_) async => EmptyResponse()); final res = await client.unblockUser(userId); @@ -2712,10 +2830,8 @@ void main() { await client.unblockUser(nonBlockedUserId); // Verify - should remain unchanged - expect(client.state.currentUser?.blockedUserIds, - contains(otherBlockedId)); - expect(client.state.currentUser?.blockedUserIds, - isNot(contains(nonBlockedUserId))); + expect(client.state.currentUser?.blockedUserIds, contains(otherBlockedId)); + expect(client.state.currentUser?.blockedUserIds, isNot(contains(nonBlockedUserId))); verify(() => api.user.unblockUser(nonBlockedUserId)).called(1); verifyNoMoreInteractions(api.user); }, @@ -2864,8 +2980,7 @@ void main() { test('`.shadowBan`', () async { const userId = 'test-user-id'; - when(() => api.moderation.banUser(userId, options: {'shadow': true})) - .thenAnswer((_) async => EmptyResponse()); + when(() => api.moderation.banUser(userId, options: {'shadow': true})).thenAnswer((_) async => EmptyResponse()); final res = await client.shadowBan(userId); @@ -2880,8 +2995,7 @@ void main() { test('`.removeShadowBan`', () async { const userId = 'test-user-id'; - when(() => api.moderation.unbanUser(userId, options: {'shadow': true})) - .thenAnswer((_) async => EmptyResponse()); + when(() => api.moderation.unbanUser(userId, options: {'shadow': true})).thenAnswer((_) async => EmptyResponse()); final res = await client.removeShadowBan(userId); @@ -2896,8 +3010,7 @@ void main() { test('`.muteUser`', () async { const userId = 'test-user-id'; - when(() => api.moderation.muteUser(userId)) - .thenAnswer((_) async => EmptyResponse()); + when(() => api.moderation.muteUser(userId)).thenAnswer((_) async => EmptyResponse()); final res = await client.muteUser(userId); @@ -2910,8 +3023,7 @@ void main() { test('`.unmuteUser`', () async { const userId = 'test-user-id'; - when(() => api.moderation.unmuteUser(userId)) - .thenAnswer((_) async => EmptyResponse()); + when(() => api.moderation.unmuteUser(userId)).thenAnswer((_) async => EmptyResponse()); final res = await client.unmuteUser(userId); @@ -2924,8 +3036,7 @@ void main() { test('`.flagMessage`', () async { const messageId = 'test-message-id'; - when(() => api.moderation.flagMessage(messageId)) - .thenAnswer((_) async => EmptyResponse()); + when(() => api.moderation.flagMessage(messageId)).thenAnswer((_) async => EmptyResponse()); final res = await client.flagMessage(messageId); @@ -2938,8 +3049,7 @@ void main() { test('`.unflagMessage`', () async { const messageId = 'test-message-id'; - when(() => api.moderation.unflagMessage(messageId)) - .thenAnswer((_) async => EmptyResponse()); + when(() => api.moderation.unflagMessage(messageId)).thenAnswer((_) async => EmptyResponse()); final res = await client.unflagMessage(messageId); @@ -2952,8 +3062,7 @@ void main() { test('`.flagUser`', () async { const userId = 'test-message-id'; - when(() => api.moderation.flagUser(userId)) - .thenAnswer((_) async => EmptyResponse()); + when(() => api.moderation.flagUser(userId)).thenAnswer((_) async => EmptyResponse()); final res = await client.flagUser(userId); @@ -2966,8 +3075,7 @@ void main() { test('`.unflagUser`', () async { const userId = 'test-message-id'; - when(() => api.moderation.unflagUser(userId)) - .thenAnswer((_) async => EmptyResponse()); + when(() => api.moderation.unflagUser(userId)).thenAnswer((_) async => EmptyResponse()); final res = await client.unflagUser(userId); @@ -2977,174 +3085,455 @@ void main() { verifyNoMoreInteractions(api.moderation); }); - test('`.markAllRead`', () async { - when(() => api.channel.markAllRead()) - .thenAnswer((_) async => EmptyResponse()); - - final res = await client.markAllRead(); - expect(res, isNotNull); - - verify(() => api.channel.markAllRead()).called(1); - verifyNoMoreInteractions(api.channel); - }); - - test('`.markChannelsDelivered`', () async { - final deliveries = [ - const MessageDelivery( - channelCid: 'messaging:test-channel-1', - messageId: 'test-message-id-1', + test('`.getActiveLiveLocations`', () async { + final locations = [ + Location( + latitude: 40.7128, + longitude: -74.0060, + createdByDeviceId: 'device-1', + endAt: DateTime.now().add(const Duration(hours: 1)), ), - const MessageDelivery( - channelCid: 'messaging:test-channel-2', - messageId: 'test-message-id-2', + Location( + latitude: 34.0522, + longitude: -118.2437, + createdByDeviceId: 'device-2', + endAt: DateTime.now().add(const Duration(hours: 2)), ), ]; - when(() => api.channel.markChannelsDelivered(deliveries)) - .thenAnswer((_) async => EmptyResponse()); + when(() => api.user.getActiveLiveLocations()).thenAnswer( + (_) async => + GetActiveLiveLocationsResponse() // + ..activeLiveLocations = locations, + ); + + // Initial state should be empty + expect(client.state.activeLiveLocations, isEmpty); + + final res = await client.getActiveLiveLocations(); - final res = await client.markChannelsDelivered(deliveries); expect(res, isNotNull); + expect(res.activeLiveLocations, hasLength(2)); + expect(res.activeLiveLocations, equals(locations)); + expect(client.state.activeLiveLocations, equals(locations)); - verify(() => api.channel.markChannelsDelivered(deliveries)).called(1); - verifyNoMoreInteractions(api.channel); + verify(() => api.user.getActiveLiveLocations()).called(1); + verifyNoMoreInteractions(api.user); }); - test('`.sendEvent`', () async { - const channelType = 'test-channel-type'; - const channelId = 'test-channel-id'; - final event = Event(type: EventType.any); + test('`.updateLiveLocation`', () async { + const messageId = 'test-message-id'; + const createdByDeviceId = 'test-device-id'; + final endAt = DateTime.timestamp().add(const Duration(hours: 1)); + const location = LocationCoordinates( + latitude: 40.7128, + longitude: -74.0060, + ); + + final expectedLocation = Location( + latitude: location.latitude, + longitude: location.longitude, + createdByDeviceId: createdByDeviceId, + endAt: endAt, + ); when( - () => api.channel.sendEvent( - channelId, - channelType, - any(that: isSameEventAs(event)), + () => api.user.updateLiveLocation( + messageId: messageId, + createdByDeviceId: createdByDeviceId, + location: location, + endAt: endAt, ), - ).thenAnswer((_) async => EmptyResponse()); + ).thenAnswer((_) async => expectedLocation); + + final res = await client.updateLiveLocation( + messageId: messageId, + createdByDeviceId: createdByDeviceId, + location: location, + endAt: endAt, + ); - final res = await client.sendEvent(channelId, channelType, event); expect(res, isNotNull); + expect(res, equals(expectedLocation)); - verify(() => api.channel.sendEvent( - channelId, - channelType, - any(that: isSameEventAs(event)), - )).called(1); - verifyNoMoreInteractions(api.channel); + verify( + () => api.user.updateLiveLocation( + messageId: messageId, + createdByDeviceId: createdByDeviceId, + location: location, + endAt: endAt, + ), + ).called(1); + verifyNoMoreInteractions(api.user); }); - group('`.sendReaction`', () { - test('`.sendReaction with default params`', () async { - const messageId = 'test-message-id'; - const reactionType = 'like'; - const extraData = {'score': 1}; + test('`.stopLiveLocation`', () async { + const messageId = 'test-message-id'; + const createdByDeviceId = 'test-device-id'; - when(() => api.message.sendReaction( - messageId, - reactionType, - extraData: extraData, - )).thenAnswer((_) async => SendReactionResponse() - ..message = Message(id: messageId) - ..reaction = Reaction(type: reactionType, messageId: messageId)); + final expectedLocation = Location( + latitude: 40.7128, + longitude: -74.0060, + createdByDeviceId: createdByDeviceId, + endAt: DateTime.now(), // Should be expired + ); - final res = await client.sendReaction(messageId, reactionType); - expect(res, isNotNull); - expect(res.message.id, messageId); - expect(res.reaction.type, reactionType); - expect(res.reaction.messageId, messageId); + when( + () => api.user.updateLiveLocation( + messageId: messageId, + createdByDeviceId: createdByDeviceId, + endAt: any(named: 'endAt'), + ), + ).thenAnswer((_) async => expectedLocation); - verify(() => api.message.sendReaction( - messageId, - reactionType, - extraData: extraData, - )).called(1); - verifyNoMoreInteractions(api.message); + final res = await client.stopLiveLocation( + messageId: messageId, + createdByDeviceId: createdByDeviceId, + ); + + expect(res, isNotNull); + expect(res, equals(expectedLocation)); + + verify( + () => api.user.updateLiveLocation( + messageId: messageId, + createdByDeviceId: createdByDeviceId, + endAt: any(named: 'endAt'), + ), + ).called(1); + verifyNoMoreInteractions(api.user); + }); + + group('Live Location Event Handling', () { + test('should handle location.shared event', () async { + final location = Location( + channelCid: 'test-channel:123', + messageId: 'message-123', + userId: userId, + latitude: 40.7128, + longitude: -74.0060, + createdByDeviceId: 'device-1', + endAt: DateTime.now().add(const Duration(hours: 1)), + ); + + final event = Event( + type: EventType.locationShared, + cid: 'test-channel:123', + message: Message( + id: 'message-123', + sharedLocation: location, + ), + ); + + // Initially empty + expect(client.state.activeLiveLocations, isEmpty); + + // Trigger the event + client.handleEvent(event); + + // Wait for the event to get processed + await Future.delayed(Duration.zero); + + // Should add location to active live locations + final activeLiveLocations = client.state.activeLiveLocations; + expect(activeLiveLocations, hasLength(1)); + expect(activeLiveLocations.first.messageId, equals('message-123')); }); - test('`.sendReaction with score`', () async { - const messageId = 'test-message-id'; - const reactionType = 'like'; - const score = 3; - const extraData = {'score': score}; + test('should handle location.updated event', () async { + final initialLocation = Location( + channelCid: 'test-channel:123', + messageId: 'message-123', + userId: userId, + latitude: 40.7128, + longitude: -74.0060, + createdByDeviceId: 'device-1', + endAt: DateTime.now().add(const Duration(hours: 1)), + ); - when(() => api.message.sendReaction( - messageId, - reactionType, - extraData: extraData, - )).thenAnswer((_) async => SendReactionResponse() - ..message = Message(id: messageId) - ..reaction = Reaction( - type: reactionType, - messageId: messageId, - score: score, - )); + // Set initial location + client.state.activeLiveLocations = [initialLocation]; + + final updatedLocation = Location( + channelCid: 'test-channel:123', + messageId: 'message-123', + userId: userId, + latitude: 40.7500, // Updated latitude + longitude: -74.1000, // Updated longitude + createdByDeviceId: 'device-1', + endAt: DateTime.now().add(const Duration(hours: 1)), + ); - final res = await client.sendReaction( - messageId, - reactionType, - score: score, + final event = Event( + type: EventType.locationUpdated, + cid: 'test-channel:123', + message: Message( + id: 'message-123', + sharedLocation: updatedLocation, + ), ); - expect(res, isNotNull); - expect(res.message.id, messageId); - expect(res.reaction.type, reactionType); - expect(res.reaction.messageId, messageId); - expect(res.reaction.score, score); - verify(() => api.message.sendReaction( - messageId, - reactionType, - extraData: extraData, - )).called(1); - verifyNoMoreInteractions(api.message); + // Trigger the event + client.handleEvent(event); + + // Wait for the event to get processed + await Future.delayed(Duration.zero); + + // Should update the location + final activeLiveLocations = client.state.activeLiveLocations; + expect(activeLiveLocations, hasLength(1)); + expect(activeLiveLocations.first.latitude, equals(40.7500)); + expect(activeLiveLocations.first.longitude, equals(-74.1000)); }); - test('`.sendReaction with score passed in extradata also`', () async { - const messageId = 'test-message-id'; - const reactionType = 'like'; - const score = 3; - const extraDataScore = 5; - const extraData = {'score': extraDataScore}; + test('should handle location.expired event', () async { + final location = Location( + channelCid: 'test-channel:123', + messageId: 'message-123', + userId: userId, + latitude: 40.7128, + longitude: -74.0060, + createdByDeviceId: 'device-1', + endAt: DateTime.now().add(const Duration(hours: 1)), + ); - when(() => api.message.sendReaction( - messageId, - reactionType, - extraData: extraData, - )).thenAnswer((_) async => SendReactionResponse() - ..message = Message(id: messageId) - ..reaction = Reaction( - type: reactionType, - messageId: messageId, - score: extraDataScore, - )); + // Set initial location + client.state.activeLiveLocations = [location]; + expect(client.state.activeLiveLocations, hasLength(1)); - final res = await client.sendReaction( - messageId, - reactionType, - score: score, - extraData: extraData, + final expiredLocation = location.copyWith( + endAt: DateTime.now().subtract(const Duration(hours: 1)), ); - expect(res, isNotNull); - expect(res.message.id, messageId); - expect(res.reaction.type, reactionType); - expect(res.reaction.messageId, messageId); - expect(res.reaction.score, extraDataScore); - verify(() => api.message.sendReaction( - messageId, - reactionType, - extraData: extraData, - )).called(1); - verifyNoMoreInteractions(api.message); + final event = Event( + type: EventType.locationExpired, + cid: 'test-channel:123', + message: Message( + id: 'message-123', + sharedLocation: expiredLocation, + ), + ); + + // Trigger the event + client.handleEvent(event); + + // Wait for the event to get processed + await Future.delayed(Duration.zero); + + // Should remove the location + expect(client.state.activeLiveLocations, isEmpty); + }); + + test('should ignore location events for other users', () async { + final location = Location( + channelCid: 'test-channel:123', + messageId: 'message-123', + userId: 'other-user', // Different user + latitude: 40.7128, + longitude: -74.0060, + createdByDeviceId: 'device-1', + endAt: DateTime.now().add(const Duration(hours: 1)), + ); + + final event = Event( + type: EventType.locationShared, + cid: 'test-channel:123', + message: Message( + id: 'message-123', + sharedLocation: location, + ), + ); + + // Trigger the event + client.handleEvent(event); + + // Wait for the event to get processed + await Future.delayed(Duration.zero); + + // Should not add location from other user + expect(client.state.activeLiveLocations, isEmpty); + }); + + test('should ignore static location events', () async { + final staticLocation = Location( + channelCid: 'test-channel:123', + messageId: 'message-123', + userId: userId, + latitude: 40.7128, + longitude: -74.0060, + createdByDeviceId: 'device-1', + // No endAt means it's static + ); + + final event = Event( + type: EventType.locationShared, + cid: 'test-channel:123', + message: Message( + id: 'message-123', + sharedLocation: staticLocation, + ), + ); + + // Trigger the event + client.handleEvent(event); + + // Wait for the event to get processed + await Future.delayed(Duration.zero); + + // Should not add static location + expect(client.state.activeLiveLocations, isEmpty); }); + + test('should merge locations with same key', () async { + final location1 = Location( + channelCid: 'test-channel:123', + messageId: 'message-123', + userId: userId, + latitude: 40.7128, + longitude: -74.0060, + createdByDeviceId: 'device-1', + endAt: DateTime.now().add(const Duration(hours: 1)), + ); + + final location2 = Location( + channelCid: 'test-channel:123', + messageId: 'message-456', + userId: userId, + latitude: 40.7500, + longitude: -74.1000, + createdByDeviceId: 'device-1', // Same device, should merge + endAt: DateTime.now().add(const Duration(hours: 1)), + ); + + final event1 = Event( + type: EventType.locationShared, + cid: 'test-channel:123', + message: Message( + id: 'message-123', + sharedLocation: location1, + ), + ); + + final event2 = Event( + type: EventType.locationShared, + cid: 'test-channel:123', + message: Message( + id: 'message-456', + sharedLocation: location2, + ), + ); + + // Trigger first event + client.handleEvent(event1); + await Future.delayed(Duration.zero); + + final activeLiveLocations = client.state.activeLiveLocations; + expect(activeLiveLocations, hasLength(1)); + expect(activeLiveLocations.first.messageId, equals('message-123')); + + // Trigger second event - should merge/update + client.handleEvent(event2); + await Future.delayed(Duration.zero); + + final activeLiveLocations2 = client.state.activeLiveLocations; + expect(activeLiveLocations2, hasLength(1)); + expect(activeLiveLocations2.first.messageId, equals('message-456')); + }); + }); + + test('`.markAllRead`', () async { + when(() => api.channel.markAllRead()).thenAnswer((_) async => EmptyResponse()); + + final res = await client.markAllRead(); + expect(res, isNotNull); + + verify(() => api.channel.markAllRead()).called(1); + verifyNoMoreInteractions(api.channel); + }); + + test('`.markChannelsDelivered`', () async { + final deliveries = [ + const MessageDelivery( + channelCid: 'messaging:test-channel-1', + messageId: 'test-message-id-1', + ), + const MessageDelivery( + channelCid: 'messaging:test-channel-2', + messageId: 'test-message-id-2', + ), + ]; + + when(() => api.channel.markChannelsDelivered(deliveries)).thenAnswer((_) async => EmptyResponse()); + + final res = await client.markChannelsDelivered(deliveries); + expect(res, isNotNull); + + verify(() => api.channel.markChannelsDelivered(deliveries)).called(1); + verifyNoMoreInteractions(api.channel); + }); + + test('`.sendEvent`', () async { + const channelType = 'test-channel-type'; + const channelId = 'test-channel-id'; + final event = Event(type: EventType.any); + + when( + () => api.channel.sendEvent( + channelId, + channelType, + any(that: isSameEventAs(event)), + ), + ).thenAnswer((_) async => EmptyResponse()); + + final res = await client.sendEvent(channelId, channelType, event); + expect(res, isNotNull); + + verify( + () => api.channel.sendEvent( + channelId, + channelType, + any(that: isSameEventAs(event)), + ), + ).called(1); + verifyNoMoreInteractions(api.channel); + }); + + test('`.sendReaction`', () async { + const messageId = 'test-message-id'; + const reactionType = 'like'; + const emojiCode = '👍'; + const score = 4; + + final reaction = Reaction( + type: reactionType, + messageId: messageId, + emojiCode: emojiCode, + score: score, + ); + + when(() => api.message.sendReaction(messageId, reaction)).thenAnswer( + (_) async => SendReactionResponse() + ..message = Message(id: messageId) + ..reaction = reaction, + ); + + final res = await client.sendReaction(messageId, reaction); + expect(res, isNotNull); + expect(res.message.id, messageId); + expect(res.reaction.type, reactionType); + expect(res.reaction.emojiCode, emojiCode); + expect(res.reaction.score, score); + expect(res.reaction.messageId, messageId); + + verify(() => api.message.sendReaction(messageId, reaction)).called(1); + verifyNoMoreInteractions(api.message); }); test('`.deleteReaction`', () async { const messageId = 'test-message-id'; const reactionType = 'like'; - when(() => api.message.deleteReaction(messageId, reactionType)) - .thenAnswer((_) async => EmptyResponse()); + when(() => api.message.deleteReaction(messageId, reactionType)).thenAnswer((_) async => EmptyResponse()); final res = await client.deleteReaction(messageId, reactionType); expect(res, isNotNull); @@ -3160,19 +3549,21 @@ void main() { const channelId = 'test-channel-id'; const channelType = 'test-channel-type'; - when(() => api.message.sendMessage( - channelId, channelType, any(that: isSameMessageAs(message)))) - .thenAnswer((_) async => SendMessageResponse()..message = message); + when( + () => api.message.sendMessage(channelId, channelType, any(that: isSameMessageAs(message))), + ).thenAnswer((_) async => SendMessageResponse()..message = message); final res = await client.sendMessage(message, channelId, channelType); expect(res, isNotNull); expect(res.message, isSameMessageAs(message)); - verify(() => api.message.sendMessage( - channelId, - channelType, - any(that: isSameMessageAs(message)), - )).called(1); + verify( + () => api.message.sendMessage( + channelId, + channelType, + any(that: isSameMessageAs(message)), + ), + ).called(1); verifyNoMoreInteractions(api.message); }); @@ -3205,11 +3596,13 @@ void main() { expect(res, isNotNull); expect(res.draft.message, isSameDraftMessageAs(message)); - verify(() => api.message.createDraft( - channelId, - channelType, - any(that: isSameDraftMessageAs(message)), - )).called(1); + verify( + () => api.message.createDraft( + channelId, + channelType, + any(that: isSameDraftMessageAs(message)), + ), + ).called(1); verifyNoMoreInteractions(api.message); }); @@ -3218,8 +3611,7 @@ void main() { const channelId = 'test-channel-id'; const channelType = 'test-channel-type'; - when(() => api.message.deleteDraft(channelId, channelType)) - .thenAnswer((_) async => EmptyResponse()); + when(() => api.message.deleteDraft(channelId, channelType)).thenAnswer((_) async => EmptyResponse()); final res = await client.deleteDraft(channelId, channelType); expect(res, isNotNull); @@ -3234,13 +3626,14 @@ void main() { final message = DraftMessage(id: 'test-message-id', text: 'Hello!'); - when(() => api.message.getDraft(channelId, channelType)) - .thenAnswer((_) async => GetDraftResponse() - ..draft = Draft( - channelCid: '$channelType:$channelId', - createdAt: DateTime.now(), - message: message, - )); + when(() => api.message.getDraft(channelId, channelType)).thenAnswer( + (_) async => GetDraftResponse() + ..draft = Draft( + channelCid: '$channelType:$channelId', + createdAt: DateTime.now(), + message: message, + ), + ); final res = await client.getDraft(channelId, channelType); @@ -3260,11 +3653,10 @@ void main() { channelCid: '$channelType:$channelId', createdAt: DateTime.now(), message: DraftMessage(id: 'test-message-id', text: 'Hello!'), - ) + ), ]; - when(() => api.message.queryDrafts()) - .thenAnswer((_) async => QueryDraftsResponse()..drafts = drafts); + when(() => api.message.queryDrafts()).thenAnswer((_) async => QueryDraftsResponse()..drafts = drafts); final res = await client.queryDrafts(); @@ -3283,8 +3675,7 @@ void main() { (index) => Message(id: 'test-message-id-$index'), ); - when(() => api.message.getReplies(parentId)) - .thenAnswer((_) async => QueryRepliesResponse()..messages = messages); + when(() => api.message.getReplies(parentId)).thenAnswer((_) async => QueryRepliesResponse()..messages = messages); final res = await client.getReplies(parentId); expect(res, isNotNull); @@ -3305,8 +3696,9 @@ void main() { ), ); - when(() => api.message.getReactions(messageId)).thenAnswer( - (_) async => QueryReactionsResponse()..reactions = reactions); + when( + () => api.message.getReactions(messageId), + ).thenAnswer((_) async => QueryReactionsResponse()..reactions = reactions); final res = await client.getReactions(messageId); expect(res, isNotNull); @@ -3317,11 +3709,40 @@ void main() { verifyNoMoreInteractions(api.message); }); + test('`.queryReactions`', () async { + const messageId = 'test-message-id'; + + final reactions = List.generate( + 3, + (index) => Reaction( + type: 'test-reactions-type-$index', + messageId: messageId, + ), + ); + + when( + () => api.message.queryReactions(messageId), + ).thenAnswer( + (_) async => QueryReactionsResponse() + ..reactions = reactions + ..next = null, + ); + + final res = await client.queryReactions(messageId); + expect(res, isNotNull); + expect(res.reactions.length, reactions.length); + expect(res.reactions.every((it) => it.messageId == messageId), isTrue); + + verify(() => api.message.queryReactions(messageId)).called(1); + verifyNoMoreInteractions(api.message); + }); + test('`.updateMessage`', () async { final message = Message(id: 'test-message-id', text: 'Hello!'); - when(() => api.message.updateMessage(any(that: isSameMessageAs(message)))) - .thenAnswer((_) async => UpdateMessageResponse()..message = message); + when( + () => api.message.updateMessage(any(that: isSameMessageAs(message))), + ).thenAnswer((_) async => UpdateMessageResponse()..message = message); final res = await client.updateMessage(message); expect(res, isNotNull); @@ -3336,8 +3757,7 @@ void main() { test('`.deleteMessage`', () async { const messageId = 'test-message-id'; - when(() => api.message.deleteMessage(messageId, hard: false)) - .thenAnswer((_) async => EmptyResponse()); + when(() => api.message.deleteMessage(messageId, hard: false)).thenAnswer((_) async => EmptyResponse()); final res = await client.deleteMessage(messageId); expect(res, isNotNull); @@ -3346,12 +3766,23 @@ void main() { verifyNoMoreInteractions(api.message); }); + test('`.deleteMessageForMe`', () async { + const messageId = 'test-message-id'; + + when(() => api.message.deleteMessage(messageId, deleteForMe: true)).thenAnswer((_) async => EmptyResponse()); + + final res = await client.deleteMessageForMe(messageId); + expect(res, isNotNull); + + verify(() => api.message.deleteMessage(messageId, deleteForMe: true)).called(1); + verifyNoMoreInteractions(api.message); + }); + test('`.getMessage`', () async { const messageId = 'test-message-id'; final message = Message(id: messageId); - when(() => api.message.getMessage(messageId)) - .thenAnswer((_) async => GetMessageResponse()..message = message); + when(() => api.message.getMessage(messageId)).thenAnswer((_) async => GetMessageResponse()..message = message); final res = await client.getMessage(messageId); expect(res, isNotNull); @@ -3419,11 +3850,13 @@ void main() { final updateMessageResponse = UpdateMessageResponse() ..message = message.copyWith(text: set['text'], pinExpires: null); - when(() => api.message.partialUpdateMessage( - message.id, - set: set, - unset: unset, - )).thenAnswer((_) async => updateMessageResponse); + when( + () => api.message.partialUpdateMessage( + message.id, + set: set, + unset: unset, + ), + ).thenAnswer((_) async => updateMessageResponse); final res = await client.partialUpdateMessage( messageId, @@ -3437,30 +3870,35 @@ void main() { expect(res.message.text, set['text']); expect(res.message.pinExpires, isNull); - verify(() => api.message.partialUpdateMessage( - message.id, - set: set, - unset: unset, - )).called(1); + verify( + () => api.message.partialUpdateMessage( + message.id, + set: set, + unset: unset, + ), + ).called(1); verifyNoMoreInteractions(api.message); }); group('`.pinMessage`', () { - test('should work fine without passing timeoutOrExpirationDate', - () async { + test('should work fine without passing timeoutOrExpirationDate', () async { const messageId = 'test-message-id'; final message = Message(id: messageId); - when(() => api.message.partialUpdateMessage( - messageId, - set: any(named: 'set'), - unset: any(named: 'unset'), - )).thenAnswer((_) async => UpdateMessageResponse() - ..message = message.copyWith( - pinned: true, - pinExpires: null, - state: MessageState.sent, - )); + when( + () => api.message.partialUpdateMessage( + messageId, + set: any(named: 'set'), + unset: any(named: 'unset'), + ), + ).thenAnswer( + (_) async => UpdateMessageResponse() + ..message = message.copyWith( + pinned: true, + pinExpires: null, + state: MessageState.sent, + ), + ); final res = await client.pinMessage(messageId); @@ -3468,11 +3906,13 @@ void main() { expect(res.message.pinned, isTrue); expect(res.message.pinExpires, isNull); - verify(() => api.message.partialUpdateMessage( - messageId, - set: any(named: 'set'), - unset: any(named: 'unset'), - )).called(1); + verify( + () => api.message.partialUpdateMessage( + messageId, + set: any(named: 'set'), + unset: any(named: 'unset'), + ), + ).called(1); verifyNoMoreInteractions(api.message); }); @@ -3483,18 +3923,22 @@ void main() { final message = Message(id: messageId); const timeoutOrExpirationDate = 300; // 300 seconds - when(() => api.message.partialUpdateMessage( - message.id, - set: any(named: 'set'), - unset: any(named: 'unset'), - )).thenAnswer((_) async => UpdateMessageResponse() - ..message = message.copyWith( - pinned: true, - pinExpires: DateTime.now().add( - const Duration(seconds: timeoutOrExpirationDate), + when( + () => api.message.partialUpdateMessage( + message.id, + set: any(named: 'set'), + unset: any(named: 'unset'), + ), + ).thenAnswer( + (_) async => UpdateMessageResponse() + ..message = message.copyWith( + pinned: true, + pinExpires: DateTime.now().add( + const Duration(seconds: timeoutOrExpirationDate), + ), + state: MessageState.sent, ), - state: MessageState.sent, - )); + ); final res = await client.pinMessage( messageId, @@ -3505,11 +3949,13 @@ void main() { expect(res.message.pinned, isTrue); expect(res.message.pinExpires, isNotNull); - verify(() => api.message.partialUpdateMessage( - messageId, - set: any(named: 'set'), - unset: any(named: 'unset'), - )).called(1); + verify( + () => api.message.partialUpdateMessage( + messageId, + set: any(named: 'set'), + unset: any(named: 'unset'), + ), + ).called(1); verifyNoMoreInteractions(api.message); }, ); @@ -3519,19 +3965,22 @@ void main() { () async { const messageId = 'test-message-id'; final message = Message(id: messageId); - final timeoutOrExpirationDate = - DateTime.now().add(const Duration(days: 3)); // 3 days - - when(() => api.message.partialUpdateMessage( - messageId, - set: any(named: 'set'), - unset: any(named: 'unset'), - )).thenAnswer((_) async => UpdateMessageResponse() - ..message = message.copyWith( - pinned: true, - pinExpires: timeoutOrExpirationDate, - state: MessageState.sent, - )); + final timeoutOrExpirationDate = DateTime.now().add(const Duration(days: 3)); // 3 days + + when( + () => api.message.partialUpdateMessage( + messageId, + set: any(named: 'set'), + unset: any(named: 'unset'), + ), + ).thenAnswer( + (_) async => UpdateMessageResponse() + ..message = message.copyWith( + pinned: true, + pinExpires: timeoutOrExpirationDate, + state: MessageState.sent, + ), + ); final res = await client.pinMessage( messageId, @@ -3543,11 +3992,13 @@ void main() { expect(res.message.pinExpires, isNotNull); expect(res.message.pinExpires, timeoutOrExpirationDate.toUtc()); - verify(() => api.message.partialUpdateMessage( - messageId, - set: any(named: 'set'), - unset: any(named: 'unset'), - )).called(1); + verify( + () => api.message.partialUpdateMessage( + messageId, + set: any(named: 'set'), + unset: any(named: 'unset'), + ), + ).called(1); verifyNoMoreInteractions(api.message); }, ); @@ -3574,30 +4025,35 @@ void main() { const messageId = 'test-message-id'; final message = Message(id: messageId, pinned: true); - when(() => api.message.partialUpdateMessage( - messageId, - set: {'pinned': false}, - )).thenAnswer((_) async => UpdateMessageResponse() - ..message = message.copyWith( - pinned: false, - state: MessageState.sent, - )); + when( + () => api.message.partialUpdateMessage( + messageId, + set: {'pinned': false}, + ), + ).thenAnswer( + (_) async => UpdateMessageResponse() + ..message = message.copyWith( + pinned: false, + state: MessageState.sent, + ), + ); final res = await client.unpinMessage(messageId); expect(res, isNotNull); expect(res.message.pinned, isFalse); - verify(() => api.message.partialUpdateMessage( - messageId, - set: {'pinned': false}, - )).called(1); + verify( + () => api.message.partialUpdateMessage( + messageId, + set: {'pinned': false}, + ), + ).called(1); verifyNoMoreInteractions(api.message); }); test('`.enrichUrl`', () async { - const url = - 'https://www.techyourchance.com/finite-state-machine-with-unit-tests-real-world-example'; + const url = 'https://www.techyourchance.com/finite-state-machine-with-unit-tests-real-world-example'; when(() => api.general.enrichUrl(url)).thenAnswer( (_) async => OGAttachmentResponse() @@ -3828,4 +4284,351 @@ void main() { }); }); }); + + group('recoverStateOnReconnect', () { + const apiKey = 'test-api-key'; + final user = User(id: 'test-user-id'); + final token = Token.development(user.id).rawValue; + + late FakeChatApi api; + late FakeWebSocket ws; + late StreamChatClient client; + + setUpAll(() { + registerFallbackValue(const PaginationParams()); + registerFallbackValue(Filter.equal('cid', '')); + }); + + setUp(() { + api = FakeChatApi(); + ws = FakeWebSocket(); + + // Stub queryChannels for every test — it's the API the recovery path + // calls when enabled, and a missing stub would surface as an unhandled + // async error inside the connection-status listener. + when( + () => api.channel.queryChannels( + filter: any(named: 'filter'), + sort: any(named: 'sort'), + state: any(named: 'state'), + watch: any(named: 'watch'), + presence: any(named: 'presence'), + memberLimit: any(named: 'memberLimit'), + messageLimit: any(named: 'messageLimit'), + paginationParams: any(named: 'paginationParams'), + ), + ).thenAnswer((_) async => QueryChannelsResponse()..channels = []); + }); + + tearDown(() async { + await client.dispose(); + }); + + // Drives the FakeWebSocket through a connected → disconnected → connected + // transition so the client's pairwise listener fires the recovery path. + Future simulateReconnect() async { + ws.connectionStatus = ConnectionStatus.disconnected; + await delay(100); + ws.connectionStatus = ConnectionStatus.connected; + await delay(300); + } + + test('should re-query active channels on reconnect when enabled (default)', () async { + // Setup: connect with default flag, register two channels. + client = StreamChatClient(apiKey, chatApi: api, ws: ws); + await client.connectUser(user, token); + await delay(300); + + final channel1 = Channel.fromState(client, ChannelState(channel: ChannelModel(cid: 'messaging:c1'))); + final channel2 = Channel.fromState(client, ChannelState(channel: ChannelModel(cid: 'messaging:c2'))); + client.state.addChannels({'messaging:c1': channel1, 'messaging:c2': channel2}); + + // Drop interactions from the initial connect's (empty-channel) recovery + // so we only count the reconnect call. + clearInteractions(api.channel); + + await simulateReconnect(); + + verify( + () => api.channel.queryChannels( + filter: Filter.in_('cid', const ['messaging:c1', 'messaging:c2']), + sort: any(named: 'sort'), + state: any(named: 'state'), + watch: any(named: 'watch'), + presence: any(named: 'presence'), + memberLimit: any(named: 'memberLimit'), + messageLimit: any(named: 'messageLimit'), + paginationParams: const PaginationParams(limit: 30), + ), + ).called(1); + }); + + test('should skip the re-query on reconnect when disabled', () async { + client = StreamChatClient(apiKey, chatApi: api, ws: ws, recoverStateOnReconnect: false); + await client.connectUser(user, token); + await delay(300); + + final channel = Channel.fromState(client, ChannelState(channel: ChannelModel(cid: 'messaging:c1'))); + client.state.addChannels({'messaging:c1': channel}); + clearInteractions(api.channel); + + await simulateReconnect(); + + verifyNever( + () => api.channel.queryChannels( + filter: any(named: 'filter'), + sort: any(named: 'sort'), + state: any(named: 'state'), + watch: any(named: 'watch'), + presence: any(named: 'presence'), + memberLimit: any(named: 'memberLimit'), + messageLimit: any(named: 'messageLimit'), + paginationParams: any(named: 'paginationParams'), + ), + ); + }); + + test('should still emit `connectionRecovered` when disabled', () async { + client = StreamChatClient(apiKey, chatApi: api, ws: ws, recoverStateOnReconnect: false); + await client.connectUser(user, token); + await delay(300); + + final channel = Channel.fromState(client, ChannelState(channel: ChannelModel(cid: 'messaging:c1'))); + client.state.addChannels({'messaging:c1': channel}); + + // Subscribe AFTER the initial connect so the captured event is the + // one fired by the manual reconnect. + final recoveredEvents = []; + final sub = client.on(EventType.connectionRecovered).listen(recoveredEvents.add); + + await simulateReconnect(); + await sub.cancel(); + + expect(recoveredEvents, hasLength(1)); + }); + + test('should skip the re-query when no active channels are tracked', () async { + client = StreamChatClient(apiKey, chatApi: api, ws: ws); + await client.connectUser(user, token); + await delay(300); + + // No channels added — the cids.isNotEmpty guard should short-circuit. + clearInteractions(api.channel); + + await simulateReconnect(); + + verifyNever( + () => api.channel.queryChannels( + filter: any(named: 'filter'), + sort: any(named: 'sort'), + state: any(named: 'state'), + watch: any(named: 'watch'), + presence: any(named: 'presence'), + memberLimit: any(named: 'memberLimit'), + messageLimit: any(named: 'messageLimit'), + paginationParams: any(named: 'paginationParams'), + ), + ); + }); + + test('should respect runtime toggling via the setter', () async { + client = StreamChatClient(apiKey, chatApi: api, ws: ws); + await client.connectUser(user, token); + await delay(300); + + final channel = Channel.fromState(client, ChannelState(channel: ChannelModel(cid: 'messaging:c1'))); + client.state.addChannels({'messaging:c1': channel}); + clearInteractions(api.channel); + + // Disable mid-flight → no re-query on reconnect. + client.recoverStateOnReconnect = false; + await simulateReconnect(); + verifyNever( + () => api.channel.queryChannels( + filter: any(named: 'filter'), + sort: any(named: 'sort'), + state: any(named: 'state'), + watch: any(named: 'watch'), + presence: any(named: 'presence'), + memberLimit: any(named: 'memberLimit'), + messageLimit: any(named: 'messageLimit'), + paginationParams: any(named: 'paginationParams'), + ), + ); + + // Re-enable → re-query on the next reconnect. + client.recoverStateOnReconnect = true; + await simulateReconnect(); + verify( + () => api.channel.queryChannels( + filter: any(named: 'filter'), + sort: any(named: 'sort'), + state: any(named: 'state'), + watch: any(named: 'watch'), + presence: any(named: 'presence'), + memberLimit: any(named: 'memberLimit'), + messageLimit: any(named: 'messageLimit'), + paginationParams: any(named: 'paginationParams'), + ), + ).called(1); + }); + }); + + group('WS events', () { + late StreamChatClient client; + + setUp(() async { + final ws = FakeWebSocket(); + client = StreamChatClient('test-api-key', ws: ws); + + final user = User(id: 'test-user-id'); + final token = Token.development(user.id).rawValue; + + await client.connectUser(user, token); + await delay(300); + expect(client.wsConnectionStatus, ConnectionStatus.connected); + }); + + tearDown(() async { + await client.dispose(); + }); + + group('User messages deleted event', () { + test( + 'should broadcast global user.messages.deleted event to all channels', + () async { + // Add messages from the user to be deleted + final bannedUser = User(id: 'banned-user', name: 'Banned User'); + final message1 = Message( + id: 'msg-1', + text: 'Message in channel 1', + user: bannedUser, + ); + final message2 = Message( + id: 'msg-2', + text: 'Message in channel 2', + user: bannedUser, + ); + + // Setup: Create multiple channels with state + final channelState1 = ChannelState( + channel: ChannelModel(id: 'channel-1', type: 'messaging'), + messages: [message1], + ); + final channelState2 = ChannelState( + channel: ChannelModel(id: 'channel-2', type: 'messaging'), + messages: [message2], + ); + + final channel1 = Channel.fromState(client, channelState1); + final channel2 = Channel.fromState(client, channelState2); + + // Register channels in client state + client.state.addChannels({ + 'messaging:channel-1': channel1, + 'messaging:channel-2': channel2, + }); + + // Verify initial state + expect(channel1.state?.messages.length, equals(1)); + expect(channel2.state?.messages.length, equals(1)); + + // Simulate global user.messages.deleted event being broadcast to channels + // (In production, ClientState._listenUserMessagesDeleted does this) + final event = Event( + type: EventType.userMessagesDeleted, + user: bannedUser, + hardDelete: false, + ); + + client.handleEvent(event); + + // Wait for the events to be processed + await Future.delayed(Duration.zero); + + // Verify messages are soft deleted in all channels + final channel1Message = channel1.state?.messages.first; + expect(channel1Message?.type, equals(MessageType.deleted)); + expect(channel1Message?.state.isDeleted, isTrue); + + final channel2Message = channel2.state?.messages.first; + expect(channel2Message?.type, equals(MessageType.deleted)); + expect(channel2Message?.state.isDeleted, isTrue); + }, + ); + + test( + 'should broadcast global hard delete to all channels', + () async { + // Add messages from the user to be deleted + final bannedUser = User(id: 'banned-user', name: 'Banned User'); + final otherUser = User(id: 'other-user', name: 'Other User'); + + final message1 = Message( + id: 'msg-1', + text: 'Message in channel 1', + user: bannedUser, + ); + final message2 = Message( + id: 'msg-2', + text: 'Message in channel 2', + user: bannedUser, + ); + final message3 = Message( + id: 'msg-3', + text: 'Safe message', + user: otherUser, + ); + + // Setup: Create multiple channels with state + final channelState1 = ChannelState( + channel: ChannelModel(id: 'channel-1', type: 'messaging'), + messages: [message1, message3], + ); + final channelState2 = ChannelState( + channel: ChannelModel(id: 'channel-2', type: 'messaging'), + messages: [message2], + ); + + final channel1 = Channel.fromState(client, channelState1); + final channel2 = Channel.fromState(client, channelState2); + + // Register channels in client state + client.state.addChannels({ + 'messaging:channel-1': channel1, + 'messaging:channel-2': channel2, + }); + + // Verify initial state + expect(channel1.state?.messages.length, equals(2)); + expect(channel2.state?.messages.length, equals(1)); + + // Simulate global user.messages.deleted event being broadcast to channels + // (In production, ClientState._listenUserMessagesDeleted does this) + final event = Event( + type: EventType.userMessagesDeleted, + user: bannedUser, + hardDelete: true, + ); + + client.handleEvent(event); + + // Wait for the events to be processed + await Future.delayed(Duration.zero); + + // Verify banned user's messages are removed from all channels + expect(channel1.state?.messages.length, equals(1)); + expect( + channel1.state?.messages.any((m) => m.user?.id == 'banned-user'), + isFalse, + ); + expect(channel2.state?.messages.length, equals(0)); + + // Verify other user's message is unaffected + final safeMessage = channel1.state?.messages.firstWhere((m) => m.id == 'msg-3'); + expect(safeMessage?.user?.id, equals('other-user')); + }, + ); + }); + }); } diff --git a/packages/stream_chat/test/src/client/event_resolvers_test.dart b/packages/stream_chat/test/src/client/event_resolvers_test.dart new file mode 100644 index 0000000000..0aed5d35a7 --- /dev/null +++ b/packages/stream_chat/test/src/client/event_resolvers_test.dart @@ -0,0 +1,665 @@ +// ignore_for_file: avoid_redundant_argument_values, lines_longer_than_80_chars + +import 'package:stream_chat/src/client/event_resolvers.dart'; +import 'package:stream_chat/src/core/models/event.dart'; +import 'package:stream_chat/src/core/models/location.dart'; +import 'package:stream_chat/src/core/models/message.dart'; +import 'package:stream_chat/src/core/models/poll.dart'; +import 'package:stream_chat/src/core/models/poll_option.dart'; +import 'package:stream_chat/src/core/models/poll_vote.dart'; +import 'package:stream_chat/src/core/models/user.dart'; +import 'package:stream_chat/src/event_type.dart'; +import 'package:test/test.dart'; + +void main() { + group('Poll resolver events', () { + group('pollCreatedResolver', () { + test('should resolve messageNew event with poll to pollCreated', () { + final poll = Poll( + id: 'poll-123', + name: 'Test Poll', + options: const [ + PollOption(id: 'option-1', text: 'Option 1'), + PollOption(id: 'option-2', text: 'Option 2'), + ], + ); + + final event = Event( + type: EventType.messageNew, + poll: poll, + cid: 'channel-123', + ); + + final resolved = pollCreatedResolver(event); + + expect(resolved, isNotNull); + expect(resolved!.type, EventType.pollCreated); + expect(resolved.poll, equals(poll)); + expect(resolved.cid, equals('channel-123')); + }); + + test( + 'should resolve notificationMessageNew event with poll to pollCreated', + () { + final poll = Poll( + id: 'poll-123', + name: 'Test Poll', + options: const [ + PollOption(id: 'option-1', text: 'Option 1'), + ], + ); + + final event = Event( + type: EventType.notificationMessageNew, + poll: poll, + cid: 'channel-123', + ); + + final resolved = pollCreatedResolver(event); + + expect(resolved, isNotNull); + expect(resolved!.type, EventType.pollCreated); + expect(resolved.poll, equals(poll)); + }, + ); + + test('should return null for messageNew event without poll', () { + final event = Event( + type: EventType.messageNew, + cid: 'channel-123', + ); + + final resolved = pollCreatedResolver(event); + + expect(resolved, isNull); + }); + + test('should return null for invalid event types', () { + final poll = Poll( + id: 'poll-123', + name: 'Test Poll', + options: const [ + PollOption(id: 'option-1', text: 'Option 1'), + ], + ); + + final event = Event( + type: EventType.messageUpdated, + poll: poll, + cid: 'channel-123', + ); + + final resolved = pollCreatedResolver(event); + + expect(resolved, isNull); + }); + + test('should return null for event with null poll', () { + final event = Event( + type: EventType.messageNew, + poll: null, + cid: 'channel-123', + ); + + final resolved = pollCreatedResolver(event); + + expect(resolved, isNull); + }); + }); + + group('pollAnswerCastedResolver', () { + test( + 'should resolve pollVoteCasted event with answer to pollAnswerCasted', + () { + final pollVote = PollVote( + id: 'vote-123', + answerText: 'My answer', + pollId: 'poll-123', + ); + + final event = Event( + type: EventType.pollVoteCasted, + pollVote: pollVote, + cid: 'channel-123', + ); + + final resolved = pollAnswerCastedResolver(event); + + expect(resolved, isNotNull); + expect(resolved!.type, EventType.pollAnswerCasted); + expect(resolved.pollVote, equals(pollVote)); + expect(resolved.cid, equals('channel-123')); + }, + ); + + test( + 'should resolve pollVoteChanged event with answer to pollAnswerCasted', + () { + final pollVote = PollVote( + id: 'vote-123', + answerText: 'My updated answer', + pollId: 'poll-123', + ); + + final event = Event( + type: EventType.pollVoteChanged, + pollVote: pollVote, + cid: 'channel-123', + ); + + final resolved = pollAnswerCastedResolver(event); + + expect(resolved, isNotNull); + expect(resolved!.type, EventType.pollAnswerCasted); + expect(resolved.pollVote, equals(pollVote)); + }, + ); + + test('should return null for pollVoteCasted event with option vote', () { + final pollVote = PollVote( + id: 'vote-123', + optionId: 'option-1', + pollId: 'poll-123', + ); + + final event = Event( + type: EventType.pollVoteCasted, + pollVote: pollVote, + cid: 'channel-123', + ); + + final resolved = pollAnswerCastedResolver(event); + + expect(resolved, isNull); + }); + + test('should return null for invalid event types', () { + final pollVote = PollVote( + id: 'vote-123', + answerText: 'My answer', + pollId: 'poll-123', + ); + + final event = Event( + type: EventType.pollVoteRemoved, + pollVote: pollVote, + cid: 'channel-123', + ); + + final resolved = pollAnswerCastedResolver(event); + + expect(resolved, isNull); + }); + + test('should return null for event with null pollVote', () { + final event = Event( + type: EventType.pollVoteCasted, + pollVote: null, + cid: 'channel-123', + ); + + final resolved = pollAnswerCastedResolver(event); + + expect(resolved, isNull); + }); + }); + + group('pollAnswerRemovedResolver', () { + test( + 'should resolve pollVoteRemoved event with answer to pollAnswerRemoved', + () { + final pollVote = PollVote( + id: 'vote-123', + answerText: 'My answer', + pollId: 'poll-123', + ); + + final event = Event( + type: EventType.pollVoteRemoved, + pollVote: pollVote, + cid: 'channel-123', + ); + + final resolved = pollAnswerRemovedResolver(event); + + expect(resolved, isNotNull); + expect(resolved!.type, EventType.pollAnswerRemoved); + expect(resolved.pollVote, equals(pollVote)); + expect(resolved.cid, equals('channel-123')); + }, + ); + + test('should return null for pollVoteRemoved event with option vote', () { + final pollVote = PollVote( + id: 'vote-123', + optionId: 'option-1', + pollId: 'poll-123', + ); + + final event = Event( + type: EventType.pollVoteRemoved, + pollVote: pollVote, + cid: 'channel-123', + ); + + final resolved = pollAnswerRemovedResolver(event); + + expect(resolved, isNull); + }); + + test('should return null for invalid event types', () { + final pollVote = PollVote( + id: 'vote-123', + answerText: 'My answer', + pollId: 'poll-123', + ); + + final event = Event( + type: EventType.pollVoteCasted, + pollVote: pollVote, + cid: 'channel-123', + ); + + final resolved = pollAnswerRemovedResolver(event); + + expect(resolved, isNull); + }); + + test('should return null for event with null pollVote', () { + final event = Event( + type: EventType.pollVoteRemoved, + pollVote: null, + cid: 'channel-123', + ); + + final resolved = pollAnswerRemovedResolver(event); + + expect(resolved, isNull); + }); + }); + }); + + group('Location resolver events', () { + group('locationSharedResolver', () { + test( + 'should resolve messageNew event with sharedLocation to locationShared', + () { + final location = Location( + latitude: 40.7128, + longitude: -74.0060, + createdByDeviceId: 'device-123', + ); + + final message = Message( + id: 'message-123', + text: 'Check out this location', + sharedLocation: location, + user: User(id: 'user-123'), + ); + + final event = Event( + type: EventType.messageNew, + message: message, + cid: 'channel-123', + ); + + final resolved = locationSharedResolver(event); + + expect(resolved, isNotNull); + expect(resolved!.type, EventType.locationShared); + expect(resolved.message, equals(message)); + expect(resolved.cid, equals('channel-123')); + }, + ); + + test( + 'should resolve notificationMessageNew event with sharedLocation to locationShared', + () { + final location = Location( + latitude: 40.7128, + longitude: -74.0060, + createdByDeviceId: 'device-123', + ); + + final message = Message( + id: 'message-123', + text: 'Check out this location', + sharedLocation: location, + user: User(id: 'user-123'), + ); + + final event = Event( + type: EventType.notificationMessageNew, + message: message, + cid: 'channel-123', + ); + + final resolved = locationSharedResolver(event); + + expect(resolved, isNotNull); + expect(resolved!.type, EventType.locationShared); + expect(resolved.message, equals(message)); + }, + ); + + test( + 'should return null for messageNew event without sharedLocation', + () { + final message = Message( + id: 'message-123', + text: 'Just a regular message', + user: User(id: 'user-123'), + ); + + final event = Event( + type: EventType.messageNew, + message: message, + cid: 'channel-123', + ); + + final resolved = locationSharedResolver(event); + + expect(resolved, isNull); + }, + ); + + test('should return null for invalid event types', () { + final location = Location( + latitude: 40.7128, + longitude: -74.0060, + createdByDeviceId: 'device-123', + ); + + final message = Message( + id: 'message-123', + text: 'Check out this location', + sharedLocation: location, + user: User(id: 'user-123'), + ); + + final event = Event( + type: EventType.messageUpdated, + message: message, + cid: 'channel-123', + ); + + final resolved = locationSharedResolver(event); + + expect(resolved, isNull); + }); + + test('should return null for event with null message', () { + final event = Event( + type: EventType.messageNew, + message: null, + cid: 'channel-123', + ); + + final resolved = locationSharedResolver(event); + + expect(resolved, isNull); + }); + }); + + group('locationUpdatedResolver', () { + test( + 'should resolve messageUpdated event with active live location to locationUpdated', + () { + final location = Location( + latitude: 40.7128, + longitude: -74.0060, + createdByDeviceId: 'device-123', + endAt: DateTime.now().add(const Duration(hours: 1)), + ); + + final message = Message( + id: 'message-123', + text: 'Live location sharing', + sharedLocation: location, + user: User(id: 'user-123'), + ); + + final event = Event( + type: EventType.messageUpdated, + message: message, + cid: 'channel-123', + ); + + final resolved = locationUpdatedResolver(event); + + expect(resolved, isNotNull); + expect(resolved!.type, EventType.locationUpdated); + expect(resolved.message, equals(message)); + expect(resolved.cid, equals('channel-123')); + }, + ); + + test( + 'should resolve messageUpdated event with static location to locationUpdated', + () { + final location = Location( + latitude: 40.7128, + longitude: -74.0060, + createdByDeviceId: 'device-123', + // No endAt means static location + ); + + final message = Message( + id: 'message-123', + text: 'Static location sharing', + sharedLocation: location, + user: User(id: 'user-123'), + ); + + final event = Event( + type: EventType.messageUpdated, + message: message, + cid: 'channel-123', + ); + + final resolved = locationUpdatedResolver(event); + + expect(resolved, isNotNull); + expect(resolved!.type, EventType.locationUpdated); + expect(resolved.message, equals(message)); + }, + ); + + test( + 'should return null for messageUpdated event with expired live location', + () { + final location = Location( + latitude: 40.7128, + longitude: -74.0060, + createdByDeviceId: 'device-123', + endAt: DateTime.now().subtract(const Duration(hours: 1)), + ); + + final message = Message( + id: 'message-123', + text: 'Expired location sharing', + sharedLocation: location, + user: User(id: 'user-123'), + ); + + final event = Event( + type: EventType.messageUpdated, + message: message, + cid: 'channel-123', + ); + + final resolved = locationUpdatedResolver(event); + + expect(resolved, isNull); + }, + ); + + test('should return null for invalid event types', () { + final location = Location( + latitude: 40.7128, + longitude: -74.0060, + createdByDeviceId: 'device-123', + ); + + final message = Message( + id: 'message-123', + text: 'Check out this location', + sharedLocation: location, + user: User(id: 'user-123'), + ); + + final event = Event( + type: EventType.messageNew, + message: message, + cid: 'channel-123', + ); + + final resolved = locationUpdatedResolver(event); + + expect(resolved, isNull); + }); + + test('should return null for event with null message', () { + final event = Event( + type: EventType.messageUpdated, + message: null, + cid: 'channel-123', + ); + + final resolved = locationUpdatedResolver(event); + + expect(resolved, isNull); + }); + }); + + group('locationExpiredResolver', () { + test( + 'should resolve messageUpdated event with expired live location to locationExpired', + () { + final location = Location( + latitude: 40.7128, + longitude: -74.0060, + createdByDeviceId: 'device-123', + endAt: DateTime.now().subtract(const Duration(hours: 1)), + ); + + final message = Message( + id: 'message-123', + text: 'Expired location sharing', + sharedLocation: location, + user: User(id: 'user-123'), + ); + + final event = Event( + type: EventType.messageUpdated, + message: message, + cid: 'channel-123', + ); + + final resolved = locationExpiredResolver(event); + + expect(resolved, isNotNull); + expect(resolved!.type, EventType.locationExpired); + expect(resolved.message, equals(message)); + expect(resolved.cid, equals('channel-123')); + }, + ); + + test( + 'should return null for messageUpdated event with active live location', + () { + final location = Location( + latitude: 40.7128, + longitude: -74.0060, + createdByDeviceId: 'device-123', + endAt: DateTime.now().add(const Duration(hours: 1)), + ); + + final message = Message( + id: 'message-123', + text: 'Active location sharing', + sharedLocation: location, + user: User(id: 'user-123'), + ); + + final event = Event( + type: EventType.messageUpdated, + message: message, + cid: 'channel-123', + ); + + final resolved = locationExpiredResolver(event); + + expect(resolved, isNull); + }, + ); + + test( + 'should return null for messageUpdated event with static location', + () { + final location = Location( + latitude: 40.7128, + longitude: -74.0060, + createdByDeviceId: 'device-123', + // No endAt means static location + ); + + final message = Message( + id: 'message-123', + text: 'Static location sharing', + sharedLocation: location, + user: User(id: 'user-123'), + ); + + final event = Event( + type: EventType.messageUpdated, + message: message, + cid: 'channel-123', + ); + + final resolved = locationExpiredResolver(event); + + expect(resolved, isNull); + }, + ); + + test('should return null for invalid event types', () { + final location = Location( + latitude: 40.7128, + longitude: -74.0060, + createdByDeviceId: 'device-123', + endAt: DateTime.now().subtract(const Duration(hours: 1)), + ); + + final message = Message( + id: 'message-123', + text: 'Expired location sharing', + sharedLocation: location, + user: User(id: 'user-123'), + ); + + final event = Event( + type: EventType.messageNew, + message: message, + cid: 'channel-123', + ); + + final resolved = locationExpiredResolver(event); + + expect(resolved, isNull); + }); + + test('should return null for event with null message', () { + final event = Event( + type: EventType.messageUpdated, + message: null, + cid: 'channel-123', + ); + + final resolved = locationExpiredResolver(event); + + expect(resolved, isNull); + }); + }); + }); +} diff --git a/packages/stream_chat/test/src/client/retry_queue_test.dart b/packages/stream_chat/test/src/client/retry_queue_test.dart index 1150c40b85..59e2e51f47 100644 --- a/packages/stream_chat/test/src/client/retry_queue_test.dart +++ b/packages/stream_chat/test/src/client/retry_queue_test.dart @@ -46,7 +46,10 @@ void main() { final message = Message( id: 'test-message-id', text: 'Sample message test', - state: MessageState.sendingFailed, + state: MessageState.sendingFailed( + skipPush: false, + skipEnrichUrl: false, + ), ); retryQueue.add([message]); expect(() => retryQueue.add([message]), returnsNormally); @@ -58,7 +61,10 @@ void main() { final message = Message( id: 'test-message-id', text: 'Sample message test', - state: MessageState.sendingFailed, + state: MessageState.sendingFailed( + skipPush: false, + skipEnrichUrl: false, + ), ); retryQueue.add([message]); expect(retryQueue.hasMessages, isTrue); diff --git a/packages/stream_chat/test/src/core/api/attachment_file_uploader_test.dart b/packages/stream_chat/test/src/core/api/attachment_file_uploader_test.dart index 58ffdefb69..1b319718e1 100644 --- a/packages/stream_chat/test/src/core/api/attachment_file_uploader_test.dart +++ b/packages/stream_chat/test/src/core/api/attachment_file_uploader_test.dart @@ -19,10 +19,10 @@ void main() { }); Response successResponse(String path, {Object? data}) => Response( - data: data, - requestOptions: RequestOptions(path: path), - statusCode: 200, - ); + data: data, + requestOptions: RequestOptions(path: path), + statusCode: 200, + ); test('sendImage', () async { const channelId = 'test-channel-id'; @@ -37,12 +37,19 @@ void main() { ); final multipartFile = await attachmentFile.toMultipartFile(); - when(() => client.postFile( - path, - any(that: isSameMultipartFileAs(multipartFile)), - )).thenAnswer((_) async => successResponse(path, data: { + when( + () => client.postFile( + path, + any(that: isSameMultipartFileAs(multipartFile)), + ), + ).thenAnswer( + (_) async => successResponse( + path, + data: { 'file': 'test-file-url', - })); + }, + ), + ); final res = await fileUploader.sendImage( attachmentFile, @@ -54,10 +61,12 @@ void main() { expect(res.file, isNotNull); expect(res.file, isNotEmpty); - verify(() => client.postFile( - path, - any(that: isSameMultipartFileAs(multipartFile)), - )).called(1); + verify( + () => client.postFile( + path, + any(that: isSameMultipartFileAs(multipartFile)), + ), + ).called(1); verifyNoMoreInteractions(client); }); @@ -74,12 +83,19 @@ void main() { ); final multipartFile = await attachmentFile.toMultipartFile(); - when(() => client.postFile( - path, - any(that: isSameMultipartFileAs(multipartFile)), - )).thenAnswer((_) async => successResponse(path, data: { + when( + () => client.postFile( + path, + any(that: isSameMultipartFileAs(multipartFile)), + ), + ).thenAnswer( + (_) async => successResponse( + path, + data: { 'file': 'test-file-url', - })); + }, + ), + ); final res = await fileUploader.sendFile( attachmentFile, @@ -91,10 +107,12 @@ void main() { expect(res.file, isNotNull); expect(res.file, isNotEmpty); - verify(() => client.postFile( - path, - any(that: isSameMultipartFileAs(multipartFile)), - )).called(1); + verify( + () => client.postFile( + path, + any(that: isSameMultipartFileAs(multipartFile)), + ), + ).called(1); verifyNoMoreInteractions(client); }); @@ -105,8 +123,9 @@ void main() { const url = 'test-image-url'; - when(() => client.delete(path, queryParameters: {'url': url})).thenAnswer( - (_) async => successResponse(path, data: {})); + when( + () => client.delete(path, queryParameters: {'url': url}), + ).thenAnswer((_) async => successResponse(path, data: {})); final res = await fileUploader.deleteImage(url, channelId, channelType); @@ -123,8 +142,9 @@ void main() { const url = 'test-file-url'; - when(() => client.delete(path, queryParameters: {'url': url})).thenAnswer( - (_) async => successResponse(path, data: {})); + when( + () => client.delete(path, queryParameters: {'url': url}), + ).thenAnswer((_) async => successResponse(path, data: {})); final res = await fileUploader.deleteFile(url, channelId, channelType); @@ -133,4 +153,114 @@ void main() { verify(() => client.delete(path, queryParameters: {'url': url})).called(1); verifyNoMoreInteractions(client); }); + + test('uploadImage', () async { + const path = '/uploads/image'; + final file = assetFile('test_image.jpeg'); + final attachmentFile = AttachmentFile( + size: 333, + path: file.path, + bytes: file.readAsBytesSync(), + ); + final multipartFile = await attachmentFile.toMultipartFile(); + + when( + () => client.postFile( + path, + any(that: isSameMultipartFileAs(multipartFile)), + ), + ).thenAnswer( + (_) async => successResponse( + path, + data: { + 'file': 'test-image-url', + }, + ), + ); + + final res = await fileUploader.uploadImage(attachmentFile); + + expect(res, isNotNull); + expect(res.file, isNotNull); + expect(res.file, isNotEmpty); + + verify( + () => client.postFile( + path, + any(that: isSameMultipartFileAs(multipartFile)), + ), + ).called(1); + verifyNoMoreInteractions(client); + }); + + test('uploadFile', () async { + const path = '/uploads/file'; + final file = assetFile('example.pdf'); + final attachmentFile = AttachmentFile( + size: 333, + path: file.path, + bytes: file.readAsBytesSync(), + ); + final multipartFile = await attachmentFile.toMultipartFile(); + + when( + () => client.postFile( + path, + any(that: isSameMultipartFileAs(multipartFile)), + ), + ).thenAnswer( + (_) async => successResponse( + path, + data: { + 'file': 'test-file-url', + }, + ), + ); + + final res = await fileUploader.uploadFile(attachmentFile); + + expect(res, isNotNull); + expect(res.file, isNotNull); + expect(res.file, isNotEmpty); + + verify( + () => client.postFile( + path, + any(that: isSameMultipartFileAs(multipartFile)), + ), + ).called(1); + verifyNoMoreInteractions(client); + }); + + test('removeImage', () async { + const path = '/uploads/image'; + const url = 'test-image-url'; + + when( + () => client.delete(path, queryParameters: {'url': url}), + ).thenAnswer((_) async => successResponse(path, data: {})); + + final res = await fileUploader.removeImage(url); + + expect(res, isNotNull); + + verify(() => client.delete(path, queryParameters: {'url': url})).called(1); + verifyNoMoreInteractions(client); + }); + + test('removeFile', () async { + const path = '/uploads/file'; + const url = 'test-file-url'; + + when( + () => client.delete(path, queryParameters: {'url': url}), + ).thenAnswer((_) async => successResponse(path, data: {})); + + final res = await fileUploader.removeFile(url); + + expect(res, isNotNull); + + verify(() => client.delete(path, queryParameters: {'url': url})).called(1); + verifyNoMoreInteractions(client); + }); } diff --git a/packages/stream_chat/test/src/core/api/call_api_test.dart b/packages/stream_chat/test/src/core/api/call_api_test.dart deleted file mode 100644 index 60c0c24adb..0000000000 --- a/packages/stream_chat/test/src/core/api/call_api_test.dart +++ /dev/null @@ -1,67 +0,0 @@ -import 'package:dio/dio.dart'; -import 'package:mocktail/mocktail.dart'; -import 'package:stream_chat/src/core/api/call_api.dart'; -import 'package:test/test.dart'; - -import '../../mocks.dart'; - -@Deprecated('Will be removed in the next major version') -void main() { - Response successResponse(String path, {Object? data}) => Response( - data: data, - requestOptions: RequestOptions(path: path), - statusCode: 200, - ); - - late final client = MockHttpClient(); - late CallApi callApi; - - setUp(() { - callApi = CallApi(client); - }); - - test('getCallToken should work', () async { - const callId = 'test-call-id'; - const path = '/calls/$callId'; - - when(() => client.post(path, data: {})).thenAnswer( - (_) async => successResponse(path, data: {})); - - final res = await callApi.getCallToken(callId); - - expect(res, isNotNull); - - verify(() => client.post(path, data: any(named: 'data'))).called(1); - verifyNoMoreInteractions(client); - }); - - test('createCall should work', () async { - const callId = 'test-call-id'; - const callType = 'test-call-type'; - const channelType = 'test-channel-type'; - const channelId = 'test-channel-id'; - const path = '/channels/$channelType/$channelId/call'; - - when(() => client.post( - path, - data: { - 'id': callId, - 'type': callType, - }, - )) - .thenAnswer( - (_) async => successResponse(path, data: {})); - - final res = await callApi.createCall( - callId: callId, - callType: callType, - channelType: channelType, - channelId: channelId, - ); - - expect(res, isNotNull); - - verify(() => client.post(path, data: any(named: 'data'))).called(1); - verifyNoMoreInteractions(client); - }); -} diff --git a/packages/stream_chat/test/src/core/api/channel_api_test.dart b/packages/stream_chat/test/src/core/api/channel_api_test.dart index ae5a0dba81..1c4ae95c13 100644 --- a/packages/stream_chat/test/src/core/api/channel_api_test.dart +++ b/packages/stream_chat/test/src/core/api/channel_api_test.dart @@ -9,8 +9,7 @@ import 'package:test/test.dart'; import '../../mocks.dart'; void main() { - String _getChannelUrl(String channelId, String channelType) => - '/channels/$channelType/$channelId'; + String _getChannelUrl(String channelId, String channelType) => '/channels/$channelType/$channelId'; ChannelState _generateChannelState( String channelId, @@ -53,10 +52,10 @@ void main() { } Response successResponse(String path, {Object? data}) => Response( - data: data, - requestOptions: RequestOptions(path: path), - statusCode: 200, - ); + data: data, + requestOptions: RequestOptions(path: path), + statusCode: 200, + ); late final client = MockHttpClient(); late ChannelApi channelApi; @@ -88,13 +87,17 @@ void main() { 'watchers': watchersPagination, }; - when(() => client.post( - path, - data: data, - )).thenAnswer((_) async => successResponse( - path, - data: channelState.toJson(), - )); + when( + () => client.post( + path, + data: data, + ), + ).thenAnswer( + (_) async => successResponse( + path, + data: channelState.toJson(), + ), + ); final res = await channelApi.queryChannel( channelType, @@ -143,20 +146,24 @@ void main() { 'message_limit': messageLimit, // pagination - ...const PaginationParams().toJson() + ...const PaginationParams().toJson(), }); - when(() => client.get( - path, - queryParameters: { - 'payload': payload, - }, - )).thenAnswer((_) async => successResponse( - path, - data: { - 'channels': [channelState.toJson()] - }, - )); + when( + () => client.get( + path, + queryParameters: { + 'payload': payload, + }, + ), + ).thenAnswer( + (_) async => successResponse( + path, + data: { + 'channels': [channelState.toJson()], + }, + ), + ); final res = await channelApi.queryChannels( filter: filter, @@ -176,8 +183,7 @@ void main() { test('markAllRead', () async { const path = '/channels/read'; - when(() => client.post(path, data: {})).thenAnswer( - (_) async => successResponse(path, data: {})); + when(() => client.post(path, data: {})).thenAnswer((_) async => successResponse(path, data: {})); final res = await channelApi.markAllRead(); @@ -201,18 +207,23 @@ void main() { extraData: data, ); - when(() => client.post( - path, - data: any( - named: 'data', - that: wrapMatcher((Map v) => - containsPair('data', data).matches(v, {}) && - contains('message').matches(v, {})), - ), - )).thenAnswer((_) async => successResponse(path, data: { + when( + () => client.post( + path, + data: any( + named: 'data', + that: wrapMatcher((Map v) => containsPair('data', data).matches(v, {}) && contains('message').matches(v, {})), + ), + ), + ).thenAnswer( + (_) async => successResponse( + path, + data: { 'channel': channelModel.toJson(), 'message': message.toJson(), - })); + }, + ), + ); final res = await channelApi.updateChannel( channelId, @@ -249,9 +260,14 @@ void main() { when( () => client.patch(path, data: {'set': set, 'unset': unset}), - ).thenAnswer((_) async => successResponse(path, data: { + ).thenAnswer( + (_) async => successResponse( + path, + data: { 'channel': channelModel.toJson(), - })); + }, + ), + ); final res = await channelApi.updateChannelPartial( channelId, @@ -277,16 +293,23 @@ void main() { final path = _getChannelUrl(channelId, channelType); - when(() => client.post( - path, - data: { - 'accept_invite': true, - 'message': message, - }, - )).thenAnswer((_) async => successResponse(path, data: { + when( + () => client.post( + path, + data: { + 'accept_invite': true, + 'message': message, + }, + ), + ).thenAnswer( + (_) async => successResponse( + path, + data: { 'channel': channelModel.toJson(), 'message': message.toJson(), - })); + }, + ), + ); final res = await channelApi.acceptChannelInvite( channelId, @@ -311,16 +334,23 @@ void main() { final path = _getChannelUrl(channelId, channelType); - when(() => client.post( - path, - data: { - 'reject_invite': true, - 'message': message, - }, - )).thenAnswer((_) async => successResponse(path, data: { + when( + () => client.post( + path, + data: { + 'reject_invite': true, + 'message': message, + }, + ), + ).thenAnswer( + (_) async => successResponse( + path, + data: { 'channel': channelModel.toJson(), 'message': message.toJson(), - })); + }, + ), + ); final res = await channelApi.rejectChannelInvite( channelId, @@ -345,16 +375,23 @@ void main() { final path = _getChannelUrl(channelId, channelType); - when(() => client.post( - path, - data: { - 'invites': memberIds, - 'message': message, - }, - )).thenAnswer((_) async => successResponse(path, data: { + when( + () => client.post( + path, + data: { + 'invites': memberIds, + 'message': message, + }, + ), + ).thenAnswer( + (_) async => successResponse( + path, + data: { 'channel': channelModel.toJson(), 'message': message.toJson(), - })); + }, + ), + ); final res = await channelApi.inviteChannelMembers( channelId, @@ -381,17 +418,24 @@ void main() { final path = _getChannelUrl(channelId, channelType); - when(() => client.post( - path, - data: { - 'add_members': memberIds, - 'message': message, - 'hide_history': hideHistory, - }, - )).thenAnswer((_) async => successResponse(path, data: { + when( + () => client.post( + path, + data: { + 'add_members': memberIds, + 'message': message, + 'hide_history': hideHistory, + }, + ), + ).thenAnswer( + (_) async => successResponse( + path, + data: { 'channel': channelModel.toJson(), 'message': message.toJson(), - })); + }, + ), + ); final res = await channelApi.addMembers( channelId, @@ -419,17 +463,24 @@ void main() { final path = _getChannelUrl(channelId, channelType); - when(() => client.post( - path, - data: { - 'add_members': memberIds, - 'message': message, - 'hide_history_before': hideHistoryBefore.toUtc().toIso8601String(), - }, - )).thenAnswer((_) async => successResponse(path, data: { + when( + () => client.post( + path, + data: { + 'add_members': memberIds, + 'message': message, + 'hide_history_before': hideHistoryBefore.toUtc().toIso8601String(), + }, + ), + ).thenAnswer( + (_) async => successResponse( + path, + data: { 'channel': channelModel.toJson(), 'message': message.toJson(), - })); + }, + ), + ); final res = await channelApi.addMembers( channelId, @@ -447,8 +498,7 @@ void main() { verifyNoMoreInteractions(client); }); - test('addMembers with hideHistoryBefore takes precedence over hideHistory', - () async { + test('addMembers with hideHistoryBefore takes precedence over hideHistory', () async { const channelId = 'test-channel-id'; const channelType = 'test-channel-type'; const memberIds = ['test-member-id-1', 'test-member-id-2']; @@ -459,17 +509,24 @@ void main() { final path = _getChannelUrl(channelId, channelType); - when(() => client.post( - path, - data: { - 'add_members': memberIds, - 'message': message, - 'hide_history_before': hideHistoryBefore.toUtc().toIso8601String(), - }, - )).thenAnswer((_) async => successResponse(path, data: { + when( + () => client.post( + path, + data: { + 'add_members': memberIds, + 'message': message, + 'hide_history_before': hideHistoryBefore.toUtc().toIso8601String(), + }, + ), + ).thenAnswer( + (_) async => successResponse( + path, + data: { 'channel': channelModel.toJson(), 'message': message.toJson(), - })); + }, + ), + ); final res = await channelApi.addMembers( channelId, @@ -497,16 +554,23 @@ void main() { final path = _getChannelUrl(channelId, channelType); - when(() => client.post( - path, - data: { - 'remove_members': memberIds, - 'message': message, - }, - )).thenAnswer((_) async => successResponse(path, data: { + when( + () => client.post( + path, + data: { + 'remove_members': memberIds, + 'message': message, + }, + ), + ).thenAnswer( + (_) async => successResponse( + path, + data: { 'channel': channelModel.toJson(), 'message': message.toJson(), - })); + }, + ), + ); final res = await channelApi.removeMembers( channelId, @@ -530,8 +594,9 @@ void main() { final path = '${_getChannelUrl(channelId, channelType)}/event'; - when(() => client.post(path, data: {'event': event})).thenAnswer( - (_) async => successResponse(path, data: {})); + when( + () => client.post(path, data: {'event': event}), + ).thenAnswer((_) async => successResponse(path, data: {})); final res = await channelApi.sendEvent(channelId, channelType, event); @@ -547,8 +612,7 @@ void main() { final path = _getChannelUrl(channelId, channelType); - when(() => client.delete(path)).thenAnswer( - (_) async => successResponse(path, data: {})); + when(() => client.delete(path)).thenAnswer((_) async => successResponse(path, data: {})); final res = await channelApi.deleteChannel(channelId, channelType); @@ -564,21 +628,23 @@ void main() { final path = '${_getChannelUrl(channelId, channelType)}/truncate'; - when(() => client.post( - path, - data: {}, - )) - .thenAnswer( - (_) async => successResponse(path, data: {})); + when( + () => client.post( + path, + data: {}, + ), + ).thenAnswer((_) async => successResponse(path, data: {})); final res = await channelApi.truncateChannel(channelId, channelType); expect(res, isNotNull); - verify(() => client.post( - path, - data: {}, - )).called(1); + verify( + () => client.post( + path, + data: {}, + ), + ).called(1); verifyNoMoreInteractions(client); }); @@ -638,21 +704,23 @@ void main() { final path = '${_getChannelUrl(channelId, channelType)}/show'; - when(() => client.post( - path, - data: {}, - )) - .thenAnswer( - (_) async => successResponse(path, data: {})); + when( + () => client.post( + path, + data: {}, + ), + ).thenAnswer((_) async => successResponse(path, data: {})); final res = await channelApi.showChannel(channelId, channelType); expect(res, isNotNull); - verify(() => client.post( - path, - data: {}, - )).called(1); + verify( + () => client.post( + path, + data: {}, + ), + ).called(1); verifyNoMoreInteractions(client); }); @@ -663,14 +731,14 @@ void main() { final path = '${_getChannelUrl(channelId, channelType)}/read'; - when(() => client.post( - path, - data: { - 'message_id': messageId, - }, - )) - .thenAnswer( - (_) async => successResponse(path, data: {})); + when( + () => client.post( + path, + data: { + 'message_id': messageId, + }, + ), + ).thenAnswer((_) async => successResponse(path, data: {})); final res = await channelApi.markRead( channelId, @@ -691,12 +759,12 @@ void main() { final path = '${_getChannelUrl(channelId, channelType)}/unread'; - when(() => client.post( - path, - data: {'message_id': messageId}, - )) - .thenAnswer( - (_) async => successResponse(path, data: {})); + when( + () => client.post( + path, + data: {'message_id': messageId}, + ), + ).thenAnswer((_) async => successResponse(path, data: {})); final res = await channelApi.markUnread( channelId, @@ -717,14 +785,14 @@ void main() { final path = '${_getChannelUrl(channelId, channelType)}/unread'; - when(() => client.post( - path, - data: { - 'message_timestamp': timestamp.toUtc().toIso8601String(), - }, - )) - .thenAnswer( - (_) async => successResponse(path, data: {})); + when( + () => client.post( + path, + data: { + 'message_timestamp': timestamp.toUtc().toIso8601String(), + }, + ), + ).thenAnswer((_) async => successResponse(path, data: {})); final res = await channelApi.markUnreadByTimestamp( channelId, @@ -745,17 +813,22 @@ void main() { final path = '${_getChannelUrl(channelId, channelType)}/member/{user_id}'; const archivedAt = '2025-04-10 10:27:03.150349'; - when(() => client.patch( - path, - data: { - 'set': {'archived': true}, + when( + () => client.patch( + path, + data: { + 'set': {'archived': true}, + }, + ), + ).thenAnswer( + (_) async => successResponse( + path, + data: { + 'channel_member': { + 'archived_at': archivedAt, }, - )).thenAnswer( - (_) async => successResponse(path, data: { - 'channel_member': { - 'archived_at': archivedAt, - } - }), + }, + ), ); final res = await channelApi.updateMemberPartial( @@ -777,14 +850,15 @@ void main() { final path = '${_getChannelUrl(channelId, channelType)}/member/{user_id}'; - when(() => client.patch( - path, - data: { - 'unset': ['archived'], - }, - )).thenAnswer( - (_) async => successResponse(path, - data: {'channel_member': {}}), + when( + () => client.patch( + path, + data: { + 'unset': ['archived'], + }, + ), + ).thenAnswer( + (_) async => successResponse(path, data: {'channel_member': {}}), ); final res = await channelApi.updateMemberPartial( @@ -807,17 +881,22 @@ void main() { final path = '${_getChannelUrl(channelId, channelType)}/member/{user_id}'; const pinnedAt = '2025-04-10 10:27:03.150349'; - when(() => client.patch( - path, - data: { - 'set': {'pinned': true}, + when( + () => client.patch( + path, + data: { + 'set': {'pinned': true}, + }, + ), + ).thenAnswer( + (_) async => successResponse( + path, + data: { + 'channel_member': { + 'pinned_at': pinnedAt, }, - )).thenAnswer( - (_) async => successResponse(path, data: { - 'channel_member': { - 'pinned_at': pinnedAt, - } - }), + }, + ), ); final res = await channelApi.updateMemberPartial( @@ -839,14 +918,15 @@ void main() { final path = '${_getChannelUrl(channelId, channelType)}/member/{user_id}'; - when(() => client.patch( - path, - data: { - 'unset': ['pinned'], - }, - )).thenAnswer( - (_) async => successResponse(path, - data: {'channel_member': {}}), + when( + () => client.patch( + path, + data: { + 'unset': ['pinned'], + }, + ), + ).thenAnswer( + (_) async => successResponse(path, data: {'channel_member': {}}), ); final res = await channelApi.updateMemberPartial( @@ -868,8 +948,7 @@ void main() { final path = '${_getChannelUrl(channelId, channelType)}/stop-watching'; - when(() => client.post(path, data: {})).thenAnswer( - (_) async => successResponse(path, data: {})); + when(() => client.post(path, data: {})).thenAnswer((_) async => successResponse(path, data: {})); final res = await channelApi.stopWatching(channelId, channelType); @@ -895,20 +974,34 @@ void main() { extraData: set, ); - when(() => client.patch(path, data: { + when( + () => client.patch( + path, + data: { 'set': set, - })).thenAnswer((_) async => successResponse(path, data: { + }, + ), + ).thenAnswer( + (_) async => successResponse( + path, + data: { 'channel': channelModel.toJson(), - })); + }, + ), + ); - final res = - await channelApi.enableSlowdown(channelId, channelType, cooldown); + final res = await channelApi.enableSlowdown(channelId, channelType, cooldown); expect(res, isNotNull); - verify(() => client.patch(path, data: { + verify( + () => client.patch( + path, + data: { 'set': set, - })).called(1); + }, + ), + ).called(1); verifyNoMoreInteractions(client); }); @@ -924,19 +1017,34 @@ void main() { type: channelType, ); - when(() => client.patch(path, data: { + when( + () => client.patch( + path, + data: { 'unset': unset, - })).thenAnswer((_) async => successResponse(path, data: { + }, + ), + ).thenAnswer( + (_) async => successResponse( + path, + data: { 'channel': channelModel.toJson(), - })); + }, + ), + ); final res = await channelApi.disableSlowdown(channelId, channelType); expect(res, isNotNull); - verify(() => client.patch(path, data: { + verify( + () => client.patch( + path, + data: { 'unset': unset, - })).called(1); + }, + ), + ).called(1); verifyNoMoreInteractions(client); }); @@ -954,24 +1062,30 @@ void main() { ), ]; - when(() => client.post( - path, - data: any(named: 'data'), - )).thenAnswer((_) async => successResponse( - path, - data: {}, - )); + when( + () => client.post( + path, + data: any(named: 'data'), + ), + ).thenAnswer( + (_) async => successResponse( + path, + data: {}, + ), + ); final res = await channelApi.markChannelsDelivered(deliveries); expect(res, isNotNull); - verify(() => client.post( - path, - data: jsonEncode({ - 'latest_delivered_messages': deliveries, - }), - )).called(1); + verify( + () => client.post( + path, + data: jsonEncode({ + 'latest_delivered_messages': deliveries, + }), + ), + ).called(1); verifyNoMoreInteractions(client); }); } diff --git a/packages/stream_chat/test/src/core/api/device_api_test.dart b/packages/stream_chat/test/src/core/api/device_api_test.dart index c9d06a843d..8318d8dda6 100644 --- a/packages/stream_chat/test/src/core/api/device_api_test.dart +++ b/packages/stream_chat/test/src/core/api/device_api_test.dart @@ -8,10 +8,10 @@ import '../../mocks.dart'; void main() { Response successResponse(String path, {Object? data}) => Response( - data: data, - requestOptions: RequestOptions(path: path), - statusCode: 200, - ); + data: data, + requestOptions: RequestOptions(path: path), + statusCode: 200, + ); late final client = MockHttpClient(); late DeviceApi deviceApi; @@ -40,19 +40,16 @@ void main() { path, data: data, ); - }).thenAnswer( - (_) async => successResponse(path, data: {})); + }).thenAnswer((_) async => successResponse(path, data: {})); - final res = - await deviceApi.addDevice(deviceId, pushProviderMapEntry.value); + final res = await deviceApi.addDevice(deviceId, pushProviderMapEntry.value); expect(res, isNotNull); verify(() => client.post(path, data: data)).called(1); } verifyNoMoreInteractions(client); - expect(pushProvidersMap.length, PushProvider.values.length, - reason: 'All PushProvider should be tested'); + expect(pushProvidersMap.length, PushProvider.values.length, reason: 'All PushProvider should be tested'); }); test('addDevice should work with pushProviderName', () async { @@ -62,16 +59,16 @@ void main() { const path = '/devices'; - when(() => client.post( - path, - data: { - 'id': deviceId, - 'push_provider': pushProvider.name, - 'push_provider_name': pushProviderName, - }, - )) - .thenAnswer( - (_) async => successResponse(path, data: {})); + when( + () => client.post( + path, + data: { + 'id': deviceId, + 'push_provider': pushProvider.name, + 'push_provider_name': pushProviderName, + }, + ), + ).thenAnswer((_) async => successResponse(path, data: {})); final res = await deviceApi.addDevice( deviceId, @@ -97,9 +94,12 @@ void main() { ); when(() => client.get(path)).thenAnswer( - (_) async => successResponse(path, data: { - 'devices': [...devices.map((it) => it.toJson())] - }), + (_) async => successResponse( + path, + data: { + 'devices': [...devices.map((it) => it.toJson())], + }, + ), ); final res = await deviceApi.getDevices(); @@ -141,10 +141,13 @@ void main() { ]; when(() => client.post(path, data: any(named: 'data'))).thenAnswer( - (_) async => successResponse(path, data: { - 'user_preferences': {}, - 'user_channel_preferences': {}, - }), + (_) async => successResponse( + path, + data: { + 'user_preferences': {}, + 'user_channel_preferences': {}, + }, + ), ); final res = await deviceApi.setPushPreferences(preferences); @@ -170,10 +173,13 @@ void main() { ]; when(() => client.post(path, data: any(named: 'data'))).thenAnswer( - (_) async => successResponse(path, data: { - 'user_preferences': {}, - 'user_channel_preferences': {}, - }), + (_) async => successResponse( + path, + data: { + 'user_preferences': {}, + 'user_channel_preferences': {}, + }, + ), ); final res = await deviceApi.setPushPreferences(preferences); @@ -195,10 +201,13 @@ void main() { ]; when(() => client.post(path, data: any(named: 'data'))).thenAnswer( - (_) async => successResponse(path, data: { - 'user_preferences': {}, - 'user_channel_preferences': {}, - }), + (_) async => successResponse( + path, + data: { + 'user_preferences': {}, + 'user_channel_preferences': {}, + }, + ), ); final res = await deviceApi.setPushPreferences(preferences); diff --git a/packages/stream_chat/test/src/core/api/general_api_test.dart b/packages/stream_chat/test/src/core/api/general_api_test.dart index 52135981fe..cd41156ce1 100644 --- a/packages/stream_chat/test/src/core/api/general_api_test.dart +++ b/packages/stream_chat/test/src/core/api/general_api_test.dart @@ -10,10 +10,10 @@ import '../../mocks.dart'; void main() { Response successResponse(String path, {Object? data}) => Response( - data: data, - requestOptions: RequestOptions(path: path), - statusCode: 200, - ); + data: data, + requestOptions: RequestOptions(path: path), + statusCode: 200, + ); late final client = MockHttpClient(); late GeneralApi generalApi; @@ -28,20 +28,26 @@ void main() { const path = '/sync'; - final events = - List.generate(3, (index) => Event(type: 'test-event-type-$index')); + final events = List.generate(3, (index) => Event(type: 'test-event-type-$index')); final data = { 'channel_cids': cids, 'last_sync_at': lastSyncAt.toUtc().toIso8601String(), }; - when(() => client.post( - path, - data: data, - )).thenAnswer((_) async => successResponse(path, data: { - 'events': [...events.map((it) => it.toJson())] - })); + when( + () => client.post( + path, + data: data, + ), + ).thenAnswer( + (_) async => successResponse( + path, + data: { + 'events': [...events.map((it) => it.toJson())], + }, + ), + ); final res = await generalApi.sync(cids, lastSyncAt); @@ -204,14 +210,21 @@ void main() { ...pagination.toJson(), }); - when(() => client.get( - path, - queryParameters: { - 'payload': payload, - }, - )).thenAnswer((_) async => successResponse(path, data: { - 'members': [...members.map((it) => it.toJson())] - })); + when( + () => client.get( + path, + queryParameters: { + 'payload': payload, + }, + ), + ).thenAnswer( + (_) async => successResponse( + path, + data: { + 'members': [...members.map((it) => it.toJson())], + }, + ), + ); final res = await generalApi.queryMembers( channelType, @@ -251,14 +264,21 @@ void main() { ...pagination.toJson(), }); - when(() => client.get( - path, - queryParameters: { - 'payload': payload, - }, - )).thenAnswer((_) async => successResponse(path, data: { - 'members': [...members.map((it) => it.toJson())] - })); + when( + () => client.get( + path, + queryParameters: { + 'payload': payload, + }, + ), + ).thenAnswer( + (_) async => successResponse( + path, + data: { + 'members': [...members.map((it) => it.toJson())], + }, + ), + ); final res = await generalApi.queryMembers( channelType, @@ -280,18 +300,24 @@ void main() { test('enrichUrl', () async { const path = '/og'; - const url = - 'https://www.techyourchance.com/finite-state-machine-with-unit-tests-real-world-example'; + const url = 'https://www.techyourchance.com/finite-state-machine-with-unit-tests-real-world-example'; - when(() => client.get( - path, - queryParameters: {'url': url}, - )).thenAnswer((_) async => successResponse(path, data: { + when( + () => client.get( + path, + queryParameters: {'url': url}, + ), + ).thenAnswer( + (_) async => successResponse( + path, + data: { 'type': 'image', 'og_scrape_url': url, 'author_name': 'TechYourChance', 'title': 'Finite State Machine with Unit Tests: Real World Example', - })); + }, + ), + ); final res = await generalApi.enrichUrl(url); diff --git a/packages/stream_chat/test/src/core/api/guest_api_test.dart b/packages/stream_chat/test/src/core/api/guest_api_test.dart index 7db74b4a31..40f83e7bee 100644 --- a/packages/stream_chat/test/src/core/api/guest_api_test.dart +++ b/packages/stream_chat/test/src/core/api/guest_api_test.dart @@ -8,10 +8,10 @@ import '../../mocks.dart'; void main() { Response successResponse(String path, {Object? data}) => Response( - data: data, - requestOptions: RequestOptions(path: path), - statusCode: 200, - ); + data: data, + requestOptions: RequestOptions(path: path), + statusCode: 200, + ); late final client = MockHttpClient(); late GuestApi guestApi; @@ -26,13 +26,20 @@ void main() { const path = '/guest'; - when(() => client.post( - path, - data: {'user': user}, - )).thenAnswer((_) async => successResponse(path, data: { + when( + () => client.post( + path, + data: {'user': user}, + ), + ).thenAnswer( + (_) async => successResponse( + path, + data: { 'access_token': accessToken, 'user': user.toJson(), - })); + }, + ), + ); final res = await guestApi.getGuestUser(user); diff --git a/packages/stream_chat/test/src/core/api/message_api_test.dart b/packages/stream_chat/test/src/core/api/message_api_test.dart index 2fa313f0aa..0cfeacdd83 100644 --- a/packages/stream_chat/test/src/core/api/message_api_test.dart +++ b/packages/stream_chat/test/src/core/api/message_api_test.dart @@ -10,10 +10,10 @@ import '../../mocks.dart'; void main() { Response successResponse(String path, {Object? data}) => Response( - data: data, - requestOptions: RequestOptions(path: path), - statusCode: 200, - ); + data: data, + requestOptions: RequestOptions(path: path), + statusCode: 200, + ); late final client = MockHttpClient(); late MessageApi messageApi; @@ -29,16 +29,23 @@ void main() { const path = '/channels/$channelType/$channelId/message'; - when(() => client.post( - path, - data: { - 'message': message, - 'skip_push': false, - 'skip_enrich_url': false, - }, - )).thenAnswer((_) async => successResponse(path, data: { + when( + () => client.post( + path, + data: { + 'message': message, + 'skip_push': false, + 'skip_enrich_url': false, + }, + ), + ).thenAnswer( + (_) async => successResponse( + path, + data: { 'message': message.toJson(), - })); + }, + ), + ); final res = await messageApi.sendMessage(channelId, channelType, message); @@ -56,16 +63,23 @@ void main() { const path = '/channels/$channelType/$channelId/message'; - when(() => client.post( - path, - data: { - 'message': message, - 'skip_push': true, - 'skip_enrich_url': false, - }, - )).thenAnswer((_) async => successResponse(path, data: { + when( + () => client.post( + path, + data: { + 'message': message, + 'skip_push': true, + 'skip_enrich_url': false, + }, + ), + ).thenAnswer( + (_) async => successResponse( + path, + data: { 'message': message.toJson(), - })); + }, + ), + ); final res = await messageApi.sendMessage( channelId, @@ -93,12 +107,19 @@ void main() { (index) => Message(id: 'test-message-id-$index'), ); - when(() => client.get( - path, - queryParameters: {'ids': messageIds.join(',')}, - )).thenAnswer((_) async => successResponse(path, data: { + when( + () => client.get( + path, + queryParameters: {'ids': messageIds.join(',')}, + ), + ).thenAnswer( + (_) async => successResponse( + path, + data: { 'messages': [...messages.map((it) => it.toJson())], - })); + }, + ), + ); final res = await messageApi.getMessagesById( channelId, @@ -122,8 +143,7 @@ void main() { final message = Message(id: messageId); - when(() => client.get(path)).thenAnswer((_) async => - successResponse(path, data: {'message': message.toJson()})); + when(() => client.get(path)).thenAnswer((_) async => successResponse(path, data: {'message': message.toJson()})); final res = await messageApi.getMessage(messageId); @@ -139,14 +159,16 @@ void main() { final path = '/messages/${message.id}'; - when(() => client.post( - path, - data: { - 'message': message, - 'skip_push': false, - 'skip_enrich_url': false, - }, - )).thenAnswer( + when( + () => client.post( + path, + data: { + 'message': message, + 'skip_push': false, + 'skip_enrich_url': false, + }, + ), + ).thenAnswer( (_) async => successResponse(path, data: {'message': message.toJson()}), ); @@ -168,14 +190,16 @@ void main() { const path = '/messages/$messageId'; final message = Message(id: 'test-message-id', text: set['text']); - when(() => client.put( - path, - data: { - 'set': set, - 'unset': unset, - 'skip_enrich_url': false, - }, - )).thenAnswer( + when( + () => client.put( + path, + data: { + 'set': set, + 'unset': unset, + 'skip_enrich_url': false, + }, + ), + ).thenAnswer( (_) async => successResponse(path, data: {'message': message.toJson()}), ); @@ -190,14 +214,16 @@ void main() { expect(res.message.text, set['text']); expect(res.message.pinExpires, isNull); - verify(() => client.put( - path, - data: { - 'set': set, - 'unset': unset, - 'skip_enrich_url': false, - }, - )).called(1); + verify( + () => client.put( + path, + data: { + 'set': set, + 'unset': unset, + 'skip_enrich_url': false, + }, + ), + ).called(1); verifyNoMoreInteractions(client); }); @@ -205,16 +231,17 @@ void main() { const messageId = 'test-message-id'; const path = '/messages/$messageId'; + const params = {'delete_for_me': true}; - when(() => client.delete(path)).thenAnswer( + when(() => client.delete(path, queryParameters: params)).thenAnswer( (_) async => successResponse(path, data: {}), ); - final res = await messageApi.deleteMessage(messageId); + final res = await messageApi.deleteMessage(messageId, deleteForMe: true); expect(res, isNotNull); - verify(() => client.delete(path)).called(1); + verify(() => client.delete(path, queryParameters: params)).called(1); verifyNoMoreInteractions(client); }); @@ -226,17 +253,17 @@ void main() { const path = '/messages/$messageId/action'; - when(() => client.post( - path, - data: { - 'id': channelId, - 'type': channelType, - 'form_data': formData, - 'message_id': messageId, - }, - )) - .thenAnswer( - (_) async => successResponse(path, data: {})); + when( + () => client.post( + path, + data: { + 'id': channelId, + 'type': channelType, + 'form_data': formData, + 'message_id': messageId, + }, + ), + ).thenAnswer((_) async => successResponse(path, data: {})); final res = await messageApi.sendAction( channelId, @@ -254,31 +281,33 @@ void main() { test('sendReaction', () async { const messageId = 'test-message-id'; const reactionType = 'test-reaction-type'; - const extraData = {'test-key': 'test-data'}; const path = '/messages/$messageId/reaction'; final message = Message(id: messageId); final reaction = Reaction(type: reactionType, messageId: messageId); - when(() => client.post( - path, - data: { - 'reaction': Map.from(extraData) - ..addAll({'type': reactionType}), - 'enforce_unique': false, - }, - )).thenAnswer((_) async => successResponse(path, data: { - 'message': message.toJson(), + when( + () => client.post( + path, + data: jsonEncode({ 'reaction': reaction.toJson(), - })); - - final res = await messageApi.sendReaction( - messageId, - reactionType, - extraData: extraData, + 'skip_push': false, + 'enforce_unique': false, + }), + ), + ).thenAnswer( + (_) async => successResponse( + path, + data: { + 'message': message.toJson(), + 'reaction': {...reaction.toJson(), 'message_id': messageId}, + }, + ), ); + final res = await messageApi.sendReaction(messageId, reaction); + expect(res, isNotNull); expect(res.message.id, messageId); expect(res.reaction.messageId, messageId); @@ -291,29 +320,34 @@ void main() { test('sendReaction with enforceUnique: true', () async { const messageId = 'test-message-id'; const reactionType = 'test-reaction-type'; - const extraData = {'test-key': 'test-data'}; const path = '/messages/$messageId/reaction'; final message = Message(id: messageId); final reaction = Reaction(type: reactionType, messageId: messageId); - when(() => client.post( - path, - data: { - 'reaction': Map.from(extraData) - ..addAll({'type': reactionType}), - 'enforce_unique': true, - }, - )).thenAnswer((_) async => successResponse(path, data: { - 'message': message.toJson(), + when( + () => client.post( + path, + data: jsonEncode({ 'reaction': reaction.toJson(), - })); + 'skip_push': false, + 'enforce_unique': true, + }), + ), + ).thenAnswer( + (_) async => successResponse( + path, + data: { + 'message': message.toJson(), + 'reaction': {...reaction.toJson(), 'message_id': messageId}, + }, + ), + ); final res = await messageApi.sendReaction( messageId, - reactionType, - extraData: extraData, + reaction, enforceUnique: true, ); @@ -332,8 +366,7 @@ void main() { const path = '/messages/$messageId/reaction/$reactionType'; - when(() => client.delete(path)).thenAnswer( - (_) async => successResponse(path, data: {})); + when(() => client.delete(path)).thenAnswer((_) async => successResponse(path, data: {})); final res = await messageApi.deleteReaction(messageId, reactionType); @@ -357,14 +390,25 @@ void main() { ), ); - when(() => client.get( - path, - queryParameters: { - ...const PaginationParams().toJson(), - }, - )).thenAnswer((_) async => successResponse(path, data: { - 'reactions': [...reactions.map((it) => it.toJson())] - })); + when( + () => client.get( + path, + queryParameters: { + ...const PaginationParams().toJson(), + }, + ), + ).thenAnswer( + (_) async => successResponse( + path, + data: { + 'reactions': [ + ...reactions.map( + (it) => {...it.toJson(), 'message_id': messageId}, + ), + ], + }, + ), + ); final res = await messageApi.getReactions(messageId, pagination: options); @@ -393,17 +437,24 @@ void main() { }, ); - when(() => client.post( - path, - data: {'language': language}, - )).thenAnswer((_) async => successResponse(path, data: { + when( + () => client.post( + path, + data: {'language': language}, + ), + ).thenAnswer( + (_) async => successResponse( + path, + data: { 'message': { ...translatedMessage.toJson(), 'i18n': { language: translatedMessageText, }, }, - })); + }, + ), + ); final res = await messageApi.translateMessage(messageId, language); @@ -429,14 +480,21 @@ void main() { ), ); - when(() => client.get( - path, - queryParameters: { - ...options.toJson(), - }, - )).thenAnswer((_) async => successResponse(path, data: { - 'messages': [...messages.map((it) => it.toJson())] - })); + when( + () => client.get( + path, + queryParameters: { + ...options.toJson(), + }, + ), + ).thenAnswer( + (_) async => successResponse( + path, + data: { + 'messages': [...messages.map((it) => it.toJson())], + }, + ), + ); final res = await messageApi.getReplies(parentId, options: options); @@ -466,13 +524,17 @@ void main() { message: draftMessage, ); - when(() => client.post( - path, - data: jsonEncode({'message': draftMessage}), - )).thenAnswer((_) async => successResponse( - path, - data: {'draft': draft.toJson()}, - )); + when( + () => client.post( + path, + data: jsonEncode({'message': draftMessage}), + ), + ).thenAnswer( + (_) async => successResponse( + path, + data: {'draft': draft.toJson()}, + ), + ); final res = await messageApi.createDraft( channelId, @@ -495,11 +557,12 @@ void main() { const path = '/channels/$channelType/$channelId/draft'; - when(() => client.delete(path, queryParameters: {})) - .thenAnswer((_) async => successResponse( - path, - data: {}, - )); + when(() => client.delete(path, queryParameters: {})).thenAnswer( + (_) async => successResponse( + path, + data: {}, + ), + ); final res = await messageApi.deleteDraft( channelId, @@ -519,11 +582,12 @@ void main() { const path = '/channels/$channelType/$channelId/draft'; - when(() => client.delete(path, queryParameters: {'parent_id': parentId})) - .thenAnswer((_) async => successResponse( - path, - data: {}, - )); + when(() => client.delete(path, queryParameters: {'parent_id': parentId})).thenAnswer( + (_) async => successResponse( + path, + data: {}, + ), + ); final res = await messageApi.deleteDraft( channelId, @@ -553,10 +617,14 @@ void main() { message: draftMessage, ); - when(() => client.get(path, queryParameters: {})) - .thenAnswer((_) async => successResponse(path, data: { - 'draft': draft.toJson(), - })); + when(() => client.get(path, queryParameters: {})).thenAnswer( + (_) async => successResponse( + path, + data: { + 'draft': draft.toJson(), + }, + ), + ); final res = await messageApi.getDraft( channelId, @@ -591,12 +659,19 @@ void main() { parentId: parentId, ); - when(() => client.get( - path, - queryParameters: {'parent_id': parentId}, - )).thenAnswer((_) async => successResponse(path, data: { + when( + () => client.get( + path, + queryParameters: {'parent_id': parentId}, + ), + ).thenAnswer( + (_) async => successResponse( + path, + data: { 'draft': draft.toJson(), - })); + }, + ), + ); final res = await messageApi.getDraft( channelId, @@ -614,6 +689,84 @@ void main() { verifyNoMoreInteractions(client); }); + test('queryReactions', () async { + const messageId = 'test-message-id'; + const path = '/messages/$messageId/reactions'; + + final reactions = List.generate( + 3, + (index) => Reaction( + type: 'test-reaction-type-$index', + messageId: messageId, + ), + ); + + when( + () => client.post(path, data: any(named: 'data')), + ).thenAnswer( + (_) async => successResponse( + path, + data: { + 'reactions': [ + ...reactions.map( + (it) => {...it.toJson(), 'message_id': messageId}, + ), + ], + 'next': null, + }, + ), + ); + + final res = await messageApi.queryReactions(messageId); + + expect(res, isNotNull); + expect(res.reactions.length, reactions.length); + expect(res.reactions.every((it) => it.messageId == messageId), isTrue); + expect(res.next, isNull); + + verify(() => client.post(path, data: any(named: 'data'))).called(1); + verifyNoMoreInteractions(client); + }); + + test('queryReactions with next cursor', () async { + const messageId = 'test-message-id'; + const path = '/messages/$messageId/reactions'; + const nextCursor = 'next_page_cursor'; + + final reactions = List.generate( + 3, + (index) => Reaction( + type: 'test-reaction-type-$index', + messageId: messageId, + ), + ); + + when( + () => client.post(path, data: any(named: 'data')), + ).thenAnswer( + (_) async => successResponse( + path, + data: { + 'reactions': [ + ...reactions.map( + (it) => {...it.toJson(), 'message_id': messageId}, + ), + ], + 'next': nextCursor, + }, + ), + ); + + final res = await messageApi.queryReactions(messageId); + + expect(res, isNotNull); + expect(res.reactions.length, reactions.length); + expect(res.next, nextCursor); + + verify(() => client.post(path, data: any(named: 'data'))).called(1); + verifyNoMoreInteractions(client); + }); + test('queryDrafts', () async { const path = '/drafts/query'; @@ -626,22 +779,31 @@ void main() { ), ); - when(() => client.post( - path, - data: any(named: 'data'), - )).thenAnswer((_) async => successResponse(path, data: { + when( + () => client.post( + path, + data: any(named: 'data'), + ), + ).thenAnswer( + (_) async => successResponse( + path, + data: { 'drafts': [...draftMessages.map((it) => it.toJson())], - })); + }, + ), + ); final res = await messageApi.queryDrafts(); expect(res, isNotNull); expect(res.drafts.length, draftMessages.length); - verify(() => client.post( - path, - data: any(named: 'data'), - )).called(1); + verify( + () => client.post( + path, + data: any(named: 'data'), + ), + ).called(1); verifyNoMoreInteractions(client); }); diff --git a/packages/stream_chat/test/src/core/api/moderation_api_test.dart b/packages/stream_chat/test/src/core/api/moderation_api_test.dart index 69d85fd91d..c5c9cfccfc 100644 --- a/packages/stream_chat/test/src/core/api/moderation_api_test.dart +++ b/packages/stream_chat/test/src/core/api/moderation_api_test.dart @@ -7,10 +7,10 @@ import '../../mocks.dart'; void main() { Response successResponse(String path, {Object? data}) => Response( - data: data, - requestOptions: RequestOptions(path: path), - statusCode: 200, - ); + data: data, + requestOptions: RequestOptions(path: path), + statusCode: 200, + ); late final client = MockHttpClient(); late ModerationApi moderationApi; @@ -65,15 +65,15 @@ void main() { const path = '/moderation/mute/channel'; - when(() => client.post( - path, - data: { - 'channel_cid': channelCid, - 'expiration': expiration.inMilliseconds, - }, - )) - .thenAnswer( - (_) async => successResponse(path, data: {})); + when( + () => client.post( + path, + data: { + 'channel_cid': channelCid, + 'expiration': expiration.inMilliseconds, + }, + ), + ).thenAnswer((_) async => successResponse(path, data: {})); final res = await moderationApi.muteChannel( channelCid, @@ -91,12 +91,12 @@ void main() { const path = '/moderation/unmute/channel'; - when(() => client.post( - path, - data: {'channel_cid': channelCid}, - )) - .thenAnswer( - (_) async => successResponse(path, data: {})); + when( + () => client.post( + path, + data: {'channel_cid': channelCid}, + ), + ).thenAnswer((_) async => successResponse(path, data: {})); final res = await moderationApi.unmuteChannel(channelCid); @@ -111,14 +111,14 @@ void main() { const path = '/moderation/flag'; - when(() => client.post( - path, - data: { - 'target_message_id': messageId, - }, - )) - .thenAnswer( - (_) async => successResponse(path, data: {})); + when( + () => client.post( + path, + data: { + 'target_message_id': messageId, + }, + ), + ).thenAnswer((_) async => successResponse(path, data: {})); final res = await moderationApi.flagMessage(messageId); @@ -133,14 +133,14 @@ void main() { const path = '/moderation/unflag'; - when(() => client.post( - path, - data: { - 'target_message_id': messageId, - }, - )) - .thenAnswer( - (_) async => successResponse(path, data: {})); + when( + () => client.post( + path, + data: { + 'target_message_id': messageId, + }, + ), + ).thenAnswer((_) async => successResponse(path, data: {})); final res = await moderationApi.unflagMessage(messageId); @@ -155,11 +155,14 @@ void main() { const path = '/moderation/flag'; - when(() => client.post(path, data: { - 'target_user_id': userId, - })) - .thenAnswer( - (_) async => successResponse(path, data: {})); + when( + () => client.post( + path, + data: { + 'target_user_id': userId, + }, + ), + ).thenAnswer((_) async => successResponse(path, data: {})); final res = await moderationApi.flagUser(userId); @@ -174,14 +177,14 @@ void main() { const path = '/moderation/unflag'; - when(() => client.post( - path, - data: { - 'target_user_id': userId, - }, - )) - .thenAnswer( - (_) async => successResponse(path, data: {})); + when( + () => client.post( + path, + data: { + 'target_user_id': userId, + }, + ), + ).thenAnswer((_) async => successResponse(path, data: {})); final res = await moderationApi.unflagUser(userId); @@ -197,12 +200,15 @@ void main() { const path = '/moderation/ban'; - when(() => client.post(path, data: { - 'target_user_id': targetUserId, - ...options, - })) - .thenAnswer( - (_) async => successResponse(path, data: {})); + when( + () => client.post( + path, + data: { + 'target_user_id': targetUserId, + ...options, + }, + ), + ).thenAnswer((_) async => successResponse(path, data: {})); final res = await moderationApi.banUser(targetUserId, options: options); diff --git a/packages/stream_chat/test/src/core/api/polls_api_test.dart b/packages/stream_chat/test/src/core/api/polls_api_test.dart index 20a3de8d85..2c303218f2 100644 --- a/packages/stream_chat/test/src/core/api/polls_api_test.dart +++ b/packages/stream_chat/test/src/core/api/polls_api_test.dart @@ -15,10 +15,10 @@ import '../../mocks.dart'; void main() { Response successResponse(String path, {Object? data}) => Response( - data: data, - requestOptions: RequestOptions(path: path), - statusCode: 200, - ); + data: data, + requestOptions: RequestOptions(path: path), + statusCode: 200, + ); late final client = MockHttpClient(); late PollsApi pollsApi; @@ -41,12 +41,19 @@ void main() { const path = '/polls'; - when(() => client.post( - path, - data: jsonEncode(poll), - )).thenAnswer((_) async => successResponse(path, data: { + when( + () => client.post( + path, + data: jsonEncode(poll), + ), + ).thenAnswer( + (_) async => successResponse( + path, + data: { 'poll': poll.toJson(), - })); + }, + ), + ); final res = await pollsApi.createPoll(poll); @@ -73,10 +80,14 @@ void main() { ], ); - when(() => client.get(path)) - .thenAnswer((_) async => successResponse(path, data: { - 'poll': poll.toJson(), - })); + when(() => client.get(path)).thenAnswer( + (_) async => successResponse( + path, + data: { + 'poll': poll.toJson(), + }, + ), + ); final res = await pollsApi.getPoll(pollId); @@ -101,12 +112,19 @@ void main() { const path = '/polls'; - when(() => client.put( - path, - data: jsonEncode(poll), - )).thenAnswer((_) async => successResponse(path, data: { + when( + () => client.put( + path, + data: jsonEncode(poll), + ), + ).thenAnswer( + (_) async => successResponse( + path, + data: { 'poll': poll.toJson(), - })); + }, + ), + ); final res = await pollsApi.updatePoll(poll); @@ -134,12 +152,19 @@ void main() { ], ); - when(() => client.patch( - path, - data: jsonEncode({'set': set, 'unset': unset}), - )).thenAnswer((_) async => successResponse(path, data: { + when( + () => client.patch( + path, + data: jsonEncode({'set': set, 'unset': unset}), + ), + ).thenAnswer( + (_) async => successResponse( + path, + data: { 'poll': poll.toJson(), - })); + }, + ), + ); final res = await pollsApi.partialUpdatePoll( pollId, @@ -151,11 +176,15 @@ void main() { expect(res.poll.id, pollId); expect(res.poll.name, set['name']); - verify(() => client.patch(path, + verify( + () => client.patch( + path, data: jsonEncode({ 'set': set, 'unset': unset, - }))).called(1); + }), + ), + ).called(1); verifyNoMoreInteractions(client); }); @@ -164,8 +193,7 @@ void main() { const path = '/polls/$pollId'; - when(() => client.delete(path)).thenAnswer( - (_) async => successResponse(path, data: {})); + when(() => client.delete(path)).thenAnswer((_) async => successResponse(path, data: {})); final res = await pollsApi.deletePoll(pollId); @@ -184,15 +212,22 @@ void main() { const path = '/polls/$pollId/options'; - when(() => client.post( - path, - data: jsonEncode(option), - )).thenAnswer((_) async => successResponse(path, data: { + when( + () => client.post( + path, + data: jsonEncode(option), + ), + ).thenAnswer( + (_) async => successResponse( + path, + data: { 'poll_option': option.toJson() ..addAll({ 'id': option.id, }), - })); + }, + ), + ); final res = await pollsApi.createPollOption(pollId, option); @@ -214,13 +249,17 @@ void main() { text: 'test-option-value', ); - when(() => client.get(path)) - .thenAnswer((_) async => successResponse(path, data: { - 'poll_option': option.toJson() - ..addAll({ - 'id': option.id, - }), - })); + when(() => client.get(path)).thenAnswer( + (_) async => successResponse( + path, + data: { + 'poll_option': option.toJson() + ..addAll({ + 'id': option.id, + }), + }, + ), + ); final res = await pollsApi.getPollOption(pollId, optionId); @@ -240,15 +279,22 @@ void main() { const path = '/polls/$pollId/options'; - when(() => client.put( - path, - data: jsonEncode(option), - )).thenAnswer((_) async => successResponse(path, data: { + when( + () => client.put( + path, + data: jsonEncode(option), + ), + ).thenAnswer( + (_) async => successResponse( + path, + data: { 'poll_option': option.toJson() ..addAll({ 'id': option.id, }), - })); + }, + ), + ); final res = await pollsApi.updatePollOption(pollId, option); @@ -265,8 +311,7 @@ void main() { const path = '/polls/$pollId/options/$optionId'; - when(() => client.delete(path)).thenAnswer( - (_) async => successResponse(path, data: {})); + when(() => client.delete(path)).thenAnswer((_) async => successResponse(path, data: {})); final res = await pollsApi.deletePollOption(pollId, optionId); @@ -286,14 +331,21 @@ void main() { const path = '/messages/$messageId/polls/$pollId/vote'; - when(() => client.post( - path, - data: jsonEncode({ - 'vote': vote, - }), - )).thenAnswer((_) async => successResponse(path, data: { + when( + () => client.post( + path, + data: jsonEncode({ + 'vote': vote, + }), + ), + ).thenAnswer( + (_) async => successResponse( + path, + data: { 'vote': vote.toJson(), - })); + }, + ), + ); final res = await pollsApi.castPollVote(messageId, pollId, vote); @@ -315,10 +367,14 @@ void main() { optionId: 'test-option-id', ); - when(() => client.delete(path)) - .thenAnswer((_) async => successResponse(path, data: { - 'vote': vote.toJson(), - })); + when(() => client.delete(path)).thenAnswer( + (_) async => successResponse( + path, + data: { + 'vote': vote.toJson(), + }, + ), + ); final res = await pollsApi.removePollVote(messageId, pollId, voteId); @@ -359,9 +415,14 @@ void main() { path, data: payload, ), - ).thenAnswer((_) async => successResponse(path, data: { + ).thenAnswer( + (_) async => successResponse( + path, + data: { 'polls': [...polls.map((it) => it.toJson())], - })); + }, + ), + ); final res = await pollsApi.queryPolls( filter: filter, @@ -405,9 +466,14 @@ void main() { path, data: payload, ), - ).thenAnswer((_) async => successResponse(path, data: { + ).thenAnswer( + (_) async => successResponse( + path, + data: { 'votes': [...votes.map((it) => it.toJson())], - })); + }, + ), + ); final res = await pollsApi.queryPollVotes( pollId, diff --git a/packages/stream_chat/test/src/core/api/reminders_api_test.dart b/packages/stream_chat/test/src/core/api/reminders_api_test.dart index e0c3be7061..919bb47661 100644 --- a/packages/stream_chat/test/src/core/api/reminders_api_test.dart +++ b/packages/stream_chat/test/src/core/api/reminders_api_test.dart @@ -15,10 +15,10 @@ import '../../mocks.dart'; void main() { Response successResponse(String path, {Object? data}) => Response( - data: data, - requestOptions: RequestOptions(path: path), - statusCode: 200, - ); + data: data, + requestOptions: RequestOptions(path: path), + statusCode: 200, + ); late final client = MockHttpClient(); late RemindersApi remindersApi; @@ -50,10 +50,14 @@ void main() { ), ]; - when(() => client.post(path, data: jsonEncode({}))) - .thenAnswer((_) async => successResponse(path, data: { - 'reminders': reminders.map((r) => r.toJson()).toList(), - })); + when(() => client.post(path, data: jsonEncode({}))).thenAnswer( + (_) async => successResponse( + path, + data: { + 'reminders': reminders.map((r) => r.toJson()).toList(), + }, + ), + ); final res = await remindersApi.queryReminders(); @@ -89,10 +93,14 @@ void main() { ), ); - when(() => client.post(path, data: expectedPayload)) - .thenAnswer((_) async => successResponse(path, data: { - 'reminders': reminders.map((r) => r.toJson()).toList(), - })); + when(() => client.post(path, data: expectedPayload)).thenAnswer( + (_) async => successResponse( + path, + data: { + 'reminders': reminders.map((r) => r.toJson()).toList(), + }, + ), + ); final res = await remindersApi.queryReminders( filter: filter, @@ -122,10 +130,14 @@ void main() { updatedAt: DateTime(2024, 1, 1), ); - when(() => client.post(path, data: jsonEncode({}))) - .thenAnswer((_) async => successResponse(path, data: { - 'reminder': reminder.toJson(), - })); + when(() => client.post(path, data: jsonEncode({}))).thenAnswer( + (_) async => successResponse( + path, + data: { + 'reminder': reminder.toJson(), + }, + ), + ); final res = await remindersApi.createReminder(messageId); @@ -154,13 +166,16 @@ void main() { 'remind_at': remindAt.toUtc().toIso8601String(), }); - when(() => client.post(path, data: expectedPayload)) - .thenAnswer((_) async => successResponse(path, data: { - 'reminder': reminder.toJson(), - })); + when(() => client.post(path, data: expectedPayload)).thenAnswer( + (_) async => successResponse( + path, + data: { + 'reminder': reminder.toJson(), + }, + ), + ); - final res = - await remindersApi.createReminder(messageId, remindAt: remindAt); + final res = await remindersApi.createReminder(messageId, remindAt: remindAt); expect(res, isNotNull); expect(res.reminder.messageId, messageId); @@ -185,10 +200,14 @@ void main() { updatedAt: DateTime(2024, 1, 2), ); - when(() => client.patch(path, data: jsonEncode({}))) - .thenAnswer((_) async => successResponse(path, data: { - 'reminder': reminder.toJson(), - })); + when(() => client.patch(path, data: jsonEncode({}))).thenAnswer( + (_) async => successResponse( + path, + data: { + 'reminder': reminder.toJson(), + }, + ), + ); final res = await remindersApi.updateReminder(messageId); @@ -217,13 +236,16 @@ void main() { 'remind_at': remindAt.toUtc().toIso8601String(), }); - when(() => client.patch(path, data: expectedPayload)) - .thenAnswer((_) async => successResponse(path, data: { - 'reminder': reminder.toJson(), - })); + when(() => client.patch(path, data: expectedPayload)).thenAnswer( + (_) async => successResponse( + path, + data: { + 'reminder': reminder.toJson(), + }, + ), + ); - final res = - await remindersApi.updateReminder(messageId, remindAt: remindAt); + final res = await remindersApi.updateReminder(messageId, remindAt: remindAt); expect(res, isNotNull); expect(res.reminder.messageId, messageId); @@ -239,8 +261,7 @@ void main() { const messageId = 'test-message-id'; const path = '/messages/$messageId/reminders'; - when(() => client.delete(path)).thenAnswer( - (_) async => successResponse(path, data: {})); + when(() => client.delete(path)).thenAnswer((_) async => successResponse(path, data: {})); final res = await remindersApi.deleteReminder(messageId); diff --git a/packages/stream_chat/test/src/core/api/responses_test.dart b/packages/stream_chat/test/src/core/api/responses_test.dart index 4f9d45c740..644e75b847 100644 --- a/packages/stream_chat/test/src/core/api/responses_test.dart +++ b/packages/stream_chat/test/src/core/api/responses_test.dart @@ -1,6 +1,5 @@ import 'dart:convert'; -import 'package:stream_chat/src/core/models/call_payload.dart'; import 'package:stream_chat/stream_chat.dart'; import 'package:test/test.dart'; @@ -3305,8 +3304,7 @@ void main() { const jsonExample = ''' {"reactions": [{"message_id": "4637f7e4-a06b-42db-ba5a-8d8270dd926f","user_id": "c1c9b454-2bcc-402d-8bb0-2f3706ce1680","user": {"id": "c1c9b454-2bcc-402d-8bb0-2f3706ce1680","role": "user","created_at": "2020-01-28T22:17:30.83015Z","updated_at": "2020-01-28T22:17:31.19435Z","banned": false,"online": false,"image": "https://randomuser.me/api/portraits/women/2.jpg","name": "Mia Denys"},"type": "love","score": 1,"created_at": "2020-01-28T22:17:31.128376Z","updated_at": "2020-01-28T22:17:31.128376Z"}]} '''; - final response = - QueryReactionsResponse.fromJson(json.decode(jsonExample)); + final response = QueryReactionsResponse.fromJson(json.decode(jsonExample)); expect(response.reactions, isA>()); }); @@ -3413,14 +3411,12 @@ void main() { }] } '''; - final response = - SearchMessagesResponse.fromJson(json.decode(jsonExample)); + final response = SearchMessagesResponse.fromJson(json.decode(jsonExample)); expect(response.results, isA>()); }); test('ListDevicesResponse', () { - const jsonExample = - '''{"devices":[{"push_provider":"firebase","id":"test"}],"duration":"0.35ms"}'''; + const jsonExample = '''{"devices":[{"push_provider":"firebase","id":"test"}],"duration":"0.35ms"}'''; final response = ListDevicesResponse.fromJson(json.decode(jsonExample)); expect(response.devices, isA>()); }); @@ -3518,8 +3514,7 @@ void main() { test('ConnectGuestUserResponse', () { const jsonExample = '''{"user":{"id":"guest-ac612aee-25fe-49fb-b1af-969e41f452a0-wild-breeze-7","role":"guest","created_at":"2020-02-03T10:19:01.538434Z","updated_at":"2020-02-03T10:19:01.539543Z","banned":false,"online":false},"access_token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX2lkIjoiZ3Vlc3QtYWM2MTJhZWUtMjVmZS00OWZiLWIxYWYtOTY5ZTQxZjQ1MmEwLXdpbGQtYnJlZXplLTcifQ.mmoFGu7oJjpFsp7nFN78UbIpO7gowbuIbyoppsuvbXA","duration":"4.66ms"}'''; - final response = - ConnectGuestUserResponse.fromJson(json.decode(jsonExample)); + final response = ConnectGuestUserResponse.fromJson(json.decode(jsonExample)); expect(response.user, isA()); expect(response.accessToken, isA()); }); @@ -3551,8 +3546,7 @@ void main() { "updated_at": "2020-01-28T22:17:31.092262Z", "mentioned_users": [] }],"duration":"4.66ms"}'''; - final response = - GetMessagesByIdResponse.fromJson(json.decode(jsonExample)); + final response = GetMessagesByIdResponse.fromJson(json.decode(jsonExample)); expect(response.messages, isA>()); }); @@ -4364,37 +4358,6 @@ void main() { expect(response.message, isA()); }); - test('CallTokenPayload', () { - const jsonExample = ''' - {"duration": "3ms", - "agora_app_id":"test", - "agora_uid": 12, - "token": "token"} - '''; - - // ignore: deprecated_member_use_from_same_package - final response = CallTokenPayload.fromJson(json.decode(jsonExample)); - expect(response.agoraAppId, isA()); - expect(response.agoraUid, isA()); - expect(response.token, isA()); - }, skip: 'Deprecated, Will be removed in the next major version'); - - test('CreateCallPayload', () { - const jsonExample = ''' - {"call": - {"id":"test", - "provider": "test", - "agora": {"channel":"test"}, - "hms":{"room_id":"test", "room_name":"test"} - }} - '''; - - // ignore: deprecated_member_use_from_same_package - final response = CreateCallPayload.fromJson(json.decode(jsonExample)); - // ignore: deprecated_member_use_from_same_package - expect(response.call, isA()); - }, skip: 'Deprecated, Will be removed in the next major version'); - test('UserBlockResponse', () { const jsonExample = ''' { @@ -4506,8 +4469,7 @@ void main() { final channel1Prefs = user1ChannelPrefs['channel1']!; expect(channel1Prefs.chatLevel, ChatLevel.all); - expect( - channel1Prefs.disabledUntil, DateTime.parse('2024-12-31T23:59:59Z')); + expect(channel1Prefs.disabledUntil, DateTime.parse('2024-12-31T23:59:59Z')); final channel2Prefs = user1ChannelPrefs['channel2']!; expect(channel2Prefs.chatLevel, ChatLevel.none); diff --git a/packages/stream_chat/test/src/core/api/sort_order_test.dart b/packages/stream_chat/test/src/core/api/sort_order_test.dart index ac1200621d..6dc5d91473 100644 --- a/packages/stream_chat/test/src/core/api/sort_order_test.dart +++ b/packages/stream_chat/test/src/core/api/sort_order_test.dart @@ -64,13 +64,6 @@ void main() { expect(option.field, 'age'); expect(option.direction, SortOption.DESC); }); - - test('should correctly deserialize from JSON', () { - final json = {'field': 'age', 'direction': 1}; - final option = SortOption.fromJson(json); - expect(option.field, 'age'); - expect(option.direction, SortOption.ASC); - }); }); group('SortOption single field', () { diff --git a/packages/stream_chat/test/src/core/api/user_api_test.dart b/packages/stream_chat/test/src/core/api/user_api_test.dart index 97eda70148..ad61fbc94d 100644 --- a/packages/stream_chat/test/src/core/api/user_api_test.dart +++ b/packages/stream_chat/test/src/core/api/user_api_test.dart @@ -10,10 +10,10 @@ import '../../mocks.dart'; void main() { Response successResponse(String path, {Object? data}) => Response( - data: data, - requestOptions: RequestOptions(path: path), - statusCode: 200, - ); + data: data, + requestOptions: RequestOptions(path: path), + statusCode: 200, + ); late final client = MockHttpClient(); late UserApi userApi; @@ -32,16 +32,26 @@ void main() { final users = List.generate(3, (index) => User(id: 'test-user-id-$index')); - when(() => client.get(path, queryParameters: { + when( + () => client.get( + path, + queryParameters: { 'payload': jsonEncode({ 'presence': presence, 'sort': sort, 'filter_conditions': filter, ...pagination.toJson(), }), - })).thenAnswer((_) async => successResponse(path, data: { - 'users': [...users.map((it) => it.toJson())] - })); + }, + ), + ).thenAnswer( + (_) async => successResponse( + path, + data: { + 'users': [...users.map((it) => it.toJson())], + }, + ), + ); final res = await userApi.queryUsers( presence: presence, @@ -66,13 +76,17 @@ void main() { final updatedUsers = {for (final user in users) user.id: user}; - when(() => client.post(path, data: { + when( + () => client.post( + path, + data: { 'users': updatedUsers, - })).thenAnswer((_) async => successResponse(path, - data: { - 'users': updatedUsers - .map((key, value) => MapEntry(key, value.toJson())) - })); + }, + ), + ).thenAnswer( + (_) async => + successResponse(path, data: {'users': updatedUsers.map((key, value) => MapEntry(key, value.toJson()))}), + ); final res = await userApi.updateUsers(users); @@ -93,16 +107,18 @@ void main() { final updatedUser = {user.id: User(id: user.id, extraData: user.set!)}; - when(() => client.patch(path, data: { - 'users': [user], - })).thenAnswer( - (_) async => successResponse( + when( + () => client.patch( path, data: { - 'users': - updatedUser.map((key, value) => MapEntry(key, value.toJson())) + 'users': [user], }, ), + ).thenAnswer( + (_) async => successResponse( + path, + data: {'users': updatedUser.map((key, value) => MapEntry(key, value.toJson()))}, + ), ); final res = await userApi.partialUpdateUsers([user]); @@ -110,9 +126,14 @@ void main() { expect(res, isNotNull); expect(res.users.length, updatedUser.length); - verify(() => client.patch(path, data: { - 'users': [user] - })).called(1); + verify( + () => client.patch( + path, + data: { + 'users': [user], + }, + ), + ).called(1); verifyNoMoreInteractions(client); }); @@ -121,9 +142,14 @@ void main() { const path = '/users/block'; - when(() => client.post(path, data: { + when( + () => client.post( + path, + data: { 'blocked_user_id': targetUserId, - })).thenAnswer( + }, + ), + ).thenAnswer( (_) async => successResponse( path, data: { @@ -138,9 +164,14 @@ void main() { expect(res, isNotNull); - verify(() => client.post(path, data: { + verify( + () => client.post( + path, + data: { 'blocked_user_id': targetUserId, - })).called(1); + }, + ), + ).called(1); verifyNoMoreInteractions(client); }); @@ -149,9 +180,14 @@ void main() { const path = '/users/unblock'; - when(() => client.post(path, data: { + when( + () => client.post( + path, + data: { 'blocked_user_id': targetUserId, - })).thenAnswer( + }, + ), + ).thenAnswer( (_) async => successResponse( path, data: {}, @@ -162,9 +198,14 @@ void main() { expect(res, isNotNull); - verify(() => client.post(path, data: { + verify( + () => client.post( + path, + data: { 'blocked_user_id': targetUserId, - })).called(1); + }, + ), + ).called(1); verifyNoMoreInteractions(client); }); @@ -187,50 +228,53 @@ void main() { const path = '/unread'; when(() => client.get(path)).thenAnswer( - (_) async => successResponse(path, data: { - 'duration': '5.23ms', - 'total_unread_count': 42, - 'total_unread_threads_count': 8, - 'total_unread_count_by_team': {'team-1': 15, 'team-2': 27}, - 'channels': [ - { - 'channel_id': 'messaging:test-channel-1', - 'unread_count': 5, - 'last_read': '2024-01-15T10:30:00.000Z', - }, - { - 'channel_id': 'messaging:test-channel-2', - 'unread_count': 10, - 'last_read': '2024-01-15T09:15:00.000Z', - }, - ], - 'channel_type': [ - { - 'channel_type': 'messaging', - 'channel_count': 3, - 'unread_count': 25, - }, - { - 'channel_type': 'livestream', - 'channel_count': 1, - 'unread_count': 17, - }, - ], - 'threads': [ - { - 'unread_count': 3, - 'last_read': '2024-01-15T10:30:00.000Z', - 'last_read_message_id': 'message-1', - 'parent_message_id': 'parent-message-1', - }, - { - 'unread_count': 5, - 'last_read': '2024-01-15T09:45:00.000Z', - 'last_read_message_id': 'message-2', - 'parent_message_id': 'parent-message-2', - }, - ], - }), + (_) async => successResponse( + path, + data: { + 'duration': '5.23ms', + 'total_unread_count': 42, + 'total_unread_threads_count': 8, + 'total_unread_count_by_team': {'team-1': 15, 'team-2': 27}, + 'channels': [ + { + 'channel_id': 'messaging:test-channel-1', + 'unread_count': 5, + 'last_read': '2024-01-15T10:30:00.000Z', + }, + { + 'channel_id': 'messaging:test-channel-2', + 'unread_count': 10, + 'last_read': '2024-01-15T09:15:00.000Z', + }, + ], + 'channel_type': [ + { + 'channel_type': 'messaging', + 'channel_count': 3, + 'unread_count': 25, + }, + { + 'channel_type': 'livestream', + 'channel_count': 1, + 'unread_count': 17, + }, + ], + 'threads': [ + { + 'unread_count': 3, + 'last_read': '2024-01-15T10:30:00.000Z', + 'last_read_message_id': 'message-1', + 'parent_message_id': 'parent-message-1', + }, + { + 'unread_count': 5, + 'last_read': '2024-01-15T09:45:00.000Z', + 'last_read_message_id': 'message-2', + 'parent_message_id': 'parent-message-2', + }, + ], + }, + ), ); final res = await userApi.getUnreadCount(); @@ -246,4 +290,80 @@ void main() { verify(() => client.get(path)).called(1); verifyNoMoreInteractions(client); }); + + test('getActiveLiveLocations', () async { + const path = '/users/live_locations'; + + when(() => client.get(path)).thenAnswer( + (_) async => successResponse( + path, + data: {'active_live_locations': []}, + ), + ); + + final res = await userApi.getActiveLiveLocations(); + + expect(res, isNotNull); + + verify(() => client.get(path)).called(1); + verifyNoMoreInteractions(client); + }); + + test('updateLiveLocation', () async { + const path = '/users/live_locations'; + const messageId = 'test-message-id'; + const createdByDeviceId = 'test-device-id'; + final endAt = DateTime.timestamp().add(const Duration(hours: 1)); + const coordinates = LocationCoordinates( + latitude: 40.7128, + longitude: -74.0060, + ); + + when( + () => client.put( + path, + data: json.encode({ + 'message_id': messageId, + 'created_by_device_id': createdByDeviceId, + 'latitude': coordinates.latitude, + 'longitude': coordinates.longitude, + 'end_at': endAt.toIso8601String(), + }), + ), + ).thenAnswer( + (_) async => successResponse( + path, + data: { + 'message_id': messageId, + 'created_by_device_id': createdByDeviceId, + 'latitude': coordinates.latitude, + 'longitude': coordinates.longitude, + 'end_at': endAt.toIso8601String(), + }, + ), + ); + + final res = await userApi.updateLiveLocation( + messageId: messageId, + createdByDeviceId: createdByDeviceId, + location: coordinates, + endAt: endAt, + ); + + expect(res, isNotNull); + + verify( + () => client.put( + path, + data: json.encode({ + 'message_id': messageId, + 'created_by_device_id': createdByDeviceId, + 'latitude': coordinates.latitude, + 'longitude': coordinates.longitude, + 'end_at': endAt.toIso8601String(), + }), + ), + ).called(1); + verifyNoMoreInteractions(client); + }); } diff --git a/packages/stream_chat/test/src/core/error/stream_chat_error_test.dart b/packages/stream_chat/test/src/core/error/stream_chat_error_test.dart index c95afd3cb4..4bba3b898d 100644 --- a/packages/stream_chat/test/src/core/error/stream_chat_error_test.dart +++ b/packages/stream_chat/test/src/core/error/stream_chat_error_test.dart @@ -92,7 +92,8 @@ void main() { const statusCode = 666; const message = 'test-error-message'; final options = RequestOptions(path: 'test-path'); - const data = ''' + const data = + ''' { "code": $code, "StatusCode": $statusCode, diff --git a/packages/stream_chat/test/src/core/http/adapter/custom_adapter_test.dart b/packages/stream_chat/test/src/core/http/adapter/custom_adapter_test.dart index 75c6bb76e5..ce334d3e92 100644 --- a/packages/stream_chat/test/src/core/http/adapter/custom_adapter_test.dart +++ b/packages/stream_chat/test/src/core/http/adapter/custom_adapter_test.dart @@ -15,11 +15,12 @@ void main() { final mockAdapter = MockHttpClientAdapter(); final httpClient = StreamHttpClient(apiKey, httpClientAdapter: mockAdapter); - when(() => mockAdapter.fetch(any(), any(), any())) - .thenAnswer((_) async => ResponseBody( - Stream.value(Uint8List(0)), - 200, - )); + when(() => mockAdapter.fetch(any(), any(), any())).thenAnswer( + (_) async => ResponseBody( + Stream.value(Uint8List(0)), + 200, + ), + ); await httpClient.get('/'); diff --git a/packages/stream_chat/test/src/core/http/interceptor/auth_interceptor_test.dart b/packages/stream_chat/test/src/core/http/interceptor/auth_interceptor_test.dart index 269e7c884d..cc92b01a8f 100644 --- a/packages/stream_chat/test/src/core/http/interceptor/auth_interceptor_test.dart +++ b/packages/stream_chat/test/src/core/http/interceptor/auth_interceptor_test.dart @@ -35,8 +35,7 @@ void main() { expect(queryParams.containsKey('user_id'), isFalse); final token = Token.development('test-user-id'); - when(() => tokenManager.loadToken(refresh: any(named: 'refresh'))) - .thenAnswer((_) async => token); + when(() => tokenManager.loadToken(refresh: any(named: 'refresh'))).thenAnswer((_) async => token); authInterceptor.onRequest(options, handler); @@ -51,8 +50,7 @@ void main() { expect(updatedQueryParams.containsKey('user_id'), isTrue); expect(updatedQueryParams['user_id'], token.userId); - verify(() => tokenManager.loadToken(refresh: any(named: 'refresh'))) - .called(1); + verify(() => tokenManager.loadToken(refresh: any(named: 'refresh'))).called(1); verifyNoMoreInteractions(tokenManager); }, ); @@ -95,13 +93,14 @@ void main() { when(() => tokenManager.isStatic).thenReturn(false); final token = Token.development('test-user-id'); - when(() => tokenManager.loadToken(refresh: true)) - .thenAnswer((_) async => token); + when(() => tokenManager.loadToken(refresh: true)).thenAnswer((_) async => token); - when(() => client.fetch(options)).thenAnswer((_) async => Response( - requestOptions: options, - statusCode: 200, - )); + when(() => client.fetch(options)).thenAnswer( + (_) async => Response( + requestOptions: options, + statusCode: 200, + ), + ); authInterceptor.onError(err, handler); @@ -142,8 +141,7 @@ void main() { when(() => tokenManager.isStatic).thenReturn(false); final token = Token.development('test-user-id'); - when(() => tokenManager.loadToken(refresh: true)) - .thenAnswer((_) async => token); + when(() => tokenManager.loadToken(refresh: true)).thenAnswer((_) async => token); when(() => client.fetch(options)).thenThrow(err); diff --git a/packages/stream_chat/test/src/core/http/stream_http_client_test.dart b/packages/stream_chat/test/src/core/http/stream_http_client_test.dart index 2ddc84e3b4..936af1670b 100644 --- a/packages/stream_chat/test/src/core/http/stream_http_client_test.dart +++ b/packages/stream_chat/test/src/core/http/stream_http_client_test.dart @@ -17,9 +17,9 @@ import '../../mocks.dart'; void main() { Response successResponse(String path) => Response( - requestOptions: RequestOptions(path: path), - statusCode: 200, - ); + requestOptions: RequestOptions(path: path), + statusCode: 200, + ); DioException throwableError( String path, { @@ -53,19 +53,14 @@ void main() { const apiKey = 'api-key'; final client = StreamHttpClient(apiKey); - expect( - client.httpClient.interceptors - .whereType() - .length, - 1); + expect(client.httpClient.interceptors.whereType().length, 1); }); test('AuthInterceptor should be added if tokenManager is provided', () { const apiKey = 'api-key'; final client = StreamHttpClient(apiKey, tokenManager: TokenManager()); - expect( - client.httpClient.interceptors.whereType().length, 1); + expect(client.httpClient.interceptors.whereType().length, 1); }); test( @@ -78,9 +73,7 @@ void main() { ); expect( - client.httpClient.interceptors - .whereType() - .length, + client.httpClient.interceptors.whereType().length, 1, ); }, @@ -175,10 +168,12 @@ void main() { final client = StreamHttpClient('api-key', dio: dio); const path = 'test-get-api-path'; - when(() => dio.get( - path, - options: any(named: 'options'), - )).thenAnswer((_) async => successResponse(path)); + when( + () => dio.get( + path, + options: any(named: 'options'), + ), + ).thenAnswer((_) async => successResponse(path)); final res = await client.get(path); @@ -186,10 +181,12 @@ void main() { expect(res.statusCode, 200); expect(res.requestOptions.path, path); - verify(() => dio.get( - path, - options: any(named: 'options'), - )).called(1); + verify( + () => dio.get( + path, + options: any(named: 'options'), + ), + ).called(1); verifyNoMoreInteractions(dio); }); @@ -202,10 +199,12 @@ void main() { path, error: StreamChatNetworkError(ChatErrorCode.internalSystemError), ); - when(() => dio.get( - path, - options: any(named: 'options'), - )).thenThrow(error); + when( + () => dio.get( + path, + options: any(named: 'options'), + ), + ).thenThrow(error); try { await client.get(path); @@ -214,10 +213,12 @@ void main() { expect(e, StreamChatNetworkError.fromDioException(error)); } - verify(() => dio.get( - path, - options: any(named: 'options'), - )).called(1); + verify( + () => dio.get( + path, + options: any(named: 'options'), + ), + ).called(1); verifyNoMoreInteractions(dio); }); @@ -226,10 +227,12 @@ void main() { final client = StreamHttpClient('api-key', dio: dio); const path = 'test-post-api-path'; - when(() => dio.post( - path, - options: any(named: 'options'), - )).thenAnswer((_) async => successResponse(path)); + when( + () => dio.post( + path, + options: any(named: 'options'), + ), + ).thenAnswer((_) async => successResponse(path)); final res = await client.post(path); @@ -237,10 +240,12 @@ void main() { expect(res.statusCode, 200); expect(res.requestOptions.path, path); - verify(() => dio.post( - path, - options: any(named: 'options'), - )).called(1); + verify( + () => dio.post( + path, + options: any(named: 'options'), + ), + ).called(1); verifyNoMoreInteractions(dio); }); @@ -255,10 +260,12 @@ void main() { path, error: StreamChatNetworkError(ChatErrorCode.internalSystemError), ); - when(() => dio.post( - path, - options: any(named: 'options'), - )).thenThrow(error); + when( + () => dio.post( + path, + options: any(named: 'options'), + ), + ).thenThrow(error); try { await client.post(path); @@ -267,10 +274,12 @@ void main() { expect(e, StreamChatNetworkError.fromDioException(error)); } - verify(() => dio.post( - path, - options: any(named: 'options'), - )).called(1); + verify( + () => dio.post( + path, + options: any(named: 'options'), + ), + ).called(1); verifyNoMoreInteractions(dio); }, ); @@ -280,10 +289,12 @@ void main() { final client = StreamHttpClient('api-key', dio: dio); const path = 'test-delete-api-path'; - when(() => dio.delete( - path, - options: any(named: 'options'), - )).thenAnswer((_) async => successResponse(path)); + when( + () => dio.delete( + path, + options: any(named: 'options'), + ), + ).thenAnswer((_) async => successResponse(path)); final res = await client.delete(path); @@ -291,10 +302,12 @@ void main() { expect(res.statusCode, 200); expect(res.requestOptions.path, path); - verify(() => dio.delete( - path, - options: any(named: 'options'), - )).called(1); + verify( + () => dio.delete( + path, + options: any(named: 'options'), + ), + ).called(1); verifyNoMoreInteractions(dio); }); @@ -309,10 +322,12 @@ void main() { path, error: StreamChatNetworkError(ChatErrorCode.internalSystemError), ); - when(() => dio.delete( - path, - options: any(named: 'options'), - )).thenThrow(error); + when( + () => dio.delete( + path, + options: any(named: 'options'), + ), + ).thenThrow(error); try { await client.delete(path); @@ -321,10 +336,12 @@ void main() { expect(e, StreamChatNetworkError.fromDioException(error)); } - verify(() => dio.delete( - path, - options: any(named: 'options'), - )).called(1); + verify( + () => dio.delete( + path, + options: any(named: 'options'), + ), + ).called(1); verifyNoMoreInteractions(dio); }, ); @@ -334,10 +351,12 @@ void main() { final client = StreamHttpClient('api-key', dio: dio); const path = 'test-patch-api-path'; - when(() => dio.patch( - path, - options: any(named: 'options'), - )).thenAnswer((_) async => successResponse(path)); + when( + () => dio.patch( + path, + options: any(named: 'options'), + ), + ).thenAnswer((_) async => successResponse(path)); final res = await client.patch(path); @@ -345,10 +364,12 @@ void main() { expect(res.statusCode, 200); expect(res.requestOptions.path, path); - verify(() => dio.patch( - path, - options: any(named: 'options'), - )).called(1); + verify( + () => dio.patch( + path, + options: any(named: 'options'), + ), + ).called(1); verifyNoMoreInteractions(dio); }); @@ -363,10 +384,12 @@ void main() { path, error: StreamChatNetworkError(ChatErrorCode.internalSystemError), ); - when(() => dio.patch( - path, - options: any(named: 'options'), - )).thenThrow(error); + when( + () => dio.patch( + path, + options: any(named: 'options'), + ), + ).thenThrow(error); try { await client.patch(path); @@ -375,10 +398,12 @@ void main() { expect(e, StreamChatNetworkError.fromDioException(error)); } - verify(() => dio.patch( - path, - options: any(named: 'options'), - )).called(1); + verify( + () => dio.patch( + path, + options: any(named: 'options'), + ), + ).called(1); verifyNoMoreInteractions(dio); }, ); @@ -388,10 +413,12 @@ void main() { final client = StreamHttpClient('api-key', dio: dio); const path = 'test-put-api-path'; - when(() => dio.put( - path, - options: any(named: 'options'), - )).thenAnswer((_) async => successResponse(path)); + when( + () => dio.put( + path, + options: any(named: 'options'), + ), + ).thenAnswer((_) async => successResponse(path)); final res = await client.put(path); @@ -399,10 +426,12 @@ void main() { expect(res.statusCode, 200); expect(res.requestOptions.path, path); - verify(() => dio.put( - path, - options: any(named: 'options'), - )).called(1); + verify( + () => dio.put( + path, + options: any(named: 'options'), + ), + ).called(1); verifyNoMoreInteractions(dio); }); @@ -417,10 +446,12 @@ void main() { path, error: StreamChatNetworkError(ChatErrorCode.internalSystemError), ); - when(() => dio.put( - path, - options: any(named: 'options'), - )).thenThrow(error); + when( + () => dio.put( + path, + options: any(named: 'options'), + ), + ).thenThrow(error); try { await client.put(path); @@ -429,10 +460,12 @@ void main() { expect(e, StreamChatNetworkError.fromDioException(error)); } - verify(() => dio.put( - path, - options: any(named: 'options'), - )).called(1); + verify( + () => dio.put( + path, + options: any(named: 'options'), + ), + ).called(1); verifyNoMoreInteractions(dio); }, ); @@ -444,11 +477,13 @@ void main() { const path = 'test-delete-api-path'; final file = MultipartFile.fromBytes([]); - when(() => dio.post( - path, - data: any(named: 'data'), - options: any(named: 'options'), - )).thenAnswer((_) async => successResponse(path)); + when( + () => dio.post( + path, + data: any(named: 'data'), + options: any(named: 'options'), + ), + ).thenAnswer((_) async => successResponse(path)); final res = await client.postFile(path, file); @@ -456,11 +491,13 @@ void main() { expect(res.statusCode, 200); expect(res.requestOptions.path, path); - verify(() => dio.post( - path, - data: any(named: 'data'), - options: any(named: 'options'), - )).called(1); + verify( + () => dio.post( + path, + data: any(named: 'data'), + options: any(named: 'options'), + ), + ).called(1); verifyNoMoreInteractions(dio); }); @@ -477,11 +514,13 @@ void main() { path, error: StreamChatNetworkError(ChatErrorCode.internalSystemError), ); - when(() => dio.post( - path, - data: any(named: 'data'), - options: any(named: 'options'), - )).thenThrow(error); + when( + () => dio.post( + path, + data: any(named: 'data'), + options: any(named: 'options'), + ), + ).thenThrow(error); try { await client.postFile(path, file); @@ -490,11 +529,13 @@ void main() { expect(e, StreamChatNetworkError.fromDioException(error)); } - verify(() => dio.post( - path, - data: any(named: 'data'), - options: any(named: 'options'), - )).called(1); + verify( + () => dio.post( + path, + data: any(named: 'data'), + options: any(named: 'options'), + ), + ).called(1); verifyNoMoreInteractions(dio); }, ); @@ -504,10 +545,12 @@ void main() { final client = StreamHttpClient('api-key', dio: dio); const path = 'test-request-api-path'; - when(() => dio.request( - path, - options: any(named: 'options'), - )).thenAnswer((_) async => successResponse(path)); + when( + () => dio.request( + path, + options: any(named: 'options'), + ), + ).thenAnswer((_) async => successResponse(path)); final res = await client.request(path); @@ -515,10 +558,12 @@ void main() { expect(res.statusCode, 200); expect(res.requestOptions.path, path); - verify(() => dio.request( - path, - options: any(named: 'options'), - )).called(1); + verify( + () => dio.request( + path, + options: any(named: 'options'), + ), + ).called(1); verifyNoMoreInteractions(dio); }); @@ -534,10 +579,12 @@ void main() { streamChatDioError: true, error: StreamChatNetworkError(ChatErrorCode.internalSystemError), ); - when(() => dio.request( - path, - options: any(named: 'options'), - )).thenThrow(error); + when( + () => dio.request( + path, + options: any(named: 'options'), + ), + ).thenThrow(error); try { await client.request(path); @@ -546,10 +593,12 @@ void main() { expect(e, error.error); } - verify(() => dio.request( - path, - options: any(named: 'options'), - )).called(1); + verify( + () => dio.request( + path, + options: any(named: 'options'), + ), + ).called(1); verifyNoMoreInteractions(dio); }, ); diff --git a/packages/stream_chat/test/src/core/http/token_manager_test.dart b/packages/stream_chat/test/src/core/http/token_manager_test.dart index 163bafffe2..626fd676b4 100644 --- a/packages/stream_chat/test/src/core/http/token_manager_test.dart +++ b/packages/stream_chat/test/src/core/http/token_manager_test.dart @@ -32,8 +32,7 @@ void main() { expect(tokenManager.userId, isNull); const userId = 'test-user-id'; - Future tokenProvider(String userId) async => - Token.development(userId).rawValue; + Future tokenProvider(String userId) async => Token.development(userId).rawValue; final returnedToken = await tokenManager.setTokenOrProvider( userId, provider: tokenProvider, @@ -65,8 +64,7 @@ void main() { const userId = 'test-user-id'; final token = Token.development(userId); - Future tokenProvider(String userId) async => - Token.development(userId).rawValue; + Future tokenProvider(String userId) async => Token.development(userId).rawValue; try { await tokenManager.setTokenOrProvider( userId, diff --git a/packages/stream_chat/test/src/core/http/token_test.dart b/packages/stream_chat/test/src/core/http/token_test.dart index 0f0e04253e..543ffda6ba 100644 --- a/packages/stream_chat/test/src/core/http/token_test.dart +++ b/packages/stream_chat/test/src/core/http/token_test.dart @@ -43,8 +43,7 @@ void main() { '`.guest` should create a guest-token with provided user and provider', () async { final user = User(id: 'test-user-id'); - Future provider(User user) async => - Token.development(user.id).rawValue; + Future provider(User user) async => Token.development(user.id).rawValue; final token = await Token.guest(user, provider); expect(token, isNotNull); diff --git a/packages/stream_chat/test/src/core/models/attachment_file_test.dart b/packages/stream_chat/test/src/core/models/attachment_file_test.dart index 6dce83f90e..440f1588ab 100644 --- a/packages/stream_chat/test/src/core/models/attachment_file_test.dart +++ b/packages/stream_chat/test/src/core/models/attachment_file_test.dart @@ -8,8 +8,7 @@ import '../../utils.dart'; void main() { group('src/models/attachment_file', () { test('should parse json correctly', () { - final attachment = - AttachmentFile.fromJson(jsonFixture('attachment_file.json')); + final attachment = AttachmentFile.fromJson(jsonFixture('attachment_file.json')); expect(attachment.name, 'test.jpg'); expect(attachment.size, 12); expect( diff --git a/packages/stream_chat/test/src/core/models/attachment_giphy_info_test.dart b/packages/stream_chat/test/src/core/models/attachment_giphy_info_test.dart index d135c217d7..7d044ea81e 100644 --- a/packages/stream_chat/test/src/core/models/attachment_giphy_info_test.dart +++ b/packages/stream_chat/test/src/core/models/attachment_giphy_info_test.dart @@ -17,15 +17,17 @@ void main() { group('GiphyInfoX', () { test('giphyInfo returns valid GiphyInfo object when data is valid', () { - final attachment = Attachment(extraData: const { - 'giphy': { - 'original': { - 'url': 'https://example.com/original.gif', - 'width': '200', - 'height': '150', - } - } - }); + final attachment = Attachment( + extraData: const { + 'giphy': { + 'original': { + 'url': 'https://example.com/original.gif', + 'width': '200', + 'height': '150', + }, + }, + }, + ); final giphyInfo = attachment.giphyInfo(GiphyInfoType.original); @@ -43,17 +45,18 @@ void main() { expect(giphyInfo, isNull); }); - test('giphyInfo returns null when the specific GiphyInfoType is missing', - () { - final attachment = Attachment(extraData: const { - 'giphy': { - 'fixed_height': { - 'url': 'https://example.com/fixed_height.gif', - 'width': '100', - 'height': '100', - } - } - }); + test('giphyInfo returns null when the specific GiphyInfoType is missing', () { + final attachment = Attachment( + extraData: const { + 'giphy': { + 'fixed_height': { + 'url': 'https://example.com/fixed_height.gif', + 'width': '100', + 'height': '100', + }, + }, + }, + ); final giphyInfo = attachment.giphyInfo(GiphyInfoType.original); diff --git a/packages/stream_chat/test/src/core/models/attachment_test.dart b/packages/stream_chat/test/src/core/models/attachment_test.dart index f702956760..4417ef1173 100644 --- a/packages/stream_chat/test/src/core/models/attachment_test.dart +++ b/packages/stream_chat/test/src/core/models/attachment_test.dart @@ -29,8 +29,7 @@ void main() { final channel = Attachment( type: 'giphy', title: 'soo', - titleLink: - 'https://giphy.com/gifs/nrkp3-dance-happy-3o7TKnCdBx5cMg0qti', + titleLink: 'https://giphy.com/gifs/nrkp3-dance-happy-3o7TKnCdBx5cMg0qti', ); expect( @@ -38,8 +37,7 @@ void main() { { 'type': 'giphy', 'title': 'soo', - 'title_link': - 'https://giphy.com/gifs/nrkp3-dance-happy-3o7TKnCdBx5cMg0qti', + 'title_link': 'https://giphy.com/gifs/nrkp3-dance-happy-3o7TKnCdBx5cMg0qti', 'actions': [], }, ); @@ -51,17 +49,12 @@ void main() { expect(attachment.fileSize, 3); expect(attachment.mimeType, 'text/plain'); - expect(attachment.toJson(), { - 'title': 'myfile.txt', - 'actions': [], - 'file_size': 3, - 'mime_type': 'text/plain' - }); + expect(attachment.toJson(), {'title': 'myfile.txt', 'actions': [], 'file_size': 3, 'mime_type': 'text/plain'}); expect(Attachment.fromJson(attachment.toJson()).toJson(), { 'title': 'myfile.txt', 'actions': [], 'file_size': 3, - 'mime_type': 'text/plain' + 'mime_type': 'text/plain', }); // Setting the size and mimeType using extraData should work fine @@ -88,10 +81,13 @@ void main() { // if file is available, should override size and mimeType. final fileThree = AttachmentFile(size: 9, path: 'myfolder/fileThree.png'); - newAttachment = attachment.copyWith(file: fileThree, extraData: { - 'file_size': 88, - 'mime_type': 'application/pdf', - }); + newAttachment = attachment.copyWith( + file: fileThree, + extraData: { + 'file_size': 88, + 'mime_type': 'application/pdf', + }, + ); expect(newAttachment.extraData['file_size'], 9); expect(newAttachment.extraData['mime_type'], 'image/png'); diff --git a/packages/stream_chat/test/src/core/models/call_payload_test.dart b/packages/stream_chat/test/src/core/models/call_payload_test.dart deleted file mode 100644 index c369122e91..0000000000 --- a/packages/stream_chat/test/src/core/models/call_payload_test.dart +++ /dev/null @@ -1,39 +0,0 @@ -import 'dart:convert'; - -import 'package:stream_chat/src/core/models/call_payload.dart'; -import 'package:test/test.dart'; - -@Deprecated('Will be removed in the next major version') -void main() { - test('CallPayload', () { - const jsonExample = ''' - {"id":"test", - "provider": "test", - "agora": {"channel":"test"}, - "hms":{"room_id":"test", "room_name":"test"} - } - '''; - final response = CallPayload.fromJson(json.decode(jsonExample)); - expect(response.agora, isA()); - expect(response.hms, isA()); - expect(response.id, isA()); - expect(response.provider, isA()); - }); - - test('AgoraPayload', () { - const jsonExample = ''' - {"channel":"test"} - '''; - final response = AgoraPayload.fromJson(json.decode(jsonExample)); - expect(response.channel, isA()); - }); - - test('HMSPayload', () { - const jsonExample = ''' - {"room_id":"test", "room_name":"test"} - '''; - final response = HMSPayload.fromJson(json.decode(jsonExample)); - expect(response.roomId, isA()); - expect(response.roomName, isA()); - }); -} diff --git a/packages/stream_chat/test/src/core/models/channel_state_test.dart b/packages/stream_chat/test/src/core/models/channel_state_test.dart index e822e0ed1c..70870104ed 100644 --- a/packages/stream_chat/test/src/core/models/channel_state_test.dart +++ b/packages/stream_chat/test/src/core/models/channel_state_test.dart @@ -6,8 +6,7 @@ import '../../utils.dart'; void main() { group('src/models/channel_state', () { test('should parse json correctly', () { - final channelState = - ChannelState.fromJson(jsonFixture('channel_state.json')); + final channelState = ChannelState.fromJson(jsonFixture('channel_state.json')); expect(channelState.channel?.cid, 'team:dev'); expect(channelState.channel?.id, 'dev'); expect(channelState.channel?.team, 'test'); @@ -16,12 +15,9 @@ void main() { expect(channelState.channel?.config, isNotNull); expect(channelState.channel?.config.commands, hasLength(1)); expect(channelState.channel?.config.commands[0], isA()); - expect(channelState.channel?.lastMessageAt, - DateTime.parse('2020-01-30T13:43:41.062362Z')); - expect(channelState.channel?.createdAt, - DateTime.parse('2019-04-03T18:43:33.213373Z')); - expect(channelState.channel?.updatedAt, - DateTime.parse('2019-04-03T18:43:33.213374Z')); + expect(channelState.channel?.lastMessageAt, DateTime.parse('2020-01-30T13:43:41.062362Z')); + expect(channelState.channel?.createdAt, DateTime.parse('2019-04-03T18:43:33.213373Z')); + expect(channelState.channel?.updatedAt, DateTime.parse('2019-04-03T18:43:33.213374Z')); expect(channelState.channel?.createdBy, isA()); expect(channelState.channel?.frozen, true); expect(channelState.channel?.extraData['example'], 1); @@ -63,6 +59,7 @@ void main() { chatLevel: ChatLevel.all, disabledUntil: DateTime.parse('2020-01-30T13:43:41.062362Z'), ), + activeLiveLocations: [], ); expect( @@ -146,8 +143,7 @@ void main() { memberCount: 42, ); - final field = - channelState.getComparableField(ChannelSortKey.memberCount); + final field = channelState.getComparableField(ChannelSortKey.memberCount); expect(field, isNotNull); expect(field!.value, equals(42)); }); diff --git a/packages/stream_chat/test/src/core/models/draft_message_test.dart b/packages/stream_chat/test/src/core/models/draft_message_test.dart index 6542e8b4df..0557c58759 100644 --- a/packages/stream_chat/test/src/core/models/draft_message_test.dart +++ b/packages/stream_chat/test/src/core/models/draft_message_test.dart @@ -35,8 +35,7 @@ void main() { expect(draftMessage.extraData, isEmpty); }); - test('should create a valid instance with UUID when id is not provided', - () { + test('should create a valid instance with UUID when id is not provided', () { final messageWithoutId = DraftMessage(text: text); expect(messageWithoutId.id, isNotNull); expect(messageWithoutId.id, isNotEmpty); @@ -156,8 +155,7 @@ void main() { expect(json['poll_id'], equals(pollId)); }); - test('should append command to text field in toJson when command exists', - () { + test('should append command to text field in toJson when command exists', () { final draftWithCommand = DraftMessage( id: id, text: 'Hello world', @@ -244,8 +242,7 @@ void main() { expect(deserializedMessage.id, equals(id)); expect(deserializedMessage.text, equals(text)); - expect(deserializedMessage.extraData['custom_field'], - equals('custom_value')); + expect(deserializedMessage.extraData['custom_field'], equals('custom_value')); expect(deserializedMessage.extraData['priority'], equals(5)); }); diff --git a/packages/stream_chat/test/src/core/models/event_test.dart b/packages/stream_chat/test/src/core/models/event_test.dart index f7847c4c60..82598566e3 100644 --- a/packages/stream_chat/test/src/core/models/event_test.dart +++ b/packages/stream_chat/test/src/core/models/event_test.dart @@ -94,7 +94,7 @@ void main() { 'total_unread_count': 0, 'unread_channels': 0, 'unread_threads': 0, - 'blocked_user_ids': [] + 'blocked_user_ids': [], }, 'user': {'id': 'id', 'teams': [], 'online': false, 'banned': false}, 'total_unread_count': 1, @@ -121,7 +121,7 @@ void main() { 'mentioned_users': [], 'silent': false, }, - } + }, }, ); diff --git a/packages/stream_chat/test/src/core/models/location_test.dart b/packages/stream_chat/test/src/core/models/location_test.dart new file mode 100644 index 0000000000..9b375e2643 --- /dev/null +++ b/packages/stream_chat/test/src/core/models/location_test.dart @@ -0,0 +1,229 @@ +import 'package:stream_chat/src/core/models/channel_model.dart'; +import 'package:stream_chat/src/core/models/location.dart'; +import 'package:stream_chat/src/core/models/location_coordinates.dart'; +import 'package:stream_chat/src/core/models/message.dart'; +import 'package:test/test.dart'; + +void main() { + group('Location', () { + const latitude = 37.7749; + const longitude = -122.4194; + const createdByDeviceId = 'device_123'; + + final location = Location( + latitude: latitude, + longitude: longitude, + createdByDeviceId: createdByDeviceId, + ); + + test('should create a valid instance with minimal parameters', () { + expect(location.latitude, equals(latitude)); + expect(location.longitude, equals(longitude)); + expect(location.createdByDeviceId, equals(createdByDeviceId)); + expect(location.endAt, isNull); + expect(location.channelCid, isNull); + expect(location.channel, isNull); + expect(location.messageId, isNull); + expect(location.message, isNull); + expect(location.userId, isNull); + expect(location.createdAt, isA()); + expect(location.updatedAt, isA()); + }); + + test('should create a valid instance with all parameters', () { + final createdAt = DateTime.parse('2023-01-01T00:00:00.000Z'); + final updatedAt = DateTime.parse('2023-01-01T01:00:00.000Z'); + final endAt = DateTime.parse('2024-12-31T23:59:59.999Z'); + final channel = ChannelModel( + cid: 'test:channel', + id: 'channel', + type: 'test', + createdAt: createdAt, + updatedAt: updatedAt, + ); + final message = Message( + id: 'message_123', + text: 'Test message', + createdAt: createdAt, + updatedAt: updatedAt, + ); + + final fullLocation = Location( + channelCid: 'test:channel', + channel: channel, + messageId: 'message_123', + message: message, + userId: 'user_123', + latitude: latitude, + longitude: longitude, + createdByDeviceId: createdByDeviceId, + endAt: endAt, + createdAt: createdAt, + updatedAt: updatedAt, + ); + + expect(fullLocation.channelCid, equals('test:channel')); + expect(fullLocation.channel, equals(channel)); + expect(fullLocation.messageId, equals('message_123')); + expect(fullLocation.message, equals(message)); + expect(fullLocation.userId, equals('user_123')); + expect(fullLocation.latitude, equals(latitude)); + expect(fullLocation.longitude, equals(longitude)); + expect(fullLocation.createdByDeviceId, equals(createdByDeviceId)); + expect(fullLocation.endAt, equals(endAt)); + expect(fullLocation.createdAt, equals(createdAt)); + expect(fullLocation.updatedAt, equals(updatedAt)); + }); + + test('should correctly serialize to JSON', () { + final json = location.toJson(); + + expect(json['latitude'], equals(latitude)); + expect(json['longitude'], equals(longitude)); + expect(json['created_by_device_id'], equals(createdByDeviceId)); + expect(json['end_at'], isNull); + expect(json.containsKey('channel_cid'), isFalse); + expect(json.containsKey('channel'), isFalse); + expect(json.containsKey('message_id'), isFalse); + expect(json.containsKey('message'), isFalse); + expect(json.containsKey('user_id'), isFalse); + expect(json.containsKey('created_at'), isFalse); + expect(json.containsKey('updated_at'), isFalse); + }); + + test('should serialize live location with endAt correctly', () { + final endAt = DateTime.parse('2024-12-31T23:59:59.999Z'); + final liveLocation = Location( + latitude: latitude, + longitude: longitude, + createdByDeviceId: createdByDeviceId, + endAt: endAt, + ); + + final json = liveLocation.toJson(); + + expect(json['latitude'], equals(latitude)); + expect(json['longitude'], equals(longitude)); + expect(json['created_by_device_id'], equals(createdByDeviceId)); + expect(json['end_at'], equals('2024-12-31T23:59:59.999Z')); + }); + + test( + 'should convert endAt to UTC in toJson regardless of input timezone', + () { + // Create a non-UTC DateTime (local time) + final localEndAt = DateTime(2024, 10, 16, 17, 12, 30, 338, 726); + final liveLocation = Location( + latitude: latitude, + longitude: longitude, + createdByDeviceId: createdByDeviceId, + endAt: localEndAt, + ); + + final json = liveLocation.toJson(); + final serializedEndAt = json['end_at'] as String?; + + // Verify the serialized date is in UTC format (ends with 'Z') + expect(serializedEndAt, isNotNull); + expect(serializedEndAt, endsWith('Z')); + + // Verify the stored endAt is in UTC + expect(liveLocation.endAt?.isUtc, isTrue); + + // Verify the date is the same instant, just in UTC + final expectedUtc = localEndAt.toUtc(); + expect(liveLocation.endAt, equals(expectedUtc)); + }, + ); + + test('should return correct coordinates', () { + final coordinates = location.coordinates; + + expect(coordinates, isA()); + expect(coordinates.latitude, equals(latitude)); + expect(coordinates.longitude, equals(longitude)); + }); + + test('isActive should return true for active live location', () { + final futureDate = DateTime.now().add(const Duration(hours: 1)); + final activeLocation = Location( + latitude: latitude, + longitude: longitude, + createdByDeviceId: createdByDeviceId, + endAt: futureDate, + ); + + expect(activeLocation.isActive, isTrue); + expect(activeLocation.isExpired, isFalse); + }); + + test('isActive should return false for expired live location', () { + final pastDate = DateTime.now().subtract(const Duration(hours: 1)); + final expiredLocation = Location( + latitude: latitude, + longitude: longitude, + createdByDeviceId: createdByDeviceId, + endAt: pastDate, + ); + + expect(expiredLocation.isActive, isFalse); + expect(expiredLocation.isExpired, isTrue); + }); + + test('isLive should return true for live location', () { + final futureDate = DateTime.now().add(const Duration(hours: 1)); + final liveLocation = Location( + latitude: latitude, + longitude: longitude, + createdByDeviceId: createdByDeviceId, + endAt: futureDate, + ); + + expect(liveLocation.isLive, isTrue); + expect(liveLocation.isStatic, isFalse); + }); + + test('equality should work correctly', () { + final location1 = Location( + channelCid: 'test:channel', + messageId: 'message_123', + userId: 'user_123', + latitude: latitude, + longitude: longitude, + createdByDeviceId: createdByDeviceId, + endAt: DateTime.parse('2024-12-31T23:59:59.999Z'), + createdAt: DateTime.parse('2023-01-01T00:00:00.000Z'), + updatedAt: DateTime.parse('2023-01-01T00:00:00.000Z'), + ); + + final location2 = Location( + channelCid: 'test:channel', + messageId: 'message_123', + userId: 'user_123', + latitude: latitude, + longitude: longitude, + createdByDeviceId: createdByDeviceId, + endAt: DateTime.parse('2024-12-31T23:59:59.999Z'), + createdAt: DateTime.parse('2023-01-01T00:00:00.000Z'), + updatedAt: DateTime.parse('2023-01-01T00:00:00.000Z'), + ); + + final location3 = Location( + channelCid: 'test:channel', + messageId: 'message_123', + userId: 'user_123', + latitude: 40.7128, + // Different latitude + longitude: longitude, + createdByDeviceId: createdByDeviceId, + endAt: DateTime.parse('2024-12-31T23:59:59.999Z'), + createdAt: DateTime.parse('2023-01-01T00:00:00.000Z'), + updatedAt: DateTime.parse('2023-01-01T00:00:00.000Z'), + ); + + expect(location1, equals(location2)); + expect(location1.hashCode, equals(location2.hashCode)); + expect(location1, isNot(equals(location3))); + }); + }); +} diff --git a/packages/stream_chat/test/src/core/models/member_test.dart b/packages/stream_chat/test/src/core/models/member_test.dart index 752ff46b60..b00884d450 100644 --- a/packages/stream_chat/test/src/core/models/member_test.dart +++ b/packages/stream_chat/test/src/core/models/member_test.dart @@ -12,6 +12,7 @@ void main() { expect(member.channelRole, 'channel_member'); expect(member.createdAt, DateTime.parse('2020-01-28T22:17:30.95443Z')); expect(member.updatedAt, DateTime.parse('2020-01-28T22:17:30.95443Z')); + expect(member.deletedMessages, ['msg-1', 'msg-2', 'msg-3']); expect(member.extraData['some_custom_field'], 'with_custom_data'); }); @@ -104,10 +105,8 @@ void main() { final field1 = recentMember.getComparableField(MemberSortKey.createdAt); final field2 = olderMember.getComparableField(MemberSortKey.createdAt); - expect(field1!.compareTo(field2!), - greaterThan(0)); // More recent > Less recent - expect( - field2.compareTo(field1), lessThan(0)); // Less recent < More recent + expect(field1!.compareTo(field2!), greaterThan(0)); // More recent > Less recent + expect(field2.compareTo(field1), lessThan(0)); // Less recent < More recent }); test('should compare two members correctly using userId', () { @@ -158,10 +157,8 @@ void main() { final field1 = owner.getComparableField(MemberSortKey.channelRole); final field2 = moderator.getComparableField(MemberSortKey.channelRole); - expect(field1!.compareTo(field2!), - greaterThan(0)); // 'owner' > 'moderator' alphabetically - expect(field2.compareTo(field1), - lessThan(0)); // 'moderator' < 'owner' alphabetically + expect(field1!.compareTo(field2!), greaterThan(0)); // 'owner' > 'moderator' alphabetically + expect(field2.compareTo(field1), lessThan(0)); // 'moderator' < 'owner' alphabetically }); test('should compare two members correctly using extraData', () { diff --git a/packages/stream_chat/test/src/core/models/message_reaction_helper_test.dart b/packages/stream_chat/test/src/core/models/message_reaction_helper_test.dart index f24748ea73..0aa5ea5561 100644 --- a/packages/stream_chat/test/src/core/models/message_reaction_helper_test.dart +++ b/packages/stream_chat/test/src/core/models/message_reaction_helper_test.dart @@ -45,10 +45,8 @@ void main() { expect(updatedMessage.reactionGroups!.containsKey('like'), isTrue); expect(updatedMessage.reactionGroups!['like']!.count, 1); expect(updatedMessage.reactionGroups!['like']!.sumScores, 1); - expect(updatedMessage.reactionGroups!['like']!.firstReactionAt, - testReaction.createdAt); - expect(updatedMessage.reactionGroups!['like']!.lastReactionAt, - testReaction.createdAt); + expect(updatedMessage.reactionGroups!['like']!.firstReactionAt, testReaction.createdAt); + expect(updatedMessage.reactionGroups!['like']!.lastReactionAt, testReaction.createdAt); }); test('should add reaction to a message with existing reactions', () { @@ -151,8 +149,7 @@ void main() { expect(updatedMessage.ownReactions, isNotNull); expect(updatedMessage.ownReactions!.length, 2); - final reactionTypes = - updatedMessage.ownReactions!.map((r) => r.type).toList(); + final reactionTypes = updatedMessage.ownReactions!.map((r) => r.type).toList(); expect(reactionTypes, contains('like')); expect(reactionTypes, contains('love')); diff --git a/packages/stream_chat/test/src/core/models/message_state_test.dart b/packages/stream_chat/test/src/core/models/message_state_test.dart index 9bdb250af9..513f2b6f13 100644 --- a/packages/stream_chat/test/src/core/models/message_state_test.dart +++ b/packages/stream_chat/test/src/core/models/message_state_test.dart @@ -1,5 +1,6 @@ -// ignore_for_file: use_named_constants, lines_longer_than_80_chars +// ignore_for_file: use_named_constants, lines_longer_than_80_chars, avoid_redundant_argument_values +import 'package:stream_chat/src/core/models/message_delete_scope.dart'; import 'package:stream_chat/src/core/models/message_state.dart'; import 'package:test/test.dart'; @@ -74,30 +75,45 @@ void main() { ); test( - 'isSoftDeleting should return true if the message state is MessageOutgoing with Deleting state and not hard deleting', + 'isSoftDeleting should return true if the message state is MessageOutgoing with Deleting state and scope is softDeleteForAll', () { const messageState = MessageState.outgoing( - state: OutgoingState.deleting(), + state: OutgoingState.deleting( + scope: MessageDeleteScope.softDeleteForAll, + ), ); expect(messageState.isSoftDeleting, true); }, ); test( - 'isHardDeleting should return true if the message state is MessageOutgoing with Deleting state and hard deleting', + 'isHardDeleting should return true if the message state is MessageOutgoing with Deleting state and scope is HardDeleteForAll', () { const messageState = MessageState.outgoing( - state: OutgoingState.deleting(hard: true), + state: OutgoingState.deleting( + scope: MessageDeleteScope.hardDeleteForAll, + ), ); expect(messageState.isHardDeleting, true); }, ); + test( + 'isDeletingForMe should return true if the message state is MessageOutgoing with Deleting state and scope is DeleteForMe', + () { + const messageState = MessageState.outgoing( + state: OutgoingState.deleting( + scope: MessageDeleteScope.deleteForMe(), + ), + ); + expect(messageState.isDeletingForMe, true); + }, + ); + test( 'isSent should return true if the message state is MessageCompleted with Sent state', () { - const messageState = - MessageState.completed(state: CompletedState.sent()); + const messageState = MessageState.completed(state: CompletedState.sent()); expect(messageState.isSent, true); }, ); @@ -121,25 +137,41 @@ void main() { ); test( - 'isSoftDeleted should return true if the message state is MessageCompleted with Deleted state and not hard deleting', + 'isSoftDeleted should return true if the message state is MessageCompleted with Deleted state and scope is softDeleteForAll', () { const messageState = MessageState.completed( - state: CompletedState.deleted(), + state: CompletedState.deleted( + scope: MessageDeleteScope.softDeleteForAll, + ), ); expect(messageState.isSoftDeleted, true); }, ); test( - 'isHardDeleted should return true if the message state is MessageCompleted with Deleted state and hard deleting', + 'isHardDeleted should return true if the message state is MessageCompleted with Deleted state and scope is hardDeleteForAll', () { const messageState = MessageState.completed( - state: CompletedState.deleted(hard: true), + state: CompletedState.deleted( + scope: MessageDeleteScope.hardDeleteForAll, + ), ); expect(messageState.isHardDeleted, true); }, ); + test( + 'isDeletedForMe should return true if the message state is MessageCompleted with Deleted state and scope is DeleteForMe', + () { + const messageState = MessageState.completed( + state: CompletedState.deleted( + scope: MessageDeleteScope.deleteForMe(), + ), + ); + expect(messageState.isDeletedForMe, true); + }, + ); + test( 'isSendingFailed should return true if the message state is MessageFailed with SendingFailed state', () { @@ -169,24 +201,40 @@ void main() { ); test( - 'isSoftDeletingFailed should return true if the message state is MessageFailed with DeletingFailed state and not hard deleting', + 'isSoftDeletingFailed should return true if the message state is MessageFailed with DeletingFailed state and scope is softDeleteForAll', () { const messageState = MessageState.failed( - state: FailedState.deletingFailed(), + state: FailedState.deletingFailed( + scope: MessageDeleteScope.softDeleteForAll, + ), ); expect(messageState.isSoftDeletingFailed, true); }, ); test( - 'isHardDeletingFailed should return true if the message state is MessageFailed with DeletingFailed state and hard deleting', + 'isHardDeletingFailed should return true if the message state is MessageFailed with DeletingFailed state and scope is hardDeleteForAll', () { const messageState = MessageState.failed( - state: FailedState.deletingFailed(hard: true), + state: FailedState.deletingFailed( + scope: MessageDeleteScope.hardDeleteForAll, + ), ); expect(messageState.isHardDeletingFailed, true); }, ); + + test( + 'isDeletingForMeFailed should return true if the message state is MessageFailed with DeletingFailed state and scope is DeleteForMe', + () { + const messageState = MessageState.failed( + state: FailedState.deletingFailed( + scope: MessageDeleteScope.deleteForMe(), + ), + ); + expect(messageState.isDeletingForMeFailed, true); + }, + ); }, ); @@ -210,22 +258,41 @@ void main() { ); test( - 'MessageState.softDeleting should create a MessageOutgoing instance with Deleting state and not hard deleting', + 'MessageState.softDeleting should create a MessageOutgoing instance with Deleting state and softDeleteForAll scope', () { const messageState = MessageState.softDeleting; expect(messageState, isA()); expect((messageState as MessageOutgoing).state, isA()); - expect((messageState.state as Deleting).hard, false); + + final deletingState = messageState.state as Deleting; + expect(deletingState.scope, isA()); + expect((deletingState.scope as DeleteForAll).hard, false); }, ); test( - 'MessageState.hardDeleting should create a MessageOutgoing instance with Deleting state and hard deleting', + 'MessageState.hardDeleting should create a MessageOutgoing instance with Deleting state and hardDeleteForAll scope', () { const messageState = MessageState.hardDeleting; expect(messageState, isA()); expect((messageState as MessageOutgoing).state, isA()); - expect((messageState.state as Deleting).hard, true); + + final deletingState = messageState.state as Deleting; + expect(deletingState.scope, isA()); + expect((deletingState.scope as DeleteForAll).hard, true); + }, + ); + + test( + 'MessageState.deletingForMe should create a MessageOutgoing instance with Deleting state and DeleteForMe scope', + () { + const messageState = MessageState.deletingForMe; + expect(messageState, isA()); + expect((messageState as MessageOutgoing).state, isA()); + + final deletingState = messageState.state as Deleting; + expect(deletingState.scope, isA()); + expect((deletingState.scope as DeleteForMe).hard, false); }, ); @@ -248,29 +315,51 @@ void main() { ); test( - 'MessageState.softDeleted should create a MessageCompleted instance with Deleted state and not hard deleting', + 'MessageState.softDeleted should create a MessageCompleted instance with Deleted state and softDeleteForAll scope', () { const messageState = MessageState.softDeleted; expect(messageState, isA()); expect((messageState as MessageCompleted).state, isA()); - expect((messageState.state as Deleted).hard, false); + + final deletedState = messageState.state as Deleted; + expect(deletedState.scope, isA()); + expect((deletedState.scope as DeleteForAll).hard, false); }, ); test( - 'MessageState.hardDeleted should create a MessageCompleted instance with Deleted state and hard deleting', + 'MessageState.hardDeleted should create a MessageCompleted instance with Deleted state and hardDeleteForAll scope', () { const messageState = MessageState.hardDeleted; expect(messageState, isA()); expect((messageState as MessageCompleted).state, isA()); - expect((messageState.state as Deleted).hard, true); + + final deletedState = messageState.state as Deleted; + expect(deletedState.scope, isA()); + expect((deletedState.scope as DeleteForAll).hard, true); + }, + ); + + test( + 'MessageState.deletedForMe should create a MessageCompleted instance with Deleted state and DeleteForMe scope', + () { + const messageState = MessageState.deletedForMe; + expect(messageState, isA()); + expect((messageState as MessageCompleted).state, isA()); + + final deletedState = messageState.state as Deleted; + expect(deletedState.scope, isA()); + expect((deletedState.scope as DeleteForMe).hard, false); }, ); test( 'MessageState.sendingFailed should create a MessageFailed instance with SendingFailed state', () { - const messageState = MessageState.sendingFailed; + final messageState = MessageState.sendingFailed( + skipPush: false, + skipEnrichUrl: false, + ); expect(messageState, isA()); expect((messageState as MessageFailed).state, isA()); }, @@ -279,29 +368,65 @@ void main() { test( 'MessageState.updatingFailed should create a MessageFailed instance with UpdatingFailed state', () { - const messageState = MessageState.updatingFailed; + final messageState = MessageState.updatingFailed( + skipPush: false, + skipEnrichUrl: false, + ); expect(messageState, isA()); expect((messageState as MessageFailed).state, isA()); }, ); test( - 'MessageState.softDeletingFailed should create a MessageFailed instance with DeletingFailed state and not hard deleting', + 'MessageState.partialUpdatingFailed should create a MessageFailed instance with PartialUpdatingFailed state', + () { + final messageState = MessageState.partialUpdatingFailed( + skipEnrichUrl: false, + ); + expect(messageState, isA()); + expect( + (messageState as MessageFailed).state, + isA(), + ); + }, + ); + + test( + 'MessageState.softDeletingFailed should create a MessageFailed instance with DeletingFailed state and softDeleteForAll scope', () { const messageState = MessageState.softDeletingFailed; expect(messageState, isA()); expect((messageState as MessageFailed).state, isA()); - expect((messageState.state as DeletingFailed).hard, false); + + final failedState = messageState.state as DeletingFailed; + expect(failedState.scope, isA()); + expect((failedState.scope as DeleteForAll).hard, false); }, ); test( - 'MessageState.hardDeletingFailed should create a MessageFailed instance with DeletingFailed state and hard deleting', + 'MessageState.hardDeletingFailed should create a MessageFailed instance with DeletingFailed state and hardDeleteForAll scope', () { const messageState = MessageState.hardDeletingFailed; expect(messageState, isA()); expect((messageState as MessageFailed).state, isA()); - expect((messageState.state as DeletingFailed).hard, true); + + final failedState = messageState.state as DeletingFailed; + expect(failedState.scope, isA()); + expect((failedState.scope as DeleteForAll).hard, true); + }, + ); + + test( + 'MessageState.deletingForMeFailed should create a MessageFailed instance with DeletingFailed state and DeleteForMe scope', + () { + const messageState = MessageState.deletingForMeFailed; + expect(messageState, isA()); + expect((messageState as MessageFailed).state, isA()); + + final failedState = messageState.state as DeletingFailed; + expect(failedState.scope, isA()); + expect((failedState.scope as DeleteForMe).hard, false); }, ); }); diff --git a/packages/stream_chat/test/src/core/models/message_test.dart b/packages/stream_chat/test/src/core/models/message_test.dart index ff5a7665e7..a2aca15ce2 100644 --- a/packages/stream_chat/test/src/core/models/message_test.dart +++ b/packages/stream_chat/test/src/core/models/message_test.dart @@ -1,8 +1,11 @@ -// ignore_for_file: avoid_redundant_argument_values, lines_longer_than_80_chars +// ignore_for_file: avoid_redundant_argument_values, lines_longer_than_80_chars, deprecated_member_use_from_same_package import 'package:stream_chat/src/core/models/attachment.dart'; import 'package:stream_chat/src/core/models/message.dart'; +import 'package:stream_chat/src/core/models/message_state.dart'; import 'package:stream_chat/src/core/models/moderation.dart'; +import 'package:stream_chat/src/core/models/poll.dart'; +import 'package:stream_chat/src/core/models/poll_option.dart'; import 'package:stream_chat/src/core/models/reaction.dart'; import 'package:stream_chat/src/core/models/reaction_group.dart'; import 'package:stream_chat/src/core/models/user.dart'; @@ -15,19 +18,16 @@ void main() { test('should parse json correctly', () { final message = Message.fromJson(jsonFixture('message.json')); expect(message.id, '4637f7e4-a06b-42db-ba5a-8d8270dd926f'); - expect(message.text, - 'https://giphy.com/gifs/the-lion-king-live-action-5zvN79uTGfLMOVfQaA'); + expect(message.text, 'https://giphy.com/gifs/the-lion-king-live-action-5zvN79uTGfLMOVfQaA'); expect(message.type, 'regular'); expect(message.user, isA()); expect(message.silent, isA()); expect(message.attachments, isA>()); expect(message.latestReactions, isA>()); expect(message.ownReactions, isA>()); - // ignore: deprecated_member_use_from_same_package - expect(message.reactionCounts, {'love': 1}); - // ignore: deprecated_member_use_from_same_package - expect(message.reactionScores, {'love': 1}); expect(message.reactionGroups, isA>()); + expect(message.reactionGroups?['love']?.count, 1); + expect(message.reactionGroups?['love']?.sumScores, 1); expect(message.createdAt, DateTime.parse('2020-01-28T22:17:31.107978Z')); expect(message.updatedAt, DateTime.parse('2020-01-28T22:17:31.130506Z')); expect(message.mentionedUsers, isA>()); @@ -43,24 +43,19 @@ void main() { test('should serialize to json correctly', () { final message = Message( id: '4637f7e4-a06b-42db-ba5a-8d8270dd926f', - text: - 'https://giphy.com/gifs/the-lion-king-live-action-5zvN79uTGfLMOVfQaA', + text: 'https://giphy.com/gifs/the-lion-king-live-action-5zvN79uTGfLMOVfQaA', attachments: [ Attachment.fromJson(const { 'type': 'giphy', 'author_name': 'GIPHY', 'title': 'The Lion King Disney GIF - Find \u0026 Share on GIPHY', - 'title_link': - 'https://media.giphy.com/media/5zvN79uTGfLMOVfQaA/giphy.gif', + 'title_link': 'https://media.giphy.com/media/5zvN79uTGfLMOVfQaA/giphy.gif', 'text': '''Discover \u0026 share this Lion King Live Action GIF with everyone you know. GIPHY is how you search, share, discover, and create GIFs.''', - 'image_url': - 'https://media.giphy.com/media/5zvN79uTGfLMOVfQaA/giphy.gif', - 'thumb_url': - 'https://media.giphy.com/media/5zvN79uTGfLMOVfQaA/giphy.gif', - 'asset_url': - 'https://media.giphy.com/media/5zvN79uTGfLMOVfQaA/giphy.mp4', - }) + 'image_url': 'https://media.giphy.com/media/5zvN79uTGfLMOVfQaA/giphy.gif', + 'thumb_url': 'https://media.giphy.com/media/5zvN79uTGfLMOVfQaA/giphy.gif', + 'asset_url': 'https://media.giphy.com/media/5zvN79uTGfLMOVfQaA/giphy.mp4', + }), ], showInChannel: true, parentId: 'parentId', @@ -290,13 +285,23 @@ void main() { }); test( - 'is derived from reactionCounts and reactionScores if not provided directly in constructor', + 'uses reactionGroups when provided directly in constructor', () { final message = Message( - // ignore: deprecated_member_use_from_same_package - reactionCounts: const {'like': 1, 'love': 2}, - // ignore: deprecated_member_use_from_same_package - reactionScores: const {'like': 1, 'love': 5}, + reactionGroups: { + 'like': ReactionGroup( + count: 1, + sumScores: 1, + firstReactionAt: DateTime.now(), + lastReactionAt: DateTime.now(), + ), + 'love': ReactionGroup( + count: 2, + sumScores: 5, + firstReactionAt: DateTime.now(), + lastReactionAt: DateTime.now(), + ), + }, ); expect(message.reactionGroups, isNotNull); @@ -340,22 +345,18 @@ void main() { }); test('should return true when user is in restrictedVisibility list', () { - final message = - Message(restrictedVisibility: const ['user1', 'user2', 'user3']); + final message = Message(restrictedVisibility: const ['user1', 'user2', 'user3']); expect(message.isVisibleTo('user2'), true); }); - test('should return false when user is not in restrictedVisibility list', - () { - final message = - Message(restrictedVisibility: const ['user1', 'user2', 'user3']); + test('should return false when user is not in restrictedVisibility list', () { + final message = Message(restrictedVisibility: const ['user1', 'user2', 'user3']); expect(message.isVisibleTo('user4'), false); }); test('should handle case sensitivity correctly', () { final message = Message(restrictedVisibility: const ['User1', 'USER2']); - expect(message.isVisibleTo('user1'), false, - reason: 'Should be case sensitive'); + expect(message.isVisibleTo('user1'), false, reason: 'Should be case sensitive'); expect(message.isVisibleTo('User1'), true); }); }); @@ -372,15 +373,12 @@ void main() { }); test('should return false when user is in restrictedVisibility list', () { - final message = - Message(restrictedVisibility: const ['user1', 'user2', 'user3']); + final message = Message(restrictedVisibility: const ['user1', 'user2', 'user3']); expect(message.isNotVisibleTo('user2'), false); }); - test('should return true when user is not in restrictedVisibility list', - () { - final message = - Message(restrictedVisibility: const ['user1', 'user2', 'user3']); + test('should return true when user is not in restrictedVisibility list', () { + final message = Message(restrictedVisibility: const ['user1', 'user2', 'user3']); expect(message.isNotVisibleTo('user4'), true); }); @@ -540,27 +538,306 @@ void main() { expect(message.isFlagged, isTrue); }); }); + + group('syncWith', () { + test('should preserve local timestamps from the other message', () { + final localCreatedAt = DateTime(2024, 1, 1); + final localUpdatedAt = DateTime(2024, 1, 2); + final localDeletedAt = DateTime(2024, 1, 3); + + final serverMessage = Message(id: 'msg-1', text: 'Hello'); + final localMessage = Message( + id: 'msg-1', + text: 'Hello', + localCreatedAt: localCreatedAt, + localUpdatedAt: localUpdatedAt, + localDeletedAt: localDeletedAt, + ); + + final synced = serverMessage.syncWith(localMessage); + expect(synced.localCreatedAt, localCreatedAt); + expect(synced.localUpdatedAt, localUpdatedAt); + expect(synced.localDeletedAt, localDeletedAt); + }); + + test('should return this if other is null', () { + final message = Message(id: 'msg-1', text: 'Hello'); + final synced = message.syncWith(null); + expect(identical(synced, message), isTrue); + }); + + test( + 'should preserve deletedForMe from local when server does not have it', + () { + final serverMessage = Message( + id: 'msg-1', + text: 'Hello', + ); + final localMessage = Message( + id: 'msg-1', + text: 'Hello', + deletedForMe: true, + type: MessageType.deleted, + state: MessageState.deletedForMe, + ); + + final synced = serverMessage.syncWith(localMessage); + expect(synced.deletedForMe, isTrue); + expect(synced.type, MessageType.deleted); + expect(synced.state, MessageState.deletedForMe); + expect(synced.isDeleted, isTrue); + }, + ); + + test( + 'should keep server deletedForMe when both server and local have it', + () { + final serverMessage = Message( + id: 'msg-1', + text: 'Hello', + deletedForMe: true, + type: MessageType.deleted, + state: MessageState.deletedForMe, + ); + final localMessage = Message( + id: 'msg-1', + text: 'Hello', + deletedForMe: true, + type: MessageType.deleted, + state: MessageState.deletedForMe, + ); + + final synced = serverMessage.syncWith(localMessage); + expect(synced.deletedForMe, isTrue); + expect(synced.type, MessageType.deleted); + expect(synced.state, MessageState.deletedForMe); + }, + ); + + test( + 'should not set deletedForMe when neither server nor local have it', + () { + final serverMessage = Message(id: 'msg-1', text: 'Hello'); + final localMessage = Message(id: 'msg-1', text: 'Hello'); + + final synced = serverMessage.syncWith(localMessage); + expect(synced.deletedForMe, isNull); + expect(synced.type, isNot(MessageType.deleted)); + }, + ); + }); + + group('updateWith', () { + test('returns this unchanged when other is null', () { + final message = createTestMessage(id: 'msg-1', text: 'Hello'); + final merged = message.updateWith(null); + expect(identical(merged, message), isTrue); + }); + + test('preserves local timestamps from this onto the server payload', () { + final localCreatedAt = DateTime(2024, 1, 1); + final localUpdatedAt = DateTime(2024, 1, 2); + final localDeletedAt = DateTime(2024, 1, 3); + + final localMessage = Message( + id: 'msg-1', + text: 'Hello', + localCreatedAt: localCreatedAt, + localUpdatedAt: localUpdatedAt, + localDeletedAt: localDeletedAt, + ); + final serverMessage = Message(id: 'msg-1', text: 'Hello (edited)'); + + final merged = localMessage.updateWith(serverMessage); + + expect(merged.text, 'Hello (edited)'); + expect(merged.localCreatedAt, localCreatedAt); + expect(merged.localUpdatedAt, localUpdatedAt); + expect(merged.localDeletedAt, localDeletedAt); + }); + + test( + 'promotes deletedForMe + type/state when local was deleted-for-me but ' + 'the server payload omits the flag', + () { + final localMessage = createTestMessage( + id: 'msg-1', + text: 'Hello', + deletedForMe: true, + type: MessageType.deleted, + state: MessageState.deletedForMe, + ); + final serverMessage = createTestMessage(id: 'msg-1', text: 'Hello'); + + final merged = localMessage.updateWith(serverMessage); + + expect(merged.deletedForMe, isTrue); + expect(merged.type, MessageType.deleted); + expect(merged.state, MessageState.deletedForMe); + expect(merged.isDeleted, isTrue); + }, + ); + + test( + 'preserves the local `poll` when the server payload omits it ' + '(regression: thread reply destroys poll on parent message)', + () { + final poll = Poll( + id: 'poll-1', + name: 'My poll', + options: const [PollOption(id: 'a', text: 'A')], + createdById: 'u', + ); + final localMessage = createTestMessage(id: 'msg-1', text: 'Vote!', poll: poll, pollId: poll.id); + + // Server bumps `replyCount` but does not echo the `poll` object back. + final serverMessage = createTestMessage(id: 'msg-1', text: 'Vote!', pollId: poll.id, replyCount: 1); + + final merged = localMessage.updateWith(serverMessage); + + expect(merged.replyCount, 1); + expect(merged.poll, isNotNull); + expect(merged.poll!.id, poll.id); + expect(merged.poll!.name, poll.name); + }, + ); + + test('the server `poll`, when present, takes precedence over the local one', () { + final localPoll = Poll( + id: 'poll-1', + name: 'Original', + options: const [PollOption(id: 'a', text: 'A')], + createdById: 'u', + ); + final updatedPoll = localPoll.copyWith(name: 'Edited'); + + final localMessage = createTestMessage(id: 'msg-1', text: 'Vote!', poll: localPoll, pollId: localPoll.id); + final serverMessage = createTestMessage(id: 'msg-1', text: 'Vote!', poll: updatedPoll, pollId: updatedPoll.id); + + final merged = localMessage.updateWith(serverMessage); + + expect(merged.poll!.name, 'Edited'); + }); + + test( + 'recursively preserves the embedded quotedMessage poll when the ' + 'server returns a stripped nested `quoted_message` (regression #59)', + () { + final poll = Poll( + id: 'poll-1', + name: 'My poll', + options: const [PollOption(id: 'a', text: 'A')], + createdById: 'u', + ); + final pollMessage = createTestMessage(id: 'poll-msg', poll: poll, pollId: poll.id); + final localReply = createTestMessage( + id: 'reply-1', + text: 'My pick', + quotedMessageId: pollMessage.id, + quotedMessage: pollMessage, + ); + // Constructed directly because copyWith cannot clear `poll` — see + // Message.copyWith. + final strippedQuoted = createTestMessage(id: pollMessage.id, pollId: pollMessage.pollId); + final serverReply = localReply.copyWith(quotedMessage: strippedQuoted); + + final merged = localReply.updateWith(serverReply); + + expect(merged.quotedMessage, isNotNull); + expect(merged.quotedMessage!.id, pollMessage.id); + expect(merged.quotedMessage!.poll, isNotNull); + expect(merged.quotedMessage!.poll!.id, poll.id); + }, + ); + + test( + 'preserves the entire local quotedMessage when the server payload ' + 'has no nested `quoted_message` at all', + () { + final quoted = createTestMessage(id: 'quoted-1', text: 'Original'); + final localReply = createTestMessage( + id: 'reply-1', + text: 'Reply', + quotedMessageId: quoted.id, + quotedMessage: quoted, + ); + final serverReply = createTestMessage( + id: 'reply-1', + text: 'Reply (edited)', + quotedMessageId: quoted.id, + ); + + final merged = localReply.updateWith(serverReply); + + expect(merged.text, 'Reply (edited)'); + expect(merged.quotedMessage, isNotNull); + expect(merged.quotedMessage!.id, quoted.id); + expect(merged.quotedMessage!.text, 'Original'); + }, + ); + + test( + 'when the user changes which message is being quoted, the server ' + 'payload wins without recursive merging into the previous quote', + () { + final originalQuoted = createTestMessage(id: 'quoted-1', text: 'First'); + final newQuoted = createTestMessage(id: 'quoted-2', text: 'Second'); + final localReply = createTestMessage( + id: 'reply-1', + text: 'Reply', + quotedMessageId: originalQuoted.id, + quotedMessage: originalQuoted, + ); + final serverReply = createTestMessage( + id: 'reply-1', + text: 'Reply', + quotedMessageId: newQuoted.id, + quotedMessage: newQuoted, + ); + + final merged = localReply.updateWith(serverReply); + + expect(merged.quotedMessage, isNotNull); + expect(merged.quotedMessage!.id, newQuoted.id); + expect(merged.quotedMessage!.text, 'Second'); + }, + ); + }); } /// Helper function to create a Message for testing Message createTestMessage({ String? id, - required String text, + String? text, String type = 'regular', DateTime? createdAt, DateTime? updatedAt, User? user, Map? extraData, List? restrictedVisibility, + Poll? poll, + String? pollId, + Message? quotedMessage, + String? quotedMessageId, + int replyCount = 0, + bool? deletedForMe, + MessageState state = const MessageState.initial(), }) { return Message( id: id, text: text, type: type, + state: state, localCreatedAt: createdAt, localUpdatedAt: updatedAt, user: user, extraData: extraData ?? {}, restrictedVisibility: restrictedVisibility, + poll: poll, + pollId: pollId, + quotedMessage: quotedMessage, + quotedMessageId: quotedMessageId, + replyCount: replyCount, + deletedForMe: deletedForMe, ); } diff --git a/packages/stream_chat/test/src/core/models/moderation_test.dart b/packages/stream_chat/test/src/core/models/moderation_test.dart index 4a099d5fc4..2405c44a90 100644 --- a/packages/stream_chat/test/src/core/models/moderation_test.dart +++ b/packages/stream_chat/test/src/core/models/moderation_test.dart @@ -153,8 +153,7 @@ void main() { }); test('should create from custom string correctly', () { - expect(ModerationAction.fromJson('custom'), - const ModerationAction('custom')); + expect(ModerationAction.fromJson('custom'), const ModerationAction('custom')); }); test('should handle legacy v1 moderation actions correctly', () { @@ -179,8 +178,7 @@ void main() { expect(ModerationAction.toJson(ModerationAction.flag), 'flag'); expect(ModerationAction.toJson(ModerationAction.remove), 'remove'); expect(ModerationAction.toJson(ModerationAction.shadow), 'shadow'); - expect(ModerationAction.toJson(const ModerationAction('custom')), - 'custom'); + expect(ModerationAction.toJson(const ModerationAction('custom')), 'custom'); }); test('should serialize legacy v1 action strings correctly', () { diff --git a/packages/stream_chat/test/src/core/models/own_user_test.dart b/packages/stream_chat/test/src/core/models/own_user_test.dart index 10f864ebd8..905a824600 100644 --- a/packages/stream_chat/test/src/core/models/own_user_test.dart +++ b/packages/stream_chat/test/src/core/models/own_user_test.dart @@ -28,8 +28,7 @@ void main() { expect(ownUser.createdAt, DateTime.parse('2020-03-03T16:48:28.853674Z')); expect(ownUser.updatedAt, DateTime.parse('2021-05-26T03:22:20.296181Z')); - expect( - ownUser.lastActive, DateTime.parse('2021-06-16T11:59:59.003453014Z')); + expect(ownUser.lastActive, DateTime.parse('2021-06-16T11:59:59.003453014Z')); expect(ownUser.banned, false); expect(ownUser.online, true); expect(ownUser.devices.length, 1); diff --git a/packages/stream_chat/test/src/core/models/poll_test.dart b/packages/stream_chat/test/src/core/models/poll_test.dart index bb3ba1c0a1..ae44676408 100644 --- a/packages/stream_chat/test/src/core/models/poll_test.dart +++ b/packages/stream_chat/test/src/core/models/poll_test.dart @@ -63,7 +63,7 @@ void main() { expect(json['name'], 'test'); expect(json['description'], isNull); expect(json['options'], [ - {'text': 'option1 text'} + {'text': 'option1 text'}, ]); expect(json['voting_visibility'], 'public'); expect(json['enforce_unique_vote'], true); diff --git a/packages/stream_chat/test/src/core/models/reaction_test.dart b/packages/stream_chat/test/src/core/models/reaction_test.dart index 08a3de80ed..2b9ed6dbeb 100644 --- a/packages/stream_chat/test/src/core/models/reaction_test.dart +++ b/packages/stream_chat/test/src/core/models/reaction_test.dart @@ -10,6 +10,7 @@ void main() { final reaction = Reaction.fromJson(jsonFixture('reaction.json')); expect(reaction.messageId, '76cd8c82-b557-4e48-9d12-87995d3a0e04'); expect(reaction.createdAt, DateTime.parse('2020-01-28T22:17:31.108742Z')); + expect(reaction.updatedAt, DateTime.parse('2020-01-28T22:17:31.108742Z')); expect(reaction.type, 'wow'); expect( reaction.user?.toJson(), @@ -22,18 +23,19 @@ void main() { 'online': false, 'banned': false, 'image': 'https://randomuser.me/api/portraits/women/45.jpg', - 'name': 'Daisy Morgan' + 'name': 'Daisy Morgan', }, ); expect(reaction.score, 1); expect(reaction.userId, '2de0297c-f3f2-489d-b930-ef77342edccf'); - expect(reaction.extraData, {'updated_at': '2020-01-28T22:17:31.108742Z'}); + expect(reaction.emojiCode, '😮'); }); test('should serialize to json correctly', () { final reaction = Reaction( messageId: '76cd8c82-b557-4e48-9d12-87995d3a0e04', createdAt: DateTime.parse('2020-01-28T22:17:31.108742Z'), + updatedAt: DateTime.parse('2020-01-28T22:17:31.108742Z'), type: 'wow', user: User( id: '2de0297c-f3f2-489d-b930-ef77342edccf', @@ -41,16 +43,16 @@ void main() { name: 'Daisy Morgan', ), userId: '2de0297c-f3f2-489d-b930-ef77342edccf', - extraData: {'bananas': 'yes'}, - score: 1, + extraData: const {'bananas': 'yes'}, + emojiCode: '😮', ); expect( reaction.toJson(), { - 'message_id': '76cd8c82-b557-4e48-9d12-87995d3a0e04', 'type': 'wow', 'score': 1, + 'emoji_code': '😮', 'bananas': 'yes', }, ); @@ -60,8 +62,8 @@ void main() { final reaction = Reaction.fromJson(jsonFixture('reaction.json')); var newReaction = reaction.copyWith(); expect(newReaction.messageId, '76cd8c82-b557-4e48-9d12-87995d3a0e04'); - expect( - newReaction.createdAt, DateTime.parse('2020-01-28T22:17:31.108742Z')); + expect(newReaction.createdAt, DateTime.parse('2020-01-28T22:17:31.108742Z')); + expect(newReaction.updatedAt, DateTime.parse('2020-01-28T22:17:31.108742Z')); expect(newReaction.type, 'wow'); expect( newReaction.user?.toJson(), @@ -74,19 +76,20 @@ void main() { 'online': false, 'banned': false, 'image': 'https://randomuser.me/api/portraits/women/45.jpg', - 'name': 'Daisy Morgan' + 'name': 'Daisy Morgan', }, ); expect(newReaction.score, 1); expect(newReaction.userId, '2de0297c-f3f2-489d-b930-ef77342edccf'); - expect( - newReaction.extraData, {'updated_at': '2020-01-28T22:17:31.108742Z'}); + expect(newReaction.emojiCode, '😮'); final newUserCreateTime = DateTime.now(); newReaction = reaction.copyWith( type: 'lol', + emojiCode: '😂', createdAt: DateTime.parse('2021-01-28T22:17:31.108742Z'), + updatedAt: DateTime.parse('2021-01-28T22:17:31.108742Z'), extraData: {}, messageId: 'test', score: 2, @@ -99,10 +102,15 @@ void main() { ); expect(newReaction.type, 'lol'); + expect(newReaction.emojiCode, '😂'); expect( newReaction.createdAt, DateTime.parse('2021-01-28T22:17:31.108742Z'), ); + expect( + newReaction.updatedAt, + DateTime.parse('2021-01-28T22:17:31.108742Z'), + ); expect(newReaction.extraData, {}); expect(newReaction.messageId, 'test'); expect(newReaction.score, 2); @@ -117,6 +125,41 @@ void main() { expect(newReaction.userId, 'test'); }); + group('ComparableFieldProvider', () { + test('should return ComparableField for reaction.createdAt', () { + final createdAt = DateTime(2020, 1, 28); + final reaction = Reaction(type: 'like', createdAt: createdAt); + + final field = reaction.getComparableField(ReactionSortKey.createdAt); + expect(field, isNotNull); + expect(field!.value, equals(createdAt)); + }); + + test('should return null for non-existent field keys', () { + final reaction = Reaction(type: 'like'); + + final field = reaction.getComparableField('non_existent_key'); + expect(field, isNull); + }); + + test('should compare two reactions correctly using createdAt', () { + final recentReaction = Reaction( + type: 'like', + createdAt: DateTime(2020, 6, 15), + ); + final olderReaction = Reaction( + type: 'like', + createdAt: DateTime(2020, 6, 10), + ); + + final field1 = recentReaction.getComparableField(ReactionSortKey.createdAt); + final field2 = olderReaction.getComparableField(ReactionSortKey.createdAt); + + expect(field1!.compareTo(field2!), greaterThan(0)); // more recent > older + expect(field2.compareTo(field1), lessThan(0)); // older < more recent + }); + }); + test('merge', () { final reaction = Reaction.fromJson(jsonFixture('reaction.json')); final newUserCreateTime = DateTime.now(); @@ -124,8 +167,9 @@ void main() { final newReaction = reaction.merge( Reaction( type: 'lol', + emojiCode: '😂', createdAt: DateTime.parse('2021-01-28T22:17:31.108742Z'), - extraData: {}, + updatedAt: DateTime.parse('2021-01-28T22:17:31.108742Z'), messageId: 'test', score: 2, user: User( @@ -138,10 +182,15 @@ void main() { ); expect(newReaction.type, 'lol'); + expect(newReaction.emojiCode, '😂'); expect( newReaction.createdAt, DateTime.parse('2021-01-28T22:17:31.108742Z'), ); + expect( + newReaction.updatedAt, + DateTime.parse('2021-01-28T22:17:31.108742Z'), + ); expect(newReaction.extraData, {}); expect(newReaction.messageId, 'test'); expect(newReaction.score, 2); diff --git a/packages/stream_chat/test/src/core/models/read_test.dart b/packages/stream_chat/test/src/core/models/read_test.dart index 197fa3f161..4a538b51ab 100644 --- a/packages/stream_chat/test/src/core/models/read_test.dart +++ b/packages/stream_chat/test/src/core/models/read_test.dart @@ -33,12 +33,7 @@ void main() { ); expect(read.toJson(), { - 'user': { - 'id': 'bbb19d9a-ee50-45bc-84e5-0584e79d0c9e', - 'teams': [], - 'online': false, - 'banned': false - }, + 'user': {'id': 'bbb19d9a-ee50-45bc-84e5-0584e79d0c9e', 'teams': [], 'online': false, 'banned': false}, 'last_read': '2020-01-28T22:17:30.966485Z', 'unread_messages': 10, 'last_read_message_id': '8cc1301d-2d47-4305-945a-cd8e19b736d6', diff --git a/packages/stream_chat/test/src/core/models/serialization_test.dart b/packages/stream_chat/test/src/core/models/serialization_test.dart index 63b1428f78..594a3427b1 100644 --- a/packages/stream_chat/test/src/core/models/serialization_test.dart +++ b/packages/stream_chat/test/src/core/models/serialization_test.dart @@ -30,15 +30,14 @@ void main() { }); test('should have empty extraData', () { - final result = Serializer.moveToExtraDataFromRoot({ - 'prop1': 'test', - 'prop2': 123, - 'prop3': true, - }, [ - 'prop1', - 'prop2', - 'prop3' - ]); + final result = Serializer.moveToExtraDataFromRoot( + { + 'prop1': 'test', + 'prop2': 123, + 'prop3': true, + }, + ['prop1', 'prop2', 'prop3'], + ); expect(result, { 'prop1': 'test', diff --git a/packages/stream_chat/test/src/core/models/thread_test.dart b/packages/stream_chat/test/src/core/models/thread_test.dart index 21ab698a59..9e0a04bbee 100644 --- a/packages/stream_chat/test/src/core/models/thread_test.dart +++ b/packages/stream_chat/test/src/core/models/thread_test.dart @@ -91,8 +91,7 @@ void main() { final updatedThread = thread.copyWith(draft: updatedDraft); expect(updatedThread.draft?.message.text, equals('Updated draft')); - expect( - updatedThread.draft?.message.text, isNot(equals(draft.message.text))); + expect(updatedThread.draft?.message.text, isNot(equals(draft.message.text))); // Test copyWith with null draft (removing draft) final removedDraftThread = thread.copyWith(draft: null); @@ -209,8 +208,7 @@ void main() { // Test text equality instead of object identity expect(thread1.draft?.message.text, equals(thread2.draft?.message.text)); - expect(thread1.draft?.message.text, - isNot(equals(thread3.draft?.message.text))); + expect(thread1.draft?.message.text, isNot(equals(thread3.draft?.message.text))); }); }); } diff --git a/packages/stream_chat/test/src/core/models/user_block_test.dart b/packages/stream_chat/test/src/core/models/user_block_test.dart index 3297e0016b..18162de5d7 100644 --- a/packages/stream_chat/test/src/core/models/user_block_test.dart +++ b/packages/stream_chat/test/src/core/models/user_block_test.dart @@ -30,21 +30,11 @@ void main() { expect( userBlock.toJson(), { - 'user': { - 'id': 'user-1', - 'teams': [], - 'online': false, - 'banned': false - }, - 'blocked_user': { - 'id': 'user-2', - 'teams': [], - 'online': false, - 'banned': false - }, + 'user': {'id': 'user-1', 'teams': [], 'online': false, 'banned': false}, + 'blocked_user': {'id': 'user-2', 'teams': [], 'online': false, 'banned': false}, 'user_id': 'user-1', 'blocked_user_id': 'user-2', - 'created_at': '2020-01-28T22:17:30.830150Z' + 'created_at': '2020-01-28T22:17:30.830150Z', }, ); }); diff --git a/packages/stream_chat/test/src/core/models/user_test.dart b/packages/stream_chat/test/src/core/models/user_test.dart index 5e16ea861a..5915e442aa 100644 --- a/packages/stream_chat/test/src/core/models/user_test.dart +++ b/packages/stream_chat/test/src/core/models/user_test.dart @@ -9,8 +9,7 @@ void main() { const id = 'bbb19d9a-ee50-45bc-84e5-0584e79d0c9e'; const role = 'test-role'; const name = 'John'; - const image = - 'https://getstream.io/random_png/?id=cool-shadow-7&name=Cool+shadow'; + const image = 'https://getstream.io/random_png/?id=cool-shadow-7&name=Cool+shadow'; const extraDataStringTest = 'Extra data test'; const extraDataIntTest = 1; const extraDataDoubleTest = 1.1; @@ -352,15 +351,11 @@ void main() { lastActive: DateTime(2023, 6, 10), ); - final field1 = - recentlyActive.getComparableField(UserSortKey.lastActive); - final field2 = - lessRecentlyActive.getComparableField(UserSortKey.lastActive); + final field1 = recentlyActive.getComparableField(UserSortKey.lastActive); + final field2 = lessRecentlyActive.getComparableField(UserSortKey.lastActive); - expect(field1!.compareTo(field2!), - greaterThan(0)); // More recent > Less recent - expect( - field2.compareTo(field1), lessThan(0)); // Less recent < More recent + expect(field1!.compareTo(field2!), greaterThan(0)); // More recent > Less recent + expect(field2.compareTo(field1), lessThan(0)); // Less recent < More recent }); test('should compare two users correctly using banned status', () { diff --git a/packages/stream_chat/test/src/core/platform_detector/platform_detector_test.dart b/packages/stream_chat/test/src/core/platform_detector/platform_detector_test.dart index 110fb64eba..0ac16d03e4 100644 --- a/packages/stream_chat/test/src/core/platform_detector/platform_detector_test.dart +++ b/packages/stream_chat/test/src/core/platform_detector/platform_detector_test.dart @@ -22,4 +22,25 @@ void main() { expect(CurrentPlatform.isWindows, isFalse); expect(CurrentPlatform.isFuchsia, isFalse); }); + + group('debugCurrentPlatformOverride', () { + tearDown(() => CurrentPlatform.debugCurrentPlatformOverride = null); + + test('changes type, name, and flags', () { + CurrentPlatform.debugCurrentPlatformOverride = PlatformType.web; + + expect(CurrentPlatform.type, PlatformType.web); + expect(CurrentPlatform.name, 'web'); + expect(CurrentPlatform.isWeb, isTrue); + expect(CurrentPlatform.isLinux, isFalse); + }); + + test('clearing the override restores the real platform', () { + CurrentPlatform.debugCurrentPlatformOverride = PlatformType.windows; + CurrentPlatform.debugCurrentPlatformOverride = null; + + expect(CurrentPlatform.type, PlatformType.linux); + expect(CurrentPlatform.isWindows, isFalse); + }); + }); } diff --git a/packages/stream_chat/test/src/core/util/event_controller_test.dart b/packages/stream_chat/test/src/core/util/event_controller_test.dart new file mode 100644 index 0000000000..fdf330a4d0 --- /dev/null +++ b/packages/stream_chat/test/src/core/util/event_controller_test.dart @@ -0,0 +1,335 @@ +// ignore_for_file: cascade_invocations, avoid_redundant_argument_values + +import 'dart:async'; +import 'package:stream_chat/src/core/models/event.dart'; +import 'package:stream_chat/src/core/util/event_controller.dart'; +import 'package:stream_chat/src/event_type.dart'; +import 'package:test/test.dart'; + +void main() { + group('EventController events', () { + late EventController controller; + + setUp(() { + controller = EventController(); + }); + + tearDown(() { + controller.close(); + }); + + test('should emit events without resolvers', () async { + final event = Event(type: EventType.messageNew); + + final streamEvents = []; + controller.listen(streamEvents.add); + + controller.add(event); + + await Future.delayed(Duration.zero); + + expect(streamEvents, hasLength(1)); + expect(streamEvents.first.type, EventType.messageNew); + }); + + test('should apply resolvers in order', () async { + Event? firstResolver(Event event) { + if (event.type == EventType.messageNew) { + return event.copyWith(type: EventType.pollCreated); + } + return null; + } + + Event? secondResolver(Event event) { + if (event.type == EventType.pollCreated) { + return event.copyWith(type: EventType.locationShared); + } + return null; + } + + controller = EventController( + resolvers: [firstResolver, secondResolver], + ); + + final streamEvents = []; + controller.listen(streamEvents.add); + + final originalEvent = Event(type: EventType.messageNew); + controller.add(originalEvent); + + await Future.delayed(Duration.zero); + + expect(streamEvents, hasLength(1)); + expect(streamEvents.first.type, EventType.pollCreated); + }); + + test('should stop at first matching resolver', () async { + var firstResolverCalled = false; + var secondResolverCalled = false; + + Event? firstResolver(Event event) { + firstResolverCalled = true; + if (event.type == EventType.messageNew) { + return event.copyWith(type: EventType.pollCreated); + } + return null; + } + + Event? secondResolver(Event event) { + secondResolverCalled = true; + return event.copyWith(type: EventType.locationShared); + } + + controller = EventController( + resolvers: [firstResolver, secondResolver], + ); + + final streamEvents = []; + controller.listen(streamEvents.add); + + final originalEvent = Event(type: EventType.messageNew); + controller.add(originalEvent); + + await Future.delayed(Duration.zero); + + expect(firstResolverCalled, isTrue); + expect(secondResolverCalled, isFalse); + expect(streamEvents, hasLength(1)); + expect(streamEvents.first.type, EventType.pollCreated); + }); + + test('should emit original event when no resolver matches', () async { + Event? resolver(Event event) { + if (event.type == EventType.pollCreated) { + return event.copyWith(type: EventType.locationShared); + } + return null; + } + + controller = EventController( + resolvers: [resolver], + ); + + final streamEvents = []; + controller.listen(streamEvents.add); + + final originalEvent = Event(type: EventType.messageNew); + controller.add(originalEvent); + + await Future.delayed(Duration.zero); + + expect(streamEvents, hasLength(1)); + expect(streamEvents.first.type, EventType.messageNew); + }); + + test('should work with multiple resolvers that return null', () async { + Event? firstResolver(Event event) => null; + Event? secondResolver(Event event) => null; + Event? thirdResolver(Event event) => null; + + controller = EventController( + resolvers: [firstResolver, secondResolver, thirdResolver], + ); + + final streamEvents = []; + controller.listen(streamEvents.add); + + final originalEvent = Event(type: EventType.messageNew); + controller.add(originalEvent); + + await Future.delayed(Duration.zero); + + expect(streamEvents, hasLength(1)); + expect(streamEvents.first.type, EventType.messageNew); + }); + + test('should handle empty resolvers list', () async { + controller = EventController(resolvers: []); + + final streamEvents = []; + controller.listen(streamEvents.add); + + final originalEvent = Event(type: EventType.messageNew); + controller.add(originalEvent); + + await Future.delayed(Duration.zero); + + expect(streamEvents, hasLength(1)); + expect(streamEvents.first.type, EventType.messageNew); + }); + + test('should support custom onListen callback', () async { + var onListenCalled = false; + + controller = EventController( + onListen: () => onListenCalled = true, + ); + + expect(onListenCalled, isFalse); + + controller.listen((_) {}); + + expect(onListenCalled, isTrue); + }); + + test('should support custom onCancel callback', () async { + var onCancelCalled = false; + + controller = EventController( + onCancel: () => onCancelCalled = true, + ); + + final subscription = controller.listen((_) {}); + + expect(onCancelCalled, isFalse); + + await subscription.cancel(); + + expect(onCancelCalled, isTrue); + }); + + test('should support sync mode', () async { + controller = EventController(sync: true); + + final streamEvents = []; + controller.listen(streamEvents.add); + + final originalEvent = Event(type: EventType.messageNew); + controller.add(originalEvent); + + // In sync mode, events should be available immediately + expect(streamEvents, hasLength(1)); + expect(streamEvents.first.type, EventType.messageNew); + }); + + test('should handle resolver exceptions gracefully', () async { + Event? failingResolver(Event event) { + throw Exception('Resolver failed'); + } + + Event? workingResolver(Event event) { + return event.copyWith(type: EventType.pollCreated); + } + + controller = EventController( + resolvers: [failingResolver, workingResolver], + ); + + final streamEvents = []; + controller.listen(streamEvents.add); + + final originalEvent = Event(type: EventType.messageNew); + + // This should throw an exception because the resolver throws + expect(() => controller.add(originalEvent), throwsException); + }); + + test('should be compatible with stream operations', () async { + final event1 = Event(type: EventType.messageNew); + final event2 = Event(type: EventType.messageUpdated); + + final streamEvents = []; + controller.where((event) => event.type == EventType.messageNew).listen(streamEvents.add); + + controller.add(event1); + controller.add(event2); + + await Future.delayed(Duration.zero); + + expect(streamEvents, hasLength(1)); + expect(streamEvents.first.type, EventType.messageNew); + }); + + test('should work with multiple listeners', () async { + final streamEvents1 = []; + final streamEvents2 = []; + + controller.listen(streamEvents1.add); + controller.listen(streamEvents2.add); + + final originalEvent = Event(type: EventType.messageNew); + controller.add(originalEvent); + + await Future.delayed(Duration.zero); + + expect(streamEvents1, hasLength(1)); + expect(streamEvents2, hasLength(1)); + expect(streamEvents1.first.type, EventType.messageNew); + expect(streamEvents2.first.type, EventType.messageNew); + }); + + test('should preserve event properties through resolvers', () async { + final originalEvent = Event( + type: EventType.messageNew, + userId: 'user123', + cid: 'channel123', + connectionId: 'conn123', + me: null, + user: null, + extraData: {'custom': 'data'}, + ); + + Event? resolver(Event event) { + if (event.type == EventType.messageNew) { + return event.copyWith(type: EventType.pollCreated); + } + return null; + } + + controller = EventController( + resolvers: [resolver], + ); + + final streamEvents = []; + controller.listen(streamEvents.add); + + controller.add(originalEvent); + + await Future.delayed(Duration.zero); + + expect(streamEvents, hasLength(1)); + final resolvedEvent = streamEvents.first; + expect(resolvedEvent.type, EventType.pollCreated); + expect(resolvedEvent.userId, 'user123'); + expect(resolvedEvent.cid, 'channel123'); + expect(resolvedEvent.connectionId, 'conn123'); + expect(resolvedEvent.extraData, {'custom': 'data'}); + }); + + test('should handle resolver modifying event data', () async { + final originalEvent = Event( + type: EventType.messageNew, + userId: 'user123', + extraData: {'original': 'data'}, + ); + + Event? resolver(Event event) { + if (event.type == EventType.messageNew) { + return event.copyWith( + type: EventType.pollCreated, + userId: 'modified_user', + extraData: {'modified': 'data'}, + ); + } + return null; + } + + controller = EventController( + resolvers: [resolver], + ); + + final streamEvents = []; + controller.listen(streamEvents.add); + + controller.add(originalEvent); + + await Future.delayed(Duration.zero); + + expect(streamEvents, hasLength(1)); + final resolvedEvent = streamEvents.first; + expect(resolvedEvent.type, EventType.pollCreated); + expect(resolvedEvent.userId, 'modified_user'); + expect(resolvedEvent.extraData, {'modified': 'data'}); + }); + }); +} diff --git a/packages/stream_chat/test/src/core/util/message_rules_test.dart b/packages/stream_chat/test/src/core/util/message_rules_test.dart index 06f0d3ac62..8f84f2183b 100644 --- a/packages/stream_chat/test/src/core/util/message_rules_test.dart +++ b/packages/stream_chat/test/src/core/util/message_rules_test.dart @@ -57,6 +57,14 @@ void main() { expect(MessageRules.canUpload(message), isTrue); }); + test('should return true for message with shared location', () { + final message = Message( + sharedLocation: Location(latitude: 1, longitude: 2), + ); + + expect(MessageRules.canUpload(message), isTrue); + }); + test('should return false for empty message', () { final message = Message(); diff --git a/packages/stream_chat/test/src/core/util/serializer_test.dart b/packages/stream_chat/test/src/core/util/serializer_test.dart index fbbb96a20c..9ae3fa536b 100644 --- a/packages/stream_chat/test/src/core/util/serializer_test.dart +++ b/packages/stream_chat/test/src/core/util/serializer_test.dart @@ -19,7 +19,7 @@ void main() { 'name': 'Sahil', 'age': 22, 'country': 'India', - } + }, }); }); @@ -30,7 +30,7 @@ void main() { 'name': 'Sahil', 'age': 22, 'country': 'India', - } + }, }); expect(serializer, { 'test': 'test', diff --git a/packages/stream_chat/test/src/db/chat_persistence_client_test.dart b/packages/stream_chat/test/src/db/chat_persistence_client_test.dart index faf69c799f..555d13ecd0 100644 --- a/packages/stream_chat/test/src/db/chat_persistence_client_test.dart +++ b/packages/stream_chat/test/src/db/chat_persistence_client_test.dart @@ -6,6 +6,7 @@ import 'package:stream_chat/src/core/models/draft.dart'; import 'package:stream_chat/src/core/models/draft_message.dart'; import 'package:stream_chat/src/core/models/event.dart'; import 'package:stream_chat/src/core/models/filter.dart'; +import 'package:stream_chat/src/core/models/location.dart'; import 'package:stream_chat/src/core/models/member.dart'; import 'package:stream_chat/src/core/models/message.dart'; import 'package:stream_chat/src/core/models/poll.dart'; @@ -45,24 +46,27 @@ class TestPersistenceClient extends ChatPersistenceClient { Future deletePinnedMessageByCids(List cids) => Future.value(); @override - Future deletePinnedMessageByIds(List messageIds) => - Future.value(); + Future deletePinnedMessageByIds(List messageIds) => Future.value(); @override - Future deleteReactionsByMessageId(List messageIds) => - Future.value(); + Future deleteMessagesFromUser({ + String? cid, + required String userId, + bool hardDelete = false, + DateTime? deletedAt, + }) => throw UnimplementedError(); @override - Future deletePinnedMessageReactionsByMessageId( - List messageIds) => - Future.value(); + Future deleteReactionsByMessageId(List messageIds) => Future.value(); + + @override + Future deletePinnedMessageReactionsByMessageId(List messageIds) => Future.value(); @override Future deletePollVotesByPollIds(List pollIds) => Future.value(); @override - Future deleteDraftMessageByCid(String cid, {String? parentId}) => - Future.value(); + Future deleteDraftMessageByCid(String cid, {String? parentId}) => Future.value(); @override Future disconnect({bool flush = false}) => throw UnimplementedError(); @@ -71,22 +75,21 @@ class TestPersistenceClient extends ChatPersistenceClient { Future flush() => throw UnimplementedError(); @override - Future getChannelByCid(String cid) async => - ChannelModel(cid: cid); + Future getChannelByCid(String cid) async => ChannelModel(cid: cid); @override Future> getChannelCids() => throw UnimplementedError(); @override - Future> getChannelStates( - {Filter? filter, - SortOrder? channelStateSort, - PaginationParams? paginationParams}) => - throw UnimplementedError(); + Future> getChannelStates({ + Filter? filter, + SortOrder? channelStateSort, + int? messageLimit, + PaginationParams? paginationParams, + }) => throw UnimplementedError(); @override - Future>> getChannelThreads(String cid) => - throw UnimplementedError(); + Future>> getChannelThreads(String cid) => throw UnimplementedError(); @override Future getConnectionInfo() => throw UnimplementedError(); @@ -98,14 +101,10 @@ class TestPersistenceClient extends ChatPersistenceClient { Future> getMembersByCid(String cid) async => []; @override - Future> getMessagesByCid(String cid, - {PaginationParams? messagePagination}) async => - []; + Future> getMessagesByCid(String cid, {PaginationParams? messagePagination}) async => []; @override - Future> getPinnedMessagesByCid(String cid, - {PaginationParams? messagePagination}) async => - []; + Future> getPinnedMessagesByCid(String cid, {PaginationParams? messagePagination}) async => []; @override Future> getReadsByCid(String cid) async => []; @@ -114,22 +113,18 @@ class TestPersistenceClient extends ChatPersistenceClient { Future getDraftMessageByCid( String cid, { String? parentId, - }) async => - Draft( - channelCid: cid, - parentId: parentId, - createdAt: DateTime.now(), - message: DraftMessage(id: 'message-id', text: 'message-text'), - ); + }) async => Draft( + channelCid: cid, + parentId: parentId, + createdAt: DateTime.now(), + message: DraftMessage(id: 'message-id', text: 'message-text'), + ); @override - Future> getReplies(String parentId, - {PaginationParams? options}) => - throw UnimplementedError(); + Future> getReplies(String parentId, {PaginationParams? options}) => throw UnimplementedError(); @override - Future updateChannelQueries(Filter? filter, List cids, - {bool clearQueryCache = false}) => + Future updateChannelQueries(Filter? filter, List cids, {bool clearQueryCache = false}) => throw UnimplementedError(); @override @@ -139,15 +134,13 @@ class TestPersistenceClient extends ChatPersistenceClient { Future updateConnectionInfo(Event event) => throw UnimplementedError(); @override - Future updateLastSyncAt(DateTime lastSyncAt) => - throw UnimplementedError(); + Future updateLastSyncAt(DateTime lastSyncAt) => throw UnimplementedError(); @override Future updateReactions(List reactions) => Future.value(); @override - Future updatePinnedMessageReactions(List reactions) => - Future.value(); + Future updatePinnedMessageReactions(List reactions) => Future.value(); @override Future updatePollVotes(List pollVotes) => Future.value(); @@ -156,20 +149,16 @@ class TestPersistenceClient extends ChatPersistenceClient { Future updateUsers(List users) => Future.value(); @override - Future bulkUpdateMembers(Map?> members) => - Future.value(); + Future bulkUpdateMembers(Map?> members) => Future.value(); @override - Future bulkUpdateMessages(Map?> messages) => - Future.value(); + Future bulkUpdateMessages(Map?> messages) => Future.value(); @override - Future bulkUpdatePinnedMessages(Map?> messages) => - Future.value(); + Future bulkUpdatePinnedMessages(Map?> messages) => Future.value(); @override - Future bulkUpdateReads(Map?> reads) => - Future.value(); + Future bulkUpdateReads(Map?> reads) => Future.value(); @override Future deletePollsByIds(List pollIds) => Future.value(); @@ -179,6 +168,21 @@ class TestPersistenceClient extends ChatPersistenceClient { @override Future updateDraftMessages(List draftMessages) => Future.value(); + + @override + Future> getLocationsByCid(String cid) async => []; + + @override + Future getLocationByMessageId(String messageId) async => null; + + @override + Future updateLocations(List locations) => Future.value(); + + @override + Future deleteLocationsByCid(String cid) => Future.value(); + + @override + Future deleteLocationsByMessageIds(List messageIds) => Future.value(); } void main() { @@ -247,8 +251,8 @@ void main() { user: user, ownReactions: [Reaction(type: 'test', user: user)], latestReactions: [Reaction(type: 'test', user: user)], - ) - ] + ), + ], }; persistenceClient.updateChannelThreads(cid, threads); }); @@ -270,7 +274,7 @@ void main() { user: user, ownReactions: [Reaction(type: 'test', user: user)], latestReactions: [Reaction(type: 'test', user: user)], - ) + ), ], pinnedMessages: [ Message( @@ -279,13 +283,10 @@ void main() { user: user, ownReactions: [Reaction(type: 'test', user: user)], latestReactions: [Reaction(type: 'test', user: user)], - ) + ), ], read: [ - Read( - lastRead: DateTime.now(), - user: user, - lastReadMessageId: 'last-test-message'), + Read(lastRead: DateTime.now(), user: user, lastReadMessageId: 'last-test-message'), ], members: [Member(user: user)], ); diff --git a/packages/stream_chat/test/src/fakes.dart b/packages/stream_chat/test/src/fakes.dart index ff2c538e74..4e561b0884 100644 --- a/packages/stream_chat/test/src/fakes.dart +++ b/packages/stream_chat/test/src/fakes.dart @@ -34,8 +34,7 @@ class FakeTokenManager extends Fake implements TokenManager { String userId, { Token? token, TokenProvider? provider, - }) async => - this.token; + }) async => this.token; @override void reset() {} @@ -48,8 +47,8 @@ class FakePersistenceClient extends Fake implements ChatPersistenceClient { FakePersistenceClient({ DateTime? lastSyncAt, List? channelCids, - }) : _lastSyncAt = lastSyncAt, - _channelCids = channelCids ?? []; + }) : _lastSyncAt = lastSyncAt, + _channelCids = channelCids ?? []; String? _userId; bool _isConnected = false; @@ -144,8 +143,7 @@ class FakeChatApi extends Fake implements StreamChatApi { AttachmentFileUploader? _fileUploader; @override - AttachmentFileUploader get fileUploader => - _fileUploader ??= MockAttachmentFileUploader(); + AttachmentFileUploader get fileUploader => _fileUploader ??= MockAttachmentFileUploader(); } class FakeClientState extends Fake implements ClientState { @@ -218,8 +216,7 @@ class FakeWebSocket extends Fake implements WebSocket { ConnectionStatus get connectionStatus => _connectionStatusController.value; @override - Stream get connectionStatusStream => - _connectionStatusController.stream; + Stream get connectionStatusStream => _connectionStatusController.stream; @override Completer? connectionCompleter; @@ -265,8 +262,7 @@ class FakeWebSocketWithConnectionError extends Fake implements WebSocket { ConnectionStatus get connectionStatus => _connectionStatusController.value; @override - Stream get connectionStatusStream => - _connectionStatusController.stream; + Stream get connectionStatusStream => _connectionStatusController.stream; @override Completer? connectionCompleter; @@ -296,8 +292,7 @@ class FakeWebSocketWithConnectionError extends Fake implements WebSocket { class FakeChannelState extends Fake implements ChannelState {} -class FakePartialUpdateMemberResponse extends Fake - implements PartialUpdateMemberResponse { +class FakePartialUpdateMemberResponse extends Fake implements PartialUpdateMemberResponse { FakePartialUpdateMemberResponse({ Member? channelMember, }) : _channelMember = channelMember ?? Member(); diff --git a/packages/stream_chat/test/src/matchers.dart b/packages/stream_chat/test/src/matchers.dart index 752f980373..27a3b823ac 100644 --- a/packages/stream_chat/test/src/matchers.dart +++ b/packages/stream_chat/test/src/matchers.dart @@ -9,8 +9,7 @@ import 'package:stream_chat/src/core/models/message.dart'; import 'package:stream_chat/src/core/models/user.dart'; import 'package:test/test.dart'; -Matcher isSameMultipartFileAs(MultipartFile targetFile) => - _IsSameMultipartFileAs(targetFile: targetFile); +Matcher isSameMultipartFileAs(MultipartFile targetFile) => _IsSameMultipartFileAs(targetFile: targetFile); class _IsSameMultipartFileAs extends Matcher { const _IsSameMultipartFileAs({required this.targetFile}); @@ -18,16 +17,13 @@ class _IsSameMultipartFileAs extends Matcher { final MultipartFile targetFile; @override - Description describe(Description description) => - description.add('is same multipartFile as $targetFile'); + Description describe(Description description) => description.add('is same multipartFile as $targetFile'); @override - bool matches(covariant MultipartFile file, Map matchState) => - file.length == targetFile.length; + bool matches(covariant MultipartFile file, Map matchState) => file.length == targetFile.length; } -Matcher isSameEventAs(Event targetEvent) => - _IsSameEventAs(targetEvent: targetEvent); +Matcher isSameEventAs(Event targetEvent) => _IsSameEventAs(targetEvent: targetEvent); class _IsSameEventAs extends Matcher { const _IsSameEventAs({required this.targetEvent}); @@ -35,12 +31,10 @@ class _IsSameEventAs extends Matcher { final Event targetEvent; @override - Description describe(Description description) => - description.add('is same event as $targetEvent'); + Description describe(Description description) => description.add('is same event as $targetEvent'); @override - bool matches(covariant Event event, Map matchState) => - event.type == targetEvent.type; + bool matches(covariant Event event, Map matchState) => event.type == targetEvent.type; } Matcher isSameMessageAs( @@ -51,16 +45,15 @@ Matcher isSameMessageAs( bool matchAttachments = false, bool matchAttachmentsUploadState = false, bool matchParentId = false, -}) => - _IsSameMessageAs( - targetMessage: targetMessage, - matchText: matchText, - matchReactions: matchReactions, - matchMessageState: matchMessageState, - matchAttachments: matchAttachments, - matchAttachmentsUploadState: matchAttachmentsUploadState, - matchParentId: matchParentId, - ); +}) => _IsSameMessageAs( + targetMessage: targetMessage, + matchText: matchText, + matchReactions: matchReactions, + matchMessageState: matchMessageState, + matchAttachments: matchAttachments, + matchAttachmentsUploadState: matchAttachmentsUploadState, + matchParentId: matchParentId, +); class _IsSameMessageAs extends Matcher { const _IsSameMessageAs({ @@ -82,8 +75,7 @@ class _IsSameMessageAs extends Matcher { final bool matchParentId; @override - Description describe(Description description) => - description.add('is same message as $targetMessage'); + Description describe(Description description) => description.add('is same message as $targetMessage'); @override bool matches(covariant Message message, Map matchState) { @@ -96,19 +88,13 @@ class _IsSameMessageAs extends Matcher { } if (matchReactions) { matches &= const ListEquality().equals( - message.ownReactions - ?.map((it) => '${it.type}-${it.messageId}') - .toList(), - targetMessage.ownReactions - ?.map((it) => '${it.type}-${it.messageId}') - .toList()); + message.ownReactions?.map((it) => '${it.type}-${it.messageId}').toList(), + targetMessage.ownReactions?.map((it) => '${it.type}-${it.messageId}').toList(), + ); matches &= const ListEquality().equals( - message.latestReactions - ?.map((it) => '${it.type}-${it.messageId}') - .toList(), - targetMessage.latestReactions - ?.map((it) => '${it.type}-${it.messageId}') - .toList()); + message.latestReactions?.map((it) => '${it.type}-${it.messageId}').toList(), + targetMessage.latestReactions?.map((it) => '${it.type}-${it.messageId}').toList(), + ); } if (matchAttachments) { bool matchAttachments() { @@ -142,13 +128,12 @@ Matcher isSameDraftMessageAs( bool matchText = false, bool matchAttachments = false, bool matchParentId = false, -}) => - _IsSameDraftMessageAs( - targetMessage: targetMessage, - matchText: matchText, - matchAttachments: matchAttachments, - matchParentId: matchParentId, - ); +}) => _IsSameDraftMessageAs( + targetMessage: targetMessage, + matchText: matchText, + matchAttachments: matchAttachments, + matchParentId: matchParentId, +); class _IsSameDraftMessageAs extends Matcher { const _IsSameDraftMessageAs({ @@ -164,8 +149,7 @@ class _IsSameDraftMessageAs extends Matcher { final bool matchParentId; @override - Description describe(Description description) => - description.add('is same draft message as $targetMessage'); + Description describe(Description description) => description.add('is same draft message as $targetMessage'); @override bool matches(covariant DraftMessage message, Map matchState) { @@ -199,11 +183,10 @@ class _IsSameDraftMessageAs extends Matcher { Matcher isSameAttachmentAs( Attachment targetAttachment, { bool matchUploadState = false, -}) => - _IsSameAttachmentAs( - targetAttachment: targetAttachment, - matchUploadState: matchUploadState, - ); +}) => _IsSameAttachmentAs( + targetAttachment: targetAttachment, + matchUploadState: matchUploadState, +); class _IsSameAttachmentAs extends Matcher { const _IsSameAttachmentAs({ @@ -215,8 +198,7 @@ class _IsSameAttachmentAs extends Matcher { final bool matchUploadState; @override - Description describe(Description description) => - description.add('is same attachment as $targetAttachment'); + Description describe(Description description) => description.add('is same attachment as $targetAttachment'); @override bool matches(covariant Attachment attachment, Map matchState) { @@ -236,15 +218,13 @@ class _IsSameUserAs extends Matcher { final User targetUser; @override - Description describe(Description description) => - description.add('is same user as $targetUser'); + Description describe(Description description) => description.add('is same user as $targetUser'); @override bool matches(covariant User user, Map matchState) => user.id == targetUser.id; } -Matcher isCorrectChannelFor(ChannelState channelState) => - _IsCorrectChannelFor(channelState: channelState); +Matcher isCorrectChannelFor(ChannelState channelState) => _IsCorrectChannelFor(channelState: channelState); class _IsCorrectChannelFor extends Matcher { const _IsCorrectChannelFor({required this.channelState}); @@ -252,10 +232,8 @@ class _IsCorrectChannelFor extends Matcher { final ChannelState channelState; @override - Description describe(Description description) => - description.add('is correct channel for $channelState'); + Description describe(Description description) => description.add('is correct channel for $channelState'); @override - bool matches(covariant Channel channel, Map matchState) => - channel.cid == channelState.channel?.cid; + bool matches(covariant Channel channel, Map matchState) => channel.cid == channelState.channel?.cid; } diff --git a/packages/stream_chat/test/src/mocks.dart b/packages/stream_chat/test/src/mocks.dart index 5aed398bce..a845274bc4 100644 --- a/packages/stream_chat/test/src/mocks.dart +++ b/packages/stream_chat/test/src/mocks.dart @@ -1,7 +1,6 @@ import 'package:dio/dio.dart'; import 'package:logging/logging.dart'; import 'package:mocktail/mocktail.dart'; -import 'package:rxdart/rxdart.dart'; import 'package:stream_chat/src/client/channel.dart'; import 'package:stream_chat/src/client/channel_delivery_reporter.dart'; import 'package:stream_chat/src/client/client.dart'; @@ -19,6 +18,7 @@ import 'package:stream_chat/src/core/http/stream_http_client.dart'; import 'package:stream_chat/src/core/http/token_manager.dart'; import 'package:stream_chat/src/core/models/channel_config.dart'; import 'package:stream_chat/src/core/models/event.dart'; +import 'package:stream_chat/src/core/util/event_controller.dart'; import 'package:stream_chat/src/db/chat_persistence_client.dart'; import 'package:stream_chat/src/event_type.dart'; import 'package:stream_chat/src/ws/websocket.dart'; @@ -67,8 +67,7 @@ class MockModerationApi extends Mock implements ModerationApi {} class MockGeneralApi extends Mock implements GeneralApi {} -class MockAttachmentFileUploader extends Mock - implements AttachmentFileUploader {} +class MockAttachmentFileUploader extends Mock implements AttachmentFileUploader {} class MockPersistenceClient extends Mock implements ChatPersistenceClient { String? _userId; @@ -106,7 +105,7 @@ class MockStreamChatClient extends Mock implements StreamChatClient { @override Stream get eventStream => _eventController.stream; - final _eventController = PublishSubject(); + final _eventController = EventController(); void addEvent(Event event) => _eventController.add(event); @override @@ -117,11 +116,10 @@ class MockStreamChatClient extends Mock implements StreamChatClient { String? eventType4, ]) { if (eventType == null || eventType == EventType.any) return eventStream; - return eventStream.where((event) => - event.type == eventType || - event.type == eventType2 || - event.type == eventType3 || - event.type == eventType4); + return eventStream.where( + (event) => + event.type == eventType || event.type == eventType2 || event.type == eventType3 || event.type == eventType4, + ); } @override @@ -132,8 +130,7 @@ class MockStreamChatClientWithPersistence extends MockStreamChatClient { ChatPersistenceClient? _persistenceClient; @override - ChatPersistenceClient get chatPersistenceClient => - _persistenceClient ??= MockPersistenceClient(); + ChatPersistenceClient get chatPersistenceClient => _persistenceClient ??= MockPersistenceClient(); @override bool get persistenceEnabled => true; @@ -166,13 +163,10 @@ class MockRetryQueueChannel extends Mock implements Channel { String? eventType3, String? eventType4, ]) { - return client - .on(eventType, eventType2, eventType3, eventType4) - .where((e) => e.cid == cid); + return client.on(eventType, eventType2, eventType3, eventType4).where((e) => e.cid == cid); } } class MockWebSocket extends Mock implements WebSocket {} -class MockChannelDeliveryReporter extends Mock - implements ChannelDeliveryReporter {} +class MockChannelDeliveryReporter extends Mock implements ChannelDeliveryReporter {} diff --git a/packages/stream_chat/test/src/utils.dart b/packages/stream_chat/test/src/utils.dart index 768d2fe296..dd3030d94c 100644 --- a/packages/stream_chat/test/src/utils.dart +++ b/packages/stream_chat/test/src/utils.dart @@ -28,5 +28,4 @@ extension IntX on num { } // Top level util function to delay the code execution -Future delay(num milliseconds) => - Future.delayed(Duration(milliseconds: milliseconds.toInt())); +Future delay(num milliseconds) => Future.delayed(Duration(milliseconds: milliseconds.toInt())); diff --git a/packages/stream_chat/test/src/ws/websocket_test.dart b/packages/stream_chat/test/src/ws/websocket_test.dart index 053a8d78ff..5509310083 100644 --- a/packages/stream_chat/test/src/ws/websocket_test.dart +++ b/packages/stream_chat/test/src/ws/websocket_test.dart @@ -24,8 +24,7 @@ void main() { WebSocketChannel channelProvider( Uri uri, { Iterable? protocols, - }) => - webSocketChannel; + }) => webSocketChannel; webSocket = WebSocket( apiKey: 'api-key', diff --git a/packages/stream_chat_flutter/CHANGELOG.md b/packages/stream_chat_flutter/CHANGELOG.md index 32cc64588a..7abe91ef8b 100644 --- a/packages/stream_chat_flutter/CHANGELOG.md +++ b/packages/stream_chat_flutter/CHANGELOG.md @@ -1,3 +1,144 @@ +## Upcoming + +🔄 Internal / Non-breaking + +- Composer UI primitives (`StreamMessageComposerInputField`, `VoiceRecordingCallback`, and the outer/inner layout containers) are now owned by `stream_chat_flutter` and exported from this package. They were previously supplied by `stream_core_flutter`. The public API of `StreamMessageComposer` / `StreamChatMessageInput` and its sub-components is unchanged. + +🛑️ Breaking + +- Renamed `StreamMessageComposer.messageInputController` parameter to `messageComposerController`. +- Removed `StreamDraftListView`, `StreamDraftListTile`, `StreamDraftListTileTheme`, and `StreamDraftListTileThemeData` from the SDK. Also removed `StreamChatThemeData.draftListTileTheme`. Refer to the sample app for a reference implementation using `StreamDraftListController` and `PagedValueListView`. +- Renamed `StreamMessageComposerInput` → `StreamMessageComposerInputCenter` (and `DefaultStreamMessageComposerInput` → `DefaultStreamMessageComposerInputCenter`). The name `StreamMessageComposerInput` is now the input container widget (assembles header, leading, center, trailing). +- Renamed `MessageComposerInputProps` → `MessageComposerInputCenterProps`. The name `MessageComposerInputProps` now refers to the new container widget's props. +- Renamed `messageComposerInput` builder key in `streamChatComponentBuilders` → `messageComposerInputCenter`. The name `messageComposerInput` now overrides the whole input container. +- Replaced `StreamMessageInput.hintGetter` with `placeholderBuilder` over a sealed `MessageInputPlaceholder`. + See [`migrations/redesign/message_composer.md`](../../migrations/redesign/message_composer.md). +- Removed `StreamMessageListView.unreadIndicatorBuilder`; use `StreamComponentFactory.jumpToUnreadButton`. +- Renamed `UnreadIndicatorButton.onTap` → `onJumpTap`. +- Renamed stream icons to remove the size suffix from the icon names. +- Removed `StreamAttachmentUploadStateBuilder.successBuilder` and the `SuccessBuilder` typedef (unreachable). +- Removed `StreamFileAttachmentThumbnail`; use `StreamImageAttachmentThumbnail` / `StreamVideoAttachmentThumbnail` or `StreamFileTypeIcon.fromMimeType(...)`. +- Removed `StreamMessageThemeData` (ownMessageTheme / otherMessageTheme) and `StreamMessageInputThemeData` (messageInputTheme). +- Removed `StreamChannelPreviewThemeData` (channelPreviewTheme). +- Renamed `StreamPollOptionsDialog` / `StreamPollResultsDialog` / `StreamPollOptionVotesDialog` / `StreamPollCommentsDialog` (and their `show*` helpers and `...DialogThemeData` types) → `...Sheet`. They now render as modal bottom sheets. +- Replaced `StreamPollCreatorDialog` / `StreamPollCreatorFullScreenDialog` (and `showStreamPollCreatorDialog`) with a single `StreamPollCreatorSheet` (`showStreamPollCreatorSheet`) that renders as a modal bottom sheet, matching the other poll sheets. `StreamPollCreatorWidget` gained an optional `scrollController` parameter. +- Removed `primaryActionStyle` and `secondaryActionStyle` from `StreamPollCreatorThemeData`. Use the new `sheetHeaderStyle.trailingStyle` / `sheetHeaderStyle.leadingStyle` instead — see [`migrations/redesign/attachments_and_polls.md`](../../migrations/redesign/attachments_and_polls.md). +- Redesigned `StreamPollOptionsSheetThemeData`, `StreamPollResultsSheetThemeData`, `StreamPollOptionVotesSheetThemeData` and `StreamPollCommentsSheetThemeData` — see [`migrations/redesign/attachments_and_polls.md`](../../migrations/redesign/attachments_and_polls.md). +- Renamed `Translations.questionsLabel` getter → `questionLabel({bool isPlural = false})` method. +- Renamed `Translations.endVoteConfirmationText` → `endVoteConfirmationTitle`; English default changed to `'End This Poll?'`. +- Reworded `Translations.endVoteLabel` English default to `'End Poll'`. +- Removed `AttachmentButton`, `StreamQuotedMessageWidget`, `EditMessageSheet`, `StreamMessageSendButton` and `DesktopReactionsBuilder`. +- Removed `StreamChannelGridView`, `StreamChannelGridTile` and `StreamMessageSearchGridView`. +- `StreamMessageActionConfirmationModal.cancelActionTitle` / `confirmActionTitle` are now nullable and fall back to `Translations.cancelLabel` / `confirmLabel`. +- Renamed `Translations.attachmentsUploadProgressText` parameter `remaining` → `completed`. +- Updated several `Translations` default strings and added new abstract members — see [`migrations/redesign/localizations.md`](../../migrations/redesign/localizations.md). +- Renamed `MuteIconPosition` → `AttributePosition` (values `title` → `inlineTitle`, `subtitle` → `trailingBottom`) and `StreamChannelListItemThemeData.muteIconPosition` → `attributePosition`. Now controls both mute and pin icons in `StreamChannelListTile`. +- Removed `AttachmentModalSheet`, `ErrorAlertSheet` and `StreamChannelInfoBottomSheet`. +- Removed `StreamMarkdownMessage`; use `StreamMessageText` (re-exported from `stream_core_flutter`) instead. +- Renamed message item components to align with Android Compose and SwiftUI conventions: + - `StreamMessageWidget` → `StreamMessageItem` (file moved to `message_item.dart`). + - `DefaultStreamMessage` → `DefaultStreamMessageItem`. + - `StreamMessageWidgetProps` → `StreamMessageItemProps`. + - `StreamMessageWidgetBuilder` typedef → `StreamMessageItemBuilder`. + - `StreamMessageAnnotations` → `StreamMessageHeader`. + - `StreamMessageMetadata` → `StreamMessageFooter`. + - `ParseAttachments` → `StreamMessageAttachments`. + - `streamChatComponentBuilders(messageWidget: ...)` → `messageItem: ...`. + - `StreamMessageContent(annotation: ..., metadata: ...)` → `header: ..., footer: ...`. + See [`migrations/redesign/message_widget.md`](../../migrations/redesign/message_widget.md). +- Redesigned the full-screen media viewer: + - Replaced `StreamFullScreenMedia` (and its `StreamFullScreenMediaBuilder` / `FullScreenMediaWidget` / `FullScreenMediaDesktop` / `fsm_stub` variants) with a single cross-platform `StreamMediaGalleryPreview`. Renamed `mediaAttachmentPackages` → `attachments`, `startIndex` → `initialIndex`; dropped `userName`, `sentAt`, `onReplyMessage`, `onShowMessage` and `attachmentActionsModalBuilder` from its surface. + - Renamed `StreamGalleryHeader` → `StreamMediaGalleryPreviewHeader` and `StreamGalleryFooter` → `StreamMediaGalleryPreviewFooter`, rebuilt on `StreamAppBar` / `StreamBottomAppBar`. Both shrank to a `title` (+ optional `subtitle`) and minimal action callbacks; the more-actions overflow header was removed. + - Renamed `StreamAttachmentPackage` → `StreamMediaGalleryAttachment` and added a `Message.toMediaGalleryAttachments({filter})` extension. + - Removed `VideoPackage`, `DesktopVideoPackage` and `GalleryNavigationItem` from the public API — each preview page now owns its own player state internally. + - Removed `StreamMessageItem.onShowMessage` / `attachmentActionsModalBuilder` and the matching `StreamMessageListView` / `StreamMessageContent` props; those callbacks no longer have a destination after the gallery overflow was removed. + - Removed `StreamChatThemeData.galleryHeaderTheme`, `StreamChatThemeData.galleryFooterTheme` (and the `imageFooterTheme:` constructor parameter) and the `StreamGalleryFooterThemeData` class. Header / footer chrome now flows through `StreamAppBarThemeData` / `StreamBottomAppBarThemeData`. + - Removed the unused `StreamAvatarThemeData`. + See [`migrations/redesign/media_viewer.md`](../../migrations/redesign/media_viewer.md). + +✅ Added + +- Video attachments use the shared `StreamVideoPlayIndicator` for the play-button overlay. +- Redesigned `StreamSystemMessage` / `StreamModeratedMessage` with a pill-shaped style and visual customisation props. +- Added visual customisation props to `ThreadSeparator` and `UnreadMessagesSeparator`. +- Added `StreamUnsupportedAttachment` and `UnsupportedAttachmentBuilder` for unrecognised attachment types. +- Added `StreamMediaGallery` — a 3-up thumbnail grid that pairs with `StreamMediaGalleryPreview`. Each tile surfaces the sender's avatar (`StreamUserAvatar`) plus a video duration badge for video attachments. Used by `StreamMediaGalleryPreviewFooter`'s gallery-grid sheet and ready to drop into channel-level media listings. Customisable via `streamChatComponentBuilders(mediaGallery: ...)`. +- Added `StreamMediaGalleryPreview` — the redesigned full-screen swipeable viewer. Built on the design system's `StreamMediaViewer`, `StreamAppBar` and `StreamBottomAppBar`. Customisable via `streamChatComponentBuilders(mediaGalleryPreview: ...)`. Exposes `StreamMediaGalleryPreviewScope` so per-page widgets (e.g. videos) can react to the active page. +- Added `StreamVideoPlayer` — the platform-aware video backend used by `StreamMediaGalleryPreview`. Pauses itself when its page is no longer active and resumes on return. +- Added `Translations.photosAndVideosLabel` — used by the footer's thumbnail-grid sheet header. +- Added `StreamQuotedMessage` and `StreamQuotedMessageThemeData` for the quoted message preview. +- `MessagePreviewFormatter` now renders `AttachmentType.urlPreview` messages with a link icon and caption / OG title / `linkAttachmentText` fallback. +- Added `StreamPollCardStyle`, `StreamPollQuestionStyle` and `StreamPollOptionVotesStyle` shared style classes for the poll sheets. +- Added a total vote count footer and per-option "View all" action to `StreamPollResultsSheet`. +- Added a `sheetHeaderStyle` field to each poll sheet theme data (including `StreamPollCreatorThemeData`). +- Re-exported `StreamSheetHeader`, `StreamSheetHeaderStyle`, `StreamSheetHeaderTheme` and `StreamSheetHeaderThemeData` from `stream_core_flutter`. +- Re-exported `showStreamSheet`, `StreamSheet`, `StreamSheetDragHandle`, `StreamSheetRoute`, `StreamSheetTransition`, `StreamSheetScrollableWidgetBuilder`, `StreamSheetTheme` and `StreamSheetThemeData` from `stream_core_flutter`. Poll sheets (`StreamPollOptionsSheet`, `StreamPollResultsSheet`, `StreamPollOptionVotesSheet`, `StreamPollCommentsSheet`, `StreamPollCreatorSheet`) now present as Stream-styled modal bottom sheets via `showStreamSheet`. See [`migrations/redesign/attachments_and_polls.md`](../../migrations/redesign/attachments_and_polls.md). +- Re-exported `StreamMessageAttachment` and `StreamMessageAttachmentStyle` from `stream_core_flutter`. +- Added a `BoxFit? fit` parameter to `ThumbnailSizeCalculator.calculate` (null defaults to `BoxFit.scaleDown`, matching `paintImage`) so callers using `cover` / `fill` get a bitmap large enough to render without upscale blur. +- Added `Translations.totalVoteCountLabel({int? count})`, `viewAllLabel`, `pollVotesLabel`, `endVoteConfirmationMessage` and `questionLabel({bool isPlural = false})`. +- Added `Translations.reactionsCountText(int count)` for the reaction-detail sheet header. +- Added `StreamChannelListTile.isPinned` — renders a pin icon alongside the existing mute icon for pinned channels. +- Added `StreamChatConfigurationData.reactionOverlap` and `StreamMessageReactions.overlap` to control whether reactions overlap the message bubble edge. When unset, falls back to the platform-based default (overlap on mobile, no overlap on desktop and web). +- Exported `StreamScrollViewLoadMoreError` and `StreamScrollViewLoadMoreIndicator` from the public API. +- Exported `StreamTimestamp`, `DateFormatter`, `formatDate` and `formatRecentDateTime` from the public API. + +🔄 Changed + +- Changed the default `StreamChat.backgroundKeepAlive` from 1 minute to 15 seconds, + matching `StreamChatCore`. See `stream_chat_flutter_core` changelog for rationale. +- `StreamPhotoGalleryTile` now auto-sizes the platform thumbnail request from the tile's layout × DPR (132px fallback) instead of always asking for 400×400, so cells decode only what they paint. Pass an explicit `thumbnailSize` to override. + +🐞 Fixed + +- Fixed `StreamCommandAutocompleteOptions` and `StreamMentionAutocompleteOptions` expanding to half the screen height — both now cap at a fixed max height (208px / 176px) and scroll internally so the list can't dominate the screen or overlap the header. +- Fixed the "Add an option" button in the poll creator looking like a tappable empty option row while disabled. The button is now hidden when adding a new option isn't allowed (an existing option is empty, or the maximum has been reached) instead of rendering as a disabled lookalike. +- Fixed voice recording duration label jumping by ~1 second when playback starts. The recording timer tracks duration in whole seconds, so the stored value can be up to 1 second longer than the actual audio file. The player now resolves this by keeping the larger of the stored and player-reported durations, matching the strategy used by the iOS SDK. +- Fixed voice message time label displaying elapsed time instead of remaining time. +- Fixed RTL layout for the scroll-to-bottom button, swipe-to-reply icon, and voice recording lock button. +- Fixed draft stream updates clobbering the composer while editing a message. +- Fixed retries of in-flight sends being routed through `updateMessage` instead of `sendMessage`. +- Fixed composer attachment button staying rotated at 45° after the inline picker was dismissed. +- Fixed composer focus being lost after selecting a command from the attachment picker. +- Fixed gallery attachment picker empty placeholder using an oversized icon. +- Fixed gallery attachment picker permission placeholder not being centered and inconsistent grid and "add more" tile spacing. +- Fixed `EndOfFrameCallbackWidget` showing a generic error string when its callback throws. +- Fixed unintended Material ink ripple on message tap. +- Standardised empty and error states across channel, thread, draft, member, user, message search, poll vote, reaction and photo gallery scroll views. +- Standardised list separators across thread, draft, member, user and message search scroll views to match the channel scroll view. +- Fixed `StreamPhotoGalleryTile` missing rounded corners and its badges not mirroring in RTL. +- Fixed the "thread reply" action showing on parent messages while already inside their thread view. +- Fixed poll vote progress bars re-animating from zero whenever the poll message was re-mounted in the message list (e.g. on jump-to-message, scrolling, or after new messages were sent). +- Voice recording playback now pauses automatically when the app moves to the background. +- Fixed voice recording playback resetting on the first tap after the app returned from the background. `StreamVoiceRecordingAttachmentPlaylist.didUpdateWidget` now compares the projected playlist (content-based equality on track uri / title / duration / waveform) instead of the raw `Attachment` list, so re-syncs that mint fresh client-side `Attachment` UUIDs no longer trip a needless `updatePlaylist()` mid-playback. +- Fixed `PollAddCommentDialog` and `PollSuggestOptionDialog` accepting whitespace-only or unchanged submissions; the confirm action now disables when the trimmed text is empty or matches the initial value. +- Fixed `StreamPhotoGalleryTile` using a hand-rolled icon as its loading placeholder and silently rendering nothing on decode failure. Now uses the shared `StreamImageLoadingPlaceholder` while loading and `StreamImageErrorPlaceholder` if the thumbnail fails to load. +- Fixed poll, attachment-action, and message-action dialog buttons rendering their labels in uppercase (e.g. `CANCEL`, `SEND`, `FLAG`, `DELETE`); they now use the localized labels as-is so they match the rest of the system. +- Fixed tapping a quoted parent message inside a thread doing nothing (or kicking back to the channel). The thread message list now resolves the parent slot directly and scrolls/highlights it instead of falling through to `loadChannelAtMessage`. +- Fixed the jump-to-message highlight starting before the scroll settled, which made the fade barely visible (or invisible if the target hadn't been mounted yet). The message list now awaits the scroll, then plays a 1s hold + 1s ease-out fade — closer to the highlight feel in Slack's permalink jump. + +## 10.0.0-beta.13 + +🛑️ Breaking +- SDK Redesign Changes. For more details, please refer to the [migration guide](https://github.com/GetStream/stream-chat-flutter/blob/210ff93f955be3f85c62e860309bd9aa240a5446/migrations). + The SDK redesign introduces a fresher default UI, but also better APIs for customization of the components. + +## 10.0.0-beta.12 + +🐞 Fixed + +- Fixed regression in `emoji_code` support for reactions. + +🛑️ Breaking + +- Replaced `ArgumentError` with typed errors in `StreamAttachmentPickerController`: + `AttachmentTooLargeError` (file size exceeds limit) and `AttachmentLimitReachedError` + (attachment count exceeds limit). [[#2476]](https://github.com/GetStream/stream-chat-flutter/issues/2476) +- By default the preview of the last message will show deleted message indicator instead of filtering it out. + +For more details, please refer to the [migration guide](../../migrations/v10-migration.md). + +- Included the changes from version [`9.23.0`](https://pub.dev/packages/stream_chat_flutter/changelog). + ## 9.23.0 🐞 Fixed @@ -6,6 +147,10 @@ - Fixed audio tone bleeding into recorded voice message when playing custom feedback sound on recording start. - Fixed poll dialog AppBar back button color not being themeable. [[#2484]](https://github.com/GetStream/stream-chat-flutter/issues/2484) +## 10.0.0-beta.11 + +- Included the changes from version [`9.22.0`](https://pub.dev/packages/stream_chat_flutter/changelog). + ## 9.22.0 ✅ Added @@ -21,6 +166,10 @@ - Fixed focus randomly shifting to poll title while editing option text in poll creator. [[#2464]](https://github.com/GetStream/stream-chat-flutter/issues/2464) +## 10.0.0-beta.10 + +- Included the changes from version [`9.21.0`](https://pub.dev/packages/stream_chat_flutter/changelog). + ## 9.21.0 🐞 Fixed @@ -28,6 +177,78 @@ - Fixed StreamGallery not respecting the safe area for fullscreen media. [[#2454]](https://github.com/GetStream/stream-chat-flutter/issues/2454) +## 10.0.0-beta.9 + +✅ Added + +- Added `reactionIndicatorBuilder` parameter to `StreamMessageWidget` for customizing reaction + indicators. Users can now display reaction counts alongside emojis on mobile, matching desktop/web + behavior. Fixes [#2434](https://github.com/GetStream/stream-chat-flutter/issues/2434). + ```dart + // Example: Show reaction count next to emoji + StreamMessageWidget( + message: message, + reactionIndicatorBuilder: (context, message, onTap) { + return StreamReactionIndicator( + message: message, + onTap: onTap, + reactionIcons: StreamChatConfiguration.of(context).reactionIcons, + reactionIconBuilder: (context, icon) { + final count = message.reactionGroups?[icon.type]?.count ?? 0; + return Row( + children: [ + icon.build(context), + const SizedBox(width: 4), + Text('$count'), + ], + ); + }, + ); + }, + ) + ``` + +- Added `reactionIconBuilder` and `backgroundColor` parameters to `StreamReactionPicker`. +- Exported `StreamReactionIndicator` and related components (`ReactionIndicatorBuilder`, + `ReactionIndicatorIconBuilder`, `ReactionIndicatorIcon`, `ReactionIndicatorIconList`). + +🛑️ Breaking + +- `onAttachmentTap` callback signature changed to include `BuildContext` as first parameter and + returns `FutureOr` to indicate if handled. + ```dart + // Before + StreamMessageWidget( + message: message, + onAttachmentTap: (message, attachment) { + // Could only override - no way to fallback to default behavior + if (attachment.type == 'location') { + showLocationDialog(context, attachment); + } + // Other attachment types lost default behavior + }, + ) + + // After + StreamMessageWidget( + message: message, + onAttachmentTap: (context, message, attachment) async { + if (attachment.type == 'location') { + await showLocationDialog(context, attachment); + return true; // Handled by custom logic + } + return false; // Use default behavior for images, videos, URLs, etc. + }, + ) + ``` + +- `ReactionPickerIconList` constructor changed: removed `message` parameter, changed `reactionIcons` + type to `List`, renamed `onReactionPicked` to `onIconPicked`. + +For more details, please refer to the [migration guide](../../migrations/v10-migration.md). + +- Included the changes from version [`9.20.0`](https://pub.dev/packages/stream_chat_flutter/changelog). + ## 9.20.0 ✅ Added @@ -47,6 +268,67 @@ - Fixed high memory usage when displaying multiple image attachments. [[#2228]](https://github.com/GetStream/stream-chat-flutter/issues/2228) +## 10.0.0-beta.8 + +🛑️ Breaking + +- `onCustomAttachmentPickerResult` has been removed. Use `onAttachmentPickerResult` which returns `FutureOr` to indicate if the result was handled. + ```dart + // Before + StreamMessageInput( + onCustomAttachmentPickerResult: (result) { + // Handle custom location attachment + final location = result.data['location']; + sendLocationMessage(location); + }, + ) + + // After + StreamMessageInput( + onAttachmentPickerResult: (result) { + if (result is CustomAttachmentPickerResult) { + // Handle custom location attachment + final location = result.data['location']; + sendLocationMessage(location); + return true; // Skip default handling + } + return false; // Use default handling for built-in types + }, + ) + ``` + +- `customAttachmentPickerOptions` has been removed. Use `attachmentPickerOptionsBuilder` to modify, reorder, or extend default options. + ```dart + // Before - could only add custom options + StreamMessageInput( + customAttachmentPickerOptions: [ + TabbedAttachmentPickerOption( + key: 'location', + icon: Icon(Icons.location_on), + // ... + ), + ], + ) + + // After - can now modify, filter, or extend default options + StreamMessageInput( + attachmentPickerOptionsBuilder: (context, defaultOptions) { + return [ + ...defaultOptions, // Keep all default options + TabbedAttachmentPickerOption( + key: 'location', + icon: Icon(Icons.location_on), + // ... + ), + ]; + }, + ) + ``` + +For more details, please refer to the [migration guide](../../migrations/v10-migration.md). + +- Included the changes from version [`9.19.0`](https://pub.dev/packages/stream_chat_flutter/changelog). + ## 9.19.0 ✅ Added @@ -55,6 +337,14 @@ floating date divider. - Added spacing to typing indicator. +## 10.0.0-beta.7 + +✅ Added + +- Added support for `StreamMessageWidget.deletedMessageBuilder` to customize the deleted message UI. + +- Included the changes from version [`9.18.0`](https://pub.dev/packages/stream_chat_flutter/changelog). + ## 9.18.0 🐞 Fixed @@ -62,6 +352,15 @@ - Fixed `StreamMessageListView` not marking thread messages as read when scrolled to the bottom of the list. - Fixed `StreamMessageInput` not validating draft messages before creating/updating them. +## 10.0.0-beta.6 + +🐞 Fixed + +- Fixed users with `sendReply` capability unable to send replies in threads. +- Fixed delete/flag message dialogs executing action when dialog is dismissed without confirmation. + +- Included the changes from version [`9.17.0`](https://pub.dev/packages/stream_chat_flutter/changelog). + ## 9.17.0 ✅ Added @@ -79,6 +378,10 @@ - Fixed `GradientAvatars` for users with same-length IDs would have identical colors. [[#2369]](https://github.com/GetStream/stream-chat-flutter/issues/2369) +## 10.0.0-beta.5 + +- Included the changes from version [`9.16.0`](https://pub.dev/packages/stream_chat_flutter/changelog). + ## 9.16.0 🐞 Fixed @@ -90,7 +393,17 @@ ✅ Added -- Added `padding` and `textInputMargin` to `StreamMessageInput` to allow fine-tuning the layout. +- Added `padding` and `textInputMargin` to `StreamMessageInput` to allow fine-tuning the layout. + +## 10.0.0-beta.4 + +✅ Added + +- Added `emojiCode` property to `StreamReactionIcon` to support custom emojis in reactions. +- Updated default reaction builders with standard emoji codes. (`❤️`, `👍`, `👎`, `😂`, `😮`) +- Added `StreamChatConfiguration.maybeOf()` method for safe context access in async operations. + +- Included the changes from version [`9.15.0`](https://pub.dev/packages/stream_chat_flutter/changelog). ## 9.15.0 @@ -104,6 +417,32 @@ - Fixed `StreamMessageInput` crashes with "Null check operator used on a null value" when async operations continue after widget unmounting. +## 10.0.0-beta.3 + +🛑️ Breaking + +- **Deprecated API Cleanup**: Removed all deprecated classes, methods, and properties for the v10 major release: + - **Removed Classes**: `DmCheckbox` (use `DmCheckboxListTile`), `StreamIconThemeSvgIcon` (use `StreamSvgIcon`), `StreamVoiceRecordingThemeData` (use `StreamVoiceRecordingAttachmentThemeData`), `StreamVoiceRecordingLoading`, `StreamVoiceRecordingSlider` (use `StreamAudioWaveformSlider`), `StreamVoiceRecordingPlayer` (use `StreamVoiceRecordingAttachment`), `StreamVoiceRecordingListPlayer` (use `StreamVoiceRecordingAttachmentPlaylist`) + - **Removed Properties**: `reactionIcons` and `voiceRecordingTheme` from `StreamChatTheme`, `isThreadConversation` from `FloatingDateDivider`, `idleSendButton` and `activeSendButton` from `StreamMessageInput`, `isCommandEnabled` and `isEditEnabled` from `StreamMessageSendButton`, `assetName`, `width`, and `height` from `StreamSvgIcon` + - **Removed Constructor Parameters**: `useNativeAttachmentPickerOnMobile` from various components, `allowCompression` from `StreamAttachmentHandler.pickFile()` and `StreamFilePicker` (use `compressionQuality` instead), `cid` from `StreamUnreadIndicator` constructor + - **Removed Methods**: `lastUnreadMessage()` from message list extensions (use `StreamChannel.getFirstUnreadMessage`), `loadBuffer()` and `_loadAsync()` from `StreamVideoThumbnailImage` + - **StreamSvgIcon Refactoring**: Removed 80+ deprecated factory constructors. Use `StreamSvgIcon(icon: StreamSvgIcons.iconName)` instead of factory constructors like `StreamSvgIcon.add()` +- `PollMessage` widget has been removed and replaced with `PollAttachment` for better integration with the attachment system. Polls can now be customized through `PollAttachmentBuilder` or by creating custom poll attachment widgets via the attachment builder system. +- `AttachmentPickerType` enum has been replaced with a sealed class to support extensible custom types like contact and location pickers. Use built-in types like `AttachmentPickerType.images` or define your own via `CustomAttachmentPickerType`. +- `StreamAttachmentPickerOption` has been replaced with two sealed classes to support layout-specific picker options: `SystemAttachmentPickerOption` for system pickers (e.g. camera, files) and `TabbedAttachmentPickerOption` for tabbed pickers (e.g. gallery, polls, location). +- `showStreamAttachmentPickerModalBottomSheet` now returns a `StreamAttachmentPickerResult` instead of `AttachmentPickerValue` for improved type safety and clearer intent handling. +- `StreamMobileAttachmentPickerBottomSheet` has been renamed to `StreamTabbedAttachmentPickerBottomSheet`, and `StreamWebOrDesktopAttachmentPickerBottomSheet` has been renamed to `StreamSystemAttachmentPickerBottomSheet` to better reflect their respective layouts. + +For more details, please refer to the [migration guide](../../migrations/v10-migration.md). + +✅ Added + +- Added `extraData` field to `AttachmentPickerValue` to support storing and retrieving custom picker state (e.g. tab-specific config). +- Added `customAttachmentPickerOptions` to `StreamMessageInput` to allow injecting custom picker tabs like contact and location pickers. +- Added `onCustomAttachmentPickerResult` callback to `StreamMessageInput` to handle results returned by custom picker tabs. + +- Included the changes from version [`9.14.0`](https://pub.dev/packages/stream_chat_flutter/changelog). + ## 9.14.0 🐞 Fixed @@ -111,6 +450,10 @@ - Fixed `StreamMessageInput` tries to expand to full height when used in a unconstrained environment. - Fixed `StreamCommandAutocompleteOptions` to style the command name with `textHighEmphasis` style. +## 10.0.0-beta.2 + +- Included the changes from version [`9.13.0`](https://pub.dev/packages/stream_chat_flutter/changelog). + ## 9.13.0 🐞 Fixed @@ -120,6 +463,38 @@ - Fixed `ScrollToBottom` button always showing when the latest message was too big and exceeded the viewport main axis size. +## 10.0.0-beta.1 + +🛑️ Breaking + +- `StreamReactionPicker` now requires reactions to be explicitly handled via `onReactionPicked`. *(Automatic handling is no longer supported.)* +- `StreamMessageAction` is now generic `(StreamMessageAction)`, enhancing type safety. Individual onTap callbacks have been removed; actions are now handled centrally by widgets like `StreamMessageWidget.onCustomActionTap` or modals using action types. +- `StreamMessageReactionsModal` no longer requires the `messageTheme` parameter. The theme now automatically derives from the `reverse` property. +- `StreamMessageWidget` no longer requires the `showReactionTail` parameter. The reaction picker tail is now always shown when the reaction picker is visible. + +For more details, please refer to the [migration guide](../../migrations/v10-migration.md). + +✅ Added + +- Added new `StreamMessageActionsBuilder` which provides a list of actions to be displayed in the message actions modal. +- Added new `StreamMessageActionConfirmationModal` for confirming destructive actions like delete or flag. +- Added new `StreamMessageModal` and `showStreamMessageModal` for consistent message-related modals with improved transitions and backdrop effects. + ```dart + showStreamMessageModal( + context: context, + ...other parameters, + builder: (context) => StreamMessageModal( + ...other parameters, + headerBuilder: (context) => YourCustomHeader(), + contentBuilder: (context) => YourCustomContent(), + ), + ); + ``` +- Added `desktopOrWeb` parameter to `PlatformWidgetBuilder` to allow specifying a single builder for both desktop and web platforms. +- Added `reactionPickerBuilder` to `StreamMessageActionsModal`, `StreamMessageReactionsModal`, and `StreamMessageWidget` to enable custom reaction picker widgets. +- Added `StreamReactionIcon.defaultReactions` providing a predefined list of common reaction icons. +- Exported `StreamMessageActionsModal` and `StreamModeratedMessageActionsModal` which are now based on `StreamMessageModal` for consistent styling and behavior. + ## 9.12.0 ✅ Added diff --git a/packages/stream_chat_flutter/README.md b/packages/stream_chat_flutter/README.md index 379a15eb9a..75d834ea90 100644 --- a/packages/stream_chat_flutter/README.md +++ b/packages/stream_chat_flutter/README.md @@ -99,9 +99,9 @@ Every widget uses the `StreamChat` or `StreamChannel` widgets to manage the stat - [StreamChannelHeader](https://getstream.io/chat/docs/sdk/flutter/stream_chat_flutter/stream_channel_header/) - [StreamChannelListView](https://getstream.io/chat/docs/sdk/flutter/stream_chat_flutter/stream_channel_list_view/) -- [StreamMessageInput](https://getstream.io/chat/docs/sdk/flutter/stream_chat_flutter/stream_message_input/) +- [StreamMessageComposer](https://getstream.io/chat/docs/sdk/flutter/stream_chat_flutter/stream_message_composer/) - [StreamMessageListView](https://getstream.io/chat/docs/sdk/flutter/stream_chat_flutter/stream_message_list_view/) -- [StreamMessageWidget](https://getstream.io/chat/docs/sdk/flutter/stream_chat_flutter/stream_message_widget/) +- [StreamMessageItem](https://getstream.io/chat/docs/sdk/flutter/stream_chat_flutter/stream_message_widget/) - [StreamChatTheme](https://getstream.io/chat/docs/sdk/flutter/stream_chat_flutter/stream_chat_and_theming/) - ... diff --git a/packages/stream_chat_flutter/dart_test.yaml b/packages/stream_chat_flutter/dart_test.yaml new file mode 100644 index 0000000000..c329c9c85d --- /dev/null +++ b/packages/stream_chat_flutter/dart_test.yaml @@ -0,0 +1,5 @@ +# The existence of this file prevents warnings about unrecognized tags when running Alchemist tests. + +tags: + golden: + timeout: 15s \ No newline at end of file diff --git a/packages/stream_chat_flutter/example/lib/debug/channel_page.dart b/packages/stream_chat_flutter/example/lib/debug/channel_page.dart index a1d4b6a419..a5719b1a16 100644 --- a/packages/stream_chat_flutter/example/lib/debug/channel_page.dart +++ b/packages/stream_chat_flutter/example/lib/debug/channel_page.dart @@ -39,8 +39,7 @@ class _DebugChannelPageState extends State { _channelSubscription = _channel.state!.channelStateStream.listen((state) { setState(() => _channelState = state); }); - _ownUserSubscription = - _channel.client.state.currentUserStream.listen((ownUser) { + _ownUserSubscription = _channel.client.state.currentUserStream.listen((ownUser) { setState(() => _ownUser = ownUser); }); } @@ -54,10 +53,8 @@ class _DebugChannelPageState extends State { @override Widget build(BuildContext context) { - final members = - _channelState?.members ?? _channel.state?.members ?? const []; - final mutes = - _ownUser?.mutes ?? _channel.client.state.currentUser?.mutes ?? const []; + final members = _channelState?.members ?? _channel.state?.members ?? const []; + final mutes = _ownUser?.mutes ?? _channel.client.state.currentUser?.mutes ?? const []; //SingleChildScrollView return Scaffold( appBar: AppBar( diff --git a/packages/stream_chat_flutter/example/lib/main.dart b/packages/stream_chat_flutter/example/lib/main.dart index 3518d0056f..8bc7908ed5 100644 --- a/packages/stream_chat_flutter/example/lib/main.dart +++ b/packages/stream_chat_flutter/example/lib/main.dart @@ -1,8 +1,6 @@ // ignore_for_file: public_member_api_docs import 'dart:async'; -import 'dart:math' as math; -import 'dart:ui'; import 'package:flutter/material.dart'; import 'package:responsive_builder/responsive_builder.dart'; @@ -195,16 +193,16 @@ class _ChannelListPageState extends State { @override Widget build(BuildContext context) => Scaffold( - body: StreamChannelListView( - onChannelTap: widget.onTap, - controller: _listController, - itemBuilder: (context, channels, index, defaultWidget) { - return defaultWidget.copyWith( - selected: channels[index] == widget.selectedChannel, - ); - }, - ), - ); + body: StreamChannelListView( + onChannelTap: widget.onTap, + controller: _listController, + itemBuilder: (context, channels, index, defaultWidget) { + return defaultWidget.copyWith( + selected: channels[index] == widget.selectedChannel, + ); + }, + ), + ); } class ChannelPage extends StatefulWidget { @@ -222,120 +220,59 @@ class ChannelPage extends StatefulWidget { } class _ChannelPageState extends State { - late final messageInputController = StreamMessageInputController(); + late final messageComposerController = StreamMessageComposerController(); final focusNode = FocusNode(); @override Widget build(BuildContext context) { return Scaffold( appBar: StreamChannelHeader( - onBackPressed: widget.onBackPressed != null - ? () { - widget.onBackPressed!(context); - } - : null, - onImageTap: () { - Navigator.push( - context, - MaterialPageRoute( - builder: (context) { - return StreamChannel( - channel: StreamChannel.of(context).channel, - child: const DebugChannelPage(), - ); - }, - ), - ); + leading: switch ((widget.showBackButton, widget.onBackPressed)) { + (true, final cb?) => StreamBackButton( + channelId: StreamChannel.of(context).channel.cid, + onPressed: () => cb(context), + showUnreadCount: true, + ), + (true, null) => StreamBackButton( + channelId: StreamChannel.of(context).channel.cid, + showUnreadCount: true, + ), + _ => const SizedBox(), }, - showBackButton: widget.showBackButton, + trailing: GestureDetector( + onTap: () { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) { + return StreamChannel( + channel: StreamChannel.of(context).channel, + child: const DebugChannelPage(), + ); + }, + ), + ); + }, + child: StreamChannelAvatar( + size: .lg, + channel: StreamChannel.of(context).channel, + ), + ), ), body: Column( children: [ Expanded( child: StreamMessageListView( threadBuilder: (_, parent) => ThreadPage(parent: parent!), - messageBuilder: ( - context, - messageDetails, - messages, - defaultWidget, - ) { - // The threshold after which the message is considered - // swiped. - const threshold = 0.2; - - final isMyMessage = messageDetails.isMyMessage; - - // The direction in which the message can be swiped. - final swipeDirection = isMyMessage - ? SwipeDirection.endToStart // - : SwipeDirection.startToEnd; - - return Swipeable( - key: ValueKey(messageDetails.message.id), - direction: swipeDirection, - swipeThreshold: threshold, - onSwiped: (details) => reply(messageDetails.message), - backgroundBuilder: (context, details) { - // The alignment of the swipe action. - final alignment = isMyMessage - ? Alignment.centerRight // - : Alignment.centerLeft; - - // The progress of the swipe action. - final progress = - math.min(details.progress, threshold) / threshold; - - // The offset for the reply icon. - var offset = Offset.lerp( - const Offset(-24, 0), - const Offset(12, 0), - progress, - )!; - - // If the message is mine, we need to flip the offset. - if (isMyMessage) { - offset = Offset(-offset.dx, -offset.dy); - } - - final _streamTheme = StreamChatTheme.of(context); - - return Align( - alignment: alignment, - child: Transform.translate( - offset: offset, - child: Opacity( - opacity: progress, - child: SizedBox.square( - dimension: 30, - child: CustomPaint( - painter: AnimatedCircleBorderPainter( - progress: progress, - color: _streamTheme.colorTheme.borders, - ), - child: Center( - child: StreamSvgIcon( - icon: StreamSvgIcons.reply, - size: lerpDouble(0, 18, progress), - color: _streamTheme.colorTheme.accentPrimary, - ), - ), - ), - ), - ), - ), - ); - }, - child: defaultWidget.copyWith(onReplyTap: reply), - ); - }, + onReplyTap: reply, + swipeToReply: true, ), ), - StreamMessageInput( + StreamMessageComposer( enableVoiceRecording: true, - onQuotedMessageCleared: messageInputController.clearQuotedMessage, + onQuotedMessageCleared: messageComposerController.clearQuotedMessage, focusNode: focusNode, - messageInputController: messageInputController, + messageComposerController: messageComposerController, ), ], ), @@ -343,7 +280,7 @@ class _ChannelPageState extends State { } void reply(Message message) { - messageInputController.quotedMessage = message; + messageComposerController.quotedMessage = message; WidgetsBinding.instance.addPostFrameCallback((timeStamp) { focusNode.requestFocus(); }); @@ -352,7 +289,7 @@ class _ChannelPageState extends State { @override void dispose() { focusNode.dispose(); - messageInputController.dispose(); + messageComposerController.dispose(); super.dispose(); } } @@ -378,9 +315,9 @@ class ThreadPage extends StatelessWidget { parentMessage: parent, ), ), - StreamMessageInput( + StreamMessageComposer( enableVoiceRecording: true, - messageInputController: StreamMessageInputController( + messageComposerController: StreamMessageComposerController( message: Message(parentId: parent.id), ), ), diff --git a/packages/stream_chat_flutter/example/lib/split_view.dart b/packages/stream_chat_flutter/example/lib/split_view.dart index 6f75b71a69..02ff86c320 100644 --- a/packages/stream_chat_flutter/example/lib/split_view.dart +++ b/packages/stream_chat_flutter/example/lib/split_view.dart @@ -113,11 +113,11 @@ class _ChannelListPageState extends State { @override Widget build(BuildContext context) => Scaffold( - body: StreamChannelListView( - onChannelTap: widget.onTap, - controller: _listController, - ), - ); + body: StreamChannelListView( + onChannelTap: widget.onTap, + controller: _listController, + ), + ); } class ChannelPage extends StatelessWidget { @@ -127,20 +127,20 @@ class ChannelPage extends StatelessWidget { @override Widget build(BuildContext context) => Navigator( - onGenerateRoute: (settings) => MaterialPageRoute( - builder: (context) => const Scaffold( - appBar: StreamChannelHeader( - showBackButton: false, - ), - body: Column( - children: [ - Expanded( - child: StreamMessageListView(), - ), - StreamMessageInput(), - ], + onGenerateRoute: (settings) => MaterialPageRoute( + builder: (context) => Scaffold( + appBar: const StreamChannelHeader( + automaticallyImplyLeading: false, + ), + body: Column( + children: [ + const Expanded( + child: StreamMessageListView(), ), - ), + StreamMessageComposer(), + ], ), - ); + ), + ), + ); } diff --git a/packages/stream_chat_flutter/example/lib/tutorial_part_1.dart b/packages/stream_chat_flutter/example/lib/tutorial_part_1.dart index ac6908e3e8..f316e5249c 100644 --- a/packages/stream_chat_flutter/example/lib/tutorial_part_1.dart +++ b/packages/stream_chat_flutter/example/lib/tutorial_part_1.dart @@ -28,7 +28,7 @@ import 'package:stream_chat_flutter/stream_chat_flutter.dart'; /// /// - We create a single [ChannelPage] widget under [StreamChat] with three /// widgets: [StreamChannelHeader], [StreamMessageListView] -/// and [StreamMessageInput] +/// and [StreamMessageComposer] /// /// If you now run the simulator you will see a single channel UI. Future main() async { @@ -91,14 +91,14 @@ class ChannelPage extends StatelessWidget { @override // ignore: prefer_expression_function_bodies Widget build(BuildContext context) { - return const Scaffold( - appBar: StreamChannelHeader(), + return Scaffold( + appBar: const StreamChannelHeader(), body: Column( children: [ - Expanded( + const Expanded( child: StreamMessageListView(), ), - StreamMessageInput(), + StreamMessageComposer(), ], ), ); diff --git a/packages/stream_chat_flutter/example/lib/tutorial_part_2.dart b/packages/stream_chat_flutter/example/lib/tutorial_part_2.dart index 8516cb40aa..78b7dec215 100644 --- a/packages/stream_chat_flutter/example/lib/tutorial_part_2.dart +++ b/packages/stream_chat_flutter/example/lib/tutorial_part_2.dart @@ -126,14 +126,14 @@ class ChannelPage extends StatelessWidget { @override Widget build(BuildContext context) { - return const Scaffold( - appBar: StreamChannelHeader(), + return Scaffold( + appBar: const StreamChannelHeader(), body: Column( children: [ - Expanded( + const Expanded( child: StreamMessageListView(), ), - StreamMessageInput(), + StreamMessageComposer(), ], ), ); diff --git a/packages/stream_chat_flutter/example/lib/tutorial_part_3.dart b/packages/stream_chat_flutter/example/lib/tutorial_part_3.dart index 8e5c52142f..91c639b921 100644 --- a/packages/stream_chat_flutter/example/lib/tutorial_part_3.dart +++ b/packages/stream_chat_flutter/example/lib/tutorial_part_3.dart @@ -17,7 +17,7 @@ import 'package:stream_chat_flutter/stream_chat_flutter.dart'; /// /// We're passing a custom widget /// to [StreamChannelListView.itemBuilder]; -/// this will override the default [StreamChannelListTile] and allows you +/// this will override the default [StreamChannelListItem] and allows you /// to create one yourself. /// /// There are a couple interesting things we do in this widget: @@ -96,27 +96,27 @@ class _ChannelListPageState extends State { @override Widget build(BuildContext context) => Scaffold( - body: StreamChannelListView( - controller: _listController, - itemBuilder: _channelPreviewBuilder, - onChannelTap: (channel) { - Navigator.of(context).push( - MaterialPageRoute( - builder: (context) => StreamChannel( - channel: channel, - child: const ChannelPage(), - ), - ), - ); - }, - ), - ); + body: StreamChannelListView( + controller: _listController, + itemBuilder: _channelPreviewBuilder, + onChannelTap: (channel) { + Navigator.of(context).push( + MaterialPageRoute( + builder: (context) => StreamChannel( + channel: channel, + child: const ChannelPage(), + ), + ), + ); + }, + ), + ); Widget _channelPreviewBuilder( BuildContext context, List channels, int index, - StreamChannelListTile defaultTile, + StreamChannelListItem defaultTile, ) { final channel = channels[index]; final lastMessage = channel.state?.messages.reversed.firstWhereOrNull( @@ -142,13 +142,11 @@ class _ChannelListPageState extends State { channel: channel, ), title: StreamChannelName( - textStyle: StreamChannelPreviewTheme.of(context).titleStyle!.copyWith( - color: StreamChatTheme.of(context) - .colorTheme - .textHighEmphasis - // ignore: deprecated_member_use - .withOpacity(opacity), - ), + textStyle: StreamChannelListItemTheme.of(context).titleStyle!.copyWith( + color: StreamChatTheme.of(context).colorTheme.textHighEmphasis + // ignore: deprecated_member_use + .withOpacity(opacity), + ), channel: channel, ), subtitle: Text(subtitle), @@ -169,14 +167,14 @@ class ChannelPage extends StatelessWidget { @override Widget build(BuildContext context) { - return const Scaffold( - appBar: StreamChannelHeader(), + return Scaffold( + appBar: const StreamChannelHeader(), body: Column( children: [ - Expanded( + const Expanded( child: StreamMessageListView(), ), - StreamMessageInput(), + StreamMessageComposer(), ], ), ); diff --git a/packages/stream_chat_flutter/example/lib/tutorial_part_4.dart b/packages/stream_chat_flutter/example/lib/tutorial_part_4.dart index 905753f7e1..dcf77b4098 100644 --- a/packages/stream_chat_flutter/example/lib/tutorial_part_4.dart +++ b/packages/stream_chat_flutter/example/lib/tutorial_part_4.dart @@ -82,20 +82,20 @@ class _ChannelListPageState extends State { @override Widget build(BuildContext context) => Scaffold( - body: StreamChannelListView( - controller: _listController, - onChannelTap: (channel) { - Navigator.of(context).push( - MaterialPageRoute( - builder: (context) => StreamChannel( - channel: channel, - child: const ChannelPage(), - ), - ), - ); - }, - ), - ); + body: StreamChannelListView( + controller: _listController, + onChannelTap: (channel) { + Navigator.of(context).push( + MaterialPageRoute( + builder: (context) => StreamChannel( + channel: channel, + child: const ChannelPage(), + ), + ), + ); + }, + ), + ); } class ChannelPage extends StatelessWidget { @@ -116,7 +116,7 @@ class ChannelPage extends StatelessWidget { ), ), ), - const StreamMessageInput(), + StreamMessageComposer(), ], ), ); @@ -144,8 +144,8 @@ class ThreadPage extends StatelessWidget { parentMessage: parent, ), ), - StreamMessageInput( - messageInputController: StreamMessageInputController( + StreamMessageComposer( + messageComposerController: StreamMessageComposerController( message: Message(parentId: parent!.id), ), ), diff --git a/packages/stream_chat_flutter/example/lib/tutorial_part_5.dart b/packages/stream_chat_flutter/example/lib/tutorial_part_5.dart index ef205abfe5..b62f519f21 100644 --- a/packages/stream_chat_flutter/example/lib/tutorial_part_5.dart +++ b/packages/stream_chat_flutter/example/lib/tutorial_part_5.dart @@ -120,7 +120,7 @@ class ChannelPage extends StatelessWidget { messageBuilder: _messageBuilder, ), ), - const StreamMessageInput(), + StreamMessageComposer(), ], ), ); @@ -128,13 +128,10 @@ class ChannelPage extends StatelessWidget { Widget _messageBuilder( BuildContext context, - MessageDetails details, - List messages, - StreamMessageWidget _, + Message message, + StreamMessageItemProps defaultProps, ) { - final message = details.message; - final isCurrentUser = - StreamChat.of(context).currentUser!.id == message.user!.id; + final isCurrentUser = StreamChat.of(context).currentUser!.id == message.user!.id; final textAlign = isCurrentUser ? TextAlign.right : TextAlign.left; final color = isCurrentUser ? Colors.blueGrey : Colors.blue; diff --git a/packages/stream_chat_flutter/example/lib/tutorial_part_6.dart b/packages/stream_chat_flutter/example/lib/tutorial_part_6.dart index 7ef017dacf..bdb73b53f9 100644 --- a/packages/stream_chat_flutter/example/lib/tutorial_part_6.dart +++ b/packages/stream_chat_flutter/example/lib/tutorial_part_6.dart @@ -60,13 +60,7 @@ class MyApp extends StatelessWidget { ), ); final defaultTheme = StreamChatThemeData.fromTheme(themeData); - final colorTheme = defaultTheme.colorTheme; final customTheme = StreamChatThemeData( - channelPreviewTheme: StreamChannelPreviewThemeData( - avatarTheme: StreamAvatarThemeData( - borderRadius: BorderRadius.circular(8), - ), - ), messageListViewTheme: const StreamMessageListViewThemeData( backgroundColor: Colors.grey, backgroundImage: DecorationImage( @@ -74,19 +68,6 @@ class MyApp extends StatelessWidget { fit: BoxFit.cover, ), ), - ownMessageTheme: const StreamMessageThemeData( - urlAttachmentTitleMaxLine: 1, - ), - otherMessageTheme: StreamMessageThemeData( - messageBackgroundColor: colorTheme.textHighEmphasis, - messageTextStyle: TextStyle( - color: colorTheme.barsBg, - ), - avatarTheme: StreamAvatarThemeData( - borderRadius: BorderRadius.circular(8), - ), - urlAttachmentTitleMaxLine: 1, - ), ).merge(defaultTheme); return MaterialApp( @@ -165,7 +146,7 @@ class ChannelPage extends StatelessWidget { ), ), ), - const StreamMessageInput(), + StreamMessageComposer(), ], ), ); @@ -193,8 +174,8 @@ class ThreadPage extends StatelessWidget { parentMessage: parent, ), ), - StreamMessageInput( - messageInputController: StreamMessageInputController( + StreamMessageComposer( + messageComposerController: StreamMessageComposerController( message: Message(parentId: parent!.id), ), ), diff --git a/packages/stream_chat_flutter/example/linux/flutter/generated_plugins.cmake b/packages/stream_chat_flutter/example/linux/flutter/generated_plugins.cmake index 516e21cdf6..809de92757 100644 --- a/packages/stream_chat_flutter/example/linux/flutter/generated_plugins.cmake +++ b/packages/stream_chat_flutter/example/linux/flutter/generated_plugins.cmake @@ -12,6 +12,7 @@ list(APPEND FLUTTER_PLUGIN_LIST ) list(APPEND FLUTTER_FFI_PLUGIN_LIST + jni ) set(PLUGIN_BUNDLED_LIBRARIES) diff --git a/packages/stream_chat_flutter/example/pubspec.yaml b/packages/stream_chat_flutter/example/pubspec.yaml index 7bb126031d..f3e8eed3e9 100644 --- a/packages/stream_chat_flutter/example/pubspec.yaml +++ b/packages/stream_chat_flutter/example/pubspec.yaml @@ -16,8 +16,8 @@ version: 1.0.0+1 # 2. Add it to the melos.yaml file for future updates. environment: - sdk: ^3.6.2 - flutter: ">=3.27.4" + sdk: ^3.10.0 + flutter: ">=3.38.1" dependencies: collection: ^1.17.2 @@ -25,9 +25,9 @@ dependencies: flutter: sdk: flutter responsive_builder: ^0.7.0 - stream_chat_flutter: ^9.23.0 - stream_chat_localizations: ^9.23.0 - stream_chat_persistence: ^9.23.0 + stream_chat_flutter: ^10.0.0-beta.13 + stream_chat_localizations: ^10.0.0-beta.13 + stream_chat_persistence: ^10.0.0-beta.13 flutter: uses-material-design: true diff --git a/packages/stream_chat_flutter/example/windows/flutter/generated_plugins.cmake b/packages/stream_chat_flutter/example/windows/flutter/generated_plugins.cmake index 8d857477c7..0fc96fdcf1 100644 --- a/packages/stream_chat_flutter/example/windows/flutter/generated_plugins.cmake +++ b/packages/stream_chat_flutter/example/windows/flutter/generated_plugins.cmake @@ -16,6 +16,7 @@ list(APPEND FLUTTER_PLUGIN_LIST ) list(APPEND FLUTTER_FFI_PLUGIN_LIST + jni ) set(PLUGIN_BUNDLED_LIBRARIES) diff --git a/packages/stream_chat_flutter/lib/conditional_parent_builder/README.md b/packages/stream_chat_flutter/lib/conditional_parent_builder/README.md index f4cd55e3df..6cf55761bf 100644 --- a/packages/stream_chat_flutter/lib/conditional_parent_builder/README.md +++ b/packages/stream_chat_flutter/lib/conditional_parent_builder/README.md @@ -22,4 +22,4 @@ ConditionalParentBuilder( ), ``` This example can be found in the `stream_chat_flutter` source code under -`src/message_widget/message_widget.dart`. \ No newline at end of file +`src/message_widget/stream_message_item.dart`. \ No newline at end of file diff --git a/packages/stream_chat_flutter/lib/conditional_parent_builder/conditional_parent_builder.dart b/packages/stream_chat_flutter/lib/conditional_parent_builder/conditional_parent_builder.dart index 8314757de3..c85017201a 100644 --- a/packages/stream_chat_flutter/lib/conditional_parent_builder/conditional_parent_builder.dart +++ b/packages/stream_chat_flutter/lib/conditional_parent_builder/conditional_parent_builder.dart @@ -5,10 +5,11 @@ import 'package:flutter/material.dart'; /// {@template parentBuilder} /// A function that provides the [BuildContext] and the [child] widget. /// {@endtemplate} -typedef ParentBuilder = Widget Function( - BuildContext context, - Widget child, -); +typedef ParentBuilder = + Widget Function( + BuildContext context, + Widget child, + ); /// {@template conditionalParentBuilder} /// A widget that allows developers to conditionally wrap the [child] widget @@ -32,7 +33,7 @@ typedef ParentBuilder = Widget Function( /// ), /// ``` /// This example can be found in the `stream_chat_flutter` source code under -/// `src/message_widget/message_widget.dart`. +/// `src/message_widget/stream_message_item.dart`. /// {@endtemplate} class ConditionalParentBuilder extends StatelessWidget { /// {@macro conditionalParentBuilder} diff --git a/packages/stream_chat_flutter/lib/platform_widget_builder/src/desktop_widget.dart b/packages/stream_chat_flutter/lib/platform_widget_builder/src/desktop_widget.dart index f820eb2c13..5bc8129188 100644 --- a/packages/stream_chat_flutter/lib/platform_widget_builder/src/desktop_widget.dart +++ b/packages/stream_chat_flutter/lib/platform_widget_builder/src/desktop_widget.dart @@ -26,14 +26,11 @@ class DesktopWidget extends DesktopWidgetBase { final PlatformBuilder? linux; @override - Widget createMacosWidget(BuildContext context) => - macOS?.call(context) ?? const Empty(); + Widget createMacosWidget(BuildContext context) => macOS?.call(context) ?? const Empty(); @override - Widget createWindowsWidget(BuildContext context) => - windows?.call(context) ?? const Empty(); + Widget createWindowsWidget(BuildContext context) => windows?.call(context) ?? const Empty(); @override - Widget createLinuxWidget(BuildContext context) => - linux?.call(context) ?? const Empty(); + Widget createLinuxWidget(BuildContext context) => linux?.call(context) ?? const Empty(); } diff --git a/packages/stream_chat_flutter/lib/platform_widget_builder/src/desktop_widget_base.dart b/packages/stream_chat_flutter/lib/platform_widget_builder/src/desktop_widget_base.dart index f5a42a7bd7..9a1a822dfc 100644 --- a/packages/stream_chat_flutter/lib/platform_widget_builder/src/desktop_widget_base.dart +++ b/packages/stream_chat_flutter/lib/platform_widget_builder/src/desktop_widget_base.dart @@ -3,9 +3,10 @@ import 'package:flutter/material.dart' show Theme; import 'package:flutter/widgets.dart'; /// A generic widget builder function. -typedef PlatformBuilder = T Function( - BuildContext context, -); +typedef PlatformBuilder = + T Function( + BuildContext context, + ); /// An abstract class used as a building block for creating /// [DesktopPlatformWidget]s. @@ -21,8 +22,7 @@ typedef PlatformBuilder = T Function( /// * M = macOS /// * W = Windows /// * L = Linux -abstract class DesktopWidgetBase extends StatelessWidget { +abstract class DesktopWidgetBase extends StatelessWidget { /// Builds a [DesktopWidgetBase]. const DesktopWidgetBase({super.key}); diff --git a/packages/stream_chat_flutter/lib/platform_widget_builder/src/desktop_widget_builder.dart b/packages/stream_chat_flutter/lib/platform_widget_builder/src/desktop_widget_builder.dart index 923678617d..5aafeee047 100644 --- a/packages/stream_chat_flutter/lib/platform_widget_builder/src/desktop_widget_builder.dart +++ b/packages/stream_chat_flutter/lib/platform_widget_builder/src/desktop_widget_builder.dart @@ -2,10 +2,11 @@ import 'package:flutter/widgets.dart'; import 'package:stream_chat_flutter/platform_widget_builder/src/desktop_widget.dart'; /// A widget-building function that includes the child widget. -typedef DesktopTargetBuilder = Widget? Function( - BuildContext context, - Widget? child, -)?; +typedef DesktopTargetBuilder = + Widget? Function( + BuildContext context, + Widget? child, + )?; /// A widget that utilizes [DesktopWidgetBuilder]s to build different widgets /// for each specified desktop platform. diff --git a/packages/stream_chat_flutter/lib/platform_widget_builder/src/platform_widget.dart b/packages/stream_chat_flutter/lib/platform_widget_builder/src/platform_widget.dart index ff558ef1e7..426556b08c 100644 --- a/packages/stream_chat_flutter/lib/platform_widget_builder/src/platform_widget.dart +++ b/packages/stream_chat_flutter/lib/platform_widget_builder/src/platform_widget.dart @@ -26,14 +26,11 @@ class PlatformWidget extends PlatformWidgetBase { final PlatformBuilder? web; @override - Widget createDesktopWidget(BuildContext context) => - desktop?.call(context) ?? const Empty(); + Widget createDesktopWidget(BuildContext context) => desktop?.call(context) ?? const Empty(); @override - Widget createMobileWidget(BuildContext context) => - mobile?.call(context) ?? const Empty(); + Widget createMobileWidget(BuildContext context) => mobile?.call(context) ?? const Empty(); @override - Widget createWebWidget(BuildContext context) => - web?.call(context) ?? const Empty(); + Widget createWebWidget(BuildContext context) => web?.call(context) ?? const Empty(); } diff --git a/packages/stream_chat_flutter/lib/platform_widget_builder/src/platform_widget_base.dart b/packages/stream_chat_flutter/lib/platform_widget_builder/src/platform_widget_base.dart index 0487bd4396..8bf30cc361 100644 --- a/packages/stream_chat_flutter/lib/platform_widget_builder/src/platform_widget_base.dart +++ b/packages/stream_chat_flutter/lib/platform_widget_builder/src/platform_widget_base.dart @@ -2,9 +2,10 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/widgets.dart'; /// A generic widget builder function. -typedef PlatformBuilder = T Function( - BuildContext context, -); +typedef PlatformBuilder = + T Function( + BuildContext context, + ); /// An abstract class used as a building block for creating [PlatformWidget]s. /// @@ -21,8 +22,7 @@ typedef PlatformBuilder = T Function( /// * M = Mobile /// * D = Desktop /// * W = Web -abstract class PlatformWidgetBase extends StatelessWidget { +abstract class PlatformWidgetBase extends StatelessWidget { /// Builds a [PlatformWidgetBase]. const PlatformWidgetBase({ super.key, diff --git a/packages/stream_chat_flutter/lib/platform_widget_builder/src/platform_widget_builder.dart b/packages/stream_chat_flutter/lib/platform_widget_builder/src/platform_widget_builder.dart index 13b0a13cda..ef5e4db01c 100644 --- a/packages/stream_chat_flutter/lib/platform_widget_builder/src/platform_widget_builder.dart +++ b/packages/stream_chat_flutter/lib/platform_widget_builder/src/platform_widget_builder.dart @@ -2,10 +2,11 @@ import 'package:flutter/widgets.dart'; import 'package:stream_chat_flutter/platform_widget_builder/src/platform_widget.dart'; /// A widget-building function that includes the child widget. -typedef PlatformTargetBuilder = Widget? Function( - BuildContext context, - Widget? child, -)?; +typedef PlatformTargetBuilder = + Widget? Function( + BuildContext context, + Widget? child, + )?; /// A widget that utilizes [PlatformTargetBuilder]s to build different widgets /// for each specified platform. @@ -25,6 +26,7 @@ class PlatformWidgetBuilder extends StatelessWidget { this.mobile, this.desktop, this.web, + this.desktopOrWeb, }); /// The child widget. @@ -39,12 +41,21 @@ class PlatformWidgetBuilder extends StatelessWidget { /// The widget to build for web platforms. final PlatformTargetBuilder? web; + /// The widget to build for desktop or web platforms. + /// + /// Note: The widget will prefer the [desktop] or [web] widget if a + /// combination of desktop/web and desktopOrWeb is provided. + final PlatformTargetBuilder? desktopOrWeb; + @override Widget build(BuildContext context) { + final webWidget = web ?? desktopOrWeb; + final desktopWidget = desktop ?? desktopOrWeb; + return PlatformWidget( - desktop: (context) => desktop?.call(context, child), + desktop: (context) => desktopWidget?.call(context, child), mobile: (context) => mobile?.call(context, child), - web: (context) => web?.call(context, child), + web: (context) => webWidget?.call(context, child), ); } } diff --git a/packages/stream_chat_flutter/lib/scrollable_positioned_list/src/element_registry.dart b/packages/stream_chat_flutter/lib/scrollable_positioned_list/src/element_registry.dart index 03f274f387..21d3b59446 100644 --- a/packages/stream_chat_flutter/lib/scrollable_positioned_list/src/element_registry.dart +++ b/packages/stream_chat_flutter/lib/scrollable_positioned_list/src/element_registry.dart @@ -38,9 +38,9 @@ class _RegistryWidgetState extends State { @override Widget build(BuildContext context) => _InheritedRegistryWidget( - state: this, - child: widget.child, - ); + state: this, + child: widget.child, + ); } class _InheritedRegistryWidget extends InheritedWidget { @@ -66,30 +66,25 @@ class _RegisteredElement extends ProxyElement { @override void mount(Element? parent, dynamic newSlot) { super.mount(parent, newSlot); - final _inheritedRegistryWidget = - dependOnInheritedWidgetOfExactType<_InheritedRegistryWidget>()!; + final _inheritedRegistryWidget = dependOnInheritedWidgetOfExactType<_InheritedRegistryWidget>()!; _registryWidgetState = _inheritedRegistryWidget.state; _registryWidgetState.registeredElements.add(this); - _registryWidgetState.widget.elementNotifier?.value = - _registryWidgetState.registeredElements; + _registryWidgetState.widget.elementNotifier?.value = _registryWidgetState.registeredElements; } @override void didChangeDependencies() { super.didChangeDependencies(); - final _inheritedRegistryWidget = - dependOnInheritedWidgetOfExactType<_InheritedRegistryWidget>()!; + final _inheritedRegistryWidget = dependOnInheritedWidgetOfExactType<_InheritedRegistryWidget>()!; _registryWidgetState = _inheritedRegistryWidget.state; _registryWidgetState.registeredElements.add(this); - _registryWidgetState.widget.elementNotifier?.value = - _registryWidgetState.registeredElements; + _registryWidgetState.widget.elementNotifier?.value = _registryWidgetState.registeredElements; } @override void unmount() { _registryWidgetState.registeredElements.remove(this); - _registryWidgetState.widget.elementNotifier?.value = - _registryWidgetState.registeredElements; + _registryWidgetState.widget.elementNotifier?.value = _registryWidgetState.registeredElements; super.unmount(); } } diff --git a/packages/stream_chat_flutter/lib/scrollable_positioned_list/src/item_positions_listener.dart b/packages/stream_chat_flutter/lib/scrollable_positioned_list/src/item_positions_listener.dart index d2752a00b2..808a2906fd 100644 --- a/packages/stream_chat_flutter/lib/scrollable_positioned_list/src/item_positions_listener.dart +++ b/packages/stream_chat_flutter/lib/scrollable_positioned_list/src/item_positions_listener.dart @@ -51,9 +51,7 @@ class ItemPosition { itemTrailingEdge == other.itemTrailingEdge; @override - int get hashCode => - 31 * (31 * (index.hashCode + 7) + itemLeadingEdge.hashCode) + - itemTrailingEdge.hashCode; + int get hashCode => 31 * (31 * (index.hashCode + 7) + itemLeadingEdge.hashCode) + itemTrailingEdge.hashCode; @override String toString() => diff --git a/packages/stream_chat_flutter/lib/scrollable_positioned_list/src/positioned_list.dart b/packages/stream_chat_flutter/lib/scrollable_positioned_list/src/positioned_list.dart index df08329cf8..a04f7c3397 100644 --- a/packages/stream_chat_flutter/lib/scrollable_positioned_list/src/positioned_list.dart +++ b/packages/stream_chat_flutter/lib/scrollable_positioned_list/src/positioned_list.dart @@ -47,9 +47,9 @@ class PositionedList extends StatefulWidget { this.findChildIndexCallback, this.keyboardDismissBehavior = ScrollViewKeyboardDismissBehavior.manual, }) : assert( - (positionedIndex == 0) || (positionedIndex < itemCount), - 'positionedIndex must be 0 or a value less than itemCount', - ); + (positionedIndex == 0) || (positionedIndex < itemCount), + 'positionedIndex must be 0 or a value less than itemCount', + ); /// Number of items the [itemBuilder] can produce. final int itemCount; @@ -186,81 +186,78 @@ class _PositionedListState extends State { @override Widget build(BuildContext context) => RegistryWidget( - elementNotifier: registeredElements, - child: UnboundedCustomScrollView( - anchor: widget.alignment, - center: _centerKey, - controller: scrollController, - scrollDirection: widget.scrollDirection, - reverse: widget.reverse, - cacheExtent: widget.cacheExtent, - physics: widget.physics, - shrinkWrap: widget.shrinkWrap, - semanticChildCount: widget.semanticChildCount ?? widget.itemCount, - keyboardDismissBehavior: widget.keyboardDismissBehavior, - slivers: [ - if (widget.positionedIndex > 0) - SliverPadding( - padding: _leadingSliverPadding, - sliver: SliverList( - delegate: SliverChildBuilderDelegate( - (context, index) => widget.separatorBuilder == null - ? _buildItem(widget.positionedIndex - (index + 1)) - : _buildSeparatedListElement( - widget.positionedIndex * 2 - (index + 1), - ), - childCount: widget.separatorBuilder == null - ? widget.positionedIndex - : widget.positionedIndex * 2, - addSemanticIndexes: false, - addRepaintBoundaries: widget.addRepaintBoundaries, - addAutomaticKeepAlives: widget.addAutomaticKeepAlives, - findChildIndexCallback: widget.findChildIndexCallback, - ), - ), - ), - SliverPadding( - key: _centerKey, - padding: _centerSliverPadding, - sliver: SliverList( - delegate: SliverChildBuilderDelegate( - (context, index) => widget.separatorBuilder == null - ? _buildItem(index + widget.positionedIndex) - : _buildSeparatedListElement( - index + widget.positionedIndex * 2, - ), - childCount: widget.itemCount != 0 ? 1 : 0, - addSemanticIndexes: false, - addRepaintBoundaries: widget.addRepaintBoundaries, - addAutomaticKeepAlives: widget.addAutomaticKeepAlives, - findChildIndexCallback: widget.findChildIndexCallback, - ), + elementNotifier: registeredElements, + child: UnboundedCustomScrollView( + anchor: widget.alignment, + center: _centerKey, + controller: scrollController, + scrollDirection: widget.scrollDirection, + reverse: widget.reverse, + cacheExtent: widget.cacheExtent, + physics: widget.physics, + shrinkWrap: widget.shrinkWrap, + semanticChildCount: widget.semanticChildCount ?? widget.itemCount, + keyboardDismissBehavior: widget.keyboardDismissBehavior, + slivers: [ + if (widget.positionedIndex > 0) + SliverPadding( + padding: _leadingSliverPadding, + sliver: SliverList( + delegate: SliverChildBuilderDelegate( + (context, index) => widget.separatorBuilder == null + ? _buildItem(widget.positionedIndex - (index + 1)) + : _buildSeparatedListElement( + widget.positionedIndex * 2 - (index + 1), + ), + childCount: widget.separatorBuilder == null ? widget.positionedIndex : widget.positionedIndex * 2, + addSemanticIndexes: false, + addRepaintBoundaries: widget.addRepaintBoundaries, + addAutomaticKeepAlives: widget.addAutomaticKeepAlives, + findChildIndexCallback: widget.findChildIndexCallback, ), ), - if (widget.positionedIndex >= 0 && - widget.positionedIndex < widget.itemCount - 1) - SliverPadding( - padding: _trailingSliverPadding, - sliver: SliverList( - delegate: SliverChildBuilderDelegate( - (context, index) => widget.separatorBuilder == null - ? _buildItem(index + widget.positionedIndex + 1) - : _buildSeparatedListElement( - index + widget.positionedIndex * 2 + 1, - ), - childCount: widget.separatorBuilder == null - ? widget.itemCount - widget.positionedIndex - 1 - : 2 * (widget.itemCount - widget.positionedIndex - 1), - addSemanticIndexes: false, - addRepaintBoundaries: widget.addRepaintBoundaries, - addAutomaticKeepAlives: widget.addAutomaticKeepAlives, - findChildIndexCallback: widget.findChildIndexCallback, - ), - ), - ), - ], + ), + SliverPadding( + key: _centerKey, + padding: _centerSliverPadding, + sliver: SliverList( + delegate: SliverChildBuilderDelegate( + (context, index) => widget.separatorBuilder == null + ? _buildItem(index + widget.positionedIndex) + : _buildSeparatedListElement( + index + widget.positionedIndex * 2, + ), + childCount: widget.itemCount != 0 ? 1 : 0, + addSemanticIndexes: false, + addRepaintBoundaries: widget.addRepaintBoundaries, + addAutomaticKeepAlives: widget.addAutomaticKeepAlives, + findChildIndexCallback: widget.findChildIndexCallback, + ), + ), ), - ); + if (widget.positionedIndex >= 0 && widget.positionedIndex < widget.itemCount - 1) + SliverPadding( + padding: _trailingSliverPadding, + sliver: SliverList( + delegate: SliverChildBuilderDelegate( + (context, index) => widget.separatorBuilder == null + ? _buildItem(index + widget.positionedIndex + 1) + : _buildSeparatedListElement( + index + widget.positionedIndex * 2 + 1, + ), + childCount: widget.separatorBuilder == null + ? widget.itemCount - widget.positionedIndex - 1 + : 2 * (widget.itemCount - widget.positionedIndex - 1), + addSemanticIndexes: false, + addRepaintBoundaries: widget.addRepaintBoundaries, + addAutomaticKeepAlives: widget.addAutomaticKeepAlives, + findChildIndexCallback: widget.findChildIndexCallback, + ), + ), + ), + ], + ), + ); Widget _buildSeparatedListElement(int index) { if (index.isEven) { @@ -274,63 +271,51 @@ class _PositionedListState extends State { final child = widget.itemBuilder(context, index); return RegisteredElementWidget( key: IndexedKey(child.key, index), - child: widget.addSemanticIndexes - ? IndexedSemantics(index: index, child: child) - : child, + child: widget.addSemanticIndexes ? IndexedSemantics(index: index, child: child) : child, ); } EdgeInsets get _leadingSliverPadding => (widget.scrollDirection == Axis.vertical ? widget.reverse - ? widget.padding?.copyWith(top: 0) - : widget.padding?.copyWith(bottom: 0) + ? widget.padding?.copyWith(top: 0) + : widget.padding?.copyWith(bottom: 0) : widget.reverse - ? widget.padding?.copyWith(left: 0) - : widget.padding?.copyWith(right: 0)) ?? + ? widget.padding?.copyWith(left: 0) + : widget.padding?.copyWith(right: 0)) ?? EdgeInsets.zero; EdgeInsets get _centerSliverPadding => widget.scrollDirection == Axis.vertical ? widget.reverse - ? widget.padding?.copyWith( - top: widget.positionedIndex == widget.itemCount - 1 - ? widget.padding!.top - : 0, - bottom: - widget.positionedIndex == 0 ? widget.padding!.bottom : 0, - ) ?? - EdgeInsets.zero - : widget.padding?.copyWith( - top: widget.positionedIndex == 0 ? widget.padding!.top : 0, - bottom: widget.positionedIndex == widget.itemCount - 1 - ? widget.padding!.bottom - : 0, - ) ?? - EdgeInsets.zero + ? widget.padding?.copyWith( + top: widget.positionedIndex == widget.itemCount - 1 ? widget.padding!.top : 0, + bottom: widget.positionedIndex == 0 ? widget.padding!.bottom : 0, + ) ?? + EdgeInsets.zero + : widget.padding?.copyWith( + top: widget.positionedIndex == 0 ? widget.padding!.top : 0, + bottom: widget.positionedIndex == widget.itemCount - 1 ? widget.padding!.bottom : 0, + ) ?? + EdgeInsets.zero : widget.reverse - ? widget.padding?.copyWith( - left: widget.positionedIndex == widget.itemCount - 1 - ? widget.padding!.left - : 0, - right: widget.positionedIndex == 0 ? widget.padding!.right : 0, - ) ?? - EdgeInsets.zero - : widget.padding?.copyWith( - left: widget.positionedIndex == 0 ? widget.padding!.left : 0, - right: widget.positionedIndex == widget.itemCount - 1 - ? widget.padding!.right - : 0, - ) ?? - EdgeInsets.zero; - - EdgeInsets get _trailingSliverPadding => - widget.scrollDirection == Axis.vertical - ? widget.reverse - ? widget.padding?.copyWith(bottom: 0) ?? EdgeInsets.zero - : widget.padding?.copyWith(top: 0) ?? EdgeInsets.zero - : widget.reverse - ? widget.padding?.copyWith(right: 0) ?? EdgeInsets.zero - : widget.padding?.copyWith(left: 0) ?? EdgeInsets.zero; + ? widget.padding?.copyWith( + left: widget.positionedIndex == widget.itemCount - 1 ? widget.padding!.left : 0, + right: widget.positionedIndex == 0 ? widget.padding!.right : 0, + ) ?? + EdgeInsets.zero + : widget.padding?.copyWith( + left: widget.positionedIndex == 0 ? widget.padding!.left : 0, + right: widget.positionedIndex == widget.itemCount - 1 ? widget.padding!.right : 0, + ) ?? + EdgeInsets.zero; + + EdgeInsets get _trailingSliverPadding => widget.scrollDirection == Axis.vertical + ? widget.reverse + ? widget.padding?.copyWith(bottom: 0) ?? EdgeInsets.zero + : widget.padding?.copyWith(top: 0) ?? EdgeInsets.zero + : widget.reverse + ? widget.padding?.copyWith(right: 0) ?? EdgeInsets.zero + : widget.padding?.copyWith(left: 0) ?? EdgeInsets.zero; void _schedulePositionNotificationUpdate() { if (!updateScheduled) { @@ -361,34 +346,34 @@ class _PositionedListState extends State { if (widget.scrollDirection == Axis.vertical) { final reveal = viewport!.getOffsetToReveal(box, 0).offset; if (!reveal.isFinite) continue; - final itemOffset = - reveal - viewport.offset.pixels + anchor * viewport.size.height; - positions.add(ItemPosition( - index: key.index, - itemLeadingEdge: itemOffset.round() / - scrollController.position.viewportDimension, - itemTrailingEdge: (itemOffset + box.size.height).round() / - scrollController.position.viewportDimension, - )); + final itemOffset = reveal - viewport.offset.pixels + anchor * viewport.size.height; + positions.add( + ItemPosition( + index: key.index, + itemLeadingEdge: itemOffset.round() / scrollController.position.viewportDimension, + itemTrailingEdge: (itemOffset + box.size.height).round() / scrollController.position.viewportDimension, + ), + ); } else { - final itemOffset = - box.localToGlobal(Offset.zero, ancestor: viewport).dx; + final itemOffset = box.localToGlobal(Offset.zero, ancestor: viewport).dx; if (!itemOffset.isFinite) continue; - positions.add(ItemPosition( - index: key.index, - itemLeadingEdge: (widget.reverse - ? scrollController.position.viewportDimension - - (itemOffset + box.size.width) - : itemOffset) - .round() / - scrollController.position.viewportDimension, - itemTrailingEdge: (widget.reverse - ? scrollController.position.viewportDimension - - itemOffset - : (itemOffset + box.size.width)) - .round() / - scrollController.position.viewportDimension, - )); + positions.add( + ItemPosition( + index: key.index, + itemLeadingEdge: + (widget.reverse + ? scrollController.position.viewportDimension - (itemOffset + box.size.width) + : itemOffset) + .round() / + scrollController.position.viewportDimension, + itemTrailingEdge: + (widget.reverse + ? scrollController.position.viewportDimension - itemOffset + : (itemOffset + box.size.width)) + .round() / + scrollController.position.viewportDimension, + ), + ); } } widget.itemPositionsNotifier?.itemPositions.value = positions; diff --git a/packages/stream_chat_flutter/lib/scrollable_positioned_list/src/scroll_view.dart b/packages/stream_chat_flutter/lib/scrollable_positioned_list/src/scroll_view.dart index 911512495b..160e8aa240 100644 --- a/packages/stream_chat_flutter/lib/scrollable_positioned_list/src/scroll_view.dart +++ b/packages/stream_chat_flutter/lib/scrollable_positioned_list/src/scroll_view.dart @@ -28,9 +28,9 @@ class UnboundedCustomScrollView extends CustomScrollView { super.semanticChildCount, super.dragStartBehavior, super.keyboardDismissBehavior, - }) : _shrinkWrap = shrinkWrap, - _anchor = anchor, - super(shrinkWrap: false); + }) : _shrinkWrap = shrinkWrap, + _anchor = anchor, + super(shrinkWrap: false); final bool _shrinkWrap; diff --git a/packages/stream_chat_flutter/lib/scrollable_positioned_list/src/scrollable_positioned_list.dart b/packages/stream_chat_flutter/lib/scrollable_positioned_list/src/scrollable_positioned_list.dart index af44d52562..e160dc6148 100644 --- a/packages/stream_chat_flutter/lib/scrollable_positioned_list/src/scrollable_positioned_list.dart +++ b/packages/stream_chat_flutter/lib/scrollable_positioned_list/src/scrollable_positioned_list.dart @@ -52,8 +52,8 @@ class ScrollablePositionedList extends StatefulWidget { this.minCacheExtent, this.findChildIndexCallback, this.keyboardDismissBehavior = ScrollViewKeyboardDismissBehavior.manual, - }) : itemPositionsNotifier = itemPositionsListener as ItemPositionsNotifier?, - separatorBuilder = null; + }) : itemPositionsNotifier = itemPositionsListener as ItemPositionsNotifier?, + separatorBuilder = null; /// Create a [ScrollablePositionedList] whose items are provided by /// [itemBuilder] and separators provided by [separatorBuilder]. @@ -278,8 +278,7 @@ class ItemScrollController { } } -class _ScrollablePositionedListState extends State - with TickerProviderStateMixin { +class _ScrollablePositionedListState extends State with TickerProviderStateMixin { /// Details for the primary (active) [ListView]. _ListDisplayDetails primary = _ListDisplayDetails(const ValueKey('Ping')); @@ -318,10 +317,8 @@ class _ScrollablePositionedListState extends State @override void dispose() { - primary.itemPositionsNotifier.itemPositions - .removeListener(_updatePositions); - secondary.itemPositionsNotifier.itemPositions - .removeListener(_updatePositions); + primary.itemPositionsNotifier.itemPositions.removeListener(_updatePositions); + secondary.itemPositionsNotifier.itemPositions.removeListener(_updatePositions); _animationController?.dispose(); super.dispose(); } @@ -431,12 +428,9 @@ class _ScrollablePositionedListState extends State } double _cacheExtent(BoxConstraints constraints) => max( - (widget.scrollDirection == Axis.vertical - ? constraints.maxHeight - : constraints.maxWidth) * - _screenScrollCount, - widget.minCacheExtent ?? 0, - ); + (widget.scrollDirection == Axis.vertical ? constraints.maxHeight : constraints.maxWidth) * _screenScrollCount, + widget.minCacheExtent ?? 0, + ); void _jumpTo({required int index, required double alignment}) { _stopScroll(canceled: true); @@ -494,14 +488,12 @@ class _ScrollablePositionedListState extends State required List opacityAnimationWeights, }) async { final direction = index > primary.target ? 1 : -1; - final itemPosition = - primary.itemPositionsNotifier.itemPositions.value.firstWhereOrNull( + final itemPosition = primary.itemPositionsNotifier.itemPositions.value.firstWhereOrNull( (ItemPosition itemPosition) => itemPosition.index == index, ); if (itemPosition != null) { // Scroll directly. - final localScrollAmount = itemPosition.itemLeadingEdge * - primary.scrollController.position.viewportDimension; + final localScrollAmount = itemPosition.itemLeadingEdge * primary.scrollController.position.viewportDimension; await primary.scrollController.animateTo( primary.scrollController.offset + localScrollAmount - @@ -510,31 +502,29 @@ class _ScrollablePositionedListState extends State curve: curve, ); } else { - final scrollAmount = _screenScrollCount * - primary.scrollController.position.viewportDimension; + final scrollAmount = _screenScrollCount * primary.scrollController.position.viewportDimension; final startCompleter = Completer(); final endCompleter = Completer(); startAnimationCallback = () { SchedulerBinding.instance.addPostFrameCallback((_) { startAnimationCallback = () {}; _animationController?.dispose(); - _animationController = - AnimationController(vsync: this, duration: duration)..forward(); - opacity.parent = _opacityAnimation(opacityAnimationWeights) - .animate(_animationController!); - secondary.scrollController.jumpTo(-direction * - (_screenScrollCount * - primary.scrollController.position.viewportDimension - - alignment * - secondary.scrollController.position.viewportDimension)); - - startCompleter.complete(primary.scrollController.animateTo( - primary.scrollController.offset + direction * scrollAmount, - duration: duration, - curve: curve, - )); - endCompleter.complete(secondary.scrollController - .animateTo(0, duration: duration, curve: curve)); + _animationController = AnimationController(vsync: this, duration: duration)..forward(); + opacity.parent = _opacityAnimation(opacityAnimationWeights).animate(_animationController!); + secondary.scrollController.jumpTo( + -direction * + (_screenScrollCount * primary.scrollController.position.viewportDimension - + alignment * secondary.scrollController.position.viewportDimension), + ); + + startCompleter.complete( + primary.scrollController.animateTo( + primary.scrollController.offset + direction * scrollAmount, + duration: duration, + curve: curve, + ), + ); + endCompleter.complete(secondary.scrollController.animateTo(0, duration: duration, curve: curve)); }); }; setState(() { @@ -599,14 +589,13 @@ class _ScrollablePositionedListState extends State } void _updatePositions() { - final itemPositions = primary.itemPositionsNotifier.itemPositions.value - .where((ItemPosition position) => - position.itemLeadingEdge < 1 && position.itemTrailingEdge > 0); + final itemPositions = primary.itemPositionsNotifier.itemPositions.value.where( + (ItemPosition position) => position.itemLeadingEdge < 1 && position.itemTrailingEdge > 0, + ); if (itemPositions.isNotEmpty) { PageStorage.of(context).writeState( context, - itemPositions.reduce((value, element) => - value.itemLeadingEdge < element.itemLeadingEdge ? value : element), + itemPositions.reduce((value, element) => value.itemLeadingEdge < element.itemLeadingEdge ? value : element), ); } widget.itemPositionsNotifier?.itemPositions.value = itemPositions; diff --git a/packages/stream_chat_flutter/lib/scrollable_positioned_list/src/viewport.dart b/packages/stream_chat_flutter/lib/scrollable_positioned_list/src/viewport.dart index aac9acc9e0..dac5e20f19 100644 --- a/packages/stream_chat_flutter/lib/scrollable_positioned_list/src/viewport.dart +++ b/packages/stream_chat_flutter/lib/scrollable_positioned_list/src/viewport.dart @@ -38,8 +38,7 @@ class UnboundedViewport extends Viewport { RenderViewport createRenderObject(BuildContext context) { return UnboundedRenderViewport( axisDirection: axisDirection, - crossAxisDirection: crossAxisDirection ?? - Viewport.getDefaultCrossAxisDirection(context, axisDirection), + crossAxisDirection: crossAxisDirection ?? Viewport.getDefaultCrossAxisDirection(context, axisDirection), anchor: anchor, offset: offset, cacheExtent: cacheExtent, @@ -233,10 +232,8 @@ class UnboundedRenderViewport extends RenderViewport { // to the zero scroll offset (the line between the forward slivers and the // reverse slivers). final centerOffset = mainAxisExtent * anchor - correctedOffset; - final reverseDirectionRemainingPaintExtent = - centerOffset.clamp(0.0, mainAxisExtent); - final forwardDirectionRemainingPaintExtent = - (mainAxisExtent - centerOffset).clamp(0.0, mainAxisExtent); + final reverseDirectionRemainingPaintExtent = centerOffset.clamp(0.0, mainAxisExtent); + final forwardDirectionRemainingPaintExtent = (mainAxisExtent - centerOffset).clamp(0.0, mainAxisExtent); switch (cacheExtentStyle) { case CacheExtentStyle.pixel: @@ -249,10 +246,8 @@ class UnboundedRenderViewport extends RenderViewport { final fullCacheExtent = mainAxisExtent + 2 * _calculatedCacheExtent!; final centerCacheOffset = centerOffset + _calculatedCacheExtent!; - final reverseDirectionRemainingCacheExtent = - centerCacheOffset.clamp(0.0, fullCacheExtent); - final forwardDirectionRemainingCacheExtent = - (fullCacheExtent - centerCacheOffset).clamp(0.0, fullCacheExtent); + final reverseDirectionRemainingCacheExtent = centerCacheOffset.clamp(0.0, fullCacheExtent); + final forwardDirectionRemainingCacheExtent = (fullCacheExtent - centerCacheOffset).clamp(0.0, fullCacheExtent); final leadingNegativeChild = childBefore(center!); @@ -269,8 +264,7 @@ class UnboundedRenderViewport extends RenderViewport { growthDirection: GrowthDirection.reverse, advance: childBefore, remainingCacheExtent: reverseDirectionRemainingCacheExtent, - cacheOrigin: (mainAxisExtent - centerOffset) - .clamp(-_calculatedCacheExtent!, 0.0), + cacheOrigin: (mainAxisExtent - centerOffset).clamp(-_calculatedCacheExtent!, 0.0), ); if (result != 0.0) return -result; } @@ -280,9 +274,7 @@ class UnboundedRenderViewport extends RenderViewport { child: center, scrollOffset: math.max(0, -centerOffset), overlap: leadingNegativeChild == null ? math.min(0, -centerOffset) : 0.0, - layoutOffset: centerOffset >= mainAxisExtent - ? centerOffset - : reverseDirectionRemainingPaintExtent, + layoutOffset: centerOffset >= mainAxisExtent ? centerOffset : reverseDirectionRemainingPaintExtent, remainingPaintExtent: forwardDirectionRemainingPaintExtent, mainAxisExtent: mainAxisExtent, crossAxisExtent: crossAxisExtent, diff --git a/packages/stream_chat_flutter/lib/scrollable_positioned_list/src/wrapping.dart b/packages/stream_chat_flutter/lib/scrollable_positioned_list/src/wrapping.dart index c3a8129ce7..e746813cae 100644 --- a/packages/stream_chat_flutter/lib/scrollable_positioned_list/src/wrapping.dart +++ b/packages/stream_chat_flutter/lib/scrollable_positioned_list/src/wrapping.dart @@ -59,8 +59,7 @@ class CustomShrinkWrappingViewport extends CustomViewport { CustomRenderShrinkWrappingViewport createRenderObject(BuildContext context) { return CustomRenderShrinkWrappingViewport( axisDirection: axisDirection, - crossAxisDirection: crossAxisDirection ?? - Viewport.getDefaultCrossAxisDirection(context, axisDirection), + crossAxisDirection: crossAxisDirection ?? Viewport.getDefaultCrossAxisDirection(context, axisDirection), offset: offset, anchor: anchor, cacheExtent: cacheExtent, @@ -74,8 +73,7 @@ class CustomShrinkWrappingViewport extends CustomViewport { ) { renderObject ..axisDirection = axisDirection - ..crossAxisDirection = crossAxisDirection ?? - Viewport.getDefaultCrossAxisDirection(context, axisDirection) + ..crossAxisDirection = crossAxisDirection ?? Viewport.getDefaultCrossAxisDirection(context, axisDirection) ..anchor = anchor ..offset = offset ..cacheExtent = cacheExtent @@ -267,10 +265,8 @@ class CustomRenderShrinkWrappingViewport extends CustomRenderViewport { final maxScrollOffset = math.max(math.min(0, top), bottom); final minScrollOffset = math.min(top, maxScrollOffset); - final didAcceptViewportDimension = - offset.applyViewportDimension(effectiveExtent); - final didAcceptContentDimension = - offset.applyContentDimensions(minScrollOffset, maxScrollOffset); + final didAcceptViewportDimension = offset.applyViewportDimension(effectiveExtent); + final didAcceptContentDimension = offset.applyContentDimensions(minScrollOffset, maxScrollOffset); if (didAcceptViewportDimension && didAcceptContentDimension) { break; } @@ -278,12 +274,10 @@ class CustomRenderShrinkWrappingViewport extends CustomRenderViewport { } while (true); switch (axis) { case Axis.vertical: - size = - constraints.constrainDimensions(crossAxisExtent, effectiveExtent); + size = constraints.constrainDimensions(crossAxisExtent, effectiveExtent); break; case Axis.horizontal: - size = - constraints.constrainDimensions(effectiveExtent, crossAxisExtent); + size = constraints.constrainDimensions(effectiveExtent, crossAxisExtent); break; } } @@ -313,10 +307,8 @@ class CustomRenderShrinkWrappingViewport extends CustomRenderViewport { // to the zero scroll offset (the line between the forward slivers and the // reverse slivers). final centerOffset = mainAxisExtent * anchor - correctedOffset; - final reverseDirectionRemainingPaintExtent = - centerOffset.clamp(0.0, mainAxisExtent); - final forwardDirectionRemainingPaintExtent = - (mainAxisExtent - centerOffset).clamp(0.0, mainAxisExtent); + final reverseDirectionRemainingPaintExtent = centerOffset.clamp(0.0, mainAxisExtent); + final forwardDirectionRemainingPaintExtent = (mainAxisExtent - centerOffset).clamp(0.0, mainAxisExtent); switch (cacheExtentStyle) { case CacheExtentStyle.pixel: @@ -329,10 +321,8 @@ class CustomRenderShrinkWrappingViewport extends CustomRenderViewport { final fullCacheExtent = mainAxisExtent + 2 * _calculatedCacheExtent!; final centerCacheOffset = centerOffset + _calculatedCacheExtent!; - final reverseDirectionRemainingCacheExtent = - centerCacheOffset.clamp(0.0, fullCacheExtent); - final forwardDirectionRemainingCacheExtent = - (fullCacheExtent - centerCacheOffset).clamp(0.0, fullCacheExtent); + final reverseDirectionRemainingCacheExtent = centerCacheOffset.clamp(0.0, fullCacheExtent); + final forwardDirectionRemainingCacheExtent = (fullCacheExtent - centerCacheOffset).clamp(0.0, fullCacheExtent); final leadingNegativeChild = childBefore(center!); @@ -349,8 +339,7 @@ class CustomRenderShrinkWrappingViewport extends CustomRenderViewport { growthDirection: GrowthDirection.reverse, advance: childBefore, remainingCacheExtent: reverseDirectionRemainingCacheExtent, - cacheOrigin: (mainAxisExtent - centerOffset) - .clamp(-_calculatedCacheExtent!, 0.0), + cacheOrigin: (mainAxisExtent - centerOffset).clamp(-_calculatedCacheExtent!, 0.0), ); if (result != 0.0) return -result; } @@ -360,9 +349,7 @@ class CustomRenderShrinkWrappingViewport extends CustomRenderViewport { child: center, scrollOffset: math.max(0, -centerOffset), overlap: leadingNegativeChild == null ? math.min(0, -centerOffset) : 0.0, - layoutOffset: centerOffset >= mainAxisExtent - ? centerOffset - : reverseDirectionRemainingPaintExtent, + layoutOffset: centerOffset >= mainAxisExtent ? centerOffset : reverseDirectionRemainingPaintExtent, remainingPaintExtent: forwardDirectionRemainingPaintExtent, mainAxisExtent: mainAxisExtent, crossAxisExtent: crossAxisExtent, @@ -450,16 +437,15 @@ abstract class CustomViewport extends MultiChildRenderObjectWidget { this.cacheExtentStyle = CacheExtentStyle.pixel, this.clipBehavior = Clip.hardEdge, List slivers = const [], - }) : assert( - center == null || - slivers.where((Widget child) => child.key == center).length == 1, - 'There should be at most one child with the same key as the center child: $center', - ), - assert( - cacheExtentStyle != CacheExtentStyle.viewport || cacheExtent != null, - 'A cacheExtent is required when using cacheExtentStyle.viewport', - ), - super(children: slivers); + }) : assert( + center == null || slivers.where((Widget child) => child.key == center).length == 1, + 'There should be at most one child with the same key as the center child: $center', + ), + assert( + cacheExtentStyle != CacheExtentStyle.viewport || cacheExtent != null, + 'A cacheExtent is required when using cacheExtentStyle.viewport', + ), + super(children: slivers); /// The direction in which the [offset]'s [ViewportOffset.pixels] increases. /// @@ -533,24 +519,24 @@ abstract class CustomViewport extends MultiChildRenderObjectWidget { ) { switch (axisDirection) { case AxisDirection.up: - assert(debugCheckHasDirectionality( - context, - why: - "to determine the cross-axis direction when the viewport has an 'up' axisDirection", - alternative: - "Alternatively, consider specifying the 'crossAxisDirection' argument on the Viewport.", - )); + assert( + debugCheckHasDirectionality( + context, + why: "to determine the cross-axis direction when the viewport has an 'up' axisDirection", + alternative: "Alternatively, consider specifying the 'crossAxisDirection' argument on the Viewport.", + ), + ); return textDirectionToAxisDirection(Directionality.of(context)); case AxisDirection.right: return AxisDirection.down; case AxisDirection.down: - assert(debugCheckHasDirectionality( - context, - why: - "to determine the cross-axis direction when the viewport has a 'down' axisDirection", - alternative: - "Alternatively, consider specifying the 'crossAxisDirection' argument on the Viewport.", - )); + assert( + debugCheckHasDirectionality( + context, + why: "to determine the cross-axis direction when the viewport has a 'down' axisDirection", + alternative: "Alternatively, consider specifying the 'crossAxisDirection' argument on the Viewport.", + ), + ); return textDirectionToAxisDirection(Directionality.of(context)); case AxisDirection.left: return AxisDirection.down; @@ -568,28 +554,34 @@ abstract class CustomViewport extends MultiChildRenderObjectWidget { super.debugFillProperties(properties); properties ..add(EnumProperty('axisDirection', axisDirection)) - ..add(EnumProperty( - 'crossAxisDirection', - crossAxisDirection, - defaultValue: null, - )) + ..add( + EnumProperty( + 'crossAxisDirection', + crossAxisDirection, + defaultValue: null, + ), + ) ..add(DoubleProperty('anchor', anchor)) ..add(DiagnosticsProperty('offset', offset)); if (center != null) { properties.add(DiagnosticsProperty('center', center)); } else if (children.isNotEmpty && children.first.key != null) { - properties.add(DiagnosticsProperty( - 'center', - children.first.key, - tooltip: 'implicit', - )); + properties.add( + DiagnosticsProperty( + 'center', + children.first.key, + tooltip: 'implicit', + ), + ); } properties ..add(DiagnosticsProperty('cacheExtent', cacheExtent)) - ..add(DiagnosticsProperty( - 'cacheExtentStyle', - cacheExtentStyle, - )); + ..add( + DiagnosticsProperty( + 'cacheExtentStyle', + cacheExtentStyle, + ), + ); } } @@ -601,8 +593,7 @@ class _ViewportElement extends MultiChildRenderObjectElement { CustomViewport get widget => super.widget as CustomViewport; @override - CustomRenderViewport get renderObject => - super.renderObject as CustomRenderViewport; + CustomRenderViewport get renderObject => super.renderObject as CustomRenderViewport; @override void mount(Element? parent, dynamic newSlot) { @@ -618,9 +609,8 @@ class _ViewportElement extends MultiChildRenderObjectElement { void _updateCenter() { if (widget.center != null) { - renderObject.center = children - .singleWhere((Element element) => element.widget.key == widget.center) - .renderObject as RenderSliver?; + renderObject.center = + children.singleWhere((Element element) => element.widget.key == widget.center).renderObject as RenderSliver?; } else if (children.isNotEmpty) { renderObject.center = children.first.renderObject as RenderSliver?; } else { @@ -630,15 +620,16 @@ class _ViewportElement extends MultiChildRenderObjectElement { @override void debugVisitOnstageChildren(ElementVisitor visitor) { - children.where((Element e) { - final renderSliver = e.renderObject! as RenderSliver; - return renderSliver.geometry!.visible; - }).forEach(visitor); + children + .where((Element e) { + final renderSliver = e.renderObject! as RenderSliver; + return renderSliver.geometry!.visible; + }) + .forEach(visitor); } } -class CustomSliverPhysicalContainerParentData - extends SliverPhysicalContainerParentData { +class CustomSliverPhysicalContainerParentData extends SliverPhysicalContainerParentData { /// The position of the child relative to the zero scroll offset. /// /// The number of pixels from from the zero scroll offset of the parent sliver @@ -685,8 +676,7 @@ class CustomSliverPhysicalContainerParentData /// placed inside a [RenderSliver] (the opposite of this class). /// * [RenderShrinkWrappingViewport], a variant of [RenderViewport] that /// shrink-wraps its contents along the main axis. -abstract class CustomRenderViewport - extends RenderViewportBase { +abstract class CustomRenderViewport extends RenderViewportBase { /// Creates a viewport for [RenderSliver] objects. /// /// If the [center] is not specified, then the first child in the `children` @@ -704,15 +694,15 @@ abstract class CustomRenderViewport super.cacheExtent, super.cacheExtentStyle, super.clipBehavior, - }) : assert( - anchor >= 0.0 && anchor <= 1.0, - 'Anchor must be between 0.0 and 1.0.', - ), - assert( - cacheExtentStyle != CacheExtentStyle.viewport || cacheExtent != null, - 'A cacheExtent is required when using CacheExtentStyle.viewport.', - ), - _center = center { + }) : assert( + anchor >= 0.0 && anchor <= 1.0, + 'Anchor must be between 0.0 and 1.0.', + ), + assert( + cacheExtentStyle != CacheExtentStyle.viewport || cacheExtent != null, + 'A cacheExtent is required when using CacheExtentStyle.viewport.', + ), + _center = center { addAll(children); if (center == null && firstChild != null) _center = firstChild; } @@ -735,8 +725,7 @@ abstract class CustomRenderViewport /// /// * [RenderViewportBase.describeSemanticsConfiguration], which adds this /// tag to its [SemanticsConfiguration]. - static const SemanticsTag useTwoPaneSemantics = - SemanticsTag('RenderViewport.twoPane'); + static const SemanticsTag useTwoPaneSemantics = SemanticsTag('RenderViewport.twoPane'); /// When a top-level [SemanticsNode] below a [RenderAbstractViewport] is /// tagged with [excludeFromScrolling] it will not be part of the scrolling @@ -751,8 +740,7 @@ abstract class CustomRenderViewport /// bar) can tag its [SemanticsNode] with [excludeFromScrolling] to indicate /// that it should no longer be considered for semantic actions related to /// scrolling. - static const SemanticsTag excludeFromScrolling = - SemanticsTag('RenderViewport.excludeFromScrolling'); + static const SemanticsTag excludeFromScrolling = SemanticsTag('RenderViewport.excludeFromScrolling'); @override void setupParentData(RenderObject child) { @@ -900,8 +888,7 @@ abstract class CustomRenderViewport double layoutOffset, GrowthDirection growthDirection, ) { - final childParentData = - child.parentData! as CustomSliverPhysicalContainerParentData; + final childParentData = child.parentData! as CustomSliverPhysicalContainerParentData; childParentData ..layoutOffset = layoutOffset ..growthDirection = growthDirection; @@ -909,8 +896,7 @@ abstract class CustomRenderViewport @override Offset paintOffsetOf(RenderSliver child) { - final childParentData = - child.parentData! as CustomSliverPhysicalContainerParentData; + final childParentData = child.parentData! as CustomSliverPhysicalContainerParentData; return computeAbsolutePaintOffset( child, childParentData.layoutOffset!, @@ -983,8 +969,7 @@ abstract class CustomRenderViewport RenderSliver child, double parentMainAxisPosition, ) { - final childParentData = - child.parentData! as CustomSliverPhysicalContainerParentData; + final childParentData = child.parentData! as CustomSliverPhysicalContainerParentData; switch (applyGrowthDirectionToAxisDirection( child.constraints.axisDirection, child.constraints.growthDirection, @@ -993,11 +978,9 @@ abstract class CustomRenderViewport case AxisDirection.right: return parentMainAxisPosition - childParentData.layoutOffset!; case AxisDirection.up: - return (size.height - parentMainAxisPosition) - - childParentData.layoutOffset!; + return (size.height - parentMainAxisPosition) - childParentData.layoutOffset!; case AxisDirection.left: - return (size.width - parentMainAxisPosition) - - childParentData.layoutOffset!; + return (size.width - parentMainAxisPosition) - childParentData.layoutOffset!; } } diff --git a/packages/stream_chat_flutter/lib/src/ai_assistant/ai_typing_indicator_view.dart b/packages/stream_chat_flutter/lib/src/ai_assistant/ai_typing_indicator_view.dart index ebafb3ef2a..288cedf09b 100644 --- a/packages/stream_chat_flutter/lib/src/ai_assistant/ai_typing_indicator_view.dart +++ b/packages/stream_chat_flutter/lib/src/ai_assistant/ai_typing_indicator_view.dart @@ -145,25 +145,25 @@ class _AnimatedDot extends StatefulWidget { State<_AnimatedDot> createState() => _AnimatedDotState(); } -class _AnimatedDotState extends State<_AnimatedDot> - with SingleTickerProviderStateMixin<_AnimatedDot> { +class _AnimatedDotState extends State<_AnimatedDot> with SingleTickerProviderStateMixin<_AnimatedDot> { late final AnimationController _repeatingController; @override void initState() { super.initState(); - _repeatingController = AnimationController( - vsync: this, - duration: const Duration(milliseconds: 800), - )..addStatusListener( - (status) { - if (status == AnimationStatus.completed) { - if (mounted) _repeatingController.reverse(); - } else if (status == AnimationStatus.dismissed) { - if (mounted) _repeatingController.forward(); - } - }, - ); + _repeatingController = + AnimationController( + vsync: this, + duration: const Duration(milliseconds: 800), + )..addStatusListener( + (status) { + if (status == AnimationStatus.completed) { + if (mounted) _repeatingController.reverse(); + } else if (status == AnimationStatus.dismissed) { + if (mounted) _repeatingController.forward(); + } + }, + ); Future.delayed( Duration(milliseconds: 200 * widget.index), diff --git a/packages/stream_chat_flutter/lib/src/ai_assistant/stream_typewriter_builder.dart b/packages/stream_chat_flutter/lib/src/ai_assistant/stream_typewriter_builder.dart index e004784bf6..f686766004 100644 --- a/packages/stream_chat_flutter/lib/src/ai_assistant/stream_typewriter_builder.dart +++ b/packages/stream_chat_flutter/lib/src/ai_assistant/stream_typewriter_builder.dart @@ -54,9 +54,7 @@ class TypewriterValue { @override bool operator ==(Object other) { if (identical(this, other)) return true; - return other is TypewriterValue && - other.text == text && - other.state == state; + return other is TypewriterValue && other.text == text && other.state == state; } @override @@ -205,11 +203,12 @@ class TypewriterController extends ValueNotifier { /// A widget builder for a [StreamTypewriterBuilder]. It allows you to build a /// widget depending on the [TypewriterValue]'s value. /// {@endtemplate} -typedef TypewriterWidgetBuilder = Widget Function( - BuildContext context, - TypewriterValue value, - Widget? child, -); +typedef TypewriterWidgetBuilder = + Widget Function( + BuildContext context, + TypewriterValue value, + Widget? child, + ); /// {@template streamTypewriterBuilder} /// A widget that listens to a [TypewriterController] and rebuilds whenever the diff --git a/packages/stream_chat_flutter/lib/src/ai_assistant/streaming_message_view.dart b/packages/stream_chat_flutter/lib/src/ai_assistant/streaming_message_view.dart index 4422b5d86a..5b8c205e96 100644 --- a/packages/stream_chat_flutter/lib/src/ai_assistant/streaming_message_view.dart +++ b/packages/stream_chat_flutter/lib/src/ai_assistant/streaming_message_view.dart @@ -1,9 +1,9 @@ import 'package:flutter/material.dart'; import 'package:flutter_markdown/flutter_markdown.dart'; import 'package:stream_chat_flutter/src/ai_assistant/stream_typewriter_builder.dart'; -import 'package:stream_chat_flutter/src/misc/markdown_message.dart'; import 'package:stream_chat_flutter/src/utils/device_segmentation.dart'; import 'package:stream_chat_flutter/src/utils/helpers.dart'; +import 'package:stream_core_flutter/stream_core_flutter.dart' as core; /// {@template streamingMessageView} /// A widget that displays a message in a streaming fashion. The message is @@ -76,14 +76,14 @@ class _StreamingMessageViewState extends State { @override Widget build(BuildContext context) { - return StreamMarkdownMessage( - data: _displayText, + return core.StreamMessageText( + _displayText, selectable: isDesktopDeviceOrWeb, onTapLink: switch (widget.onTapLink) { final onTapLink? => onTapLink, _ => (String link, String? href, String title) { - if (href != null) launchURL(context, href); - }, + if (href != null) launchURL(context, href); + }, }, ); } diff --git a/packages/stream_chat_flutter/lib/src/attachment/attachment.dart b/packages/stream_chat_flutter/lib/src/attachment/attachment.dart index 0b73a93c7c..77fa831c7c 100644 --- a/packages/stream_chat_flutter/lib/src/attachment/attachment.dart +++ b/packages/stream_chat_flutter/lib/src/attachment/attachment.dart @@ -3,5 +3,9 @@ export 'file_attachment.dart'; export 'gallery_attachment.dart'; export 'giphy_attachment.dart'; export 'image_attachment.dart'; -export 'url_attachment.dart'; +export 'link_preview_attachment.dart'; +export 'poll_attachment.dart'; +export 'unsupported_attachment.dart'; export 'video_attachment.dart'; +export 'voice_recording_attachment.dart'; +export 'voice_recording_attachment_playlist.dart'; diff --git a/packages/stream_chat_flutter/lib/src/attachment/attachment_upload_state_builder.dart b/packages/stream_chat_flutter/lib/src/attachment/attachment_upload_state_builder.dart index dcb6a47a08..e851ef0f37 100644 --- a/packages/stream_chat_flutter/lib/src/attachment/attachment_upload_state_builder.dart +++ b/packages/stream_chat_flutter/lib/src/attachment/attachment_upload_state_builder.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:stream_chat_flutter/src/misc/empty_widget.dart'; import 'package:stream_chat_flutter/stream_chat_flutter.dart'; +import 'package:stream_core_flutter/stream_core_flutter.dart'; /// {@template streamAttachmentUploadStateBuilder} /// Widget to display attachment upload state @@ -11,10 +12,9 @@ class StreamAttachmentUploadStateBuilder extends StatelessWidget { super.key, required this.message, required this.attachment, - this.preparingBuilder, - this.inProgressBuilder, - this.successBuilder, - this.failedBuilder, + this.preparingBuilder = _defaultPreparingBuilder, + this.inProgressBuilder = _defaultInProgressBuilder, + this.failedBuilder = _defaultFailedBuilder, }); /// The message that [attachment] is associated with @@ -24,229 +24,42 @@ class StreamAttachmentUploadStateBuilder extends StatelessWidget { final Attachment attachment; /// Widget to display when preparing to upload the [attachment] - final PreparingBuilder? preparingBuilder; + final PreparingBuilder preparingBuilder; /// {@macro inProgressBuilder} - final InProgressBuilder? inProgressBuilder; - - /// {@macro successBuilder} - final SuccessBuilder? successBuilder; + final InProgressBuilder inProgressBuilder; /// {@macro failedBuilder} - final FailedBuilder? failedBuilder; - - @override - Widget build(BuildContext context) { - if (message.state.isCompleted) { - return const Empty(); - } - - final messageId = message.id; - final attachmentId = attachment.id; - - final inProgress = inProgressBuilder ?? - (context, int sent, int total) { - return _InProgressState( - sent: sent, - total: total, - attachmentId: attachmentId, - ); - }; - - final failed = failedBuilder ?? - (context, error) { - return _FailedState( - error: error, - messageId: messageId, - attachmentId: attachmentId, - ); - }; - - final success = successBuilder ?? (context) => _SuccessState(); - - final preparing = preparingBuilder ?? - (context) => _PreparingState(attachmentId: attachmentId); - - return attachment.uploadState.when( - preparing: () => preparing(context), - inProgress: (sent, total) => inProgress(context, sent, total), - success: () => success(context), - failed: (error) => failed(context, error), - ); - } -} - -class _IconButton extends StatelessWidget { - const _IconButton({ - this.icon, - this.onPressed, - }); - - final Widget? icon; - final VoidCallback? onPressed; - - @override - Widget build(BuildContext context) { - return SizedBox( - height: 24, - width: 24, - child: RawMaterialButton( - elevation: 0, - highlightElevation: 0, - focusElevation: 0, - hoverElevation: 0, - onPressed: onPressed, - fillColor: StreamChatTheme.of(context).colorTheme.overlayDark, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(16), - ), - child: icon, - ), - ); - } -} - -class _PreparingState extends StatelessWidget { - const _PreparingState({required this.attachmentId}); - - final String attachmentId; + final FailedBuilder failedBuilder; - @override - Widget build(BuildContext context) { - final channel = StreamChannel.of(context).channel; - return Column( - crossAxisAlignment: CrossAxisAlignment.end, - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Align( - alignment: Alignment.topRight, - child: _IconButton( - icon: StreamSvgIcon( - icon: StreamSvgIcons.close, - color: StreamChatTheme.of(context).colorTheme.barsBg, - ), - onPressed: () => channel.cancelAttachmentUpload(attachmentId), - ), - ), - Align( - alignment: Alignment.topRight, - child: StreamUploadProgressIndicator( - uploaded: 0, - total: double.maxFinite.toInt(), - ), - ), - ], - ); - } -} + static Widget _defaultPreparingBuilder(BuildContext context) => StreamLoadingSpinner(size: .md); -class _InProgressState extends StatelessWidget { - const _InProgressState({ - required this.sent, - required this.total, - required this.attachmentId, - }); - - final int sent; - final int total; - final String attachmentId; - - @override - Widget build(BuildContext context) { - final channel = StreamChannel.of(context).channel; - return Column( - crossAxisAlignment: CrossAxisAlignment.end, - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Align( - alignment: Alignment.topRight, - child: _IconButton( - icon: StreamSvgIcon( - icon: StreamSvgIcons.close, - color: StreamChatTheme.of(context).colorTheme.barsBg, - ), - onPressed: () => channel.cancelAttachmentUpload(attachmentId), - ), - ), - Align( - alignment: Alignment.topRight, - child: StreamUploadProgressIndicator( - uploaded: sent, - total: total, - ), - ), - ], - ); + static Widget _defaultInProgressBuilder(BuildContext context, int sent, int total) { + // Fall back to an indeterminate spinner when the total size is unknown + // (e.g. `total` reported as `-1` or `0`) instead of rendering a fake 0%. + final progress = total > 0 ? sent / total : null; + return StreamLoadingSpinner(value: progress, size: .md); } -} -class _FailedState extends StatelessWidget { - const _FailedState({ - this.error, - required this.messageId, - required this.attachmentId, - }); - - final String? error; - final String messageId; - final String attachmentId; - - @override - Widget build(BuildContext context) { - final channel = StreamChannel.of(context).channel; - final theme = StreamChatTheme.of(context); - return Column( - crossAxisAlignment: CrossAxisAlignment.end, - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - _IconButton( - icon: StreamSvgIcon( - size: 14, - icon: StreamSvgIcons.retry, - color: theme.colorTheme.barsBg, - ), - onPressed: () { - channel.retryAttachmentUpload(messageId, attachmentId); - }, - ), - Center( - child: DecoratedBox( - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(12), - // ignore: deprecated_member_use - color: theme.colorTheme.overlayDark.withOpacity(0.6), - ), - child: Padding( - padding: const EdgeInsets.symmetric( - vertical: 6, - horizontal: 12, - ), - child: Text( - context.translations.uploadErrorLabel, - textAlign: TextAlign.center, - style: theme.textTheme.footnote.copyWith( - color: theme.colorTheme.barsBg, - ), - ), - ), - ), - ), - ], - ); - } -} + static Widget _defaultFailedBuilder(BuildContext context, String? error) => StreamErrorBadge(size: .sm); -class _SuccessState extends StatelessWidget { @override Widget build(BuildContext context) { - return Align( - alignment: Alignment.topRight, - child: CircleAvatar( - backgroundColor: StreamChatTheme.of(context).colorTheme.overlayDark, - maxRadius: 12, - child: StreamSvgIcon( - icon: StreamSvgIcons.check, - color: StreamChatTheme.of(context).colorTheme.barsBg, + // Hide the overlay once this individual attachment is done uploading + // or the whole message has been delivered. + if (attachment.uploadState.isSuccess || message.state.isCompleted) return const Empty(); + + final colorScheme = context.streamColorScheme; + + return ColoredBox( + color: colorScheme.backgroundOverlayLight, + child: Center( + child: attachment.uploadState.when( + preparing: () => preparingBuilder(context), + inProgress: (sent, total) => inProgressBuilder(context, sent, total), + // Unreachable — the early-return above already covers success. + success: () => const Empty(), + failed: (error) => failedBuilder(context, error), ), ), ); diff --git a/packages/stream_chat_flutter/lib/src/attachment/attachment_widget_catalog.dart b/packages/stream_chat_flutter/lib/src/attachment/attachment_widget_catalog.dart index 5e51c7333b..32c8eefcd6 100644 --- a/packages/stream_chat_flutter/lib/src/attachment/attachment_widget_catalog.dart +++ b/packages/stream_chat_flutter/lib/src/attachment/attachment_widget_catalog.dart @@ -20,7 +20,10 @@ import 'package:stream_chat_flutter_core/stream_chat_flutter_core.dart'; /// widget for the [Message.attachments]. class AttachmentWidgetCatalog { /// {@macro attachmentWidgetCatalog} - const AttachmentWidgetCatalog({required this.builders}); + const AttachmentWidgetCatalog({ + required this.builders, + this.padding, + }); /// The list of builders to use to build the widget. /// @@ -28,25 +31,25 @@ class AttachmentWidgetCatalog { /// the message and attachments will be used to build the widget. final List builders; + /// The padding around the built attachment content. + final EdgeInsetsGeometry? padding; + /// Builds a widget for the given [message] and [attachments]. /// /// It iterates through the list of builders and uses the first builder /// that can handle the message and attachments. /// /// Throws an [Exception] if no builder is found for the message. - Widget build(BuildContext context, Message message) { + Widget? build(BuildContext context, Message message) { assert(!message.isDeleted, 'Cannot build attachment for deleted message'); - assert( - message.attachments.isNotEmpty, - 'Cannot build attachment for message without attachments', - ); - - // The list of attachments to build the widget for. final attachments = message.attachments.grouped; for (final builder in builders) { if (builder.canHandle(message, attachments)) { - return builder.build(context, message, attachments); + final child = builder.build(context, message, attachments); + if (child == null || padding == null) return child; + + return Padding(padding: padding!, child: child); } } @@ -57,8 +60,11 @@ class AttachmentWidgetCatalog { extension on List { /// Groups the attachments by their type. Map> get grouped { - return groupBy(where((it) { - return it.type != null; - }), (attachment) => attachment.type!); + return groupBy( + where((it) { + return it.type != null; + }), + (attachment) => attachment.type!, + ); } } diff --git a/packages/stream_chat_flutter/lib/src/attachment/builder/attachment_widget_builder.dart b/packages/stream_chat_flutter/lib/src/attachment/builder/attachment_widget_builder.dart index bbdc950ba0..0fc27d2d49 100644 --- a/packages/stream_chat_flutter/lib/src/attachment/builder/attachment_widget_builder.dart +++ b/packages/stream_chat_flutter/lib/src/attachment/builder/attachment_widget_builder.dart @@ -1,6 +1,6 @@ import 'package:flutter/material.dart'; -import 'package:stream_chat_flutter/src/misc/empty_widget.dart'; import 'package:stream_chat_flutter/stream_chat_flutter.dart'; +import 'package:stream_core_flutter/stream_core_flutter.dart'; part 'fallback_attachment_builder.dart'; part 'file_attachment_builder.dart'; @@ -8,18 +8,20 @@ part 'gallery_attachment_builder.dart'; part 'giphy_attachment_builder.dart'; part 'image_attachment_builder.dart'; part 'mixed_attachment_builder.dart'; -part 'url_attachment_builder.dart'; +part 'link_preview_attachment_builder.dart'; +part 'unsupported_attachment_builder.dart'; part 'video_attachment_builder.dart'; part 'voice_recording_attachment_playlist_builder.dart'; -part 'voice_recording_attachment_builder/voice_recording_attachment_builder.dart'; +part 'poll_attachment_builder.dart'; /// {@template streamAttachmentWidgetTapCallback} /// Signature for a function that's called when the user taps on an attachment. /// {@endtemplate} -typedef StreamAttachmentWidgetTapCallback = void Function( - Message message, - Attachment attachment, -); +typedef StreamAttachmentWidgetTapCallback = + void Function( + Message message, + Attachment attachment, + ); /// {@template attachmentWidgetBuilder} /// A builder which is used to build a widget for a given [Message] and @@ -43,6 +45,8 @@ abstract class StreamAttachmentWidgetBuilder { /// * [FileAttachmentBuilder] /// * [ImageAttachmentBuilder] /// * [VideoAttachmentBuilder] + /// * [VoiceRecordingAttachmentPlaylistBuilder] + /// * [PollAttachmentBuilder] /// * [UrlAttachmentBuilder] /// * [FallbackAttachmentBuilder] /// @@ -64,72 +68,60 @@ abstract class StreamAttachmentWidgetBuilder { /// widget. static List defaultBuilders({ required Message message, - ShapeBorder? shape, - EdgeInsetsGeometry padding = const EdgeInsets.all(4), StreamAttachmentWidgetTapCallback? onAttachmentTap, List? customAttachmentBuilders, }) { return [ ...?customAttachmentBuilders, - // Handles a mix of image, gif, video, url and file attachments. + // Handles poll attachments. + const PollAttachmentBuilder(), + + // Handles a mix of image, gif, video, url, file and voice recording + // attachments. MixedAttachmentBuilder( - padding: padding, onAttachmentTap: onAttachmentTap, ), // Handles a mix of image, gif, and video attachments. GalleryAttachmentBuilder( - shape: shape, - padding: padding, - runSpacing: padding.vertical / 2, - spacing: padding.horizontal / 2, onAttachmentTap: onAttachmentTap, ), // Handles file attachments. FileAttachmentBuilder( - shape: shape, - padding: padding, onAttachmentTap: onAttachmentTap, ), // Handles giphy attachments. GiphyAttachmentBuilder( - shape: shape, - padding: padding, onAttachmentTap: onAttachmentTap, ), // Handles image attachments. ImageAttachmentBuilder( - shape: shape, - padding: padding, onAttachmentTap: onAttachmentTap, ), // Handles video attachments. VideoAttachmentBuilder( - shape: shape, - padding: padding, onAttachmentTap: onAttachmentTap, ), // Handles voice recording attachments. VoiceRecordingAttachmentPlaylistBuilder( - shape: shape, - padding: padding, onAttachmentTap: onAttachmentTap, ), // We don't handle URL attachments if the message is a reply. if (message.quotedMessage == null) - UrlAttachmentBuilder( - shape: shape, - padding: padding, + LinkPreviewAttachmentBuilder( onAttachmentTap: onAttachmentTap, ), + // Handles unrecognised content with a visible placeholder. + const UnsupportedAttachmentBuilder(), + // Fallback builder should always be the last builder in the list. const FallbackAttachmentBuilder(), ]; @@ -142,7 +134,7 @@ abstract class StreamAttachmentWidgetBuilder { /// Builds a widget for the given [message] and [attachments]. /// This will only be called if [canHandle] returns `true`. - Widget build( + Widget? build( BuildContext context, Message message, Map> attachments, diff --git a/packages/stream_chat_flutter/lib/src/attachment/builder/fallback_attachment_builder.dart b/packages/stream_chat_flutter/lib/src/attachment/builder/fallback_attachment_builder.dart index 3e80a7d9fe..eea39f9ba7 100644 --- a/packages/stream_chat_flutter/lib/src/attachment/builder/fallback_attachment_builder.dart +++ b/packages/stream_chat_flutter/lib/src/attachment/builder/fallback_attachment_builder.dart @@ -1,10 +1,19 @@ part of 'attachment_widget_builder.dart'; /// {@template fallbackAttachmentBuilder} -/// A widget builder for when no other builder can handle the attachments. +/// A silent catch-all builder that prevents errors when no other +/// [StreamAttachmentWidgetBuilder] can handle the attachments. /// -/// Saves you from getting an error when you have an attachment type that is not -/// supported by the SDK. +/// Returns `null` (no widget), ensuring the attachment area is simply empty +/// rather than throwing. This builder should always be the **last** entry in +/// the builder list. +/// +/// See also: +/// +/// * [UnsupportedAttachmentBuilder], which renders a visible placeholder for +/// unrecognised content types. +/// * [StreamAttachmentWidgetBuilder.defaultBuilders], which places this +/// builder at the end of the default list. /// {@endtemplate} class FallbackAttachmentBuilder extends StreamAttachmentWidgetBuilder { /// {@macro fallbackAttachmentBuilder} @@ -21,13 +30,12 @@ class FallbackAttachmentBuilder extends StreamAttachmentWidgetBuilder { } @override - Widget build( + Widget? build( BuildContext context, Message message, Map> attachments, ) { - // Returns an empty widget because this builder will be used as a fallback - // when no other builder can handle the attachments. - return const Empty(); + // No visual representation for unsupported attachment types. + return null; } } diff --git a/packages/stream_chat_flutter/lib/src/attachment/builder/file_attachment_builder.dart b/packages/stream_chat_flutter/lib/src/attachment/builder/file_attachment_builder.dart index 1b05939250..3c6a61d3af 100644 --- a/packages/stream_chat_flutter/lib/src/attachment/builder/file_attachment_builder.dart +++ b/packages/stream_chat_flutter/lib/src/attachment/builder/file_attachment_builder.dart @@ -6,24 +6,19 @@ part of 'attachment_widget_builder.dart'; class FileAttachmentBuilder extends StreamAttachmentWidgetBuilder { /// {@macro fileAttachmentBuilder} const FileAttachmentBuilder({ - this.shape, - this.backgroundColor, - this.constraints = const BoxConstraints(), - this.padding = const EdgeInsets.all(4), + this.style, + this.constraints, this.onAttachmentTap, }); - /// The shape of the file attachment. - final ShapeBorder? shape; - - /// The background color of the file attachment. - final Color? backgroundColor; + /// The style of the file attachment container. + /// + /// When null, a default style with placement-aware background color and + /// a superellipse shape is used. + final StreamMessageAttachmentStyle? style; /// The constraints to apply to the file attachment widget. - final BoxConstraints constraints; - - /// The padding to apply to the file attachment widget. - final EdgeInsetsGeometry padding; + final BoxConstraints? constraints; /// The callback to call when the attachment is tapped. final StreamAttachmentWidgetTapCallback? onAttachmentTap; @@ -38,49 +33,45 @@ class FileAttachmentBuilder extends StreamAttachmentWidgetBuilder { } @override - Widget build( + Widget? build( BuildContext context, Message message, Map> attachments, ) { assert(debugAssertCanHandle(message, attachments), ''); + final spacing = context.streamSpacing; + final files = attachments[AttachmentType.file]!; Widget _buildFileAttachment(Attachment file) { - VoidCallback? onTap; - if (onAttachmentTap != null) { - onTap = () => onAttachmentTap!(message, file); - } + final onTap = switch (onAttachmentTap) { + final onTap? => () => onTap(message, file), + _ => null, + }; - return InkWell( - onTap: onTap, - child: StreamFileAttachment( - file: file, - message: message, - shape: shape, - constraints: constraints, - backgroundColor: backgroundColor, + return StreamMessageAttachment( + style: style, + child: InkWell( + onTap: onTap, + child: StreamFileAttachment( + file: file, + message: message, + constraints: constraints, + ), ), ); } - Widget child; if (files.length == 1) { - child = _buildFileAttachment(files.first); - } else { - child = Column( - // Add a small vertical padding between each attachment. - spacing: padding.vertical / 2, - children: [ - for (final file in files) _buildFileAttachment(file), - ], - ); + return _buildFileAttachment(files.first); } - return Padding( - padding: padding, - child: child, + return Column( + spacing: spacing.xs, + children: [ + for (final file in files) _buildFileAttachment(file), + ], ); } } diff --git a/packages/stream_chat_flutter/lib/src/attachment/builder/gallery_attachment_builder.dart b/packages/stream_chat_flutter/lib/src/attachment/builder/gallery_attachment_builder.dart index cd2bf22e92..7177fb8687 100644 --- a/packages/stream_chat_flutter/lib/src/attachment/builder/gallery_attachment_builder.dart +++ b/packages/stream_chat_flutter/lib/src/attachment/builder/gallery_attachment_builder.dart @@ -1,10 +1,5 @@ part of 'attachment_widget_builder.dart'; -const _kDefaultGalleryConstraints = BoxConstraints.tightFor( - width: 256, - height: 195, -); - /// {@template galleryAttachmentBuilder} /// A widget builder for [AttachmentType.image], [AttachmentType.video] and /// [AttachmentType.giphy] attachment types. @@ -15,38 +10,37 @@ const _kDefaultGalleryConstraints = BoxConstraints.tightFor( class GalleryAttachmentBuilder extends StreamAttachmentWidgetBuilder { /// {@macro galleryAttachmentBuilder} const GalleryAttachmentBuilder({ - this.shape, - this.padding = const EdgeInsets.all(2), - this.spacing = 2, - this.runSpacing = 2, - this.constraints = _kDefaultGalleryConstraints, + this.style, + this.spacing, + this.runSpacing, + this.constraints, this.onAttachmentTap, }); - /// The shape of the gallery attachment. - final ShapeBorder? shape; + /// The style of the gallery attachment container. + /// + /// When null, a default style with a rounded rectangle shape and border + /// is used. + final StreamMessageAttachmentStyle? style; /// The constraints to apply to the gallery attachment widget. - final BoxConstraints constraints; - - /// The padding to apply to the gallery attachment widget. - final EdgeInsetsGeometry padding; + final BoxConstraints? constraints; /// How much space to place between children in a run in the main axis. /// /// For example, if [spacing] is 10.0, the children will be spaced at least /// 10.0 logical pixels apart in the main axis. /// - /// Defaults to 2.0. - final double spacing; + /// When null, defaults to [StreamSpacing.xxs]. + final double? spacing; /// How much space to place between the runs themselves in the cross axis. /// /// For example, if [runSpacing] is 10.0, the runs will be spaced at least /// 10.0 logical pixels apart in the cross axis. /// - /// Defaults to 2.0. - final double runSpacing; + /// When null, defaults to [StreamSpacing.xxs]. + final double? runSpacing; /// The callback to call when the attachment is tapped. final StreamAttachmentWidgetTapCallback? onAttachmentTap; @@ -73,19 +67,22 @@ class GalleryAttachmentBuilder extends StreamAttachmentWidgetBuilder { } @override - Widget build( + Widget? build( BuildContext context, Message message, Map> attachments, ) { assert(debugAssertCanHandle(message, attachments), ''); + final effectiveStyle = StreamMessageAttachmentStyle.from( + backgroundColor: StreamColors.transparent, + ).merge(style); + final galleryAttachments = [...attachments.values.expand((it) => it)]; - return Padding( - padding: padding, + return StreamMessageAttachment( + style: effectiveStyle, child: StreamGalleryAttachment( - shape: shape, message: message, spacing: spacing, runSpacing: runSpacing, @@ -99,24 +96,30 @@ class GalleryAttachmentBuilder extends StreamAttachmentWidgetBuilder { onTap = () => onAttachmentTap!(message, attachment); } - return InkWell( - onTap: onTap, - child: Stack( - children: [ - StreamMediaAttachmentThumbnail( - media: attachment, - width: constraints.maxWidth, - height: constraints.maxHeight, - fit: BoxFit.cover, - ), - Padding( - padding: const EdgeInsets.all(8), - child: StreamAttachmentUploadStateBuilder( - message: message, - attachment: attachment, + return StreamMessageAttachment( + style: .from(padding: .zero), + child: InkWell( + onTap: onTap, + child: Stack( + fit: .expand, + alignment: .center, + children: [ + StreamMediaAttachmentThumbnail( + media: attachment, + fit: BoxFit.cover, ), - ), - ], + if (attachment.type == .video && attachment.uploadState.isSuccess) ...[ + const Center(child: StreamVideoPlayIndicator(size: .lg)), + ] else ...[ + Positioned.fill( + child: StreamAttachmentUploadStateBuilder( + message: message, + attachment: attachment, + ), + ), + ], + ], + ), ), ); }, diff --git a/packages/stream_chat_flutter/lib/src/attachment/builder/giphy_attachment_builder.dart b/packages/stream_chat_flutter/lib/src/attachment/builder/giphy_attachment_builder.dart index 2220ae9240..8eed7cc485 100644 --- a/packages/stream_chat_flutter/lib/src/attachment/builder/giphy_attachment_builder.dart +++ b/packages/stream_chat_flutter/lib/src/attachment/builder/giphy_attachment_builder.dart @@ -1,12 +1,5 @@ part of 'attachment_widget_builder.dart'; -const _kDefaultGiphyConstraints = BoxConstraints( - minWidth: 170, - maxWidth: 256, - minHeight: 100, - maxHeight: 300, -); - /// {@template giphyAttachmentBuilder} /// A widget builder for [AttachmentType.giphy] attachment type. /// @@ -15,20 +8,19 @@ const _kDefaultGiphyConstraints = BoxConstraints( class GiphyAttachmentBuilder extends StreamAttachmentWidgetBuilder { /// {@macro giphyAttachmentBuilder} const GiphyAttachmentBuilder({ - this.shape, - this.padding = const EdgeInsets.all(2), - this.constraints = _kDefaultGiphyConstraints, + this.style, + this.constraints, this.onAttachmentTap, }); - /// The shape of the giphy attachment. - final ShapeBorder? shape; + /// The style of the giphy attachment container. + /// + /// When null, a default style with a rounded rectangle shape and border + /// is used. + final StreamMessageAttachmentStyle? style; /// The constraints to apply to the giphy attachment widget. - final BoxConstraints constraints; - - /// The padding to apply to the giphy attachment widget. - final EdgeInsetsGeometry padding; + final BoxConstraints? constraints; /// The callback to call when the attachment is tapped. final StreamAttachmentWidgetTapCallback? onAttachmentTap; @@ -43,7 +35,7 @@ class GiphyAttachmentBuilder extends StreamAttachmentWidgetBuilder { } @override - Widget build( + Widget? build( BuildContext context, Message message, Map> attachments, @@ -57,15 +49,14 @@ class GiphyAttachmentBuilder extends StreamAttachmentWidgetBuilder { onTap = () => onAttachmentTap!(message, giphy); } - return Padding( - padding: padding, + return StreamMessageAttachment( + style: style, child: InkWell( onTap: onTap, child: StreamGiphyAttachment( message: message, constraints: constraints, giphy: giphy, - shape: shape, ), ), ); diff --git a/packages/stream_chat_flutter/lib/src/attachment/builder/image_attachment_builder.dart b/packages/stream_chat_flutter/lib/src/attachment/builder/image_attachment_builder.dart index 14aa7c687c..70cf7279df 100644 --- a/packages/stream_chat_flutter/lib/src/attachment/builder/image_attachment_builder.dart +++ b/packages/stream_chat_flutter/lib/src/attachment/builder/image_attachment_builder.dart @@ -1,12 +1,5 @@ part of 'attachment_widget_builder.dart'; -const _kDefaultImageConstraints = BoxConstraints( - minWidth: 170, - maxWidth: 256, - minHeight: 100, - maxHeight: 300, -); - /// {@template imageAttachmentBuilder} /// A widget builder for [AttachmentType.image] attachment type. /// @@ -15,20 +8,19 @@ const _kDefaultImageConstraints = BoxConstraints( class ImageAttachmentBuilder extends StreamAttachmentWidgetBuilder { /// {@macro imageAttachmentBuilder} const ImageAttachmentBuilder({ - this.shape, - this.padding = const EdgeInsets.all(2), - this.constraints = _kDefaultImageConstraints, + this.style, + this.constraints, this.onAttachmentTap, }); - /// The shape of the image attachment. - final ShapeBorder? shape; + /// The style of the image attachment container. + /// + /// When null, a default style with a rounded rectangle shape and border + /// is used. + final StreamMessageAttachmentStyle? style; /// The constraints to apply to the image attachment widget. - final BoxConstraints constraints; - - /// The padding to apply to the image attachment widget. - final EdgeInsetsGeometry padding; + final BoxConstraints? constraints; /// The callback to call when the attachment is tapped. final StreamAttachmentWidgetTapCallback? onAttachmentTap; @@ -43,7 +35,7 @@ class ImageAttachmentBuilder extends StreamAttachmentWidgetBuilder { } @override - Widget build( + Widget? build( BuildContext context, Message message, Map> attachments, @@ -57,12 +49,11 @@ class ImageAttachmentBuilder extends StreamAttachmentWidgetBuilder { onTap = () => onAttachmentTap!(message, image); } - return Padding( - padding: padding, + return StreamMessageAttachment( + style: style, child: InkWell( onTap: onTap, child: StreamImageAttachment( - shape: shape, message: message, constraints: constraints, image: image, diff --git a/packages/stream_chat_flutter/lib/src/attachment/builder/link_preview_attachment_builder.dart b/packages/stream_chat_flutter/lib/src/attachment/builder/link_preview_attachment_builder.dart new file mode 100644 index 0000000000..b97ff89d41 --- /dev/null +++ b/packages/stream_chat_flutter/lib/src/attachment/builder/link_preview_attachment_builder.dart @@ -0,0 +1,80 @@ +part of 'attachment_widget_builder.dart'; + +/// {@template urlAttachmentBuilder} +/// A widget builder for link preview attachment type. +/// +/// This is used to show url attachments with a preview. e.g. youtube, twitter, +/// etc. +/// {@endtemplate} +class LinkPreviewAttachmentBuilder extends StreamAttachmentWidgetBuilder { + /// {@macro urlAttachmentBuilder} + const LinkPreviewAttachmentBuilder({ + this.style, + this.constraints, + this.onAttachmentTap, + }); + + /// The style of the url attachment container. + /// + /// When null, a default style with a rounded rectangle shape, border, + /// and background color is used. + final StreamMessageAttachmentStyle? style; + + /// The constraints to apply to the url attachment widget. + final BoxConstraints? constraints; + + /// The callback to call when the attachment is tapped. + final StreamAttachmentWidgetTapCallback? onAttachmentTap; + + @override + bool canHandle( + Message message, + Map> attachments, + ) { + final urls = attachments[AttachmentType.urlPreview]; + return urls != null && urls.isNotEmpty; + } + + @override + Widget? build( + BuildContext context, + Message message, + Map> attachments, + ) { + assert(debugAssertCanHandle(message, attachments), ''); + + final spacing = context.streamSpacing; + + final urlPreviews = attachments[AttachmentType.urlPreview]!; + + Widget _buildUrlPreview(Attachment urlPreview) { + VoidCallback? onTap; + if (onAttachmentTap != null) { + onTap = () => onAttachmentTap!(message, urlPreview); + } + + return StreamMessageAttachment( + style: style, + child: InkWell( + onTap: onTap, + child: StreamLinkPreviewAttachment( + message: message, + urlAttachment: urlPreview, + constraints: constraints, + ), + ), + ); + } + + if (urlPreviews.length == 1) { + return _buildUrlPreview(urlPreviews.first); + } + + return Column( + spacing: spacing.xs, + children: [ + for (final urlPreview in urlPreviews) _buildUrlPreview(urlPreview), + ], + ); + } +} diff --git a/packages/stream_chat_flutter/lib/src/attachment/builder/mixed_attachment_builder.dart b/packages/stream_chat_flutter/lib/src/attachment/builder/mixed_attachment_builder.dart index 121c78e707..dd38a64375 100644 --- a/packages/stream_chat_flutter/lib/src/attachment/builder/mixed_attachment_builder.dart +++ b/packages/stream_chat_flutter/lib/src/attachment/builder/mixed_attachment_builder.dart @@ -12,49 +12,24 @@ part of 'attachment_widget_builder.dart'; class MixedAttachmentBuilder extends StreamAttachmentWidgetBuilder { /// {@macro mixedAttachmentBuilder} MixedAttachmentBuilder({ - this.padding = const EdgeInsets.all(4), StreamAttachmentWidgetTapCallback? onAttachmentTap, - }) : _imageAttachmentBuilder = ImageAttachmentBuilder( - padding: EdgeInsets.zero, - onAttachmentTap: onAttachmentTap, - ), - _videoAttachmentBuilder = VideoAttachmentBuilder( - padding: EdgeInsets.zero, - onAttachmentTap: onAttachmentTap, - ), - _giphyAttachmentBuilder = GiphyAttachmentBuilder( - padding: EdgeInsets.zero, - onAttachmentTap: onAttachmentTap, - ), - _galleryAttachmentBuilder = GalleryAttachmentBuilder( - padding: EdgeInsets.zero, - onAttachmentTap: onAttachmentTap, - ), - _fileAttachmentBuilder = FileAttachmentBuilder( - padding: EdgeInsets.zero, - onAttachmentTap: onAttachmentTap, - ), - _urlAttachmentBuilder = UrlAttachmentBuilder( - padding: EdgeInsets.zero, - onAttachmentTap: onAttachmentTap, - ), - _voiceRecordingAttachmentPlaylistBuilder = - VoiceRecordingAttachmentPlaylistBuilder( - padding: EdgeInsets.zero, - onAttachmentTap: onAttachmentTap, - ); - - /// The padding to apply to the mixed attachment widget. - final EdgeInsetsGeometry padding; + }) : _imageAttachmentBuilder = ImageAttachmentBuilder(onAttachmentTap: onAttachmentTap), + _videoAttachmentBuilder = VideoAttachmentBuilder(onAttachmentTap: onAttachmentTap), + _giphyAttachmentBuilder = GiphyAttachmentBuilder(onAttachmentTap: onAttachmentTap), + _galleryAttachmentBuilder = GalleryAttachmentBuilder(onAttachmentTap: onAttachmentTap), + _fileAttachmentBuilder = FileAttachmentBuilder(onAttachmentTap: onAttachmentTap), + _linkPreviewAttachmentBuilder = LinkPreviewAttachmentBuilder(onAttachmentTap: onAttachmentTap), + _voiceRecordingAttachmentPlaylistBuilder = VoiceRecordingAttachmentPlaylistBuilder( + onAttachmentTap: onAttachmentTap, + ); late final StreamAttachmentWidgetBuilder _imageAttachmentBuilder; late final StreamAttachmentWidgetBuilder _videoAttachmentBuilder; late final StreamAttachmentWidgetBuilder _giphyAttachmentBuilder; late final StreamAttachmentWidgetBuilder _galleryAttachmentBuilder; late final StreamAttachmentWidgetBuilder _fileAttachmentBuilder; - late final StreamAttachmentWidgetBuilder _urlAttachmentBuilder; - late final StreamAttachmentWidgetBuilder - _voiceRecordingAttachmentPlaylistBuilder; + late final StreamAttachmentWidgetBuilder _linkPreviewAttachmentBuilder; + late final StreamAttachmentWidgetBuilder _voiceRecordingAttachmentPlaylistBuilder; @override bool canHandle( @@ -83,7 +58,7 @@ class MixedAttachmentBuilder extends StreamAttachmentWidgetBuilder { } @override - Widget build( + Widget? build( BuildContext context, Message message, Map> attachments, @@ -99,44 +74,45 @@ class MixedAttachmentBuilder extends StreamAttachmentWidgetBuilder { final shouldBuildGallery = [...?images, ...?videos, ...?giphys].length > 1; - return Padding( - padding: padding, - child: Column( - spacing: padding.vertical / 2, - mainAxisSize: MainAxisSize.min, - children: [ - if (urls != null) - _urlAttachmentBuilder.build(context, message, { - AttachmentType.urlPreview: urls, - }), - if (files != null) - _fileAttachmentBuilder.build(context, message, { - AttachmentType.file: files, - }), - if (voiceRecordings != null) - _voiceRecordingAttachmentPlaylistBuilder.build(context, message, { - AttachmentType.voiceRecording: voiceRecordings, - }), - if (shouldBuildGallery) - _galleryAttachmentBuilder.build(context, message, { - if (images != null) AttachmentType.image: images, - if (videos != null) AttachmentType.video: videos, - if (giphys != null) AttachmentType.giphy: giphys, - }) - else if (images != null && images.length == 1) - _imageAttachmentBuilder.build(context, message, { - AttachmentType.image: images, - }) - else if (videos != null && videos.length == 1) - _videoAttachmentBuilder.build(context, message, { - AttachmentType.video: videos, - }) - else if (giphys != null && giphys.length == 1) - _giphyAttachmentBuilder.build(context, message, { - AttachmentType.giphy: giphys, - }), - ], - ), + final spacing = context.streamSpacing; + final crossAxisAlignment = StreamMessageLayout.crossAxisAlignmentOf(context); + + return Column( + spacing: spacing.xs, + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: crossAxisAlignment, + children: [ + if (urls != null) + ?_linkPreviewAttachmentBuilder.build(context, message, { + AttachmentType.urlPreview: urls, + }), + if (shouldBuildGallery) + ?_galleryAttachmentBuilder.build(context, message, { + if (images != null) AttachmentType.image: images, + if (videos != null) AttachmentType.video: videos, + if (giphys != null) AttachmentType.giphy: giphys, + }) + else if (images != null && images.length == 1) + ?_imageAttachmentBuilder.build(context, message, { + AttachmentType.image: images, + }) + else if (videos != null && videos.length == 1) + ?_videoAttachmentBuilder.build(context, message, { + AttachmentType.video: videos, + }) + else if (giphys != null && giphys.length == 1) + ?_giphyAttachmentBuilder.build(context, message, { + AttachmentType.giphy: giphys, + }), + if (files != null) + ?_fileAttachmentBuilder.build(context, message, { + AttachmentType.file: files, + }), + if (voiceRecordings != null) + ?_voiceRecordingAttachmentPlaylistBuilder.build(context, message, { + AttachmentType.voiceRecording: voiceRecordings, + }), + ], ); } } diff --git a/packages/stream_chat_flutter/lib/src/attachment/builder/poll_attachment_builder.dart b/packages/stream_chat_flutter/lib/src/attachment/builder/poll_attachment_builder.dart new file mode 100644 index 0000000000..cae2623ef2 --- /dev/null +++ b/packages/stream_chat_flutter/lib/src/attachment/builder/poll_attachment_builder.dart @@ -0,0 +1,53 @@ +part of 'attachment_widget_builder.dart'; + +/// {@template pollAttachmentBuilder} +/// A widget builder for Poll attachment type. +/// +/// This builder is used when a message contains a poll. +/// {@endtemplate} +class PollAttachmentBuilder extends StreamAttachmentWidgetBuilder { + /// {@macro pollAttachmentBuilder} + const PollAttachmentBuilder({ + this.style, + this.constraints, + }); + + /// The style of the poll attachment container. + /// + /// When null, a default style with a rounded rectangle shape and border + /// is used. + final StreamMessageAttachmentStyle? style; + + /// The constraints to apply to the poll attachment widget. + final BoxConstraints? constraints; + + @override + bool canHandle( + Message message, + Map> attachments, + ) { + final poll = message.poll; + return poll != null; + } + + @override + Widget? build( + BuildContext context, + Message message, + Map> attachments, + ) { + assert(debugAssertCanHandle(message, attachments), ''); + + final effectiveStyle = StreamMessageAttachmentStyle.from( + backgroundColor: StreamColors.transparent, + ).merge(style); + + return StreamMessageAttachment( + style: effectiveStyle, + child: StreamPollAttachment( + message: message, + constraints: constraints, + ), + ); + } +} diff --git a/packages/stream_chat_flutter/lib/src/attachment/builder/unsupported_attachment_builder.dart b/packages/stream_chat_flutter/lib/src/attachment/builder/unsupported_attachment_builder.dart new file mode 100644 index 0000000000..efe1ba2ad1 --- /dev/null +++ b/packages/stream_chat_flutter/lib/src/attachment/builder/unsupported_attachment_builder.dart @@ -0,0 +1,76 @@ +part of 'attachment_widget_builder.dart'; + +/// {@template unsupportedAttachmentBuilder} +/// A builder that renders a [StreamUnsupportedAttachment] for messages +/// containing SDK-known content that no prior builder could handle. +/// +/// Checks for typed attachments, polls, and shared locations. Custom builders +/// registered by the app are always prepended and take priority. +/// +/// {@tool snippet} +/// +/// Override the style of the unsupported attachment container: +/// +/// ```dart +/// UnsupportedAttachmentBuilder( +/// style: StreamMessageAttachmentStyle.from( +/// backgroundColor: Colors.grey.shade200, +/// ), +/// ) +/// ``` +/// {@end-tool} +/// +/// See also: +/// +/// * [StreamUnsupportedAttachment], the widget rendered by this builder. +/// * [FallbackAttachmentBuilder], the silent catch-all placed after this. +/// * [StreamAttachmentWidgetBuilder.defaultBuilders], which includes this +/// builder in the default list. +/// {@endtemplate} +class UnsupportedAttachmentBuilder extends StreamAttachmentWidgetBuilder { + /// {@macro unsupportedAttachmentBuilder} + const UnsupportedAttachmentBuilder({ + this.style, + this.constraints, + }); + + /// The style of the attachment container. + /// + /// When null, the default [StreamMessageAttachment] styling is used. + final StreamMessageAttachmentStyle? style; + + /// The constraints to apply to the unsupported attachment widget. + final BoxConstraints? constraints; + + @override + bool canHandle( + Message message, + Map> attachments, + ) { + // Typed attachments that no built-in builder recognised. + if (attachments.isNotEmpty) return true; + + // Message-level features that no prior builder handled. + if (message.poll != null) return true; + if (message.sharedLocation != null) return true; + + return false; + } + + @override + Widget build( + BuildContext context, + Message message, + Map> attachments, + ) { + assert(debugAssertCanHandle(message, attachments), ''); + + return StreamMessageAttachment( + style: style, + child: StreamUnsupportedAttachment( + message: message, + constraints: constraints, + ), + ); + } +} diff --git a/packages/stream_chat_flutter/lib/src/attachment/builder/url_attachment_builder.dart b/packages/stream_chat_flutter/lib/src/attachment/builder/url_attachment_builder.dart deleted file mode 100644 index ab464d9ed0..0000000000 --- a/packages/stream_chat_flutter/lib/src/attachment/builder/url_attachment_builder.dart +++ /dev/null @@ -1,103 +0,0 @@ -part of 'attachment_widget_builder.dart'; - -const _kDefaultUrlAttachmentConstraints = BoxConstraints(maxWidth: 256); - -/// {@template urlAttachmentBuilder} -/// A widget builder for url attachment type. -/// -/// This is used to show url attachments with a preview. e.g. youtube, twitter, -/// etc. -/// {@endtemplate} -class UrlAttachmentBuilder extends StreamAttachmentWidgetBuilder { - /// {@macro urlAttachmentBuilder} - const UrlAttachmentBuilder({ - this.shape, - this.padding = const EdgeInsets.all(8), - this.constraints = _kDefaultUrlAttachmentConstraints, - this.onAttachmentTap, - }); - - /// The shape of the url attachment. - final ShapeBorder? shape; - - /// The constraints to apply to the url attachment widget. - final BoxConstraints constraints; - - /// The padding to apply to the url attachment widget. - final EdgeInsetsGeometry padding; - - /// The callback to call when the attachment is tapped. - final StreamAttachmentWidgetTapCallback? onAttachmentTap; - - @override - bool canHandle( - Message message, - Map> attachments, - ) { - final urls = attachments[AttachmentType.urlPreview]; - return urls != null && urls.isNotEmpty; - } - - @override - Widget build( - BuildContext context, - Message message, - Map> attachments, - ) { - assert(debugAssertCanHandle(message, attachments), ''); - - final urlPreviews = attachments[AttachmentType.urlPreview]!; - - final client = StreamChat.of(context).client; - final isMyMessage = message.user?.id == client.state.currentUser?.id; - - final streamChatTheme = StreamChatTheme.of(context); - final messageTheme = isMyMessage - ? streamChatTheme.ownMessageTheme - : streamChatTheme.otherMessageTheme; - - Widget _buildUrlPreview(Attachment urlPreview) { - VoidCallback? onTap; - if (onAttachmentTap != null) { - onTap = () => onAttachmentTap!(message, urlPreview); - } - - final host = Uri.parse(urlPreview.titleLink!).host; - final splitList = host.split('.'); - final hostName = splitList.length == 3 ? splitList[1] : splitList[0]; - final hostDisplayName = urlPreview.authorName?.capitalize() ?? - getWebsiteName(hostName.toLowerCase()) ?? - hostName.capitalize(); - - return InkWell( - onTap: onTap, - child: StreamUrlAttachment( - message: message, - urlAttachment: urlPreview, - hostDisplayName: hostDisplayName, - messageTheme: messageTheme, - constraints: constraints, - shape: shape, - ), - ); - } - - Widget child; - if (urlPreviews.length == 1) { - child = _buildUrlPreview(urlPreviews.first); - } else { - child = Column( - // Add a small vertical padding between each attachment. - spacing: padding.vertical / 2, - children: [ - for (final urlPreview in urlPreviews) _buildUrlPreview(urlPreview), - ], - ); - } - - return Padding( - padding: padding, - child: child, - ); - } -} diff --git a/packages/stream_chat_flutter/lib/src/attachment/builder/video_attachment_builder.dart b/packages/stream_chat_flutter/lib/src/attachment/builder/video_attachment_builder.dart index 45f92ef8b8..23d9e81062 100644 --- a/packages/stream_chat_flutter/lib/src/attachment/builder/video_attachment_builder.dart +++ b/packages/stream_chat_flutter/lib/src/attachment/builder/video_attachment_builder.dart @@ -1,10 +1,5 @@ part of 'attachment_widget_builder.dart'; -const _kDefaultVideoConstraints = BoxConstraints.tightFor( - width: 256, - height: 195, -); - /// {@template videoAttachmentBuilder} /// A widget builder for [AttachmentType.video] attachment type. /// @@ -13,20 +8,19 @@ const _kDefaultVideoConstraints = BoxConstraints.tightFor( class VideoAttachmentBuilder extends StreamAttachmentWidgetBuilder { /// {@macro videoAttachmentBuilder} const VideoAttachmentBuilder({ - this.shape, - this.padding = const EdgeInsets.all(2), - this.constraints = _kDefaultVideoConstraints, + this.style, + this.constraints, this.onAttachmentTap, }); - /// The shape of the video attachment. - final ShapeBorder? shape; + /// The style of the video attachment container. + /// + /// When null, a default style with a rounded rectangle shape and border + /// is used. + final StreamMessageAttachmentStyle? style; /// The constraints to apply to the video attachment widget. - final BoxConstraints constraints; - - /// The padding to apply to the video attachment widget. - final EdgeInsetsGeometry padding; + final BoxConstraints? constraints; /// The callback to call when the attachment is tapped. final StreamAttachmentWidgetTapCallback? onAttachmentTap; @@ -43,7 +37,7 @@ class VideoAttachmentBuilder extends StreamAttachmentWidgetBuilder { } @override - Widget build( + Widget? build( BuildContext context, Message message, Map> attachments, @@ -57,8 +51,8 @@ class VideoAttachmentBuilder extends StreamAttachmentWidgetBuilder { onTap = () => onAttachmentTap!(message, video); } - return Padding( - padding: padding, + return StreamMessageAttachment( + style: style, child: InkWell( onTap: onTap, child: StreamVideoAttachment( diff --git a/packages/stream_chat_flutter/lib/src/attachment/builder/voice_recording_attachment_builder/stream_voice_recording_list_player.dart b/packages/stream_chat_flutter/lib/src/attachment/builder/voice_recording_attachment_builder/stream_voice_recording_list_player.dart deleted file mode 100644 index d13e2ec620..0000000000 --- a/packages/stream_chat_flutter/lib/src/attachment/builder/voice_recording_attachment_builder/stream_voice_recording_list_player.dart +++ /dev/null @@ -1,139 +0,0 @@ -// coverage:ignore-file - -import 'dart:async'; - -import 'package:collection/collection.dart'; -import 'package:flutter/material.dart'; -import 'package:just_audio/just_audio.dart'; -import 'package:stream_chat_flutter/stream_chat_flutter.dart'; - -/// {@template StreamVoiceRecordingListPlayer} -/// Display many audios and displays a list of AudioPlayerMessage. -/// {@endtemplate} -@Deprecated('Use StreamVoiceRecordingAttachmentPlaylist instead') -class StreamVoiceRecordingListPlayer extends StatefulWidget { - /// {@macro StreamVoiceRecordingListPlayer} - const StreamVoiceRecordingListPlayer({ - super.key, - required this.playList, - this.attachmentBorderRadiusGeometry, - this.constraints, - }); - - /// List of audio attachments. - final List playList; - - /// The border radius of each audio. - final BorderRadiusGeometry? attachmentBorderRadiusGeometry; - - /// Constraints of audio attachments - final BoxConstraints? constraints; - - @override - State createState() => - _StreamVoiceRecordingListPlayerState(); -} - -@Deprecated("Use 'StreamVoiceRecordingAttachmentPlaylist' instead") -class _StreamVoiceRecordingListPlayerState - extends State { - final _player = AudioPlayer(); - late StreamSubscription _playerStateChangedSubscription; - - Widget _createAudioPlayer(int index, PlayListItem item) { - final url = item.assetUrl; - Widget child; - - if (url == null) { - child = const StreamVoiceRecordingLoading(); - } else { - child = StreamVoiceRecordingPlayer( - player: _player, - duration: item.duration, - waveBars: item.waveForm, - index: index, - ); - } - - final theme = - StreamChatTheme.of(context).voiceRecordingTheme.listPlayerTheme; - - return Container( - margin: theme.margin, - constraints: widget.constraints, - decoration: BoxDecoration( - color: theme.backgroundColor, - border: Border.all( - color: theme.borderColor!, - ), - borderRadius: - widget.attachmentBorderRadiusGeometry ?? theme.borderRadius, - ), - child: child, - ); - } - - void _playerStateListener(PlayerState state) async { - if (state.processingState == ProcessingState.completed) { - await _player.stop(); - await _player.seek(Duration.zero, index: 0); - } - } - - @override - void initState() { - super.initState(); - - _playerStateChangedSubscription = - _player.playerStateStream.listen(_playerStateListener); - } - - @override - void dispose() { - super.dispose(); - - _playerStateChangedSubscription.cancel(); - _player.dispose(); - } - - @override - Widget build(BuildContext context) { - final playList = widget.playList - .where((attachment) => attachment.assetUrl != null) - .map((attachment) => AudioSource.uri(Uri.parse(attachment.assetUrl!))) - .toList(); - - final audioSource = ConcatenatingAudioSource(children: playList); - - _player - ..setShuffleModeEnabled(false) - ..setLoopMode(LoopMode.off) - ..setAudioSource(audioSource, preload: false); - - return Column( - children: widget.playList.mapIndexed(_createAudioPlayer).toList(), - ); - } -} - -/// {@template PlayListItem} -/// Represents an audio attachment meta data. -/// {@endtemplate} -@Deprecated("Use 'PlaylistTrack' instead") -class PlayListItem { - /// {@macro PlayListItem} - const PlayListItem({ - this.assetUrl, - required this.duration, - required this.waveForm, - }); - - /// The url of the audio. - final String? assetUrl; - - /// The duration of the audio. - final Duration duration; - - /// The wave form of the audio. - final List waveForm; -} diff --git a/packages/stream_chat_flutter/lib/src/attachment/builder/voice_recording_attachment_builder/stream_voice_recording_loading.dart b/packages/stream_chat_flutter/lib/src/attachment/builder/voice_recording_attachment_builder/stream_voice_recording_loading.dart deleted file mode 100644 index 7f26f1b6ff..0000000000 --- a/packages/stream_chat_flutter/lib/src/attachment/builder/voice_recording_attachment_builder/stream_voice_recording_loading.dart +++ /dev/null @@ -1,33 +0,0 @@ -// coverage:ignore-file - -import 'package:flutter/material.dart'; -import 'package:stream_chat_flutter/stream_chat_flutter.dart'; - -/// {@template StreamVoiceRecordingLoading} -/// Loading widget for audio message. Use this when the url from the audio -/// message is still not available. One use situation in when the audio is -/// still being uploaded. -/// {@endtemplate} -@Deprecated('Will be removed in the next major version') -class StreamVoiceRecordingLoading extends StatelessWidget { - /// {@macro StreamVoiceRecordingLoading} - const StreamVoiceRecordingLoading({super.key}); - - @override - Widget build(BuildContext context) { - final theme = StreamChatTheme.of(context).voiceRecordingTheme.loadingTheme; - - return Padding( - padding: theme.padding!, - child: SizedBox( - height: theme.size!.height, - width: theme.size!.width, - child: CircularProgressIndicator( - // ignore: unnecessary_null_checks - strokeWidth: theme.strokeWidth!, - color: theme.color, - ), - ), - ); - } -} diff --git a/packages/stream_chat_flutter/lib/src/attachment/builder/voice_recording_attachment_builder/stream_voice_recording_player.dart b/packages/stream_chat_flutter/lib/src/attachment/builder/voice_recording_attachment_builder/stream_voice_recording_player.dart deleted file mode 100644 index 0136fd3f81..0000000000 --- a/packages/stream_chat_flutter/lib/src/attachment/builder/voice_recording_attachment_builder/stream_voice_recording_player.dart +++ /dev/null @@ -1,320 +0,0 @@ -// coverage:ignore-file - -import 'dart:async'; -import 'dart:math'; - -import 'package:flutter/material.dart'; -import 'package:just_audio/just_audio.dart'; -import 'package:rxdart/rxdart.dart'; -import 'package:stream_chat_flutter/src/attachment/builder/voice_recording_attachment_builder/stream_voice_recording_slider.dart'; -import 'package:stream_chat_flutter/src/misc/empty_widget.dart'; -import 'package:stream_chat_flutter/stream_chat_flutter.dart'; - -/// {@template StreamVoiceRecordingPlayer} -/// Embedded player for audio messages. It displays the data for the audio -/// message and allow the user to interact with the player providing buttons -/// to play/pause, seek the audio and change the speed of reproduction. -/// -/// When waveBars are not provided they are shown as 0 bars. -/// {@endtemplate} -@Deprecated("Use 'StreamVoiceRecordingAttachment' instead") -class StreamVoiceRecordingPlayer extends StatefulWidget { - /// {@macro StreamVoiceRecordingPlayer} - const StreamVoiceRecordingPlayer({ - super.key, - required this.player, - required this.duration, - this.waveBars, - this.index = 0, - this.fileSize, - this.actionButton, - }); - - /// The player of the audio. - final AudioPlayer player; - - /// The wave bars of the recorded audio from 0 to 1. When not provided - /// this Widget shows then as small dots. - final List? waveBars; - - /// The duration of the audio. - final Duration duration; - - /// The index of the audio inside the play list. If not provided, this is - /// assumed to be zero. - final int index; - - /// The file size in bits. - final int? fileSize; - - /// An action button to be used. - final Widget? actionButton; - - @override - _StreamVoiceRecordingPlayerState createState() => - _StreamVoiceRecordingPlayerState(); -} - -@Deprecated("Use 'StreamVoiceRecordingAttachment' instead") -class _StreamVoiceRecordingPlayerState - extends State { - var _seeking = false; - - @override - void dispose() { - super.dispose(); - - widget.player.dispose(); - } - - @override - Widget build(BuildContext context) { - if (widget.duration != Duration.zero) { - return _content(widget.duration); - } else { - return StreamBuilder( - stream: widget.player.durationStream, - builder: (context, snapshot) { - if (snapshot.hasData) { - return _content(snapshot.data!); - } else if (snapshot.hasError) { - return const Center(child: Text('Error!!')); - } else { - return const StreamVoiceRecordingLoading(); - } - }, - ); - } - } - - Widget _content(Duration totalDuration) { - return Container( - padding: const EdgeInsets.all(8), - height: 60, - child: Row( - children: [ - SizedBox( - width: 36, - height: 36, - child: _controlButton(), - ), - Padding( - padding: const EdgeInsets.only(left: 8), - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - _timer(totalDuration), - _fileSizeWidget(widget.fileSize), - ], - ), - ), - _audioWaveSlider(totalDuration), - _speedAndActionButton(), - ], - ), - ); - } - - Widget _controlButton() { - final theme = StreamChatTheme.of(context).voiceRecordingTheme.playerTheme; - - return StreamBuilder( - initialData: false, - stream: _playingThisStream(), - builder: (context, snapshot) { - final playingThis = snapshot.data == true; - - final icon = playingThis ? theme.pauseIcon : theme.playIcon; - - final processingState = widget.player.playerStateStream - .map((event) => event.processingState); - - return StreamBuilder( - stream: processingState, - initialData: ProcessingState.idle, - builder: (context, snapshot) { - final state = snapshot.data ?? ProcessingState.idle; - if (state == ProcessingState.ready || - state == ProcessingState.idle || - !playingThis) { - return ElevatedButton( - style: ElevatedButton.styleFrom( - elevation: theme.buttonElevation, - padding: theme.buttonPadding, - backgroundColor: theme.buttonBackgroundColor, - shape: theme.buttonShape, - ), - child: Icon(icon, color: theme.iconColor), - onPressed: () { - if (playingThis) { - _pause(); - } else { - _play(); - } - }, - ); - } else { - return const CircularProgressIndicator(strokeWidth: 3); - } - }, - ); - }, - ); - } - - Widget _speedAndActionButton() { - final theme = StreamChatTheme.of(context).voiceRecordingTheme.playerTheme; - - final speedStream = _playingThisStream().flatMap((showSpeed) => - widget.player.speedStream.map((speed) => showSpeed ? speed : -1.0)); - - return StreamBuilder( - initialData: -1, - stream: speedStream, - builder: (context, snapshot) { - if (snapshot.hasData && snapshot.data! > 0) { - final speed = snapshot.data!; - return SizedBox( - width: theme.speedButtonSize!.width, - height: theme.speedButtonSize!.height, - child: ElevatedButton( - style: ElevatedButton.styleFrom( - elevation: theme.speedButtonElevation, - backgroundColor: theme.speedButtonBackgroundColor, - padding: theme.speedButtonPadding, - shape: theme.speedButtonShape, - ), - child: Text( - '${speed}x', - style: theme.speedButtonTextStyle, - ), - onPressed: () { - setState(() { - if (speed == 2) { - widget.player.setSpeed(1); - } else { - widget.player.setSpeed(speed + 0.5); - } - }); - }, - ), - ); - } else { - if (widget.actionButton != null) { - return widget.actionButton!; - } else { - return SizedBox( - width: theme.speedButtonSize!.width, - height: theme.speedButtonSize!.height, - child: theme.fileTypeIcon, - ); - } - } - }, - ); - } - - Widget _fileSizeWidget(int? fileSize) { - final theme = StreamChatTheme.of(context).voiceRecordingTheme.playerTheme; - - if (fileSize != null) { - return Text( - fileSize.toHumanReadableSize(), - style: theme.fileSizeTextStyle, - ); - } else { - return const Empty(); - } - } - - Widget _timer(Duration totalDuration) { - final theme = StreamChatTheme.of(context).voiceRecordingTheme.playerTheme; - - return StreamBuilder( - stream: widget.player.positionStream, - builder: (context, snapshot) { - if (snapshot.hasData && - (widget.player.currentIndex == widget.index && - (widget.player.playing || - snapshot.data!.inMilliseconds > 0 || - _seeking))) { - return Text( - snapshot.data!.toMinutesAndSeconds(), - style: theme.timerTextStyle, - ); - } else { - return Text( - totalDuration.toMinutesAndSeconds(), - style: theme.timerTextStyle, - ); - } - }, - ); - } - - Widget _audioWaveSlider(Duration totalDuration) { - final positionStream = widget.player.currentIndexStream.flatMap( - (index) => widget.player.positionStream.map((duration) => _sliderValue( - duration, - totalDuration, - index, - )), - ); - - return Expanded( - child: StreamVoiceRecordingSlider( - waves: widget.waveBars ?? List.filled(50, 0), - progressStream: positionStream, - onChangeStart: (val) { - setState(() { - _seeking = true; - }); - }, - onChanged: (val) { - widget.player.pause(); - widget.player.seek( - totalDuration * val, - index: widget.index, - ); - }, - onChangeEnd: () { - setState(() { - _seeking = false; - }); - }, - ), - ); - } - - double _sliderValue( - Duration duration, - Duration totalDuration, - int? currentIndex, - ) { - if (widget.index != currentIndex) { - return 0; - } else { - return min(duration.inMicroseconds / totalDuration.inMicroseconds, 1); - } - } - - Stream _playingThisStream() { - return widget.player.playingStream.flatMap((playing) { - return widget.player.currentIndexStream.map( - (index) => playing && index == widget.index, - ); - }); - } - - Future _play() async { - if (widget.index != widget.player.currentIndex) { - widget.player.seek(Duration.zero, index: widget.index); - } - - widget.player.play(); - } - - Future _pause() { - return widget.player.pause(); - } -} diff --git a/packages/stream_chat_flutter/lib/src/attachment/builder/voice_recording_attachment_builder/stream_voice_recording_slider.dart b/packages/stream_chat_flutter/lib/src/attachment/builder/voice_recording_attachment_builder/stream_voice_recording_slider.dart deleted file mode 100644 index a3ed7ddbbb..0000000000 --- a/packages/stream_chat_flutter/lib/src/attachment/builder/voice_recording_attachment_builder/stream_voice_recording_slider.dart +++ /dev/null @@ -1,239 +0,0 @@ -// coverage:ignore-file - -import 'dart:math'; - -import 'package:collection/collection.dart'; -import 'package:flutter/material.dart'; -import 'package:stream_chat_flutter/stream_chat_flutter.dart'; - -/// {@template StreamVoiceRecordingSlider} -/// A Widget that draws the audio wave bars for an audio inside a Slider. -/// This Widget is indeed to be used to control the position of an audio message -/// and to get feedback of the position. -/// {@endtemplate} -@Deprecated("Use 'StreamAudioWaveformSlider' instead") -class StreamVoiceRecordingSlider extends StatefulWidget { - /// {@macro StreamVoiceRecordingSlider} - const StreamVoiceRecordingSlider({ - super.key, - required this.waves, - required this.progressStream, - this.onChangeStart, - this.onChanged, - this.onChangeEnd, - this.customSliderButton, - this.customSliderButtonWidth, - }); - - /// The audio bars from 0.0 to 1.0. - final List waves; - - /// The progress of the audio. - final Stream progressStream; - - /// Callback called when Slider drag starts. - final Function(double)? onChangeStart; - - /// Callback called when Slider drag updates. - final Function(double)? onChanged; - - /// Callback called when Slider drag ends. - final Function()? onChangeEnd; - - /// A custom Slider button. Use this to substitute the default rounded - /// rectangle. - final Widget? customSliderButton; - - /// The width of the customSliderButton. This should match the width of the - /// provided Widget. - final double? customSliderButtonWidth; - - @override - _StreamVoiceRecordingSliderState createState() => - _StreamVoiceRecordingSliderState(); -} - -@Deprecated("Use 'StreamAudioWaveformSlider' instead") -class _StreamVoiceRecordingSliderState - extends State { - var _dragging = false; - final _initialWidth = 7.0; - final _finalWidth = 14.0; - final _initialHeight = 30.0; - final _finalHeight = 35.0; - - Duration get animationDuration => - _dragging ? Duration.zero : const Duration(milliseconds: 300); - - double get _currentWidth { - if (widget.customSliderButtonWidth != null) { - return widget.customSliderButtonWidth!; - } else { - return _dragging ? _finalWidth : _initialWidth; - } - } - - double get _currentHeight => _dragging ? _finalHeight : _initialHeight; - - double _progressToWidth( - BoxConstraints constraints, double progress, double horizontalPadding) { - final availableWidth = constraints.maxWidth - horizontalPadding * 2; - - return availableWidth * progress - _currentWidth / 2 + horizontalPadding; - } - - @override - Widget build(BuildContext context) { - final theme = StreamChatTheme.of(context).voiceRecordingTheme.sliderTheme; - - return StreamBuilder( - initialData: 0, - stream: widget.progressStream, - builder: (context, snapshot) { - final progress = snapshot.data ?? 0; - - final sliderButton = widget.customSliderButton ?? - Container( - width: _currentWidth, - height: _currentHeight, - decoration: BoxDecoration( - color: theme.buttonColor, - boxShadow: [ - theme.buttonShadow!, - ], - border: Border.all( - color: theme.buttonBorderColor!, - width: theme.buttonBorderWidth!, - ), - borderRadius: theme.buttonBorderRadius, - ), - ); - - return LayoutBuilder( - builder: (BuildContext context, BoxConstraints constraints) { - return Stack( - alignment: Alignment.center, - children: [ - CustomPaint( - size: Size(constraints.maxWidth, constraints.maxHeight), - painter: _AudioBarsPainter( - bars: widget.waves, - spacingRatio: theme.spacingRatio, - barHeightRatio: theme.waveHeightRatio, - colorLeft: theme.waveColorPlayed!, - colorRight: theme.waveColorUnplayed!, - progressPercentage: progress, - padding: theme.horizontalPadding, - ), - ), - AnimatedPositioned( - duration: animationDuration, - left: _progressToWidth( - constraints, progress, theme.horizontalPadding), - curve: const ElasticOutCurve(1.05), - child: sliderButton, - ), - GestureDetector( - onHorizontalDragStart: (details) { - widget.onChangeStart - ?.call(details.localPosition.dx / constraints.maxWidth); - - setState(() { - _dragging = true; - }); - }, - onHorizontalDragEnd: (details) { - widget.onChangeEnd?.call(); - - setState(() { - _dragging = false; - }); - }, - onHorizontalDragUpdate: (details) { - widget.onChanged?.call( - min( - max(details.localPosition.dx / constraints.maxWidth, 0), - 1, - ), - ); - }, - ), - ], - ); - }, - ); - }, - ); - } -} - -class _AudioBarsPainter extends CustomPainter { - _AudioBarsPainter({ - required this.bars, - required this.progressPercentage, - this.colorLeft = Colors.blueAccent, - this.colorRight = Colors.grey, - this.spacingRatio = 0.01, - this.barHeightRatio = 1, - this.padding = 20, - }); - - final List bars; - final double progressPercentage; - final Color colorRight; - final Color colorLeft; - final double spacingRatio; - final double barHeightRatio; - final double padding; - - /// barWidth should include spacing, not only the width of the bar. - /// progressX should be the middle of the moving button of the slider, not - /// initial X position. - Color _barColor(double buttonCenter, double progressX) { - return (progressX > buttonCenter) ? colorLeft : colorRight; - } - - double _barHeight(double barValue, totalHeight) { - return max(barValue * totalHeight * barHeightRatio, 2); - } - - double _progressToWidth(double totalWidth, double progress) { - final availableWidth = totalWidth; - - return availableWidth * progress + padding; - } - - @override - void paint(Canvas canvas, Size size) { - final totalWidth = size.width - padding * 2; - - final spacingWidth = totalWidth * spacingRatio; - final totalBarWidth = totalWidth - spacingWidth * (bars.length - 1); - final barWidth = totalBarWidth / bars.length; - final barY = size.height / 2; - - bars.forEachIndexed((i, barValue) { - final barHeight = _barHeight(barValue, size.height); - final barX = i * (barWidth + spacingWidth) + barWidth / 2 + padding; - - final rect = RRect.fromRectAndRadius( - Rect.fromCenter( - center: Offset(barX, barY), - width: barWidth, - height: barHeight, - ), - const Radius.circular(50), - ); - - final paint = Paint() - ..color = _barColor( - barX + barWidth / 2, - _progressToWidth(totalWidth, progressPercentage), - ); - canvas.drawRRect(rect, paint); - }); - } - - @override - bool shouldRepaint(covariant CustomPainter oldDelegate) => true; -} diff --git a/packages/stream_chat_flutter/lib/src/attachment/builder/voice_recording_attachment_builder/voice_recording_attachment_builder.dart b/packages/stream_chat_flutter/lib/src/attachment/builder/voice_recording_attachment_builder/voice_recording_attachment_builder.dart deleted file mode 100644 index 412b653cc0..0000000000 --- a/packages/stream_chat_flutter/lib/src/attachment/builder/voice_recording_attachment_builder/voice_recording_attachment_builder.dart +++ /dev/null @@ -1,35 +0,0 @@ -// coverage:ignore-file - -part of '../attachment_widget_builder.dart'; - -/// The default attachment builder for voice recordings -@Deprecated("Use 'VoiceRecordingAttachmentPlaylistBuilder' instead") -class VoiceRecordingAttachmentBuilder extends StreamAttachmentWidgetBuilder { - @override - bool canHandle(Message message, Map> attachments) { - final recordings = attachments[AttachmentType.voiceRecording]; - if (recordings != null && recordings.length == 1) return true; - - return false; - } - - @override - Widget build(BuildContext context, Message message, - Map> attachments) { - final recordings = attachments[AttachmentType.voiceRecording]!; - - return StreamVoiceRecordingListPlayer( - playList: recordings - .map( - (r) => PlayListItem( - assetUrl: r.assetUrl, - duration: r.duration, - waveForm: r.waveform, - ), - ) - .toList(), - attachmentBorderRadiusGeometry: BorderRadius.circular(16), - constraints: const BoxConstraints.tightFor(width: 400), - ); - } -} diff --git a/packages/stream_chat_flutter/lib/src/attachment/builder/voice_recording_attachment_playlist_builder.dart b/packages/stream_chat_flutter/lib/src/attachment/builder/voice_recording_attachment_playlist_builder.dart index f53fea8642..d50de626ff 100644 --- a/packages/stream_chat_flutter/lib/src/attachment/builder/voice_recording_attachment_playlist_builder.dart +++ b/packages/stream_chat_flutter/lib/src/attachment/builder/voice_recording_attachment_playlist_builder.dart @@ -9,24 +9,22 @@ part of 'attachment_widget_builder.dart'; /// The widget is built when the message has at least one voice recording /// attachment. /// {@endtemplate} -class VoiceRecordingAttachmentPlaylistBuilder - extends StreamAttachmentWidgetBuilder { +class VoiceRecordingAttachmentPlaylistBuilder extends StreamAttachmentWidgetBuilder { /// {@macro voiceRecordingAttachmentPlaylistBuilder} const VoiceRecordingAttachmentPlaylistBuilder({ - this.shape, - this.padding = const EdgeInsets.all(16), - this.constraints = const BoxConstraints(), + this.style, + this.constraints, this.onAttachmentTap, }); - /// The shape of the video attachment. - final ShapeBorder? shape; - - /// The padding to apply to the video attachment widget. - final EdgeInsetsGeometry padding; + /// The style of the voice recording attachment container. + /// + /// When null, a default style with a rounded rectangle shape and border + /// is used. + final StreamMessageAttachmentStyle? style; /// The constraints to apply to the video attachment widget. - final BoxConstraints constraints; + final BoxConstraints? constraints; /// The callback to call when the attachment is tapped. final StreamAttachmentWidgetTapCallback? onAttachmentTap; @@ -41,7 +39,7 @@ class VoiceRecordingAttachmentPlaylistBuilder } @override - Widget build( + Widget? build( BuildContext context, Message message, Map> attachments, @@ -50,15 +48,37 @@ class VoiceRecordingAttachmentPlaylistBuilder final playlist = attachments[AttachmentType.voiceRecording]!; - return Padding( - padding: padding, + return StreamVoiceRecordingAttachmentTheme( + data: _StreamVoiceRecordingAttachmentDefaults(context), child: StreamVoiceRecordingAttachmentPlaylist( - shape: shape, message: message, voiceRecordings: playlist, constraints: constraints, - separatorBuilder: (_, __) => SizedBox(height: padding.vertical / 2), + itemDecorator: (context, index, child) { + return StreamMessageAttachment(style: style, child: child); + }, ), ); } } + +// Default values for [StreamVoiceRecordingAttachmentThemeData] backed by stream design tokens. +class _StreamVoiceRecordingAttachmentDefaults extends StreamVoiceRecordingAttachmentThemeData { + _StreamVoiceRecordingAttachmentDefaults(this._context); + + final BuildContext _context; + + late final _alignment = StreamMessageLayout.messageAlignmentOf(_context); + late final StreamColorScheme _colorScheme = _context.streamColorScheme; + + Color get _borderColor => switch (_alignment) { + .start => _colorScheme.borderStrong, + .end => _colorScheme.brand.shade300, + }; + + @override + StreamButtonThemeStyle get controlButtonStyle => .from(borderColor: _borderColor); + + @override + StreamPlaybackSpeedToggleStyle get speedToggleStyle => .from(borderColor: _borderColor); +} diff --git a/packages/stream_chat_flutter/lib/src/attachment/file_attachment.dart b/packages/stream_chat_flutter/lib/src/attachment/file_attachment.dart index a6cf3a04a0..688690cd96 100644 --- a/packages/stream_chat_flutter/lib/src/attachment/file_attachment.dart +++ b/packages/stream_chat_flutter/lib/src/attachment/file_attachment.dart @@ -1,28 +1,75 @@ import 'package:flutter/material.dart'; -import 'package:stream_chat_flutter/src/attachment/handler/stream_attachment_handler.dart'; -import 'package:stream_chat_flutter/src/attachment/thumbnail/file_attachment_thumbnail.dart'; -import 'package:stream_chat_flutter/src/icons/stream_svg_icon.dart'; -import 'package:stream_chat_flutter/src/indicators/upload_progress_indicator.dart'; -import 'package:stream_chat_flutter/src/theme/stream_chat_theme.dart'; +import 'package:stream_chat_flutter/src/components/stream_chat_component_builders.dart'; import 'package:stream_chat_flutter/src/utils/utils.dart'; import 'package:stream_chat_flutter_core/stream_chat_flutter_core.dart'; +import 'package:stream_core_flutter/stream_core_flutter.dart'; -/// {@template streamFileAttachment} -/// Displays file attachments that have been sent in a chat. +/// A file attachment component with file information and actions. /// -/// Used in [MessageWidget]. -/// {@endtemplate} +/// [StreamFileAttachment] presents a file attachment, including the file +/// name, size, and appropriate actions based on the message state. +/// +/// {@tool snippet} +/// +/// Basic usage: +/// +/// ```dart +/// StreamFileAttachment( +/// message: message, +/// file: fileAttachment, +/// ) +/// ``` +/// {@end-tool} +/// +/// See also: +/// +/// * [StreamFileAttachmentProps], which configures this widget. +/// * [DefaultStreamFileAttachment], the default implementation. class StreamFileAttachment extends StatelessWidget { - /// {@macro streamFileAttachment} - const StreamFileAttachment({ + /// Creates a [StreamFileAttachment]. + StreamFileAttachment({ super.key, + required Message message, + required Attachment file, + BoxConstraints? constraints, + Widget? title, + Widget? trailing, + }) : props = .new( + message: message, + file: file, + constraints: constraints, + title: title, + trailing: trailing, + ); + + /// The properties that configure this attachment. + final StreamFileAttachmentProps props; + + @override + Widget build(BuildContext context) { + final builder = context.chatComponentBuilder(); + if (builder != null) return builder(context, props); + return DefaultStreamFileAttachment(props: props); + } +} + +/// Properties for configuring a [StreamFileAttachment]. +/// +/// This class holds all the configuration options for a file attachment, +/// allowing them to be passed through the [StreamComponentFactory]. +/// +/// See also: +/// +/// * [StreamFileAttachment], which uses these properties. +/// * [DefaultStreamFileAttachment], the default implementation. +class StreamFileAttachmentProps { + /// Creates properties for a file attachment. + const StreamFileAttachmentProps({ required this.message, required this.file, this.title, this.trailing, - this.shape, - this.backgroundColor, - this.constraints = const BoxConstraints(), + this.constraints, }); /// The [Message] that the file is attached to. @@ -31,18 +78,8 @@ class StreamFileAttachment extends StatelessWidget { /// The [Attachment] object containing the file information. final Attachment file; - /// The shape of the attachment. - /// - /// Defaults to [RoundedRectangleBorder] with a radius of 12. - final ShapeBorder? shape; - - /// The background color of the attachment. - /// - /// Defaults to [StreamChatTheme.colorTheme.barsBg]. - final Color? backgroundColor; - /// The constraints to use when displaying the file. - final BoxConstraints constraints; + final BoxConstraints? constraints; /// Widget for displaying the title of the attachment. /// (usually the file name) @@ -51,227 +88,80 @@ class StreamFileAttachment extends StatelessWidget { /// Widget for displaying at the end of the attachment. /// (such as a download button) final Widget? trailing; - - @override - Widget build(BuildContext context) { - final chatTheme = StreamChatTheme.of(context); - final textTheme = chatTheme.textTheme; - final colorTheme = chatTheme.colorTheme; - - final backgroundColor = this.backgroundColor ?? colorTheme.barsBg; - final shape = this.shape ?? - RoundedRectangleBorder( - side: BorderSide( - color: colorTheme.borders, - strokeAlign: BorderSide.strokeAlignOutside, - ), - borderRadius: BorderRadius.circular(12), - ); - - return Container( - constraints: constraints, - clipBehavior: Clip.hardEdge, - decoration: ShapeDecoration( - shape: shape, - color: backgroundColor, - ), - child: Row( - children: [ - Container( - width: 34, - height: 40, - margin: const EdgeInsets.all(8), - child: _FileTypeImage(file: file), - ), - const SizedBox(width: 8), - Expanded( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - file.title ?? context.translations.fileText, - maxLines: 1, - style: textTheme.bodyBold, - overflow: TextOverflow.ellipsis, - ), - const SizedBox(height: 3), - _FileAttachmentSubtitle(attachment: file), - ], - ), - ), - const SizedBox(width: 8), - Material( - type: MaterialType.transparency, - child: trailing ?? - _Trailing( - attachment: file, - message: message, - ), - ), - ], - ), - ); - } } -class _FileTypeImage extends StatelessWidget { - const _FileTypeImage({required this.file}); +const _kDefaultConstraints = BoxConstraints( + minWidth: 256, + maxWidth: 256, + minHeight: 64, +); - final Attachment file; - - // TODO: Improve image memory. - // This is using the full image instead of a smaller version (thumbnail) - @override - Widget build(BuildContext context) { - Widget child = StreamFileAttachmentThumbnail( - file: file, - width: double.infinity, - height: double.infinity, - ); - - final mediaType = file.title?.mediaType; - final isImage = mediaType?.type == AttachmentType.image; - final isVideo = mediaType?.type == AttachmentType.video; - if (isImage || isVideo) { - final colorTheme = StreamChatTheme.of(context).colorTheme; - child = Container( - clipBehavior: Clip.hardEdge, - decoration: ShapeDecoration( - shape: RoundedRectangleBorder( - side: BorderSide( - color: colorTheme.borders, - strokeAlign: BorderSide.strokeAlignOutside, - ), - borderRadius: BorderRadius.circular(8), - ), - ), - child: child, - ); - } - - return child; - } -} - -class _Trailing extends StatelessWidget { - const _Trailing({ - required this.attachment, - required this.message, +/// The default implementation of [StreamFileAttachment]. +/// +/// Renders the file information with download and upload controls. +/// +/// See also: +/// +/// * [StreamFileAttachment], the public API widget. +/// * [StreamFileAttachmentProps], which configures this widget. +class DefaultStreamFileAttachment extends StatelessWidget { + /// Creates a default Stream file attachment. + const DefaultStreamFileAttachment({ + super.key, + required this.props, }); - final Attachment attachment; - final Message message; + /// The properties that configure this attachment. + final StreamFileAttachmentProps props; @override Widget build(BuildContext context) { - final theme = StreamChatTheme.of(context); - final channel = StreamChannel.of(context).channel; - final attachmentId = attachment.id; + final file = props.file; - if (message.state.isCompleted) { - return IconButton( - icon: StreamSvgIcon( - icon: StreamSvgIcons.cloudDownload, - color: theme.colorTheme.textHighEmphasis, - ), - visualDensity: VisualDensity.compact, - splashRadius: 16, - onPressed: () async { - final assetUrl = attachment.assetUrl; - if (assetUrl != null) { - if (isMobileDeviceOrWeb) { - launchURL(context, assetUrl); - } else { - StreamAttachmentHandler.instance.downloadAttachment(attachment); - } - } - }, - ); - } + final spacing = context.streamSpacing; + final textTheme = context.streamTextTheme; + final colorScheme = context.streamColorScheme; + final constraints = props.constraints ?? _kDefaultConstraints; - return attachment.uploadState.when( - preparing: () => Padding( - padding: const EdgeInsets.all(8), - child: _TrailingButton( - icon: StreamSvgIcon( - icon: StreamSvgIcons.close, - color: theme.colorTheme.barsBg, - ), - fillColor: theme.colorTheme.overlayDark, - onPressed: () => channel.cancelAttachmentUpload(attachmentId), - ), - ), - inProgress: (_, __) => Padding( - padding: const EdgeInsets.all(8), - child: _TrailingButton( - icon: StreamSvgIcon( - icon: StreamSvgIcons.close, - color: theme.colorTheme.barsBg, - ), - fillColor: theme.colorTheme.overlayDark, - onPressed: () => channel.cancelAttachmentUpload(attachmentId), - ), - ), - success: () => Padding( - padding: const EdgeInsets.all(8), - child: CircleAvatar( - backgroundColor: theme.colorTheme.accentPrimary, - maxRadius: 12, - child: StreamSvgIcon( - icon: StreamSvgIcons.check, - color: theme.colorTheme.barsBg, - ), - ), - ), - failed: (_) => Padding( - padding: const EdgeInsets.all(8), - child: _TrailingButton( - icon: StreamSvgIcon( - icon: StreamSvgIcons.retry, - color: theme.colorTheme.barsBg, - ), - fillColor: theme.colorTheme.overlayDark, - onPressed: () => channel.retryAttachmentUpload( - message.id, - attachmentId, - ), + return ConstrainedBox( + constraints: constraints, + child: Padding( + padding: .all(spacing.sm), + child: Row( + spacing: spacing.sm, + children: [ + StreamFileTypeIcon.fromMimeType( + size: .lg, + mimeType: file.title?.mediaType?.mimeType, + ), + Expanded( + child: Column( + spacing: spacing.xxs, + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + DefaultTextStyle.merge( + maxLines: 1, + overflow: .ellipsis, + style: textTheme.captionEmphasis.copyWith(color: colorScheme.textPrimary), + child: Text(file.title ?? context.translations.fileText), + ), + DefaultTextStyle.merge( + maxLines: 1, + overflow: .ellipsis, + style: textTheme.metadataDefault.copyWith(color: colorScheme.textPrimary), + child: _FileAttachmentSubtitle(attachment: file), + ), + ], + ), + ), + ], ), ), ); } } -class _TrailingButton extends StatelessWidget { - const _TrailingButton({ - this.onPressed, - this.fillColor, - this.icon, - }); - - final VoidCallback? onPressed; - final Color? fillColor; - final Widget? icon; - - @override - Widget build(BuildContext context) { - return SizedBox( - height: 24, - width: 24, - child: RawMaterialButton( - elevation: 0, - highlightElevation: 0, - focusElevation: 0, - hoverElevation: 0, - onPressed: onPressed, - fillColor: fillColor, - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), - child: icon, - ), - ); - } -} - class _FileAttachmentSubtitle extends StatelessWidget { const _FileAttachmentSubtitle({ required this.attachment, @@ -281,24 +171,37 @@ class _FileAttachmentSubtitle extends StatelessWidget { @override Widget build(BuildContext context) { - final theme = StreamChatTheme.of(context); - final size = attachment.file?.size ?? attachment.extraData['file_size']; - final textStyle = theme.textTheme.footnote.copyWith( - color: theme.colorTheme.textLowEmphasis, - ); + final icons = context.streamIcons; + final spacing = context.streamSpacing; + + final colorScheme = context.streamColorScheme; + + final attachmentSize = attachment.file?.size ?? attachment.extraData['file_size']; + return attachment.uploadState.when( - preparing: () => Text(fileSize(size), style: textStyle), - inProgress: (sent, total) => StreamUploadProgressIndicator( - uploaded: sent, - total: total, - showBackground: false, - textStyle: textStyle, - progressIndicatorColor: theme.colorTheme.accentPrimary, - ), - success: () => Text(fileSize(size), style: textStyle), - failed: (_) => Text( - context.translations.uploadErrorLabel, - style: textStyle, + success: () => Text(fileSize(attachmentSize)), + preparing: () => Text(fileSize(attachmentSize)), + inProgress: (sent, total) { + // Fall back to an indeterminate spinner when the total size is unknown + // (e.g. `total` reported as `-1` or `0`) instead of rendering a fake 0%. + final progress = total > 0 ? sent / total : null; + + return Row( + mainAxisSize: .min, + spacing: spacing.xxs, + children: [ + StreamLoadingSpinner(value: progress, size: .xs), + Text('${fileSize(sent)} / ${fileSize(total)}'), + ], + ); + }, + failed: (_) => Row( + mainAxisSize: .min, + spacing: spacing.xxs, + children: [ + Icon(icons.exclamationTriangleFill, size: 16, color: colorScheme.accentError), + Text(context.translations.uploadErrorLabel), + ], ), ); } diff --git a/packages/stream_chat_flutter/lib/src/attachment/gallery_attachment.dart b/packages/stream_chat_flutter/lib/src/attachment/gallery_attachment.dart index 22cbf84265..bfb7c4dcb2 100644 --- a/packages/stream_chat_flutter/lib/src/attachment/gallery_attachment.dart +++ b/packages/stream_chat_flutter/lib/src/attachment/gallery_attachment.dart @@ -2,123 +2,176 @@ import 'package:flutter/material.dart'; import 'package:stream_chat_flutter/src/misc/flex_grid.dart'; import 'package:stream_chat_flutter/stream_chat_flutter.dart'; -/// {@template streamGalleryAttachment} -/// Constructs a gallery of images, videos, and gifs from a list of attachments. +/// A responsive grid layout for multiple media attachments. /// -/// This widget uses a [FlexGrid] to display the attachments in a grid format. -/// The grid will automatically resize based on the size of the attachment. -/// {@endtemplate} +/// [StreamGalleryAttachment] arranges two or more image, video, or GIF +/// attachments in a responsive grid layout. +/// +/// {@tool snippet} +/// +/// Basic usage: +/// +/// ```dart +/// StreamGalleryAttachment( +/// message: message, +/// attachments: mediaAttachments, +/// itemBuilder: (context, index) => MyMediaThumbnail( +/// attachment: mediaAttachments[index], +/// ), +/// ) +/// ``` +/// {@end-tool} /// /// See also: /// -/// * [StreamImageAttachmentThumbnail], which is used to display the image -/// thumbnails. -/// * [StreamVideoAttachmentThumbnail], which is used to display the video -/// thumbnails. -/// * [StreamGiphyAttachmentThumbnail], which is used to display the gif -/// thumbnails. +/// * [StreamGalleryAttachmentProps], which configures this widget. +/// * [DefaultStreamGalleryAttachment], the default implementation. class StreamGalleryAttachment extends StatelessWidget { - /// {@macro streamGalleryAttachment} - const StreamGalleryAttachment({ + /// Creates a [StreamGalleryAttachment]. + StreamGalleryAttachment({ super.key, - required this.attachments, + required Message message, + required List attachments, + BoxConstraints? constraints, + double? spacing, + double? runSpacing, + required IndexedWidgetBuilder itemBuilder, + }) : props = .new( + message: message, + attachments: attachments, + constraints: constraints, + spacing: spacing, + runSpacing: runSpacing, + itemBuilder: itemBuilder, + ); + + /// The properties that configure this attachment. + final StreamGalleryAttachmentProps props; + + @override + Widget build(BuildContext context) { + final builder = context.chatComponentBuilder(); + if (builder != null) return builder(context, props); + return DefaultStreamGalleryAttachment(props: props); + } +} + +/// Properties for configuring a [StreamGalleryAttachment]. +/// +/// This class holds all the configuration options for a gallery attachment, +/// allowing them to be passed through the [StreamComponentFactory]. +/// +/// See also: +/// +/// * [StreamGalleryAttachment], which uses these properties. +/// * [DefaultStreamGalleryAttachment], the default implementation. +class StreamGalleryAttachmentProps { + /// Creates properties for a gallery attachment. + const StreamGalleryAttachmentProps({ required this.message, - this.shape, - this.constraints = const BoxConstraints(), - this.spacing = 2.0, - this.runSpacing = 2.0, + required this.attachments, + this.constraints, + this.spacing, + this.runSpacing, required this.itemBuilder, }); - /// List of attachments to show - final List attachments; - - /// The [Message] that the images are attached to + /// The [Message] that the images are attached to. final Message message; - /// The shape of the attachment. - /// - /// Defaults to [RoundedRectangleBorder] with a radius of 14. - final ShapeBorder? shape; + /// The list of media attachments to display in the grid. + final List attachments; - /// The constraints of the [attachments] - final BoxConstraints constraints; + /// The constraints to use when displaying the gallery. + final BoxConstraints? constraints; /// How much space to place between children in a run in the main axis. /// /// For example, if [spacing] is 10.0, the children will be spaced at least /// 10.0 logical pixels apart in the main axis. /// - /// Defaults to 2.0. - final double spacing; + /// When null, defaults to [StreamSpacing.xxs]. + final double? spacing; /// How much space to place between the runs themselves in the cross axis. /// /// For example, if [runSpacing] is 10.0, the runs will be spaced at least /// 10.0 logical pixels apart in the cross axis. /// - /// Defaults to 2.0. - final double runSpacing; + /// When null, defaults to [StreamSpacing.xxs]. + final double? runSpacing; /// Item builder for the gallery. final IndexedWidgetBuilder itemBuilder; +} + +const _kDefaultConstraints = BoxConstraints.tightFor(width: 256, height: 195); + +/// The default implementation of [StreamGalleryAttachment]. +/// +/// Renders a responsive grid of media attachment thumbnails. +/// +/// See also: +/// +/// * [StreamGalleryAttachment], the public API widget. +/// * [StreamGalleryAttachmentProps], which configures this widget. +class DefaultStreamGalleryAttachment extends StatelessWidget { + /// Creates a default Stream gallery attachment. + const DefaultStreamGalleryAttachment({ + super.key, + required this.props, + }); + + /// The properties that configure this attachment. + final StreamGalleryAttachmentProps props; @override Widget build(BuildContext context) { + final attachments = props.attachments; assert( attachments.length >= 2, 'Gallery should have at least 2 attachments, found ${attachments.length}', ); - final chatTheme = StreamChatTheme.of(context); - final colorTheme = chatTheme.colorTheme; - final shape = this.shape ?? - RoundedRectangleBorder( - side: BorderSide( - color: colorTheme.borders, - strokeAlign: BorderSide.strokeAlignOutside, - ), - borderRadius: BorderRadius.circular(14), - ); + final streamSpacing = context.streamSpacing; + final constraints = props.constraints ?? _kDefaultConstraints; + + final spacing = props.spacing ?? streamSpacing.xxs; + final runSpacing = props.runSpacing ?? streamSpacing.xxs; - return Container( + return ConstrainedBox( constraints: constraints, - clipBehavior: Clip.hardEdge, - decoration: ShapeDecoration(shape: shape), - // Added a builder just for the sake of calculating the image count - // and building the appropriate layout based on the image count. child: Builder( - builder: (context) { - final attachmentCount = attachments.length; - if (attachmentCount == 2) { - return _buildForTwo(context, attachments); - } - - if (attachmentCount == 3) { - return _buildForThree(context, attachments); - } - - return _buildForFourOrMore(context, attachments); + builder: (context) => switch (attachments.length) { + 2 => _buildForTwo(context, attachments, props.itemBuilder, spacing: spacing, runSpacing: runSpacing), + 3 => _buildForThree(context, attachments, props.itemBuilder, spacing: spacing, runSpacing: runSpacing), + _ => _buildForFourOrMore(context, attachments, props.itemBuilder, spacing: spacing, runSpacing: runSpacing), }, ), ); } - Widget _buildForTwo(BuildContext context, List attachments) { + Widget _buildForTwo( + BuildContext context, + List attachments, + IndexedWidgetBuilder itemBuilder, { + required double spacing, + required double runSpacing, + }) { final aspectRatio1 = attachments[0].originalSize?.aspectRatio; final aspectRatio2 = attachments[1].originalSize?.aspectRatio; - // check if one image is landscape and other is portrait or vice versa + // Check if one image is landscape and other is portrait or vice versa. final isLandscape1 = aspectRatio1 != null && aspectRatio1 > 1; final isLandscape2 = aspectRatio2 != null && aspectRatio2 > 1; // Both the images are landscape. + // ---------- + // | | + // ---------- + // | | + // ---------- if (isLandscape1 && isLandscape2) { - // ---------- - // | | - // ---------- - // | | - // ---------- return FlexGrid( pattern: const [ [1], @@ -133,43 +186,15 @@ class StreamGalleryAttachment extends StatelessWidget { ); } - // Both the images are portrait. - if (!isLandscape1 && !isLandscape2) { - // ----------- - // | | | - // | | | - // | | | - // ----------- - return FlexGrid( - pattern: const [ - [1, 1], - ], - spacing: spacing, - runSpacing: runSpacing, - children: [ - itemBuilder(context, 0), - itemBuilder(context, 1), - ], - ); - } - - // Layout on the basis of isLandscape1. - // 1. True - // ----------- - // | | | - // | | | - // | | | - // ----------- - // - // 2. False + // Portrait, mixed, or unknown — strict 50/50 width split with cover crop. // ----------- - // | | | - // | | | - // | | | + // | | | + // | | | + // | | | // ----------- return FlexGrid( - pattern: [ - if (isLandscape1) [2, 1] else [1, 2], + pattern: const [ + [1, 1], ], spacing: spacing, runSpacing: runSpacing, @@ -180,7 +205,13 @@ class StreamGalleryAttachment extends StatelessWidget { ); } - Widget _buildForThree(BuildContext context, List attachments) { + Widget _buildForThree( + BuildContext context, + List attachments, + IndexedWidgetBuilder itemBuilder, { + required double spacing, + required double runSpacing, + }) { final aspectRatio1 = attachments[0].originalSize?.aspectRatio; final isLandscape1 = aspectRatio1 != null && aspectRatio1 > 1; @@ -219,7 +250,12 @@ class StreamGalleryAttachment extends StatelessWidget { } Widget _buildForFourOrMore( - BuildContext context, List attachments) { + BuildContext context, + List attachments, + IndexedWidgetBuilder itemBuilder, { + required double spacing, + required double runSpacing, + }) { final pattern = >[]; final children = []; @@ -233,6 +269,10 @@ class StreamGalleryAttachment extends StatelessWidget { children.add(itemBuilder(context, i)); } + final radius = context.streamRadius; + final textTheme = context.streamTextTheme; + final colorScheme = context.streamColorScheme; + // ----------- // | | | // | | | @@ -248,15 +288,15 @@ class StreamGalleryAttachment extends StatelessWidget { children: children, overlayBuilder: (context, remaining) { return IgnorePointer( - child: ColoredBox( - color: Colors.black38, + child: Material( + clipBehavior: .hardEdge, + color: colorScheme.backgroundOverlayDark, + shape: RoundedSuperellipseBorder(borderRadius: .all(radius.md)), child: Center( child: Text( '+$remaining', - style: const TextStyle( - fontSize: 26, - color: Colors.white, - fontWeight: FontWeight.bold, + style: textTheme.headingLg.copyWith( + color: colorScheme.textOnAccent, ), ), ), diff --git a/packages/stream_chat_flutter/lib/src/attachment/giphy_attachment.dart b/packages/stream_chat_flutter/lib/src/attachment/giphy_attachment.dart index e370cd00bf..791b6c3d7f 100644 --- a/packages/stream_chat_flutter/lib/src/attachment/giphy_attachment.dart +++ b/packages/stream_chat_flutter/lib/src/attachment/giphy_attachment.dart @@ -1,19 +1,69 @@ import 'package:flutter/material.dart'; -import 'package:stream_chat_flutter/src/misc/giphy_chip.dart'; import 'package:stream_chat_flutter/stream_chat_flutter.dart'; -/// {@template streamGiphyAttachment} -/// Shows a GIF attachment in a [StreamMessageWidget]. -/// {@endtemplate} +/// A Giphy GIF attachment component with automatic sizing. +/// +/// [StreamGiphyAttachment] displays a Giphy GIF attachment, automatically +/// sized based on the GIF's metadata dimensions. +/// +/// {@tool snippet} +/// +/// Basic usage: +/// +/// ```dart +/// StreamGiphyAttachment( +/// message: message, +/// giphy: giphyAttachment, +/// ) +/// ``` +/// {@end-tool} +/// +/// See also: +/// +/// * [StreamGiphyAttachmentProps], which configures this widget. +/// * [DefaultStreamGiphyAttachment], the default implementation. class StreamGiphyAttachment extends StatelessWidget { - /// {@macro streamGiphyAttachment} - const StreamGiphyAttachment({ + /// Creates a [StreamGiphyAttachment]. + StreamGiphyAttachment({ super.key, + required Message message, + required Attachment giphy, + GiphyInfoType type = GiphyInfoType.original, + BoxConstraints? constraints, + }) : props = .new( + message: message, + giphy: giphy, + type: type, + constraints: constraints, + ); + + /// The properties that configure this attachment. + final StreamGiphyAttachmentProps props; + + @override + Widget build(BuildContext context) { + final builder = context.chatComponentBuilder(); + if (builder != null) return builder(context, props); + return DefaultStreamGiphyAttachment(props: props); + } +} + +/// Properties for configuring a [StreamGiphyAttachment]. +/// +/// This class holds all the configuration options for a giphy attachment, +/// allowing them to be passed through the [StreamComponentFactory]. +/// +/// See also: +/// +/// * [StreamGiphyAttachment], which uses these properties. +/// * [DefaultStreamGiphyAttachment], the default implementation. +class StreamGiphyAttachmentProps { + /// Creates properties for a giphy attachment. + const StreamGiphyAttachmentProps({ required this.message, required this.giphy, this.type = GiphyInfoType.original, - this.shape, - this.constraints = const BoxConstraints(), + this.constraints, }); /// The [Message] that the giphy is attached to. @@ -24,21 +74,42 @@ class StreamGiphyAttachment extends StatelessWidget { /// The type of giphy to display. /// - /// Defaults to [GiphyInfoType.fixedHeight]. + /// Defaults to [GiphyInfoType.original]. final GiphyInfoType type; - /// The shape of the attachment. - /// - /// Defaults to [RoundedRectangleBorder] with a radius of 14. - final ShapeBorder? shape; - /// The constraints to use when displaying the giphy. - final BoxConstraints constraints; + final BoxConstraints? constraints; +} + +const _kDefaultConstraints = BoxConstraints( + minWidth: 170, + maxWidth: 256, + minHeight: 100, + maxHeight: 300, +); + +/// The default implementation of [StreamGiphyAttachment]. +/// +/// Renders the GIF thumbnail with upload progress indication. +/// +/// See also: +/// +/// * [StreamGiphyAttachment], the public API widget. +/// * [StreamGiphyAttachmentProps], which configures this widget. +class DefaultStreamGiphyAttachment extends StatelessWidget { + /// Creates a default Stream giphy attachment. + const DefaultStreamGiphyAttachment({ + super.key, + required this.props, + }); + + /// The properties that configure this attachment. + final StreamGiphyAttachmentProps props; @override Widget build(BuildContext context) { BoxFit? fit; - final giphyInfo = giphy.giphyInfo(type); + final giphyInfo = props.giphy.giphyInfo(props.type); Size? giphySize; if (giphyInfo != null) { @@ -47,7 +118,7 @@ class StreamGiphyAttachment extends StatelessWidget { // If attachment size is available, we will tighten the constraints max // size to the attachment size. - var constraints = this.constraints; + var constraints = props.constraints ?? _kDefaultConstraints; if (giphySize != null) { constraints = constraints.tightenMaxSize(giphySize); } else { @@ -56,47 +127,24 @@ class StreamGiphyAttachment extends StatelessWidget { fit = BoxFit.cover; } - final chatTheme = StreamChatTheme.of(context); - final colorTheme = chatTheme.colorTheme; - final shape = this.shape ?? - RoundedRectangleBorder( - side: BorderSide( - color: colorTheme.borders, - strokeAlign: BorderSide.strokeAlignOutside, - ), - borderRadius: BorderRadius.circular(14), - ); - - return Container( + return ConstrainedBox( constraints: constraints, - clipBehavior: Clip.hardEdge, - decoration: ShapeDecoration(shape: shape), child: AspectRatio( aspectRatio: giphySize?.aspectRatio ?? 1, child: Stack( - alignment: Alignment.center, + fit: .expand, + alignment: .center, children: [ StreamGiphyAttachmentThumbnail( - type: type, - giphy: giphy, + type: props.type, + giphy: props.giphy, fit: fit, - width: double.infinity, - height: double.infinity, ), - if (giphy.uploadState.isSuccess) - const Positioned( - bottom: 8, - left: 8, - child: GiphyChip(), - ) - else - Padding( - padding: const EdgeInsets.all(8), - child: StreamAttachmentUploadStateBuilder( - message: message, - attachment: giphy, - ), - ), + PositionedDirectional( + bottom: 8, + start: 8, + child: StreamImageSourceBadge.giphy, + ), ], ), ), diff --git a/packages/stream_chat_flutter/lib/src/attachment/handler/common.dart b/packages/stream_chat_flutter/lib/src/attachment/handler/common.dart index 8c58fe2dc3..256c8ac6da 100644 --- a/packages/stream_chat_flutter/lib/src/attachment/handler/common.dart +++ b/packages/stream_chat_flutter/lib/src/attachment/handler/common.dart @@ -78,8 +78,7 @@ Future downloadAttachmentData( queryParameters: queryParameters, cancelToken: cancelToken, // set responseType to `bytes` - options: options?.copyWith(responseType: ResponseType.bytes) ?? - Options(responseType: ResponseType.bytes), + options: options?.copyWith(responseType: ResponseType.bytes) ?? Options(responseType: ResponseType.bytes), ); final bytes = Uint8List.fromList(response.data!); diff --git a/packages/stream_chat_flutter/lib/src/attachment/handler/stream_attachment_handler_base.dart b/packages/stream_chat_flutter/lib/src/attachment/handler/stream_attachment_handler_base.dart index 7879f63644..4f4605c677 100644 --- a/packages/stream_chat_flutter/lib/src/attachment/handler/stream_attachment_handler_base.dart +++ b/packages/stream_chat_flutter/lib/src/attachment/handler/stream_attachment_handler_base.dart @@ -31,7 +31,7 @@ abstract class StreamAttachmentHandlerBase { FileType type = FileType.any, List? allowedExtensions, Function(FilePickerStatus)? onFileLoading, - bool allowCompression = true, + int compressionQuality = 0, bool withData = true, bool withReadStream = false, bool lockParentWindow = true, diff --git a/packages/stream_chat_flutter/lib/src/attachment/handler/stream_attachment_handler_html.dart b/packages/stream_chat_flutter/lib/src/attachment/handler/stream_attachment_handler_html.dart index 0c351f0326..ef3568b165 100644 --- a/packages/stream_chat_flutter/lib/src/attachment/handler/stream_attachment_handler_html.dart +++ b/packages/stream_chat_flutter/lib/src/attachment/handler/stream_attachment_handler_html.dart @@ -11,8 +11,7 @@ class StreamAttachmentHandler extends StreamAttachmentHandlerBase { /// Returns the singleton instance of [StreamAttachmentHandler]. // ignore: prefer_constructors_over_static_methods - static StreamAttachmentHandler get instance => - _instance ??= StreamAttachmentHandler._(); + static StreamAttachmentHandler get instance => _instance ??= StreamAttachmentHandler._(); late final _filePicker = FilePicker.platform; @@ -23,8 +22,6 @@ class StreamAttachmentHandler extends StreamAttachmentHandlerBase { FileType type = FileType.any, List? allowedExtensions, Function(FilePickerStatus)? onFileLoading, - @Deprecated('Has no effect, Use compressionQuality instead.') - bool allowCompression = true, int compressionQuality = 0, bool withData = true, bool withReadStream = false, diff --git a/packages/stream_chat_flutter/lib/src/attachment/handler/stream_attachment_handler_io.dart b/packages/stream_chat_flutter/lib/src/attachment/handler/stream_attachment_handler_io.dart index 02d051f72c..f6e67e64f1 100644 --- a/packages/stream_chat_flutter/lib/src/attachment/handler/stream_attachment_handler_io.dart +++ b/packages/stream_chat_flutter/lib/src/attachment/handler/stream_attachment_handler_io.dart @@ -8,6 +8,7 @@ import 'package:path_provider/path_provider.dart'; import 'package:stream_chat_flutter/src/attachment/handler/common.dart'; import 'package:stream_chat_flutter/src/attachment/handler/stream_attachment_handler_base.dart'; import 'package:stream_chat_flutter/stream_chat_flutter.dart'; +import 'package:video_player/video_player.dart'; /// StreamAttachmentHandler implementation for desktop. class StreamAttachmentHandlerDesktop extends StreamAttachmentHandler { @@ -64,8 +65,7 @@ class StreamAttachmentHandler extends StreamAttachmentHandlerBase { /// Returns the singleton instance of [StreamAttachmentHandler]. // ignore: prefer_constructors_over_static_methods - static StreamAttachmentHandler get instance => - _instance ??= StreamAttachmentHandler._(); + static StreamAttachmentHandler get instance => _instance ??= StreamAttachmentHandler._(); late final _imagePicker = ImagePicker(); late final _filePicker = FilePicker.platform; @@ -101,7 +101,26 @@ class StreamAttachmentHandler extends StreamAttachmentHandlerBase { maxDuration: maxDuration, ); - return video?.toAttachment(type: 'video'); + if (video == null) return null; + + final attachment = await video.toAttachment(type: 'video'); + + final videoController = VideoPlayerController.file(File(video.path)); + try { + await videoController.initialize(); + final duration = videoController.value.duration; + if (duration.inSeconds > 0) { + return attachment.copyWith( + extraData: {...attachment.extraData, 'duration': duration.inSeconds}, + ); + } + } catch (_) { + // If duration extraction fails, return the attachment without it. + } finally { + await videoController.dispose(); + } + + return attachment; } @override @@ -111,8 +130,6 @@ class StreamAttachmentHandler extends StreamAttachmentHandlerBase { FileType type = FileType.any, List? allowedExtensions, Function(FilePickerStatus)? onFileLoading, - @Deprecated('Has no effect, Use compressionQuality instead.') - bool allowCompression = true, int compressionQuality = 0, bool withData = true, bool withReadStream = false, @@ -142,9 +159,7 @@ class StreamAttachmentHandler extends StreamAttachmentHandlerBase { final tempDir = await getTemporaryDirectory(); final tempPath = Uri.file(tempDir.path, windows: CurrentPlatform.isWindows); - final tempFilePath = tempPath - .resolve(fileName!) - .toFilePath(windows: CurrentPlatform.isWindows); + final tempFilePath = tempPath.resolve(fileName!).toFilePath(windows: CurrentPlatform.isWindows); final attachmentFileBytes = attachmentFile.bytes; if (attachmentFileBytes == null) { diff --git a/packages/stream_chat_flutter/lib/src/attachment/image_attachment.dart b/packages/stream_chat_flutter/lib/src/attachment/image_attachment.dart index 5a3837b487..256eebd9db 100644 --- a/packages/stream_chat_flutter/lib/src/attachment/image_attachment.dart +++ b/packages/stream_chat_flutter/lib/src/attachment/image_attachment.dart @@ -1,20 +1,69 @@ import 'package:flutter/material.dart'; import 'package:stream_chat_flutter/stream_chat_flutter.dart'; -/// {@template streamImageAttachment} -/// Shows an image attachment in a [StreamMessageWidget]. -/// {@endtemplate} +/// An image attachment component with automatic sizing. +/// +/// [StreamImageAttachment] displays an image attachment, automatically +/// sized based on the image's original dimensions. +/// +/// {@tool snippet} +/// +/// Basic usage: +/// +/// ```dart +/// StreamImageAttachment( +/// message: message, +/// image: imageAttachment, +/// ) +/// ``` +/// {@end-tool} +/// +/// See also: +/// +/// * [StreamImageAttachmentProps], which configures this widget. +/// * [DefaultStreamImageAttachment], the default implementation. class StreamImageAttachment extends StatelessWidget { - /// {@macro streamImageAttachment} - const StreamImageAttachment({ + /// Creates a [StreamImageAttachment]. + StreamImageAttachment({ super.key, + required Message message, + required Attachment image, + BoxConstraints? constraints, + ImageResize? resize, + }) : props = .new( + message: message, + image: image, + constraints: constraints, + resize: resize, + ); + + /// The properties that configure this attachment. + final StreamImageAttachmentProps props; + + @override + Widget build(BuildContext context) { + final builder = context.chatComponentBuilder(); + if (builder != null) return builder(context, props); + return DefaultStreamImageAttachment(props: props); + } +} + +/// Properties for configuring a [StreamImageAttachment]. +/// +/// This class holds all the configuration options for an image attachment, +/// allowing them to be passed through the [StreamComponentFactory]. +/// +/// See also: +/// +/// * [StreamImageAttachment], which uses these properties. +/// * [DefaultStreamImageAttachment], the default implementation. +class StreamImageAttachmentProps { + /// Creates properties for an image attachment. + const StreamImageAttachmentProps({ required this.message, required this.image, - this.shape, - this.constraints = const BoxConstraints(), - this.imageThumbnailSize, - this.imageThumbnailResizeType = 'clip', - this.imageThumbnailCropType = 'center', + this.constraints, + this.resize, }); /// The [Message] that the image is attached to. @@ -23,35 +72,52 @@ class StreamImageAttachment extends StatelessWidget { /// The [Attachment] object containing the image information. final Attachment image; - /// The shape of the attachment. - /// - /// Defaults to [RoundedRectangleBorder] with a radius of 14. - final ShapeBorder? shape; - /// The constraints to use when displaying the image. - final BoxConstraints constraints; + final BoxConstraints? constraints; - /// Size of the attachment image thumbnail. - final Size? imageThumbnailSize; - - /// Resize type of the image attachment thumbnail. + /// The resize configuration for the image attachment thumbnail. /// - /// Defaults to [crop] - final String /*clip|crop|scale|fill*/ imageThumbnailResizeType; - - /// Crop type of the image attachment thumbnail. + /// When provided, its [ImageResize.width] and [ImageResize.height] are used + /// directly as the CDN resize dimensions. /// - /// Defaults to [center] - final String /*center|top|bottom|left|right*/ imageThumbnailCropType; + /// When null, the size is auto-calculated from the layout constraints + /// and defaults to [ResizeMode.clip] and [CropMode.center]. + final ImageResize? resize; +} + +const _kDefaultConstraints = BoxConstraints( + minWidth: 170, + maxWidth: 256, + minHeight: 100, + maxHeight: 300, +); + +/// The default implementation of [StreamImageAttachment]. +/// +/// Renders the image thumbnail with upload progress indication. +/// +/// See also: +/// +/// * [StreamImageAttachment], the public API widget. +/// * [StreamImageAttachmentProps], which configures this widget. +class DefaultStreamImageAttachment extends StatelessWidget { + /// Creates a default Stream image attachment. + const DefaultStreamImageAttachment({ + super.key, + required this.props, + }); + + /// The properties that configure this attachment. + final StreamImageAttachmentProps props; @override Widget build(BuildContext context) { BoxFit? fit; - final imageSize = image.originalSize; + final imageSize = props.image.originalSize; // If attachment size is available, we will tighten the constraints max // size to the attachment size. - var constraints = this.constraints; + var constraints = props.constraints ?? _kDefaultConstraints; if (imageSize != null) { constraints = constraints.tightenMaxSize(imageSize); } else { @@ -60,40 +126,23 @@ class StreamImageAttachment extends StatelessWidget { fit = BoxFit.cover; } - final chatTheme = StreamChatTheme.of(context); - final colorTheme = chatTheme.colorTheme; - final shape = this.shape ?? - RoundedRectangleBorder( - side: BorderSide( - color: colorTheme.borders, - strokeAlign: BorderSide.strokeAlignOutside, - ), - borderRadius: BorderRadius.circular(14), - ); - - return Container( + return ConstrainedBox( constraints: constraints, - clipBehavior: Clip.hardEdge, - decoration: ShapeDecoration(shape: shape), child: AspectRatio( aspectRatio: imageSize?.aspectRatio ?? 1, child: Stack( - alignment: Alignment.center, + fit: .expand, + alignment: .center, children: [ StreamImageAttachmentThumbnail( - image: image, + image: props.image, fit: fit, - width: double.infinity, - height: double.infinity, - thumbnailSize: imageThumbnailSize, - thumbnailResizeType: imageThumbnailResizeType, - thumbnailCropType: imageThumbnailCropType, + resize: props.resize, ), - Padding( - padding: const EdgeInsets.all(8), + Positioned.fill( child: StreamAttachmentUploadStateBuilder( - message: message, - attachment: image, + message: props.message, + attachment: props.image, ), ), ], diff --git a/packages/stream_chat_flutter/lib/src/attachment/link_preview_attachment.dart b/packages/stream_chat_flutter/lib/src/attachment/link_preview_attachment.dart new file mode 100644 index 0000000000..3308f540b5 --- /dev/null +++ b/packages/stream_chat_flutter/lib/src/attachment/link_preview_attachment.dart @@ -0,0 +1,172 @@ +import 'package:flutter/material.dart'; +import 'package:stream_chat_flutter/stream_chat_flutter.dart'; + +/// A link preview attachment with Open Graph metadata. +/// +/// [StreamLinkPreviewAttachment] presents a link preview, showing the +/// page's image, title, and description. +/// +/// {@tool snippet} +/// +/// Basic usage: +/// +/// ```dart +/// StreamLinkPreviewAttachment( +/// message: message, +/// urlAttachment: urlAttachment, +/// hostDisplayName: 'GitHub', +/// messageTheme: messageTheme, +/// ) +/// ``` +/// {@end-tool} +/// +/// See also: +/// +/// * [StreamLinkPreviewAttachmentProps], which configures this widget. +/// * [DefaultStreamLinkPreviewAttachment], the default implementation. +class StreamLinkPreviewAttachment extends StatelessWidget { + /// Creates a [StreamLinkPreviewAttachment]. + StreamLinkPreviewAttachment({ + super.key, + required Message message, + required Attachment urlAttachment, + BoxConstraints? constraints, + }) : props = .new( + message: message, + urlAttachment: urlAttachment, + constraints: constraints, + ); + + /// The properties that configure this attachment. + final StreamLinkPreviewAttachmentProps props; + + @override + Widget build(BuildContext context) { + final builder = context.chatComponentBuilder(); + if (builder != null) return builder(context, props); + return DefaultStreamLinkPreviewAttachment(props: props); + } +} + +/// Properties for configuring a [StreamLinkPreviewAttachment]. +/// +/// This class holds all the configuration options for a link preview +/// attachment, allowing them to be passed through the [StreamComponentFactory]. +/// +/// See also: +/// +/// * [StreamLinkPreviewAttachment], which uses these properties. +/// * [DefaultStreamLinkPreviewAttachment], the default implementation. +class StreamLinkPreviewAttachmentProps { + /// Creates properties for a link preview attachment. + const StreamLinkPreviewAttachmentProps({ + required this.message, + required this.urlAttachment, + this.constraints, + }); + + /// The [Message] that the image is attached to. + final Message message; + + /// Attachment to be displayed. + final Attachment urlAttachment; + + /// The constraints to use when displaying the link preview. + final BoxConstraints? constraints; +} + +const _kDefaultConstraints = BoxConstraints(maxWidth: 256); + +/// The default implementation of [StreamLinkPreviewAttachment]. +/// +/// Renders the Open Graph preview with host name, title, and description. +/// +/// See also: +/// +/// * [StreamLinkPreviewAttachment], the public API widget. +/// * [StreamLinkPreviewAttachmentProps], which configures this widget. +class DefaultStreamLinkPreviewAttachment extends StatelessWidget { + /// Creates a default Stream link preview attachment. + const DefaultStreamLinkPreviewAttachment({ + super.key, + required this.props, + }); + + /// The properties that configure this attachment. + final StreamLinkPreviewAttachmentProps props; + + @override + Widget build(BuildContext context) { + final urlAttachment = props.urlAttachment; + + final icons = context.streamIcons; + final spacing = context.streamSpacing; + final textTheme = context.streamTextTheme; + final colorScheme = context.streamColorScheme; + + final constraints = props.constraints ?? _kDefaultConstraints; + + return ConstrainedBox( + constraints: constraints, + child: Column( + mainAxisSize: .min, + children: [ + AspectRatio( + // Default aspect ratio for Open Graph images. + // https://www.kapwing.com/resources/what-is-an-og-image-make-and-format-og-images-for-your-blog-or-webpage + aspectRatio: 1.91 / 1, + child: StreamImageAttachmentThumbnail( + image: urlAttachment, + fit: BoxFit.cover, + ), + ), + Padding( + padding: .all(spacing.sm), + child: Column( + spacing: spacing.xxs, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + if (urlAttachment.title case final title?) + Text( + title.trim(), + maxLines: 1, + overflow: .ellipsis, + style: textTheme.captionEmphasis.copyWith( + color: colorScheme.textPrimary, + ), + ), + if (urlAttachment.text case final text?) + Text( + text, + maxLines: 3, + overflow: .ellipsis, + style: textTheme.metadataDefault.copyWith( + color: colorScheme.textPrimary, + ), + ), + if (urlAttachment.titleLink case final titleLink?) + Row( + mainAxisSize: .min, + spacing: spacing.xxs, + children: [ + Icon(icons.link, size: 12), + Expanded( + child: Text( + titleLink, + maxLines: 1, + overflow: .ellipsis, + style: textTheme.metadataDefault.copyWith( + color: colorScheme.textPrimary, + ), + ), + ), + ], + ), + ], + ), + ), + ], + ), + ); + } +} diff --git a/packages/stream_chat_flutter/lib/src/message_widget/poll_message.dart b/packages/stream_chat_flutter/lib/src/attachment/poll_attachment.dart similarity index 51% rename from packages/stream_chat_flutter/lib/src/message_widget/poll_message.dart rename to packages/stream_chat_flutter/lib/src/attachment/poll_attachment.dart index aec0646c78..62edabb506 100644 --- a/packages/stream_chat_flutter/lib/src/message_widget/poll_message.dart +++ b/packages/stream_chat_flutter/lib/src/attachment/poll_attachment.dart @@ -1,50 +1,117 @@ import 'package:flutter/material.dart'; +import 'package:stream_chat_flutter/src/components/stream_chat_component_builders.dart'; import 'package:stream_chat_flutter/src/misc/empty_widget.dart'; import 'package:stream_chat_flutter/src/poll/interactor/poll_add_comment_dialog.dart'; import 'package:stream_chat_flutter/src/poll/interactor/poll_end_vote_dialog.dart'; import 'package:stream_chat_flutter/src/poll/interactor/poll_suggest_option_dialog.dart'; import 'package:stream_chat_flutter/src/poll/interactor/stream_poll_interactor.dart'; -import 'package:stream_chat_flutter/src/poll/stream_poll_comments_dialog.dart'; -import 'package:stream_chat_flutter/src/poll/stream_poll_options_dialog.dart'; -import 'package:stream_chat_flutter/src/poll/stream_poll_results_dialog.dart'; +import 'package:stream_chat_flutter/src/poll/stream_poll_comments_sheet.dart'; +import 'package:stream_chat_flutter/src/poll/stream_poll_options_sheet.dart'; +import 'package:stream_chat_flutter/src/poll/stream_poll_results_sheet.dart'; import 'package:stream_chat_flutter/src/stream_chat.dart'; import 'package:stream_chat_flutter_core/stream_chat_flutter_core.dart'; +import 'package:stream_core_flutter/stream_core_flutter.dart'; -const _maxVisibleOptionCount = 10; +/// An interactive poll attachment with voting and results. +/// +/// [StreamPollAttachment] presents an interactive poll, supporting +/// voting, comments, and results viewing. +/// +/// {@tool snippet} +/// +/// Basic usage: +/// +/// ```dart +/// StreamPollAttachment( +/// message: message, +/// ) +/// ``` +/// {@end-tool} +/// +/// See also: +/// +/// * [StreamPollAttachmentProps], which configures this widget. +/// * [DefaultStreamPollAttachment], the default implementation. +class StreamPollAttachment extends StatelessWidget { + /// Creates a [StreamPollAttachment]. + StreamPollAttachment({ + super.key, + required Message message, + BoxConstraints? constraints, + }) : props = .new( + message: message, + constraints: constraints, + ); -const _kDefaultPollMessageConstraints = BoxConstraints( - maxWidth: 270, -); + /// The properties that configure this attachment. + final StreamPollAttachmentProps props; -/// {@template pollMessage} -/// A widget that displays a poll message. + @override + Widget build(BuildContext context) { + final builder = context.chatComponentBuilder(); + if (builder != null) return builder(context, props); + return DefaultStreamPollAttachment(props: props); + } +} + +/// Properties for configuring a [StreamPollAttachment]. /// -/// Used in [MessageCard] to display a poll message. -/// {@endtemplate} -class PollMessage extends StatefulWidget { - /// {@macro pollMessage} - const PollMessage({ - super.key, +/// This class holds all the configuration options for a poll attachment, +/// allowing them to be passed through the [StreamComponentFactory]. +/// +/// See also: +/// +/// * [StreamPollAttachment], which uses these properties. +/// * [DefaultStreamPollAttachment], the default implementation. +class StreamPollAttachmentProps { + /// Creates properties for a poll attachment. + const StreamPollAttachmentProps({ required this.message, + this.constraints, }); - /// The message with the poll to display. + /// The message containing the poll. final Message message; + /// The constraints to use when displaying the poll. + final BoxConstraints? constraints; +} + +const _maxVisibleOptionCount = 5; +const _kDefaultConstraints = BoxConstraints(maxWidth: 270); + +/// The default implementation of [StreamPollAttachment]. +/// +/// Renders an interactive poll with voting and result controls. +/// +/// See also: +/// +/// * [StreamPollAttachment], the public API widget. +/// * [StreamPollAttachmentProps], which configures this widget. +class DefaultStreamPollAttachment extends StatefulWidget { + /// Creates a default Stream poll attachment. + const DefaultStreamPollAttachment({ + super.key, + required this.props, + }); + + /// The properties that configure this attachment. + final StreamPollAttachmentProps props; + @override - State createState() => _PollMessageState(); + State createState() => _DefaultStreamPollAttachmentState(); } -class _PollMessageState extends State { - late final _messageNotifier = ValueNotifier(widget.message); +class _DefaultStreamPollAttachmentState extends State { + late final _messageNotifier = ValueNotifier(widget.props.message); @override - void didUpdateWidget(covariant PollMessage oldWidget) { + void didUpdateWidget(covariant DefaultStreamPollAttachment oldWidget) { super.didUpdateWidget(oldWidget); - if (oldWidget.message != widget.message) { - // If the message changes, schedule an update for the next frame + if (oldWidget.props.message != widget.props.message) { + // If the message changes, schedule an update for the next frame. WidgetsBinding.instance.addPostFrameCallback((_) { - _messageNotifier.value = widget.message; + _messageNotifier.value = widget.props.message; }); } } @@ -96,8 +163,10 @@ class _PollMessageState extends State { channel.createPollOption(poll, PollOption(text: optionText)); } + final constraints = widget.props.constraints ?? _kDefaultConstraints; + return ConstrainedBox( - constraints: _kDefaultPollMessageConstraints, + constraints: constraints, child: StreamPollInteractor( poll: poll, currentUser: currentUser, @@ -109,15 +178,15 @@ class _PollMessageState extends State { onSuggestOption: onSuggestOption, // We need to pass the notifier here instead of the poll because the // options dialog will have no way to update the poll itself. - onViewComments: () => showStreamPollCommentsDialog( + onViewComments: () => showStreamPollCommentsSheet( context: context, messageNotifier: _messageNotifier, ), - onSeeMoreOptions: () => showStreamPollOptionsDialog( + onSeeMoreOptions: () => showStreamPollOptionsSheet( context: context, messageNotifier: _messageNotifier, ), - onViewResults: () => showStreamPollResultsDialog( + onViewResults: () => showStreamPollResultsSheet( context: context, messageNotifier: _messageNotifier, ), diff --git a/packages/stream_chat_flutter/lib/src/attachment/stream_attachment_package.dart b/packages/stream_chat_flutter/lib/src/attachment/stream_attachment_package.dart deleted file mode 100644 index e1e0df0e83..0000000000 --- a/packages/stream_chat_flutter/lib/src/attachment/stream_attachment_package.dart +++ /dev/null @@ -1,18 +0,0 @@ -import 'package:stream_chat_flutter/stream_chat_flutter.dart'; - -/// The [StreamAttachmentPackage] class is basically meant to wrap -/// individual attachments with their corresponding message -class StreamAttachmentPackage { - /// Default constructor to prepare an [StreamAttachmentPackage] object - StreamAttachmentPackage({ - required this.attachment, - required this.message, - }); - - /// This is the individual attachment - final Attachment attachment; - - /// This is the message that the attachment belongs to - /// The message object may have attachment(s) other than the one packaged - final Message message; -} diff --git a/packages/stream_chat_flutter/lib/src/attachment/thumbnail/file_attachment_thumbnail.dart b/packages/stream_chat_flutter/lib/src/attachment/thumbnail/file_attachment_thumbnail.dart deleted file mode 100644 index b6f14b291c..0000000000 --- a/packages/stream_chat_flutter/lib/src/attachment/thumbnail/file_attachment_thumbnail.dart +++ /dev/null @@ -1,72 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:stream_chat_flutter/src/attachment/thumbnail/image_attachment_thumbnail.dart'; -import 'package:stream_chat_flutter/src/attachment/thumbnail/thumbnail_error.dart'; -import 'package:stream_chat_flutter/src/attachment/thumbnail/video_attachment_thumbnail.dart'; -import 'package:stream_chat_flutter/src/utils/helpers.dart'; -import 'package:stream_chat_flutter_core/stream_chat_flutter_core.dart'; - -/// {@template streamFileAttachmentThumbnail} -/// Widget for building file attachment thumbnail. -/// -/// This widget first tries to build an image thumbnail for the file attachment. -/// If the image thumbnail fails to load, it tries to build a video thumbnail. -/// If the video thumbnail fails to load, it returns a generic file type icon. -/// {@endtemplate} -class StreamFileAttachmentThumbnail extends StatelessWidget { - /// {@macro streamFileAttachmentThumbnail} - const StreamFileAttachmentThumbnail({ - super.key, - required this.file, - this.width, - this.height, - this.fit, - this.errorBuilder = _defaultErrorBuilder, - }); - - /// The file attachment to build the thumbnail for. - final Attachment file; - - /// The width of the thumbnail. - final double? width; - - /// The height of the thumbnail. - final double? height; - - /// How to inscribe the thumbnail into the space allocated during layout. - final BoxFit? fit; - - /// Builder used when the thumbnail fails to load. - final ThumbnailErrorBuilder errorBuilder; - - // Default error builder for file attachment thumbnail. - static Widget _defaultErrorBuilder( - BuildContext context, - Object error, - StackTrace? stackTrace, - ) { - // Return a generic file type icon. - return getFileTypeImage(); - } - - @override - Widget build(BuildContext context) { - final mediaType = file.title?.mediaType; - - return switch (mediaType?.type) { - AttachmentType.image => StreamImageAttachmentThumbnail( - image: file, - width: width, - height: height, - fit: fit, - ), - AttachmentType.video => StreamVideoAttachmentThumbnail( - video: file, - width: width, - height: height, - fit: fit, - ), - // Return a generic file type icon. - _ => getFileTypeImage(mediaType?.mimeType), - }; - } -} diff --git a/packages/stream_chat_flutter/lib/src/attachment/thumbnail/giphy_attachment_thumbnail.dart b/packages/stream_chat_flutter/lib/src/attachment/thumbnail/giphy_attachment_thumbnail.dart index 952b0f04e4..ffe5b7d576 100644 --- a/packages/stream_chat_flutter/lib/src/attachment/thumbnail/giphy_attachment_thumbnail.dart +++ b/packages/stream_chat_flutter/lib/src/attachment/thumbnail/giphy_attachment_thumbnail.dart @@ -17,7 +17,7 @@ class StreamGiphyAttachmentThumbnail extends StatelessWidget { this.width, this.height, this.fit, - this.errorBuilder = _defaultErrorBuilder, + this.errorBuilder, }); /// The giphy attachment to build the thumbnail for. @@ -36,22 +36,9 @@ class StreamGiphyAttachmentThumbnail extends StatelessWidget { final BoxFit? fit; /// Builder used when the thumbnail fails to load. - final ThumbnailErrorBuilder errorBuilder; - - // Default error builder for image attachment thumbnail. - static Widget _defaultErrorBuilder( - BuildContext context, - Object error, - StackTrace? stackTrace, - ) { - return ThumbnailError( - error: error, - stackTrace: stackTrace, - height: double.infinity, - width: double.infinity, - fit: BoxFit.cover, - ); - } + /// + /// If null, default error handling is used. + final ThumbnailErrorBuilder? errorBuilder; @override Widget build(BuildContext context) { diff --git a/packages/stream_chat_flutter/lib/src/attachment/thumbnail/image_attachment_thumbnail.dart b/packages/stream_chat_flutter/lib/src/attachment/thumbnail/image_attachment_thumbnail.dart index 55474beae4..e9a3c0c909 100644 --- a/packages/stream_chat_flutter/lib/src/attachment/thumbnail/image_attachment_thumbnail.dart +++ b/packages/stream_chat_flutter/lib/src/attachment/thumbnail/image_attachment_thumbnail.dart @@ -1,13 +1,8 @@ import 'dart:io' show File; -import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/material.dart'; -import 'package:shimmer/shimmer.dart'; -import 'package:stream_chat_flutter/src/attachment/thumbnail/thumbnail_error.dart'; import 'package:stream_chat_flutter/src/attachment/thumbnail/thumbnail_size_calculator.dart'; -import 'package:stream_chat_flutter/src/theme/stream_chat_theme.dart'; -import 'package:stream_chat_flutter/src/utils/utils.dart'; -import 'package:stream_chat_flutter_core/stream_chat_flutter_core.dart'; +import 'package:stream_chat_flutter/stream_chat_flutter.dart'; /// {@template imageAttachmentThumbnail} /// Widget for building image attachment thumbnail. @@ -22,10 +17,8 @@ class StreamImageAttachmentThumbnail extends StatelessWidget { this.width, this.height, this.fit, - this.thumbnailSize, - this.thumbnailResizeType = 'clip', - this.thumbnailCropType = 'center', - this.errorBuilder = _defaultErrorBuilder, + this.resize, + this.errorBuilder, }); /// The image attachment to show. @@ -40,70 +33,53 @@ class StreamImageAttachmentThumbnail extends StatelessWidget { /// Fit of the attachment image thumbnail. final BoxFit? fit; - /// Size of the attachment image thumbnail. - final Size? thumbnailSize; - - /// Resize type of the image attachment thumbnail. + /// The resize configuration for the image attachment thumbnail. /// - /// Defaults to [crop] - final String /*clip|crop|scale|fill*/ thumbnailResizeType; - - /// Crop type of the image attachment thumbnail. + /// When provided, its [ImageResize.width] and [ImageResize.height] are used + /// directly as the CDN resize dimensions. /// - /// Defaults to [center] - final String /*center|top|bottom|left|right*/ thumbnailCropType; + /// When null, the size is auto-calculated from the layout constraints + /// and defaults to [ResizeMode.clip] and [CropMode.center]. + final ImageResize? resize; /// Builder used when the thumbnail fails to load. - final ThumbnailErrorBuilder errorBuilder; - - // Default error builder for image attachment thumbnail. - static Widget _defaultErrorBuilder( - BuildContext context, - Object error, - StackTrace? stackTrace, - ) { - return ThumbnailError( - error: error, - stackTrace: stackTrace, - height: double.infinity, - width: double.infinity, - fit: BoxFit.cover, - ); - } + /// + /// If null, the default error handling of the underlying image widget is + /// used. For remote images, [StreamNetworkImage] provides a tap-to-retry + /// error placeholder. For local images, a static + /// [StreamImageErrorPlaceholder] is shown. + final ThumbnailErrorBuilder? errorBuilder; @override Widget build(BuildContext context) { return LayoutBuilder( builder: (context, constraints) { - // Calculate optimal thumbnail size once for all paths - final effectiveThumbnailSize = switch (thumbnailSize) { - final thumbnailSize? => thumbnailSize, - _ => ThumbnailSizeCalculator.calculate( - targetSize: constraints.biggest, - originalSize: image.originalSize, - pixelRatio: MediaQuery.devicePixelRatioOf(context), - ), - }; - - final cacheWidth = effectiveThumbnailSize?.width.round(); - final cacheHeight = effectiveThumbnailSize?.height.round(); + var effectiveResize = resize; + if (effectiveResize == null) { + final size = ThumbnailSizeCalculator.calculate( + targetSize: constraints.biggest, + originalSize: image.originalSize, + pixelRatio: MediaQuery.devicePixelRatioOf(context), + fit: fit, + ); + + if (size != null) effectiveResize = .new(width: size.width, height: size.height); + } + + final cacheWidth = effectiveResize?.width.round(); + final cacheHeight = effectiveResize?.height.round(); // If the remote image URL is available, we can directly show it using // the _RemoteImageAttachment widget. final imageUrl = image.thumbUrl ?? image.imageUrl ?? image.assetUrl; if (imageUrl case final imageUrl?) { - var resizedImageUrl = imageUrl; - if (effectiveThumbnailSize case final thumbnailSize?) { - resizedImageUrl = imageUrl.getResizedImageUrl( - crop: thumbnailCropType, - resize: thumbnailResizeType, - width: thumbnailSize.width, - height: thumbnailSize.height, - ); - } + final imageCDN = StreamChatConfiguration.maybeOf(context)?.imageCDN ?? const StreamImageCDN(); + final resolvedUrl = imageCDN.resolveUrl(imageUrl, resize: effectiveResize); + final resolvedCacheKey = imageCDN.cacheKey(resolvedUrl); return _RemoteImageAttachment( - url: resizedImageUrl, + url: resolvedUrl, + cacheKey: resolvedCacheKey, width: width, height: height, fit: fit, @@ -126,11 +102,11 @@ class StreamImageAttachmentThumbnail extends StatelessWidget { ); } - return errorBuilder( - context, - 'Image attachment is not valid', - StackTrace.current, - ); + if (errorBuilder case final builder?) { + return builder(context, 'Image attachment is not valid', null); + } + + return StreamImageErrorPlaceholder(width: width, height: height); }, ); } @@ -139,7 +115,7 @@ class StreamImageAttachmentThumbnail extends StatelessWidget { class _LocalImageAttachment extends StatelessWidget { const _LocalImageAttachment({ required this.file, - required this.errorBuilder, + this.errorBuilder, this.width, this.height, this.cacheWidth, @@ -153,7 +129,17 @@ class _LocalImageAttachment extends StatelessWidget { final int? cacheWidth; final int? cacheHeight; final BoxFit? fit; - final ThumbnailErrorBuilder errorBuilder; + final ThumbnailErrorBuilder? errorBuilder; + + // Default error builder for local attachment thumbnail. + Widget _defaultErrorBuilder( + BuildContext context, + Object error, + StackTrace? stackTrace, + ) { + if (errorBuilder case final builder?) return builder(context, error, null); + return StreamImageErrorPlaceholder(width: width, height: height); + } @override Widget build(BuildContext context) { @@ -166,7 +152,7 @@ class _LocalImageAttachment extends StatelessWidget { cacheWidth: cacheWidth, cacheHeight: cacheHeight, fit: fit, - errorBuilder: errorBuilder, + errorBuilder: _defaultErrorBuilder, ); } @@ -179,12 +165,12 @@ class _LocalImageAttachment extends StatelessWidget { cacheWidth: cacheWidth, cacheHeight: cacheHeight, fit: fit, - errorBuilder: errorBuilder, + errorBuilder: _defaultErrorBuilder, ); } // Return error widget if no image is found. - return errorBuilder( + return _defaultErrorBuilder( context, 'Image attachment is not valid', StackTrace.current, @@ -195,54 +181,35 @@ class _LocalImageAttachment extends StatelessWidget { class _RemoteImageAttachment extends StatelessWidget { const _RemoteImageAttachment({ required this.url, - required this.errorBuilder, + this.cacheKey, this.width, this.height, this.cacheWidth, this.cacheHeight, this.fit, + this.errorBuilder, }); final String url; + final String? cacheKey; final double? width; final double? height; final int? cacheWidth; final int? cacheHeight; final BoxFit? fit; - final ThumbnailErrorBuilder errorBuilder; + final ThumbnailErrorBuilder? errorBuilder; @override Widget build(BuildContext context) { - return CachedNetworkImage( - imageUrl: url, + return StreamNetworkImage( + url, + cacheKey: cacheKey, width: width, height: height, - memCacheWidth: cacheWidth, - memCacheHeight: cacheHeight, + cacheWidth: cacheWidth, + cacheHeight: cacheHeight, fit: fit, - placeholder: (context, __) { - final image = Image.asset( - 'lib/assets/images/placeholder.png', - width: width, - height: height, - fit: BoxFit.cover, - package: 'stream_chat_flutter', - ); - - final colorTheme = StreamChatTheme.of(context).colorTheme; - return Shimmer.fromColors( - baseColor: colorTheme.disabled, - highlightColor: colorTheme.inputBg, - child: image, - ); - }, - errorWidget: (context, url, error) { - return errorBuilder( - context, - error, - StackTrace.current, - ); - }, + errorBuilder: errorBuilder, ); } } diff --git a/packages/stream_chat_flutter/lib/src/attachment/thumbnail/media_attachment_thumbnail.dart b/packages/stream_chat_flutter/lib/src/attachment/thumbnail/media_attachment_thumbnail.dart index ab8c60c571..f3962ac38e 100644 --- a/packages/stream_chat_flutter/lib/src/attachment/thumbnail/media_attachment_thumbnail.dart +++ b/packages/stream_chat_flutter/lib/src/attachment/thumbnail/media_attachment_thumbnail.dart @@ -3,7 +3,9 @@ import 'package:stream_chat_flutter/src/attachment/thumbnail/giphy_attachment_th import 'package:stream_chat_flutter/src/attachment/thumbnail/image_attachment_thumbnail.dart'; import 'package:stream_chat_flutter/src/attachment/thumbnail/thumbnail_error.dart'; import 'package:stream_chat_flutter/src/attachment/thumbnail/video_attachment_thumbnail.dart'; +import 'package:stream_chat_flutter/src/utils/stream_image_cdn.dart'; import 'package:stream_chat_flutter_core/stream_chat_flutter_core.dart'; +import 'package:stream_core_flutter/stream_core_flutter.dart'; /// {@template mediaAttachmentThumbnail} /// Widget for building media attachment thumbnail. @@ -24,11 +26,9 @@ class StreamMediaAttachmentThumbnail extends StatelessWidget { this.width, this.height, this.fit, - this.thumbnailSize, - this.thumbnailResizeType = 'clip', - this.thumbnailCropType = 'center', + this.resize, this.gifInfoType = GiphyInfoType.original, - this.errorBuilder = _defaultErrorBuilder, + this.errorBuilder, }); /// The giphy attachment to build the thumbnail for. @@ -44,45 +44,34 @@ class StreamMediaAttachmentThumbnail extends StatelessWidget { final BoxFit? fit; /// Builder used when the thumbnail fails to load. - final ThumbnailErrorBuilder errorBuilder; - - /// Size of the attachment image thumbnail. /// - /// Ignored if the [Attachment.type] is not [AttachmentType.image]. - final Size? thumbnailSize; + /// If null, default error handling is used. + final ThumbnailErrorBuilder? errorBuilder; - /// Resize type of the image attachment thumbnail. - /// - /// Defaults to [crop] + /// The resize configuration for the image attachment thumbnail. /// - /// Ignored if the [Attachment.type] is not [AttachmentType.image]. - final String /*clip|crop|scale|fill*/ thumbnailResizeType; - - /// Crop type of the image attachment thumbnail. + /// When provided, its [ImageResize.width] and [ImageResize.height] are used + /// directly as the CDN resize dimensions. /// - /// Defaults to [center] + /// When null, the size is auto-calculated from the layout constraints + /// and defaults to [ResizeMode.clip] and [CropMode.center]. /// /// Ignored if the [Attachment.type] is not [AttachmentType.image]. - final String /*center|top|bottom|left|right*/ thumbnailCropType; + final ImageResize? resize; /// The type of giphy thumbnail to build. /// /// Ignored if the [Attachment.type] is not [AttachmentType.giphy]. final GiphyInfoType gifInfoType; - // Default error builder for image attachment thumbnail. - static Widget _defaultErrorBuilder( + // Default error builder for media attachment thumbnail. + Widget _defaultErrorBuilder( BuildContext context, Object error, StackTrace? stackTrace, ) { - return ThumbnailError( - error: error, - stackTrace: stackTrace, - height: double.infinity, - width: double.infinity, - fit: BoxFit.cover, - ); + if (errorBuilder case final builder?) return builder(context, error, null); + return StreamImageErrorPlaceholder(width: width, height: height); } @override @@ -94,9 +83,7 @@ class StreamMediaAttachmentThumbnail extends StatelessWidget { width: width, height: height, fit: fit, - thumbnailSize: thumbnailSize, - thumbnailResizeType: thumbnailResizeType, - thumbnailCropType: thumbnailCropType, + resize: resize, errorBuilder: errorBuilder, ); } @@ -122,7 +109,7 @@ class StreamMediaAttachmentThumbnail extends StatelessWidget { ); } - return errorBuilder( + return _defaultErrorBuilder( context, 'Unsupported attachment type: $type', StackTrace.current, diff --git a/packages/stream_chat_flutter/lib/src/attachment/thumbnail/thumbnail_error.dart b/packages/stream_chat_flutter/lib/src/attachment/thumbnail/thumbnail_error.dart index d5f5a4ae56..f1db5b5433 100644 --- a/packages/stream_chat_flutter/lib/src/attachment/thumbnail/thumbnail_error.dart +++ b/packages/stream_chat_flutter/lib/src/attachment/thumbnail/thumbnail_error.dart @@ -1,54 +1,8 @@ import 'package:flutter/material.dart'; -/// {@template thumbnailErrorBuilder} -/// Signature for the builder callback used by [ThumbnailError.builder]. +/// Signature for a function that builds an error widget when an attachment +/// thumbnail fails to load. /// -/// The parameters represent the [BuildContext], [error] and [stackTrace] of the -/// error that triggered this callback. -/// {@endtemplate} -typedef ThumbnailErrorBuilder = Widget Function( - BuildContext context, - Object error, - StackTrace? stackTrace, -); - -/// {@template thumbnailError} -/// A widget that shows an error state when a thumbnail fails to load. -/// {@endtemplate} -class ThumbnailError extends StatelessWidget { - /// {@macro thumbnailError} - const ThumbnailError({ - super.key, - required this.error, - this.stackTrace, - this.width, - this.height, - this.fit, - }); - - /// The width of the thumbnail. - final double? width; - - /// The height of the thumbnail. - final double? height; - - /// How to inscribe the thumbnail into the space allocated during layout. - final BoxFit? fit; - - /// The error that triggered this error widget. - final Object error; - - /// The stack trace of the error that triggered this error widget. - final StackTrace? stackTrace; - - @override - Widget build(BuildContext context) { - return Image.asset( - 'lib/assets/images/placeholder.png', - width: width, - height: height, - fit: fit, - package: 'stream_chat_flutter', - ); - } -} +/// When [retry] is non-null, it can be invoked to retry loading the image +/// (e.g. to show a tap-to-reload button). +typedef ThumbnailErrorBuilder = Widget Function(BuildContext context, Object error, VoidCallback? retry); diff --git a/packages/stream_chat_flutter/lib/src/attachment/thumbnail/thumbnail_size_calculator.dart b/packages/stream_chat_flutter/lib/src/attachment/thumbnail/thumbnail_size_calculator.dart index 94756f2e67..e2f8a3bebf 100644 --- a/packages/stream_chat_flutter/lib/src/attachment/thumbnail/thumbnail_size_calculator.dart +++ b/packages/stream_chat_flutter/lib/src/attachment/thumbnail/thumbnail_size_calculator.dart @@ -1,4 +1,4 @@ -import 'dart:ui'; +import 'package:flutter/rendering.dart'; /// Utility class for calculating optimal thumbnail sizes for image /// attachments. @@ -18,9 +18,32 @@ class ThumbnailSizeCalculator { /// The calculation: /// 1. Handles infinite constraints by calculating from the finite /// dimension - /// 2. Maintains aspect ratio to prevent image distortion + /// 2. Applies [fit] semantics to determine sizing behavior /// 3. Applies [pixelRatio] for device-appropriate resolution /// + /// The [fit] parameter controls how the image is sized within + /// [targetSize]. When null (the default), [BoxFit.scaleDown] is used — + /// the same fallback Flutter's painter (`paintImage`) applies when an + /// `Image` widget is rendered without an explicit fit. + /// + /// - [BoxFit.contain]: Scales to fit within the box while preserving + /// aspect ratio. The result will be no larger than the target in + /// either dimension. + /// - [BoxFit.cover]: Scales to cover the entire box while preserving + /// aspect ratio. The result will be at least as large as the target + /// in both dimensions (one dimension may exceed it). + /// - [BoxFit.fill]: Stretches to fill the box exactly, ignoring + /// aspect ratio. + /// - [BoxFit.fitWidth]: Scales so the width matches the target width; + /// height is derived from the original aspect ratio. + /// - [BoxFit.fitHeight]: Scales so the height matches the target + /// height; width is derived from the original aspect ratio. + /// - [BoxFit.none]: Uses the original size without scaling, clamped + /// to the target dimensions. + /// - [BoxFit.scaleDown] (default when [fit] is null): Same as + /// [BoxFit.contain] if the original is larger than the target; + /// otherwise [BoxFit.none]. + /// /// Example: /// ```dart /// final size = ThumbnailSizeCalculator.calculate( @@ -35,11 +58,12 @@ class ThumbnailSizeCalculator { Size? originalSize, required Size targetSize, required double pixelRatio, + BoxFit? fit, }) { - final originalAspectRatio = originalSize?.aspectRatio; - // If original aspect ratio is not available, skip optimization - // We need the aspect ratio to avoid incorrect cropping - if (originalAspectRatio == null) return null; + // If original size is not available, skip optimization. + // We need the aspect ratio to avoid incorrect cropping. + if (originalSize == null) return null; + final originalAspectRatio = originalSize.aspectRatio; // Invalid aspect ratio indicates invalid original size if (originalAspectRatio.isInfinite || originalAspectRatio <= 0) { @@ -57,23 +81,125 @@ class ThumbnailSizeCalculator { if (thumbnailWidth.isInfinite) { // Width is infinite, calculate from height thumbnailWidth = thumbnailHeight * originalAspectRatio; - } - if (thumbnailHeight.isInfinite) { + } else if (thumbnailHeight.isInfinite) { // Height is infinite, calculate from width thumbnailHeight = thumbnailWidth / originalAspectRatio; } - // Calculate size that maintains aspect ratio within constraints - final targetAspectRatio = thumbnailWidth / thumbnailHeight; - if (originalAspectRatio > targetAspectRatio) { - // Image is wider than container - fit to width - thumbnailHeight = thumbnailWidth / originalAspectRatio; + final resolved = _applyFit( + // Match Flutter's paintImage default when fit isn't specified. + fit: fit ?? BoxFit.scaleDown, + originalSize: originalSize, + originalAspectRatio: originalAspectRatio, + boxWidth: thumbnailWidth, + boxHeight: thumbnailHeight, + ); + + // Apply pixel ratio to get physical pixel dimensions + return resolved * pixelRatio; + } + + static Size _applyFit({ + required BoxFit fit, + required Size originalSize, + required double originalAspectRatio, + required double boxWidth, + required double boxHeight, + }) { + return switch (fit) { + // Stretch to fill exactly; aspect ratio not preserved. + .fill => Size(boxWidth, boxHeight), + + // Match width, derive height from aspect ratio. + .fitWidth => Size(boxWidth, boxWidth / originalAspectRatio), + + // Match height, derive width from aspect ratio. + .fitHeight => Size(boxHeight * originalAspectRatio, boxHeight), + + // Use original size, clamped so we never decode larger than the + // target box (decoding bigger than the box wastes memory). + .none => Size( + originalSize.width.clamp(0.0, boxWidth), + originalSize.height.clamp(0.0, boxHeight), + ), + + // Behaves like contain when the original would overflow, + // otherwise returns the original size. + .scaleDown => _scaleDown( + originalSize: originalSize, + originalAspectRatio: originalAspectRatio, + boxWidth: boxWidth, + boxHeight: boxHeight, + ), + + // Largest size that covers the box while preserving aspect ratio. + .cover => _cover( + originalAspectRatio: originalAspectRatio, + boxWidth: boxWidth, + boxHeight: boxHeight, + ), + + // Default: largest size that fits inside the box while preserving aspect ratio. + .contain => _contain( + originalAspectRatio: originalAspectRatio, + boxWidth: boxWidth, + boxHeight: boxHeight, + ), + }; + } + + // Largest size that fits inside the box while preserving aspect ratio. + static Size _contain({ + required double originalAspectRatio, + required double boxWidth, + required double boxHeight, + }) { + final boxAspectRatio = boxWidth / boxHeight; + if (originalAspectRatio > boxAspectRatio) { + // Image is wider than the box — fit to width. + return Size(boxWidth, boxWidth / originalAspectRatio); } else { - // Image is taller than container - fit to height - thumbnailWidth = thumbnailHeight * originalAspectRatio; + // Image is taller than the box — fit to height. + return Size(boxHeight * originalAspectRatio, boxHeight); } + } - // Apply pixel ratio to get physical pixel dimensions - return Size(thumbnailWidth * pixelRatio, thumbnailHeight * pixelRatio); + // Original size if it already fits inside the box, otherwise the + // largest size that fits inside the box while preserving aspect ratio. + static Size _scaleDown({ + required Size originalSize, + required double originalAspectRatio, + required double boxWidth, + required double boxHeight, + }) { + final containSize = _contain( + originalAspectRatio: originalAspectRatio, + boxWidth: boxWidth, + boxHeight: boxHeight, + ); + if (originalSize.width <= containSize.width && originalSize.height <= containSize.height) { + return originalSize; + } + return containSize; + } + + // Smallest size that covers the box while preserving aspect ratio. + // + // One dimension will match the box; the other will be larger. + // We cap the decoded size at the cover dimensions — anything larger + // would be cropped by the painter and just waste memory. + static Size _cover({ + required double originalAspectRatio, + required double boxWidth, + required double boxHeight, + }) { + final boxAspectRatio = boxWidth / boxHeight; + if (originalAspectRatio < boxAspectRatio) { + // Image is taller than the box — match width, overflow height. + return Size(boxWidth, boxWidth / originalAspectRatio); + } else { + // Image is wider than the box — match height, overflow width. + return Size(boxHeight * originalAspectRatio, boxHeight); + } } } diff --git a/packages/stream_chat_flutter/lib/src/attachment/thumbnail/video_attachment_thumbnail.dart b/packages/stream_chat_flutter/lib/src/attachment/thumbnail/video_attachment_thumbnail.dart index 946023f6ec..d56479976c 100644 --- a/packages/stream_chat_flutter/lib/src/attachment/thumbnail/video_attachment_thumbnail.dart +++ b/packages/stream_chat_flutter/lib/src/attachment/thumbnail/video_attachment_thumbnail.dart @@ -1,10 +1,9 @@ import 'package:flutter/material.dart'; -import 'package:shimmer/shimmer.dart'; import 'package:stream_chat_flutter/src/attachment/thumbnail/image_attachment_thumbnail.dart'; import 'package:stream_chat_flutter/src/attachment/thumbnail/thumbnail_error.dart'; -import 'package:stream_chat_flutter/src/theme/stream_chat_theme.dart'; import 'package:stream_chat_flutter/src/video/video_thumbnail_image.dart'; import 'package:stream_chat_flutter_core/stream_chat_flutter_core.dart'; +import 'package:stream_core_flutter/stream_core_flutter.dart'; /// {@template videoAttachmentThumbnail} /// Widget for building video attachment thumbnail. @@ -19,7 +18,7 @@ class StreamVideoAttachmentThumbnail extends StatelessWidget { this.width, this.height, this.fit, - this.errorBuilder = _defaultErrorBuilder, + this.errorBuilder, }); /// The video attachment to build the thumbnail for. @@ -35,21 +34,18 @@ class StreamVideoAttachmentThumbnail extends StatelessWidget { final BoxFit? fit; /// Builder used when the thumbnail fails to load. - final ThumbnailErrorBuilder errorBuilder; + /// + /// If null, default error handling is used. + final ThumbnailErrorBuilder? errorBuilder; - // Default error builder for image attachment thumbnail. - static Widget _defaultErrorBuilder( + // Default error builder for video attachment thumbnail. + Widget _defaultErrorBuilder( BuildContext context, Object error, StackTrace? stackTrace, ) { - return ThumbnailError( - error: error, - stackTrace: stackTrace, - height: double.infinity, - width: double.infinity, - fit: BoxFit.cover, - ); + if (errorBuilder case final builder?) return builder(context, error, null); + return StreamImageErrorPlaceholder(width: width, height: height); } @override @@ -76,31 +72,14 @@ class StreamVideoAttachmentThumbnail extends StatelessWidget { height: height, fit: fit, frameBuilder: (context, child, frame, wasSynchronouslyLoaded) { - if (frame != null || wasSynchronouslyLoaded) { - return child; - } - - final image = Image.asset( - 'lib/assets/images/placeholder.png', - width: width, - height: height, - fit: BoxFit.cover, - package: 'stream_chat_flutter', - ); - - final colorTheme = StreamChatTheme.of(context).colorTheme; - return Shimmer.fromColors( - baseColor: colorTheme.disabled, - highlightColor: colorTheme.inputBg, - child: image, - ); + if (frame != null || wasSynchronouslyLoaded) return child; + return StreamImageLoadingPlaceholder(height: height, width: width); }, - errorBuilder: errorBuilder, + errorBuilder: _defaultErrorBuilder, ); } - // Return error widget if no thumbnail is found. - return errorBuilder( + return _defaultErrorBuilder( context, 'Video attachment is not valid', StackTrace.current, diff --git a/packages/stream_chat_flutter/lib/src/attachment/unsupported_attachment.dart b/packages/stream_chat_flutter/lib/src/attachment/unsupported_attachment.dart new file mode 100644 index 0000000000..a5b76666bd --- /dev/null +++ b/packages/stream_chat_flutter/lib/src/attachment/unsupported_attachment.dart @@ -0,0 +1,126 @@ +import 'package:flutter/material.dart'; +import 'package:stream_chat_flutter/stream_chat_flutter.dart'; + +/// {@template streamUnsupportedAttachment} +/// Displays a placeholder for attachment types not supported by the SDK. +/// +/// Shown automatically by [UnsupportedAttachmentBuilder] when no other +/// [StreamAttachmentWidgetBuilder] can handle the attachment. Can also be used +/// directly for custom attachment builder chains. +/// +/// {@tool snippet} +/// +/// Using as a standalone widget: +/// +/// ```dart +/// StreamUnsupportedAttachment( +/// message: message, +/// ) +/// ``` +/// {@end-tool} +/// +/// See also: +/// +/// * [StreamUnsupportedAttachmentProps], which configures this widget. +/// * [DefaultStreamUnsupportedAttachment], the default implementation. +/// * [UnsupportedAttachmentBuilder], which renders this widget for +/// unrecognised attachment types. +/// {@endtemplate} +class StreamUnsupportedAttachment extends StatelessWidget { + /// Creates a [StreamUnsupportedAttachment]. + StreamUnsupportedAttachment({ + super.key, + required Message message, + BoxConstraints? constraints, + }) : props = .new(message: message, constraints: constraints); + + /// The properties that configure this attachment. + final StreamUnsupportedAttachmentProps props; + + @override + Widget build(BuildContext context) { + final builder = context.chatComponentBuilder(); + if (builder != null) return builder(context, props); + return DefaultStreamUnsupportedAttachment(props: props); + } +} + +/// Properties for configuring a [StreamUnsupportedAttachment]. +/// +/// This class holds all the configuration options for an unsupported +/// attachment, allowing them to be passed through the [StreamComponentFactory]. +/// +/// See also: +/// +/// * [StreamUnsupportedAttachment], which uses these properties. +/// * [DefaultStreamUnsupportedAttachment], the default implementation. +class StreamUnsupportedAttachmentProps { + /// Creates properties for an unsupported attachment. + const StreamUnsupportedAttachmentProps({ + required this.message, + this.constraints, + }); + + /// The [Message] that the unsupported attachment belongs to. + final Message message; + + /// Constraints for the attachment widget. + /// + /// When null, defaults to a fixed width of 256. + final BoxConstraints? constraints; +} + +const _kDefaultConstraints = BoxConstraints(maxWidth: 256); + +/// The default implementation of [StreamUnsupportedAttachment]. +/// +/// Renders an icon and localised label indicating the attachment type is +/// not supported. +/// +/// See also: +/// +/// * [StreamUnsupportedAttachment], the public API widget. +/// * [StreamUnsupportedAttachmentProps], which configures this widget. +class DefaultStreamUnsupportedAttachment extends StatelessWidget { + /// Creates a default Stream unsupported attachment. + const DefaultStreamUnsupportedAttachment({ + super.key, + required this.props, + }); + + /// The properties that configure this attachment. + final StreamUnsupportedAttachmentProps props; + + @override + Widget build(BuildContext context) { + final spacing = context.streamSpacing; + final textTheme = context.streamTextTheme; + final colorScheme = context.streamColorScheme; + + final foregroundColor = colorScheme.textPrimary; + final effectiveConstraints = props.constraints ?? _kDefaultConstraints; + + return ConstrainedBox( + constraints: effectiveConstraints, + child: Padding( + padding: .fromLTRB(spacing.sm, spacing.md, spacing.md, spacing.md), + child: IconTheme( + data: IconThemeData(size: 20, color: foregroundColor), + child: DefaultTextStyle( + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: textTheme.captionEmphasis.copyWith(color: foregroundColor), + child: Row( + mainAxisSize: .min, + spacing: spacing.xs, + children: [ + Icon(context.streamIcons.unsupportedAttachment), + Expanded(child: Text(context.translations.unsupportedAttachmentLabel)), + ], + ), + ), + ), + ), + ); + } +} diff --git a/packages/stream_chat_flutter/lib/src/attachment/url_attachment.dart b/packages/stream_chat_flutter/lib/src/attachment/url_attachment.dart deleted file mode 100644 index 6d8629ea4c..0000000000 --- a/packages/stream_chat_flutter/lib/src/attachment/url_attachment.dart +++ /dev/null @@ -1,146 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:stream_chat_flutter/stream_chat_flutter.dart'; - -/// {@template streamUrlAttachment} -/// Displays a URL attachment in a [StreamMessageWidget]. -/// {@endtemplate} -class StreamUrlAttachment extends StatelessWidget { - /// {@macro streamUrlAttachment} - const StreamUrlAttachment({ - super.key, - required this.message, - required this.urlAttachment, - required this.hostDisplayName, - required this.messageTheme, - this.shape, - this.constraints = const BoxConstraints(), - }); - - /// The [Message] that the image is attached to. - final Message message; - - /// Attachment to be displayed - final Attachment urlAttachment; - - /// The shape of the attachment. - /// - /// Defaults to [RoundedRectangleBorder] with a radius of 14. - final ShapeBorder? shape; - - /// The constraints to use when displaying the file. - final BoxConstraints constraints; - - /// Host name - final String hostDisplayName; - - /// The [StreamMessageThemeData] to use for the image title - final StreamMessageThemeData messageTheme; - - @override - Widget build(BuildContext context) { - final chatTheme = StreamChatTheme.of(context); - final colorTheme = chatTheme.colorTheme; - final shape = this.shape ?? - RoundedRectangleBorder( - side: BorderSide( - color: colorTheme.borders, - strokeAlign: BorderSide.strokeAlignOutside, - ), - borderRadius: BorderRadius.circular(8), - ); - - final backgroundColor = messageTheme.urlAttachmentBackgroundColor; - - return Container( - constraints: constraints, - clipBehavior: Clip.hardEdge, - decoration: ShapeDecoration( - shape: shape, - color: backgroundColor, - ), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Stack( - children: [ - AspectRatio( - // Default aspect ratio for Open Graph images. - // https://www.kapwing.com/resources/what-is-an-og-image-make-and-format-og-images-for-your-blog-or-webpage - aspectRatio: 1.91 / 1, - child: StreamImageAttachmentThumbnail( - image: urlAttachment, - fit: BoxFit.cover, - ), - ), - Positioned( - left: 0, - bottom: 0, - child: DecoratedBox( - decoration: BoxDecoration( - borderRadius: const BorderRadius.only( - topRight: Radius.circular(16), - ), - color: backgroundColor, - ), - child: Padding( - padding: const EdgeInsets.only( - top: 8, - left: 8, - right: 12, - bottom: 4, - ), - child: Text( - hostDisplayName, - style: messageTheme.urlAttachmentHostStyle, - ), - ), - ), - ), - ], - ), - Padding( - padding: const EdgeInsets.all(8), - child: Column( - spacing: 4, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - if (urlAttachment.title != null) - Builder(builder: (context) { - final maxLines = messageTheme.urlAttachmentTitleMaxLine; - - TextOverflow? overflow; - if (maxLines != null && maxLines > 0) { - overflow = TextOverflow.ellipsis; - } - - return Text( - urlAttachment.title!.trim(), - maxLines: maxLines, - overflow: overflow, - style: messageTheme.urlAttachmentTitleStyle, - ); - }), - if (urlAttachment.text != null) - Builder(builder: (context) { - final maxLines = messageTheme.urlAttachmentTextMaxLine; - - TextOverflow? overflow; - if (maxLines != null && maxLines > 0) { - overflow = TextOverflow.ellipsis; - } - - return Text( - urlAttachment.text!, - maxLines: maxLines, - overflow: overflow, - style: messageTheme.urlAttachmentTextStyle, - ); - }), - ], - ), - ), - ], - ), - ); - } -} diff --git a/packages/stream_chat_flutter/lib/src/attachment/video_attachment.dart b/packages/stream_chat_flutter/lib/src/attachment/video_attachment.dart index a79d44c77f..6fb0346fc1 100644 --- a/packages/stream_chat_flutter/lib/src/attachment/video_attachment.dart +++ b/packages/stream_chat_flutter/lib/src/attachment/video_attachment.dart @@ -1,17 +1,66 @@ import 'package:flutter/material.dart'; import 'package:stream_chat_flutter/stream_chat_flutter.dart'; -/// {@template streamVideoAttachment} -/// Shows a video attachment in a [StreamMessageWidget]. -/// {@endtemplate} +/// A video attachment component with a play indicator. +/// +/// [StreamVideoAttachment] displays a video attachment with a visual +/// indicator that it can be played. +/// +/// {@tool snippet} +/// +/// Basic usage: +/// +/// ```dart +/// StreamVideoAttachment( +/// message: message, +/// video: videoAttachment, +/// ) +/// ``` +/// {@end-tool} +/// +/// See also: +/// +/// * [StreamVideoAttachmentProps], which configures this widget. +/// * [DefaultStreamVideoAttachment], the default implementation. class StreamVideoAttachment extends StatelessWidget { - /// {@macro streamVideoAttachment} - const StreamVideoAttachment({ + /// Creates a [StreamVideoAttachment]. + StreamVideoAttachment({ super.key, + required Message message, + required Attachment video, + BoxConstraints? constraints, + }) : props = StreamVideoAttachmentProps( + message: message, + video: video, + constraints: constraints, + ); + + /// The properties that configure this attachment. + final StreamVideoAttachmentProps props; + + @override + Widget build(BuildContext context) { + final builder = context.chatComponentBuilder(); + if (builder != null) return builder(context, props); + return DefaultStreamVideoAttachment(props: props); + } +} + +/// Properties for configuring a [StreamVideoAttachment]. +/// +/// This class holds all the configuration options for a video attachment, +/// allowing them to be passed through the [StreamComponentFactory]. +/// +/// See also: +/// +/// * [StreamVideoAttachment], which uses these properties. +/// * [DefaultStreamVideoAttachment], the default implementation. +class StreamVideoAttachmentProps { + /// Creates properties for a video attachment. + const StreamVideoAttachmentProps({ required this.message, required this.video, - this.shape, - this.constraints = const BoxConstraints(), + this.constraints, }); /// The [Message] that the video is attached to. @@ -20,54 +69,57 @@ class StreamVideoAttachment extends StatelessWidget { /// The [Attachment] object containing the video information. final Attachment video; - /// The shape of the attachment. - /// - /// Defaults to [RoundedRectangleBorder] with a radius of 14. - final ShapeBorder? shape; - /// The constraints to use when displaying the video. - final BoxConstraints constraints; + final BoxConstraints? constraints; +} + +const _kDefaultConstraints = BoxConstraints.tightFor( + width: 256, + height: 195, +); + +/// The default implementation of [StreamVideoAttachment]. +/// +/// Renders the video thumbnail with upload progress indication. +/// +/// See also: +/// +/// * [StreamVideoAttachment], the public API widget. +/// * [StreamVideoAttachmentProps], which configures this widget. +class DefaultStreamVideoAttachment extends StatelessWidget { + /// Creates a default Stream video attachment. + const DefaultStreamVideoAttachment({ + super.key, + required this.props, + }); + + /// The properties that configure this attachment. + final StreamVideoAttachmentProps props; @override Widget build(BuildContext context) { - final chatTheme = StreamChatTheme.of(context); - final colorTheme = chatTheme.colorTheme; - final shape = this.shape ?? - RoundedRectangleBorder( - side: BorderSide( - color: colorTheme.borders, - strokeAlign: BorderSide.strokeAlignOutside, - ), - borderRadius: BorderRadius.circular(14), - ); + final constraints = props.constraints ?? _kDefaultConstraints; - return Container( + return ConstrainedBox( constraints: constraints, - clipBehavior: Clip.hardEdge, - decoration: ShapeDecoration(shape: shape), child: Stack( - alignment: Alignment.center, + fit: .expand, + alignment: .center, children: [ StreamVideoAttachmentThumbnail( - video: video, - width: double.infinity, - height: double.infinity, + video: props.video, fit: BoxFit.cover, ), - const Material( - shape: CircleBorder(), - child: Padding( - padding: EdgeInsets.all(16), - child: Icon(Icons.play_arrow), - ), - ), - Padding( - padding: const EdgeInsets.all(8), - child: StreamAttachmentUploadStateBuilder( - message: message, - attachment: video, + if (props.video.uploadState.isSuccess) ...[ + const Center(child: StreamVideoPlayIndicator(size: .lg)), + ] else ...[ + Positioned.fill( + child: StreamAttachmentUploadStateBuilder( + message: props.message, + attachment: props.video, + ), ), - ), + ], ], ), ); diff --git a/packages/stream_chat_flutter/lib/src/attachment/voice_recording_attachment.dart b/packages/stream_chat_flutter/lib/src/attachment/voice_recording_attachment.dart index 3681d30939..e4fc0d9f23 100644 --- a/packages/stream_chat_flutter/lib/src/attachment/voice_recording_attachment.dart +++ b/packages/stream_chat_flutter/lib/src/attachment/voice_recording_attachment.dart @@ -1,39 +1,89 @@ +import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; import 'package:stream_chat_flutter/src/audio/audio_playlist_state.dart'; import 'package:stream_chat_flutter/src/audio/audio_sampling.dart' as sampling; -import 'package:stream_chat_flutter/src/misc/audio_waveform.dart'; -import 'package:stream_chat_flutter/stream_chat_flutter.dart'; +import 'package:stream_chat_flutter/stream_chat_flutter.dart' hide StreamTextTheme; +import 'package:stream_core_flutter/stream_core_flutter.dart'; const _kDefaultWaveformLimit = 35; -const _kDefaultWaveformHeight = 28.0; +const _kDefaultWaveformHeight = 24.0; -/// Signature for building trailing widgets in voice recording attachments. +/// An inline audio player for a voice recording attachment. /// -/// Provides a flexible way to customize the trailing section of the -/// voice recording player based on the current track and playback state. -typedef StreamVoiceRecordingAttachmentTrailingWidgetBuilder = Widget Function( - BuildContext context, - PlaylistTrack track, - PlaybackSpeed speed, - ValueChanged? onChangeSpeed, -); - -/// {@template streamVoiceRecordingAttachment} -/// An embedded audio player for voice recordings with comprehensive playback -/// controls. +/// [StreamVoiceRecordingAttachment] displays a single voice recording with +/// playback controls, waveform visualization, and speed adjustment. /// -/// Provides a rich audio message player with features including: -/// - Play/pause controls -/// - Waveform visualization -/// - Playback speed adjustment -/// - Optional title display +/// {@tool snippet} /// -/// Supports customizable appearance and interaction through various parameters. -/// {@endtemplate} +/// Basic usage: +/// +/// ```dart +/// StreamVoiceRecordingAttachment( +/// track: track, +/// speed: playbackSpeed, +/// ) +/// ``` +/// {@end-tool} +/// +/// See also: +/// +/// * [StreamVoiceRecordingAttachmentProps], which configures this widget. +/// * [DefaultStreamVoiceRecordingAttachment], the default implementation. class StreamVoiceRecordingAttachment extends StatelessWidget { - /// {@macro streamVoiceRecordingAttachment} - const StreamVoiceRecordingAttachment({ + /// Creates a [StreamVoiceRecordingAttachment]. + StreamVoiceRecordingAttachment({ super.key, + required PlaylistTrack track, + required StreamPlaybackSpeed speed, + VoidCallback? onTrackPause, + VoidCallback? onTrackPlay, + VoidCallback? onTrackReplay, + ValueChanged? onTrackSeekStart, + ValueChanged? onTrackSeekChanged, + ValueChanged? onTrackSeekEnd, + ValueChanged? onChangeSpeed, + BoxConstraints constraints = const BoxConstraints(), + bool showTitle = false, + String? title, + }) : props = .new( + track: track, + speed: speed, + onTrackPause: onTrackPause, + onTrackPlay: onTrackPlay, + onTrackReplay: onTrackReplay, + onTrackSeekStart: onTrackSeekStart, + onTrackSeekChanged: onTrackSeekChanged, + onTrackSeekEnd: onTrackSeekEnd, + onChangeSpeed: onChangeSpeed, + constraints: constraints, + showTitle: showTitle, + title: title, + ); + + /// The properties that configure this attachment. + final StreamVoiceRecordingAttachmentProps props; + + @override + Widget build(BuildContext context) { + final builder = context.chatComponentBuilder(); + if (builder != null) return builder(context, props); + return DefaultStreamVoiceRecordingAttachment(props: props); + } +} + +/// Properties for configuring a [StreamVoiceRecordingAttachment]. +/// +/// This class holds all the configuration options for a voice recording +/// attachment, allowing them to be passed through the +/// [StreamComponentFactory]. +/// +/// See also: +/// +/// * [StreamVoiceRecordingAttachment], which uses these properties. +/// * [DefaultStreamVoiceRecordingAttachment], the default implementation. +class StreamVoiceRecordingAttachmentProps { + /// Creates properties for a voice recording attachment. + const StreamVoiceRecordingAttachmentProps({ required this.track, required this.speed, this.onTrackPause, @@ -43,17 +93,16 @@ class StreamVoiceRecordingAttachment extends StatelessWidget { this.onTrackSeekChanged, this.onTrackSeekEnd, this.onChangeSpeed, - this.shape, this.constraints = const BoxConstraints(), this.showTitle = false, - this.trailingBuilder = _defaultTrailingBuilder, + this.title, }); /// The audio track to display. final PlaylistTrack track; /// The current playback speed of the audio track. - final PlaybackSpeed speed; + final StreamPlaybackSpeed speed; /// Callback when the track is paused. final VoidCallback? onTrackPause; @@ -74,115 +123,102 @@ class StreamVoiceRecordingAttachment extends StatelessWidget { final ValueChanged? onTrackSeekEnd; /// Callback when the playback speed is changed. - final ValueChanged? onChangeSpeed; - - /// The shape of the attachment. - /// - /// Defaults to [RoundedRectangleBorder] with a radius of 14. - final ShapeBorder? shape; + final ValueChanged? onChangeSpeed; /// The constraints to use when displaying the voice recording. final BoxConstraints constraints; + /// The title of the audio message to display when [showTitle] is `true`. + /// If not provided, the [track] title will be used. + final String? title; + /// Whether to show the title of the audio message. /// /// Defaults to `false`. final bool showTitle; +} - /// The builder to use for the trailing widget. - final StreamVoiceRecordingAttachmentTrailingWidgetBuilder trailingBuilder; - - static Widget _defaultTrailingBuilder( - BuildContext context, - PlaylistTrack track, - PlaybackSpeed speed, - ValueChanged? onChangeSpeed, - ) { - return AnimatedSwitcher( - duration: const Duration(milliseconds: 200), - child: switch (track.state.isPlaying) { - true => SpeedControlButton( - speed: speed, - onChangeSpeed: onChangeSpeed, - ), - false => getFileTypeImage(track.title?.mediaType?.mimeType), - }, - ); - } +/// The default implementation of [StreamVoiceRecordingAttachment]. +/// +/// Renders an inline audio player with playback controls, waveform +/// visualization, and playback speed adjustment. +/// +/// See also: +/// +/// * [StreamVoiceRecordingAttachment], the public API widget. +/// * [StreamVoiceRecordingAttachmentProps], which configures this widget. +class DefaultStreamVoiceRecordingAttachment extends StatelessWidget { + /// Creates a default Stream voice recording attachment. + const DefaultStreamVoiceRecordingAttachment({ + super.key, + required this.props, + }); + + /// The properties that configure this attachment. + final StreamVoiceRecordingAttachmentProps props; @override Widget build(BuildContext context) { + final spacing = context.streamSpacing; + final textTheme = context.streamTextTheme; + final theme = StreamVoiceRecordingAttachmentTheme.of(context); - final waveformSliderTheme = theme.audioWaveformSliderTheme; - final waveformTheme = waveformSliderTheme?.audioWaveformTheme; - - final shape = this.shape ?? - RoundedRectangleBorder( - side: BorderSide( - color: StreamChatTheme.of(context).colorTheme.borders, - strokeAlign: BorderSide.strokeAlignOutside, - ), - borderRadius: BorderRadius.circular(14), - ); - - return Container( - constraints: constraints, - clipBehavior: Clip.hardEdge, - padding: const EdgeInsets.all(8), - decoration: ShapeDecoration( - shape: shape, - color: theme.backgroundColor, - ), + final defaults = _StreamVoiceRecordingAttachmentDefaults(context); + + final isActive = props.track.state != TrackState.idle; + final isPlaying = props.track.state == TrackState.playing; + + final effectiveDurationTextStyle = theme.durationTextStyle ?? defaults.durationTextStyle; + final effectiveActiveDurationTextStyle = theme.activeDurationTextStyle ?? defaults.activeDurationTextStyle; + final effectiveSpeedToggleStyle = theme.speedToggleStyle ?? defaults.speedToggleStyle; + final effectiveControlButtonStyle = theme.controlButtonStyle ?? defaults.controlButtonStyle; + + return Padding( + padding: .all(spacing.xs), child: Row( + spacing: spacing.xs, + crossAxisAlignment: .center, children: [ AudioControlButton( - state: track.state, - onPlay: onTrackPlay, - onPause: onTrackPause, - onReplay: onTrackReplay, + state: props.track.state, + onPlay: props.onTrackPlay, + onPause: props.onTrackPause, + onReplay: props.onTrackReplay, + themeStyle: effectiveControlButtonStyle, ), - const SizedBox(width: 14), Expanded( child: Column( + spacing: spacing.xxxs, mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ - if (track.title case final title? when showTitle) ...[ + if (props.title ?? props.track.title case final title? when props.showTitle) AudioTitleText( title: title, - style: theme.titleTextStyle, + style: theme.titleTextStyle ?? textTheme.metadataEmphasis, ), - const SizedBox(height: 6), - ], Row( + spacing: spacing.sm, children: [ AudioDurationText( - duration: track.duration, - position: track.position, - style: theme.durationTextStyle, + duration: props.track.duration, + position: props.track.position, + style: isPlaying ? effectiveActiveDurationTextStyle : effectiveDurationTextStyle, ), - const SizedBox(width: 8), Expanded( child: SizedBox( height: _kDefaultWaveformHeight, child: StreamAudioWaveformSlider( + isActive: isActive, limit: _kDefaultWaveformLimit, waveform: sampling.resampleWaveformData( - track.waveform, + props.track.waveform, _kDefaultWaveformLimit, ), - progress: track.progress, - onChangeStart: onTrackSeekStart, - onChanged: onTrackSeekChanged, - onChangeEnd: onTrackSeekEnd, - color: waveformTheme?.color, - progressColor: waveformTheme?.progressColor, - minBarHeight: waveformTheme?.minBarHeight, - spacingRatio: waveformTheme?.spacingRatio, - heightScale: waveformTheme?.heightScale, - thumbColor: waveformSliderTheme?.thumbColor, - thumbBorderColor: - waveformSliderTheme?.thumbBorderColor, + progress: props.track.progress, + onChangeStart: props.onTrackSeekStart, + onChanged: props.onTrackSeekChanged, + onChangeEnd: props.onTrackSeekEnd, ), ), ), @@ -191,8 +227,11 @@ class StreamVoiceRecordingAttachment extends StatelessWidget { ], ), ), - const SizedBox(width: 14), - trailingBuilder(context, track, speed, onChangeSpeed), + StreamPlaybackSpeedToggle( + value: props.speed, + onChanged: props.onChangeSpeed, + style: effectiveSpeedToggleStyle, + ), ], ), ); @@ -232,7 +271,7 @@ class AudioTitleText extends StatelessWidget { /// {@template audioDurationText} /// Displays duration for audio playback with dynamic formatting. /// -/// Shows either current position or total duration based on playback state. +/// Shows either remaining time or total duration based on playback state. /// {@endtemplate} class AudioDurationText extends StatelessWidget { /// {@macro audioDurationText} @@ -254,11 +293,9 @@ class AudioDurationText extends StatelessWidget { @override Widget build(BuildContext context) { + final remaining = [duration - position, Duration.zero].max; return Text( - switch (position.inMilliseconds > 0) { - true => position.toMinutesAndSeconds(), - false => duration.toMinutesAndSeconds(), - }, + remaining.toMinutesAndSeconds(), style: style?.copyWith( // Use mono space for each num character. fontFeatures: [const FontFeature.tabularFigures()], @@ -284,6 +321,10 @@ class AudioControlButton extends StatelessWidget { this.onPlay, this.onPause, this.onReplay, + this.style = .secondary, + this.type = .outline, + this.size = .medium, + this.themeStyle, }); /// The current state of the audio track. @@ -298,58 +339,58 @@ class AudioControlButton extends StatelessWidget { /// Callback when the track is replayed. final VoidCallback? onReplay; + /// The style of the button. + final StreamButtonStyle style; + + /// The type of the button. + final StreamButtonType type; + + /// The size of the button. + final StreamButtonSize size; + + /// The optional style override for the button. + final StreamButtonThemeStyle? themeStyle; + @override Widget build(BuildContext context) { - final theme = StreamVoiceRecordingAttachmentTheme.of(context); + final icons = context.streamIcons; - return ElevatedButton( - style: theme.audioControlButtonStyle, + return StreamButton.icon( + style: style, + type: type, + size: size, + themeStyle: themeStyle, + icon: switch (state) { + TrackState.loading => Icon(icons.playFill), + TrackState.idle => Icon(icons.playFill), + TrackState.playing => Icon(icons.pauseFill), + TrackState.paused => Icon(icons.playFill), + }, onPressed: switch (state) { TrackState.loading => null, TrackState.idle => onPlay, TrackState.playing => onPause, TrackState.paused => onPlay, }, - child: switch (state) { - TrackState.loading => theme.loadingIndicator, - TrackState.idle => theme.playIcon, - TrackState.playing => theme.pauseIcon, - TrackState.paused => theme.playIcon, - }, ); } } -/// {@template speedControlButton} -/// A button for controlling audio playback speed. -/// -/// Allows cycling through predefined playback speeds when pressed. -/// {@endtemplate} -class SpeedControlButton extends StatelessWidget { - /// {@macro speedControlButton} - const SpeedControlButton({ - super.key, - required this.speed, - this.onChangeSpeed, - }); +// Default values for [StreamVoiceRecordingAttachmentThemeData] backed by stream design tokens. +class _StreamVoiceRecordingAttachmentDefaults extends StreamVoiceRecordingAttachmentThemeData { + _StreamVoiceRecordingAttachmentDefaults(this._context); - /// The current playback speed of the audio track. - final PlaybackSpeed speed; + final BuildContext _context; - /// Callback when the playback speed is changed. - final ValueChanged? onChangeSpeed; + late final StreamColorScheme _colorScheme = _context.streamColorScheme; + late final StreamTextTheme _textTheme = _context.streamTextTheme; @override - Widget build(BuildContext context) { - final theme = StreamVoiceRecordingAttachmentTheme.of(context); + TextStyle get titleTextStyle => _textTheme.captionEmphasis.copyWith(color: _colorScheme.textPrimary); - return ElevatedButton( - style: theme.speedControlButtonStyle, - onPressed: switch (onChangeSpeed) { - final it? => () => it(speed.next), - _ => null, - }, - child: Text('x${speed.speed}'), - ); - } + @override + TextStyle get durationTextStyle => _textTheme.metadataEmphasis.copyWith(color: _colorScheme.textPrimary); + + @override + TextStyle get activeDurationTextStyle => _textTheme.metadataEmphasis.copyWith(color: _colorScheme.accentPrimary); } diff --git a/packages/stream_chat_flutter/lib/src/attachment/voice_recording_attachment_playlist.dart b/packages/stream_chat_flutter/lib/src/attachment/voice_recording_attachment_playlist.dart index d4bb67c0f7..55ed6980cd 100644 --- a/packages/stream_chat_flutter/lib/src/attachment/voice_recording_attachment_playlist.dart +++ b/packages/stream_chat_flutter/lib/src/attachment/voice_recording_attachment_playlist.dart @@ -1,68 +1,124 @@ import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; -import 'package:stream_chat_flutter/src/attachment/voice_recording_attachment.dart'; import 'package:stream_chat_flutter/src/audio/audio_playlist_controller.dart'; -import 'package:stream_chat_flutter/src/misc/empty_widget.dart'; import 'package:stream_chat_flutter/stream_chat_flutter.dart'; -/// {@template streamVoiceRecordingAttachmentPlaylist} -/// Shows a voice recording attachment in a [StreamMessageWidget]. -/// {@endtemplate} +/// Signature for decorating each voice recording item in a playlist. +/// +/// The [child] is the default [StreamVoiceRecordingAttachment] widget built +/// by the playlist. Return a widget that wraps [child] with the desired +/// container. +/// +/// See also: +/// +/// * [StreamVoiceRecordingAttachmentPlaylist.itemDecorator], which uses this +/// typedef. +typedef StreamVoiceRecordingItemDecorator = Widget Function(BuildContext context, int index, Widget child); + +/// A playlist container for multiple voice recording attachments. +/// +/// [StreamVoiceRecordingAttachmentPlaylist] manages audio playback across +/// multiple voice recordings using a shared controller, ensuring only one +/// recording plays at a time. +/// +/// {@tool snippet} +/// +/// Basic usage: +/// +/// ```dart +/// StreamVoiceRecordingAttachmentPlaylist( +/// message: message, +/// voiceRecordings: voiceRecordingAttachments, +/// ) +/// ``` +/// {@end-tool} +/// +/// {@tool snippet} +/// +/// With a decorator to wrap each item in a message attachment container: +/// +/// ```dart +/// StreamVoiceRecordingAttachmentPlaylist( +/// message: message, +/// voiceRecordings: voiceRecordingAttachments, +/// itemDecorator: (context, index, child) { +/// return StreamMessageAttachment(style: style, child: child); +/// }, +/// ) +/// ``` +/// {@end-tool} +/// +/// See also: +/// +/// * [StreamVoiceRecordingAttachment], the individual voice recording widget +/// used for each item in the playlist. class StreamVoiceRecordingAttachmentPlaylist extends StatefulWidget { - /// {@macro streamVoiceRecordingAttachmentPlaylist} + /// Creates a [StreamVoiceRecordingAttachmentPlaylist]. const StreamVoiceRecordingAttachmentPlaylist({ super.key, - this.shape, required this.message, required this.voiceRecordings, this.padding, this.itemBuilder, + this.itemDecorator, this.separatorBuilder = _defaultVoiceRecordingPlaylistSeparatorBuilder, - this.constraints = const BoxConstraints(), + this.constraints, + this.voiceRecordingTitle, }); - /// The shape of the attachment. - /// - /// Defaults to [RoundedRectangleBorder] with a radius of 14. - final ShapeBorder? shape; - - /// The [Message] that the voice recording is attached to. + /// The [Message] that the voice recordings are attached to. final Message message; - /// The list of [Attachment] object containing the voice recording + /// The list of [Attachment] objects containing the voice recording /// information. final List voiceRecordings; - /// The constraints to use when displaying the voice recording. - final BoxConstraints constraints; + /// The constraints to use when displaying each voice recording. + final BoxConstraints? constraints; /// The amount of space by which to inset the children. final EdgeInsetsGeometry? padding; /// The builder to use for each voice recording. /// - /// If not provided, a default implementation will be used. + /// If not provided, a default implementation using + /// [StreamVoiceRecordingAttachment] will be used. + /// + /// When provided, [itemDecorator] is ignored since the builder has full + /// control over the item widget. final IndexedWidgetBuilder? itemBuilder; + /// Optional decorator that wraps each default voice recording item. + /// + /// Use this to provide context-specific containers around each + /// [StreamVoiceRecordingAttachment] without replacing the default + /// item building logic. + /// + /// Ignored when [itemBuilder] is provided. + final StreamVoiceRecordingItemDecorator? itemDecorator; + /// The separator to use between the voice recordings. final IndexedWidgetBuilder separatorBuilder; + /// The title to use for each voice recording. + final String? voiceRecordingTitle; + // Default separator builder for the voice recording playlist. static Widget _defaultVoiceRecordingPlaylistSeparatorBuilder( BuildContext context, int index, ) { - return const Empty(); + final spacing = context.streamSpacing; + return SizedBox(height: spacing.xxs); } @override - State createState() => - _StreamVoiceRecordingAttachmentPlaylistState(); + State createState() => _StreamVoiceRecordingAttachmentPlaylistState(); } -class _StreamVoiceRecordingAttachmentPlaylistState - extends State { +class _StreamVoiceRecordingAttachmentPlaylistState extends State + with WidgetsBindingObserver { late final _controller = StreamAudioPlaylistController( widget.voiceRecordings.toPlaylist(), ); @@ -70,23 +126,33 @@ class _StreamVoiceRecordingAttachmentPlaylistState @override void initState() { super.initState(); + WidgetsBinding.instance.addObserver(this); _controller.initialize(); } + @override + void didChangeAppLifecycleState(AppLifecycleState state) { + // Pause playback when the app goes to background or is detached. + final isBackground = ![AppLifecycleState.resumed, AppLifecycleState.inactive].contains(state); + if (isBackground) _controller.pause(); + } + @override void didUpdateWidget( covariant StreamVoiceRecordingAttachmentPlaylist oldWidget, ) { super.didUpdateWidget(oldWidget); - final equals = const ListEquality().equals; - if (!equals(widget.voiceRecordings, oldWidget.voiceRecordings)) { + final newPlaylist = widget.voiceRecordings.toPlaylist(); + final oldPlaylist = oldWidget.voiceRecordings.toPlaylist(); + if (!const ListEquality().equals(newPlaylist, oldPlaylist)) { // If the playlist have changed, update the playlist. - _controller.updatePlaylist(widget.voiceRecordings.toPlaylist()); + _controller.updatePlaylist(newPlaylist); } } @override void dispose() { + WidgetsBinding.instance.removeObserver(this); _controller.dispose(); super.dispose(); } @@ -114,12 +180,13 @@ class _StreamVoiceRecordingAttachmentPlaylistState } final track = state.tracks[index]; - return StreamVoiceRecordingAttachment( + + final child = StreamVoiceRecordingAttachment( track: track, speed: state.speed, - showTitle: true, - shape: widget.shape, - constraints: widget.constraints, + showTitle: false, + title: widget.voiceRecordingTitle, + constraints: widget.constraints ?? const BoxConstraints(), onTrackPause: _controller.pause, onChangeSpeed: _controller.setSpeed, onTrackPlay: () async { @@ -134,10 +201,6 @@ class _StreamVoiceRecordingAttachmentPlaylistState if (state.currentIndex != index) return; return _controller.pause(); }, - onTrackSeekEnd: (_) async { - if (state.currentIndex != index) return; - return _controller.play(); - }, onTrackSeekChanged: (progress) async { if (state.currentIndex != index) return; @@ -145,9 +208,15 @@ class _StreamVoiceRecordingAttachmentPlaylistState final seekPosition = (duration * progress).toInt(); final seekDuration = Duration(microseconds: seekPosition); - return _controller.seek(seekDuration); + await _controller.seek(seekDuration); }, ); + + if (widget.itemDecorator case final decorator?) { + return decorator(context, index, child); + } + + return child; }, ), ); diff --git a/packages/stream_chat_flutter/lib/src/attachment_actions_modal/attachment_actions_modal.dart b/packages/stream_chat_flutter/lib/src/attachment_actions_modal/attachment_actions_modal.dart index f11e4e127e..16e21b4487 100644 --- a/packages/stream_chat_flutter/lib/src/attachment_actions_modal/attachment_actions_modal.dart +++ b/packages/stream_chat_flutter/lib/src/attachment_actions_modal/attachment_actions_modal.dart @@ -48,7 +48,7 @@ class AttachmentActionsModal extends StatelessWidget { /// List of custom actions final List customActions; - /// Creates a copy of [StreamMessageWidget] with + /// Creates a copy of [AttachmentActionsModal] with /// specified attributes overridden. AttachmentActionsModal copyWith({ Key? key, @@ -105,148 +105,149 @@ class AttachmentActionsModal extends StatelessWidget { child: Column( crossAxisAlignment: CrossAxisAlignment.end, mainAxisSize: MainAxisSize.min, - children: [ - if (showReply) - _buildButton( - context, - context.translations.replyLabel, - StreamSvgIcon( - size: 24, - icon: StreamSvgIcons.reply, - color: theme.colorTheme.textLowEmphasis, - ), - onReply, - ), - if (showShowInChat) - _buildButton( - context, - context.translations.showInChatLabel, - StreamSvgIcon( - size: 24, - icon: StreamSvgIcons.eye, - color: theme.colorTheme.textHighEmphasis, - ), - onShowMessage, - ), - if (showSave) - _buildButton( - context, - attachment.type == AttachmentType.video - ? context.translations.saveVideoLabel - : context.translations.saveImageLabel, - StreamSvgIcon( - size: 24, - icon: StreamSvgIcons.save, - color: theme.colorTheme.textLowEmphasis, - ), - () { - // Closing attachment actions modal before opening - // attachment download dialog - Navigator.of(context).pop(); - - final downloader = attachmentDownloader ?? - StreamAttachmentHandler.instance.downloadAttachment; - - // No need to show progress dialog in case of - // web or desktop. - if (isDesktopDeviceOrWeb) { - downloader(attachment); - return; - } - - final progressNotifier = - ValueNotifier<_DownloadProgress?>( - _DownloadProgress.initial(), - ); - - final downloadedPathNotifier = - ValueNotifier(null); - - downloader( - attachment, - onReceiveProgress: (received, total) { - progressNotifier.value = _DownloadProgress( - total, - received, - ); - }, - ).then((path) { - downloadedPathNotifier.value = path; - }).catchError((e, stk) { - print(e); - print(stk); - progressNotifier.value = null; - }); - - showDialog( - barrierDismissible: false, - context: context, - barrierColor: theme.colorTheme.overlay, - builder: (context) => _buildDownloadProgressDialog( - context, - progressNotifier, - downloadedPathNotifier, + children: + [ + if (showReply) + _buildButton( + context, + context.translations.replyLabel, + Icon( + context.streamIcons.reply, + size: 20, + color: theme.colorTheme.textLowEmphasis, + ), + onReply, + ), + if (showShowInChat) + _buildButton( + context, + context.translations.showInChatLabel, + Icon( + context.streamIcons.messageBubble, + size: 20, + color: theme.colorTheme.textLowEmphasis, + ), + onShowMessage, + ), + if (showSave) + _buildButton( + context, + attachment.type == AttachmentType.video + ? context.translations.saveVideoLabel + : context.translations.saveImageLabel, + Icon( + context.streamIcons.arrowDownCircle, + size: 20, + color: theme.colorTheme.textLowEmphasis, + ), + () { + // Closing attachment actions modal before opening + // attachment download dialog + Navigator.of(context).pop(); + + final downloader = + attachmentDownloader ?? StreamAttachmentHandler.instance.downloadAttachment; + + // No need to show progress dialog in case of + // web or desktop. + if (isDesktopDeviceOrWeb) { + downloader(attachment); + return; + } + + final progressNotifier = ValueNotifier<_DownloadProgress?>( + _DownloadProgress.initial(), + ); + + final downloadedPathNotifier = ValueNotifier(null); + + downloader( + attachment, + onReceiveProgress: (received, total) { + progressNotifier.value = _DownloadProgress( + total, + received, + ); + }, + ) + .then((path) { + downloadedPathNotifier.value = path; + }) + .catchError((e, stk) { + print(e); + print(stk); + progressNotifier.value = null; + }); + + showDialog( + barrierDismissible: false, + context: context, + barrierColor: theme.colorTheme.overlay, + builder: (context) => _buildDownloadProgressDialog( + context, + progressNotifier, + downloadedPathNotifier, + ), + ); + }, + ), + if (StreamChat.of(context).currentUser?.id == message.user?.id && showDelete) + _buildButton( + context, + context.translations.deleteLabel, + Icon( + context.streamIcons.delete, + size: 20, + color: theme.colorTheme.accentError, + ), + () { + final channel = StreamChannel.of(context).channel; + if (message.attachments.length > 1 || message.text?.isNotEmpty == true) { + final currentAttachmentIndex = message.attachments.indexWhere( + (element) => element.id == attachment.id, + ); + final remainingAttachments = [...message.attachments] + ..removeAt(currentAttachmentIndex); + channel.updateMessage( + message.copyWith( + attachments: remainingAttachments, + ), + ); + Navigator.of(context) + ..pop() + ..maybePop(); + } else { + channel.deleteMessage(message); + Navigator.of(context) + ..pop() + ..maybePop(); + } + }, + color: theme.colorTheme.accentError, + ), + ...customActions + .map( + (e) => _buildButton( + context, + e.actionTitle, + e.icon, + e.onTap, + ), + ) + .toList(), + ] + .map( + (e) => Align( + alignment: Alignment.centerRight, + child: e, + ), + ) + .insertBetween( + Container( + height: 1, + color: theme.colorTheme.borders, ), - ); - }, - ), - if (StreamChat.of(context).currentUser?.id == - message.user?.id && - showDelete) - _buildButton( - context, - context.translations.deleteLabel.capitalize(), - StreamSvgIcon( - size: 24, - icon: StreamSvgIcons.delete, - color: theme.colorTheme.accentError, - ), - () { - final channel = StreamChannel.of(context).channel; - if (message.attachments.length > 1 || - message.text?.isNotEmpty == true) { - final currentAttachmentIndex = - message.attachments.indexWhere( - (element) => element.id == attachment.id, - ); - final remainingAttachments = [...message.attachments] - ..removeAt(currentAttachmentIndex); - channel.updateMessage(message.copyWith( - attachments: remainingAttachments, - )); - Navigator.of(context) - ..pop() - ..maybePop(); - } else { - channel.deleteMessage(message); - Navigator.of(context) - ..pop() - ..maybePop(); - } - }, - color: theme.colorTheme.accentError, - ), - ...customActions - .map( - (e) => _buildButton( - context, - e.actionTitle, - e.icon, - e.onTap, ), - ) - .toList(), - ] - .map((e) => Align( - alignment: Alignment.centerRight, - child: e, - )) - .insertBetween( - Container( - height: 1, - color: theme.colorTheme.borders, - ), - ), ), ), ), @@ -276,10 +277,7 @@ class AttachmentActionsModal extends StatelessWidget { const SizedBox(width: 16), Text( title, - style: StreamChatTheme.of(context) - .textTheme - .body - .copyWith(color: color), + style: StreamChatTheme.of(context).textTheme.body.copyWith(color: color), ), ], ), @@ -330,46 +328,44 @@ class AttachmentActionsModal extends StatelessWidget { ? SizedBox( height: 100, width: 100, - child: StreamSvgIcon( - icon: StreamSvgIcons.error, + child: Icon( + context.streamIcons.exclamationCircleFill, color: theme.colorTheme.disabled, ), ) : _downloadComplete - ? SizedBox( - key: const Key('completedIcon'), - height: 160, - width: 160, - child: StreamSvgIcon( - icon: StreamSvgIcons.check, - color: theme.colorTheme.disabled, + ? SizedBox( + key: const Key('completedIcon'), + height: 160, + width: 160, + child: Icon( + context.streamIcons.checkmark, + color: theme.colorTheme.disabled, + ), + ) + : SizedBox( + height: 100, + width: 100, + child: Stack( + fit: StackFit.expand, + children: [ + CircularProgressIndicator.adaptive( + strokeWidth: 8, + valueColor: AlwaysStoppedAnimation( + theme.colorTheme.accentPrimary, + ), ), - ) - : SizedBox( - height: 100, - width: 100, - child: Stack( - fit: StackFit.expand, - children: [ - CircularProgressIndicator.adaptive( - strokeWidth: 8, - valueColor: AlwaysStoppedAnimation( - theme.colorTheme.accentPrimary, - ), - ), - Center( - child: Text( - '${progress.receivedValueInMB} MB', - style: - theme.textTheme.headline.copyWith( - color: - theme.colorTheme.textLowEmphasis, - ), - ), + Center( + child: Text( + '${progress.receivedValueInMB} MB', + style: theme.textTheme.headline.copyWith( + color: theme.colorTheme.textLowEmphasis, ), - ], + ), ), - ), + ], + ), + ), ), ), ), @@ -384,8 +380,7 @@ class AttachmentActionsModal extends StatelessWidget { class _DownloadProgress { const _DownloadProgress(this.total, this.received); - factory _DownloadProgress.initial() => - _DownloadProgress(double.maxFinite.toInt(), 0); + factory _DownloadProgress.initial() => _DownloadProgress(double.maxFinite.toInt(), 0); final int total; final int received; diff --git a/packages/stream_chat_flutter/lib/src/audio/audio_playlist_controller.dart b/packages/stream_chat_flutter/lib/src/audio/audio_playlist_controller.dart index 013aadde1e..c0133623d9 100644 --- a/packages/stream_chat_flutter/lib/src/audio/audio_playlist_controller.dart +++ b/packages/stream_chat_flutter/lib/src/audio/audio_playlist_controller.dart @@ -4,6 +4,7 @@ import 'package:collection/collection.dart'; import 'package:flutter/foundation.dart'; import 'package:just_audio/just_audio.dart'; import 'package:stream_chat_flutter/src/audio/audio_playlist_state.dart'; +import 'package:stream_core_flutter/stream_core_flutter.dart'; /// {@template streamAudioPlaylistController} /// A controller for managing an audio playlist. @@ -21,8 +22,8 @@ class StreamAudioPlaylistController extends ValueNotifier { StreamAudioPlaylistController.raw({ AudioPlayer? player, AudioPlaylistState state = const AudioPlaylistState(tracks: []), - }) : _player = player ?? AudioPlayer(), - super(state); + }) : _player = player ?? AudioPlayer(), + super(state); final AudioPlayer _player; @@ -45,21 +46,22 @@ class StreamAudioPlaylistController extends ValueNotifier { final tracks = [ ...value.tracks.mapIndexed((index, track) { final trackState = switch (index == currentIndex) { - true => state.playing - ? TrackState.playing - : switch (state.processingState) { - ProcessingState.idle => TrackState.idle, - ProcessingState.loading => TrackState.loading, - _ => TrackState.paused, - }, + true => + state.playing + ? TrackState.playing + : switch (state.processingState) { + ProcessingState.idle => TrackState.idle, + ProcessingState.loading => TrackState.loading, + _ => TrackState.paused, + }, false => switch (track.state) { - TrackState.idle => TrackState.idle, - _ => TrackState.paused, - }, + TrackState.idle => TrackState.idle, + _ => TrackState.paused, + }, }; return track.copyWith(state: trackState); - }) + }), ]; value = value.copyWith(tracks: tracks); @@ -74,7 +76,7 @@ class StreamAudioPlaylistController extends ValueNotifier { ...value.tracks.mapIndexed((index, track) { if (index != currentIndex) return track; return track.copyWith(position: position); - }) + }), ]; value = value.copyWith(tracks: tracks); @@ -82,7 +84,11 @@ class StreamAudioPlaylistController extends ValueNotifier { // Listen to speed changes _speedSubscription = _player.speedStream.listen((speed) { - value = value.copyWith(speed: PlaybackSpeed.fromValue(speed)); + final playbackSpeed = StreamPlaybackSpeed.values.firstWhere( + (e) => e.speed == speed, + orElse: () => StreamPlaybackSpeed.x1, + ); + value = value.copyWith(speed: playbackSpeed); }); } @@ -116,7 +122,7 @@ class StreamAudioPlaylistController extends ValueNotifier { Future stop() => _player.stop(); /// Sets the speed of the current track. - Future setSpeed(PlaybackSpeed speed) => _player.setSpeed(speed.speed); + Future setSpeed(StreamPlaybackSpeed speed) => _player.setSpeed(speed.speed); /// Seeks to the given position in the current track. Future seek(Duration position) => _player.seek(position); @@ -164,7 +170,9 @@ class StreamAudioPlaylistController extends ValueNotifier { tracks: [ ...tracks.mapIndexed((i, track) { if (i != index) return track; - return track.copyWith(duration: duration); + // Prefer the larger of the two to guard against tiny clock jitter + // between what was stored and what the decoder reports. + return track.copyWith(duration: [track.duration, ?duration].max); }), ], ); diff --git a/packages/stream_chat_flutter/lib/src/audio/audio_playlist_state.dart b/packages/stream_chat_flutter/lib/src/audio/audio_playlist_state.dart index 36a3078037..f9489094e3 100644 --- a/packages/stream_chat_flutter/lib/src/audio/audio_playlist_state.dart +++ b/packages/stream_chat_flutter/lib/src/audio/audio_playlist_state.dart @@ -1,6 +1,7 @@ import 'dart:math' as math; import 'package:collection/collection.dart'; +import 'package:stream_core_flutter/stream_core_flutter.dart'; /// {@template playlistLoopMode} /// Represents the loop mode of a playlist. @@ -24,7 +25,7 @@ class AudioPlaylistState { const AudioPlaylistState({ required this.tracks, this.currentIndex, - this.speed = PlaybackSpeed.regular, + this.speed = StreamPlaybackSpeed.x1, this.loopMode = PlaylistLoopMode.off, }); @@ -37,7 +38,7 @@ class AudioPlaylistState { final int? currentIndex; /// The current playback speed of the playlist. - final PlaybackSpeed speed; + final StreamPlaybackSpeed speed; /// The current loop mode of the playlist. final PlaylistLoopMode loopMode; @@ -47,7 +48,7 @@ class AudioPlaylistState { AudioPlaylistState copyWith({ List? tracks, int? currentIndex, - PlaybackSpeed? speed, + StreamPlaybackSpeed? speed, PlaylistLoopMode? loopMode, }) { return AudioPlaylistState( @@ -86,7 +87,8 @@ enum TrackState { playing, /// The track is currently paused. - paused; + paused + ; /// Returns `true` if the track is currently idle. bool get isIdle => this == TrackState.idle; @@ -101,45 +103,6 @@ enum TrackState { bool get isPaused => this == TrackState.paused; } -/// {@template playbackSpeed} -/// Represents the speed of a track. -/// {@endtemplate} -enum PlaybackSpeed { - /// The regular speed of the playback (1x). - regular._(1), - - /// A faster speed of the playback (1.5x). - faster._(1.5), - - /// The fastest speed of the playback (2x). - fastest._(2); - - const PlaybackSpeed._(this.speed); - - /// Creates a [PlaybackSpeed] from the given value. - factory PlaybackSpeed.fromValue(double speed) { - return PlaybackSpeed.values.firstWhere( - (it) => it.speed == speed, - orElse: () => PlaybackSpeed.regular, - ); - } - - /// The speed of the playback. - final double speed; -} - -/// Helper extension for [PlaybackSpeed]. -extension StreamAudioPlayerExtension on PlaybackSpeed { - /// Returns the next [PlaybackSpeed] value. - PlaybackSpeed get next { - return switch (this) { - PlaybackSpeed.regular => PlaybackSpeed.faster, - PlaybackSpeed.faster => PlaybackSpeed.fastest, - PlaybackSpeed.fastest => PlaybackSpeed.regular, - }; - } -} - /// {@template playlistTrack} /// Represents a track in a playlist. /// {@endtemplate} @@ -147,6 +110,7 @@ class PlaylistTrack { /// {@macro playlistTrack} const PlaylistTrack({ required this.uri, + this.key, this.title, this.waveform = const [], this.duration = Duration.zero, @@ -154,6 +118,9 @@ class PlaylistTrack { this.state = TrackState.idle, }); + /// The key to identify the track. + final Object? key; + /// The uri of the track. final Uri uri; @@ -200,6 +167,7 @@ class PlaylistTrack { TrackState? state, }) { return PlaylistTrack( + key: key, uri: uri ?? this.uri, title: title ?? this.title, duration: duration ?? this.duration, @@ -211,7 +179,7 @@ class PlaylistTrack { @override int get hashCode { - return Object.hash(uri, title, duration, waveform, position, state); + return Object.hash(uri, title, duration, Object.hashAll(waveform), position, state); } @override @@ -222,7 +190,7 @@ class PlaylistTrack { uri == other.uri && title == other.title && duration == other.duration && - waveform == other.waveform && + const ListEquality().equals(waveform, other.waveform) && position == other.position && state == other.state; } diff --git a/packages/stream_chat_flutter/lib/src/audio/audio_sampling.dart b/packages/stream_chat_flutter/lib/src/audio/audio_sampling.dart index 0381d273eb..943032510d 100644 --- a/packages/stream_chat_flutter/lib/src/audio/audio_sampling.dart +++ b/packages/stream_chat_flutter/lib/src/audio/audio_sampling.dart @@ -36,23 +36,21 @@ List downSample(List data, int targetOutputSize) { final previousBucketRefPoint = data[lastSelectedPointIndex]; final nextBucketMean = _getNextBucketMean(data, bucketIndex, bucketSize); - final currentBucketStartIndex = - ((bucketIndex - 1) * bucketSize).floor() + 1; + final currentBucketStartIndex = ((bucketIndex - 1) * bucketSize).floor() + 1; final nextBucketStartIndex = (bucketIndex * bucketSize).floor() + 1; - final countUnitsBetweenAtoC = - 1 + nextBucketStartIndex - currentBucketStartIndex; + final countUnitsBetweenAtoC = 1 + nextBucketStartIndex - currentBucketStartIndex; var maxArea = -1.0; var triangleArea = -1.0; double? maxAreaPoint; - for (var currentPointIndex = currentBucketStartIndex; - currentPointIndex < nextBucketStartIndex; - currentPointIndex++) { - final countUnitsBetweenAtoB = - (currentPointIndex - currentBucketStartIndex).abs() + 1; - final countUnitsBetweenBtoC = - countUnitsBetweenAtoC - countUnitsBetweenAtoB; + for ( + var currentPointIndex = currentBucketStartIndex; + currentPointIndex < nextBucketStartIndex; + currentPointIndex++ + ) { + final countUnitsBetweenAtoB = (currentPointIndex - currentBucketStartIndex).abs() + 1; + final countUnitsBetweenBtoC = countUnitsBetweenAtoC - countUnitsBetweenAtoB; final currentPointValue = data[currentPointIndex]; triangleArea = _triangleAreaHeron( @@ -107,11 +105,8 @@ double _getNextBucketMean( double bucketSize, ) { final nextBucketStartIndex = (currentBucketIndex * bucketSize).floor() + 1; - var nextNextBucketStartIndex = - ((currentBucketIndex + 1) * bucketSize).floor() + 1; - nextNextBucketStartIndex = nextNextBucketStartIndex < data.length - ? nextNextBucketStartIndex - : data.length; + var nextNextBucketStartIndex = ((currentBucketIndex + 1) * bucketSize).floor() + 1; + nextNextBucketStartIndex = nextNextBucketStartIndex < data.length ? nextNextBucketStartIndex : data.length; return _mean(data.sublist(nextBucketStartIndex, nextNextBucketStartIndex)); } diff --git a/packages/stream_chat_flutter/lib/src/autocomplete/stream_autocomplete.dart b/packages/stream_chat_flutter/lib/src/autocomplete/stream_autocomplete.dart index 98487690c2..b57029ab83 100644 --- a/packages/stream_chat_flutter/lib/src/autocomplete/stream_autocomplete.dart +++ b/packages/stream_chat_flutter/lib/src/autocomplete/stream_autocomplete.dart @@ -7,9 +7,6 @@ import 'package:stream_chat_flutter/stream_chat_flutter.dart'; export 'stream_command_autocomplete_options.dart'; export 'stream_mention_autocomplete_options.dart'; -/// {@macro stream_chat_flutter.StreamMessageInputController} -typedef StreamMessageEditingController = StreamMessageInputController; - /// Positions the [AutocompleteTrigger] options around the [TextField] or /// [TextFormField] that triggered the autocomplete. enum OptionsAlignment { @@ -19,7 +16,8 @@ enum OptionsAlignment { /// The options are displayed above the field. /// /// This is the default. - above; + above + ; Anchor _toAnchor() { switch (this) { @@ -45,11 +43,12 @@ enum OptionsAlignment { /// See also: /// /// * [StreamAutocomplete.fieldViewBuilder], which is of this type. -typedef StreamAutocompleteFieldViewBuilder = Widget Function( - BuildContext context, - StreamMessageEditingController messageEditingController, - FocusNode focusNode, -); +typedef StreamAutocompleteFieldViewBuilder = + Widget Function( + BuildContext context, + StreamMessageComposerController messageComposerController, + FocusNode focusNode, + ); /// The type of the [StreamAutocompleteTrigger] callback which returns a /// [Widget] that displays the specified [options]. @@ -57,11 +56,12 @@ typedef StreamAutocompleteFieldViewBuilder = Widget Function( /// See also: /// /// * [StreamAutocompleteTrigger.optionsViewBuilder], which is of this type. -typedef StreamAutocompleteOptionsViewBuilder = Widget Function( - BuildContext context, - StreamAutocompleteQuery autocompleteQuery, - StreamMessageEditingController messageEditingController, -); +typedef StreamAutocompleteOptionsViewBuilder = + Widget Function( + BuildContext context, + StreamAutocompleteQuery autocompleteQuery, + StreamMessageComposerController messageComposerController, + ); /// The query to determine the autocomplete options. class StreamAutocompleteQuery { @@ -148,8 +148,7 @@ class StreamAutocompleteTrigger { final cursorPosition = textEditingValue.selection.baseOffset; // Find the first [trigger] location before the input cursor. - final firstTriggerIndexBeforeCursor = - text.substring(0, cursorPosition).lastIndexOf(trigger); + final firstTriggerIndexBeforeCursor = text.substring(0, cursorPosition).lastIndexOf(trigger); // If the [trigger] is not found before the cursor, then it's not a trigger. if (firstTriggerIndexBeforeCursor == -1) return null; @@ -164,9 +163,7 @@ class StreamAutocompleteTrigger { // valid examples: "@user", "Hello @user" // invalid examples: "Hello@user" final textBeforeTrigger = text.substring(0, firstTriggerIndexBeforeCursor); - if (triggerOnlyAfterSpace && - textBeforeTrigger.isNotEmpty && - !textBeforeTrigger.endsWith(' ')) { + if (triggerOnlyAfterSpace && textBeforeTrigger.isNotEmpty && !textBeforeTrigger.endsWith(' ')) { return null; } @@ -204,19 +201,19 @@ class StreamAutocomplete extends StatefulWidget { const StreamAutocomplete({ super.key, this.focusNode, - this.messageEditingController, + this.messageComposerController, required this.autocompleteTriggers, this.fieldViewBuilder = _defaultFieldViewBuilder, this.optionsAlignment = OptionsAlignment.above, this.debounceDuration = const Duration(milliseconds: 300), - }) : assert((focusNode == null) == (messageEditingController == null), ''); + }) : assert((focusNode == null) == (messageComposerController == null), ''); /// The triggers that trigger autocomplete. final Iterable autocompleteTriggers; /// Builds the field whose input is used to get the options. /// - /// Pass the provided [StreamMessageEditingController] to the field built + /// Pass the provided [StreamMessageComposerController] to the field built /// here so that StreamAutocomplete can listen for changes. final StreamAutocompleteFieldViewBuilder fieldViewBuilder; @@ -230,17 +227,17 @@ class StreamAutocomplete extends StatefulWidget { /// When following this pattern, [fieldViewBuilder] can return /// `EmptyWidget()` so that nothing is drawn where the text field would /// normally be. A separate text field can be created elsewhere, and a - /// FocusNode and StreamMessageEditingController can be passed both to that + /// FocusNode and StreamMessageComposerController can be passed both to that /// text field and to StreamAutocomplete. /// - /// If this parameter is not null, then [messageEditingController] must also + /// If this parameter is not null, then [messageComposerController] must also /// be not null. final FocusNode? focusNode; - /// The [StreamMessageEditingController] that is used for the text field. + /// The [StreamMessageComposerController] that is used for the text field. /// /// If this parameter is not null, then [focusNode] must also be not null. - final StreamMessageEditingController? messageEditingController; + final StreamMessageComposerController? messageComposerController; /// The alignment of the options. /// @@ -248,19 +245,19 @@ class StreamAutocomplete extends StatefulWidget { final OptionsAlignment optionsAlignment; /// The duration of the debounce period for the - /// [StreamMessageEditingController]. + /// [StreamMessageComposerController]. /// /// The default value is [300ms]. final Duration debounceDuration; static Widget _defaultFieldViewBuilder( BuildContext context, - StreamMessageEditingController messageEditingController, + StreamMessageComposerController messageComposerController, FocusNode focusNode, ) { return _StreamAutocompleteField( focusNode: focusNode, - messageEditingController: messageEditingController, + messageComposerController: messageComposerController, ); } @@ -276,7 +273,7 @@ class StreamAutocomplete extends StatefulWidget { } class _StreamAutocompleteState extends State { - late StreamMessageEditingController _messageEditingController; + late StreamMessageComposerController _messageComposerController; late FocusNode _focusNode; StreamAutocompleteQuery? _currentQuery; @@ -287,10 +284,7 @@ class _StreamAutocompleteState extends State { // True if the state indicates that the options should be visible. bool get _shouldShowOptions { - return !_hideOptions && - _focusNode.hasFocus && - _currentQuery != null && - _currentTrigger != null; + return !_hideOptions && _focusNode.hasFocus && _currentQuery != null && _currentTrigger != null; } /// Accepts and replaces the current query with the given [option] and closes @@ -308,7 +302,7 @@ class _StreamAutocompleteState extends State { if (query == null || trigger == null) return; final querySelection = query.selection; - final text = _messageEditingController.text; + final text = _messageComposerController.text; var start = querySelection.baseOffset; if (!keepTrigger) start -= 1; @@ -328,7 +322,7 @@ class _StreamAutocompleteState extends State { final newText = text.replaceRange(start, end, option); final newSelection = TextSelection.collapsed(offset: selectionOffset); - _messageEditingController.textEditingValue = TextEditingValue( + _messageComposerController.textEditingValue = TextEditingValue( text: newText, selection: newSelection, ); @@ -375,11 +369,11 @@ class _StreamAutocompleteState extends State { return null; } - // Called when _textEditingController changes. + // Called when _messageComposerController changes. late final _onChangedField = debounce( () { - final messageValue = _messageEditingController.message; - final textEditingValue = _messageEditingController.textEditingValue; + final messageValue = _messageComposerController.message; + final textEditingValue = _messageComposerController.textEditingValue; // If the content has not changed, then there is nothing to do. if (textEditingValue.text == _lastFieldText) return; @@ -419,28 +413,28 @@ class _StreamAutocompleteState extends State { if (mounted) setState(() {}); } - // Handle a potential change in textEditingController by properly disposing of - // the old one and setting up the new one, if needed. + // Handle a potential change in messageComposerController by properly + // disposing of the old one and setting up the new one, if needed. void _updateTextEditingController( - StreamMessageEditingController? old, - StreamMessageEditingController? current, + StreamMessageComposerController? old, + StreamMessageComposerController? current, ) { if ((old == null && current == null) || old == current) { return; } if (old == null) { - _messageEditingController + _messageComposerController ..removeListener(_onChangedField) ..dispose(); - _messageEditingController = current!; + _messageComposerController = current!; } else if (current == null) { - _messageEditingController.removeListener(_onChangedField); - _messageEditingController = StreamMessageEditingController(); + _messageComposerController.removeListener(_onChangedField); + _messageComposerController = StreamMessageComposerController(); } else { - _messageEditingController.removeListener(_onChangedField); - _messageEditingController = current; + _messageComposerController.removeListener(_onChangedField); + _messageComposerController = current; } - _messageEditingController.addListener(_onChangedField); + _messageComposerController.addListener(_onChangedField); } // Handle a potential change in focusNode by properly disposing of the old one @@ -467,9 +461,8 @@ class _StreamAutocompleteState extends State { @override void initState() { super.initState(); - _messageEditingController = - widget.messageEditingController ?? StreamMessageEditingController(); - _messageEditingController.addListener(_onChangedField); + _messageComposerController = widget.messageComposerController ?? StreamMessageComposerController(); + _messageComposerController.addListener(_onChangedField); _focusNode = widget.focusNode ?? FocusNode(); _focusNode.addListener(_onChangedFocus); } @@ -478,24 +471,25 @@ class _StreamAutocompleteState extends State { void didUpdateWidget(StreamAutocomplete oldWidget) { super.didUpdateWidget(oldWidget); _updateTextEditingController( - oldWidget.messageEditingController, - widget.messageEditingController, + oldWidget.messageComposerController, + widget.messageComposerController, ); _updateFocusNode(oldWidget.focusNode, widget.focusNode); } @override void dispose() { - _messageEditingController.removeListener(_onChangedField); - if (widget.messageEditingController == null) { - _messageEditingController.dispose(); + _messageComposerController.removeListener(_onChangedField); + if (widget.messageComposerController == null) { + _messageComposerController.dispose(); } _focusNode.removeListener(_onChangedFocus); if (widget.focusNode == null) { _focusNode.dispose(); } _onChangedField.cancel(); - closeSuggestions(); + _currentQuery = null; + _currentTrigger = null; super.dispose(); } @@ -511,7 +505,7 @@ class _StreamAutocompleteState extends State { child: _currentTrigger!.optionsViewBuilder( context, _currentQuery!, - _messageEditingController, + _messageComposerController, ), ) : null; @@ -522,7 +516,7 @@ class _StreamAutocompleteState extends State { portalFollower: optionViewBuilder, child: widget.fieldViewBuilder( context, - _messageEditingController, + _messageComposerController, _focusNode, ), ); @@ -535,17 +529,17 @@ class _StreamAutocompleteState extends State { class _StreamAutocompleteField extends StatelessWidget { const _StreamAutocompleteField({ required this.focusNode, - required this.messageEditingController, + required this.messageComposerController, }); final FocusNode focusNode; - final StreamMessageEditingController messageEditingController; + final StreamMessageComposerController messageComposerController; @override Widget build(BuildContext context) { return StreamMessageTextField( - controller: messageEditingController, + controller: messageComposerController, focusNode: focusNode, ); } @@ -555,6 +549,72 @@ const _kDefaultStreamAutocompleteOptionsShape = RoundedRectangleBorder( borderRadius: BorderRadius.all(Radius.circular(8)), ); +/// Defines the visual style of autocomplete options overlay. +enum AutocompleteOptionsStyle { + /// Flat overlay with no elevation or margin. + /// + /// Used for overlays that appear directly above the composer (default). + fixed, + + /// Floating card with elevation and rounded corners. + /// + /// Used for overlays that appear in open space away from the composer. + floating, +} + +/// Resolves visual parameters for a [StreamAutocompleteOptions] widget based +/// on [AutocompleteOptionsStyle]. +extension AutocompleteOptionsStyleX on AutocompleteOptionsStyle { + /// Returns the elevation, margin, and shape for [StreamAutocompleteOptions]. + /// + /// [borderColor] is used for the top border (fixed) or outline (floating). + ({double elevation, EdgeInsetsGeometry margin, ShapeBorder shape}) resolve( + Color borderColor, + ) { + return switch (this) { + AutocompleteOptionsStyle.fixed => ( + elevation: 0.0, + margin: EdgeInsets.zero, + shape: _TopBorderShape(BorderSide(color: borderColor)), + ), + AutocompleteOptionsStyle.floating => ( + elevation: 4.0, + margin: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + shape: RoundedRectangleBorder( + borderRadius: const BorderRadius.all(Radius.circular(24)), + side: BorderSide(color: borderColor), + ), + ), + }; + } +} + +/// A [ShapeBorder] that paints only a top border, with no rounding or sides. +class _TopBorderShape extends ShapeBorder { + const _TopBorderShape(this.top); + + final BorderSide top; + + @override + EdgeInsetsGeometry get dimensions => EdgeInsets.only(top: top.width); + + @override + Path getInnerPath(Rect rect, {TextDirection? textDirection}) => Path()..addRect(rect); + + @override + Path getOuterPath(Rect rect, {TextDirection? textDirection}) => Path()..addRect(rect); + + @override + void paint(Canvas canvas, Rect rect, {TextDirection? textDirection}) { + final paint = top.toPaint()..strokeCap = StrokeCap.square; + final y = rect.top + top.width / 2; + canvas.drawLine(Offset(rect.left, y), Offset(rect.right, y), paint); + } + + @override + ShapeBorder scale(double t) => _TopBorderShape(top.scale(t)); +} + /// A helper widget used to show the options of a [StreamAutocomplete]. class StreamAutocompleteOptions extends StatelessWidget { /// Creates a [StreamAutocompleteOptions] widget. @@ -621,10 +681,7 @@ class StreamAutocompleteOptions extends StatelessWidget { child: Column( mainAxisSize: MainAxisSize.min, children: [ - if (headerBuilder != null) ...[ - headerBuilder!(context), - Divider(height: 0, color: colorTheme.borders), - ], + if (headerBuilder != null) headerBuilder!(context), LimitedBox( maxHeight: maxHeight ?? height * 0.5, child: ListView.builder( diff --git a/packages/stream_chat_flutter/lib/src/autocomplete/stream_command_autocomplete_options.dart b/packages/stream_chat_flutter/lib/src/autocomplete/stream_command_autocomplete_options.dart index fddd4abc72..625e66cba2 100644 --- a/packages/stream_chat_flutter/lib/src/autocomplete/stream_command_autocomplete_options.dart +++ b/packages/stream_chat_flutter/lib/src/autocomplete/stream_command_autocomplete_options.dart @@ -1,7 +1,12 @@ import 'package:flutter/material.dart'; +import 'package:stream_chat_flutter/src/message_input/attachment_picker/options/stream_command_icon.dart'; import 'package:stream_chat_flutter/src/misc/empty_widget.dart'; import 'package:stream_chat_flutter/stream_chat_flutter.dart'; +// Caps the card height so a long command list scrolls internally +// instead of pushing the composer / header off the screen. +const _kMaxHeight = 208.0; + /// {@template commands_overlay} /// Overlay for displaying commands that can be used /// to interact with the channel. @@ -12,6 +17,7 @@ class StreamCommandAutocompleteOptions extends StatelessWidget { required this.query, required this.channel, this.onCommandSelected, + this.style = AutocompleteOptionsStyle.fixed, super.key, }); @@ -24,6 +30,11 @@ class StreamCommandAutocompleteOptions extends StatelessWidget { /// Callback called when a command is selected. final ValueSetter? onCommandSelected; + /// The visual style of the autocomplete options overlay. + /// + /// Defaults to [AutocompleteOptionsStyle.fixed]. + final AutocompleteOptionsStyle style; + @override Widget build(BuildContext context) { final commands = channel.config?.commands.where((it) { @@ -34,25 +45,30 @@ class StreamCommandAutocompleteOptions extends StatelessWidget { if (commands == null || commands.isEmpty) return const Empty(); - final streamChatTheme = StreamChatTheme.of(context); - final colorTheme = streamChatTheme.colorTheme; - final textTheme = streamChatTheme.textTheme; + final colorScheme = context.streamColorScheme; + final textTheme = context.streamTextTheme; + + final (:elevation, :margin, :shape) = style.resolve(colorScheme.borderDefault); return StreamAutocompleteOptions( options: commands, + maxHeight: _kMaxHeight, + elevation: elevation, + margin: margin, + shape: shape, headerBuilder: (context) { - return ListTile( - dense: true, - horizontalTitleGap: 0, - leading: StreamSvgIcon( - icon: StreamSvgIcons.lightning, - color: colorTheme.accentPrimary, - size: 28, + return Padding( + padding: EdgeInsets.only( + left: context.streamSpacing.sm, + right: context.streamSpacing.sm, + top: context.streamSpacing.md, + bottom: context.streamSpacing.xs, ), - title: Text( - context.translations.instantCommandsLabel, - style: textTheme.body.copyWith( - color: colorTheme.textLowEmphasis, + child: Align( + alignment: AlignmentDirectional.centerStart, + child: Text( + context.translations.instantCommandsLabel, + style: textTheme.headingXs.copyWith(color: colorScheme.textTertiary), ), ), ); @@ -60,122 +76,28 @@ class StreamCommandAutocompleteOptions extends StatelessWidget { optionBuilder: (context, command) { return ListTile( dense: true, - horizontalTitleGap: 8, - leading: _CommandIcon(command: command), - title: Row( + horizontalTitleGap: context.streamSpacing.sm, + leading: StreamCommandIcon(command: command), + title: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( - command.name.capitalize(), - style: textTheme.bodyBold.copyWith( - color: colorTheme.textHighEmphasis, - ), + command.name.sentenceCase, + style: textTheme.bodyDefault, ), - const SizedBox(width: 8), + SizedBox(height: context.streamSpacing.xxs), Text( - '/${command.name} ${command.args}', - style: textTheme.body.copyWith( - color: colorTheme.textLowEmphasis, + command.description, + style: textTheme.captionDefault.copyWith( + color: colorScheme.textTertiary, ), ), ], ), - onTap: onCommandSelected == null - ? null - : () => onCommandSelected!(command), + onTap: onCommandSelected == null ? null : () => onCommandSelected!(command), ); }, ); } } - -class _CommandIcon extends StatelessWidget { - const _CommandIcon({required this.command}); - - final Command command; - - @override - Widget build(BuildContext context) { - final _streamChatTheme = StreamChatTheme.of(context); - switch (command.name) { - case 'giphy': - return const CircleAvatar( - radius: 12, - child: StreamSvgIcon( - size: 24, - icon: StreamSvgIcons.giphy, - ), - ); - case 'ban': - return CircleAvatar( - backgroundColor: _streamChatTheme.colorTheme.accentPrimary, - radius: 12, - child: const StreamSvgIcon( - size: 16, - color: Colors.white, - icon: StreamSvgIcons.userRemove, - ), - ); - case 'flag': - return CircleAvatar( - backgroundColor: _streamChatTheme.colorTheme.accentPrimary, - radius: 12, - child: const StreamSvgIcon( - size: 14, - color: Colors.white, - icon: StreamSvgIcons.flag, - ), - ); - case 'imgur': - return CircleAvatar( - backgroundColor: _streamChatTheme.colorTheme.accentPrimary, - radius: 12, - child: const ClipOval( - child: StreamSvgIcon( - size: 24, - icon: StreamSvgIcons.imgur, - ), - ), - ); - case 'mute': - return CircleAvatar( - backgroundColor: _streamChatTheme.colorTheme.accentPrimary, - radius: 12, - child: const StreamSvgIcon( - size: 16, - color: Colors.white, - icon: StreamSvgIcons.mute, - ), - ); - case 'unban': - return CircleAvatar( - backgroundColor: _streamChatTheme.colorTheme.accentPrimary, - radius: 12, - child: const StreamSvgIcon( - size: 16, - color: Colors.white, - icon: StreamSvgIcons.userAdd, - ), - ); - case 'unmute': - return CircleAvatar( - backgroundColor: _streamChatTheme.colorTheme.accentPrimary, - radius: 12, - child: const StreamSvgIcon( - size: 16, - color: Colors.white, - icon: StreamSvgIcons.volumeUp, - ), - ); - default: - return CircleAvatar( - backgroundColor: _streamChatTheme.colorTheme.accentPrimary, - radius: 12, - child: const StreamSvgIcon( - size: 16, - color: Colors.white, - icon: StreamSvgIcons.lightning, - ), - ); - } - } -} diff --git a/packages/stream_chat_flutter/lib/src/autocomplete/stream_mention_autocomplete_options.dart b/packages/stream_chat_flutter/lib/src/autocomplete/stream_mention_autocomplete_options.dart index 955e02ae34..ceac0ba902 100644 --- a/packages/stream_chat_flutter/lib/src/autocomplete/stream_mention_autocomplete_options.dart +++ b/packages/stream_chat_flutter/lib/src/autocomplete/stream_mention_autocomplete_options.dart @@ -2,6 +2,10 @@ import 'package:flutter/material.dart'; import 'package:stream_chat_flutter/src/misc/empty_widget.dart'; import 'package:stream_chat_flutter/stream_chat_flutter.dart'; +// Caps the card height so a long mention list scrolls internally +// instead of pushing the composer / header off the screen. +const _kMaxHeight = 176.0; + /// {@template user_mentions_overlay} /// Overlay for displaying users that can be mentioned. /// {@endtemplate} @@ -16,14 +20,15 @@ class StreamMentionAutocompleteOptions extends StatefulWidget { this.mentionAllAppUsers = false, this.mentionsTileBuilder, this.onMentionUserTap, - }) : assert( - channel.state != null, - 'Channel ${channel.cid} is not yet initialized', - ), - assert( - !mentionAllAppUsers || (mentionAllAppUsers && client != null), - 'StreamChatClient is required in order to use mentionAllAppUsers', - ); + this.style = AutocompleteOptionsStyle.fixed, + }) : assert( + channel.state != null, + 'Channel ${channel.cid} is not yet initialized', + ), + assert( + !mentionAllAppUsers || (mentionAllAppUsers && client != null), + 'StreamChatClient is required in order to use mentionAllAppUsers', + ); /// Query for searching users. final String query; @@ -48,13 +53,16 @@ class StreamMentionAutocompleteOptions extends StatefulWidget { /// Callback called when a user is selected. final ValueSetter? onMentionUserTap; + /// The visual style of the autocomplete options overlay. + /// + /// Defaults to [AutocompleteOptionsStyle.fixed]. + final AutocompleteOptionsStyle style; + @override - _StreamMentionAutocompleteOptionsState createState() => - _StreamMentionAutocompleteOptionsState(); + _StreamMentionAutocompleteOptionsState createState() => _StreamMentionAutocompleteOptionsState(); } -class _StreamMentionAutocompleteOptionsState - extends State { +class _StreamMentionAutocompleteOptionsState extends State { late Future> userMentionsFuture; @override @@ -83,18 +91,49 @@ class _StreamMentionAutocompleteOptionsState if (!snapshot.hasData) return const Empty(); final users = snapshot.data!; + final colorScheme = context.streamColorScheme; + final spacing = context.streamSpacing; + final (:elevation, :margin, :shape) = widget.style.resolve(colorScheme.borderDefault); + return StreamAutocompleteOptions( options: users, + maxHeight: _kMaxHeight, + elevation: elevation, + margin: margin, + shape: shape, optionBuilder: (context, user) { - final colorTheme = StreamChatTheme.of(context).colorTheme; - return Material( - color: colorTheme.barsBg, + final mentionsTileBuilder = widget.mentionsTileBuilder; + if (mentionsTileBuilder != null) { + final colorTheme = StreamChatTheme.of(context).colorTheme; + return Material( + color: colorTheme.barsBg, + child: InkWell( + onTap: widget.onMentionUserTap == null ? null : () => widget.onMentionUserTap!(user), + child: mentionsTileBuilder(context, user), + ), + ); + } + + return Padding( + padding: EdgeInsets.symmetric(horizontal: spacing.xxs), child: InkWell( - onTap: widget.onMentionUserTap == null - ? null - : () => widget.onMentionUserTap!(user), - child: widget.mentionsTileBuilder?.call(context, user) ?? - StreamUserMentionTile(user), + borderRadius: BorderRadius.circular(12), + onTap: widget.onMentionUserTap == null ? null : () => widget.onMentionUserTap!(user), + child: Padding( + padding: EdgeInsets.symmetric(horizontal: spacing.sm, vertical: spacing.xs), + child: Row( + spacing: spacing.sm, + children: [ + StreamUserAvatar(size: .md, user: user), + Text( + user.name, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: context.streamTextTheme.bodyDefault, + ), + ], + ), + ), ), ); }, @@ -131,18 +170,13 @@ class _StreamMentionAutocompleteOptionsState } final result = await _queryMembers(query); - return result - .map((it) => it.user) - .whereType() - .toList(growable: false); + return result.map((it) => it.user).whereType().toList(growable: false); } Future> _queryMembers(String query) async { final response = await widget.channel.queryMembers( pagination: PaginationParams(limit: widget.limit), - filter: query.isEmpty - ? const Filter.empty() - : Filter.autoComplete('name', query), + filter: query.isEmpty ? const Filter.empty() : Filter.autoComplete('name', query), ); return response.members; } diff --git a/packages/stream_chat_flutter/lib/src/avatars/group_avatar.dart b/packages/stream_chat_flutter/lib/src/avatars/group_avatar.dart deleted file mode 100644 index 146a664318..0000000000 --- a/packages/stream_chat_flutter/lib/src/avatars/group_avatar.dart +++ /dev/null @@ -1,176 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:stream_chat_flutter/stream_chat_flutter.dart'; - -/// WidgetBuilder for [StreamGroupAvatar]. -typedef StreamGroupAvatarBuilder = Widget Function( - BuildContext context, - List members, - // ignore: avoid_positional_boolean_parameters - bool isSelected, -); - -/// {@template streamGroupAvatar} -/// Widget for constructing a group of images -/// {@endtemplate} -class StreamGroupAvatar extends StatelessWidget { - /// {@macro streamGroupAvatar} - const StreamGroupAvatar({ - super.key, - this.channel, - required this.members, - this.constraints, - this.onTap, - this.borderRadius, - this.selected = false, - this.selectionColor, - this.selectionThickness = 4, - }); - - /// The channel of the avatar - final Channel? channel; - - /// The list of members in the group whose avatars should be displayed. - final List members; - - /// Constraints on the widget - final BoxConstraints? constraints; - - /// The action to perform when the widget is tapped - final VoidCallback? onTap; - - /// If `true`, this widget should be highlighted. - /// - /// Defaults to `false`. - final bool selected; - - /// [BorderRadius] to pass to the widget - final BorderRadius? borderRadius; - - /// The color to highlight the widget with if [selected] is `true` - final Color? selectionColor; - - /// The value to use for the border thickness and padding of the - /// selected image - final double selectionThickness; - - @override - Widget build(BuildContext context) { - final channel = this.channel ?? StreamChannel.of(context).channel; - - assert(channel.state != null, 'Channel ${channel.id} is not initialized'); - - final streamChatTheme = StreamChatTheme.of(context); - final colorTheme = streamChatTheme.colorTheme; - final previewTheme = streamChatTheme.channelPreviewTheme.avatarTheme; - - Widget avatar = GestureDetector( - onTap: onTap, - child: ClipRRect( - borderRadius: - borderRadius ?? previewTheme?.borderRadius ?? BorderRadius.zero, - child: Container( - constraints: constraints ?? previewTheme?.constraints, - decoration: BoxDecoration(color: colorTheme.accentPrimary), - child: Flex( - direction: Axis.vertical, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Flexible( - fit: FlexFit.tight, - child: Flex( - direction: Axis.horizontal, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: members - .take(2) - .map( - (member) => Flexible( - fit: FlexFit.tight, - child: FittedBox( - fit: BoxFit.cover, - clipBehavior: Clip.antiAlias, - child: Transform.scale( - scale: 1.2, - child: BetterStreamBuilder( - stream: channel.state!.membersStream.map( - (members) => members.firstWhere( - (it) => it.userId == member.userId, - orElse: () => member, - ), - ), - initialData: member, - builder: (context, member) => StreamUserAvatar( - showOnlineStatus: false, - user: member.user!, - borderRadius: BorderRadius.zero, - ), - ), - ), - ), - ), - ) - .toList(), - ), - ), - if (members.length > 2) - Flexible( - fit: FlexFit.tight, - child: Flex( - direction: Axis.horizontal, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: members - .skip(2) - .take(2) - .map( - (member) => Flexible( - fit: FlexFit.tight, - child: FittedBox( - fit: BoxFit.cover, - clipBehavior: Clip.antiAlias, - child: Transform.scale( - scale: 1.2, - child: BetterStreamBuilder( - stream: channel.state!.membersStream.map( - (members) => members.firstWhere( - (it) => it.userId == member.userId, - orElse: () => member, - ), - ), - initialData: member, - builder: (context, member) => - StreamUserAvatar( - showOnlineStatus: false, - user: member.user!, - borderRadius: BorderRadius.zero, - ), - ), - ), - ), - ), - ) - .toList(), - ), - ), - ], - ), - ), - ), - ); - - if (selected) { - avatar = ClipRRect( - borderRadius: BorderRadius.circular(selectionThickness) + - (borderRadius ?? previewTheme?.borderRadius ?? BorderRadius.zero), - child: Container( - constraints: constraints ?? previewTheme?.constraints, - color: selectionColor ?? colorTheme.accentPrimary, - child: Padding( - padding: EdgeInsets.all(selectionThickness), - child: avatar, - ), - ), - ); - } - - return avatar; - } -} diff --git a/packages/stream_chat_flutter/lib/src/avatars/user_avatar.dart b/packages/stream_chat_flutter/lib/src/avatars/user_avatar.dart deleted file mode 100644 index f62e40bcff..0000000000 --- a/packages/stream_chat_flutter/lib/src/avatars/user_avatar.dart +++ /dev/null @@ -1,192 +0,0 @@ -import 'package:cached_network_image/cached_network_image.dart'; -import 'package:flutter/material.dart'; -import 'package:stream_chat_flutter/stream_chat_flutter.dart'; - -/// WidgetBuilder for [StreamUserAvatar]. -typedef StreamUserAvatarBuilder = Widget Function( - BuildContext context, - User user, - // ignore: avoid_positional_boolean_parameters - bool isSelected, -); - -/// {@template streamUserAvatar} -/// Displays a user's avatar. -/// {@endtemplate} -class StreamUserAvatar extends StatelessWidget { - /// {@macro streamUserAvatar} - const StreamUserAvatar({ - super.key, - required this.user, - this.constraints, - this.onlineIndicatorConstraints, - this.onTap, - this.onLongPress, - this.showOnlineStatus = true, - this.borderRadius, - this.onlineIndicatorAlignment = Alignment.topRight, - this.selected = false, - this.selectionColor, - this.selectionThickness = 4, - this.placeholder, - }); - - /// User whose avatar is to be displayed - final User user; - - /// Alignment of the online indicator - /// - /// Defaults to `Alignment.topRight` - final Alignment onlineIndicatorAlignment; - - /// Sizing constraints of the avatar - final BoxConstraints? constraints; - - /// [BorderRadius] of the image - final BorderRadius? borderRadius; - - /// Sizing constraints of the online indicator - final BoxConstraints? onlineIndicatorConstraints; - - /// {@macro onUserAvatarTap} - final OnUserAvatarPress? onTap; - - /// {@macro onUserAvatarTap} - final OnUserAvatarPress? onLongPress; - - /// Flag for showing online status - /// - /// Defaults to `true` - final bool showOnlineStatus; - - /// Flag for if avatar is selected - /// - /// Defaults to `false` - final bool selected; - - /// Color of selection - final Color? selectionColor; - - /// Selection thickness around the avatar - /// - /// Defaults to `4` - final double selectionThickness; - - /// {@macro placeholderUserImage} - final PlaceholderUserImage? placeholder; - - @override - Widget build(BuildContext context) { - final streamChatTheme = StreamChatTheme.of(context); - final colorTheme = streamChatTheme.colorTheme; - final avatarTheme = streamChatTheme.ownMessageTheme.avatarTheme; - final streamChatConfig = StreamChatConfiguration.of(context); - - final effectivePlaceholder = switch (placeholder) { - final placeholder? => placeholder, - _ => streamChatConfig.placeholderUserImage, - }; - - final effectiveBorderRadius = borderRadius ?? avatarTheme?.borderRadius; - - final backupGradientAvatar = ClipRRect( - borderRadius: effectiveBorderRadius ?? BorderRadius.zero, - child: streamChatConfig.defaultUserImage(context, user), - ); - - Widget avatar = FittedBox( - fit: BoxFit.cover, - child: Container( - constraints: constraints ?? avatarTheme?.constraints, - child: LayoutBuilder( - builder: (context, constraints) { - final imageUrl = user.image; - if (imageUrl == null || imageUrl.isEmpty) { - return backupGradientAvatar; - } - - // Calculate optimal thumbnail size for the avatar - final devicePixelRatio = MediaQuery.devicePixelRatioOf(context); - final thumbnailSize = constraints.biggest * devicePixelRatio; - - int? cacheWidth, cacheHeight; - if (thumbnailSize.isFinite && !thumbnailSize.isEmpty) { - cacheWidth = thumbnailSize.width.round(); - cacheHeight = thumbnailSize.height.round(); - } - - return CachedNetworkImage( - fit: BoxFit.cover, - filterQuality: FilterQuality.high, - imageUrl: imageUrl, - errorWidget: (_, __, ___) => backupGradientAvatar, - placeholder: switch (effectivePlaceholder) { - final holder? => (context, __) => holder(context, user), - _ => null, - }, - imageBuilder: (context, imageProvider) => DecoratedBox( - decoration: BoxDecoration( - borderRadius: effectiveBorderRadius, - image: DecorationImage( - fit: BoxFit.cover, - image: ResizeImage( - imageProvider, - width: cacheWidth, - height: cacheHeight, - ), - ), - ), - ), - ); - }, - ), - ), - ); - - if (selected) { - avatar = ClipRRect( - borderRadius: (effectiveBorderRadius ?? BorderRadius.zero) + - BorderRadius.circular(selectionThickness), - child: Container( - constraints: constraints ?? avatarTheme?.constraints, - color: selectionColor ?? colorTheme.accentPrimary, - child: Padding( - padding: EdgeInsets.all(selectionThickness), - child: avatar, - ), - ), - ); - } - return GestureDetector( - onTap: onTap != null ? () => onTap!(user) : null, - onLongPress: onLongPress != null ? () => onLongPress!(user) : null, - child: Stack( - children: [ - avatar, - if (showOnlineStatus && user.online) - Positioned.fill( - child: Align( - alignment: onlineIndicatorAlignment, - child: Material( - type: MaterialType.circle, - color: colorTheme.barsBg, - child: Container( - margin: const EdgeInsets.all(2), - constraints: onlineIndicatorConstraints ?? - const BoxConstraints.tightFor( - width: 8, - height: 8, - ), - child: Material( - shape: const CircleBorder(), - color: colorTheme.accentInfo, - ), - ), - ), - ), - ), - ], - ), - ); - } -} diff --git a/packages/stream_chat_flutter/lib/src/bottom_sheets/attachment_modal_sheet.dart b/packages/stream_chat_flutter/lib/src/bottom_sheets/attachment_modal_sheet.dart deleted file mode 100644 index 7367e0201c..0000000000 --- a/packages/stream_chat_flutter/lib/src/bottom_sheets/attachment_modal_sheet.dart +++ /dev/null @@ -1,68 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:stream_chat_flutter/stream_chat_flutter.dart'; - -/// {@template attachmentModalSheet} -/// The modalBottomSheet that appears when a mobile user attempts to add -/// attachments to a chat. -/// -/// Should not be used on desktop or web. -/// {@endtemplate} -class AttachmentModalSheet extends StatelessWidget { - /// {@macro attachmentModalSheet} - const AttachmentModalSheet({ - super.key, - required this.onFileTap, - required this.onPhotoTap, - required this.onVideoTap, - }); - - /// The action to perform when the "file" button is tapped. - final VoidCallback onFileTap; - - /// The action to perform when the "photo" button is tapped. - final VoidCallback onPhotoTap; - - /// The action to perform when the "video" button is tapped. - final VoidCallback onVideoTap; - - @override - Widget build(BuildContext context) { - return Column( - mainAxisSize: MainAxisSize.min, - children: [ - ListTile( - title: Text( - context.translations.addAFileLabel, - style: const TextStyle( - fontWeight: FontWeight.bold, - ), - ), - ), - ListTile( - leading: const Icon(Icons.image), - title: Text(context.translations.uploadAPhotoLabel), - onTap: () { - onPhotoTap.call(); - Navigator.of(context).pop(); - }, - ), - ListTile( - leading: const Icon(Icons.video_library), - title: Text(context.translations.uploadAVideoLabel), - onTap: () { - onVideoTap.call(); - Navigator.of(context).pop(); - }, - ), - ListTile( - leading: const Icon(Icons.insert_drive_file), - title: Text(context.translations.uploadAFileLabel), - onTap: () { - onFileTap.call(); - Navigator.of(context).pop(); - }, - ), - ], - ); - } -} diff --git a/packages/stream_chat_flutter/lib/src/bottom_sheets/edit_message_sheet.dart b/packages/stream_chat_flutter/lib/src/bottom_sheets/edit_message_sheet.dart deleted file mode 100644 index b53db1f2cb..0000000000 --- a/packages/stream_chat_flutter/lib/src/bottom_sheets/edit_message_sheet.dart +++ /dev/null @@ -1,134 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:stream_chat_flutter/stream_chat_flutter.dart'; - -/// {@template showEditMessageSheet} -/// Displays an interactive modal bottom sheet to edit a message. -/// {@endtemplate} -Future showEditMessageSheet({ - required BuildContext context, - required Message message, - required Channel channel, - EditMessageInputBuilder? editMessageInputBuilder, -}) { - final messageInputTheme = StreamMessageInputTheme.of(context); - - return showModalBottomSheet( - context: context, - elevation: 2, - isScrollControlled: true, - clipBehavior: Clip.antiAlias, - backgroundColor: messageInputTheme.inputBackgroundColor, - shape: const RoundedRectangleBorder( - borderRadius: BorderRadius.only( - topLeft: Radius.circular(16), - topRight: Radius.circular(16), - ), - ), - builder: (context) => EditMessageSheet( - channel: channel, - message: message, - editMessageInputBuilder: editMessageInputBuilder, - ), - ); -} - -/// {@template editMessageSheet} -/// Allows a user to edit the selected message. -/// {@endtemplate} -class EditMessageSheet extends StatefulWidget { - /// {@macro editMessageSheet} - const EditMessageSheet({ - super.key, - required this.message, - required this.channel, - this.editMessageInputBuilder, - }); - - /// {@macro editMessageInputBuilder} - final EditMessageInputBuilder? editMessageInputBuilder; - - /// The message to edit. - final Message message; - - /// The [StreamChannel] above this widget. - final Channel channel; - - @override - State createState() => _EditMessageSheetState(); -} - -class _EditMessageSheetState extends State { - late final controller = StreamMessageInputController( - message: widget.message, - ); - - @override - void dispose() { - controller.dispose(); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - final streamChatThemeData = StreamChatTheme.of(context); - return KeyboardShortcutRunner( - onEscapeKeypress: () => Navigator.of(context).pop(), - child: Padding( - padding: MediaQuery.of(context).viewInsets, - child: StreamChannel( - channel: widget.channel, - child: Flex( - direction: Axis.vertical, - mainAxisAlignment: MainAxisAlignment.end, - mainAxisSize: MainAxisSize.min, - children: [ - Padding( - padding: const EdgeInsets.fromLTRB(8, 8, 8, 0), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Padding( - padding: const EdgeInsets.all(8), - child: StreamSvgIcon( - icon: StreamSvgIcons.edit, - color: streamChatThemeData.colorTheme.disabled, - ), - ), - Text( - context.translations.editMessageLabel, - style: const TextStyle(fontWeight: FontWeight.bold), - ), - IconButton( - visualDensity: VisualDensity.compact, - icon: StreamSvgIcon( - icon: StreamSvgIcons.closeSmall, - color: streamChatThemeData.colorTheme.textLowEmphasis, - ), - onPressed: Navigator.of(context).pop, - ), - ], - ), - ), - if (widget.editMessageInputBuilder != null) - widget.editMessageInputBuilder!(context, widget.message) - else - StreamMessageInput( - elevation: 0, - messageInputController: controller, - // Disallow editing poll for now as it's not supported. - allowedAttachmentPickerTypes: [ - ...AttachmentPickerType.values, - ]..remove(AttachmentPickerType.poll), - preMessageSending: (m) { - FocusScope.of(context).unfocus(); - Navigator.of(context).pop(); - return m; - }, - ), - ], - ), - ), - ), - ); - } -} diff --git a/packages/stream_chat_flutter/lib/src/bottom_sheets/stream_channel_info_bottom_sheet.dart b/packages/stream_chat_flutter/lib/src/bottom_sheets/stream_channel_info_bottom_sheet.dart deleted file mode 100644 index c1cb4ad060..0000000000 --- a/packages/stream_chat_flutter/lib/src/bottom_sheets/stream_channel_info_bottom_sheet.dart +++ /dev/null @@ -1,359 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:stream_chat_flutter/stream_chat_flutter.dart'; - -/// A [BottomSheet] that shows information about a [Channel]. -class StreamChannelInfoBottomSheet extends StatelessWidget { - /// Creates a new instance [StreamChannelInfoBottomSheet] widget. - StreamChannelInfoBottomSheet({ - super.key, - required this.channel, - this.onMemberTap, - this.onViewInfoTap, - this.onLeaveChannelTap, - this.onDeleteConversationTap, - this.onCancelTap, - }) : assert( - channel.state != null, - 'Channel ${channel.id} is not initialized', - ); - - /// The [Channel] to show information about. - final Channel channel; - - /// A callback that is called when a member is tapped. - final void Function(Member)? onMemberTap; - - /// A callback that is called when the "View Info" button is tapped. - final VoidCallback? onViewInfoTap; - - /// A callback that is called when the "Leave Channel" button is tapped. - /// - /// Only shown when the channel is a group channel. - final VoidCallback? onLeaveChannelTap; - - /// A callback that is called when the "Delete Conversation" button is tapped. - /// - /// Only shown when you are the `owner` of the channel. - final VoidCallback? onDeleteConversationTap; - - /// A callback that is called when the "Cancel" button is tapped. - final VoidCallback? onCancelTap; - - @override - Widget build(BuildContext context) { - final themeData = StreamChatTheme.of(context); - final colorTheme = themeData.colorTheme; - final channelPreviewTheme = StreamChannelPreviewTheme.of(context); - - final currentUser = channel.client.state.currentUser; - final isOneToOneChannel = channel.isDistinct && channel.memberCount == 2; - - final members = channel.state?.members ?? []; - - // remove current user in case it's 1-1 conversation - if (isOneToOneChannel) { - members.removeWhere((it) => it.user?.id == currentUser?.id); - } - - return Column( - mainAxisSize: MainAxisSize.min, - children: [ - const SizedBox(height: 24), - Center( - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 16), - child: StreamChannelName( - channel: channel, - textStyle: themeData.textTheme.headlineBold, - ), - ), - ), - const SizedBox(height: 5), - Center( - // TODO: Refactor ChannelInfo - child: StreamChannelInfo( - showTypingIndicator: false, - channel: channel, - textStyle: channelPreviewTheme.subtitleStyle, - ), - ), - const SizedBox(height: 17), - Container( - height: 94, - alignment: Alignment.center, - child: ListView.separated( - shrinkWrap: true, - itemCount: members.length, - scrollDirection: Axis.horizontal, - padding: const EdgeInsets.symmetric(horizontal: 8), - separatorBuilder: (context, index) => const SizedBox(width: 16), - itemBuilder: (context, index) { - final member = members[index]; - final user = member.user!; - return Column( - mainAxisSize: MainAxisSize.min, - mainAxisAlignment: MainAxisAlignment.center, - children: [ - StreamUserAvatar( - user: user, - constraints: const BoxConstraints.tightFor( - height: 64, - width: 64, - ), - borderRadius: BorderRadius.circular(32), - onlineIndicatorConstraints: BoxConstraints.tight( - const Size(12, 12), - ), - onTap: onMemberTap != null - ? (_) => onMemberTap!(member) - : null, - ), - const SizedBox(height: 6), - Text( - user.name, - style: themeData.textTheme.footnoteBold, - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - ], - ); - }, - ), - ), - const SizedBox(height: 24), - StreamOptionListTile( - leading: Padding( - padding: const EdgeInsets.symmetric(horizontal: 16), - child: StreamSvgIcon( - icon: StreamSvgIcons.user, - color: colorTheme.textLowEmphasis, - ), - ), - title: context.translations.viewInfoLabel, - onTap: onViewInfoTap, - ), - if (!isOneToOneChannel) - StreamOptionListTile( - title: context.translations.leaveGroupLabel, - leading: Padding( - padding: const EdgeInsets.symmetric(horizontal: 16), - child: StreamSvgIcon( - icon: StreamSvgIcons.userRemove, - color: colorTheme.textLowEmphasis, - ), - ), - onTap: onLeaveChannelTap, - ), - if (channel.canDeleteChannel) - StreamOptionListTile( - leading: Padding( - padding: const EdgeInsets.symmetric(horizontal: 16), - child: StreamSvgIcon( - icon: StreamSvgIcons.delete, - color: colorTheme.accentError, - ), - ), - title: context.translations.deleteConversationLabel, - titleColor: colorTheme.accentError, - onTap: onDeleteConversationTap, - ), - StreamOptionListTile( - leading: Padding( - padding: const EdgeInsets.symmetric(horizontal: 16), - child: StreamSvgIcon( - icon: StreamSvgIcons.closeSmall, - color: colorTheme.textLowEmphasis, - ), - ), - title: context.translations.cancelLabel, - onTap: onCancelTap ?? Navigator.of(context).pop, - ), - ], - ); - } -} - -const _kDefaultChannelInfoBottomSheetShape = RoundedRectangleBorder( - borderRadius: BorderRadius.only( - topLeft: Radius.circular(32), - topRight: Radius.circular(32), - ), -); - -/// Shows a modal material design bottom sheet. -/// -/// A modal bottom sheet is an alternative to a menu or a dialog and prevents -/// the user from interacting with the rest of the app. -/// -/// A closely related widget is a persistent bottom sheet, which shows -/// information that supplements the primary content of the app without -/// preventing the use from interacting with the app. Persistent bottom sheets -/// can be created and displayed with the [showBottomSheet] function or the -/// [ScaffoldState.showBottomSheet] method. -/// -/// The `context` argument is used to look up the [Navigator] and [Theme] for -/// the bottom sheet. It is only used when the method is called. Its -/// corresponding widget can be safely removed from the tree before the bottom -/// sheet is closed. -/// -/// The `isScrollControlled` parameter specifies whether this is a route for -/// a bottom sheet that will utilize [DraggableScrollableSheet]. If you wish -/// to have a bottom sheet that has a scrollable child such as a [ListView] or -/// a [GridView] and have the bottom sheet be draggable, you should set this -/// parameter to true. -/// -/// The `useRootNavigator` parameter ensures that the root navigator is used to -/// display the [BottomSheet] when set to `true`. This is useful in the case -/// that a modal [BottomSheet] needs to be displayed above all other content -/// but the caller is inside another [Navigator]. -/// -/// The [isDismissible] parameter specifies whether the bottom sheet will be -/// dismissed when user taps on the scrim. -/// -/// The [enableDrag] parameter specifies whether the bottom sheet can be -/// dragged up and down and dismissed by swiping downwards. -/// -/// The optional [backgroundColor], [elevation], [shape], [clipBehavior], -/// [constraints] and [transitionAnimationController] -/// parameters can be passed in to customize the appearance and behavior of -/// modal bottom sheets (see the documentation for these on [BottomSheet] -/// for more details). -/// -/// The [transitionAnimationController] controls the bottom sheet's entrance and -/// exit animations if provided. -/// -/// The optional `routeSettings` parameter sets the [RouteSettings] -/// of the modal bottom sheet sheet. -/// This is particularly useful in the case that a user wants to observe -/// [PopupRoute]s within a [NavigatorObserver]. -/// -/// Returns a `Future` that resolves to the value (if any) that was passed to -/// [Navigator.pop] when the modal bottom sheet was closed. -/// -/// See also: -/// -/// * [BottomSheet], which becomes the parent of the widget returned by the -/// function passed as the `builder` argument to [showModalBottomSheet]. -/// * [showBottomSheet] and [ScaffoldState.showBottomSheet], for showing -/// non-modal bottom sheets. -/// * [DraggableScrollableSheet], which allows you to create a bottom sheet -/// that grows and then becomes scrollable once it reaches its maximum size. -/// * -Future showChannelInfoModalBottomSheet({ - required BuildContext context, - required Channel channel, - Color? backgroundColor, - double? elevation, - BoxConstraints? constraints, - Color? barrierColor, - bool isScrollControlled = true, - bool useRootNavigator = false, - bool isDismissible = true, - bool enableDrag = true, - RouteSettings? routeSettings, - AnimationController? transitionAnimationController, - Clip? clipBehavior = Clip.hardEdge, - ShapeBorder? shape = _kDefaultChannelInfoBottomSheetShape, - void Function(Member)? onMemberTap, - VoidCallback? onViewInfoTap, - VoidCallback? onLeaveChannelTap, - VoidCallback? onDeleteConversationTap, - VoidCallback? onCancelTap, -}) => - showModalBottomSheet( - context: context, - backgroundColor: backgroundColor, - elevation: elevation, - shape: shape, - clipBehavior: clipBehavior, - constraints: constraints, - barrierColor: barrierColor, - isScrollControlled: isScrollControlled, - useRootNavigator: useRootNavigator, - isDismissible: isDismissible, - enableDrag: enableDrag, - routeSettings: routeSettings, - transitionAnimationController: transitionAnimationController, - builder: (BuildContext context) => StreamChannelInfoBottomSheet( - channel: channel, - onMemberTap: onMemberTap, - onViewInfoTap: onViewInfoTap, - onLeaveChannelTap: onLeaveChannelTap, - onDeleteConversationTap: onDeleteConversationTap, - onCancelTap: onCancelTap, - ), - ); - -/// Shows a material design bottom sheet in the nearest [Scaffold] ancestor. If -/// you wish to show a persistent bottom sheet, use [Scaffold.bottomSheet]. -/// -/// Returns a controller that can be used to close and otherwise manipulate the -/// bottom sheet. -/// -/// The optional [backgroundColor], [elevation], [shape], [clipBehavior], -/// [constraints] and [transitionAnimationController] -/// parameters can be passed in to customize the appearance and behavior of -/// persistent bottom sheets (see the documentation for these on [BottomSheet] -/// for more details). -/// -/// To rebuild the bottom sheet (e.g. if it is stateful), call -/// [PersistentBottomSheetController.setState] on the controller returned by -/// this method. -/// -/// The new bottom sheet becomes a [LocalHistoryEntry] for the enclosing -/// [ModalRoute] and a back button is added to the app bar of the [Scaffold] -/// that closes the bottom sheet. -/// -/// To create a persistent bottom sheet that is not a [LocalHistoryEntry] and -/// does not add a back button to the enclosing Scaffold's app bar, use the -/// [Scaffold.bottomSheet] constructor parameter. -/// -/// A closely related widget is a modal bottom sheet, which is an alternative -/// to a menu or a dialog and prevents the user from interacting with the rest -/// of the app. Modal bottom sheets can be created and displayed with the -/// [showModalBottomSheet] function. -/// -/// The `context` argument is used to look up the [Scaffold] for the bottom -/// sheet. It is only used when the method is called. Its corresponding widget -/// can be safely removed from the tree before the bottom sheet is closed. -/// -/// See also: -/// -/// * [BottomSheet], which becomes the parent of the widget returned by the -/// `builder`. -/// * [showModalBottomSheet], which can be used to display a modal bottom -/// sheet. -/// * [Scaffold.of], for information about how to obtain the [BuildContext]. -/// * -PersistentBottomSheetController showChannelInfoBottomSheet({ - required BuildContext context, - required Channel channel, - Color? backgroundColor, - double? elevation, - BoxConstraints? constraints, - AnimationController? transitionAnimationController, - Clip? clipBehavior = Clip.hardEdge, - ShapeBorder? shape = _kDefaultChannelInfoBottomSheetShape, - void Function(Member)? onMemberTap, - VoidCallback? onViewInfoTap, - VoidCallback? onLeaveChannelTap, - VoidCallback? onDeleteConversationTap, - VoidCallback? onCancelTap, -}) => - showBottomSheet( - context: context, - backgroundColor: backgroundColor, - elevation: elevation, - shape: shape, - clipBehavior: clipBehavior, - constraints: constraints, - transitionAnimationController: transitionAnimationController, - builder: (BuildContext context) => StreamChannelInfoBottomSheet( - channel: channel, - onMemberTap: onMemberTap, - onViewInfoTap: onViewInfoTap, - onLeaveChannelTap: onLeaveChannelTap, - onDeleteConversationTap: onDeleteConversationTap, - onCancelTap: onCancelTap, - ), - ); diff --git a/packages/stream_chat_flutter/lib/src/channel/channel_header.dart b/packages/stream_chat_flutter/lib/src/channel/channel_header.dart index 20dd2d5ae3..3d1fb1e3e5 100644 --- a/packages/stream_chat_flutter/lib/src/channel/channel_header.dart +++ b/packages/stream_chat_flutter/lib/src/channel/channel_header.dart @@ -1,234 +1,229 @@ import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; +import 'package:flutter_portal/flutter_portal.dart'; import 'package:stream_chat_flutter/stream_chat_flutter.dart'; /// {@template streamChannelHeader} -/// ![screenshot](https://raw.githubusercontent.com/GetStream/stream-chat-flutter/master/packages/stream_chat_flutter/screenshots/channel_header.png) -/// ![screenshot](https://raw.githubusercontent.com/GetStream/stream-chat-flutter/master/packages/stream_chat_flutter/screenshots/channel_header_paint.png) +/// A top-of-screen header for a single channel. /// -/// Shows information about the current [Channel]. +/// [StreamChannelHeader] renders a [StreamAppBar] whose default title is +/// the channel's name (via [StreamChannelName]) and whose default subtitle +/// is the channel's typing / member status (via [StreamChannelInfo]). /// -/// ```dart -/// class MyApp extends StatelessWidget { -/// final StreamChatClient client; -/// final Channel channel; +/// The default leading is a [StreamBackButton] that pops the route when +/// tapped, gated by [automaticallyImplyLeading] — set it to `false` to +/// suppress the default, or pass [leading] to replace it entirely. +/// +/// The default trailing is the channel avatar (via [StreamChannelAvatar]). +/// Tap behaviour is wired through [onChannelAvatarPressed]; when the +/// callback is null the avatar is rendered non-interactive. Pass [trailing] +/// to replace the avatar with a custom action — the callback is then +/// ignored. +/// +/// A [StreamChannel] ancestor is required so the title and subtitle can +/// observe the channel's stream of updates. When [showConnectionStateTile] +/// is true, a [StreamInfoTile] banner is rendered above the bar while the +/// client is reconnecting or offline. +/// +/// [StreamChannelHeader] implements [PreferredSizeWidget] so it can be +/// passed directly to [Scaffold.appBar]. +/// +/// {@tool snippet} /// -/// MyApp(this.client, this.channel); +/// Basic usage as a [Scaffold.appBar] — the back button and channel +/// avatar are auto-populated from the enclosing [StreamChannel]: /// -/// @override -/// Widget build(BuildContext context) { -/// return MaterialApp( -/// home: StreamChat( -/// client: client, -/// child: StreamChannel( -/// channel: channel, -/// child: Scaffold( -/// appBar: ChannelHeader(), -/// ), -/// ), -/// ), -/// ); -/// } -/// } +/// ```dart +/// Scaffold( +/// appBar: const StreamChannelHeader(), +/// body: const StreamMessageListView(), +/// ) /// ``` +/// {@end-tool} +/// +/// {@tool snippet} +/// +/// With a tap handler that opens a channel-detail screen: /// -/// Usually you would use this widget as an [AppBar] inside a [Scaffold]. -/// However, you can also use it as a normal widget. +/// ```dart +/// StreamChannelHeader( +/// onChannelAvatarPressed: (channel) => GoRouter.of(context).pushNamed( +/// 'channel-detail', +/// extra: channel, +/// ), +/// ) +/// ``` +/// {@end-tool} /// -/// Make sure to have a [StreamChannel] ancestor in order to provide the -/// information about the channel. +/// ## Theming /// -/// Every part of the widget uses a [StreamBuilder] to render the channel -/// information as soon as it updates. +/// [StreamChannelHeader] reads its chrome (background, padding, typography, +/// divider) from [StreamChatThemeData.channelHeaderTheme], which is a +/// [StreamAppBarThemeData]. Per-instance overrides go on [style]. /// -/// By default the widget shows a backButton that calls [Navigator.pop]. -/// You can disable this button using the [showBackButton] property. -/// Alternatively, you can override this behaviour via the [onBackPressed] -/// callback. +/// See also: /// -/// The UI is rendered based on the first ancestor of type [StreamChatTheme] -/// and the [StreamChatThemeData.channelHeaderTheme] property. Modify it to -/// change the widget's appearance. +/// * [StreamAppBar], the underlying app bar component. +/// * [StreamAppBarThemeData], for customizing appearance globally. +/// * [StreamChannelListHeader], the equivalent header for the channel +/// list. +/// * [StreamThreadHeader], the equivalent header for a thread. /// {@endtemplate} -class StreamChannelHeader extends StatelessWidget - implements PreferredSizeWidget { +class StreamChannelHeader extends StatelessWidget implements PreferredSizeWidget { /// {@macro streamChannelHeader} const StreamChannelHeader({ super.key, - this.showBackButton = true, - this.onBackPressed, - this.onTitleTap, - this.showTypingIndicator = true, - this.onImageTap, + this.onChannelAvatarPressed, this.showConnectionStateTile = false, + this.leading, + this.automaticallyImplyLeading = true, this.title, this.subtitle, - this.centerTitle, - this.leading, - this.actions, - this.bottom, - this.backgroundColor, - this.elevation = 1, - this.bottomOpacity = 1, + this.trailing, + this.primary = true, + this.style, }); - /// Whether to show the leading back button + /// Called when the default channel-avatar trailing is pressed. /// - /// Defaults to `true` - final bool showBackButton; - - /// The action to perform when the back button is pressed. - /// - /// By default it calls [Navigator.pop] - final VoidCallback? onBackPressed; - - /// The action to perform when the header is tapped. - final VoidCallback? onTitleTap; + /// Ignored when [trailing] is provided. When null, the avatar is rendered + /// non-interactive. + final void Function(Channel channel)? onChannelAvatarPressed; - /// The action to perform when the image is tapped. - final VoidCallback? onImageTap; + /// Whether to show the connection-state banner above the bar. + final bool showConnectionStateTile; - /// Whether to show the typing indicator + /// {@macro StreamAppBar.leading} /// - /// Defaults to `true` - final bool showTypingIndicator; + /// Defaults to a [StreamBackButton] when [automaticallyImplyLeading] is + /// `true`. + final Widget? leading; - /// Whether to show the connection state tile - final bool showConnectionStateTile; + /// Whether to render a default [StreamBackButton] as the leading when + /// [leading] is null. + /// + /// Defaults to `true`. Set to `false` to suppress the back button. + final bool automaticallyImplyLeading; - /// Title widget + /// {@macro StreamAppBar.title} + /// + /// Defaults to a [StreamChannelName] for the enclosing channel. final Widget? title; - /// Subtitle widget + /// {@macro StreamAppBar.subtitle} + /// + /// Defaults to a [StreamChannelInfo] showing typing / member status. final Widget? subtitle; - /// Whether the title should be centered - final bool? centerTitle; - - /// Leading widget - final Widget? leading; - - /// The bottom widget - final PreferredSizeWidget? bottom; - - /// {@macro flutter.material.appbar.actions} + /// {@macro StreamAppBar.trailing} /// - /// The [StreamChannelAvatar] is shown by default - final List? actions; - - /// The background color for this [StreamChannelHeader]. - final Color? backgroundColor; + /// Defaults to a [StreamChannelAvatar] for the enclosing channel wired to + /// [onChannelAvatarPressed]. + final Widget? trailing; - /// The elevation for this [StreamChannelHeader]. - final double elevation; + /// {@macro StreamAppBar.primary} + final bool primary; - /// The opacity of the bottom widget. - final double bottomOpacity; + /// {@macro StreamAppBar.style} + /// + /// Per-instance override; merges over + /// [StreamChatThemeData.channelHeaderTheme]. + final StreamAppBarStyle? style; @override - Size get preferredSize { - final bottomHeight = bottom?.preferredSize.height ?? 0; - return Size.fromHeight(kToolbarHeight + bottomHeight); - } + Size get preferredSize => const Size.fromHeight(kStreamToolbarHeight); @override Widget build(BuildContext context) { - final effectiveCenterTitle = getEffectiveCenterTitle( - Theme.of(context), - actions: actions, - centerTitle: centerTitle, - ); final channel = StreamChannel.of(context).channel; - final channelHeaderTheme = StreamChannelHeaderTheme.of(context); - - final leadingWidget = leading ?? - (showBackButton - ? StreamBackButton( - onPressed: onBackPressed, - showUnreadCount: true, - ) - : const SizedBox()); - - return StreamConnectionStatusBuilder( - statusBuilder: (context, status) { - var statusString = ''; - var showStatus = true; - - switch (status) { - case ConnectionStatus.connected: - statusString = context.translations.connectedLabel; - showStatus = false; - break; - case ConnectionStatus.connecting: - statusString = context.translations.reconnectingLabel; - break; - case ConnectionStatus.disconnected: - statusString = context.translations.disconnectedLabel; - break; - } - - final theme = Theme.of(context); - - return StreamInfoTile( - showMessage: showConnectionStateTile && showStatus, - message: statusString, - child: AppBar( - toolbarTextStyle: theme.textTheme.bodyMedium, - titleTextStyle: theme.textTheme.titleLarge, - systemOverlayStyle: theme.brightness == Brightness.dark - ? SystemUiOverlayStyle.light - : SystemUiOverlayStyle.dark, - elevation: elevation, - leading: leadingWidget, - bottom: bottom, - bottomOpacity: bottomOpacity, - backgroundColor: backgroundColor ?? channelHeaderTheme.color, - actions: actions ?? - [ - Padding( - padding: const EdgeInsets.only(right: 10), - child: Center( - child: StreamChannelAvatar( - channel: channel, - borderRadius: - channelHeaderTheme.avatarTheme?.borderRadius, - constraints: - channelHeaderTheme.avatarTheme?.constraints, - onTap: onImageTap, - ), - ), - ), - ], - centerTitle: centerTitle, - title: InkWell( - onTap: onTitleTap, - child: SizedBox( - height: preferredSize.height, - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - crossAxisAlignment: effectiveCenterTitle - ? CrossAxisAlignment.center - : CrossAxisAlignment.stretch, - children: [ - title ?? - StreamChannelName( - channel: channel, - textStyle: channelHeaderTheme.titleStyle, - ), - const SizedBox(height: 2), - subtitle ?? - StreamChannelInfo( - showTypingIndicator: showTypingIndicator, - channel: channel, - textStyle: channelHeaderTheme.subtitleStyle, - ), - ], - ), + final headerTheme = StreamChatTheme.of(context).channelHeaderTheme; + + var leading = this.leading; + if (leading == null && automaticallyImplyLeading) { + leading = const StreamBackButton(showUnreadCount: true); + } + + var title = this.title; + title ??= StreamChannelName(channel: channel); + + var subtitle = this.subtitle; + subtitle ??= StreamChannelInfo(channel: channel); + + var trailing = this.trailing; + trailing ??= _DefaultChannelAvatar(channel: channel, onPressed: onChannelAvatarPressed); + + return Portal( + child: StreamConnectionStatusBuilder( + statusBuilder: (context, status) { + var statusString = ''; + var showStatus = true; + + switch (status) { + case ConnectionStatus.connected: + statusString = context.translations.connectedLabel; + showStatus = false; + break; + case ConnectionStatus.connecting: + statusString = context.translations.reconnectingLabel; + break; + case ConnectionStatus.disconnected: + statusString = context.translations.disconnectedLabel; + break; + } + + return StreamInfoTile( + showMessage: showConnectionStateTile && showStatus, + message: statusString, + // Wrap the bar in a [StreamAppBarTheme] so the per-header chat + // theme drives all default styling (background, padding, + // typography, divider) — the bar internally merges in any + // [style] override the caller passed. + child: StreamAppBarTheme( + data: headerTheme, + child: StreamAppBar( + leading: leading, + automaticallyImplyLeading: false, + title: title, + subtitle: subtitle, + trailing: trailing, + primary: primary, + style: style, ), ), + ); + }, + ), + ); + } +} + +class _DefaultChannelAvatar extends StatelessWidget { + const _DefaultChannelAvatar({required this.channel, this.onPressed}); + + final Channel channel; + final void Function(Channel channel)? onPressed; + + @override + Widget build(BuildContext context) { + final effectiveOnTap = switch (onPressed) { + final cb? => () => cb(channel), + _ => null, + }; + + // Match the 48×48 tap target StreamAppBar's auto-implied leading uses + // (StreamButton.icon medium = 40 visible + Material padded tap target), + // so the avatar slot sizes and hit-tests consistently with other bars. + return SizedBox.square( + dimension: 48, + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: effectiveOnTap, + child: Center( + child: StreamChannelAvatar( + size: .lg, + channel: channel, ), - ); - }, + ), + ), ); } } diff --git a/packages/stream_chat_flutter/lib/src/channel/channel_info.dart b/packages/stream_chat_flutter/lib/src/channel/channel_info.dart index d74d26673b..5a4e1396f7 100644 --- a/packages/stream_chat_flutter/lib/src/channel/channel_info.dart +++ b/packages/stream_chat_flutter/lib/src/channel/channel_info.dart @@ -33,6 +33,12 @@ class StreamChannelInfo extends StatelessWidget { @override Widget build(BuildContext context) { final client = StreamChat.of(context).client; + final effectiveTextStyle = + textStyle ?? + context.streamTextTheme.captionDefault.copyWith( + color: context.streamColorScheme.textSecondary, + ); + return BetterStreamBuilder>( stream: channel.state!.membersStream, initialData: channel.state!.members, @@ -43,16 +49,16 @@ class StreamChannelInfo extends StatelessWidget { return _ConnectedTitleState( channel: channel, showTypingIndicator: showTypingIndicator, - textStyle: textStyle, + textStyle: effectiveTextStyle, members: data, parentId: parentId, ); case ConnectionStatus.connecting: - return _ConnectingTitleState(textStyle: textStyle); + return _ConnectingTitleState(textStyle: effectiveTextStyle); case ConnectionStatus.disconnected: return _DisconnectedTitleState( client: client, - textStyle: textStyle, + textStyle: effectiveTextStyle, ); } }, @@ -80,18 +86,12 @@ class _ConnectedTitleState extends StatelessWidget { Widget build(BuildContext context) { Widget? alternativeWidget; + final translations = context.translations; final memberCount = channel.memberCount; if (memberCount != null && memberCount > 2) { - var text = context.translations.membersCountText(memberCount); - final onlineCount = - members?.where((m) => m.user?.online == true).length ?? 0; - if (onlineCount > 0) { - text += ', ${context.translations.watchersCountText(onlineCount)}'; - } - alternativeWidget = Text( - text, - style: StreamChannelHeaderTheme.of(context).subtitleStyle, - ); + final onlineCount = members?.where((m) => m.user?.online == true).length ?? 0; + final text = translations.membersCountWithOnlineText(memberCount: memberCount, onlineCount: onlineCount); + alternativeWidget = Text(text, style: textStyle); } else { final userId = StreamChat.of(context).currentUser?.id; final otherMember = members?.firstWhereOrNull( @@ -100,14 +100,11 @@ class _ConnectedTitleState extends StatelessWidget { if (otherMember != null) { if (otherMember.user?.online == true) { - alternativeWidget = Text( - context.translations.userOnlineText, - style: textStyle, - ); + alternativeWidget = Text(translations.userOnlineText, style: textStyle); } else { final lastActive = otherMember.user?.lastActive ?? DateTime.now(); alternativeWidget = Text( - '${context.translations.userLastOnlineText} ' + '${translations.userLastOnlineText} ' '${Jiffy.parseFromDateTime(lastActive).fromNow()}', style: textStyle, ); diff --git a/packages/stream_chat_flutter/lib/src/channel/channel_list_header.dart b/packages/stream_chat_flutter/lib/src/channel/channel_list_header.dart index 2c3338425e..e4ed899c7b 100644 --- a/packages/stream_chat_flutter/lib/src/channel/channel_list_header.dart +++ b/packages/stream_chat_flutter/lib/src/channel/channel_list_header.dart @@ -1,210 +1,219 @@ import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; -import 'package:stream_chat_flutter/src/misc/empty_widget.dart'; +import 'package:flutter_portal/flutter_portal.dart'; import 'package:stream_chat_flutter/stream_chat_flutter.dart'; /// {@template streamChannelListHeader} -/// Shows the current [StreamChatClient] status. +/// A top-of-screen header for the channel list, surfacing the current +/// [StreamChatClient] connection status. +/// +/// [StreamChannelListHeader] renders a [StreamAppBar] whose default title +/// reflects the connection state — _Stream Chat_ when connected, a +/// loading spinner + _Searching for network…_ when connecting, and an +/// _Offline_ label with a _try again_ affordance when disconnected. +/// +/// The leading slot is always the signed-in user's avatar. Tap behaviour +/// is wired through [onUserAvatarPressed]; when the callback is null the +/// avatar mirrors Material [AppBar]'s auto-implied leading by opening the +/// enclosing [Scaffold]'s drawer if one exists, and is otherwise rendered +/// non-interactive. +/// +/// The trailing slot is empty by default — pass [trailing] to wire up an +/// action such as a _new chat_ button. +/// +/// When [showConnectionStateTile] is true, a [StreamInfoTile] banner is +/// rendered above the bar while the client is reconnecting or offline. +/// +/// [StreamChannelListHeader] implements [PreferredSizeWidget] so it can +/// be passed directly to [Scaffold.appBar]. +/// +/// {@tool snippet} +/// +/// Basic usage as a [Scaffold.appBar] — the avatar opens the [Scaffold]'s +/// drawer automatically when one is provided: /// /// ```dart -/// class MyApp extends StatelessWidget { -/// final StreamChatClient client; +/// Scaffold( +/// appBar: const StreamChannelListHeader(), +/// drawer: MyDrawer(user: currentUser), +/// body: const StreamChannelListView(), +/// ) +/// ``` +/// {@end-tool} +/// +/// {@tool snippet} /// -/// MyApp(this.client); +/// With a custom avatar tap and a trailing _new chat_ button: /// -/// @override -/// Widget build(BuildContext context) { -/// return MaterialApp( -/// home: StreamChat( -/// client: client, -/// child: Scaffold( -/// appBar: ChannelListHeader(), -/// ), -/// ), -/// ); -/// } -/// } +/// ```dart +/// StreamChannelListHeader( +/// onUserAvatarPressed: (user) => showProfile(context, user), +/// trailing: StreamButton.icon( +/// icon: Icon(context.streamIcons.plus), +/// onPressed: () => GoRouter.of(context).pushNamed('new-chat'), +/// ), +/// ) /// ``` +/// {@end-tool} +/// +/// ## Theming /// -/// Usually you would use this widget as an [AppBar] inside a [Scaffold]. -/// However, you can also use it as a normal widget. +/// [StreamChannelListHeader] reads its chrome (background, padding, +/// typography, divider) from [StreamChatThemeData.channelListHeaderTheme], +/// which is a [StreamAppBarThemeData]. Per-instance overrides go on +/// [style]. /// -/// Uses the inherited [StreamChatClient], by default, to fetch information -/// about the status of the [client]. You can also pass your own -/// [StreamChatClient] if you don't have it in the widget tree. +/// See also: /// -/// Renders the UI based on the first ancestor of type [StreamChatTheme] and -/// the [StreamChannelListHeaderThemeData] property. Modify it to change the -/// widget's appearance. +/// * [StreamAppBar], the underlying app bar component. +/// * [StreamAppBarThemeData], for customizing appearance globally. +/// * [StreamChannelHeader], the equivalent header for a single channel. /// {@endtemplate} -class StreamChannelListHeader extends StatelessWidget - implements PreferredSizeWidget { +class StreamChannelListHeader extends StatelessWidget implements PreferredSizeWidget { /// {@macro streamChannelListHeader} const StreamChannelListHeader({ super.key, this.client, - this.titleBuilder, - this.onUserAvatarTap, - this.onNewChatButtonTap, + this.onUserAvatarPressed, this.showConnectionStateTile = false, - this.preNavigationCallback, + this.title, this.subtitle, - this.centerTitle, - this.leading, - this.actions, - this.backgroundColor, - this.elevation = 1, + this.trailing, + this.primary = true, + this.style, }); /// Use this if you don't have a [StreamChatClient] in your widget tree. final StreamChatClient? client; - /// {@macro channelListHeaderTitleBuilder} - final ChannelListHeaderTitleBuilder? titleBuilder; - - /// The action to perform when pressing the user avatar button. + /// Called when the user-avatar leading is pressed. /// - /// By default it calls `Scaffold.of(context).openDrawer()`. - final Function(User)? onUserAvatarTap; - - /// The action to perform when pressing the "new chat" button. - final VoidCallback? onNewChatButtonTap; + /// When null, the avatar opens the enclosing [Scaffold]'s drawer if one + /// exists (matching Material [AppBar]); otherwise it's rendered + /// non-interactive. + final void Function(User user)? onUserAvatarPressed; - /// Whether to show the connection state tile + /// Whether to show the connection-state banner above the bar. final bool showConnectionStateTile; - /// The function to execute before navigation is performed - final VoidCallback? preNavigationCallback; + /// {@macro StreamAppBar.title} + /// + /// Defaults to a connection-state-aware title — see the class docs. + final Widget? title; - /// Subtitle widget + /// {@macro StreamAppBar.subtitle} final Widget? subtitle; - /// Whether the title should be centered - final bool? centerTitle; - - /// Leading widget - /// - /// By default it shows the logged in user's avatar - final Widget? leading; - - /// {@macro flutter.material.appbar.actions} + /// {@macro StreamAppBar.trailing} /// - /// The "new chat" button is shown by default. - final List? actions; + /// No default — pass a widget to wire up an action. + final Widget? trailing; - /// The background color for this [StreamChannelListHeader]. - final Color? backgroundColor; + /// {@macro StreamAppBar.primary} + final bool primary; - /// The elevation for this [StreamChannelListHeader]. - final double elevation; + /// {@macro StreamAppBar.style} + /// + /// Per-instance override; merges over + /// [StreamChatThemeData.channelListHeaderTheme]. + final StreamAppBarStyle? style; @override - Size get preferredSize => const Size.fromHeight(kToolbarHeight); + Size get preferredSize => const Size.fromHeight(kStreamToolbarHeight); @override Widget build(BuildContext context) { final _client = client ?? StreamChat.of(context).client; - final user = _client.state.currentUser; - return StreamConnectionStatusBuilder( - statusBuilder: (context, status) { - var statusString = ''; - var showStatus = true; + final headerTheme = StreamChatTheme.of(context).channelListHeaderTheme; + + final leading = _DefaultUserAvatar(client: _client, onPressed: onUserAvatarPressed); + + return Portal( + child: StreamConnectionStatusBuilder( + statusBuilder: (context, status) { + var statusString = ''; + var showStatus = true; - switch (status) { - case ConnectionStatus.connected: - statusString = context.translations.connectedLabel; - showStatus = false; - break; - case ConnectionStatus.connecting: - statusString = context.translations.reconnectingLabel; - break; - case ConnectionStatus.disconnected: - statusString = context.translations.disconnectedLabel; - break; - } + switch (status) { + case ConnectionStatus.connected: + statusString = context.translations.connectedLabel; + showStatus = false; + break; + case ConnectionStatus.connecting: + statusString = context.translations.reconnectingLabel; + break; + case ConnectionStatus.disconnected: + statusString = context.translations.disconnectedLabel; + break; + } - final chatThemeData = StreamChatTheme.of(context); - final channelListHeaderThemeData = - StreamChannelListHeaderTheme.of(context); - final theme = Theme.of(context); - return StreamInfoTile( - showMessage: showConnectionStateTile && showStatus, - message: statusString, - child: AppBar( - toolbarTextStyle: theme.textTheme.bodyMedium, - titleTextStyle: theme.textTheme.titleLarge, - systemOverlayStyle: theme.brightness == Brightness.dark - ? SystemUiOverlayStyle.light - : SystemUiOverlayStyle.dark, - elevation: elevation, - backgroundColor: - backgroundColor ?? channelListHeaderThemeData.color, - centerTitle: centerTitle, - leading: leading ?? - Center( - child: user != null - ? StreamUserAvatar( - user: user, - showOnlineStatus: false, - onTap: onUserAvatarTap ?? - (_) { - preNavigationCallback?.call(); - Scaffold.of(context).openDrawer(); - }, - borderRadius: channelListHeaderThemeData - .avatarTheme?.borderRadius, - constraints: channelListHeaderThemeData - .avatarTheme?.constraints, - ) - : const Empty(), - ), - actions: actions ?? - [ - StreamNeumorphicButton( - child: IconButton( - icon: StreamConnectionStatusBuilder( - statusBuilder: (context, status) { - final color = switch (status) { - ConnectionStatus.connected => - chatThemeData.colorTheme.accentPrimary, - ConnectionStatus.connecting => Colors.grey, - ConnectionStatus.disconnected => Colors.grey, - }; + final title = + this.title ?? + switch (status) { + ConnectionStatus.connected => _ConnectedTitleState(), + ConnectionStatus.connecting => _ConnectingTitleState(), + ConnectionStatus.disconnected => _DisconnectedTitleState(client: _client), + }; - return StreamSvgIcon( - size: 24, - color: color, - icon: StreamSvgIcons.penWrite, - ); - }, - ), - onPressed: onNewChatButtonTap, - ), - ), - ], - title: Column( - children: [ - Builder( - builder: (context) { - if (titleBuilder != null) { - return titleBuilder!(context, status, _client); - } - switch (status) { - case ConnectionStatus.connected: - return _ConnectedTitleState(); - case ConnectionStatus.connecting: - return _ConnectingTitleState(); - case ConnectionStatus.disconnected: - return _DisconnectedTitleState(client: _client); - } - }, - ), - subtitle ?? const Empty(), - ], + return StreamInfoTile( + showMessage: showConnectionStateTile && showStatus, + message: statusString, + // Wrap the bar in a [StreamAppBarTheme] so the per-header chat + // theme drives all default styling — the bar internally merges + // in any [style] override the caller passed. + child: StreamAppBarTheme( + data: headerTheme, + child: StreamAppBar( + leading: leading, + title: title, + subtitle: subtitle, + trailing: trailing, + primary: primary, + style: style, + ), ), + ); + }, + ), + ); + } +} + +class _DefaultUserAvatar extends StatelessWidget { + const _DefaultUserAvatar({required this.client, this.onPressed}); + + final StreamChatClient client; + final void Function(User user)? onPressed; + + @override + Widget build(BuildContext context) { + final user = client.state.currentUser; + if (user == null) return const SizedBox.shrink(); + + // Caller-provided handler wins; otherwise mirror Material AppBar and + // open the enclosing Scaffold's drawer if one exists. With no callback + // and no drawer, the avatar is non-interactive. + final scaffold = Scaffold.maybeOf(context); + final effectiveOnTap = switch (onPressed) { + final cb? => () => cb(user), + _ => scaffold?.openDrawer, + }; + + // Match the 48×48 tap target StreamAppBar's auto-implied leading uses + // (StreamButton.icon medium = 40 visible + Material padded tap target), + // so the avatar slot sizes and hit-tests consistently with other bars. + return SizedBox.square( + dimension: 48, + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: effectiveOnTap, + child: Center( + child: StreamUserAvatar( + size: .lg, + user: user, + showOnlineIndicator: false, ), - ); - }, + ), + ), ); } } @@ -212,12 +221,9 @@ class StreamChannelListHeader extends StatelessWidget class _ConnectedTitleState extends StatelessWidget { @override Widget build(BuildContext context) { - final chatThemeData = StreamChatTheme.of(context); return Text( context.translations.streamChatLabel, - style: chatThemeData.textTheme.headlineBold.copyWith( - color: chatThemeData.colorTheme.textHighEmphasis, - ), + style: context.streamTextTheme.headingSm, ); } } @@ -225,23 +231,17 @@ class _ConnectedTitleState extends StatelessWidget { class _ConnectingTitleState extends StatelessWidget { @override Widget build(BuildContext context) { + final textTheme = context.streamTextTheme; + final colorScheme = context.streamColorScheme; return Row( mainAxisAlignment: MainAxisAlignment.center, + mainAxisSize: MainAxisSize.min, children: [ - const SizedBox( - height: 16, - width: 16, - child: Center( - child: CircularProgressIndicator.adaptive(), - ), - ), + StreamLoadingSpinner(size: .sm), const SizedBox(width: 10), Text( context.translations.searchingForNetworkText, - style: StreamChannelListHeaderTheme.of(context).titleStyle?.copyWith( - fontSize: 16, - fontWeight: FontWeight.bold, - ), + style: textTheme.headingSm.copyWith(color: colorScheme.textPrimary), ), ], ); @@ -249,36 +249,28 @@ class _ConnectingTitleState extends StatelessWidget { } class _DisconnectedTitleState extends StatelessWidget { - const _DisconnectedTitleState({ - required this.client, - }); + const _DisconnectedTitleState({required this.client}); final StreamChatClient client; @override Widget build(BuildContext context) { - final chatThemeData = StreamChatTheme.of(context); - final channelListHeaderTheme = StreamChannelListHeaderTheme.of(context); + final textTheme = context.streamTextTheme; + final colorScheme = context.streamColorScheme; return Row( mainAxisAlignment: MainAxisAlignment.center, + mainAxisSize: MainAxisSize.min, children: [ Text( context.translations.offlineLabel, - style: channelListHeaderTheme.titleStyle?.copyWith( - fontSize: 16, - fontWeight: FontWeight.bold, - ), + style: textTheme.headingSm.copyWith(color: colorScheme.textPrimary), ), - TextButton( + StreamButton( + type: .ghost, + style: .primary, + size: .small, onPressed: client.maybeReconnect, - child: Text( - context.translations.tryAgainLabel, - style: channelListHeaderTheme.titleStyle?.copyWith( - fontSize: 16, - fontWeight: FontWeight.bold, - color: chatThemeData.colorTheme.accentPrimary, - ), - ), + child: Text(context.translations.tryAgainLabel), ), ], ); diff --git a/packages/stream_chat_flutter/lib/src/channel/channel_name.dart b/packages/stream_chat_flutter/lib/src/channel/channel_name.dart index ddccdb7200..79aedaddbd 100644 --- a/packages/stream_chat_flutter/lib/src/channel/channel_name.dart +++ b/packages/stream_chat_flutter/lib/src/channel/channel_name.dart @@ -87,8 +87,7 @@ class _NameGenerator extends StatelessWidget { } }); - final exceedingMembers = - otherMembers.length - currentMembers.length; + final exceedingMembers = otherMembers.length - currentMembers.length; channelName = '${currentMembers.map((e) => e.user?.name).join(', ')} ' '${exceedingMembers > 0 ? '+ $exceedingMembers' : ''}'; diff --git a/packages/stream_chat_flutter/lib/src/channel/stream_channel_avatar.dart b/packages/stream_chat_flutter/lib/src/channel/stream_channel_avatar.dart deleted file mode 100644 index 2f7665939f..0000000000 --- a/packages/stream_chat_flutter/lib/src/channel/stream_channel_avatar.dart +++ /dev/null @@ -1,256 +0,0 @@ -import 'package:cached_network_image/cached_network_image.dart'; -import 'package:flutter/material.dart'; -import 'package:stream_chat_flutter/stream_chat_flutter.dart'; - -/// ![screenshot](https://raw.githubusercontent.com/GetStream/stream-chat-flutter/master/packages/stream_chat_flutter/screenshots/channel_image.png) -/// ![screenshot](https://raw.githubusercontent.com/GetStream/stream-chat-flutter/master/packages/stream_chat_flutter/screenshots/channel_image_paint.png) -/// -/// It shows the current [Channel] image. -/// -/// ```dart -/// class MyApp extends StatelessWidget { -/// final StreamChatClient client; -/// final Channel channel; -/// -/// MyApp(this.client, this.channel); -/// -/// @override -/// Widget build(BuildContext context) { -/// return MaterialApp( -/// debugShowCheckedModeBanner: false, -/// home: StreamChat( -/// client: client, -/// child: StreamChannel( -/// channel: channel, -/// child: Center( -/// child: StreamChannelAvatar( -/// channel: channel, -/// ), -/// ), -/// ), -/// ), -/// ); -/// } -/// } -/// ``` -/// -/// The widget uses a [StreamBuilder] to render the channel information -/// image as soon as it updates. -/// -/// By default the widget radius size is 40x40 pixels. -/// Set the property [constraints] to set a custom dimension. -/// -/// The widget renders the ui based on the first ancestor of type -/// [StreamChatTheme]. -/// Modify it to change the widget appearance. -class StreamChannelAvatar extends StatelessWidget { - /// Instantiate a new ChannelImage - StreamChannelAvatar({ - super.key, - required this.channel, - this.constraints, - this.onTap, - this.borderRadius, - this.selected = false, - this.selectionColor, - this.selectionThickness = 4, - this.ownSpaceAvatarBuilder, - this.oneToOneAvatarBuilder, - this.groupAvatarBuilder, - }) : assert( - channel.state != null, - 'Channel ${channel.id} is not initialized', - ); - - /// [BorderRadius] to display the widget - final BorderRadius? borderRadius; - - /// The channel to show the image of - final Channel channel; - - /// The diameter of the image - final BoxConstraints? constraints; - - /// The function called when the image is tapped - final VoidCallback? onTap; - - /// If image is selected - final bool selected; - - /// Selection color for image - final Color? selectionColor; - - /// Thickness of selection image - final double selectionThickness; - - /// Builder to create avatar for own space channel. - /// - /// Defaults to [StreamUserAvatar]. - final StreamUserAvatarBuilder? ownSpaceAvatarBuilder; - - /// Builder to create avatar for one to one channel. - /// - /// Defaults to [StreamUserAvatar]. - final StreamUserAvatarBuilder? oneToOneAvatarBuilder; - - /// Builder to create avatar for group channel. - /// - /// Defaults to [StreamGroupAvatar]. - final StreamGroupAvatarBuilder? groupAvatarBuilder; - - @override - Widget build(BuildContext context) { - final client = channel.client.state; - - final chatThemeData = StreamChatTheme.of(context); - final colorTheme = chatThemeData.colorTheme; - final previewTheme = chatThemeData.channelPreviewTheme.avatarTheme; - - final fallbackWidget = Center( - child: Text( - channel.name?.characters.firstOrNull ?? '', - style: TextStyle( - color: colorTheme.barsBg, - fontWeight: FontWeight.bold, - ), - ), - ); - - return BetterStreamBuilder( - stream: channel.imageStream, - initialData: channel.image, - builder: (context, channelImage) { - Widget child = ClipRRect( - borderRadius: - borderRadius ?? previewTheme?.borderRadius ?? BorderRadius.zero, - child: Container( - constraints: constraints ?? previewTheme?.constraints, - decoration: BoxDecoration(color: colorTheme.accentPrimary), - child: InkWell( - onTap: onTap, - child: LayoutBuilder( - builder: (context, constraints) { - if (channelImage.isEmpty) return fallbackWidget; - - // Calculate optimal thumbnail size for the avatar - final devicePixel = MediaQuery.devicePixelRatioOf(context); - final thumbnailSize = constraints.biggest * devicePixel; - - int? cacheWidth, cacheHeight; - if (thumbnailSize.isFinite && !thumbnailSize.isEmpty) { - cacheWidth = thumbnailSize.width.round(); - cacheHeight = thumbnailSize.height.round(); - } - - return CachedNetworkImage( - imageUrl: channelImage, - memCacheWidth: cacheWidth, - memCacheHeight: cacheHeight, - errorWidget: (_, __, ___) => fallbackWidget, - fit: BoxFit.cover, - ); - }, - ), - ), - ), - ); - - if (selected) { - child = ClipRRect( - key: const Key('selectedImage'), - borderRadius: BorderRadius.circular(selectionThickness) + - (borderRadius ?? - previewTheme?.borderRadius ?? - BorderRadius.zero), - child: Container( - constraints: constraints ?? previewTheme?.constraints, - color: selectionColor ?? colorTheme.accentPrimary, - child: Padding( - padding: EdgeInsets.all(selectionThickness), - child: child, - ), - ), - ); - } - return child; - }, - noDataBuilder: (context) { - final currentUser = client.currentUser!; - final otherMembers = channel.state!.members - .where((it) => it.userId != currentUser.id) - .toList(growable: false); - - // our own space, no other members - if (otherMembers.isEmpty) { - return BetterStreamBuilder( - stream: client.currentUserStream.map((it) => it!), - initialData: currentUser, - builder: (context, user) { - final ownSpaceBuilder = ownSpaceAvatarBuilder; - if (ownSpaceBuilder != null) { - return ownSpaceBuilder(context, user, selected); - } - - return StreamUserAvatar( - borderRadius: borderRadius ?? previewTheme?.borderRadius, - user: user, - constraints: constraints ?? previewTheme?.constraints, - onTap: onTap != null ? (_) => onTap!() : null, - selected: selected, - selectionColor: selectionColor ?? colorTheme.accentPrimary, - selectionThickness: selectionThickness, - ); - }, - ); - } - - // 1-1 Conversation - if (otherMembers.length == 1) { - final member = otherMembers.first; - return BetterStreamBuilder( - stream: channel.state!.membersStream.map( - (members) => members.firstWhere( - (it) => it.userId == member.userId, - orElse: () => member, - ), - ), - initialData: member, - builder: (context, member) { - final oneToOneBuilder = oneToOneAvatarBuilder; - if (oneToOneBuilder != null) { - return oneToOneBuilder(context, member.user!, selected); - } - - return StreamUserAvatar( - borderRadius: borderRadius ?? previewTheme?.borderRadius, - user: member.user!, - constraints: constraints ?? previewTheme?.constraints, - onTap: onTap != null ? (_) => onTap!() : null, - selected: selected, - selectionColor: selectionColor ?? colorTheme.accentPrimary, - selectionThickness: selectionThickness, - ); - }, - ); - } - - final groupBuilder = groupAvatarBuilder; - if (groupBuilder != null) { - return groupBuilder(context, otherMembers, selected); - } - - // Group conversation - return StreamGroupAvatar( - channel: channel, - members: otherMembers, - borderRadius: borderRadius ?? previewTheme?.borderRadius, - constraints: constraints ?? previewTheme?.constraints, - onTap: onTap, - selected: selected, - selectionColor: selectionColor ?? colorTheme.accentPrimary, - selectionThickness: selectionThickness, - ); - }, - ); - } -} diff --git a/packages/stream_chat_flutter/lib/src/channel/stream_channel_name.dart b/packages/stream_chat_flutter/lib/src/channel/stream_channel_name.dart index 273b5b37e8..4dc226be84 100644 --- a/packages/stream_chat_flutter/lib/src/channel/stream_channel_name.dart +++ b/packages/stream_chat_flutter/lib/src/channel/stream_channel_name.dart @@ -13,9 +13,9 @@ class StreamChannelName extends StatelessWidget { this.textStyle, this.textOverflow = TextOverflow.ellipsis, }) : assert( - channel.state != null, - 'Channel ${channel.id} is not initialized', - ); + channel.state != null, + 'Channel ${channel.id} is not initialized', + ); /// The [Channel] to show the name for. final Channel channel; @@ -28,63 +28,69 @@ class StreamChannelName extends StatelessWidget { @override Widget build(BuildContext context) => BetterStreamBuilder( - stream: channel.nameStream, - initialData: channel.name, - builder: (context, channelName) => Text( - channelName, - style: textStyle, - overflow: textOverflow, - ), - noDataBuilder: (context) => _generateName( - channel.client.state.currentUser!, - channel.state!.members, - ), - ); + stream: channel.nameStream, + initialData: channel.name, + builder: (context, channelName) => Text( + channelName, + style: textStyle, + overflow: textOverflow, + ), + noDataBuilder: (context) => _generateName( + channel.client.state.currentUser!, + channel.state!.members, + ), + ); Widget _generateName( User currentUser, List members, - ) => - LayoutBuilder( - builder: (context, constraints) { - var channelName = context.translations.noTitleText; - final otherMembers = members.where( - (member) => member.userId != currentUser.id, - ); - - if (otherMembers.isNotEmpty) { - if (otherMembers.length == 1) { - final user = otherMembers.first.user; - if (user != null) { - channelName = user.name; - } - } else { - final maxWidth = constraints.maxWidth; - final maxChars = maxWidth / (textStyle?.fontSize ?? 1); - var currentChars = 0; - final currentMembers = []; - otherMembers.forEach((element) { - final newLength = - currentChars + (element.user?.name.length ?? 0); - if (newLength < maxChars) { - currentChars = newLength; - currentMembers.add(element); - } - }); + ) => LayoutBuilder( + builder: (context, constraints) { + var channelName = context.translations.noTitleText; + final otherMembers = members.where( + (member) => member.userId != currentUser.id, + ); - final exceedingMembers = - otherMembers.length - currentMembers.length; - channelName = - '${currentMembers.map((e) => e.user?.name).join(', ')} ' - '${exceedingMembers > 0 ? '+ $exceedingMembers' : ''}'; - } + if (otherMembers.isNotEmpty) { + if (otherMembers.length == 1) { + final user = otherMembers.first.user; + if (user != null) { + channelName = user.name; } + } else { + final maxWidth = constraints.maxWidth; + channelName = ''; + final currentMembers = []; + otherMembers.forEach((element) { + final newTitle = _getChannelName(currentMembers: [...currentMembers, element], members: members); + if (_calculateTextSize(newTitle).width < maxWidth) { + currentMembers.add(element); + channelName = newTitle; + } + }); + } + } - return Text( - channelName, - style: textStyle, - overflow: textOverflow, - ); - }, + return Text( + channelName, + style: textStyle, + overflow: textOverflow, ); + }, + ); + + String _getChannelName({required List currentMembers, required List members}) { + final exceedingMembers = members.length - currentMembers.length; + return '${currentMembers.map((e) => e.user?.name).join(', ')} ' + '${exceedingMembers > 0 ? '+ $exceedingMembers' : ''}'; + } + + Size _calculateTextSize(String text) { + final textPainter = TextPainter( + text: TextSpan(text: text, style: textStyle), + maxLines: 1, + textDirection: TextDirection.ltr, + )..layout(minWidth: 0, maxWidth: double.infinity); + return textPainter.size; + } } diff --git a/packages/stream_chat_flutter/lib/src/channel/stream_draft_message_preview_text.dart b/packages/stream_chat_flutter/lib/src/channel/stream_draft_message_preview_text.dart index 0a935e2b6a..2ac3eb91be 100644 --- a/packages/stream_chat_flutter/lib/src/channel/stream_draft_message_preview_text.dart +++ b/packages/stream_chat_flutter/lib/src/channel/stream_draft_message_preview_text.dart @@ -8,6 +8,7 @@ class StreamDraftMessagePreviewText extends StatelessWidget { super.key, required this.draftMessage, this.textStyle, + this.showCaption = true, }); /// The draft message to display. @@ -16,20 +17,30 @@ class StreamDraftMessagePreviewText extends StatelessWidget { /// The style to use for the text. final TextStyle? textStyle; + /// Whether to include the draft text caption alongside the type label. + /// + /// Set to `false` for tight previews (e.g. quoted / edit headers) where + /// only the attachment type label should be shown. + final bool showCaption; + @override Widget build(BuildContext context) { + final currentUser = StreamChat.maybeOf(context)?.currentUser; + final config = StreamChatConfiguration.of(context); final formatter = config.messagePreviewFormatter; final previewTextSpan = formatter.formatDraftMessage( context, draftMessage, - textStyle: textStyle, + currentUser: currentUser, + showCaption: showCaption, ); return Text.rich( maxLines: 1, previewTextSpan, + style: textStyle, overflow: TextOverflow.ellipsis, textAlign: TextAlign.start, ); diff --git a/packages/stream_chat_flutter/lib/src/channel/stream_message_preview_text.dart b/packages/stream_chat_flutter/lib/src/channel/stream_message_preview_text.dart index adb2ffdc51..5bf38854c3 100644 --- a/packages/stream_chat_flutter/lib/src/channel/stream_message_preview_text.dart +++ b/packages/stream_chat_flutter/lib/src/channel/stream_message_preview_text.dart @@ -10,6 +10,7 @@ class StreamMessagePreviewText extends StatelessWidget { this.channel, this.language, this.textStyle, + this.showCaption = true, }); /// The message to display. @@ -24,10 +25,16 @@ class StreamMessagePreviewText extends StatelessWidget { /// The style to use for the text. final TextStyle? textStyle; + /// Whether to include the message text caption alongside the type label. + /// + /// Set to `false` for tight previews (e.g. quoted / edit headers) where + /// only the attachment type label should be shown. + final bool showCaption; + @override Widget build(BuildContext context) { - final currentUser = StreamChat.of(context).currentUser!; - final translationLanguage = language ?? currentUser.language ?? 'en'; + final currentUser = StreamChat.maybeOf(context)?.currentUser; + final translationLanguage = language ?? currentUser?.language ?? 'en'; final translatedMessage = message.translate(translationLanguage); final previewMessage = translatedMessage.replaceMentions(linkify: false); @@ -39,12 +46,13 @@ class StreamMessagePreviewText extends StatelessWidget { previewMessage, channel: channel, currentUser: currentUser, - textStyle: textStyle, + showCaption: showCaption, ); return Text.rich( maxLines: 1, previewTextSpan, + style: textStyle, overflow: TextOverflow.ellipsis, textAlign: TextAlign.start, ); diff --git a/packages/stream_chat_flutter/lib/src/components/avatar/stream_channel_avatar.dart b/packages/stream_chat_flutter/lib/src/components/avatar/stream_channel_avatar.dart new file mode 100644 index 0000000000..92739aa219 --- /dev/null +++ b/packages/stream_chat_flutter/lib/src/components/avatar/stream_channel_avatar.dart @@ -0,0 +1,130 @@ +import 'package:collection/collection.dart'; +import 'package:flutter/material.dart'; +import 'package:stream_chat_flutter/src/components/avatar/stream_user_avatar.dart'; +import 'package:stream_chat_flutter/src/components/avatar/stream_user_avatar_group.dart'; +import 'package:stream_chat_flutter_core/stream_chat_flutter_core.dart'; +import 'package:stream_core_flutter/stream_core_flutter.dart'; + +/// A circular avatar component for displaying a channel's image. +/// +/// [StreamChannelAvatar] displays a channel's image or an appropriate fallback +/// based on the channel type. It supports channel images, user avatars for +/// 1-1 conversations, and group avatars for multi-member channels. +/// +/// The avatar automatically handles: +/// - Reactive updates when channel image changes via [Channel.imageStream] +/// - Fallback to member avatars when no channel image is set +/// - Deterministic color assignment for member avatars +/// +/// {@tool snippet} +/// +/// Basic usage with a channel: +/// +/// ```dart +/// StreamChannelAvatar(channel: channel) +/// ``` +/// {@end-tool} +/// +/// {@tool snippet} +/// +/// With custom size: +/// +/// ```dart +/// StreamChannelAvatar( +/// channel: channel, +/// size: StreamAvatarGroupSize.xl, +/// ) +/// ``` +/// {@end-tool} +/// +/// ## Theming +/// +/// [StreamChannelAvatar] uses [StreamAvatarThemeData] for default styling. +/// Member avatars within the channel avatar use deterministic colors from +/// [StreamColorScheme.avatarPalette]. +/// +/// See also: +/// +/// * [StreamAvatarGroupSize], which defines the available size variants. +/// * [StreamAvatarThemeData], which provides theme-level customization. +/// * [StreamUserAvatar], which is used to display individual member avatars. +class StreamChannelAvatar extends StatelessWidget { + /// Creates a Stream channel avatar. + const StreamChannelAvatar({ + super.key, + this.size, + required this.channel, + }); + + /// The channel whose avatar is displayed. + final Channel channel; + + /// The size of the channel avatar. + /// + /// If null, defaults to [StreamAvatarGroupSize.lg]. + final StreamAvatarGroupSize? size; + + @override + Widget build(BuildContext context) { + assert(channel.state != null, 'Channel ${channel.id} is not initialized'); + + final effectiveSize = size ?? StreamAvatarGroupSize.lg; + + return BetterStreamBuilder( + stream: channel.imageStream, + initialData: channel.image, + builder: (context, channelImage) => StreamAvatar( + imageUrl: channelImage, + size: _avatarSizeForAvatarGroupSize(effectiveSize), + placeholder: (_) => const _StreamChannelAvatarPlaceholder(), + ), + noDataBuilder: (context) => BetterStreamBuilder( + stream: channel.state!.membersStream, + initialData: channel.state!.members, + builder: (context, members) { + final users = members.map((it) => it.user!).toList(); + final currentUserId = channel.client.state.currentUser?.id; + + if (channel.isDistinct && users.length == 2) { + final otherUser = users.firstWhere( + (u) => u.id != currentUserId, + orElse: () => users.first, + ); + return StreamUserAvatar( + user: otherUser, + size: _avatarSizeForAvatarGroupSize(effectiveSize), + // TODO: make this configurable when the online state is shown. + showOnlineIndicator: otherUser.online, + ); + } + + return StreamUserAvatarGroup( + size: effectiveSize, + users: users.sortedBy((it) => it.id == currentUserId ? 1 : 0), + ); + }, + ), + ); + } + + // Maps [StreamAvatarGroupSize] to corresponding [StreamAvatarSize]. + // + // Used when displaying a single channel image avatar. + StreamAvatarSize _avatarSizeForAvatarGroupSize( + StreamAvatarGroupSize size, + ) => switch (size) { + .lg => StreamAvatarSize.lg, + .xl => StreamAvatarSize.xl, + .xxl => StreamAvatarSize.xxl, + }; +} + +// Placeholder widget for [StreamChannelAvatar]. +// +// Displays a team icon as a fallback when the channel image fails to load. +class _StreamChannelAvatarPlaceholder extends StatelessWidget { + const _StreamChannelAvatarPlaceholder(); + + @override + Widget build(BuildContext context) => Icon(context.streamIcons.users); +} diff --git a/packages/stream_chat_flutter/lib/src/components/avatar/stream_user_avatar.dart b/packages/stream_chat_flutter/lib/src/components/avatar/stream_user_avatar.dart new file mode 100644 index 0000000000..251dcaf701 --- /dev/null +++ b/packages/stream_chat_flutter/lib/src/components/avatar/stream_user_avatar.dart @@ -0,0 +1,168 @@ +import 'package:flutter/material.dart'; +import 'package:stream_chat_flutter/src/utils/extensions.dart'; +import 'package:stream_chat_flutter_core/stream_chat_flutter_core.dart'; +import 'package:stream_core_flutter/stream_core_flutter.dart'; + +/// A circular avatar component for displaying a user's profile. +/// +/// [StreamUserAvatar] displays a user's profile image or initials placeholder +/// when no image is available. It supports multiple sizes, deterministic +/// color assignment, and an optional online status indicator. +/// +/// The avatar automatically handles: +/// - Displaying the user's profile image when available +/// - Showing user initials as a placeholder when no image exists +/// - Deterministic color assignment based on the user's ID +/// - Online status indicator positioning +/// +/// {@tool snippet} +/// +/// Basic usage with a user: +/// +/// ```dart +/// StreamUserAvatar(user: currentUser) +/// ``` +/// {@end-tool} +/// +/// {@tool snippet} +/// +/// With online indicator and custom size: +/// +/// ```dart +/// StreamUserAvatar( +/// user: currentUser, +/// size: StreamAvatarSize.lg, +/// showOnlineIndicator: true, +/// ) +/// ``` +/// {@end-tool} +/// +/// {@tool snippet} +/// +/// With border for selection states: +/// +/// ```dart +/// StreamUserAvatar( +/// user: selectedUser, +/// showBorder: true, +/// ) +/// ``` +/// {@end-tool} +/// +/// ## Theming +/// +/// [StreamUserAvatar] uses [StreamAvatarThemeData] for default styling. Colors +/// are automatically assigned based on the user's ID hash, selecting from +/// [StreamColorScheme.avatarPalette] for consistent user-specific colors. +/// +/// See also: +/// +/// * [StreamAvatarSize], which defines the available size variants. +/// * [StreamAvatarThemeData], which provides theme-level customization. +/// * [StreamColorScheme.avatarPalette], which provides colors for user avatars. +class StreamUserAvatar extends StatelessWidget { + /// Creates a Stream user avatar. + const StreamUserAvatar({ + super.key, + this.size, + required this.user, + this.showBorder = true, + this.showOnlineIndicator = true, + }); + + /// The user whose avatar is displayed. + final User user; + + /// Whether to show a border around the avatar. + /// + /// Defaults to true. The border style is determined by + /// [StreamAvatarThemeData.border]. + final bool showBorder; + + /// Whether to show the online status indicator. + /// + /// Defaults to true. + final bool showOnlineIndicator; + + /// The size of the avatar. + /// + /// If null, uses [StreamAvatarThemeData.size], or falls back to + /// [StreamAvatarSize.lg]. + final StreamAvatarSize? size; + + @override + Widget build(BuildContext context) { + final avatarTheme = context.streamAvatarTheme; + final colorScheme = context.streamColorScheme; + final avatarPalette = colorScheme.avatarPalette; + + final userHash = user.id.hashCode; // Ensure deterministic colors. + final colorPair = avatarPalette[userHash % avatarPalette.length]; + + final effectiveSize = size ?? avatarTheme.size ?? .lg; + final effectiveBackgroundColor = avatarTheme.backgroundColor ?? colorPair.backgroundColor; + final effectiveForegroundColor = avatarTheme.foregroundColor ?? colorPair.foregroundColor; + + final userAvatar = StreamAvatar( + size: effectiveSize, + imageUrl: user.image, + showBorder: showBorder, + backgroundColor: effectiveBackgroundColor, + foregroundColor: effectiveForegroundColor, + placeholder: (_) => _StreamUserAvatarPlaceholder(user: user, size: effectiveSize), + ); + + if (showOnlineIndicator) { + final indicatorSize = _indicatorSizeForAvatarSize(effectiveSize); + + return StreamOnlineIndicator( + size: indicatorSize, + isOnline: user.online, + alignment: AlignmentDirectional.topEnd, + child: userAvatar, + ); + } + + return userAvatar; + } + + // Maps [StreamAvatarSize] to corresponding [StreamOnlineIndicatorSize]. + // + // Ensures the online indicator scales appropriately with the avatar size. + StreamOnlineIndicatorSize _indicatorSizeForAvatarSize( + StreamAvatarSize size, + ) => switch (size) { + .xs || .sm => StreamOnlineIndicatorSize.sm, + .md => StreamOnlineIndicatorSize.md, + .lg => StreamOnlineIndicatorSize.lg, + .xl || .xxl => StreamOnlineIndicatorSize.xl, + }; +} + +// Placeholder widget for [StreamUserAvatar]. +// +// Displays user initials or a fallback person icon when no name is available. +// Shows full initials (up to 2 characters) for medium and larger sizes, +// and only the first initial for extra-small and small sizes. +class _StreamUserAvatarPlaceholder extends StatelessWidget { + const _StreamUserAvatarPlaceholder({ + required this.user, + required this.size, + }); + + final User user; + final StreamAvatarSize size; + + @override + Widget build(BuildContext context) { + final userInitials = user.name.initials; + if (userInitials != null && userInitials.isNotEmpty) { + return switch (size) { + .md || .lg || .xl || .xxl => Text(userInitials), + .xs || .sm => Text(userInitials.characters.first), + }; + } + + return Icon(context.streamIcons.user); + } +} diff --git a/packages/stream_chat_flutter/lib/src/components/avatar/stream_user_avatar_group.dart b/packages/stream_chat_flutter/lib/src/components/avatar/stream_user_avatar_group.dart new file mode 100644 index 0000000000..5b557f4e6b --- /dev/null +++ b/packages/stream_chat_flutter/lib/src/components/avatar/stream_user_avatar_group.dart @@ -0,0 +1,80 @@ +import 'package:flutter/material.dart'; +import 'package:stream_chat_flutter/src/components/avatar/stream_user_avatar.dart'; +import 'package:stream_chat_flutter_core/stream_chat_flutter_core.dart'; +import 'package:stream_core_flutter/stream_core_flutter.dart'; + +/// A widget that displays multiple user avatars in a grid layout. +/// +/// [StreamUserAvatarGroup] arranges user avatars in a 2x2 grid pattern, +/// typically used for displaying group channel participants. It supports +/// two sizes and automatically handles overflow with a badge indicator. +/// +/// The group automatically handles: +/// - Grid layout for up to 4 user avatars +/// - Overflow indicator showing remaining count for additional users +/// - Deterministic color assignment for each user avatar +/// - Consistent sizing across all child avatars +/// +/// {@tool snippet} +/// +/// Basic usage with a list of users: +/// +/// ```dart +/// StreamUserAvatarGroup(users: groupMembers) +/// ``` +/// {@end-tool} +/// +/// {@tool snippet} +/// +/// With custom size: +/// +/// ```dart +/// StreamUserAvatarGroup( +/// users: groupMembers, +/// size: StreamAvatarGroupSize.xl, +/// ) +/// ``` +/// {@end-tool} +/// +/// ## Theming +/// +/// [StreamUserAvatarGroup] uses [StreamAvatarThemeData] for styling the child +/// avatars. Individual user avatars use deterministic colors from +/// [StreamColorScheme.avatarPalette]. +/// +/// See also: +/// +/// * [StreamAvatarGroupSize], which defines the available size variants. +/// * [StreamUserAvatar], which is used to display individual user avatars. +/// * [StreamAvatarGroup], the underlying group component. +class StreamUserAvatarGroup extends StatelessWidget { + /// Creates a Stream user avatar group. + /// + /// If [users] is empty, returns an empty [SizedBox]. + const StreamUserAvatarGroup({ + super.key, + required this.users, + this.size, + }); + + /// The list of users whose avatars are displayed. + final Iterable users; + + /// The size of the avatar group. + /// + /// If null, defaults to [StreamAvatarGroupSize.lg]. + final StreamAvatarGroupSize? size; + + @override + Widget build(BuildContext context) { + return StreamAvatarGroup( + size: size, + children: users.map( + (user) => StreamUserAvatar( + user: user, + showOnlineIndicator: false, + ), + ), + ); + } +} diff --git a/packages/stream_chat_flutter/lib/src/components/avatar/stream_user_avatar_stack.dart b/packages/stream_chat_flutter/lib/src/components/avatar/stream_user_avatar_stack.dart new file mode 100644 index 0000000000..12b8ee48f9 --- /dev/null +++ b/packages/stream_chat_flutter/lib/src/components/avatar/stream_user_avatar_stack.dart @@ -0,0 +1,113 @@ +import 'package:flutter/material.dart'; +import 'package:stream_chat_flutter/src/components/avatar/stream_user_avatar.dart'; +import 'package:stream_chat_flutter_core/stream_chat_flutter_core.dart'; +import 'package:stream_core_flutter/stream_core_flutter.dart'; + +/// A widget that displays a stack of user avatars with overlap. +/// +/// [StreamUserAvatarStack] displays multiple user avatars in a horizontal +/// stack, with each avatar partially overlapping the previous one. This is +/// useful for showing participants in a conversation, group members, or +/// any collection of users in a compact space. +/// +/// The stack automatically handles: +/// - Displaying user avatars with deterministic colors +/// - Overflow handling with a badge showing remaining count +/// - Customizable overlap and maximum visible avatars +/// +/// {@tool snippet} +/// +/// Basic usage with a list of users: +/// +/// ```dart +/// StreamUserAvatarStack(users: participants) +/// ``` +/// {@end-tool} +/// +/// {@tool snippet} +/// +/// With custom max and size: +/// +/// ```dart +/// StreamUserAvatarStack( +/// users: participants, +/// max: 3, +/// size: StreamAvatarStackSize.xs, +/// ) +/// ``` +/// {@end-tool} +/// +/// {@tool snippet} +/// +/// With custom overlap: +/// +/// ```dart +/// StreamUserAvatarStack( +/// users: participants, +/// overlap: 0.5, // 50% overlap +/// ) +/// ``` +/// {@end-tool} +/// +/// ## Theming +/// +/// [StreamUserAvatarStack] uses [StreamAvatarThemeData] for default styling. +/// Individual user avatars use deterministic colors from +/// [StreamColorScheme.avatarPalette]. +/// +/// See also: +/// +/// * [StreamAvatarStackSize], which defines the available size variants. +/// * [StreamUserAvatar], which is used to display individual user avatars. +/// * [StreamAvatarStack], the underlying stack component. +class StreamUserAvatarStack extends StatelessWidget { + /// Creates a Stream user avatar stack. + /// + /// If [users] is empty, returns an empty [SizedBox]. + /// The [max] must be at least 2. + const StreamUserAvatarStack({ + super.key, + required this.users, + this.size, + this.overlap = 0.33, + this.max = 5, + }) : assert(max >= 2, 'max must be at least 2'); + + /// The list of users whose avatars are displayed. + final Iterable users; + + /// The size of the avatar stack. + /// + /// If null, defaults to [StreamAvatarStackSize.sm]. + final StreamAvatarStackSize? size; + + /// How much each avatar overlaps the previous one, as a fraction of size. + /// + /// - `0.0`: No overlap (side by side) + /// - `0.33`: 33% overlap (default) + /// - `1.0`: Fully stacked + final double overlap; + + /// Maximum number of avatars to display before showing overflow badge. + /// + /// When [users] exceeds this value, displays [max] avatars followed + /// by a badge showing the overflow count (e.g., "+2"). + /// + /// Must be at least 2. Defaults to 5. + final int max; + + @override + Widget build(BuildContext context) { + return StreamAvatarStack( + max: max, + size: size, + overlap: overlap, + children: users.map( + (user) => StreamUserAvatar( + user: user, + showOnlineIndicator: false, + ), + ), + ); + } +} diff --git a/packages/stream_chat_flutter/lib/src/components/message_composer/message_composer.dart b/packages/stream_chat_flutter/lib/src/components/message_composer/message_composer.dart new file mode 100644 index 0000000000..a7cee4e7f6 --- /dev/null +++ b/packages/stream_chat_flutter/lib/src/components/message_composer/message_composer.dart @@ -0,0 +1,6 @@ +export 'message_composer_component_props.dart'; +export 'message_composer_input.dart' show DefaultStreamMessageComposerInput; +export 'message_composer_input_center.dart' show DefaultStreamMessageComposerInputCenter; +export 'message_composer_input_field.dart'; +export 'message_composer_input_trailing.dart' show DefaultStreamMessageComposerInputTrailing; +export 'message_composer_leading.dart' show DefaultStreamMessageComposerLeading; diff --git a/packages/stream_chat_flutter/lib/src/components/message_composer/message_composer_component_props.dart b/packages/stream_chat_flutter/lib/src/components/message_composer/message_composer_component_props.dart new file mode 100644 index 0000000000..c9acbb2a61 --- /dev/null +++ b/packages/stream_chat_flutter/lib/src/components/message_composer/message_composer_component_props.dart @@ -0,0 +1,429 @@ +import 'package:flutter/widgets.dart'; +import 'package:stream_chat_flutter/stream_chat_flutter.dart'; + +/// Callback object that wires long-press gesture events from the voice-recording +/// button through to the audio-recorder controller. +class VoiceRecordingCallback { + /// Creates a [VoiceRecordingCallback]. + VoiceRecordingCallback({ + required this.onLongPressStart, + required this.onLongPressCancel, + required this.onLongPressEnd, + this.onLongPressMoveUpdate, + }); + + /// Called when the long-press gesture starts. + final VoidCallback onLongPressStart; + + /// Called when the long-press gesture is cancelled before it registers. + final VoidCallback onLongPressCancel; + + /// Called when the long-press gesture ends. + final GestureLongPressEndCallback onLongPressEnd; + + /// Called when the pointer moves during a long-press gesture. + final GestureLongPressMoveUpdateCallback? onLongPressMoveUpdate; +} + +/// Properties to build any of the sub-components. +/// These properties are all the same, so features such as 'add attachment', +/// can be added to any of the sub-components. +class MessageComposerComponentProps { + /// Creates a new instance of [MessageComposerComponentProps]. + /// [controller] is the controller for the message composer component. + /// [isFloating] is whether the message composer is floating. + /// [onSendPressed] is the callback for when the send button is pressed. + /// [onMicrophonePressed] is the callback for when the microphone button is pressed. + /// [onAttachmentButtonPressed] is the callback for when the attachment button is pressed. + /// [focusNode] is the focus node for the message composer component. + /// [currentUserId] is the current user id. + const MessageComposerComponentProps({ + required this.controller, + this.isFloating = false, + required this.onSendPressed, + this.voiceRecordingCallback, + this.onAttachmentButtonPressed, + this.isPickerOpen = false, + this.focusNode, + this.currentUserId, + required this.audioRecorderState, + this.onQuotedMessageCleared, + }); + + /// The controller for the message composer component. + final StreamMessageComposerController controller; + + /// Whether the message composer is floating. + final bool isFloating; + + /// The callback for when the send button is pressed. + final VoidCallback onSendPressed; + + /// The callback for when the microphone button is pressed. + final VoiceRecordingCallback? voiceRecordingCallback; + + /// The callback for when the attachment button is pressed. + final VoidCallback? onAttachmentButtonPressed; + + /// Whether the inline attachment picker is currently open. + final bool isPickerOpen; + + /// The focus node for the message composer component. + final FocusNode? focusNode; + + /// The current user id. + final String? currentUserId; + + /// Whether the audio recording flow is active. + final AudioRecorderState audioRecorderState; + + /// Callback for when the quoted message is cleared. + final VoidCallback? onQuotedMessageCleared; + + /// Whether the audio recording flow is active. + bool get isAudioRecordingFlowActive => audioRecorderState is RecordStateRecording || isAudioRecordingFlowStopped; + + /// Whether the audio recording flow is locked. + bool get isAudioRecordingFlowLocked => audioRecorderState is RecordStateRecordingLocked; + + /// Whether the audio recording flow is stopped. + bool get isAudioRecordingFlowStopped => audioRecorderState is RecordStateStopped; +} + +/// Properties for building the leading component of the message composer. +class MessageComposerLeadingProps extends MessageComposerComponentProps { + const MessageComposerLeadingProps._({ + required super.controller, + required super.isFloating, + required super.onSendPressed, + required super.voiceRecordingCallback, + required super.onAttachmentButtonPressed, + required super.isPickerOpen, + required super.focusNode, + required super.currentUserId, + required super.audioRecorderState, + required super.onQuotedMessageCleared, + }) : super(); + + /// Creates a new instance of [MessageComposerLeadingProps] from a [MessageComposerComponentProps]. + factory MessageComposerLeadingProps.from(MessageComposerComponentProps props) { + return MessageComposerLeadingProps._( + controller: props.controller, + isFloating: props.isFloating, + onSendPressed: props.onSendPressed, + voiceRecordingCallback: props.voiceRecordingCallback, + onAttachmentButtonPressed: props.onAttachmentButtonPressed, + isPickerOpen: props.isPickerOpen, + focusNode: props.focusNode, + currentUserId: props.currentUserId, + audioRecorderState: props.audioRecorderState, + onQuotedMessageCleared: props.onQuotedMessageCleared, + ); + } +} + +/// Properties for building the trailing component of the message composer. +class MessageComposerTrailingProps extends MessageComposerComponentProps { + const MessageComposerTrailingProps._({ + required super.controller, + required super.isFloating, + required super.onSendPressed, + required super.voiceRecordingCallback, + required super.onAttachmentButtonPressed, + required super.isPickerOpen, + required super.focusNode, + required super.currentUserId, + required super.audioRecorderState, + required super.onQuotedMessageCleared, + }) : super(); + + /// Creates a new instance of [MessageComposerTrailingProps] from a [MessageComposerComponentProps]. + factory MessageComposerTrailingProps.from(MessageComposerComponentProps props) { + return MessageComposerTrailingProps._( + controller: props.controller, + isFloating: props.isFloating, + onSendPressed: props.onSendPressed, + voiceRecordingCallback: props.voiceRecordingCallback, + onAttachmentButtonPressed: props.onAttachmentButtonPressed, + isPickerOpen: props.isPickerOpen, + focusNode: props.focusNode, + currentUserId: props.currentUserId, + audioRecorderState: props.audioRecorderState, + onQuotedMessageCleared: props.onQuotedMessageCleared, + ); + } +} + +/// Properties for building the input container component of the message composer. +class MessageComposerInputProps extends MessageComposerComponentProps { + const MessageComposerInputProps._({ + required super.controller, + required super.isFloating, + required super.onSendPressed, + required super.voiceRecordingCallback, + required super.onAttachmentButtonPressed, + required super.isPickerOpen, + required super.focusNode, + required super.currentUserId, + required super.audioRecorderState, + required super.onQuotedMessageCleared, + this.placeholder, + this.textInputAction, + this.keyboardType, + this.textCapitalization = TextCapitalization.sentences, + this.autofocus = false, + this.autocorrect = true, + this.canAlsoSendToChannel = false, + this.audioRecorderController, + this.feedback = const AudioRecorderFeedback(), + this.sendVoiceRecordingAutomatically = false, + }) : super(); + + /// Creates a new instance of [MessageComposerInputProps] from a + /// [MessageComposerComponentProps] and named input-level configuration values. + factory MessageComposerInputProps.from( + MessageComposerComponentProps props, { + String? placeholder, + TextInputAction? textInputAction, + TextInputType? keyboardType, + TextCapitalization textCapitalization = TextCapitalization.sentences, + bool autofocus = false, + bool autocorrect = true, + bool canAlsoSendToChannel = false, + StreamAudioRecorderController? audioRecorderController, + AudioRecorderFeedback feedback = const AudioRecorderFeedback(), + bool sendVoiceRecordingAutomatically = false, + }) { + return MessageComposerInputProps._( + controller: props.controller, + isFloating: props.isFloating, + onSendPressed: props.onSendPressed, + voiceRecordingCallback: props.voiceRecordingCallback, + onAttachmentButtonPressed: props.onAttachmentButtonPressed, + isPickerOpen: props.isPickerOpen, + focusNode: props.focusNode, + currentUserId: props.currentUserId, + audioRecorderState: props.audioRecorderState, + onQuotedMessageCleared: props.onQuotedMessageCleared, + placeholder: placeholder, + textInputAction: textInputAction, + keyboardType: keyboardType, + textCapitalization: textCapitalization, + autofocus: autofocus, + autocorrect: autocorrect, + canAlsoSendToChannel: canAlsoSendToChannel, + audioRecorderController: audioRecorderController, + feedback: feedback, + sendVoiceRecordingAutomatically: sendVoiceRecordingAutomatically, + ); + } + + /// The placeholder text shown inside the input field when it is empty. + final String? placeholder; + + /// The type of action button to use for the keyboard. + final TextInputAction? textInputAction; + + /// The type of keyboard to use for editing the text. + final TextInputType? keyboardType; + + /// {@macro flutter.widgets.editableText.textCapitalization} + final TextCapitalization textCapitalization; + + /// Whether the text field should be focused initially. + final bool autofocus; + + /// Whether to enable autocorrect. + final bool autocorrect; + + /// Whether to show the "also send to channel" checkbox. + final bool canAlsoSendToChannel; + + /// The audio recorder controller. + final StreamAudioRecorderController? audioRecorderController; + + /// The feedback handler for voice recording interactions. + final AudioRecorderFeedback feedback; + + /// Whether to send the voice recording automatically when recording stops. + final bool sendVoiceRecordingAutomatically; +} + +/// Properties for building the center content of the message composer input. +class MessageComposerInputCenterProps extends MessageComposerComponentProps { + const MessageComposerInputCenterProps._({ + required super.controller, + required super.isFloating, + required super.onSendPressed, + required super.voiceRecordingCallback, + required super.onAttachmentButtonPressed, + required super.isPickerOpen, + required super.focusNode, + required super.currentUserId, + required super.audioRecorderState, + required super.onQuotedMessageCleared, + this.placeholder, + this.textInputAction, + this.keyboardType, + this.textCapitalization = TextCapitalization.sentences, + this.autofocus = false, + this.autocorrect = true, + this.canAlsoSendToChannel = false, + this.audioRecorderController, + this.feedback = const AudioRecorderFeedback(), + this.sendVoiceRecordingAutomatically = false, + }) : super(); + + /// Creates a new instance of [MessageComposerInputCenterProps] from a + /// [MessageComposerInputProps], forwarding all base and input-level fields. + factory MessageComposerInputCenterProps.from(MessageComposerInputProps inputProps) { + return MessageComposerInputCenterProps._( + controller: inputProps.controller, + isFloating: inputProps.isFloating, + onSendPressed: inputProps.onSendPressed, + voiceRecordingCallback: inputProps.voiceRecordingCallback, + onAttachmentButtonPressed: inputProps.onAttachmentButtonPressed, + isPickerOpen: inputProps.isPickerOpen, + focusNode: inputProps.focusNode, + currentUserId: inputProps.currentUserId, + audioRecorderState: inputProps.audioRecorderState, + onQuotedMessageCleared: inputProps.onQuotedMessageCleared, + placeholder: inputProps.placeholder, + textInputAction: inputProps.textInputAction, + keyboardType: inputProps.keyboardType, + textCapitalization: inputProps.textCapitalization, + autofocus: inputProps.autofocus, + autocorrect: inputProps.autocorrect, + canAlsoSendToChannel: inputProps.canAlsoSendToChannel, + audioRecorderController: inputProps.audioRecorderController, + feedback: inputProps.feedback, + sendVoiceRecordingAutomatically: inputProps.sendVoiceRecordingAutomatically, + ); + } + + /// The placeholder text shown inside the input field when it is empty. + final String? placeholder; + + /// The type of action button to use for the keyboard. + final TextInputAction? textInputAction; + + /// The type of keyboard to use for editing the text. + final TextInputType? keyboardType; + + /// {@macro flutter.widgets.editableText.textCapitalization} + final TextCapitalization textCapitalization; + + /// Whether the text field should be focused initially. + final bool autofocus; + + /// Whether to enable autocorrect. + final bool autocorrect; + + /// Whether to show the "also send to channel" checkbox. + final bool canAlsoSendToChannel; + + /// The audio recorder controller. + final StreamAudioRecorderController? audioRecorderController; + + /// The feedback handler for voice recording interactions. + final AudioRecorderFeedback feedback; + + /// Whether to send the voice recording automatically when recording stops. + final bool sendVoiceRecordingAutomatically; +} + +/// Properties for building the input leading component of the message composer. +class MessageComposerInputLeadingProps extends MessageComposerComponentProps { + const MessageComposerInputLeadingProps._({ + required super.controller, + required super.isFloating, + required super.onSendPressed, + required super.voiceRecordingCallback, + required super.onAttachmentButtonPressed, + required super.isPickerOpen, + required super.focusNode, + required super.currentUserId, + required super.audioRecorderState, + required super.onQuotedMessageCleared, + }) : super(); + + /// Creates a new instance of [MessageComposerInputLeadingProps] from a [MessageComposerComponentProps]. + factory MessageComposerInputLeadingProps.from(MessageComposerComponentProps props) { + return MessageComposerInputLeadingProps._( + controller: props.controller, + isFloating: props.isFloating, + onSendPressed: props.onSendPressed, + voiceRecordingCallback: props.voiceRecordingCallback, + onAttachmentButtonPressed: props.onAttachmentButtonPressed, + isPickerOpen: props.isPickerOpen, + focusNode: props.focusNode, + currentUserId: props.currentUserId, + audioRecorderState: props.audioRecorderState, + onQuotedMessageCleared: props.onQuotedMessageCleared, + ); + } +} + +/// Properties for building the input header component of the message composer. +class MessageComposerInputHeaderProps extends MessageComposerComponentProps { + const MessageComposerInputHeaderProps._({ + required super.controller, + required super.isFloating, + required super.onSendPressed, + required super.voiceRecordingCallback, + required super.onAttachmentButtonPressed, + required super.isPickerOpen, + required super.focusNode, + required super.currentUserId, + required super.audioRecorderState, + required super.onQuotedMessageCleared, + }) : super(); + + /// Creates a new instance of [MessageComposerInputHeaderProps] from a [MessageComposerComponentProps]. + factory MessageComposerInputHeaderProps.from(MessageComposerComponentProps props) { + return MessageComposerInputHeaderProps._( + controller: props.controller, + isFloating: props.isFloating, + onSendPressed: props.onSendPressed, + voiceRecordingCallback: props.voiceRecordingCallback, + onAttachmentButtonPressed: props.onAttachmentButtonPressed, + isPickerOpen: props.isPickerOpen, + focusNode: props.focusNode, + currentUserId: props.currentUserId, + audioRecorderState: props.audioRecorderState, + onQuotedMessageCleared: props.onQuotedMessageCleared, + ); + } +} + +/// Properties for building the input trailing component of the message composer. +class MessageComposerInputTrailingProps extends MessageComposerComponentProps { + const MessageComposerInputTrailingProps._({ + required super.controller, + required super.isFloating, + required super.onSendPressed, + required super.voiceRecordingCallback, + required super.onAttachmentButtonPressed, + required super.isPickerOpen, + required super.focusNode, + required super.currentUserId, + required super.audioRecorderState, + required super.onQuotedMessageCleared, + }) : super(); + + /// Creates a new instance of [MessageComposerInputTrailingProps] from a [MessageComposerComponentProps]. + factory MessageComposerInputTrailingProps.from(MessageComposerComponentProps props) { + return MessageComposerInputTrailingProps._( + controller: props.controller, + isFloating: props.isFloating, + onSendPressed: props.onSendPressed, + voiceRecordingCallback: props.voiceRecordingCallback, + onAttachmentButtonPressed: props.onAttachmentButtonPressed, + isPickerOpen: props.isPickerOpen, + focusNode: props.focusNode, + currentUserId: props.currentUserId, + audioRecorderState: props.audioRecorderState, + onQuotedMessageCleared: props.onQuotedMessageCleared, + ); + } +} diff --git a/packages/stream_chat_flutter/lib/src/components/message_composer/message_composer_input.dart b/packages/stream_chat_flutter/lib/src/components/message_composer/message_composer_input.dart new file mode 100644 index 0000000000..6276cf98d0 --- /dev/null +++ b/packages/stream_chat_flutter/lib/src/components/message_composer/message_composer_input.dart @@ -0,0 +1,83 @@ +import 'package:flutter/widgets.dart'; +import 'package:stream_chat_flutter/src/components/message_composer/message_composer_input_center.dart'; +import 'package:stream_chat_flutter/src/components/message_composer/message_composer_input_header.dart'; +import 'package:stream_chat_flutter/src/components/message_composer/message_composer_input_leading.dart'; +import 'package:stream_chat_flutter/src/components/message_composer/message_composer_input_trailing.dart'; +import 'package:stream_chat_flutter/stream_chat_flutter.dart'; + +/// A widget that shows the input container of the message composer. +/// Uses the factory to show a fully custom container or the default +/// implementation that assembles the header, leading, center, and trailing +/// sub-components. +class StreamMessageComposerInput extends StatelessWidget { + /// Creates a new instance of [StreamMessageComposerInput]. + /// [props] contains the properties for the message composer input container, + /// including all text-field and audio-recording configuration. + const StreamMessageComposerInput({ + super.key, + required this.props, + }); + + /// The properties for the message composer input container. + final MessageComposerInputProps props; + + @override + Widget build(BuildContext context) { + return context.chatComponentBuilder()?.call(context, props) ?? + DefaultStreamMessageComposerInput(props: props); + } +} + +/// Default implementation of the message composer input container. +/// +/// Renders the rounded input surface (background, border, optional shadow) and +/// assembles the header, leading, center and trailing sub-components into a +/// [Column] / [Row] layout identical to the former core widget. +class DefaultStreamMessageComposerInput extends StatelessWidget { + /// Creates a new instance of [DefaultStreamMessageComposerInput]. + const DefaultStreamMessageComposerInput({ + super.key, + required this.props, + }); + + /// The properties for the message composer input container. + final MessageComposerInputProps props; + + @override + Widget build(BuildContext context) { + final isFloating = props.isFloating; + + return Container( + clipBehavior: Clip.antiAlias, + foregroundDecoration: BoxDecoration( + borderRadius: BorderRadius.all(context.streamRadius.xxxl), + border: Border.all( + color: context.streamColorScheme.borderDefault, + ), + ), + decoration: BoxDecoration( + color: context.streamColorScheme.backgroundElevation1, + borderRadius: BorderRadius.all(context.streamRadius.xxxl), + boxShadow: isFloating ? context.streamBoxShadow.elevation3 : null, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + StreamMessageComposerInputHeader(props: props), + Row( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + StreamMessageComposerInputLeading(props: props), + Expanded( + child: StreamMessageComposerInputCenter( + props: MessageComposerInputCenterProps.from(props), + ), + ), + StreamMessageComposerInputTrailing(props: props), + ], + ), + ], + ), + ); + } +} diff --git a/packages/stream_chat_flutter/lib/src/components/message_composer/message_composer_input_center.dart b/packages/stream_chat_flutter/lib/src/components/message_composer/message_composer_input_center.dart new file mode 100644 index 0000000000..53a02c3955 --- /dev/null +++ b/packages/stream_chat_flutter/lib/src/components/message_composer/message_composer_input_center.dart @@ -0,0 +1,101 @@ +import 'package:flutter/widgets.dart'; +import 'package:stream_chat_flutter/src/components/message_composer/message_composer_recording_locked.dart'; +import 'package:stream_chat_flutter/src/components/message_composer/message_composer_recording_ongoing.dart'; +import 'package:stream_chat_flutter/src/message_input/dm_checkbox_list_tile.dart'; +import 'package:stream_chat_flutter/stream_chat_flutter.dart'; + +/// A widget that shows the center content of the message composer input area. +/// Uses the factory to show custom components or the default implementation. +/// By default shows the text field and an optional "also send to channel" +/// checkbox, or the appropriate audio recording UI when recording is active. +class StreamMessageComposerInputCenter extends StatelessWidget { + /// Creates a new instance of [StreamMessageComposerInputCenter]. + /// [props] contains the full input properties including text field + /// configuration and audio recording settings. + const StreamMessageComposerInputCenter({super.key, required this.props}); + + /// The properties for the message composer input center. + final MessageComposerInputCenterProps props; + + @override + Widget build(BuildContext context) { + return context.chatComponentBuilder()?.call(context, props) ?? + DefaultStreamMessageComposerInputCenter(props: props); + } +} + +/// Default implementation of the center content of the message composer input. +/// +/// Shows the appropriate audio recording UI when a recording state is active, +/// or the text input field (and an optional "also send to channel" checkbox) +/// when idle. +class DefaultStreamMessageComposerInputCenter extends StatelessWidget { + /// Creates a new instance of [DefaultStreamMessageComposerInputCenter]. + const DefaultStreamMessageComposerInputCenter({super.key, required this.props}); + + /// The properties for the message composer input center. + final MessageComposerInputCenterProps props; + + @override + Widget build(BuildContext context) { + final recorder = props.audioRecorderController; + if (recorder != null) { + final sendMessageCallback = props.sendVoiceRecordingAutomatically ? props.onSendPressed : null; + final audioState = props.audioRecorderState; + + final recordingBody = switch (audioState) { + RecordStateRecordingLocked() => MessageComposerRecordingLocked( + audioRecorderController: recorder, + feedback: props.feedback, + messageComposerController: props.controller, + sendMessageCallback: sendMessageCallback, + state: audioState, + ), + RecordStateStopped() => MessageComposerRecordingStopped( + audioRecorderController: recorder, + feedback: props.feedback, + messageComposerController: props.controller, + sendMessageCallback: sendMessageCallback, + recordingState: audioState, + ), + RecordStateRecording() => StreamMessageComposerRecordingOngoing( + audioRecorderController: recorder, + ), + _ => null, + }; + + if (recordingBody != null) return recordingBody; + } + + final controller = props.controller; + + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + StreamMessageComposerInputField( + controller: controller.textFieldController, + placeholder: props.placeholder, + focusNode: props.focusNode, + command: controller.message.command?.toUpperCase(), + onDismissCommand: controller.clearCommand, + textInputAction: props.textInputAction, + keyboardType: props.keyboardType, + textCapitalization: props.textCapitalization, + autofocus: props.autofocus, + autocorrect: props.autocorrect, + ), + if (props.canAlsoSendToChannel) + DmCheckboxListTile( + value: controller.showInChannel, + // height of list tile is 34px, height of checkbox is 16px, so we need to subtract 8px to make the spacing correct. + contentPadding: EdgeInsets.only( + right: context.streamSpacing.md, + left: context.streamSpacing.md, + bottom: context.streamSpacing.md - 8, + ), + onChanged: (value) => controller.showInChannel = value, + ), + ], + ); + } +} diff --git a/packages/stream_chat_flutter/lib/src/components/message_composer/message_composer_input_field.dart b/packages/stream_chat_flutter/lib/src/components/message_composer/message_composer_input_field.dart new file mode 100644 index 0000000000..3832e447fa --- /dev/null +++ b/packages/stream_chat_flutter/lib/src/components/message_composer/message_composer_input_field.dart @@ -0,0 +1,140 @@ +import 'package:flutter/material.dart'; +import 'package:stream_chat_flutter/stream_chat_flutter.dart'; +import 'package:stream_core_flutter/stream_core_flutter.dart' as core; + +/// A widget that represents the actual text input field for the message +/// composer. +/// +/// Renders the [TextField] used by [DefaultStreamMessageComposerInputCenter], +/// along with an optional [StreamCommandChip] when a slash command is active. +class StreamMessageComposerInputField extends StatelessWidget { + /// Creates a new instance of [StreamMessageComposerInputField]. + const StreamMessageComposerInputField({ + super.key, + required this.controller, + this.placeholder, + this.focusNode, + this.command, + this.onDismissCommand, + this.textInputAction, + this.keyboardType, + this.textCapitalization = TextCapitalization.sentences, + this.autofocus = false, + this.autocorrect = true, + }); + + /// The controller for the text field. + final TextEditingController controller; + + /// The placeholder text shown when the field is empty. + final String? placeholder; + + /// The focus node for the text field. + final FocusNode? focusNode; + + /// The active command label displayed as a chip. + final String? command; + + /// Called when the user dismisses the command chip. + final VoidCallback? onDismissCommand; + + /// The type of action button to use for the keyboard. + final TextInputAction? textInputAction; + + /// The type of keyboard to use for editing the text. + final TextInputType? keyboardType; + + /// {@macro flutter.widgets.editableText.textCapitalization} + final TextCapitalization textCapitalization; + + /// Whether the text field should be focused initially. + final bool autofocus; + + /// Whether to enable autocorrect. + final bool autocorrect; + + @override + Widget build(BuildContext context) { + final spacing = context.streamSpacing; + + final inputStyle = context.streamTextInputTheme.style; + final inputDefaults = _InputThemeDefaults(context: context).style; + + final effectiveStyle = inputStyle?.textStyle ?? inputDefaults.textStyle; + final effectiveHintStyle = inputStyle?.hintStyle ?? inputDefaults.hintStyle; + final effectiveCursorColor = inputStyle?.cursorColor ?? inputDefaults.cursorColor; + final effectiveCursorWidth = inputStyle?.cursorWidth ?? inputDefaults.cursorWidth ?? 2.0; + final effectiveCursorHeight = inputStyle?.cursorHeight ?? inputDefaults.cursorHeight; + final effectiveCursorRadius = inputStyle?.cursorRadius ?? inputDefaults.cursorRadius; + + return ConstrainedBox( + constraints: const BoxConstraints(maxHeight: 124), + child: Padding( + padding: EdgeInsets.all(spacing.sm), + child: Row( + mainAxisSize: MainAxisSize.min, + spacing: spacing.xxs, + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + if (command case final command?) + core.StreamCommandChip( + label: command, + onDismiss: onDismissCommand, + ), + Expanded( + child: TextField( + controller: controller, + focusNode: focusNode, + textInputAction: textInputAction, + keyboardType: keyboardType, + textCapitalization: textCapitalization, + autofocus: autofocus, + autocorrect: autocorrect, + style: effectiveStyle, + cursorColor: effectiveCursorColor, + cursorWidth: effectiveCursorWidth, + cursorHeight: effectiveCursorHeight, + cursorRadius: effectiveCursorRadius, + maxLines: null, + decoration: InputDecoration( + isCollapsed: true, + filled: false, + border: InputBorder.none, + enabledBorder: InputBorder.none, + focusedBorder: InputBorder.none, + disabledBorder: InputBorder.none, + errorBorder: InputBorder.none, + focusedErrorBorder: InputBorder.none, + hintText: placeholder, + hintStyle: effectiveHintStyle, + contentPadding: EdgeInsets.symmetric( + horizontal: spacing.xxs, + vertical: spacing.xxxs, + ), + ), + ), + ), + ], + ), + ), + ); + } +} + +class _InputThemeDefaults { + _InputThemeDefaults({required this.context}) + : _colorScheme = context.streamColorScheme, + _textTheme = context.streamTextTheme; + + final BuildContext context; + final StreamColorScheme _colorScheme; + final core.StreamTextTheme _textTheme; + + StreamTextInputStyle get style => StreamTextInputStyle( + cursorWidth: 2, + cursorColor: _colorScheme.accentPrimary, + cursorErrorColor: _colorScheme.accentError, + textStyle: _textTheme.bodyDefault.copyWith(color: _colorScheme.textPrimary), + hintStyle: _textTheme.bodyDefault.copyWith(color: _colorScheme.textTertiary), + ); +} diff --git a/packages/stream_chat_flutter/lib/src/components/message_composer/message_composer_input_header.dart b/packages/stream_chat_flutter/lib/src/components/message_composer/message_composer_input_header.dart new file mode 100644 index 0000000000..2bfb0489e7 --- /dev/null +++ b/packages/stream_chat_flutter/lib/src/components/message_composer/message_composer_input_header.dart @@ -0,0 +1,225 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/widgets.dart'; +import 'package:stream_chat_flutter/stream_chat_flutter.dart'; +import 'package:stream_core_flutter/stream_core_flutter.dart' as core; + +/// A widget that shows the input header of the message composer. +/// Uses the factory to show custom components or used the default implementation. +class StreamMessageComposerInputHeader extends StatelessWidget { + /// Creates a new instance of [StreamMessageComposerInputHeader]. + /// [props] contains the properties for the message composer component. + const StreamMessageComposerInputHeader({super.key, required this.props}); + + /// The properties for the message composer component. + final MessageComposerComponentProps props; + + @override + Widget build(BuildContext context) { + final headerProps = MessageComposerInputHeaderProps.from(props); + + return context.chatComponentBuilder()?.call(context, headerProps) ?? + _DefaultStreamMessageComposerInputHeader(props: headerProps); + } +} + +class _DefaultStreamMessageComposerInputHeader extends StatelessWidget { + const _DefaultStreamMessageComposerInputHeader({required this.props}); + + final MessageComposerComponentProps props; + StreamMessageComposerController get controller => props.controller; + + @override + Widget build(BuildContext context) { + return ValueListenableBuilder( + valueListenable: props.controller, + builder: (context, _, __) => _buildContent(context), + ); + } + + Widget _buildContent(BuildContext context) { + final isEditing = controller.isEditing; + final quotedMessage = !isEditing ? controller.message.quotedMessage : null; + final ogAttachment = controller.ogAttachment; + final nonOGAttachments = controller.attachments + .where((it) { + return it.titleLink == null && it.type != AttachmentType.voiceRecording; + }) + .toList(growable: false); + final voiceRecordings = controller.attachments + .where((it) { + return it.type == AttachmentType.voiceRecording; + }) + .toList(growable: false); + + final hasAttachments = nonOGAttachments.isNotEmpty; + final hasContent = + isEditing || quotedMessage != null || hasAttachments || ogAttachment != null || voiceRecordings.isNotEmpty; + + final spacing = context.streamSpacing; + final contentPadding = EdgeInsets.only( + left: spacing.xs, + right: spacing.xs, + ); + + return AnimatedSize( + duration: const Duration(milliseconds: 200), + alignment: Alignment.topCenter, + child: Padding( + padding: EdgeInsets.only( + top: hasContent ? spacing.xs : 0, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + if (isEditing) + Padding( + padding: contentPadding, + child: _EditMessageInHeader( + message: controller.messageBeingEdited ?? controller.message, + onRemovePressed: controller.cancelEditMessage, + ), + ) + else if (quotedMessage != null) + Padding( + padding: contentPadding, + child: _QuotedMessageInHeader( + quotedMessage: quotedMessage, + onRemovePressed: () { + controller.clearQuotedMessage(); + props.onQuotedMessageCleared?.call(); + }, + currentUserId: props.currentUserId, + ), + ), + if (voiceRecordings.isNotEmpty) + Padding( + padding: contentPadding, + child: StreamVoiceRecordingAttachmentPlaylist( + voiceRecordings: voiceRecordings, + voiceRecordingTitle: context.translations.voiceRecordingText, + message: props.controller.message, + itemDecorator: (context, index, child) { + final attachment = voiceRecordings.elementAtOrNull(index); + if (attachment == null) return child; + + return core.StreamMessageComposerAttachment( + onRemovePressed: () => _onAttachmentRemovePressed(attachment), + child: child, + ); + }, + ), + ), + if (hasAttachments) + StreamMessageComposerAttachmentList( + attachments: nonOGAttachments, + onRemovePressed: _onAttachmentRemovePressed, + ), + if (ogAttachment != null) + Padding( + padding: contentPadding, + child: core.StreamMessageComposerLinkPreviewAttachment( + title: ogAttachment.title != null ? Text(ogAttachment.title!) : null, + subtitle: ogAttachment.text != null ? Text(ogAttachment.text!) : null, + caption: ogAttachment.titleLink != null ? Text(ogAttachment.titleLink!) : null, + thumbnail: switch (ogAttachment.imageUrl) { + final imageUrl? when imageUrl.isNotEmpty => core.StreamNetworkImage(imageUrl, fit: .cover), + _ => null, + }, + onRemovePressed: () { + controller.clearOGAttachment(); + props.focusNode?.unfocus(); + }, + ), + ), + ], + ), + ), + ); + } + + // Default callback for removing an attachment. + Future _onAttachmentRemovePressed(Attachment attachment) async { + final file = attachment.file; + final uploadState = attachment.uploadState; + + if (file != null && !uploadState.isSuccess && !isWeb) { + await StreamAttachmentHandler.instance.deleteAttachmentFile( + attachmentFile: file, + ); + } + + controller.removeAttachmentById(attachment.id); + } +} + +class _EditMessageInHeader extends StatelessWidget { + const _EditMessageInHeader({ + required this.message, + required this.onRemovePressed, + }); + + final Message message; + final VoidCallback onRemovePressed; + + @override + Widget build(BuildContext context) { + return core.StreamMessageComposerEditMessageAttachment( + title: Text(context.translations.editMessageLabel), + subtitle: StreamMessagePreviewText(message: message), + onRemovePressed: onRemovePressed, + ); + } +} + +class _QuotedMessageInHeader extends StatelessWidget { + const _QuotedMessageInHeader({ + required this.quotedMessage, + required this.onRemovePressed, + required this.currentUserId, + }); + + final Message quotedMessage; + final VoidCallback onRemovePressed; + final String? currentUserId; + + Widget? _buildThumbnail(BuildContext context, Message message) { + final attachments = message.attachments; + if (attachments.isEmpty || attachments.length > 1) return null; + + final attachment = attachments.first; + final type = attachment.type; + + if (type == .image || type == .video || type == .giphy) { + return StreamMediaAttachmentThumbnail(media: attachment, fit: .cover); + } + + if (type == .file) { + // Only show a single file-type icon when every file shares a mime type. + final mimeType = attachment.mimeType; + if (mimeType == null) return null; + if (attachments.any((it) => it.mimeType != mimeType)) return null; + return StreamFileTypeIcon.fromMimeType(mimeType: mimeType, size: .lg); + } + + return null; + } + + @override + Widget build(BuildContext context) { + final isIncoming = currentUserId != quotedMessage.user?.id; + + final translations = context.translations; + final title = switch (isIncoming) { + true => translations.replyToUserLabel(quotedMessage.user?.name ?? ''), + false => translations.youText, + }; + + return core.StreamMessageComposerReplyAttachment( + title: Text(title), + subtitle: StreamMessagePreviewText(message: quotedMessage), + onRemovePressed: onRemovePressed, + thumbnail: _buildThumbnail(context, quotedMessage), + direction: isIncoming ? .incoming : .outgoing, + ); + } +} diff --git a/packages/stream_chat_flutter/lib/src/components/message_composer/message_composer_input_leading.dart b/packages/stream_chat_flutter/lib/src/components/message_composer/message_composer_input_leading.dart new file mode 100644 index 0000000000..2399796aa3 --- /dev/null +++ b/packages/stream_chat_flutter/lib/src/components/message_composer/message_composer_input_leading.dart @@ -0,0 +1,22 @@ +import 'package:flutter/widgets.dart'; +import 'package:stream_chat_flutter/stream_chat_flutter.dart'; + +/// A widget that shows the input leading of the message composer. +/// Uses the factory to show custom components, but is empty by default. +class StreamMessageComposerInputLeading extends StatelessWidget { + /// Creates a new instance of [StreamMessageComposerInputLeading]. + /// [props] contains the properties for the message composer component. + const StreamMessageComposerInputLeading({super.key, required this.props}); + + /// The properties for the message composer component. + final MessageComposerComponentProps props; + + @override + Widget build(BuildContext context) { + return context.chatComponentBuilder()?.call( + context, + MessageComposerInputLeadingProps.from(props), + ) ?? + const SizedBox.shrink(); + } +} diff --git a/packages/stream_chat_flutter/lib/src/components/message_composer/message_composer_input_trailing.dart b/packages/stream_chat_flutter/lib/src/components/message_composer/message_composer_input_trailing.dart new file mode 100644 index 0000000000..0681245311 --- /dev/null +++ b/packages/stream_chat_flutter/lib/src/components/message_composer/message_composer_input_trailing.dart @@ -0,0 +1,144 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/widgets.dart'; +import 'package:stream_chat_flutter/stream_chat_flutter.dart'; +import 'package:stream_core_flutter/stream_core_flutter.dart'; + +/// A widget that shows the input trailing of the message composer. +/// Uses the factory to show custom components or the default implementation. +/// By default it shows the send button and the microphone button. +class StreamMessageComposerInputTrailing extends StatelessWidget { + /// Creates a new instance of [StreamMessageComposerInputTrailing]. + /// [props] contains the properties for the message composer component. + const StreamMessageComposerInputTrailing({super.key, required this.props}); + + /// The properties for the message composer component. + final MessageComposerComponentProps props; + + @override + Widget build(BuildContext context) { + final trailingProps = MessageComposerInputTrailingProps.from(props); + + return context.chatComponentBuilder()?.call(context, trailingProps) ?? + DefaultStreamMessageComposerInputTrailing(props: trailingProps); + } +} + +/// Default implementation of the input trailing of the message composer. +/// Shows the send button or the microphone button based on the state of the message composer. +/// It shows no button when the audio recording flow is locked or stopped. +class DefaultStreamMessageComposerInputTrailing extends StatelessWidget { + /// Creates a new instance of [DefaultStreamMessageComposerInputTrailing]. + /// [props] contains the properties for the message composer component. + const DefaultStreamMessageComposerInputTrailing({super.key, required this.props}); + + /// The properties for the message composer component. + final MessageComposerComponentProps props; + + StreamMessageComposerController get _controller => props.controller; + + @override + Widget build(BuildContext context) { + return ValueListenableBuilder( + valueListenable: _controller, + builder: (context, value, child) { + final hasText = _controller.text.trim().isNotEmpty; + final hasContent = hasText || _controller.attachments.isNotEmpty; + final isEditing = _controller.isEditing; + final hasCommand = _controller.message.command != null; + var buttonState = _ButtonState.microphone; + if (props.isAudioRecordingFlowActive) { + buttonState = _ButtonState.voiceRecordingActive; + } + + if (isEditing) { + buttonState = _ButtonState.edit; + } else if (hasCommand) { + buttonState = _ButtonState.command; + } else if (hasContent) { + buttonState = _ButtonState.send; + } + + final isEnabled = (!isEditing && !hasCommand) || hasContent; + + if (props.isAudioRecordingFlowLocked || props.isAudioRecordingFlowStopped) { + return const SizedBox.shrink(); + } + + final voiceRecordingCallback = props.voiceRecordingCallback; + if (buttonState == _ButtonState.send || + buttonState == _ButtonState.edit || + buttonState == _ButtonState.command || + voiceRecordingCallback == null) { + return StreamButton.icon( + key: _sendKey, + icon: Icon( + buttonState == _ButtonState.edit || buttonState == _ButtonState.command + ? context.streamIcons.checkmark + : context.streamIcons.send, + ), + size: StreamButtonSize.small, + onPressed: isEnabled ? props.onSendPressed : null, + ); + } + + return _VoiceRecordingButton( + voiceRecordingCallback: voiceRecordingCallback, + isRecording: buttonState == _ButtonState.voiceRecordingActive, + ); + }, + ); + } +} + +enum _ButtonState { + send, + edit, + command, + microphone, + voiceRecordingActive, +} + +class _VoiceRecordingButton extends StatelessWidget { + const _VoiceRecordingButton({ + required this.voiceRecordingCallback, + required this.isRecording, + }); + + final VoiceRecordingCallback voiceRecordingCallback; + final bool isRecording; + + @override + Widget build(BuildContext context) { + return GestureDetector( + key: _microphoneKey, + onLongPress: voiceRecordingCallback.onLongPressStart, + onLongPressCancel: voiceRecordingCallback.onLongPressCancel, + onLongPressEnd: voiceRecordingCallback.onLongPressEnd, + onLongPressMoveUpdate: voiceRecordingCallback.onLongPressMoveUpdate, + behavior: HitTestBehavior.translucent, + child: StreamButtonTheme( + data: StreamButtonThemeData( + secondary: StreamButtonTypeStyle( + ghost: StreamButtonThemeStyle( + backgroundColor: isRecording + ? WidgetStateProperty.all( + context.streamColorScheme.backgroundPressed, + ) + : null, + ), + ), + ), + child: StreamButton.icon( + icon: Icon(context.streamIcons.voice), + type: StreamButtonType.ghost, + style: StreamButtonStyle.secondary, + size: StreamButtonSize.small, + onPressed: () {}, + ), + ), + ); + } +} + +final _sendKey = UniqueKey(); +final _microphoneKey = UniqueKey(); diff --git a/packages/stream_chat_flutter/lib/src/components/message_composer/message_composer_leading.dart b/packages/stream_chat_flutter/lib/src/components/message_composer/message_composer_leading.dart new file mode 100644 index 0000000000..50113af72f --- /dev/null +++ b/packages/stream_chat_flutter/lib/src/components/message_composer/message_composer_leading.dart @@ -0,0 +1,73 @@ +import 'package:flutter/material.dart'; +import 'package:stream_chat_flutter/stream_chat_flutter.dart'; + +/// A widget that shows the leading of the message composer. +/// Uses the factory to show custom components or the default implementation. +/// By default this contains a button to add attachments. +class StreamMessageComposerLeading extends StatelessWidget { + /// Creates a new instance of [StreamMessageComposerLeading]. + /// [props] contains the properties for the message composer component. + const StreamMessageComposerLeading({super.key, required this.props}); + + /// The properties for the message composer component. + final MessageComposerComponentProps props; + + @override + Widget build(BuildContext context) { + final leadingProps = MessageComposerLeadingProps.from(props); + + return context.chatComponentBuilder()?.call(context, leadingProps) ?? + DefaultStreamMessageComposerLeading(props: leadingProps); + } +} + +/// Default implementation of the leading of the message composer. +/// Shows the attachment button when the message composer is not in audio recording flow and no command is selected. +class DefaultStreamMessageComposerLeading extends StatelessWidget { + /// Creates a new instance of [DefaultStreamMessageComposerLeading]. + /// [props] contains the properties for the message composer component. + const DefaultStreamMessageComposerLeading({super.key, required this.props}); + + /// The properties for the message composer component. + final MessageComposerComponentProps props; + + @override + Widget build(BuildContext context) { + // 45 degrees = 0.125 turns + const closedRotation = 0.125; + final showButton = + props.onAttachmentButtonPressed != null && + !props.isAudioRecordingFlowActive && + props.controller.message.command == null; + + return AnimatedOpacity( + opacity: showButton ? 1.0 : 0.0, + duration: showButton ? const Duration(milliseconds: 200) : Duration.zero, + curve: Curves.easeInQuint, + child: AnimatedSize( + duration: const Duration(milliseconds: 200), + alignment: Alignment.bottomCenter, + child: Row( + children: [ + if (showButton) ...[ + AnimatedRotation( + turns: props.isPickerOpen ? closedRotation : 0, + duration: const Duration(milliseconds: 150), + curve: Curves.easeOut, + child: StreamButton.icon( + icon: Icon(context.streamIcons.plus), + style: StreamButtonStyle.secondary, + type: StreamButtonType.outline, + size: StreamButtonSize.large, + isFloating: props.isFloating, + onPressed: () => props.onAttachmentButtonPressed?.call(), + ), + ), + SizedBox(width: context.streamSpacing.xs), + ], + ], + ), + ), + ); + } +} diff --git a/packages/stream_chat_flutter/lib/src/components/message_composer/message_composer_recording_locked.dart b/packages/stream_chat_flutter/lib/src/components/message_composer/message_composer_recording_locked.dart new file mode 100644 index 0000000000..bed54e5ae9 --- /dev/null +++ b/packages/stream_chat_flutter/lib/src/components/message_composer/message_composer_recording_locked.dart @@ -0,0 +1,327 @@ +import 'package:flutter/material.dart'; +import 'package:stream_chat_flutter/src/audio/audio_playlist_controller.dart'; +import 'package:stream_chat_flutter/src/audio/audio_playlist_state.dart'; +import 'package:stream_chat_flutter/src/audio/audio_sampling.dart' as sampling; +import 'package:stream_chat_flutter/stream_chat_flutter.dart'; + +const _kDefaultWaveformHeight = 20.0; +const _kDefaultWaveformLimit = 35; + +/// Widget to display the recording locked state. +/// This widget can be used inside of the [StreamBaseMessageComposer] instead of the default `inputCenter`. +class MessageComposerRecordingLocked extends StatelessWidget { + /// Creates a new instance of [MessageComposerRecordingLocked]. + /// [audioRecorderController] is the controller for the audio recorder. + /// [feedback] is the feedback for the audio recorder. + /// [messageComposerController] is the controller for the message composer. + /// [sendMessageCallback] is the callback for when the message is sent automatically. + const MessageComposerRecordingLocked({ + super.key, + required this.audioRecorderController, + required this.feedback, + required this.messageComposerController, + required this.sendMessageCallback, + required this.state, + }); + + /// The controller for the audio recorder. + final StreamAudioRecorderController audioRecorderController; + + /// The feedback for the audio recorder. + final AudioRecorderFeedback feedback; + + /// The controller for the message composer. + final StreamMessageComposerController messageComposerController; + + /// The callback for when the message is sent automatically. + /// This callback should be null when the message is not supposed to be sent automatically. + final VoidCallback? sendMessageCallback; + + /// The state of the recording. + final RecordStateRecording state; + + @override + Widget build(BuildContext context) { + final icons = context.streamIcons; + final textTheme = context.streamTextTheme; + final colorScheme = context.streamColorScheme; + final spacing = context.streamSpacing; + + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.start, + children: [ + Container( + height: 48, + width: 48, + alignment: Alignment.center, + child: Icon( + icons.voice, + color: context.streamColorScheme.accentError, + size: 20, + ), + ), + Text( + state.duration.toMinutesAndSeconds(), + style: textTheme.captionEmphasis.copyWith( + color: colorScheme.textPrimary, + fontFeatures: [const FontFeature.tabularFigures()], + ), + ), + Expanded( + child: Container( + height: _kDefaultWaveformHeight, + padding: EdgeInsets.symmetric(horizontal: spacing.md), + child: StreamAudioWaveform(waveform: state.waveform, limit: 50), + ), + ), + ], + ), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + StreamButton.icon( + key: const ValueKey('cancel-record-button'), + style: StreamButtonStyle.secondary, + type: StreamButtonType.outline, + size: StreamButtonSize.small, + icon: Icon(icons.delete), + onPressed: audioRecorderController.cancelRecord, + ), + if (audioRecorderController.value is RecordStateRecording) + StreamButton.icon( + key: const ValueKey('stop-record-button'), + style: StreamButtonStyle.destructive, + type: StreamButtonType.outline, + size: StreamButtonSize.small, + icon: Icon(icons.stopFill), + onPressed: audioRecorderController.stopRecord, + ), + StreamButton.icon( + key: const ValueKey('finish-record-button'), + style: StreamButtonStyle.primary, + type: StreamButtonType.solid, + size: StreamButtonSize.small, + icon: Icon(icons.checkmark), + onPressed: () async { + await feedback.onRecordFinish(context); + final audio = await audioRecorderController.finishRecord(); + if (audio != null) { + messageComposerController.addAttachment(audio); + } + + // Once the recording is finished, cancel the recorder. + audioRecorderController.cancelRecord(discardTrack: false); + + // Send the message if the user has enabled the option to + // send the voice recording automatically. + sendMessageCallback?.call(); + }, + ), + ], + ), + ], + ); + } +} + +/// Widget to display the recording stopped state. +/// This widget can be used inside of the [StreamBaseMessageComposer] instead of the default `inputCenter`. +class MessageComposerRecordingStopped extends StatefulWidget { + /// Creates a new instance of [MessageComposerRecordingStopped]. + /// [audioRecorderController] is the controller for the audio recorder. + /// [feedback] is the feedback for the audio recorder. + /// [messageComposerController] is the controller for the message composer. + /// [sendMessageCallback] is the callback for when the message is sent automatically. + const MessageComposerRecordingStopped({ + super.key, + required this.audioRecorderController, + required this.feedback, + required this.messageComposerController, + required this.sendMessageCallback, + required this.recordingState, + }); + + /// The controller for the audio recorder. + final StreamAudioRecorderController audioRecorderController; + + /// The feedback for the audio recorder. + final AudioRecorderFeedback feedback; + + /// The controller for the message composer. + final StreamMessageComposerController messageComposerController; + + /// The callback for when the message is sent automatically. + /// This callback should be null when the message is not supposed to be sent automatically. + final VoidCallback? sendMessageCallback; + + /// The state of the recording. + final RecordStateStopped recordingState; + + Attachment get _audioRecording => recordingState.audioRecording; + + @override + State createState() => _MessageComposerRecordingStoppedState(); +} + +class _MessageComposerRecordingStoppedState extends State { + late final _controller = StreamAudioPlaylistController( + widget._audioRecording.toPlaylist(), + ); + + @override + void initState() { + super.initState(); + _controller.initialize(); + } + + @override + void didUpdateWidget( + covariant MessageComposerRecordingStopped oldWidget, + ) { + super.didUpdateWidget(oldWidget); + if (widget._audioRecording != widget._audioRecording) { + // If the playlist have changed, update the playlist. + _controller.updatePlaylist(widget._audioRecording.toPlaylist()); + } + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final icons = context.streamIcons; + final textTheme = context.streamTextTheme; + final colorScheme = context.streamColorScheme; + const index = 0; + + final spacing = context.streamSpacing; + + return ValueListenableBuilder( + valueListenable: _controller, + builder: (context, state, child) { + final track = state.tracks.firstOrNull; + if (track == null) return const SizedBox.shrink(); + + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.start, + children: [ + AudioControlButton( + state: track.state, + style: StreamButtonStyle.secondary, + type: StreamButtonType.ghost, + size: StreamButtonSize.small, + onPlay: () { + if (state.currentIndex == index) { + // Play the track directly if it is already loaded. + _controller.play(); + } else { + // Otherwise, load the track first and then play it. + _controller.skipToItem(index); + } + }, + onPause: _controller.pause, + ), + AudioDurationText( + duration: track.duration, + position: track.position, + style: textTheme.captionEmphasis.copyWith( + color: colorScheme.textPrimary, + fontFeatures: [const FontFeature.tabularFigures()], + ), + ), + + Expanded( + child: Container( + padding: EdgeInsets.symmetric(horizontal: spacing.md), + height: _kDefaultWaveformHeight, + child: StreamAudioWaveformSlider( + limit: _kDefaultWaveformLimit, + waveform: sampling.resampleWaveformData( + track.waveform, + _kDefaultWaveformLimit, + ), + progress: track.progress, + // Only allow seeking if the current track is the one being + // interacted with. + onChangeStart: (_) async { + if (state.currentIndex != index) return; + return _controller.pause(); + }, + onChangeEnd: (_) async { + if (state.currentIndex != index) return; + return _controller.play(); + }, + onChanged: (progress) async { + if (state.currentIndex != index) return; + + final duration = track.duration.inMicroseconds; + final seekPosition = (duration * progress).toInt(); + final seekDuration = Duration(microseconds: seekPosition); + + return _controller.seek(seekDuration); + }, + isActive: track.state != TrackState.idle, + ), + ), + ), + ], + ), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + StreamButton.icon( + key: const ValueKey('cancel-record-button'), + style: StreamButtonStyle.secondary, + type: StreamButtonType.outline, + size: StreamButtonSize.small, + icon: Icon(icons.delete), + onPressed: widget.audioRecorderController.cancelRecord, + ), + if (widget.audioRecorderController.value is RecordStateRecording) + StreamButton.icon( + key: const ValueKey('stop-record-button'), + style: StreamButtonStyle.destructive, + type: StreamButtonType.outline, + size: StreamButtonSize.small, + icon: Icon(icons.stopFill), + onPressed: widget.audioRecorderController.stopRecord, + ), + StreamButton.icon( + key: const ValueKey('finish-record-button'), + style: StreamButtonStyle.primary, + type: StreamButtonType.solid, + size: StreamButtonSize.small, + icon: Icon(icons.checkmark), + onPressed: () async { + await widget.feedback.onRecordFinish(context); + final audio = await widget.audioRecorderController.finishRecord(); + if (audio != null) { + widget.messageComposerController.addAttachment(audio); + } + + // Once the recording is finished, cancel the recorder. + widget.audioRecorderController.cancelRecord(discardTrack: false); + + // Send the message if the user has enabled the option to + // send the voice recording automatically. + widget.sendMessageCallback?.call(); + }, + ), + ], + ), + ], + ); + }, + ); + } +} diff --git a/packages/stream_chat_flutter/lib/src/components/message_composer/message_composer_recording_ongoing.dart b/packages/stream_chat_flutter/lib/src/components/message_composer/message_composer_recording_ongoing.dart new file mode 100644 index 0000000000..9a1cbf2c03 --- /dev/null +++ b/packages/stream_chat_flutter/lib/src/components/message_composer/message_composer_recording_ongoing.dart @@ -0,0 +1,88 @@ +import 'package:flutter/material.dart'; +import 'package:stream_chat_flutter/stream_chat_flutter.dart'; + +/// Widget to display the recording ongoing state. +/// This widget can be used inside of the [StreamBaseMessageComposer] instead of the default `inputCenter`. +/// It shows a hint to slide to cancel the recording. +class StreamMessageComposerRecordingOngoing extends StatelessWidget { + /// Creates a new instance of [StreamMessageComposerRecordingOngoing]. + /// [audioRecorderController] is the controller for the audio recorder. + const StreamMessageComposerRecordingOngoing({super.key, required this.audioRecorderController}); + + /// The controller for the audio recorder. + final StreamAudioRecorderController audioRecorderController; + + @override + Widget build(BuildContext context) { + final textTheme = context.streamTextTheme; + final colorScheme = context.streamColorScheme; + final icons = context.streamIcons; + + return ConstrainedBox( + constraints: const BoxConstraints( + minHeight: 48, + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Container( + height: 48, + width: 48, + alignment: Alignment.center, + child: Icon( + icons.voice, + color: context.streamColorScheme.accentError, + size: 20, + ), + ), + ValueListenableBuilder( + valueListenable: audioRecorderController, + builder: (context, state, child) { + final duration = state is RecordStateRecording ? state.duration : Duration.zero; + return Text( + duration.toMinutesAndSeconds(), + style: textTheme.captionEmphasis.copyWith( + color: colorScheme.textPrimary, + fontFeatures: [const FontFeature.tabularFigures()], + ), + ); + }, + ), + const SizedBox(width: 23), + _GradientText( + context.translations.slideToCancelLabel, + style: context.streamTextTheme.bodyDefault, + gradient: LinearGradient( + colors: [colorScheme.textPrimary, colorScheme.textTertiary], + ), + ), + SizedBox(width: context.streamSpacing.xxs), + Icon(icons.chevronLeft, color: colorScheme.textTertiary, size: 20), + ], + ), + ); + } +} + +class _GradientText extends StatelessWidget { + const _GradientText( + this.text, { + required this.gradient, + this.style, + }); + + final String text; + final TextStyle? style; + final Gradient gradient; + + @override + Widget build(BuildContext context) { + return ShaderMask( + blendMode: BlendMode.srcIn, + shaderCallback: (bounds) => gradient.createShader( + Rect.fromLTWH(0, 0, bounds.width, bounds.height), + ), + child: Text(text, style: style), + ); + } +} diff --git a/packages/stream_chat_flutter/lib/src/components/message_composer/message_composer_trailing.dart b/packages/stream_chat_flutter/lib/src/components/message_composer/message_composer_trailing.dart new file mode 100644 index 0000000000..f5399ebc34 --- /dev/null +++ b/packages/stream_chat_flutter/lib/src/components/message_composer/message_composer_trailing.dart @@ -0,0 +1,23 @@ +import 'package:flutter/widgets.dart'; +import 'package:stream_chat_flutter/stream_chat_flutter.dart'; + +/// A widget that shows the trailing of the message composer. +/// Uses the factory to show custom components or the default implementation. +/// By default this area is empty. +class StreamMessageComposerTrailing extends StatelessWidget { + /// Creates a new instance of [StreamMessageComposerTrailing]. + /// [props] contains the properties for the message composer component. + const StreamMessageComposerTrailing({super.key, required this.props}); + + /// The properties for the message composer component. + final MessageComposerComponentProps props; + + @override + Widget build(BuildContext context) { + return context.chatComponentBuilder()?.call( + context, + MessageComposerTrailingProps.from(props), + ) ?? + const SizedBox.shrink(); + } +} diff --git a/packages/stream_chat_flutter/lib/src/components/stream_chat_component_builders.dart b/packages/stream_chat_flutter/lib/src/components/stream_chat_component_builders.dart new file mode 100644 index 0000000000..cbce66f01d --- /dev/null +++ b/packages/stream_chat_flutter/lib/src/components/stream_chat_component_builders.dart @@ -0,0 +1,67 @@ +import 'package:flutter/widgets.dart'; +import 'package:stream_chat_flutter/stream_chat_flutter.dart'; + +/// Builds the list of component builders for the stream chat components. +Iterable> streamChatComponentBuilders({ + StreamComponentBuilder? channelListItem, + StreamComponentBuilder? threadListItem, + StreamComponentBuilder? messageComposer, + StreamComponentBuilder? messageComposerLeading, + StreamComponentBuilder? messageComposerTrailing, + StreamComponentBuilder? messageComposerInput, + StreamComponentBuilder? messageComposerInputCenter, + StreamComponentBuilder? messageComposerInputLeading, + StreamComponentBuilder? messageComposerInputHeader, + StreamComponentBuilder? messageComposerInputTrailing, + StreamComponentBuilder? messageItem, + StreamComponentBuilder? messageComposerAttachmentList, + StreamComponentBuilder? messageComposerAttachment, + StreamComponentBuilder? imageAttachment, + StreamComponentBuilder? videoAttachment, + StreamComponentBuilder? giphyAttachment, + StreamComponentBuilder? galleryAttachment, + StreamComponentBuilder? fileAttachment, + StreamComponentBuilder? linkPreviewAttachment, + StreamComponentBuilder? voiceRecordingAttachment, + StreamComponentBuilder? pollAttachment, + StreamComponentBuilder? quotedMessage, + StreamComponentBuilder? unsupportedAttachment, + StreamComponentBuilder? mediaGallery, + StreamComponentBuilder? mediaGalleryPreview, +}) { + final builders = [ + if (channelListItem != null) StreamComponentBuilderExtension(builder: channelListItem), + if (threadListItem != null) StreamComponentBuilderExtension(builder: threadListItem), + if (messageComposer != null) StreamComponentBuilderExtension(builder: messageComposer), + if (messageComposerLeading != null) StreamComponentBuilderExtension(builder: messageComposerLeading), + if (messageComposerTrailing != null) StreamComponentBuilderExtension(builder: messageComposerTrailing), + if (messageComposerInput != null) StreamComponentBuilderExtension(builder: messageComposerInput), + if (messageComposerInputCenter != null) StreamComponentBuilderExtension(builder: messageComposerInputCenter), + if (messageComposerInputLeading != null) StreamComponentBuilderExtension(builder: messageComposerInputLeading), + if (messageComposerInputHeader != null) StreamComponentBuilderExtension(builder: messageComposerInputHeader), + if (messageComposerInputTrailing != null) StreamComponentBuilderExtension(builder: messageComposerInputTrailing), + if (messageItem != null) StreamComponentBuilderExtension(builder: messageItem), + if (messageComposerAttachmentList != null) StreamComponentBuilderExtension(builder: messageComposerAttachmentList), + if (messageComposerAttachment != null) StreamComponentBuilderExtension(builder: messageComposerAttachment), + if (imageAttachment != null) StreamComponentBuilderExtension(builder: imageAttachment), + if (videoAttachment != null) StreamComponentBuilderExtension(builder: videoAttachment), + if (giphyAttachment != null) StreamComponentBuilderExtension(builder: giphyAttachment), + if (galleryAttachment != null) StreamComponentBuilderExtension(builder: galleryAttachment), + if (fileAttachment != null) StreamComponentBuilderExtension(builder: fileAttachment), + if (linkPreviewAttachment != null) StreamComponentBuilderExtension(builder: linkPreviewAttachment), + if (voiceRecordingAttachment != null) StreamComponentBuilderExtension(builder: voiceRecordingAttachment), + if (pollAttachment != null) StreamComponentBuilderExtension(builder: pollAttachment), + if (quotedMessage != null) StreamComponentBuilderExtension(builder: quotedMessage), + if (unsupportedAttachment != null) StreamComponentBuilderExtension(builder: unsupportedAttachment), + if (mediaGallery != null) StreamComponentBuilderExtension(builder: mediaGallery), + if (mediaGalleryPreview != null) StreamComponentBuilderExtension(builder: mediaGalleryPreview), + ]; + + return builders; +} + +/// Helper extensions for the factory builders. +extension StreamChatComponentBuildersExtension on BuildContext { + /// The builder for the given component type. + StreamComponentBuilder? chatComponentBuilder() => StreamComponentFactory.of(this).extension(); +} diff --git a/packages/stream_chat_flutter/lib/src/context_menu/context_menu.dart b/packages/stream_chat_flutter/lib/src/context_menu/context_menu.dart index 12b9e68e25..2158b77e31 100644 --- a/packages/stream_chat_flutter/lib/src/context_menu/context_menu.dart +++ b/packages/stream_chat_flutter/lib/src/context_menu/context_menu.dart @@ -1,16 +1,17 @@ import 'package:flutter/material.dart'; +import 'package:stream_chat_flutter/stream_chat_flutter.dart'; const double _kContextMenuScreenPadding = 8; -const double _kContextMenuWidth = 222; -/// Signature for a builder function that wraps the context menu widget. +/// Signature for a builder function that wraps the context menu items. /// /// This builder can be used to customize the appearance of the menu -/// container by wrapping [child] in additional UI elements. -typedef ContextMenuBuilder = Widget Function( - BuildContext context, - Widget child, -); +/// container by wrapping [menuItems] in additional UI elements. +typedef ContextMenuContainerBuilder = + Widget Function( + BuildContext context, + List menuItems, + ); /// A widget that displays a context menu anchored to a specific [Offset]. /// @@ -41,35 +42,20 @@ class ContextMenu extends StatelessWidget { /// Builds the outer container for the menu. /// - /// The [menuBuilder] receives the current context and a [child] widget - /// containing all the [menuItems]. + /// The [menuBuilder] receives the current context and the [menuItems] list, + /// and should return a widget wrapping all items. /// - /// Defaults to a card-style scrollable container with fixed width. - final ContextMenuBuilder menuBuilder; + /// Defaults to a [StreamContextMenu] wrapping all items. + final ContextMenuContainerBuilder menuBuilder; /// Default menu container with standard styling. /// /// Wraps the menu content in a card-like [Material] with scroll support, /// applying max width and height constraints. - static Widget _defaultMenuBuilder(BuildContext context, Widget child) { - final availableHeight = MediaQuery.of(context).size.height; - final maxHeight = availableHeight - _kContextMenuScreenPadding * 2; - - return ConstrainedBox( - constraints: BoxConstraints( - minWidth: _kContextMenuWidth, - maxWidth: _kContextMenuWidth, - maxHeight: maxHeight, - ), - child: Material( - elevation: 1, - type: MaterialType.card, - clipBehavior: Clip.antiAlias, - borderRadius: const BorderRadius.all(Radius.circular(7)), - child: SingleChildScrollView(child: child), - ), - ); - } + static Widget _defaultMenuBuilder( + BuildContext context, + List menuItems, + ) => StreamContextMenu(children: menuItems); @override Widget build(BuildContext context) { @@ -90,14 +76,7 @@ class ContextMenu extends StatelessWidget { delegate: DesktopTextSelectionToolbarLayoutDelegate( anchor: anchor - localAdjustment, ), - child: menuBuilder.call( - context, - Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: menuItems, - ), - ), + child: menuBuilder.call(context, menuItems), ), ); } diff --git a/packages/stream_chat_flutter/lib/src/context_menu/context_menu_region.dart b/packages/stream_chat_flutter/lib/src/context_menu/context_menu_region.dart index d3554889dc..9a30e03e3a 100644 --- a/packages/stream_chat_flutter/lib/src/context_menu/context_menu_region.dart +++ b/packages/stream_chat_flutter/lib/src/context_menu/context_menu_region.dart @@ -6,14 +6,15 @@ import 'package:stream_chat_flutter_core/stream_chat_flutter_core.dart'; /// /// The function receives the [BuildContext] and the [Offset] where /// the menu should appear. -typedef ContextMenuBuilder = Widget Function( - BuildContext context, - Offset offset, -); +typedef ContextMenuBuilder = + Widget Function( + BuildContext context, + Offset offset, + ); /// Displays a custom context menu as a general dialog. /// -/// The [contextMenuBuilder] is used to construct the contents of the +/// The [menuBuilder] is used to construct the contents of the /// context menu, typically positioned based on the triggering gesture. /// /// The dialog can be customized using parameters such as [barrierColor], @@ -22,7 +23,7 @@ typedef ContextMenuBuilder = Widget Function( /// Returns a [Future] that resolves when the menu is dismissed. Future showContextMenu({ required BuildContext context, - required WidgetBuilder contextMenuBuilder, + required WidgetBuilder menuBuilder, String? barrierLabel, Color? barrierColor, Duration transitionDuration = const Duration(milliseconds: 150), @@ -59,7 +60,7 @@ Future showContextMenu({ ); }, pageBuilder: (context, animation, secondaryAnimation) { - final pageChild = Builder(builder: contextMenuBuilder); + final pageChild = Builder(builder: menuBuilder); return capturedThemes.wrap(pageChild); }, ); @@ -73,11 +74,12 @@ class ContextMenuRegion extends StatefulWidget { /// Creates a [ContextMenuRegion]. /// /// The [child] is the widget wrapped by this region. When a gesture is - /// detected on it, the [contextMenuBuilder] is used to construct the menu. + /// detected on it, the [menuBuilder] is used to construct the menu. const ContextMenuRegion({ super.key, required this.child, - required this.contextMenuBuilder, + required this.menuBuilder, + this.onSelected, }); /// The widget below this widget in the tree. @@ -86,7 +88,14 @@ class ContextMenuRegion extends StatefulWidget { /// Called to build the context menu when the gesture is triggered. /// /// The builder is given the [BuildContext] and the [Offset] of the gesture. - final ContextMenuBuilder contextMenuBuilder; + final ContextMenuBuilder menuBuilder; + + /// Called with the value returned when the context menu is dismissed. + /// + /// When a menu item pops the route with a value (e.g. via + /// [Navigator.pop]), that value is forwarded here. If the menu is dismissed + /// without a selection the value will be `null`. + final ValueChanged? onSelected; @override State createState() => _ContextMenuRegionState(); @@ -107,14 +116,16 @@ class _ContextMenuRegionState extends State { super.dispose(); } - Future _showContextMenu(BuildContext context, Offset position) async { - print('ContextMenuRegion: Showing context menu at $position'); - await showContextMenu( + Future _showContextMenu( + BuildContext context, + Offset position, + ) async { + final result = await showContextMenu( context: context, - contextMenuBuilder: (context) { - return widget.contextMenuBuilder(context, position); - }, + menuBuilder: (context) => widget.menuBuilder(context, position), ); + + return widget.onSelected?.call(result); } @override diff --git a/packages/stream_chat_flutter/lib/src/context_menu_items/context_menu_reaction_picker.dart b/packages/stream_chat_flutter/lib/src/context_menu_items/context_menu_reaction_picker.dart deleted file mode 100644 index fb22d44d27..0000000000 --- a/packages/stream_chat_flutter/lib/src/context_menu_items/context_menu_reaction_picker.dart +++ /dev/null @@ -1,168 +0,0 @@ -import 'package:ezanimation/ezanimation.dart'; -import 'package:flutter/material.dart'; -import 'package:stream_chat_flutter/stream_chat_flutter.dart'; - -/// {@template contextMenuReactionPicker} -/// Allows the user to select reactions to a message on desktop & web via -/// context menu. -/// -/// This differs slightly from [StreamReactionPicker] in order to match our -/// design spec. -/// -/// Used by the `_buildContextMenu()` function found in `message_widget.dart`. -/// It is not recommended to use this widget directly. -/// {@endtemplate} -class ContextMenuReactionPicker extends StatefulWidget { - /// {@macro contextMenuReactionPicker} - const ContextMenuReactionPicker({ - super.key, - required this.message, - }); - - /// The message to react to. - final Message message; - - @override - State createState() => - _ContextMenuReactionPickerState(); -} - -class _ContextMenuReactionPickerState extends State - with TickerProviderStateMixin { - List animations = []; - - Future triggerAnimations() async { - for (final a in animations) { - a.start(); - await Future.delayed(const Duration(milliseconds: 100)); - } - } - - Future pop() async { - for (final a in animations) { - a.stop(); - } - Navigator.of(context).pop(); - } - - /// Add a reaction to the message - void sendReaction(BuildContext context, String reactionType) { - StreamChannel.of(context).channel.sendReaction( - widget.message, - reactionType, - enforceUnique: - StreamChatConfiguration.of(context).enforceUniqueReactions, - ); - pop(); - } - - /// Remove a reaction from the message - void removeReaction(BuildContext context, Reaction reaction) { - StreamChannel.of(context).channel.deleteReaction(widget.message, reaction); - pop(); - } - - @override - void dispose() { - for (final a in animations) { - a.dispose(); - } - super.dispose(); - } - - @override - Widget build(BuildContext context) { - final reactionIcons = StreamChatConfiguration.of(context).reactionIcons; - - if (animations.isEmpty && reactionIcons.isNotEmpty) { - reactionIcons.forEach((element) { - animations.add( - EzAnimation.tween( - Tween(begin: 0.0, end: 1.0), - const Duration(milliseconds: 250), - curve: Curves.easeInOutBack, - ), - ); - }); - - triggerAnimations(); - } - - final child = Material( - color: StreamChatTheme.of(context).messageListViewTheme.backgroundColor ?? - Theme.of(context).scaffoldBackgroundColor, - //clipBehavior: Clip.hardEdge, - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), - child: Row( - spacing: 16, - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisAlignment: MainAxisAlignment.spaceAround, - mainAxisSize: MainAxisSize.min, - children: reactionIcons.map((reactionIcon) { - final ownReactionIndex = widget.message.ownReactions?.indexWhere( - (reaction) => reaction.type == reactionIcon.type, - ) ?? - -1; - final index = reactionIcons.indexOf(reactionIcon); - - final child = reactionIcon.builder( - context, - ownReactionIndex != -1, - 24, - ); - - return ConstrainedBox( - constraints: const BoxConstraints.tightFor( - height: 24, - width: 24, - ), - child: RawMaterialButton( - elevation: 0, - shape: ContinuousRectangleBorder( - borderRadius: BorderRadius.circular(16), - ), - constraints: const BoxConstraints.tightFor( - height: 24, - width: 24, - ), - onPressed: () { - if (ownReactionIndex != -1) { - removeReaction( - context, - widget.message.ownReactions![ownReactionIndex], - ); - } else { - sendReaction( - context, - reactionIcon.type, - ); - } - }, - child: AnimatedBuilder( - animation: animations[index], - builder: (context, child) => Transform.scale( - scale: animations[index].value, - child: child, - ), - child: child, - ), - ), - ); - }).toList(), - ), - ), - ); - - return TweenAnimationBuilder( - tween: Tween(begin: 0, end: 1), - curve: Curves.easeInOutBack, - duration: const Duration(milliseconds: 500), - builder: (context, val, widget) => Transform.scale( - scale: val, - child: widget, - ), - child: child, - ); - } -} diff --git a/packages/stream_chat_flutter/lib/src/context_menu_items/download_menu_item.dart b/packages/stream_chat_flutter/lib/src/context_menu_items/download_menu_item.dart deleted file mode 100644 index a3f4d5a207..0000000000 --- a/packages/stream_chat_flutter/lib/src/context_menu_items/download_menu_item.dart +++ /dev/null @@ -1,32 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:stream_chat_flutter/src/context_menu_items/stream_chat_context_menu_item.dart'; -import 'package:stream_chat_flutter/stream_chat_flutter.dart'; - -/// {@template downloadMenuItem} -/// Defines a "download" context menu item that allows a user to download -/// a given attachment. -/// -/// Used in [DesktopFullscreenMedia]. -/// {@endtemplate} -class DownloadMenuItem extends StatelessWidget { - /// {@macro downloadMenuItem} - const DownloadMenuItem({ - super.key, - required this.attachment, - }); - - /// The attachment to download. - final Attachment attachment; - - @override - Widget build(BuildContext context) { - return StreamChatContextMenuItem( - leading: const StreamSvgIcon(icon: StreamSvgIcons.download), - title: Text(context.translations.downloadLabel), - onClick: () async { - Navigator.of(context).pop(); - StreamAttachmentHandler.instance.downloadAttachment(attachment); - }, - ); - } -} diff --git a/packages/stream_chat_flutter/lib/src/context_menu_items/stream_chat_context_menu_item.dart b/packages/stream_chat_flutter/lib/src/context_menu_items/stream_chat_context_menu_item.dart deleted file mode 100644 index dfe64684e2..0000000000 --- a/packages/stream_chat_flutter/lib/src/context_menu_items/stream_chat_context_menu_item.dart +++ /dev/null @@ -1,51 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:stream_chat_flutter/stream_chat_flutter.dart'; - -/// {@template streamChatContextMenuItem} -/// Builds a context menu item according to Stream design specification. -/// {@endtemplate} -class StreamChatContextMenuItem extends StatelessWidget { - /// {@macro streamChatContextMenuItem} - const StreamChatContextMenuItem({ - super.key, - this.child, - this.leading, - this.title, - this.onClick, - }); - - /// The child widget for this menu item. Usually a [DesktopReactionPicker]. - /// - /// Leave null in order to use the default menu item widget. - final Widget? child; - - /// The widget to lead the menu item with. Usually an [Icon]. - /// - /// If [child] is specified, this will be ignored. - final Widget? leading; - - /// The title of the menu item. Usually a [Text]. - /// - /// If [child] is specified, this will be ignored. - final Widget? title; - - /// The action to perform when the menu item is clicked. - /// - /// If [child] is specified, this will be ignored. - final VoidCallback? onClick; - - @override - Widget build(BuildContext context) { - return Ink( - color: StreamChatTheme.of(context).messageListViewTheme.backgroundColor ?? - Theme.of(context).scaffoldBackgroundColor, - child: child ?? - ListTile( - dense: true, - leading: leading, - title: title, - onTap: onClick, - ), - ); - } -} diff --git a/packages/stream_chat_flutter/lib/src/dialogs/channel_info_dialog.dart b/packages/stream_chat_flutter/lib/src/dialogs/channel_info_dialog.dart deleted file mode 100644 index 974a1c2fc6..0000000000 --- a/packages/stream_chat_flutter/lib/src/dialogs/channel_info_dialog.dart +++ /dev/null @@ -1,85 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:stream_chat_flutter/stream_chat_flutter.dart'; - -/// {@template channelInfoDialog} -/// A dialog for showing information about a channel on desktop & web platforms. -/// {@endtemplate} -class ChannelInfoDialog extends StatelessWidget { - /// {@macro channelInfoDialog} - const ChannelInfoDialog({ - super.key, - required this.channel, - }); - - /// The channel to display information about. - final Channel channel; - - @override - Widget build(BuildContext context) { - final streamTheme = StreamChatTheme.of(context); - final members = channel.state?.members ?? []; - - final userAsMember = members.firstWhere( - (e) => e.user?.id == StreamChat.of(context).currentUser?.id, - ); - return StreamChannel( - channel: channel, - child: SimpleDialog( - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(16), - ), - backgroundColor: streamTheme.colorTheme.appBg, - title: Text( - channel.name ?? channel.id!, - style: StreamChatTheme.of(context).textTheme.headlineBold, - ), - children: [ - Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - StreamChannelInfo( - channel: channel, - textStyle: StreamChatTheme.of(context) - .channelPreviewTheme - .subtitleStyle, - ), - ], - ), - const SizedBox(height: 16), - if (channel.isDistinct && channel.memberCount == 2) - Column( - children: [ - StreamUserAvatar( - user: members - .firstWhere( - (e) => e.user?.id != userAsMember.user?.id, - ) - .user!, - constraints: const BoxConstraints.tightFor( - height: 64, - width: 64, - ), - borderRadius: BorderRadius.circular(32), - onlineIndicatorConstraints: - BoxConstraints.tight(const Size(12, 12)), - ), - const SizedBox(height: 6), - Text( - members - .firstWhere( - (e) => e.user?.id != userAsMember.user?.id, - ) - .user - ?.name ?? - '', - style: StreamChatTheme.of(context).textTheme.footnoteBold, - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - ], - ), - ], - ), - ); - } -} diff --git a/packages/stream_chat_flutter/lib/src/dialogs/confirmation_dialog.dart b/packages/stream_chat_flutter/lib/src/dialogs/confirmation_dialog.dart deleted file mode 100644 index 04fd31b841..0000000000 --- a/packages/stream_chat_flutter/lib/src/dialogs/confirmation_dialog.dart +++ /dev/null @@ -1,60 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:stream_chat_flutter/stream_chat_flutter.dart'; - -/// {@template confirmationDialog} -/// A dialog that prompts the user to take an action or cancel. -/// {@endtemplate} -class ConfirmationDialog extends StatelessWidget { - /// {@macro confirmationDialog} - const ConfirmationDialog({ - super.key, - required this.titleText, - required this.promptText, - required this.affirmativeText, - required this.onConfirmation, - }); - - /// The text to use for the dialog title. - final String titleText; - - /// The text to use for the dialog prompt. - final String promptText; - - /// The text to use for the confirmation button. - final String affirmativeText; - - /// The action to perform when the user confirms their choice. - final VoidCallback onConfirmation; - - @override - Widget build(BuildContext context) { - final streamTheme = StreamChatTheme.of(context); - return AlertDialog( - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(16), - ), - backgroundColor: streamTheme.colorTheme.appBg, - title: Text(titleText), - content: Text(promptText), - actions: [ - TextButton( - style: TextButton.styleFrom( - foregroundColor: streamTheme.colorTheme.accentPrimary, - ), - onPressed: () => Navigator.of(context).pop(false), - child: Text(context.translations.cancelLabel), - ), - TextButton( - style: TextButton.styleFrom( - foregroundColor: streamTheme.colorTheme.accentPrimary, - ), - onPressed: () { - onConfirmation.call(); - Navigator.of(context).pop(true); - }, - child: Text(affirmativeText), - ), - ], - ); - } -} diff --git a/packages/stream_chat_flutter/lib/src/dialogs/delete_message_dialog.dart b/packages/stream_chat_flutter/lib/src/dialogs/delete_message_dialog.dart deleted file mode 100644 index 3532b0930e..0000000000 --- a/packages/stream_chat_flutter/lib/src/dialogs/delete_message_dialog.dart +++ /dev/null @@ -1,42 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:stream_chat_flutter/stream_chat_flutter.dart'; - -/// {@template deleteMessageDialog} -/// A dialog that asks the user to confirm that they want to -/// delete the selected message. -/// {@endtemplate} -class DeleteMessageDialog extends StatelessWidget { - /// {@macro deleteMessageDialog} - const DeleteMessageDialog({ - super.key, - }); - - @override - Widget build(BuildContext context) { - final streamTheme = StreamChatTheme.of(context); - return AlertDialog( - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(16), - ), - backgroundColor: streamTheme.colorTheme.appBg, - title: Text(context.translations.deleteMessageLabel), - content: Text(context.translations.deleteMessageQuestion), - actions: [ - TextButton( - style: TextButton.styleFrom( - foregroundColor: streamTheme.colorTheme.accentPrimary, - ), - onPressed: () => Navigator.of(context).pop(false), - child: Text(context.translations.cancelLabel), - ), - TextButton( - style: TextButton.styleFrom( - foregroundColor: streamTheme.colorTheme.accentPrimary, - ), - onPressed: () => Navigator.of(context).pop(true), - child: Text(context.translations.deleteLabel), - ), - ], - ); - } -} diff --git a/packages/stream_chat_flutter/lib/src/dialogs/dialogs.dart b/packages/stream_chat_flutter/lib/src/dialogs/dialogs.dart deleted file mode 100644 index 2daa1f6fb0..0000000000 --- a/packages/stream_chat_flutter/lib/src/dialogs/dialogs.dart +++ /dev/null @@ -1,4 +0,0 @@ -export 'channel_info_dialog.dart'; -export 'confirmation_dialog.dart'; -export 'delete_message_dialog.dart'; -export 'message_dialog.dart'; diff --git a/packages/stream_chat_flutter/lib/src/dialogs/message_dialog.dart b/packages/stream_chat_flutter/lib/src/dialogs/message_dialog.dart deleted file mode 100644 index f8b9930c43..0000000000 --- a/packages/stream_chat_flutter/lib/src/dialogs/message_dialog.dart +++ /dev/null @@ -1,51 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:stream_chat_flutter/stream_chat_flutter.dart'; - -/// {@template messageDialog} -/// A dialog that displays a message to a user. Falls back to a -/// generic error message if no [titleText] and [messageText] are specified. -/// -/// If using this dialog to display the default generic error, be sure NOT to -/// specify a [titleText] and [messageText] so the fallback strings can be used. -/// {@endtemplate} -class MessageDialog extends StatelessWidget { - /// {@macro messageDialog} - const MessageDialog({ - super.key, - this.titleText, - this.messageText, - }); - - /// The optional error message title to use. - final String? titleText; - - /// The optional error message to use. - final String? messageText; - - @override - Widget build(BuildContext context) { - final streamTheme = StreamChatTheme.of(context); - return AlertDialog( - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(16), - ), - backgroundColor: streamTheme.colorTheme.appBg, - title: Text(titleText ?? context.translations.somethingWentWrongError), - content: messageText != null - ? Text( - messageText ?? - context.translations.operationCouldNotBeCompletedText, - ) - : null, - actions: [ - TextButton( - style: TextButton.styleFrom( - foregroundColor: streamTheme.colorTheme.accentPrimary, - ), - child: Text(context.translations.okLabel), - onPressed: () => Navigator.of(context).pop(), - ), - ], - ); - } -} diff --git a/packages/stream_chat_flutter/lib/src/fullscreen_media/fsm_stub.dart b/packages/stream_chat_flutter/lib/src/fullscreen_media/fsm_stub.dart deleted file mode 100644 index 1bf7c435b3..0000000000 --- a/packages/stream_chat_flutter/lib/src/fullscreen_media/fsm_stub.dart +++ /dev/null @@ -1,19 +0,0 @@ -import 'package:flutter/widgets.dart'; -import 'package:stream_chat_flutter/src/fullscreen_media/full_screen_media_widget.dart'; -import 'package:stream_chat_flutter/stream_chat_flutter.dart'; - -/// Stub function for returning an instance of either [FullScreenMedia] or -/// [FullScreenMediaDesktop]. -/// -/// This should ONLY be used in [FullScreenMediaBuilder]. -FullScreenMediaWidget getFsm({ - Key? key, - required List mediaAttachmentPackages, - required int startIndex, - required String userName, - ShowMessageCallback? onShowMessage, - ReplyMessageCallback? onReplyMessage, - AttachmentActionsBuilder? attachmentActionsModalBuilder, - bool? autoplayVideos, -}) => - throw UnsupportedError('Cannot create FullScreenMedia'); diff --git a/packages/stream_chat_flutter/lib/src/fullscreen_media/full_screen_media.dart b/packages/stream_chat_flutter/lib/src/fullscreen_media/full_screen_media.dart deleted file mode 100644 index eb95489c24..0000000000 --- a/packages/stream_chat_flutter/lib/src/fullscreen_media/full_screen_media.dart +++ /dev/null @@ -1,399 +0,0 @@ -import 'dart:async'; -import 'dart:io'; - -import 'package:chewie/chewie.dart'; -import 'package:flutter/material.dart'; -import 'package:photo_view/photo_view.dart'; -import 'package:stream_chat_flutter/src/fullscreen_media/full_screen_media_widget.dart'; -import 'package:stream_chat_flutter/src/fullscreen_media/gallery_navigation_item.dart'; -import 'package:stream_chat_flutter/src/misc/empty_widget.dart'; -import 'package:stream_chat_flutter/stream_chat_flutter.dart'; -import 'package:video_player/video_player.dart'; - -/// A full screen image widget -class StreamFullScreenMedia extends FullScreenMediaWidget { - /// Instantiate a new FullScreenImage - const StreamFullScreenMedia({ - super.key, - required this.mediaAttachmentPackages, - this.startIndex = 0, - this.userName = '', - this.onShowMessage, - this.onReplyMessage, - this.attachmentActionsModalBuilder, - this.autoplayVideos = false, - }) : assert(startIndex >= 0, 'startIndex cannot be negative'); - - /// The url of the image - final List mediaAttachmentPackages; - - /// First index of media shown - final int startIndex; - - /// Username of sender - final String userName; - - /// Callback for when show message is tapped - final ShowMessageCallback? onShowMessage; - - /// Callback for when reply message is tapped - final ReplyMessageCallback? onReplyMessage; - - /// Widget builder for attachment actions modal - /// [defaultActionsModal] is the default [AttachmentActionsModal] config - /// Use [defaultActionsModal.copyWith] to easily customize it - final AttachmentActionsBuilder? attachmentActionsModalBuilder; - - /// Auto-play videos when page is opened - final bool autoplayVideos; - - @override - _FullScreenMediaState createState() => _FullScreenMediaState(); -} - -class _FullScreenMediaState extends State { - late final PageController _pageController; - - late final _currentPage = ValueNotifier(widget.startIndex); - late final _isDisplayingDetail = ValueNotifier(true); - - void switchDisplayingDetail() { - _isDisplayingDetail.value = !_isDisplayingDetail.value; - } - - final videoPackages = {}; - - @override - void initState() { - super.initState(); - _pageController = PageController(initialPage: widget.startIndex); - for (var i = 0; i < widget.mediaAttachmentPackages.length; i++) { - final attachment = widget.mediaAttachmentPackages[i].attachment; - if (attachment.type != AttachmentType.video) continue; - final package = VideoPackage(attachment, showControls: true); - videoPackages[attachment.id] = package; - } - initializePlayers(); - } - - Future initializePlayers() async { - if (videoPackages.isEmpty) { - return; - } - - final currentAttachment = - widget.mediaAttachmentPackages[widget.startIndex].attachment; - - await Future.wait(videoPackages.values.map( - (it) => it.initialize(), - )); - - if (widget.autoplayVideos && - currentAttachment.type == AttachmentType.video) { - final package = videoPackages.values - .firstWhere((e) => e._attachment == currentAttachment); - package._chewieController?.play(); - } - setState(() {}); // ignore: no-empty-block - } - - @override - void dispose() { - _currentPage.dispose(); - _pageController.dispose(); - _isDisplayingDetail.dispose(); - for (final package in videoPackages.values) { - package.dispose(); - } - super.dispose(); - } - - @override - Widget build(BuildContext context) { - return Scaffold( - resizeToAvoidBottomInset: false, - body: ValueListenableBuilder( - valueListenable: _currentPage, - builder: (context, currentPage, child) { - final _currentAttachmentPackage = - widget.mediaAttachmentPackages[currentPage]; - final _currentMessage = _currentAttachmentPackage.message; - final _currentAttachment = _currentAttachmentPackage.attachment; - return Stack( - children: [ - child!, - ValueListenableBuilder( - valueListenable: _isDisplayingDetail, - builder: (context, isDisplayingDetail, child) { - final mediaQuery = MediaQuery.of(context); - final topPadding = mediaQuery.padding.top; - return AnimatedPositionedDirectional( - duration: kThemeAnimationDuration, - curve: Curves.easeInOut, - top: - isDisplayingDetail ? 0 : -(topPadding + kToolbarHeight), - start: 0, - end: 0, - height: topPadding + kToolbarHeight, - child: StreamGalleryHeader( - userName: widget.userName, - sentAt: context.translations.sentAtText( - date: _currentAttachmentPackage.message.createdAt, - time: _currentAttachmentPackage.message.createdAt, - ), - onBackPressed: Navigator.of(context).pop, - message: _currentMessage, - attachment: _currentAttachment, - onShowMessage: widget.onShowMessage != null - ? () { - Navigator.pop(context); - Navigator.pop(context); - widget.onShowMessage?.call( - _currentMessage, - StreamChannel.of(context).channel, - ); - } - : null, - onReplyMessage: widget.onReplyMessage != null - ? () { - Navigator.pop(context); - Navigator.pop(context); - widget.onReplyMessage?.call( - _currentMessage, - ); - } - : null, - attachmentActionsModalBuilder: - widget.attachmentActionsModalBuilder, - ), - ); - }, - ), - if (!_currentMessage.isEphemeral) - ValueListenableBuilder( - valueListenable: _isDisplayingDetail, - builder: (context, isDisplayingDetail, child) { - final mediaQuery = MediaQuery.of(context); - final bottomPadding = mediaQuery.padding.bottom; - return AnimatedPositionedDirectional( - duration: kThemeAnimationDuration, - curve: Curves.easeInOut, - bottom: isDisplayingDetail - ? 0 - : -(bottomPadding + kToolbarHeight), - start: 0, - end: 0, - height: bottomPadding + kToolbarHeight, - child: StreamGalleryFooter( - currentPage: currentPage, - totalPages: widget.mediaAttachmentPackages.length, - mediaAttachmentPackages: widget.mediaAttachmentPackages, - mediaSelectedCallBack: (val) { - _currentPage.value = val; - _pageController.animateToPage( - val, - duration: const Duration(milliseconds: 300), - curve: Curves.easeInOut, - ); - Navigator.pop(context); - }, - ), - ); - }, - ), - if (widget.mediaAttachmentPackages.length > 1) ...[ - if (currentPage > 0) - GalleryNavigationItem( - left: 8, - opacityAnimation: _isDisplayingDetail, - icon: const Icon(Icons.chevron_left_rounded), - onPressed: () { - _currentPage.value--; - _pageController.previousPage( - duration: const Duration(milliseconds: 300), - curve: Curves.easeInOut, - ); - }, - ), - if (currentPage < widget.mediaAttachmentPackages.length - 1) - GalleryNavigationItem( - right: 8, - opacityAnimation: _isDisplayingDetail, - icon: const Icon(Icons.chevron_right_rounded), - onPressed: () { - _currentPage.value++; - _pageController.nextPage( - duration: const Duration(milliseconds: 300), - curve: Curves.easeInOut, - ); - }, - ), - ], - ], - ); - }, - child: InkWell( - onTap: switchDisplayingDetail, - child: KeyboardShortcutRunner( - onEscapeKeypress: Navigator.of(context).pop, - onLeftArrowKeypress: () { - if (_currentPage.value > 0) { - _currentPage.value--; - _pageController.previousPage( - duration: const Duration(milliseconds: 300), - curve: Curves.easeInOut, - ); - } - }, - onRightArrowKeypress: () { - if (_currentPage.value < - widget.mediaAttachmentPackages.length - 1) { - _currentPage.value++; - _pageController.nextPage( - duration: const Duration(milliseconds: 300), - curve: Curves.easeInOut, - ); - } - }, - child: PageView.builder( - controller: _pageController, - itemCount: widget.mediaAttachmentPackages.length, - onPageChanged: (val) { - _currentPage.value = val; - if (videoPackages.isEmpty) return; - final currentAttachment = - widget.mediaAttachmentPackages[val].attachment; - for (final e in videoPackages.values) { - if (e._attachment != currentAttachment) { - e._chewieController?.pause(); - } - } - if (widget.autoplayVideos && - currentAttachment.type == AttachmentType.video) { - final controller = videoPackages[currentAttachment.id]!; - controller._chewieController?.play(); - } - }, - itemBuilder: (context, index) { - final currentAttachmentPackage = - widget.mediaAttachmentPackages[index]; - final attachment = currentAttachmentPackage.attachment; - return ValueListenableBuilder( - valueListenable: _isDisplayingDetail, - builder: (context, isDisplayingDetail, child) { - final padding = MediaQuery.paddingOf(context); - - return AnimatedContainer( - duration: kThemeChangeDuration, - color: switch (isDisplayingDetail) { - true => StreamChannelHeaderTheme.of(context).color, - false => Colors.black, - }, - padding: EdgeInsetsDirectional.only( - top: padding.top + kToolbarHeight, - bottom: padding.bottom + kToolbarHeight, - ), - child: child, - ); - }, - child: Builder( - builder: (context) { - if (attachment.type == AttachmentType.image || - attachment.type == AttachmentType.giphy) { - return PhotoView.customChild( - maxScale: PhotoViewComputedScale.covered, - minScale: PhotoViewComputedScale.contained, - backgroundDecoration: const BoxDecoration( - color: Colors.transparent, - ), - child: StreamMediaAttachmentThumbnail( - media: attachment, - width: double.infinity, - height: double.infinity, - ), - ); - } else if (attachment.type == AttachmentType.video) { - final controller = videoPackages[attachment.id]!; - if (!controller.initialized) { - return const Center( - child: CircularProgressIndicator.adaptive(), - ); - } - - return Chewie( - controller: controller.chewieController!, - ); - } - - return const Empty(); - }, - ), - ); - }, - ), - ), - ), - ), - ); - } -} - -/// Class for packaging up things required for videos -class VideoPackage { - /// Constructor for creating [VideoPackage] - VideoPackage( - this._attachment, { - bool showControls = false, - bool autoInitialize = true, - }) : _showControls = showControls, - _autoInitialize = autoInitialize, - _videoPlayerController = _attachment.localUri != null - ? VideoPlayerController.file( - File.fromUri(_attachment.localUri!), - ) - : VideoPlayerController.networkUrl( - Uri.parse(_attachment.assetUrl!), - ); - - final Attachment _attachment; - final bool _showControls; - final bool _autoInitialize; - final VideoPlayerController _videoPlayerController; - ChewieController? _chewieController; - - /// Get video player for video - VideoPlayerController get videoPlayer => _videoPlayerController; - - /// Get [ChewieController] for video - ChewieController? get chewieController => _chewieController; - - /// Check if controller is initialised - bool get initialized => _videoPlayerController.value.isInitialized; - - /// Initialize all things required for [VideoPackage] - Future initialize() { - return _videoPlayerController.initialize().then((_) { - _chewieController = ChewieController( - videoPlayerController: _videoPlayerController, - autoInitialize: _autoInitialize, - showControls: _showControls, - showOptions: false, - aspectRatio: _videoPlayerController.value.aspectRatio, - ); - }); - } - - /// Add a listener to video player controller - void addListener(VoidCallback listener) => - _videoPlayerController.addListener(listener); - - /// Remove a listener to video player controller - void removeListener(VoidCallback listener) => - _videoPlayerController.removeListener(listener); - - /// Dispose controllers - Future dispose() { - _chewieController?.dispose(); - return _videoPlayerController.dispose(); - } -} diff --git a/packages/stream_chat_flutter/lib/src/fullscreen_media/full_screen_media_builder.dart b/packages/stream_chat_flutter/lib/src/fullscreen_media/full_screen_media_builder.dart deleted file mode 100644 index f1919db192..0000000000 --- a/packages/stream_chat_flutter/lib/src/fullscreen_media/full_screen_media_builder.dart +++ /dev/null @@ -1,86 +0,0 @@ -import 'package:flutter/foundation.dart'; -import 'package:flutter/material.dart'; -import 'package:stream_chat_flutter/src/fullscreen_media/fsm_stub.dart' - if (dart.library.io) 'full_screen_media_desktop.dart' as desktop_fsm; -import 'package:stream_chat_flutter/stream_chat_flutter.dart'; - -/// {@template fsmBuilder} -/// A wrapper widget for conditionally providing the proper -/// StreamFullScreenMedia widget when writing an application that targets -/// all available Flutter platforms (Android, iOS, macOS, Windows, Linux, -/// & Web). -/// -/// This is required because: -/// * `package:video_player` and `package:chewie` do not support macOS, Windows, -/// & Linux, but _do_ support Android, iOS, & Web. -/// * `package:dart_vlc` _does_ support macOS, Windows, & Linux via FFI. This -/// has the unfortunate consequence of not supporting Web. -/// -/// This widget makes use of dart's conditional imports to ensure that Stream's -/// desktop implementation of StreamFullScreenMedia is not imported when -/// building applications that target web. Additionally, this widget ensures -/// that applications targeting mobile platforms do not build the version of -/// StreamFullScreenMedia that targets desktop platforms (even though -/// `package:dart_vlc` technically supports iOS). -/// {@endtemplate} -class StreamFullScreenMediaBuilder extends StatelessWidget { - /// {@macro fsmBuilder} - const StreamFullScreenMediaBuilder({ - super.key, - required this.mediaAttachmentPackages, - required this.startIndex, - required this.userName, - this.onShowMessage, - this.onReplyMessage, - this.attachmentActionsModalBuilder, - this.autoplayVideos = false, - }); - - /// The url of the image - final List mediaAttachmentPackages; - - /// First index of media shown - final int startIndex; - - /// Username of sender - final String userName; - - /// Callback for when show message is tapped - final ShowMessageCallback? onShowMessage; - - /// Callback for when reply message is tapped - final ReplyMessageCallback? onReplyMessage; - - /// Widget builder for attachment actions modal - /// [defaultActionsModal] is the default [AttachmentActionsModal] config - /// Use [defaultActionsModal.copyWith] to easily customize it - final AttachmentActionsBuilder? attachmentActionsModalBuilder; - - /// Auto-play videos when page is opened - final bool autoplayVideos; - - @override - Widget build(BuildContext context) { - if (!kIsWeb && isDesktopVideoPlayerSupported) { - return desktop_fsm.getFsm( - mediaAttachmentPackages: mediaAttachmentPackages, - startIndex: startIndex, - userName: userName, - autoplayVideos: autoplayVideos, - onShowMessage: onShowMessage, - onReplyMessage: onReplyMessage, - attachmentActionsModalBuilder: attachmentActionsModalBuilder, - ); - } - - return StreamFullScreenMedia( - mediaAttachmentPackages: mediaAttachmentPackages, - startIndex: startIndex, - userName: userName, - onShowMessage: onShowMessage, - onReplyMessage: onReplyMessage, - attachmentActionsModalBuilder: attachmentActionsModalBuilder, - autoplayVideos: autoplayVideos, - ); - } -} diff --git a/packages/stream_chat_flutter/lib/src/fullscreen_media/full_screen_media_desktop.dart b/packages/stream_chat_flutter/lib/src/fullscreen_media/full_screen_media_desktop.dart deleted file mode 100644 index 3e04fc5e7d..0000000000 --- a/packages/stream_chat_flutter/lib/src/fullscreen_media/full_screen_media_desktop.dart +++ /dev/null @@ -1,458 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:media_kit/media_kit.dart'; -import 'package:media_kit_video/media_kit_video.dart'; -import 'package:photo_view/photo_view.dart'; -import 'package:stream_chat_flutter/src/context_menu/context_menu.dart'; -import 'package:stream_chat_flutter/src/context_menu/context_menu_region.dart'; -import 'package:stream_chat_flutter/src/context_menu_items/download_menu_item.dart'; -import 'package:stream_chat_flutter/src/fullscreen_media/full_screen_media_widget.dart'; -import 'package:stream_chat_flutter/src/fullscreen_media/gallery_navigation_item.dart'; -import 'package:stream_chat_flutter/src/misc/empty_widget.dart'; -import 'package:stream_chat_flutter/stream_chat_flutter.dart'; - -/// Returns an instance of [FullScreenMediaDesktop]. -/// -/// This should ONLY be used in [FullScreenMediaBuilder]. -FullScreenMediaWidget getFsm({ - Key? key, - required List mediaAttachmentPackages, - required int startIndex, - required String userName, - ShowMessageCallback? onShowMessage, - ReplyMessageCallback? onReplyMessage, - AttachmentActionsBuilder? attachmentActionsModalBuilder, - bool? autoplayVideos, -}) { - return FullScreenMediaDesktop( - key: key, - mediaAttachmentPackages: mediaAttachmentPackages, - startIndex: startIndex, - userName: userName, - onReplyMessage: onReplyMessage, - onShowMessage: onShowMessage, - attachmentActionsModalBuilder: attachmentActionsModalBuilder, - autoplayVideos: autoplayVideos ?? false, - ); -} - -/// A full screen image widget -class FullScreenMediaDesktop extends FullScreenMediaWidget { - /// Instantiate a new FullScreenImage - const FullScreenMediaDesktop({ - super.key, - required this.mediaAttachmentPackages, - this.startIndex = 0, - String? userName, - this.onShowMessage, - this.onReplyMessage, - this.attachmentActionsModalBuilder, - this.autoplayVideos = false, - }) : userName = userName ?? ''; - - /// The url of the image - final List mediaAttachmentPackages; - - /// First index of media shown - final int startIndex; - - /// Username of sender - final String userName; - - /// Callback for when show message is tapped - final ShowMessageCallback? onShowMessage; - - /// Callback for when reply message is tapped - final ReplyMessageCallback? onReplyMessage; - - /// Widget builder for attachment actions modal - /// [defaultActionsModal] is the default [AttachmentActionsModal] config - /// Use [defaultActionsModal.copyWith] to easily customize it - final AttachmentActionsBuilder? attachmentActionsModalBuilder; - - /// Auto-play videos when page is opened - final bool autoplayVideos; - - @override - _FullScreenMediaDesktopState createState() => _FullScreenMediaDesktopState(); -} - -class _FullScreenMediaDesktopState extends State { - late final PageController _pageController; - - late final _currentPage = ValueNotifier(widget.startIndex); - late final _isDisplayingDetail = ValueNotifier(true); - - void switchDisplayingDetail() { - _isDisplayingDetail.value = !_isDisplayingDetail.value; - } - - final videoPackages = {}; - - @override - void initState() { - super.initState(); - _pageController = PageController(initialPage: widget.startIndex); - for (var i = 0; i < widget.mediaAttachmentPackages.length; i++) { - final attachment = widget.mediaAttachmentPackages[i].attachment; - if (attachment.type != AttachmentType.video) continue; - final package = DesktopVideoPackage(attachment); - videoPackages[attachment.id] = package; - } - } - - @override - void dispose() { - _currentPage.dispose(); - _pageController.dispose(); - _isDisplayingDetail.dispose(); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - final containsOnlyVideos = - widget.mediaAttachmentPackages.length == videoPackages.length; - - return Scaffold( - resizeToAvoidBottomInset: false, - body: containsOnlyVideos ? _buildVideoPageView() : _buildPageView(), - ); - } - - Widget _buildVideoPageView() { - return Stack( - children: [ - ContextMenuRegion( - contextMenuBuilder: (_, anchor) { - return ContextMenu( - anchor: anchor, - menuItems: [ - DownloadMenuItem( - attachment: widget - .mediaAttachmentPackages[_currentPage.value].attachment, - ), - ], - ); - }, - child: _PlaylistPlayer( - packages: videoPackages.values.toList(), - autoStart: widget.autoplayVideos, - ), - ), - Positioned( - left: 8, - top: 8, - child: MouseRegion( - cursor: SystemMouseCursors.click, - child: GestureDetector( - onTap: () { - videoPackages.values.first.player.stop(); - Navigator.of(context).pop(); - }, - child: const StreamSvgIcon( - size: 30, - icon: StreamSvgIcons.close, - ), - ), - ), - ), - ], - ); - } - - Widget _buildPageView() { - return ValueListenableBuilder( - valueListenable: _currentPage, - builder: (context, currentPage, child) { - final _currentAttachmentPackage = - widget.mediaAttachmentPackages[currentPage]; - final _currentMessage = _currentAttachmentPackage.message; - final _currentAttachment = _currentAttachmentPackage.attachment; - return Stack( - children: [ - child!, - ValueListenableBuilder( - valueListenable: _isDisplayingDetail, - builder: (context, isDisplayingDetail, child) { - final mediaQuery = MediaQuery.of(context); - final topPadding = mediaQuery.padding.top; - return AnimatedPositionedDirectional( - duration: kThemeAnimationDuration, - curve: Curves.easeInOut, - top: isDisplayingDetail ? 0 : -(topPadding + kToolbarHeight), - start: 0, - end: 0, - height: topPadding + kToolbarHeight, - child: StreamGalleryHeader( - userName: widget.userName, - sentAt: context.translations.sentAtText( - date: _currentAttachmentPackage.message.createdAt, - time: _currentAttachmentPackage.message.createdAt, - ), - onBackPressed: Navigator.of(context).pop, - message: _currentMessage, - attachment: _currentAttachment, - onShowMessage: () { - widget.onShowMessage?.call( - _currentMessage, - StreamChannel.of(context).channel, - ); - }, - attachmentActionsModalBuilder: - widget.attachmentActionsModalBuilder, - ), - ); - }, - ), - if (!_currentMessage.isEphemeral) - ValueListenableBuilder( - valueListenable: _isDisplayingDetail, - builder: (context, isDisplayingDetail, child) { - final mediaQuery = MediaQuery.of(context); - final bottomPadding = mediaQuery.padding.bottom; - return AnimatedPositionedDirectional( - duration: kThemeAnimationDuration, - curve: Curves.easeInOut, - bottom: isDisplayingDetail - ? 0 - : -(bottomPadding + kToolbarHeight), - start: 0, - end: 0, - height: bottomPadding + kToolbarHeight, - child: StreamGalleryFooter( - currentPage: currentPage, - totalPages: widget.mediaAttachmentPackages.length, - mediaAttachmentPackages: widget.mediaAttachmentPackages, - mediaSelectedCallBack: (val) { - _currentPage.value = val; - _pageController.animateToPage( - val, - duration: const Duration(milliseconds: 300), - curve: Curves.easeInOut, - ); - Navigator.pop(context); - }, - ), - ); - }, - ), - if (widget.mediaAttachmentPackages.length > 1) ...[ - if (currentPage > 0) - GalleryNavigationItem( - left: 8, - opacityAnimation: _isDisplayingDetail, - icon: const Icon(Icons.chevron_left_rounded), - onPressed: () { - _currentPage.value--; - _pageController.previousPage( - duration: const Duration(milliseconds: 300), - curve: Curves.easeInOut, - ); - }, - ), - if (currentPage < widget.mediaAttachmentPackages.length - 1) - GalleryNavigationItem( - right: 8, - opacityAnimation: _isDisplayingDetail, - icon: const Icon(Icons.chevron_right_rounded), - onPressed: () { - _currentPage.value++; - _pageController.nextPage( - duration: const Duration(milliseconds: 300), - curve: Curves.easeInOut, - ); - }, - ), - ], - ], - ); - }, - child: InkWell( - onTap: switchDisplayingDetail, - child: KeyboardShortcutRunner( - onEscapeKeypress: Navigator.of(context).pop, - onLeftArrowKeypress: () { - if (_currentPage.value > 0) { - _currentPage.value--; - _pageController.previousPage( - duration: const Duration(milliseconds: 300), - curve: Curves.easeInOut, - ); - } - }, - onRightArrowKeypress: () { - if (_currentPage.value < - widget.mediaAttachmentPackages.length - 1) { - _currentPage.value++; - _pageController.nextPage( - duration: const Duration(milliseconds: 300), - curve: Curves.easeInOut, - ); - } - }, - child: PageView.builder( - controller: _pageController, - itemCount: widget.mediaAttachmentPackages.length, - onPageChanged: (val) { - _currentPage.value = val; - if (videoPackages.isEmpty) return; - final currentAttachment = - widget.mediaAttachmentPackages[val].attachment; - for (final p in videoPackages.values) { - if (p.attachment != currentAttachment) { - p.player.pause(); - } - } - if (widget.autoplayVideos && - currentAttachment.type == AttachmentType.video) { - final package = videoPackages[currentAttachment.id]!; - package.player.play(); - } - }, - itemBuilder: (context, index) { - final currentAttachmentPackage = - widget.mediaAttachmentPackages[index]; - final attachment = currentAttachmentPackage.attachment; - - return ValueListenableBuilder( - valueListenable: _isDisplayingDetail, - builder: (context, isDisplayingDetail, child) { - final padding = MediaQuery.paddingOf(context); - - return AnimatedContainer( - duration: kThemeChangeDuration, - color: switch (isDisplayingDetail) { - true => StreamChannelHeaderTheme.of(context).color, - false => Colors.black, - }, - padding: EdgeInsetsDirectional.only( - top: padding.top + kToolbarHeight, - bottom: padding.bottom + kToolbarHeight, - ), - child: child, - ); - }, - child: Builder( - builder: (context) { - if (attachment.type == AttachmentType.image || - attachment.type == AttachmentType.giphy) { - return PhotoView.customChild( - maxScale: PhotoViewComputedScale.covered, - minScale: PhotoViewComputedScale.contained, - backgroundDecoration: const BoxDecoration( - color: Colors.transparent, - ), - child: StreamMediaAttachmentThumbnail( - media: attachment, - width: double.infinity, - height: double.infinity, - ), - ); - } else if (attachment.type == AttachmentType.video) { - final package = videoPackages[attachment.id]!; - if (package.attachment.assetUrl != null) { - package.player.open( - Playlist( - [ - Media(package.attachment.assetUrl!), - ], - ), - play: widget.autoplayVideos, - ); - } - - return ContextMenuRegion( - contextMenuBuilder: (_, anchor) { - return ContextMenu( - anchor: anchor, - menuItems: [ - DownloadMenuItem( - attachment: attachment, - ), - ], - ); - }, - child: Video( - controller: package.controller, - ), - ); - } - - return const Empty(); - }, - ), - ); - }, - ), - ), - ), - ); - } -} - -/// Class for packaging up things required for videos -class DesktopVideoPackage { - /// Constructor for creating [VideoPackage] - factory DesktopVideoPackage( - Attachment attachment, { - bool showControls = true, - }) { - final player = Player(); - final controller = VideoController(player); - return DesktopVideoPackage._internal( - attachment, - player, - controller, - showControls, - ); - } - - DesktopVideoPackage._internal( - this.attachment, - this.player, - this.controller, - this.showControls, - ); - - /// The video attachment to play. - final Attachment attachment; - - /// The VLC player to use. - final Player player; - - /// The VLC video controller to use. - final VideoController controller; - - /// Whether to show the player controls or not. - final bool showControls; -} - -class _PlaylistPlayer extends StatelessWidget { - const _PlaylistPlayer({ - required this.packages, - required this.autoStart, - }); - - final List packages; - final bool autoStart; - - @override - Widget build(BuildContext context) { - final _media = []; - for (final package in packages) { - if (package.attachment.assetUrl != null) { - _media.add(Media(package.attachment.assetUrl!)); - } - } - packages.first.player.open( - Playlist( - _media, - ), - play: autoStart, - ); - return Video( - controller: packages.first.controller, - fit: BoxFit.cover, - ); - } -} diff --git a/packages/stream_chat_flutter/lib/src/fullscreen_media/full_screen_media_widget.dart b/packages/stream_chat_flutter/lib/src/fullscreen_media/full_screen_media_widget.dart deleted file mode 100644 index adcac9d3ff..0000000000 --- a/packages/stream_chat_flutter/lib/src/fullscreen_media/full_screen_media_widget.dart +++ /dev/null @@ -1,10 +0,0 @@ -import 'package:flutter/material.dart'; - -/// {@template fsmWidget} -/// An ultra-simple abstract class that allows [FullScreenMediaBuilder] -/// to call `getFsm()` and build the correct version of FullScreenMedia. -/// {@endtemplate} -abstract class FullScreenMediaWidget extends StatefulWidget { - /// {@macro fsmWidget} - const FullScreenMediaWidget({super.key}); -} diff --git a/packages/stream_chat_flutter/lib/src/fullscreen_media/gallery_navigation_item.dart b/packages/stream_chat_flutter/lib/src/fullscreen_media/gallery_navigation_item.dart deleted file mode 100644 index 5d1af701b4..0000000000 --- a/packages/stream_chat_flutter/lib/src/fullscreen_media/gallery_navigation_item.dart +++ /dev/null @@ -1,71 +0,0 @@ -import 'package:flutter/foundation.dart'; -import 'package:flutter/material.dart'; -import 'package:stream_chat_flutter/platform_widget_builder/src/platform_widget_builder.dart'; - -/// A widget for desktop and web users to be able to navigate left and right -/// through a gallery of images. -class GalleryNavigationItem extends StatelessWidget { - /// Builds a [GalleryNavigationItem]. - const GalleryNavigationItem({ - super.key, - required this.icon, - this.iconSize = 48, - required this.onPressed, - required this.opacityAnimation, - this.left, - this.right, - }); - - /// The icon to display. - final Widget icon; - - /// The size of the icon. - /// - /// Defaults to 48. - final double iconSize; - - /// The callback to perform when the button is clicked. - final VoidCallback onPressed; - - /// The animation for showing & hiding this widget. - final ValueListenable opacityAnimation; - - /// The left-hand placement of the button. - final double? left; - - /// The right-hand placement of the button. - final double? right; - - @override - Widget build(BuildContext context) { - return PlatformWidgetBuilder( - desktop: (_, child) => child, - web: (_, child) => child, - child: Positioned( - left: left, - right: right, - top: MediaQuery.of(context).size.height / 2, - child: ValueListenableBuilder( - valueListenable: opacityAnimation, - builder: (context, shouldShow, child) { - return AnimatedOpacity( - opacity: shouldShow ? 1 : 0, - duration: kThemeAnimationDuration, - child: child, - ); - }, - child: Material( - color: Colors.transparent, - type: MaterialType.circle, - clipBehavior: Clip.antiAlias, - child: IconButton( - icon: icon, - iconSize: iconSize, - onPressed: onPressed, - ), - ), - ), - ), - ); - } -} diff --git a/packages/stream_chat_flutter/lib/src/gallery/gallery_footer.dart b/packages/stream_chat_flutter/lib/src/gallery/gallery_footer.dart deleted file mode 100644 index ffcfef6cd2..0000000000 --- a/packages/stream_chat_flutter/lib/src/gallery/gallery_footer.dart +++ /dev/null @@ -1,297 +0,0 @@ -import 'dart:io'; - -import 'package:flutter/foundation.dart'; -import 'package:flutter/material.dart'; -import 'package:path_provider/path_provider.dart'; -import 'package:share_plus/share_plus.dart'; -import 'package:stream_chat_flutter/stream_chat_flutter.dart'; - -/// {@template streamGalleryFooter} -/// Footer widget for media display -/// {@endtemplate} -class StreamGalleryFooter extends StatefulWidget - implements PreferredSizeWidget { - /// {@macro streamGalleryFooter} - const StreamGalleryFooter({ - super.key, - this.onBackPressed, - this.onTitleTap, - this.onImageTap, - this.currentPage = 0, - this.totalPages = 0, - required this.mediaAttachmentPackages, - this.mediaSelectedCallBack, - this.backgroundColor, - }) : preferredSize = const Size.fromHeight(kToolbarHeight); - - /// Callback to call when pressing the back button. - /// By default it calls [Navigator.pop] - final VoidCallback? onBackPressed; - - /// Callback to call when the header is tapped. - final VoidCallback? onTitleTap; - - /// Callback to call when the image is tapped. - final VoidCallback? onImageTap; - - /// Stores the current index of media shown - final int currentPage; - - /// Total number of pages of media - final int totalPages; - - /// All attachments to show - final List mediaAttachmentPackages; - - /// Callback when media is selected - final ValueChanged? mediaSelectedCallBack; - - /// The background color of this [StreamGalleryFooter]. - final Color? backgroundColor; - - @override - _StreamGalleryFooterState createState() => _StreamGalleryFooterState(); - - @override - final Size preferredSize; -} - -class _StreamGalleryFooterState extends State { - final shareButtonKey = GlobalKey(); - - @override - Widget build(BuildContext context) { - const showShareButton = !kIsWeb; - final mediaQueryData = MediaQuery.of(context); - final galleryFooterThemeData = StreamGalleryFooterTheme.of(context); - return SizedBox.fromSize( - size: Size( - mediaQueryData.size.width, - mediaQueryData.padding.bottom + widget.preferredSize.height, - ), - child: MediaQuery.removePadding( - context: context, - removeTop: true, - child: BottomAppBar( - surfaceTintColor: - widget.backgroundColor ?? galleryFooterThemeData.backgroundColor, - color: - widget.backgroundColor ?? galleryFooterThemeData.backgroundColor, - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - if (!showShareButton) - const SizedBox(width: 48) - else - IconButton( - key: shareButtonKey, - icon: StreamSvgIcon( - size: 24, - icon: StreamSvgIcons.share, - color: galleryFooterThemeData.shareIconColor, - ), - onPressed: () async { - final attachment = widget - .mediaAttachmentPackages[widget.currentPage].attachment; - final url = attachment.imageUrl ?? - attachment.assetUrl ?? - attachment.thumbUrl!; - final type = attachment.type == AttachmentType.image - ? 'jpg' - : url.split('?').first.split('.').last; - final request = await HttpClient().getUrl(Uri.parse(url)); - final response = await request.close(); - final bytes = - await consolidateHttpClientResponseBytes(response); - final tmpPath = await getTemporaryDirectory(); - final filePath = '${tmpPath.path}/${attachment.id}.$type'; - final file = File(filePath); - await file.writeAsBytes(bytes); - final box = - shareButtonKey.currentContext?.findRenderObject(); - final size = shareButtonKey.currentContext?.size; - - final position = - (box! as RenderBox).localToGlobal(Offset.zero); - - await SharePlus.instance.share( - ShareParams( - files: [XFile(filePath)], - sharePositionOrigin: Rect.fromLTWH( - position.dx, - position.dy, - size?.width ?? 50, - (size?.height ?? 2) / 2, - ), - ), - ); - }, - ), - InkWell( - onTap: widget.onTitleTap, - child: SizedBox( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - mainAxisSize: MainAxisSize.min, - children: [ - Text( - context.translations.galleryPaginationText( - currentPage: widget.currentPage, - totalPages: widget.totalPages, - ), - style: galleryFooterThemeData.titleTextStyle, - ), - ], - ), - ), - ), - IconButton( - icon: StreamSvgIcon( - icon: StreamSvgIcons.grid, - color: galleryFooterThemeData.gridIconButtonColor, - ), - onPressed: () => _showPhotosModal(context), - ), - ], - ), - ), - ), - ); - } - - void _showPhotosModal(context) { - final chatThemeData = StreamChatTheme.of(context); - final galleryFooterThemeData = StreamGalleryFooterTheme.of(context); - showModalBottomSheet( - context: context, - barrierColor: galleryFooterThemeData.bottomSheetBarrierColor, - backgroundColor: galleryFooterThemeData.bottomSheetBackgroundColor, - isScrollControlled: true, - shape: const RoundedRectangleBorder( - borderRadius: BorderRadius.only( - topLeft: Radius.circular(16), - topRight: Radius.circular(16), - ), - ), - builder: (context) { - return DraggableScrollableSheet( - expand: false, - initialChildSize: - (CurrentPlatform.isAndroid || CurrentPlatform.isIos) ? 0.3 : 0.5, - minChildSize: 0.3, - maxChildSize: 0.7, - builder: (context, scrollController) => Column( - children: [ - Stack( - children: [ - Center( - child: Padding( - padding: const EdgeInsets.all(16), - child: Text( - context.translations.photosLabel, - style: - galleryFooterThemeData.bottomSheetPhotosTextStyle, - ), - ), - ), - Align( - alignment: Alignment.centerRight, - child: IconButton( - icon: StreamSvgIcon( - icon: StreamSvgIcons.close, - color: galleryFooterThemeData.bottomSheetCloseIconColor, - ), - onPressed: () => Navigator.of(context).maybePop(), - ), - ), - ], - ), - Flexible( - child: GridView.builder( - shrinkWrap: true, - controller: scrollController, - itemCount: widget.mediaAttachmentPackages.length, - padding: const EdgeInsets.all(1), - gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( - crossAxisCount: 3, - mainAxisSpacing: 2, - crossAxisSpacing: 2, - ), - itemBuilder: (context, index) { - Widget media; - final attachmentPackage = - widget.mediaAttachmentPackages[index]; - final attachment = attachmentPackage.attachment; - final message = attachmentPackage.message; - if (attachment.type == AttachmentType.video) { - media = MouseRegion( - cursor: SystemMouseCursors.click, - child: GestureDetector( - onTap: () => widget.mediaSelectedCallBack!(index), - child: AspectRatio( - aspectRatio: 1, - child: StreamVideoAttachmentThumbnail( - video: attachment, - ), - ), - ), - ); - } else { - media = MouseRegion( - cursor: SystemMouseCursors.click, - child: GestureDetector( - onTap: () => widget.mediaSelectedCallBack!(index), - child: AspectRatio( - aspectRatio: 1, - child: StreamImageAttachmentThumbnail( - image: attachment, - fit: BoxFit.cover, - ), - ), - ), - ); - } - - return Stack( - children: [ - media, - if (message.user != null) - Padding( - padding: const EdgeInsets.all(8), - child: Container( - padding: const EdgeInsets.all(2), - clipBehavior: Clip.antiAlias, - decoration: BoxDecoration( - shape: BoxShape.circle, - // ignore: deprecated_member_use - color: Colors.white.withOpacity(0.6), - boxShadow: [ - BoxShadow( - blurRadius: 8, - color: chatThemeData - .colorTheme.textHighEmphasis - // ignore: deprecated_member_use - .withOpacity(0.3), - ), - ], - ), - child: StreamUserAvatar( - user: message.user!, - constraints: - BoxConstraints.tight(const Size(24, 24)), - showOnlineStatus: false, - ), - ), - ), - ], - ); - }, - ), - ), - ], - ), - ); - }, - ); - } -} diff --git a/packages/stream_chat_flutter/lib/src/gallery/gallery_header.dart b/packages/stream_chat_flutter/lib/src/gallery/gallery_header.dart deleted file mode 100644 index 679c1c3d72..0000000000 --- a/packages/stream_chat_flutter/lib/src/gallery/gallery_header.dart +++ /dev/null @@ -1,177 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; -import 'package:stream_chat_flutter/src/attachment_actions_modal/attachment_actions_modal.dart'; -import 'package:stream_chat_flutter/src/icons/stream_svg_icon.dart'; -import 'package:stream_chat_flutter/src/misc/empty_widget.dart'; -import 'package:stream_chat_flutter/src/theme/stream_chat_theme.dart'; -import 'package:stream_chat_flutter/src/theme/themes.dart'; -import 'package:stream_chat_flutter/src/utils/utils.dart'; -import 'package:stream_chat_flutter_core/stream_chat_flutter_core.dart'; - -/// {@template streamGalleryHeader} -/// Header/AppBar widget for media display screen -/// {@endtemplate} -class StreamGalleryHeader extends StatelessWidget - implements PreferredSizeWidget { - /// {@macro streamGalleryHeader} - const StreamGalleryHeader({ - super.key, - required this.message, - required this.attachment, - this.showBackButton = true, - this.onBackPressed, - this.onShowMessage, - this.onReplyMessage, - this.onTitleTap, - this.onImageTap, - this.userName = '', - this.sentAt = '', - this.backgroundColor, - this.attachmentActionsModalBuilder, - this.elevation = 1.0, - }) : preferredSize = const Size.fromHeight(kToolbarHeight); - - /// Whether to show the leading back button. - /// - /// Defaults to `true`. - final bool showBackButton; - - /// Callback to call when pressing the back button. - /// By default it calls [Navigator.pop] - final VoidCallback? onBackPressed; - - /// Callback to call when pressing the show message button. - final VoidCallback? onShowMessage; - - /// Callback to call when pressing the reply message button. - final VoidCallback? onReplyMessage; - - /// Callback to call when the header is tapped. - final VoidCallback? onTitleTap; - - /// Callback to call when the image is tapped. - final VoidCallback? onImageTap; - - /// Message which attachments are attached to - final Message message; - - /// The attachment that's currently in focus - final Attachment attachment; - - /// Username of sender - final String userName; - - /// Text which connotes the time the message was sent - final String sentAt; - - /// The background color of this [StreamGalleryHeader]. - final Color? backgroundColor; - - /// {@macro attachmentActionsBuilder} - final AttachmentActionsBuilder? attachmentActionsModalBuilder; - - /// The elevation of this [StreamGalleryHeader]. - /// - /// Defaults to `1.0`. When used for desktop & web platforms, it should - /// be set to `0.0`. - final double elevation; - - @override - Widget build(BuildContext context) { - final galleryHeaderThemeData = StreamGalleryHeaderTheme.of(context); - final theme = Theme.of(context); - return AppBar( - toolbarTextStyle: theme.textTheme.bodyMedium, - titleTextStyle: theme.textTheme.titleLarge, - systemOverlayStyle: theme.brightness == Brightness.dark - ? SystemUiOverlayStyle.light - : SystemUiOverlayStyle.dark, - elevation: elevation, - leading: showBackButton - ? IconButton( - icon: StreamSvgIcon( - icon: StreamSvgIcons.close, - color: galleryHeaderThemeData.closeButtonColor, - size: 24, - ), - onPressed: onBackPressed, - ) - : const Empty(), - surfaceTintColor: - backgroundColor ?? galleryHeaderThemeData.backgroundColor, - backgroundColor: - backgroundColor ?? galleryHeaderThemeData.backgroundColor, - actions: [ - if (!message.isEphemeral) - IconButton( - icon: StreamSvgIcon( - icon: StreamSvgIcons.menuPoint, - color: galleryHeaderThemeData.iconMenuPointColor, - ), - onPressed: () => _showMessageActionModalBottomSheet(context), - ), - ], - centerTitle: true, - title: !message.isEphemeral - ? InkWell( - onTap: onTitleTap, - child: SizedBox( - height: preferredSize.height, - width: preferredSize.width, - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - mainAxisSize: MainAxisSize.min, - children: [ - Text( - userName, - style: galleryHeaderThemeData.titleTextStyle, - ), - Text( - sentAt, - style: galleryHeaderThemeData.subtitleTextStyle, - ), - ], - ), - ), - ) - : const Empty(), - ); - } - - @override - final Size preferredSize; - - Future _showMessageActionModalBottomSheet(BuildContext context) async { - final channel = StreamChannel.of(context).channel; - final galleryHeaderThemeData = - StreamChatTheme.of(context).galleryHeaderTheme; - - final defaultModal = AttachmentActionsModal( - attachment: attachment, - message: message, - onShowMessage: onShowMessage, - onReply: onReplyMessage, - ); - - final effectiveModal = attachmentActionsModalBuilder?.call( - context, - attachment, - defaultModal, - ) ?? - defaultModal; - - final result = await showDialog( - useRootNavigator: false, - context: context, - barrierColor: galleryHeaderThemeData.bottomSheetBarrierColor, - builder: (context) => StreamChannel( - channel: channel, - child: effectiveModal, - ), - ); - - if (result != null) { - Navigator.of(context).pop(result); - } - } -} diff --git a/packages/stream_chat_flutter/lib/src/icons/stream_svg_icon.dart b/packages/stream_chat_flutter/lib/src/icons/stream_svg_icon.dart index 03138b891e..ca3dc1dc37 100644 --- a/packages/stream_chat_flutter/lib/src/icons/stream_svg_icon.dart +++ b/packages/stream_chat_flutter/lib/src/icons/stream_svg_icon.dart @@ -1,7 +1,5 @@ -// ignore_for_file: deprecated_member_use_from_same_package - import 'package:flutter/material.dart'; -import 'package:svg_icon_widget/svg_icon_widget.dart'; +import 'package:stream_core_flutter/stream_core_flutter.dart'; part 'stream_svg_icon.g.dart'; @@ -13,1155 +11,110 @@ typedef StreamSvgIconData = SvgIconData; /// {@template StreamSvgIcon} /// Icon set of stream chat /// {@endtemplate} +@Deprecated('Use Icon(context.streamIcons.*) instead') class StreamSvgIcon extends StatelessWidget { /// Creates a [StreamSvgIcon]. + /// + /// Deprecated in favor of regular [Icon] widgets. + /// New icons can be replaced using the [StreamIcons] theme data. + /// + /// Previously: + /// + /// ```dart + /// StreamSvgIcon(icon: StreamSvgIcons.arrowRight) + /// ``` + /// + /// Replacement: + /// + /// ```dart + /// Icon(context.streamIcons.arrowRight) + /// ``` + /// + /// List of replacement icons: + /// + /// - StreamSvgIcons.arrowRight -> context.streamIcons.arrowRight + /// - StreamSvgIcons.attach -> context.streamIcons.attachment + /// - StreamSvgIcons.award -> context.streamIcons.trophy + /// - StreamSvgIcons.camera -> context.streamIcons.camera + /// - StreamSvgIcons.check -> context.streamIcons.checkmark + /// - StreamSvgIcons.checkAll -> context.streamIcons.checks + /// - StreamSvgIcons.checkSend -> context.streamIcons.checkmark + /// - StreamSvgIcons.circleUp -> context.streamIcons.arrowUp + /// - StreamSvgIcons.close -> context.streamIcons.xmark + /// - StreamSvgIcons.closeSmall -> context.streamIcons.xmark + /// - StreamSvgIcons.contacts -> context.streamIcons.users + /// - StreamSvgIcons.copy -> context.streamIcons.copy + /// - StreamSvgIcons.delete -> context.streamIcons.delete + /// - StreamSvgIcons.down -> context.streamIcons.chevronDown + /// - StreamSvgIcons.download -> context.streamIcons.download + /// - StreamSvgIcons.edit -> context.streamIcons.edit + /// - StreamSvgIcons.emptyCircleRight -> context.streamIcons.chevronRight + /// - StreamSvgIcons.error -> context.streamIcons.exclamationCircleFill + /// - StreamSvgIcons.eye -> context.streamIcons.eyeFill + /// - StreamSvgIcons.files -> context.streamIcons.file + /// - StreamSvgIcons.flag -> context.streamIcons.flag + /// - StreamSvgIcons.grid -> context.streamIcons.gallery + /// - StreamSvgIcons.group -> context.streamIcons.users + /// - StreamSvgIcons.left -> context.streamIcons.chevronLeft + /// - StreamSvgIcons.lightning -> context.streamIcons.bolt + /// - StreamSvgIcons.link -> context.streamIcons.link + /// - StreamSvgIcons.lock -> context.streamIcons.lock + /// - StreamSvgIcons.mentions -> context.streamIcons.mention + /// - StreamSvgIcons.menuPoint -> context.streamIcons.more + /// - StreamSvgIcons.message -> context.streamIcons.messageBubble + /// - StreamSvgIcons.messageUnread -> context.streamIcons.notification + /// - StreamSvgIcons.mic -> context.streamIcons.voice + /// - StreamSvgIcons.mute -> context.streamIcons.mute + /// - StreamSvgIcons.notification -> context.streamIcons.bell + /// - StreamSvgIcons.pause -> context.streamIcons.pauseFill + /// - StreamSvgIcons.penWrite -> context.streamIcons.edit + /// - StreamSvgIcons.pictures -> context.streamIcons.image + /// - StreamSvgIcons.pin -> context.streamIcons.pin + /// - StreamSvgIcons.play -> context.streamIcons.playFill + /// - StreamSvgIcons.polls -> context.streamIcons.poll + /// - StreamSvgIcons.record -> context.streamIcons.video + /// - StreamSvgIcons.reload -> context.streamIcons.refresh + /// - StreamSvgIcons.reply -> context.streamIcons.reply + /// - StreamSvgIcons.retry -> context.streamIcons.retry + /// - StreamSvgIcons.right -> context.streamIcons.chevronRight + /// - StreamSvgIcons.save -> context.streamIcons.save + /// - StreamSvgIcons.search -> context.streamIcons.search + /// - StreamSvgIcons.send -> context.streamIcons.send + /// - StreamSvgIcons.sendMessage -> context.streamIcons.send + /// - StreamSvgIcons.share -> context.streamIcons.export + /// - StreamSvgIcons.shareArrow -> context.streamIcons.share + /// - StreamSvgIcons.smile -> context.streamIcons.emoji + /// - StreamSvgIcons.stop -> context.streamIcons.stopFill + /// - StreamSvgIcons.threadReply -> context.streamIcons.thread + /// - StreamSvgIcons.time -> context.streamIcons.clock + /// - StreamSvgIcons.up -> context.streamIcons.chevronUp + /// - StreamSvgIcons.user -> context.streamIcons.user + /// - StreamSvgIcons.userAdd -> context.streamIcons.userAdd + /// - StreamSvgIcons.userDelete -> context.streamIcons.userRemove + /// - StreamSvgIcons.userRemove -> context.streamIcons.userRemove + /// - StreamSvgIcons.userSettings -> context.streamIcons.userCheck + /// - StreamSvgIcons.videoCall -> context.streamIcons.videoFill + /// - StreamSvgIcons.volumeUp -> context.streamIcons.audio + /// + /// Removed in new set (no equivalent): + /// - StreamSvgIcons.cloudDownload + /// - StreamSvgIcons.lolReaction + /// - StreamSvgIcons.loveReaction + /// - StreamSvgIcons.moon + /// - StreamSvgIcons.settings + /// - StreamSvgIcons.thumbsDownReaction + /// - StreamSvgIcons.thumbsUpReaction + /// - StreamSvgIcons.wutReaction + @Deprecated('Use Icon(context.streamIcons.*) instead') const StreamSvgIcon({ super.key, this.icon, - @Deprecated("Use 'icon' instead") this.assetName, this.color, - double? size, - @Deprecated("Use 'size' instead") this.width, - @Deprecated("Use 'size' instead") this.height, + this.size, this.textDirection, this.semanticLabel, this.applyTextScaling, - }) : assert( - size == null || (width == null && height == null), - 'Cannot provide both a size and a width or height', - ), - size = size ?? width ?? height; - - /// [StreamSvgIcon] type - @Deprecated("Use regular 'StreamSvgIcon' with 'icon' instead") - factory StreamSvgIcon.settings({ - double? size, - Color? color, - }) { - return StreamSvgIcon( - icon: StreamSvgIcons.settings, - color: color, - size: size, - ); - } - - /// [StreamSvgIcon] type - @Deprecated("Use regular 'StreamSvgIcon' with 'icon' instead") - factory StreamSvgIcon.down({ - double? size, - Color? color, - }) { - return StreamSvgIcon( - icon: StreamSvgIcons.down, - color: color, - size: size, - ); - } - - /// [StreamSvgIcon] type - @Deprecated("Use regular 'StreamSvgIcon' with 'icon' instead") - factory StreamSvgIcon.up({ - double? size, - Color? color, - }) { - return StreamSvgIcon( - icon: StreamSvgIcons.up, - color: color, - size: size, - ); - } - - /// [StreamSvgIcon] type - @Deprecated("Use regular 'StreamSvgIcon' with 'icon' instead") - factory StreamSvgIcon.attach({ - double? size, - Color? color, - }) { - return StreamSvgIcon( - icon: StreamSvgIcons.attach, - color: color, - size: size, - ); - } - - /// [StreamSvgIcon] type - @Deprecated("Use regular 'StreamSvgIcon' with 'icon' instead") - factory StreamSvgIcon.loveReaction({ - double? size, - Color? color, - }) { - return StreamSvgIcon( - icon: StreamSvgIcons.loveReaction, - color: color, - size: size, - ); - } - - /// [StreamSvgIcon] type - @Deprecated("Use regular 'StreamSvgIcon' with 'icon' instead") - factory StreamSvgIcon.thumbsUpReaction({ - double? size, - Color? color, - }) { - return StreamSvgIcon( - icon: StreamSvgIcons.thumbsUpReaction, - color: color, - size: size, - ); - } - - /// [StreamSvgIcon] type - @Deprecated("Use regular 'StreamSvgIcon' with 'icon' instead") - factory StreamSvgIcon.thumbsDownReaction({ - double? size, - Color? color, - }) { - return StreamSvgIcon( - icon: StreamSvgIcons.thumbsDownReaction, - color: color, - size: size, - ); - } - - /// [StreamSvgIcon] type - @Deprecated("Use regular 'StreamSvgIcon' with 'icon' instead") - factory StreamSvgIcon.lolReaction({ - double? size, - Color? color, - }) { - return StreamSvgIcon( - icon: StreamSvgIcons.lolReaction, - color: color, - size: size, - ); - } - - /// [StreamSvgIcon] type - @Deprecated("Use regular 'StreamSvgIcon' with 'icon' instead") - factory StreamSvgIcon.wutReaction({ - double? size, - Color? color, - }) { - return StreamSvgIcon( - icon: StreamSvgIcons.wutReaction, - color: color, - size: size, - ); - } - - /// [StreamSvgIcon] type - @Deprecated("Use regular 'StreamSvgIcon' with 'icon' instead") - factory StreamSvgIcon.smile({ - double? size, - Color? color, - }) { - return StreamSvgIcon( - icon: StreamSvgIcons.smile, - color: color, - size: size, - ); - } - - /// [StreamSvgIcon] type - @Deprecated("Use regular 'StreamSvgIcon' with 'icon' instead") - factory StreamSvgIcon.mentions({ - double? size, - Color? color, - }) { - return StreamSvgIcon( - icon: StreamSvgIcons.mentions, - color: color, - size: size, - ); - } - - /// [StreamSvgIcon] type - @Deprecated("Use regular 'StreamSvgIcon' with 'icon' instead") - factory StreamSvgIcon.record({ - double? size, - Color? color, - }) { - return StreamSvgIcon( - icon: StreamSvgIcons.record, - color: color, - size: size, - ); - } - - /// [StreamSvgIcon] type - @Deprecated("Use regular 'StreamSvgIcon' with 'icon' instead") - factory StreamSvgIcon.camera({ - double? size, - Color? color, - }) { - return StreamSvgIcon( - icon: StreamSvgIcons.camera, - color: color, - size: size, - ); - } - - /// [StreamSvgIcon] type - @Deprecated("Use regular 'StreamSvgIcon' with 'icon' instead") - factory StreamSvgIcon.files({ - double? size, - Color? color, - }) { - return StreamSvgIcon( - icon: StreamSvgIcons.files, - color: color, - size: size, - ); - } - - /// [StreamSvgIcon] type - @Deprecated("Use regular 'StreamSvgIcon' with 'icon' instead") - factory StreamSvgIcon.polls({ - double? size, - Color? color, - }) { - return StreamSvgIcon( - icon: StreamSvgIcons.polls, - color: color, - size: size, - ); - } - - /// [StreamSvgIcon] type - @Deprecated("Use regular 'StreamSvgIcon' with 'icon' instead") - factory StreamSvgIcon.send({ - double? size, - Color? color, - }) { - return StreamSvgIcon( - icon: StreamSvgIcons.send, - color: color, - size: size, - ); - } - - /// [StreamSvgIcon] type - @Deprecated("Use regular 'StreamSvgIcon' with 'icon' instead") - factory StreamSvgIcon.pictures({ - double? size, - Color? color, - }) { - return StreamSvgIcon( - icon: StreamSvgIcons.pictures, - color: color, - size: size, - ); - } - - /// [StreamSvgIcon] type - @Deprecated("Use regular 'StreamSvgIcon' with 'icon' instead") - factory StreamSvgIcon.left({ - double? size, - Color? color, - }) { - return StreamSvgIcon( - icon: StreamSvgIcons.left, - color: color, - size: size, - ); - } - - /// [StreamSvgIcon] type - @Deprecated("Use regular 'StreamSvgIcon' with 'icon' instead") - factory StreamSvgIcon.user({ - double? size, - Color? color, - }) { - return StreamSvgIcon( - icon: StreamSvgIcons.user, - color: color, - size: size, - ); - } - - /// [StreamSvgIcon] type - @Deprecated("Use regular 'StreamSvgIcon' with 'icon' instead") - factory StreamSvgIcon.userAdd({ - double? size, - Color? color, - }) { - return StreamSvgIcon( - icon: StreamSvgIcons.userAdd, - color: color, - size: size, - ); - } - - /// [StreamSvgIcon] type - @Deprecated("Use regular 'StreamSvgIcon' with 'icon' instead") - factory StreamSvgIcon.check({ - double? size, - Color? color, - }) { - return StreamSvgIcon( - icon: StreamSvgIcons.check, - color: color, - size: size, - ); - } - - /// [StreamSvgIcon] type - @Deprecated("Use regular 'StreamSvgIcon' with 'icon' instead") - factory StreamSvgIcon.checkAll({ - double? size, - Color? color, - }) { - return StreamSvgIcon( - icon: StreamSvgIcons.checkAll, - color: color, - size: size, - ); - } - - /// [StreamSvgIcon] type - @Deprecated("Use regular 'StreamSvgIcon' with 'icon' instead") - factory StreamSvgIcon.checkSend({ - double? size, - Color? color, - }) { - return StreamSvgIcon( - icon: StreamSvgIcons.checkSend, - color: color, - size: size, - ); - } - - /// [StreamSvgIcon] type - @Deprecated("Use regular 'StreamSvgIcon' with 'icon' instead") - factory StreamSvgIcon.penWrite({ - double? size, - Color? color, - }) { - return StreamSvgIcon( - icon: StreamSvgIcons.penWrite, - color: color, - size: size, - ); - } - - /// [StreamSvgIcon] type - @Deprecated("Use regular 'StreamSvgIcon' with 'icon' instead") - factory StreamSvgIcon.contacts({ - double? size, - Color? color, - }) { - return StreamSvgIcon( - icon: StreamSvgIcons.contacts, - color: color, - size: size, - ); - } - - /// [StreamSvgIcon] type - @Deprecated("Use regular 'StreamSvgIcon' with 'icon' instead") - factory StreamSvgIcon.close({ - double? size, - Color? color, - }) { - return StreamSvgIcon( - icon: StreamSvgIcons.close, - color: color, - size: size, - ); - } - - /// [StreamSvgIcon] type - @Deprecated("Use regular 'StreamSvgIcon' with 'icon' instead") - factory StreamSvgIcon.search({ - double? size, - Color? color, - }) { - return StreamSvgIcon( - icon: StreamSvgIcons.search, - color: color, - size: size, - ); - } - - /// [StreamSvgIcon] type - @Deprecated("Use regular 'StreamSvgIcon' with 'icon' instead") - factory StreamSvgIcon.right({ - double? size, - Color? color, - }) { - return StreamSvgIcon( - icon: StreamSvgIcons.right, - color: color, - size: size, - ); - } - - /// [StreamSvgIcon] type - @Deprecated("Use regular 'StreamSvgIcon' with 'icon' instead") - factory StreamSvgIcon.mute({ - double? size, - Color? color, - }) { - return StreamSvgIcon( - icon: StreamSvgIcons.mute, - color: color, - size: size, - ); - } - - /// [StreamSvgIcon] type - @Deprecated("Use regular 'StreamSvgIcon' with 'icon' instead") - factory StreamSvgIcon.userRemove({ - double? size, - Color? color, - }) { - return StreamSvgIcon( - icon: StreamSvgIcons.userRemove, - color: color, - size: size, - ); - } - - /// [StreamSvgIcon] type - @Deprecated("Use regular 'StreamSvgIcon' with 'icon' instead") - factory StreamSvgIcon.lightning({ - double? size, - Color? color, - }) { - return StreamSvgIcon( - icon: StreamSvgIcons.lightning, - color: color, - size: size, - ); - } - - /// [StreamSvgIcon] type - @Deprecated("Use regular 'StreamSvgIcon' with 'icon' instead") - factory StreamSvgIcon.emptyCircleLeft({ - double? size, - Color? color, - }) { - return StreamSvgIcon( - icon: StreamSvgIcons.emptyCircleRight, - color: color, - size: size, - ); - } - - /// [StreamSvgIcon] type - @Deprecated("Use regular 'StreamSvgIcon' with 'icon' instead") - factory StreamSvgIcon.message({ - double? size, - Color? color, - }) { - return StreamSvgIcon( - icon: StreamSvgIcons.message, - color: color, - size: size, - ); - } - - /// [StreamSvgIcon] type - @Deprecated("Use regular 'StreamSvgIcon' with 'icon' instead") - factory StreamSvgIcon.messageUnread({ - double? size, - Color? color, - }) { - return StreamSvgIcon( - icon: StreamSvgIcons.messageUnread, - color: color, - size: size, - ); - } - - /// [StreamSvgIcon] type - @Deprecated("Use regular 'StreamSvgIcon' with 'icon' instead") - factory StreamSvgIcon.thread({ - double? size, - Color? color, - }) { - return StreamSvgIcon( - icon: StreamSvgIcons.threadReply, - color: color, - size: size, - ); - } - - /// [StreamSvgIcon] type - @Deprecated("Use regular 'StreamSvgIcon' with 'icon' instead") - factory StreamSvgIcon.reply({ - double? size, - Color? color, - }) { - return StreamSvgIcon( - icon: StreamSvgIcons.reply, - color: color, - size: size, - ); - } - - /// [StreamSvgIcon] type - @Deprecated("Use regular 'StreamSvgIcon' with 'icon' instead") - factory StreamSvgIcon.edit({ - double? size, - Color? color, - }) { - return StreamSvgIcon( - icon: StreamSvgIcons.edit, - color: color, - size: size, - ); - } - - /// [StreamSvgIcon] type - @Deprecated("Use regular 'StreamSvgIcon' with 'icon' instead") - factory StreamSvgIcon.download({ - double? size, - Color? color, - }) { - return StreamSvgIcon( - icon: StreamSvgIcons.download, - color: color, - size: size, - ); - } - - /// [StreamSvgIcon] type - @Deprecated("Use regular 'StreamSvgIcon' with 'icon' instead") - factory StreamSvgIcon.cloudDownload({ - double? size, - Color? color, - }) { - return StreamSvgIcon( - icon: StreamSvgIcons.cloudDownload, - color: color, - size: size, - ); - } - - /// [StreamSvgIcon] type - @Deprecated("Use regular 'StreamSvgIcon' with 'icon' instead") - factory StreamSvgIcon.copy({ - double? size, - Color? color, - }) { - return StreamSvgIcon( - icon: StreamSvgIcons.copy, - color: color, - size: size, - ); - } - - /// [StreamSvgIcon] type - @Deprecated("Use regular 'StreamSvgIcon' with 'icon' instead") - factory StreamSvgIcon.delete({ - double? size, - Color? color, - }) { - return StreamSvgIcon( - icon: StreamSvgIcons.delete, - color: color, - size: size, - ); - } - - /// [StreamSvgIcon] type - @Deprecated("Use regular 'StreamSvgIcon' with 'icon' instead") - factory StreamSvgIcon.eye({ - double? size, - Color? color, - }) { - return StreamSvgIcon( - icon: StreamSvgIcons.eye, - color: color, - size: size, - ); - } - - /// [StreamSvgIcon] type - @Deprecated("Use regular 'StreamSvgIcon' with 'icon' instead") - factory StreamSvgIcon.arrowRight({ - double? size, - Color? color, - }) { - return StreamSvgIcon( - icon: StreamSvgIcons.arrowRight, - color: color, - size: size, - ); - } - - /// [StreamSvgIcon] type - @Deprecated("Use regular 'StreamSvgIcon' with 'icon' instead") - factory StreamSvgIcon.closeSmall({ - double? size, - Color? color, - }) { - return StreamSvgIcon( - icon: StreamSvgIcons.closeSmall, - color: color, - size: size, - ); - } - - /// [StreamSvgIcon] type - @Deprecated("Use regular 'StreamSvgIcon' with 'icon' instead") - factory StreamSvgIcon.iconCurveLineLeftUp({ - double? size, - Color? color, - }) { - return StreamSvgIcon( - icon: StreamSvgIcons.reply, - color: color, - size: size, - ); - } - - /// [StreamSvgIcon] type - @Deprecated("Use regular 'StreamSvgIcon' with 'icon' instead") - factory StreamSvgIcon.iconMoon({ - double? size, - Color? color, - }) { - return StreamSvgIcon( - icon: StreamSvgIcons.moon, - color: color, - size: size, - ); - } - - /// [StreamSvgIcon] type - @Deprecated("Use regular 'StreamSvgIcon' with 'icon' instead") - factory StreamSvgIcon.iconShare({ - double? size, - Color? color, - }) { - return StreamSvgIcon( - icon: StreamSvgIcons.share, - color: color, - size: size, - ); - } - - /// [StreamSvgIcon] type - @Deprecated("Use regular 'StreamSvgIcon' with 'icon' instead") - factory StreamSvgIcon.iconGrid({ - double? size, - Color? color, - }) { - return StreamSvgIcon( - icon: StreamSvgIcons.grid, - color: color, - size: size, - ); - } - - /// [StreamSvgIcon] type - @Deprecated("Use regular 'StreamSvgIcon' with 'icon' instead") - factory StreamSvgIcon.iconSendMessage({ - double? size, - Color? color, - }) { - return StreamSvgIcon( - icon: StreamSvgIcons.sendMessage, - color: color, - size: size, - ); - } - - /// [StreamSvgIcon] type - @Deprecated("Use regular 'StreamSvgIcon' with 'icon' instead") - factory StreamSvgIcon.iconMenuPoint({ - double? size, - Color? color, - }) { - return StreamSvgIcon( - icon: StreamSvgIcons.menuPoint, - color: color, - size: size, - ); - } - - /// [StreamSvgIcon] type - @Deprecated("Use regular 'StreamSvgIcon' with 'icon' instead") - factory StreamSvgIcon.iconSave({ - double? size, - Color? color, - }) { - return StreamSvgIcon( - icon: StreamSvgIcons.save, - color: color, - size: size, - ); - } - - /// [StreamSvgIcon] type - @Deprecated("Use regular 'StreamSvgIcon' with 'icon' instead") - factory StreamSvgIcon.shareArrow({ - double? size, - Color? color, - }) { - return StreamSvgIcon( - icon: StreamSvgIcons.shareArrow, - color: color, - size: size, - ); - } - - /// [StreamSvgIcon] type - @Deprecated("Use regular 'StreamSvgIcon' with 'icon' instead") - factory StreamSvgIcon.filetypeAac({ - double? size, - Color? color, - }) { - return StreamSvgIcon( - icon: StreamSvgIcons.filetypeAudioAac, - color: color, - size: size, - ); - } - - /// [StreamSvgIcon] type - @Deprecated("Use regular 'StreamSvgIcon' with 'icon' instead") - factory StreamSvgIcon.filetype7z({ - double? size, - Color? color, - }) { - return StreamSvgIcon( - icon: StreamSvgIcons.filetypeCompression7z, - color: color, - size: size, - ); - } - - /// [StreamSvgIcon] type - @Deprecated("Use regular 'StreamSvgIcon' with 'icon' instead") - factory StreamSvgIcon.filetypeCsv({ - double? size, - Color? color, - }) { - return StreamSvgIcon( - icon: StreamSvgIcons.filetypeCodeCsv, - color: color, - size: size, - ); - } - - /// [StreamSvgIcon] type - @Deprecated("Use regular 'StreamSvgIcon' with 'icon' instead") - factory StreamSvgIcon.filetypeDoc({ - double? size, - Color? color, - }) { - return StreamSvgIcon( - icon: StreamSvgIcons.filetypeTextDoc, - color: color, - size: size, - ); - } - - /// [StreamSvgIcon] type - @Deprecated("Use regular 'StreamSvgIcon' with 'icon' instead") - factory StreamSvgIcon.filetypeDocx({ - double? size, - Color? color, - }) { - return StreamSvgIcon( - icon: StreamSvgIcons.filetypeTextDocx, - color: color, - size: size, - ); - } - - /// [StreamSvgIcon] type - @Deprecated("Use regular 'StreamSvgIcon' with 'icon' instead") - factory StreamSvgIcon.filetypeGeneric({ - double? size, - Color? color, - }) { - return StreamSvgIcon( - icon: StreamSvgIcons.filetypeOtherStandard, - color: color, - size: size, - ); - } - - /// [StreamSvgIcon] type - @Deprecated("Use regular 'StreamSvgIcon' with 'icon' instead") - factory StreamSvgIcon.filetypeHtml({ - double? size, - Color? color, - }) { - return StreamSvgIcon( - icon: StreamSvgIcons.filetypeCodeHtml, - color: color, - size: size, - ); - } - - /// [StreamSvgIcon] type - @Deprecated("Use regular 'StreamSvgIcon' with 'icon' instead") - factory StreamSvgIcon.filetypeMd({ - double? size, - Color? color, - }) { - return StreamSvgIcon( - icon: StreamSvgIcons.filetypeCodeMd, - color: color, - size: size, - ); - } - - /// [StreamSvgIcon] type - @Deprecated("Use regular 'StreamSvgIcon' with 'icon' instead") - factory StreamSvgIcon.filetypeOdt({ - double? size, - Color? color, - }) { - return StreamSvgIcon( - icon: StreamSvgIcons.filetypeTextOdt, - color: color, - size: size, - ); - } - - /// [StreamSvgIcon] type - @Deprecated("Use regular 'StreamSvgIcon' with 'icon' instead") - factory StreamSvgIcon.filetypePdf({ - double? size, - Color? color, - }) { - return StreamSvgIcon( - icon: StreamSvgIcons.filetypeOtherPdf, - color: color, - size: size, - ); - } - - /// [StreamSvgIcon] type - @Deprecated("Use regular 'StreamSvgIcon' with 'icon' instead") - factory StreamSvgIcon.filetypePpt({ - double? size, - Color? color, - }) { - return StreamSvgIcon( - icon: StreamSvgIcons.filetypePresentationPpt, - color: color, - size: size, - ); - } - - /// [StreamSvgIcon] type - @Deprecated("Use regular 'StreamSvgIcon' with 'icon' instead") - factory StreamSvgIcon.filetypePptx({ - double? size, - Color? color, - }) { - return StreamSvgIcon( - icon: StreamSvgIcons.filetypePresentationPptx, - color: color, - size: size, - ); - } - - /// [StreamSvgIcon] type - @Deprecated("Use regular 'StreamSvgIcon' with 'icon' instead") - factory StreamSvgIcon.filetypeRar({ - double? size, - Color? color, - }) { - return StreamSvgIcon( - icon: StreamSvgIcons.filetypeCompressionRar, - color: color, - size: size, - ); - } - - /// [StreamSvgIcon] type - @Deprecated("Use regular 'StreamSvgIcon' with 'icon' instead") - factory StreamSvgIcon.filetypeRtf({ - double? size, - Color? color, - }) { - return StreamSvgIcon( - icon: StreamSvgIcons.filetypeTextRtf, - color: color, - size: size, - ); - } - - /// [StreamSvgIcon] type - @Deprecated("Use regular 'StreamSvgIcon' with 'icon' instead") - factory StreamSvgIcon.filetypeTar({ - double? size, - Color? color, - }) { - return StreamSvgIcon( - icon: StreamSvgIcons.filetypeCodeTar, - color: color, - size: size, - ); - } - - /// [StreamSvgIcon] type - @Deprecated("Use regular 'StreamSvgIcon' with 'icon' instead") - factory StreamSvgIcon.filetypeTxt({ - double? size, - Color? color, - }) { - return StreamSvgIcon( - icon: StreamSvgIcons.filetypeTextTxt, - color: color, - size: size, - ); - } - - /// [StreamSvgIcon] type - @Deprecated("Use regular 'StreamSvgIcon' with 'icon' instead") - factory StreamSvgIcon.filetypeXls({ - double? size, - Color? color, - }) { - return StreamSvgIcon( - icon: StreamSvgIcons.filetypeSpreadsheetXls, - color: color, - size: size, - ); - } - - /// [StreamSvgIcon] type - @Deprecated("Use regular 'StreamSvgIcon' with 'icon' instead") - factory StreamSvgIcon.filetypeXlsx({ - double? size, - Color? color, - }) { - return StreamSvgIcon( - icon: StreamSvgIcons.filetypeSpreadsheetXlsx, - color: color, - size: size, - ); - } - - /// [StreamSvgIcon] type - @Deprecated("Use regular 'StreamSvgIcon' with 'icon' instead") - factory StreamSvgIcon.filetypeZip({ - double? size, - Color? color, - }) { - return StreamSvgIcon( - icon: StreamSvgIcons.filetypeCompressionZip, - color: color, - size: size, - ); - } - - /// [StreamSvgIcon] type - @Deprecated("Use regular 'StreamSvgIcon' with 'icon' instead") - factory StreamSvgIcon.iconGroup({ - double? size, - Color? color, - }) { - return StreamSvgIcon( - icon: StreamSvgIcons.group, - color: color, - size: size, - ); - } - - /// [StreamSvgIcon] type - @Deprecated("Use regular 'StreamSvgIcon' with 'icon' instead") - factory StreamSvgIcon.iconNotification({ - double? size, - Color? color, - }) { - return StreamSvgIcon( - icon: StreamSvgIcons.notification, - color: color, - size: size, - ); - } - - /// [StreamSvgIcon] type - @Deprecated("Use regular 'StreamSvgIcon' with 'icon' instead") - factory StreamSvgIcon.iconUserDelete({ - double? size, - Color? color, - }) { - return StreamSvgIcon( - icon: StreamSvgIcons.userDelete, - color: color, - size: size, - ); - } - - /// [StreamSvgIcon] type - @Deprecated("Use regular 'StreamSvgIcon' with 'icon' instead") - factory StreamSvgIcon.error({ - double? size, - Color? color, - }) { - return StreamSvgIcon( - icon: StreamSvgIcons.error, - color: color, - size: size, - ); - } - - /// [StreamSvgIcon] type - @Deprecated("Use regular 'StreamSvgIcon' with 'icon' instead") - factory StreamSvgIcon.circleUp({ - double? size, - Color? color, - }) { - return StreamSvgIcon( - icon: StreamSvgIcons.circleUp, - color: color, - size: size, - ); - } - - /// [StreamSvgIcon] type - @Deprecated("Use regular 'StreamSvgIcon' with 'icon' instead") - factory StreamSvgIcon.iconUserSettings({ - double? size, - Color? color, - }) { - return StreamSvgIcon( - icon: StreamSvgIcons.userSettings, - color: color, - size: size, - ); - } - - /// [StreamSvgIcon] type - @Deprecated("Use regular 'StreamSvgIcon' with 'icon' instead") - factory StreamSvgIcon.giphyIcon({ - double? size, - Color? color, - }) { - return StreamSvgIcon( - icon: StreamSvgIcons.giphy, - color: color, - size: size, - ); - } - - /// [StreamSvgIcon] type - @Deprecated("Use regular 'StreamSvgIcon' with 'icon' instead") - factory StreamSvgIcon.imgur({ - double? size, - Color? color, - }) { - return StreamSvgIcon( - icon: StreamSvgIcons.imgur, - color: color, - size: size, - ); - } - - /// [StreamSvgIcon] type - @Deprecated("Use regular 'StreamSvgIcon' with 'icon' instead") - factory StreamSvgIcon.volumeUp({ - double? size, - Color? color, - }) { - return StreamSvgIcon( - icon: StreamSvgIcons.volumeUp, - color: color, - size: size, - ); - } - - /// [StreamSvgIcon] type - @Deprecated("Use regular 'StreamSvgIcon' with 'icon' instead") - factory StreamSvgIcon.flag({ - double? size, - Color? color, - }) { - return StreamSvgIcon( - icon: StreamSvgIcons.flag, - color: color, - size: size, - ); - } - - /// [StreamSvgIcon] type - @Deprecated("Use regular 'StreamSvgIcon' with 'icon' instead") - factory StreamSvgIcon.iconFlag({ - double? size, - Color? color, - }) { - return StreamSvgIcon( - icon: StreamSvgIcons.flag, - color: color, - size: size, - ); - } - - /// [StreamSvgIcon] type - @Deprecated("Use regular 'StreamSvgIcon' with 'icon' instead") - factory StreamSvgIcon.retry({ - double? size, - Color? color, - }) { - return StreamSvgIcon( - icon: StreamSvgIcons.retry, - color: color, - size: size, - ); - } - - /// [StreamSvgIcon] type - @Deprecated("Use regular 'StreamSvgIcon' with 'icon' instead") - factory StreamSvgIcon.pin({ - double? size, - Color? color, - }) { - return StreamSvgIcon( - icon: StreamSvgIcons.pin, - color: color, - size: size, - ); - } - - /// [StreamSvgIcon] type - @Deprecated("Use regular 'StreamSvgIcon' with 'icon' instead") - factory StreamSvgIcon.videoCall({ - double? size, - Color? color, - }) { - return StreamSvgIcon( - icon: StreamSvgIcons.videoCall, - color: color, - size: size, - ); - } - - /// [StreamSvgIcon] type - @Deprecated("Use regular 'StreamSvgIcon' with 'icon' instead") - factory StreamSvgIcon.award({ - double? size, - Color? color, - }) { - return StreamSvgIcon( - icon: StreamSvgIcons.award, - color: color, - size: size, - ); - } - - /// [StreamSvgIcon] type - @Deprecated("Use regular 'StreamSvgIcon' with 'icon' instead") - factory StreamSvgIcon.reload({ - double? size, - Color? color, - }) { - return StreamSvgIcon( - icon: StreamSvgIcons.reload, - color: color, - size: size, - ); - } + }); /// The icon to display. /// @@ -1169,21 +122,6 @@ class StreamSvgIcon extends StatelessWidget { /// space of the specified [size]. final StreamSvgIconData? icon; - /// The asset to display. - /// - /// The asset can be null, in which case the widget will render as an empty - /// space of the specified [size]. - @Deprecated("Use 'icon' instead") - final String? assetName; - - /// Width of icon - @Deprecated("Use 'size' instead") - final double? width; - - /// Height of icon - @Deprecated("Use 'size' instead") - final double? height; - /// The size of the icon in logical pixels. /// /// Icons occupy a square with width and height equal to size. @@ -1254,25 +192,8 @@ class StreamSvgIcon extends StatelessWidget { @override Widget build(BuildContext context) { - assert( - icon == null || assetName == null, - 'Cannot provide both an icon and an assetName', - ); - - const iconPackage = 'stream_chat_flutter'; - final iconData = switch (icon) { - final icon? => icon, - null => switch (assetName) { - final name? => SvgIconData( - 'lib/svgs/$name', - package: iconPackage, - ), - _ => null, - }, - }; - return SvgIcon( - iconData, + icon, size: size, color: color, textDirection: textDirection, @@ -1281,58 +202,3 @@ class StreamSvgIcon extends StatelessWidget { ); } } - -/// Alternative of [StreamSvgIcon] which follows the [IconTheme]. -@Deprecated("Use regular 'StreamSvgIcon' instead") -class StreamIconThemeSvgIcon extends StatelessWidget { - /// Creates a [StreamIconThemeSvgIcon]. - @Deprecated("Use regular 'StreamSvgIcon' instead") - const StreamIconThemeSvgIcon({ - super.key, - this.assetName, - this.width, - this.height, - this.color, - }); - - /// Factory constructor to create [StreamIconThemeSvgIcon] - /// from [StreamSvgIcon]. - @Deprecated("Use regular 'StreamSvgIcon' instead") - factory StreamIconThemeSvgIcon.fromSvgIcon( - StreamSvgIcon streamSvgIcon, - ) { - return StreamIconThemeSvgIcon( - assetName: streamSvgIcon.assetName, - width: streamSvgIcon.width, - height: streamSvgIcon.height, - color: streamSvgIcon.color, - ); - } - - /// Name of icon asset - final String? assetName; - - /// Width of icon - final double? width; - - /// Height of icon - final double? height; - - /// Color of icon - final Color? color; - - @override - Widget build(BuildContext context) { - final iconTheme = IconTheme.of(context); - final color = this.color ?? iconTheme.color; - final width = this.width ?? iconTheme.size; - final height = this.height ?? iconTheme.size; - - return StreamSvgIcon( - assetName: assetName, - width: width, - height: height, - color: color, - ); - } -} diff --git a/packages/stream_chat_flutter/lib/src/icons/stream_svg_icon.g.dart b/packages/stream_chat_flutter/lib/src/icons/stream_svg_icon.g.dart index c6a7fc2001..f9f154eb59 100644 --- a/packages/stream_chat_flutter/lib/src/icons/stream_svg_icon.g.dart +++ b/packages/stream_chat_flutter/lib/src/icons/stream_svg_icon.g.dart @@ -503,16 +503,14 @@ abstract final class StreamSvgIcons { ); /// Stream SVG icon named 'filetypePresentationStandard'. - static const StreamSvgIconData filetypePresentationStandard = - StreamSvgIconData( + static const StreamSvgIconData filetypePresentationStandard = StreamSvgIconData( 'lib/assets/icons/colored/icon_filetype_presentation_standard.svg', package: package, preserveColors: true, ); /// Stream SVG icon named 'filetypePresentationSpecial'. - static const StreamSvgIconData filetypePresentationSpecial = - StreamSvgIconData( + static const StreamSvgIconData filetypePresentationSpecial = StreamSvgIconData( 'lib/assets/icons/colored/icon_filetype_presentation_special.svg', package: package, preserveColors: true, @@ -652,8 +650,7 @@ abstract final class StreamSvgIcons { ); /// Stream SVG icon named 'filetypeSpreadsheetStandard'. - static const StreamSvgIconData filetypeSpreadsheetStandard = - StreamSvgIconData( + static const StreamSvgIconData filetypeSpreadsheetStandard = StreamSvgIconData( 'lib/assets/icons/colored/icon_filetype_spreadsheet_standard.svg', package: package, preserveColors: true, @@ -828,8 +825,7 @@ abstract final class StreamSvgIcons { ); /// Stream SVG icon named 'filetypeCompressionStandard'. - static const StreamSvgIconData filetypeCompressionStandard = - StreamSvgIconData( + static const StreamSvgIconData filetypeCompressionStandard = StreamSvgIconData( 'lib/assets/icons/colored/icon_filetype_compression_standard.svg', package: package, preserveColors: true, diff --git a/packages/stream_chat_flutter/lib/src/indicators/sending_indicator.dart b/packages/stream_chat_flutter/lib/src/indicators/sending_indicator.dart index 5951336615..46e30640cb 100644 --- a/packages/stream_chat_flutter/lib/src/indicators/sending_indicator.dart +++ b/packages/stream_chat_flutter/lib/src/indicators/sending_indicator.dart @@ -12,7 +12,7 @@ class StreamSendingIndicator extends StatelessWidget { required this.message, this.isMessageRead = false, this.isMessageDelivered = false, - this.size = 12, + this.size, }); /// The message whose sending status is to be shown. @@ -33,33 +33,33 @@ class StreamSendingIndicator extends StatelessWidget { final colorTheme = streamChatTheme.colorTheme; if (isMessageRead) { - return StreamSvgIcon( + return Icon( + context.streamIcons.checks, size: size, - icon: StreamSvgIcons.checkAll, color: colorTheme.accentPrimary, ); } if (isMessageDelivered) { - return StreamSvgIcon( + return Icon( + context.streamIcons.checks, size: size, - icon: StreamSvgIcons.checkAll, color: colorTheme.textLowEmphasis, ); } if (message.state.isCompleted) { - return StreamSvgIcon( + return Icon( + context.streamIcons.checkmark, size: size, - icon: StreamSvgIcons.check, color: colorTheme.textLowEmphasis, ); } if (message.state.isOutgoing) { - return StreamSvgIcon( + return Icon( + context.streamIcons.clock, size: size, - icon: StreamSvgIcons.time, color: colorTheme.textLowEmphasis, ); } diff --git a/packages/stream_chat_flutter/lib/src/indicators/typing_indicator.dart b/packages/stream_chat_flutter/lib/src/indicators/typing_indicator.dart index fdf8e3a001..10a84b2c7f 100644 --- a/packages/stream_chat_flutter/lib/src/indicators/typing_indicator.dart +++ b/packages/stream_chat_flutter/lib/src/indicators/typing_indicator.dart @@ -34,17 +34,15 @@ class StreamTypingIndicator extends StatelessWidget { @override Widget build(BuildContext context) { - final channelState = - channel?.state ?? StreamChannel.of(context).channel.state!; + final channelState = channel?.state ?? StreamChannel.of(context).channel.state!; final altWidget = alternativeWidget ?? const Empty(); return BetterStreamBuilder>( initialData: channelState.typingEvents.keys, - stream: channelState.typingEventsStream.map((typingEvents) => typingEvents - .entries - .where((element) => element.value.parentId == parentId) - .map((e) => e.key)), + stream: channelState.typingEventsStream.map( + (typingEvents) => typingEvents.entries.where((element) => element.value.parentId == parentId).map((e) => e.key), + ), builder: (context, users) => AnimatedSwitcher( layoutBuilder: (currentChild, previousChildren) => Stack( children: [ @@ -60,11 +58,6 @@ class StreamTypingIndicator extends StatelessWidget { mainAxisSize: MainAxisSize.min, spacing: 4, children: [ - Lottie.asset( - 'lib/assets/animations/typing_dots.json', - package: 'stream_chat_flutter', - height: 4, - ), Flexible( child: Text( context.translations.userTypingText(users), @@ -72,6 +65,14 @@ class StreamTypingIndicator extends StatelessWidget { style: style, ), ), + Padding( + padding: const EdgeInsets.only(top: 2), + child: Lottie.asset( + 'lib/assets/animations/typing_dots.json', + package: 'stream_chat_flutter', + height: 5, + ), + ), ], ), ) diff --git a/packages/stream_chat_flutter/lib/src/indicators/unread_indicator.dart b/packages/stream_chat_flutter/lib/src/indicators/unread_indicator.dart index 728d54f659..3caf8974e7 100644 --- a/packages/stream_chat_flutter/lib/src/indicators/unread_indicator.dart +++ b/packages/stream_chat_flutter/lib/src/indicators/unread_indicator.dart @@ -4,16 +4,25 @@ import 'package:stream_chat_flutter/stream_chat_flutter.dart'; /// {@template streamUnreadIndicator} /// Shows different unread counts of the user. +/// +/// When [child] is provided, the badge is overlaid on top of the child widget. +/// +/// ```dart +/// // Standalone badge (no child). +/// const StreamUnreadIndicator() +/// +/// // Badge overlaid on an icon. +/// StreamUnreadIndicator(child: Icon(Icons.chat_bubble_outline)) +/// ``` /// {@endtemplate} class StreamUnreadIndicator extends StatelessWidget { /// Displays the total unread count. - StreamUnreadIndicator({ + const StreamUnreadIndicator({ super.key, - @Deprecated('Use StreamUnreadIndicator.channels instead') String? cid, - }) : _unreadType = switch (cid) { - final cid? => _UnreadChannels(cid: cid), - _ => const _TotalUnreadCount(), - }; + this.child, + this.alignment, + this.offset, + }) : _unreadType = const _TotalUnreadCount(); /// Displays the unreadChannel count. /// @@ -21,6 +30,9 @@ class StreamUnreadIndicator extends StatelessWidget { StreamUnreadIndicator.channels({ super.key, String? cid, + this.child, + this.alignment, + this.offset, }) : _unreadType = _UnreadChannels(cid: cid); /// Displays the unreadThreads count. @@ -29,59 +41,85 @@ class StreamUnreadIndicator extends StatelessWidget { StreamUnreadIndicator.threads({ super.key, String? id, + this.child, + this.alignment, + this.offset, }) : _unreadType = _UnreadThreads(id: id); final _UnreadTypes _unreadType; + /// Optional child widget to overlay the badge on. + /// + /// When non-null, the badge is positioned on top of this widget. + /// When null, only the badge itself is rendered (or nothing when + /// the count is zero). + final Widget? child; + + /// The alignment of the badge relative to the [child]. + /// + /// Only used when [child] is non-null. + /// + /// Defaults to [AlignmentDirectional.topEnd]. + final AlignmentGeometry? alignment; + + /// Additional pixel offset applied after [alignment]. + /// + /// Only used when [child] is non-null. + /// + /// Defaults to `Offset(8, -6)` for [TextDirection.ltr] or + /// `Offset(-8, -6)` for [TextDirection.rtl]. + final Offset? offset; + @override Widget build(BuildContext context) { - final theme = StreamChatTheme.of(context); final client = StreamChat.of(context).client; final stream = switch (_unreadType) { _TotalUnreadCount() => client.state.totalUnreadCountStream, _UnreadChannels(cid: final cid) => switch (cid) { - final cid? => client.state.channels[cid]?.state?.unreadCountStream, - _ => client.state.unreadChannelsStream, - }, + final cid? => client.state.channels[cid]?.state?.unreadCountStream, + _ => client.state.unreadChannelsStream, + }, _UnreadThreads(id: final id) => switch (id) { - // TODO: Handle id once it's supported - _ => client.state.unreadThreadsStream, - } + // TODO: Handle id once it's supported + _ => client.state.unreadThreadsStream, + }, }; final initialData = switch (_unreadType) { _TotalUnreadCount() => client.state.totalUnreadCount, _UnreadChannels(cid: final cid) => switch (cid) { - final cid? => client.state.channels[cid]?.state?.unreadCount, - _ => client.state.unreadChannels, - }, + final cid? => client.state.channels[cid]?.state?.unreadCount, + _ => client.state.unreadChannels, + }, _UnreadThreads(id: final id) => switch (id) { - // TODO: Handle id once it's supported - _ => client.state.unreadThreads, - } + // TODO: Handle id once it's supported + _ => client.state.unreadThreads, + }, }; - return IgnorePointer( - child: BetterStreamBuilder( - stream: stream, - initialData: initialData, - builder: (context, unreadCount) { - if (unreadCount == 0) return const Empty(); - - return Badge( - textColor: Colors.white, - textStyle: theme.textTheme.footnoteBold, - backgroundColor: theme.channelPreviewTheme.unreadCounterColor, - label: Text( - switch (unreadCount) { - > 99 => '99+', - _ => '$unreadCount', - }, - ), - ); - }, - ), + return BetterStreamBuilder( + stream: stream, + initialData: initialData, + builder: (context, unreadCount) { + if (child == null && unreadCount == 0) return const Empty(); + if (child case final child? when unreadCount == 0) return child; + + final textDirection = Directionality.maybeOf(context); + + final effectiveAlignment = alignment ?? AlignmentDirectional.topEnd; + final effectiveOffset = offset ?? const Offset(8, -6).directional(textDirection); + + return StreamBadgeNotification( + label: switch (unreadCount) { + > 99 => '99+', + _ => '$unreadCount', + }, + alignment: effectiveAlignment, + offset: effectiveOffset, + child: child, + ); + }, ); } } diff --git a/packages/stream_chat_flutter/lib/src/indicators/upload_progress_indicator.dart b/packages/stream_chat_flutter/lib/src/indicators/upload_progress_indicator.dart deleted file mode 100644 index 7a6b289814..0000000000 --- a/packages/stream_chat_flutter/lib/src/indicators/upload_progress_indicator.dart +++ /dev/null @@ -1,82 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:stream_chat_flutter/stream_chat_flutter.dart'; - -/// {@template streamUploadProgressIndicator} -/// Shows the upload progress of an attachment. -/// {@endtemplate} -class StreamUploadProgressIndicator extends StatelessWidget { - /// {@macro streamUploadProgressIndicator} - const StreamUploadProgressIndicator({ - super.key, - required this.uploaded, - required this.total, - this.progressIndicatorColor = const Color(0xffb2b2b2), - this.padding = const EdgeInsets.only( - top: 5, - bottom: 5, - right: 11, - left: 5, - ), - this.showBackground = true, - this.textStyle, - }); - - /// Bytes uploaded - final int uploaded; - - /// Total bytes - final int total; - - /// Color of progress indicator - final Color progressIndicatorColor; - - /// Padding for widget - final EdgeInsetsGeometry padding; - - /// Flag for showing background - final bool showBackground; - - /// [TextStyle] to be applied to text - final TextStyle? textStyle; - - @override - Widget build(BuildContext context) { - final theme = StreamChatTheme.of(context); - final _percentage = (uploaded / total) * 100; - Widget child = Padding( - padding: padding, - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - SizedBox( - height: 16, - width: 16, - child: CircularProgressIndicator.adaptive( - strokeWidth: 3, - valueColor: AlwaysStoppedAnimation(progressIndicatorColor), - ), - ), - const SizedBox(width: 8), - Text( - '${_percentage.toInt()}%', - style: textStyle ?? - theme.textTheme.footnote.copyWith( - color: theme.colorTheme.barsBg, - ), - ), - ], - ), - ); - if (showBackground) { - child = DecoratedBox( - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(12), - // ignore: deprecated_member_use - color: theme.colorTheme.overlayDark.withOpacity(0.6), - ), - child: child, - ); - } - return child; - } -} diff --git a/packages/stream_chat_flutter/lib/src/keyboard_shortcuts/keyboard_shortcut_runner.dart b/packages/stream_chat_flutter/lib/src/keyboard_shortcuts/keyboard_shortcut_runner.dart index 706adad454..fa2383a17b 100644 --- a/packages/stream_chat_flutter/lib/src/keyboard_shortcuts/keyboard_shortcut_runner.dart +++ b/packages/stream_chat_flutter/lib/src/keyboard_shortcuts/keyboard_shortcut_runner.dart @@ -37,8 +37,7 @@ class KeyboardShortcutRunner extends StatelessWidget { shortcuts: { if (onEnterKeypress != null) enterKeySet: EnterKeyIntent(), if (onEscapeKeypress != null) escapeKeySet: EscapeKeyIntent(), - if (onRightArrowKeypress != null) - rightArrowKeySet: RightArrowKeyIntent(), + if (onRightArrowKeypress != null) rightArrowKeySet: RightArrowKeyIntent(), if (onLeftArrowKeypress != null) leftArrowKeySet: LeftArrowKeyIntent(), }, actions: { diff --git a/packages/stream_chat_flutter/lib/src/keyboard_shortcuts/keysets.dart b/packages/stream_chat_flutter/lib/src/keyboard_shortcuts/keysets.dart index 495be7d922..c7cf246742 100644 --- a/packages/stream_chat_flutter/lib/src/keyboard_shortcuts/keysets.dart +++ b/packages/stream_chat_flutter/lib/src/keyboard_shortcuts/keysets.dart @@ -3,7 +3,7 @@ import 'package:flutter/widgets.dart'; /// The "enter" keyset. /// -/// Use to quickly send a message in [StreamMessageInput]. +/// Use to quickly send a message in [StreamMessageComposer]. final enterKeySet = LogicalKeySet( LogicalKeyboardKey.enter, ); @@ -11,22 +11,22 @@ final enterKeySet = LogicalKeySet( /// The "escape" keyset. /// /// Use for: -/// * Removing a reply from [StreamMessageInput]. -/// * Closing [FullScreenMediaDesktop]. +/// * Removing a reply from [StreamMessageComposer]. +/// * Closing [StreamMediaGalleryPreview]. final escapeKeySet = LogicalKeySet( LogicalKeyboardKey.escape, ); /// The "right arrow" keyset. /// -/// Use for navigating to the next [FullScreenMediaDesktop] item. +/// Use for navigating to the next [StreamMediaGalleryPreview] page. final rightArrowKeySet = LogicalKeySet( LogicalKeyboardKey.arrowRight, ); /// The "left arrow" keyset. /// -/// Use for navigating to the previous [FullScreenMediaDesktop] item. +/// Use for navigating to the previous [StreamMediaGalleryPreview] page. final leftArrowKeySet = LogicalKeySet( LogicalKeyboardKey.arrowLeft, ); diff --git a/packages/stream_chat_flutter/lib/src/localization/stream_chat_localizations.dart b/packages/stream_chat_flutter/lib/src/localization/stream_chat_localizations.dart index 66c09e8847..bc7da0a593 100644 --- a/packages/stream_chat_flutter/lib/src/localization/stream_chat_localizations.dart +++ b/packages/stream_chat_flutter/lib/src/localization/stream_chat_localizations.dart @@ -1,6 +1,5 @@ import 'package:flutter/widgets.dart'; -import 'package:stream_chat_flutter/src/localization/translations.dart' - show Translations; +import 'package:stream_chat_flutter/src/localization/translations.dart' show Translations; /// Defines the localized resource values used by the StreamChatFlutter widgets. /// diff --git a/packages/stream_chat_flutter/lib/src/localization/translations.dart b/packages/stream_chat_flutter/lib/src/localization/translations.dart index ce5989e7df..34ad68c91b 100644 --- a/packages/stream_chat_flutter/lib/src/localization/translations.dart +++ b/packages/stream_chat_flutter/lib/src/localization/translations.dart @@ -1,3 +1,5 @@ +// ignore_for_file: lines_longer_than_80_chars + import 'package:jiffy/jiffy.dart'; import 'package:stream_chat_flutter/src/message_list_view/message_list_view.dart'; import 'package:stream_chat_flutter/src/misc/connection_status_builder.dart'; @@ -40,7 +42,7 @@ abstract class Translations { /// The text for showing the attachments upload progress String attachmentsUploadProgressText({ - required int remaining, + required int completed, required int total, }); @@ -98,7 +100,7 @@ abstract class Translations { String get reconnectingLabel; /// The label for also send - /// as direct message "checkbox"" in [StreamMessageInput] + /// as direct message "checkbox" in [StreamMessageComposer] String get alsoSendAsDirectMessageLabel; /// The label for search Gif @@ -108,24 +110,31 @@ abstract class Translations { String get sendMessagePermissionError; /// The label for add a comment or send in case of - /// attachments inside [StreamMessageInput] + /// attachments inside [StreamMessageComposer] String get addACommentOrSendLabel; - /// The label for write a message in [StreamMessageInput] + /// The label for write a message in [StreamMessageComposer] String get writeAMessageLabel; - /// The label for slow mode enabled in [StreamMessageInput] + /// The label for slow mode enabled in [StreamMessageComposer] String get slowModeOnLabel; - /// The label for instant commands in [StreamMessageInput] + /// The placeholder shown in the composer when a user-target command (for + /// example `/mute`, `/unmute`, `/ban`, `/unban`) is active. + /// + /// Renders literally, for example as `@username`, to hint that the user + /// should select or type a username. + String get commandUsernameLabel; + + /// The label for instant commands in [StreamMessageComposer] String get instantCommandsLabel; /// The error shown in case the file is too large even after compression - /// while uploading via [StreamMessageInput] + /// while uploading via [StreamMessageComposer] String fileTooLargeAfterCompressionError(double limitInMB); /// The error shown in case the file is too large - /// while uploading via [StreamMessageInput] + /// while uploading via [StreamMessageComposer] String fileTooLargeError(double limitInMB); /// The error shown when the file being read has no bytes @@ -238,6 +247,9 @@ abstract class Translations { /// The label for "Photos" String get photosLabel; + /// The label for "Photos & Videos" + String get photosAndVideosLabel; + /// The text for showing on which [date] and [time] the message was sent String sentAtText({required DateTime date, required DateTime time}); @@ -247,6 +259,9 @@ abstract class Translations { /// The label for "Yesterday" String get yesterdayLabel; + /// The label for "Just now", shown for timestamps within the last minute. + String get justNowLabel; + /// The text for showing the channel is muted String get channelIsMutedText; @@ -289,6 +304,18 @@ abstract class Translations { /// The text for showing the watchers count based on [count] String watchersCountText(int count); + /// The text for showing the combined members and online-watchers count in + /// the channel header subtitle of a group channel. + /// + /// When [onlineCount] is `0`, the returned string is equivalent to + /// [membersCountText]. Otherwise, the result bakes in the separator and + /// word order chosen by the current locale — e.g. `'42 Members, 5 Online'` + /// in English or `'42人、5人がオンライン'` in Japanese. + String membersCountWithOnlineText({ + required int memberCount, + required int onlineCount, + }); + /// The label for "View Info" String get viewInfoLabel; @@ -346,6 +373,10 @@ abstract class Translations { /// The label for "Reply to message" String get replyToMessageLabel; + /// The label for the composer reply header when quoting another user's + /// message (e.g. "Reply to Alice"). + String replyToUserLabel(String userName); + /// The label for "View library" String get viewLibrary; @@ -378,8 +409,10 @@ abstract class Translations { /// If [isNew] is true, it returns "Create a new poll". String createPollLabel({bool isNew = false}); - /// The label for "Questions". - String get questionsLabel; + /// The label for "Question". + /// + /// If [isPlural] is true, it returns "Questions". + String questionLabel({bool isPlural = false}); /// The label for "Ask a question". String get askAQuestionLabel; @@ -408,9 +441,18 @@ abstract class Translations { /// The label for "Multiple answers". String get multipleAnswersLabel; + /// The description shown under the "Multiple answers" toggle in the poll + /// creator (e.g. "Select more than one option"). + String get multipleAnswersDescription; + /// The label for "Maximum votes per person". String get maximumVotesPerPersonLabel; + /// The description shown under the "Maximum votes per person" stepper in + /// the poll creator, describing the allowed vote range (e.g. "Choose + /// between 2–10 options"). + String maximumVotesPerPersonDescription([Range? range]); + /// The error shown when the max [votes] is not within the [range]. /// /// Returns 'Vote count must be at least ${range.min}' if the vote count is @@ -421,18 +463,30 @@ abstract class Translations { /// The label for "Anonymous poll". String get anonymousPollLabel; + /// The description shown under the "Anonymous poll" toggle in the poll + /// creator (e.g. "Hide who voted"). + String get anonymousPollDescription; + /// The label for "Poll Options". String get pollOptionsLabel; /// The label for "Suggest an option". String get suggestAnOptionLabel; + /// The description shown under the "Suggest an option" toggle in the poll + /// creator (e.g. "Let others add options"). + String get suggestAnOptionDescription; + /// The label for "Enter a new option". String get enterANewOptionLabel; /// The label for "Add a comment". String get addACommentLabel; + /// The description shown under the "Add a comment" toggle in the poll + /// creator (e.g. "Allow others to add comments"). + String get addACommentDescription; + /// The label for "Poll comments". String get pollCommentsLabel; @@ -442,8 +496,11 @@ abstract class Translations { /// The label for "Enter your comment". String get enterYourCommentLabel; - /// The confirmation text shown when the user tries to end a poll. - String get endVoteConfirmationText; + /// The confirmation title shown when the user tries to end a poll. + String get endVoteConfirmationTitle; + + /// The confirmation body message shown when the user tries to end a poll. + String get endVoteConfirmationMessage; /// The label for "delete poll option" String get deletePollOptionLabel; @@ -476,20 +533,32 @@ abstract class Translations { /// The label for "View Results". String get viewResultsLabel; - /// The label for "End Vote". + /// The label for "End Poll". String get endVoteLabel; /// The label for "Poll Results". String get pollResultsLabel; + /// The label for the poll votes screen app bar title (shown when viewing + /// all votes for a specific poll option). + String get pollVotesLabel; + /// The label for "$count votes". String voteCountLabel({int? count}); + /// The label for the total vote count footer in the poll results dialog, + /// e.g. "$count votes total". + String totalVoteCountLabel({int? count}); + /// The label for "Show all votes". /// /// If [count] is provided, it returns "Show all $count votes". String showAllVotesLabel({int? count}); + /// The label for a generic "View all" call-to-action, e.g. the footer + /// action of a truncated list. + String get viewAllLabel; + /// The label for "There are no poll votes currently". String get noPollVotesLabel; @@ -502,6 +571,9 @@ abstract class Translations { /// The label for "$count new threads" String newThreadsLabel({required int count}); + /// The label for "Loading..." + String get loadingLabel; + /// The label for "Slide to cancel" String get slideToCancelLabel; @@ -535,6 +607,21 @@ abstract class Translations { /// The text for video attachment in channel list preview String get videoAttachmentText; + /// The text for file attachment in channel list preview + String get fileAttachmentText; + + /// The text for link attachment in channel list preview + String get linkAttachmentText; + + /// The text for multiple files attachment in channel list preview + String filesAttachmentCountText(int count); + + /// The text for multiple photos attachment in channel list preview + String photosAttachmentCountText(int count); + + /// The text for multiple videos attachment in channel list preview + String videosAttachmentCountText(int count); + /// The text for poll when current user voted String get pollYouVotedText; @@ -549,6 +636,78 @@ abstract class Translations { /// The label for draft message String get draftLabel; + + /// The label for location attachment. + /// + /// [isLive] indicates if the location is live or not. + String locationLabel({bool isLive = false}); + + /// The text shown when there are no conversations yet. + String get noConversationsYetText; + + /// The text shown when there are no threads yet. + String get replyToStartThreadText; + + /// The text shown to prompt the user to send a message. + String get sendMessageToStartConversationText; + + /// The label for the "Saved for later" message annotation. + String get savedForLaterLabel; + + /// The annotation label shown on a message that was replied to a thread, + /// displayed in channel view (e.g. "Replied to a thread"). + String get repliedToThreadAnnotationLabel; + + /// The annotation label shown on a message that was also sent in channel, + /// displayed in thread view (e.g. "Also sent in channel"). + String get alsoSentInChannelAnnotationLabel; + + /// The "View" link label used in message annotations. + String get viewLabel; + + /// The annotation label for a reminder (e.g. "Reminder set"). + String get reminderSetLabel; + + /// The text displaying the reminder time (e.g. "Today at 3:00 PM"). + String reminderAtText(String time); + + /// The label for "Create a poll and let everyone vote!" + String get createPollPromptLabel; + + /// The label for "Take a photo and share" + String get takePhotoAndShareLabel; + + /// The label for "Take a video and share" + String get takeVideoAndShareLabel; + + /// The label for "Open camera" + String get openCameraLabel; + + /// The label for "Select files to share" + String get selectFilesToShareLabel; + + /// The label for "Open files" + String get openFilesLabel; + + /// The label for unsupported attachment types + String get unsupportedAttachmentLabel; + + /// The label for "CONFIRM" (e.g. [StreamMessageActionConfirmationModal]). + String get confirmLabel; + + /// The text shown when there are no reactions on a message. + String get emptyReactionsText; + + /// The error shown when the reactions list fails to load. + String get loadingReactionsError; + + /// The label hint shown next to the viewer's own reaction indicating the + /// reaction can be tapped to remove it. + String get tapToRemoveReactionLabel; + + /// The header text for the reaction detail sheet showing the count of + /// visible reactions (e.g. "1 Reaction" / "5 Reactions"). + String reactionsCountText(int count); } /// Default implementation of Translation strings for the stream chat widgets @@ -590,20 +749,19 @@ class DefaultTranslations implements Translations { } @override - String get threadReplyLabel => 'Thread Reply'; + String get threadReplyLabel => 'Thread'; @override String get onlyVisibleToYouText => 'Only visible to you'; @override - String threadReplyCountText(int count) => '$count Thread Replies'; + String threadReplyCountText(int count) => count == 1 ? '1 reply' : '$count replies'; @override String attachmentsUploadProgressText({ - required int remaining, + required int completed, required int total, - }) => - 'Uploading $remaining/$total ...'; + }) => 'Uploaded $completed of $total ...'; @override String pinnedByUserText({ @@ -616,11 +774,10 @@ class DefaultTranslations implements Translations { } @override - String get sendMessagePermissionError => - "You don't have permission to send messages"; + String get sendMessagePermissionError => "You don't have permission to send messages"; @override - String get emptyMessagesText => 'There are no messages currently'; + String get emptyMessagesText => 'No messages yet'; @override String get genericErrorText => 'Something went wrong'; @@ -651,8 +808,8 @@ class DefaultTranslations implements Translations { @override String threadSeparatorText(int replyCount) { - if (replyCount == 1) return '1 Reply'; - return '$replyCount Replies'; + if (replyCount == 1) return '1 reply'; + return '$replyCount replies'; } @override @@ -665,7 +822,7 @@ class DefaultTranslations implements Translations { String get reconnectingLabel => 'Reconnecting...'; @override - String get alsoSendAsDirectMessageLabel => 'Also send as direct message'; + String get alsoSendAsDirectMessageLabel => 'Also send in Channel'; @override String get addACommentOrSendLabel => 'Add a comment or send'; @@ -674,7 +831,7 @@ class DefaultTranslations implements Translations { String get searchGifLabel => 'Search GIFs'; @override - String get writeAMessageLabel => 'Write a message'; + String get writeAMessageLabel => 'Send a message'; @override String get instantCommandsLabel => 'Instant Commands'; @@ -690,8 +847,7 @@ class DefaultTranslations implements Translations { 'The file is too large to upload. The file size limit is $limitInMB MB.'; @override - String get couldNotReadBytesFromFileError => - 'Could not read bytes from file.'; + String get couldNotReadBytesFromFileError => 'Could not read bytes from file.'; @override String get addAFileLabel => 'Add a file'; @@ -718,7 +874,7 @@ class DefaultTranslations implements Translations { String get somethingWentWrongError => 'Something went wrong'; @override - String get addMoreFilesLabel => 'Add more files'; + String get addMoreFilesLabel => 'Add more'; @override String get enablePhotoAndVideoAccessMessage => @@ -733,35 +889,31 @@ class DefaultTranslations implements Translations { @override String get flagMessageQuestion => - 'Do you want to send a copy of this message to a' - '\nmoderator for further investigation?'; + 'Do you want to send a copy of this message to a moderator for further investigation?'; @override - String get flagLabel => 'FLAG'; + String get flagLabel => 'Flag'; @override - String get cancelLabel => 'CANCEL'; + String get cancelLabel => 'Cancel'; @override String get flagMessageSuccessfulLabel => 'Message flagged'; @override - String get flagMessageSuccessfulText => - 'The message has been reported to a moderator.'; + String get flagMessageSuccessfulText => 'The message has been reported to a moderator.'; @override - String get deleteLabel => 'DELETE'; + String get deleteLabel => 'Delete'; @override String get deleteMessageLabel => 'Delete Message'; @override - String get deleteMessageQuestion => - 'Are you sure you want to permanently delete this\nmessage?'; + String get deleteMessageQuestion => 'Are you sure you want to permanently delete this message?'; @override - String get operationCouldNotBeCompletedText => - "The operation couldn't be completed."; + String get operationCouldNotBeCompletedText => "The operation couldn't be completed."; @override String get replyLabel => 'Reply'; @@ -801,6 +953,9 @@ class DefaultTranslations implements Translations { @override String get photosLabel => 'Photos'; + @override + String get photosAndVideosLabel => 'Photos & Videos'; + String _getDay(DateTime dateTime) { final now = DateTime.now(); final today = DateTime(now.year, now.month, now.day); @@ -829,6 +984,9 @@ class DefaultTranslations implements Translations { @override String get yesterdayLabel => 'Yesterday'; + @override + String get justNowLabel => 'Just now'; + @override String get channelIsMutedText => 'Channel is muted'; @@ -839,8 +997,7 @@ class DefaultTranslations implements Translations { String get letsStartChattingLabel => 'Let’s start chatting!'; @override - String get sendingFirstMessageLabel => - 'How about sending your first message to a friend?'; + String get sendingFirstMessageLabel => 'How about sending your first message to a friend?'; @override String get startAChatLabel => 'Start a chat'; @@ -852,8 +1009,7 @@ class DefaultTranslations implements Translations { String get deleteConversationLabel => 'Delete Conversation'; @override - String get deleteConversationQuestion => - 'Are you sure you want to delete this conversation?'; + String get deleteConversationQuestion => 'Are you sure you want to delete this conversation?'; @override String get streamChatLabel => 'Stream Chat'; @@ -879,6 +1035,16 @@ class DefaultTranslations implements Translations { return '$count Online'; } + @override + String membersCountWithOnlineText({ + required int memberCount, + required int onlineCount, + }) { + final members = membersCountText(memberCount); + if (onlineCount <= 0) return members; + return '$members, ${watchersCountText(onlineCount)}'; + } + @override String get viewInfoLabel => 'View Info'; @@ -892,8 +1058,7 @@ class DefaultTranslations implements Translations { String get leaveConversationLabel => 'Leave conversation'; @override - String get leaveConversationQuestion => - 'Are you sure you want to leave this conversation?'; + String get leaveConversationQuestion => 'Are you sure you want to leave this conversation?'; @override String get showInChatLabel => 'Show in Chat'; @@ -929,8 +1094,7 @@ class DefaultTranslations implements Translations { String galleryPaginationText({ required int currentPage, required int totalPages, - }) => - '${currentPage + 1} of $totalPages'; + }) => '${currentPage + 1} of $totalPages'; @override String get fileText => 'File'; @@ -938,9 +1102,15 @@ class DefaultTranslations implements Translations { @override String get replyToMessageLabel => 'Reply to Message'; + @override + String replyToUserLabel(String userName) => 'Reply to $userName'; + @override String get slowModeOnLabel => 'Slow mode ON'; + @override + String get commandUsernameLabel => '@username'; + @override String get viewLibrary => 'View library'; @@ -997,8 +1167,7 @@ Attachment limit exceeded: it's not possible to add more than $limit attachments } @override - String get linkDisabledDetails => - 'Sending links is not allowed in this conversation.'; + String get linkDisabledDetails => 'Sending links is not allowed in this conversation.'; @override String get linkDisabledError => 'Links are disabled'; @@ -1007,7 +1176,8 @@ Attachment limit exceeded: it's not possible to add more than $limit attachments String unreadMessagesSeparatorText() => 'New messages'; @override - String get enableFileAccessMessage => 'Please enable access to files' + String get enableFileAccessMessage => + 'Please enable access to files' '\nso you can share them with friends.'; @override @@ -1025,7 +1195,10 @@ Attachment limit exceeded: it's not possible to add more than $limit attachments } @override - String get questionsLabel => 'Questions'; + String questionLabel({bool isPlural = false}) { + if (isPlural) return 'Questions'; + return 'Question'; + } @override String get askAQuestionLabel => 'Ask a question'; @@ -1065,9 +1238,18 @@ Attachment limit exceeded: it's not possible to add more than $limit attachments @override String get multipleAnswersLabel => 'Multiple answers'; + @override + String get multipleAnswersDescription => 'Select more than one option'; + @override String get maximumVotesPerPersonLabel => 'Maximum votes per person'; + @override + String maximumVotesPerPersonDescription([Range? range]) { + final (:min, :max) = range ?? (min: 2, max: 10); + return 'Choose between $min\u2013$max options'; + } + @override String? maxVotesPerPersonValidationError(int votes, Range range) { final (:min, :max) = range; @@ -1086,18 +1268,27 @@ Attachment limit exceeded: it's not possible to add more than $limit attachments @override String get anonymousPollLabel => 'Anonymous poll'; + @override + String get anonymousPollDescription => 'Hide who voted'; + @override String get pollOptionsLabel => 'Poll Options'; @override String get suggestAnOptionLabel => 'Suggest an option'; + @override + String get suggestAnOptionDescription => 'Let others add options'; + @override String get enterANewOptionLabel => 'Enter a new option'; @override String get addACommentLabel => 'Add a comment'; + @override + String get addACommentDescription => 'Allow others to add comments'; + @override String get pollCommentsLabel => 'Poll Comments'; @@ -1108,15 +1299,17 @@ Attachment limit exceeded: it's not possible to add more than $limit attachments String get enterYourCommentLabel => 'Enter your comment'; @override - String get endVoteConfirmationText => - 'Are you sure you want to end the vote?'; + String get endVoteConfirmationTitle => 'End This Poll?'; + + @override + String get endVoteConfirmationMessage => + 'Do you want to end this poll now? Nobody will be able to vote in this poll anymore.'; @override String get deletePollOptionLabel => 'Delete Option'; @override - String get deletePollOptionQuestion => - 'Are you sure you want to delete this option?'; + String get deletePollOptionQuestion => 'Are you sure you want to delete this option?'; @override String get createLabel => 'Create'; @@ -1147,23 +1340,36 @@ Attachment limit exceeded: it's not possible to add more than $limit attachments String get viewResultsLabel => 'View Results'; @override - String get endVoteLabel => 'End Vote'; + String get endVoteLabel => 'End Poll'; @override String get pollResultsLabel => 'Poll Results'; + @override + String get pollVotesLabel => 'Votes'; + @override String showAllVotesLabel({int? count}) { if (count == null) return 'Show all votes'; return 'Show all $count votes'; } + @override + String get viewAllLabel => 'View all'; + @override String voteCountLabel({int? count}) => switch (count) { - null || < 1 => '0 votes', - 1 => '1 vote', - _ => '$count votes', - }; + null || < 1 => '0 votes', + 1 => '1 vote', + _ => '$count votes', + }; + + @override + String totalVoteCountLabel({int? count}) => switch (count) { + null || < 1 => '0 votes total', + 1 => '1 vote total', + _ => '$count votes total', + }; @override String get noPollVotesLabel => 'There are no poll votes currently'; @@ -1180,18 +1386,20 @@ Attachment limit exceeded: it's not possible to add more than $limit attachments return '$count new threads'; } + @override + String get loadingLabel => 'Loading...'; + @override String get slideToCancelLabel => 'Slide to cancel'; @override - String get holdToRecordLabel => 'Hold to record, release to send.'; + String get holdToRecordLabel => 'Hold to record. Release to save.'; @override String get sendAnywayLabel => 'Send Anyway'; @override - String get moderatedMessageBlockedText => - 'Message was blocked by moderation policies'; + String get moderatedMessageBlockedText => 'Message was blocked by moderation policies'; @override String get moderationReviewModalTitle => 'Are you sure?'; @@ -1215,6 +1423,21 @@ Attachment limit exceeded: it's not possible to add more than $limit attachments @override String get videoAttachmentText => 'Video'; + @override + String get fileAttachmentText => 'File'; + + @override + String get linkAttachmentText => 'Link'; + + @override + String filesAttachmentCountText(int count) => count == 1 ? 'File' : '$count files'; + + @override + String photosAttachmentCountText(int count) => count == 1 ? 'Photo' : '$count photos'; + + @override + String videosAttachmentCountText(int count) => count == 1 ? 'Video' : '$count videos'; + @override String get pollYouVotedText => 'You voted'; @@ -1229,4 +1452,73 @@ Attachment limit exceeded: it's not possible to add more than $limit attachments @override String get draftLabel => 'Draft'; + + @override + String locationLabel({bool isLive = false}) { + if (isLive) return 'Live Location'; + return 'Location'; + } + + @override + String get noConversationsYetText => 'No conversations yet'; + + @override + String get replyToStartThreadText => 'Reply to a message to start a thread'; + + @override + String get sendMessageToStartConversationText => 'Send a message to start the conversation'; + + @override + String get savedForLaterLabel => 'Saved for later'; + + @override + String get repliedToThreadAnnotationLabel => 'Replied to a thread'; + + @override + String get alsoSentInChannelAnnotationLabel => 'Also sent in channel'; + + @override + String get viewLabel => 'View'; + + @override + String get reminderSetLabel => 'Reminder set'; + + @override + String reminderAtText(String time) => 'Today at $time'; + + @override + String get createPollPromptLabel => 'Create a poll and let everyone vote!'; + + @override + String get takePhotoAndShareLabel => 'Take a photo and share'; + + @override + String get takeVideoAndShareLabel => 'Take a video and share'; + + @override + String get openCameraLabel => 'Open camera'; + + @override + String get selectFilesToShareLabel => 'Select files to share'; + + @override + String get openFilesLabel => 'Open files'; + + @override + String get unsupportedAttachmentLabel => 'Unsupported Attachment'; + + @override + String get confirmLabel => 'CONFIRM'; + + @override + String get emptyReactionsText => 'No reactions yet'; + + @override + String get loadingReactionsError => 'Error loading reactions'; + + @override + String get tapToRemoveReactionLabel => 'Tap to remove'; + + @override + String reactionsCountText(int count) => count == 1 ? '1 Reaction' : '$count Reactions'; } diff --git a/packages/stream_chat_flutter/lib/src/media_gallery/stream_media_gallery.dart b/packages/stream_chat_flutter/lib/src/media_gallery/stream_media_gallery.dart new file mode 100644 index 0000000000..7dac486102 --- /dev/null +++ b/packages/stream_chat_flutter/lib/src/media_gallery/stream_media_gallery.dart @@ -0,0 +1,151 @@ +import 'package:flutter/material.dart'; +import 'package:stream_chat_flutter/stream_chat_flutter.dart'; + +/// A scrollable grid of [StreamMediaGalleryAttachment]s — the thumbnail +/// companion to [StreamMediaGalleryPreview]. +/// +/// Each cell is rendered by a [StreamMediaGalleryItem] in a 1:1 grid with +/// the sender's avatar surfaced on every tile. Inter-cell gutters and the +/// outer padding both default to `spacing.xxxs` (2 logical pixels) so +/// every gap in the grid is uniform; pass [StreamMediaGalleryProps.padding] +/// to override. +/// +/// {@tool snippet} +/// +/// Open the full-screen viewer when a tile is tapped: +/// +/// ```dart +/// StreamMediaGallery( +/// attachments: attachments, +/// onItemTap: (index) => Navigator.push( +/// context, +/// MaterialPageRoute( +/// builder: (_) => StreamMediaGalleryPreview( +/// attachments: attachments, +/// initialIndex: index, +/// ), +/// ), +/// ), +/// ) +/// ``` +/// {@end-tool} +/// +/// See also: +/// +/// * [StreamMediaGalleryItem], the cell widget. +/// * [StreamMediaGalleryPreview], the full-screen swipeable viewer. +/// * [DefaultStreamMediaGallery], the default implementation. +class StreamMediaGallery extends StatelessWidget { + /// Creates a [StreamMediaGallery]. + StreamMediaGallery({ + super.key, + required List attachments, + int crossAxisCount = 3, + EdgeInsetsGeometry? padding, + ScrollController? scrollController, + ValueChanged? onItemTap, + ValueChanged? onItemLongPress, + }) : props = .new( + attachments: attachments, + crossAxisCount: crossAxisCount, + padding: padding, + scrollController: scrollController, + onItemTap: onItemTap, + onItemLongPress: onItemLongPress, + ); + + /// The properties that configure this gallery. + final StreamMediaGalleryProps props; + + @override + Widget build(BuildContext context) { + final builder = context.chatComponentBuilder(); + if (builder != null) return builder(context, props); + return DefaultStreamMediaGallery(props: props); + } +} + +/// Properties for configuring a [StreamMediaGallery]. +/// +/// This class holds all configuration options for the gallery, allowing +/// them to be passed through the [StreamComponentFactory]. +/// +/// See also: +/// +/// * [StreamMediaGallery], which uses these properties. +/// * [DefaultStreamMediaGallery], the default implementation. +@immutable +class StreamMediaGalleryProps { + /// Creates properties for a media gallery. + const StreamMediaGalleryProps({ + required this.attachments, + this.crossAxisCount = 3, + this.padding, + this.scrollController, + this.onItemTap, + this.onItemLongPress, + }); + + /// The attachments to display, in render order. + final List attachments; + + /// Number of tiles per row. Defaults to 3. + final int crossAxisCount; + + /// Padding around the grid. + final EdgeInsetsGeometry? padding; + + /// Scroll controller for the underlying [GridView]. + final ScrollController? scrollController; + + /// Called when the user taps the tile at the given index. + final ValueChanged? onItemTap; + + /// Called when the user long-presses the tile at the given index. + final ValueChanged? onItemLongPress; +} + +/// The default implementation of [StreamMediaGallery]. +/// +/// See also: +/// +/// * [StreamMediaGallery], the public API widget. +/// * [StreamMediaGalleryProps], which configures this widget. +class DefaultStreamMediaGallery extends StatelessWidget { + /// Creates a default media gallery with the given [props]. + const DefaultStreamMediaGallery({super.key, required this.props}); + + /// The properties that configure this gallery. + final StreamMediaGalleryProps props; + + @override + Widget build(BuildContext context) { + final spacing = context.streamSpacing; + final effectivePadding = props.padding ?? EdgeInsets.all(spacing.xxxs); + + return MediaQuery.removePadding( + context: context, + removeTop: true, + removeBottom: true, + child: GridView.builder( + padding: effectivePadding, + controller: props.scrollController, + itemCount: props.attachments.length, + gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: props.crossAxisCount, + crossAxisSpacing: spacing.xxxs, + mainAxisSpacing: spacing.xxxs, + ), + itemBuilder: (context, index) { + final ga = props.attachments[index]; + return StreamMediaGalleryItem( + attachment: ga.attachment, + author: ga.message.user, + onTap: props.onItemTap == null ? null : () => props.onItemTap!(index), + onLongPress: props.onItemLongPress == null ? null : () => props.onItemLongPress!(index), + ); + }, + ), + ); + } +} diff --git a/packages/stream_chat_flutter/lib/src/media_gallery/stream_media_gallery_attachment.dart b/packages/stream_chat_flutter/lib/src/media_gallery/stream_media_gallery_attachment.dart new file mode 100644 index 0000000000..2c1af88c77 --- /dev/null +++ b/packages/stream_chat_flutter/lib/src/media_gallery/stream_media_gallery_attachment.dart @@ -0,0 +1,71 @@ +import 'package:stream_chat_flutter_core/stream_chat_flutter_core.dart'; + +/// A media attachment paired with its parent [Message] for use in a media +/// gallery. +/// +/// [StreamMediaGalleryAttachment] is the input format consumed by +/// [StreamMediaGallery] (the thumbnail grid) and [StreamMediaGalleryPreview] +/// (the full-screen swipeable viewer). The bundled [message] lets each +/// surface render the sender / timestamp metadata alongside the media +/// without a separate lookup. +/// +/// {@tool snippet} +/// +/// Build a list from a message's media attachments: +/// +/// ```dart +/// final attachments = [ +/// for (final a in message.attachments.where((it) => +/// it.type == AttachmentType.image || +/// it.type == AttachmentType.video || +/// it.type == AttachmentType.giphy)) +/// StreamMediaGalleryAttachment(attachment: a, message: message), +/// ]; +/// ``` +/// {@end-tool} +/// +/// See also: +/// +/// * [StreamMediaGallery], the thumbnail grid that consumes these. +/// * [StreamMediaGalleryPreview], the full-screen swipeable viewer. +class StreamMediaGalleryAttachment { + /// Creates a [StreamMediaGalleryAttachment]. + const StreamMediaGalleryAttachment({ + required this.attachment, + required this.message, + }); + + /// The media attachment being shown. + final Attachment attachment; + + /// The [Message] [attachment] was sent on. + /// + /// Used by gallery surfaces to surface sender / timestamp metadata. + final Message message; +} + +/// Convenience helpers for producing [StreamMediaGalleryAttachment]s from a +/// [Message]. +extension MessageMediaGalleryX on Message { + /// Returns one [StreamMediaGalleryAttachment] per [Message.attachments] + /// entry on this message, each paired with the message itself. + /// + /// Pass [filter] to narrow the result — typically to keep only media + /// attachment types (image / video / giphy). + /// + /// {@tool snippet} + /// + /// ```dart + /// final mediaOnly = message.toMediaGalleryAttachments( + /// filter: (a) => + /// a.type == AttachmentType.image || + /// a.type == AttachmentType.video || + /// a.type == AttachmentType.giphy, + /// ); + /// ``` + /// {@end-tool} + List toMediaGalleryAttachments({bool Function(Attachment)? filter}) { + final source = filter == null ? attachments : attachments.where(filter); + return [for (final a in source) StreamMediaGalleryAttachment(attachment: a, message: this)]; + } +} diff --git a/packages/stream_chat_flutter/lib/src/media_gallery/stream_media_gallery_item.dart b/packages/stream_chat_flutter/lib/src/media_gallery/stream_media_gallery_item.dart new file mode 100644 index 0000000000..97488c7d80 --- /dev/null +++ b/packages/stream_chat_flutter/lib/src/media_gallery/stream_media_gallery_item.dart @@ -0,0 +1,133 @@ +import 'package:flutter/material.dart'; +import 'package:stream_chat_flutter/stream_chat_flutter.dart'; +import 'package:stream_core_flutter/stream_core_flutter.dart'; + +/// A single tile inside a [StreamMediaGallery]. +/// +/// Renders [attachment]'s media filling a 1:1 square cell, clipped to a +/// `radius.xxs` corner, with optional overlays: +/// +/// - An [author] avatar pinned to the top-leading corner, drawn with a +/// white outside-aligned border so it reads against any thumbnail. +/// - A "video" [StreamMediaBadge] pinned to the bottom-leading corner when +/// [attachment] is a video; the badge surfaces the video's duration in +/// `M:SS` format when present on `extraData['duration']`. +/// +/// The tile is tappable when [onTap] / [onLongPress] is set; the ripple is +/// drawn over the media via a transparent [Material]. +/// +/// {@tool snippet} +/// +/// Basic usage inside a [StreamMediaGallery]'s item builder: +/// +/// ```dart +/// StreamMediaGalleryItem( +/// attachment: attachment, +/// author: message.user, +/// onTap: () => openPreview(index), +/// ) +/// ``` +/// {@end-tool} +/// +/// See also: +/// +/// * [StreamMediaGallery], the grid that lays out these tiles. +/// * [StreamMediaGalleryPreview], the full-screen viewer opened from a tap. +class StreamMediaGalleryItem extends StatelessWidget { + /// Creates a [StreamMediaGalleryItem]. + const StreamMediaGalleryItem({ + super.key, + required this.attachment, + this.author, + this.onTap, + this.onLongPress, + }); + + /// The media attachment rendered by this tile. + final Attachment attachment; + + /// Optional user to surface as a small avatar in the top-leading corner. + /// + /// When null, no avatar is drawn. + final User? author; + + /// Called when the user taps this tile. + final GestureTapCallback? onTap; + + /// Called when the user long-presses this tile. + final GestureLongPressCallback? onLongPress; + + @override + Widget build(BuildContext context) { + final radius = context.streamRadius; + final spacing = context.streamSpacing; + + final colorScheme = context.streamColorScheme; + + final isVideo = attachment.type == AttachmentType.video; + + Duration? videoDuration; + if (isVideo) { + final secs = attachment.extraData['duration'] as num?; + if (secs != null) videoDuration = Duration(seconds: secs.round()); + } + + return Material( + clipBehavior: .hardEdge, + shape: RoundedSuperellipseBorder( + borderRadius: BorderRadius.all(radius.xxs), + ), + child: AspectRatio( + aspectRatio: 1, + child: Stack( + children: [ + Positioned.fill( + child: StreamMediaAttachmentThumbnail( + media: attachment, + fit: BoxFit.cover, + ), + ), + if (author case final author?) + PositionedDirectional( + top: spacing.xs, + start: spacing.xs, + child: StreamAvatarTheme( + data: StreamAvatarThemeData( + border: BoxBorder.all( + width: 2, + color: colorScheme.borderOnInverse, + strokeAlign: BorderSide.strokeAlignOutside, + ), + ), + child: StreamUserAvatar( + user: author, + size: StreamAvatarSize.sm, + showOnlineIndicator: false, + ), + ), + ), + if (isVideo) + PositionedDirectional( + start: spacing.xs, + bottom: spacing.xs, + child: StreamMediaBadge( + type: .video, + duration: videoDuration, + durationFormat: .exact, + ), + ), + Positioned.fill( + child: Material( + color: Colors.transparent, + child: InkWell( + onTap: onTap, + onLongPress: onLongPress, + ), + ), + ), + ], + ), + ), + ); + } +} diff --git a/packages/stream_chat_flutter/lib/src/media_gallery_preview/stream_media_gallery_preview.dart b/packages/stream_chat_flutter/lib/src/media_gallery_preview/stream_media_gallery_preview.dart new file mode 100644 index 0000000000..5fe5479bc2 --- /dev/null +++ b/packages/stream_chat_flutter/lib/src/media_gallery_preview/stream_media_gallery_preview.dart @@ -0,0 +1,353 @@ +import 'package:dio/dio.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:share_plus/share_plus.dart'; +import 'package:stream_chat_flutter/stream_chat_flutter.dart'; +import 'package:stream_core_flutter/stream_core_flutter.dart' as core; + +/// A swipeable, full-screen viewer for a list of chat media attachments. +/// +/// Wraps a [PageView] in a [core.StreamMediaViewer] whose chrome — +/// [StreamMediaGalleryPreviewHeader] on top, [StreamMediaGalleryPreviewFooter] +/// on the bottom — is composed internally from the active package. +/// +/// Behaviour built in: +/// +/// - Tapping the media area toggles the chrome (it slides off the top / +/// bottom edges while the background fades to immersive black). +/// - Keyboard shortcuts: ← / → advance pages, esc pops the enclosing route. +/// - The footer's gallery-grid button opens [StreamMediaGallery] in a +/// [showStreamSheet] bottom sheet; tapping a tile seeks the page view. +/// - The footer's share button downloads the active attachment's bytes and +/// hands them to the system share sheet. +/// - Video attachments are played by [StreamVideoPlayer], which pauses +/// itself when its page is no longer the active one (see +/// [StreamMediaGalleryPreviewScope]). +/// +/// {@tool snippet} +/// +/// Open the viewer at a specific attachment: +/// +/// ```dart +/// Navigator.of(context).push( +/// MaterialPageRoute( +/// builder: (_) => StreamChannel( +/// channel: channel, +/// child: StreamMediaGalleryPreview( +/// attachments: attachments, +/// initialIndex: 3, +/// ), +/// ), +/// ), +/// ); +/// ``` +/// {@end-tool} +/// +/// {@tool snippet} +/// +/// Substitute a custom preview implementation via the component factory: +/// +/// ```dart +/// StreamComponentFactory( +/// builders: StreamComponentBuilders( +/// extensions: streamChatComponentBuilders( +/// mediaGalleryPreview: (context, props) => MyCustomPreview(props: props), +/// ), +/// ), +/// child: ..., +/// ) +/// ``` +/// {@end-tool} +/// +/// See also: +/// +/// * [StreamMediaGalleryPreviewProps], which configures this widget. +/// * [DefaultStreamMediaGalleryPreview], the default implementation. +/// * [StreamMediaGalleryPreviewScope], which exposes the active page to +/// descendants (videos pause based on this). +/// * [StreamMediaGallery], the thumbnail companion shown in the footer's +/// bottom sheet. +class StreamMediaGalleryPreview extends StatelessWidget { + /// Creates a [StreamMediaGalleryPreview]. + StreamMediaGalleryPreview({ + super.key, + required List attachments, + int initialIndex = 0, + bool autoplayVideos = false, + }) : assert(initialIndex >= 0, 'initialIndex cannot be negative'), + props = .new( + attachments: attachments, + initialIndex: initialIndex, + autoplayVideos: autoplayVideos, + ); + + /// The properties that configure this preview. + final StreamMediaGalleryPreviewProps props; + + @override + Widget build(BuildContext context) { + final builder = context.chatComponentBuilder(); + if (builder != null) return builder(context, props); + return DefaultStreamMediaGalleryPreview(props: props); + } +} + +/// Properties for configuring a [StreamMediaGalleryPreview]. +/// +/// This class holds all configuration options for the preview, allowing +/// them to be passed through the [StreamComponentFactory]. +/// +/// See also: +/// +/// * [StreamMediaGalleryPreview], which uses these properties. +/// * [DefaultStreamMediaGalleryPreview], the default implementation. +@immutable +class StreamMediaGalleryPreviewProps { + /// Creates properties for a media gallery preview. + const StreamMediaGalleryPreviewProps({ + required this.attachments, + this.initialIndex = 0, + this.autoplayVideos = false, + }); + + /// The 0-based index of the attachment to show first. + final int initialIndex; + + /// The attachments to browse. + final List attachments; + + /// Whether video attachments auto-play when their page becomes active. + final bool autoplayVideos; +} + +/// The default implementation of [StreamMediaGalleryPreview]. +/// +/// See also: +/// +/// * [StreamMediaGalleryPreview], the public API widget. +/// * [StreamMediaGalleryPreviewProps], which configures this widget. +class DefaultStreamMediaGalleryPreview extends StatefulWidget { + /// Creates a default media gallery preview with the given [props]. + const DefaultStreamMediaGalleryPreview({super.key, required this.props}); + + /// The properties that configure this preview. + final StreamMediaGalleryPreviewProps props; + + @override + State createState() => _DefaultStreamMediaGalleryPreviewState(); +} + +class _DefaultStreamMediaGalleryPreviewState extends State { + late final _showChrome = ValueNotifier(true); + late final _currentPage = ValueNotifier(widget.props.initialIndex); + late final _pageController = PageController(initialPage: _currentPage.value); + + // Animates the page view to the given page index. + Future _animateToPage(int page) { + return _pageController.animateToPage( + page, + duration: const Duration(milliseconds: 300), + curve: Curves.easeInOut, + ); + } + + // Downloads the currently-focused attachment's bytes and hands them to + // the system share sheet. + Future _shareCurrentAttachment(BuildContext context) async { + final attachment = widget.props.attachments[_currentPage.value].attachment; + final url = attachment.imageUrl ?? attachment.assetUrl ?? attachment.thumbUrl; + if (url == null) return; + + final response = await Dio().get>(url, options: Options(responseType: .bytes)); + final data = response.data; + if (data == null || data.isEmpty) return; + + // sharePositionOrigin anchors the iPad / macOS popover; without it the + // share sheet asserts on those platforms. + final box = context.findRenderObject() as RenderBox?; + final origin = box != null ? box.localToGlobal(Offset.zero) & box.size : Rect.zero; + + final params = ShareParams( + sharePositionOrigin: origin, + fileNameOverrides: [attachment.title ?? attachment.id], + files: [XFile.fromData(Uint8List.fromList(data), mimeType: attachment.mimeType)], + ); + + await SharePlus.instance.share(params); + } + + // Opens the thumbnail grid in a bottom sheet; tapping a tile pops the + // sheet and seeks the page view to that index. + Future _openGallery(BuildContext context) async { + final itemIndex = await showStreamSheet( + context: context, + builder: (sheetContext, scrollController) => Column( + mainAxisSize: MainAxisSize.min, + children: [ + StreamSheetHeader(title: Text(sheetContext.translations.photosAndVideosLabel)), + Expanded( + child: StreamMediaGallery( + attachments: widget.props.attachments, + scrollController: scrollController, + onItemTap: Navigator.of(sheetContext).maybePop, + ), + ), + ], + ), + ); + + if (itemIndex == null || !mounted) return; + _animateToPage(itemIndex); // Animate the page to the selected index from the gallery. + } + + @override + void dispose() { + _showChrome.dispose(); + _currentPage.dispose(); + _pageController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final attachments = widget.props.attachments; + final itemCount = attachments.length; + + final gallery = ValueListenableBuilder( + valueListenable: _showChrome, + builder: (context, showChrome, pageView) { + return ValueListenableBuilder( + valueListenable: _currentPage, + builder: (context, currentPage, _) { + final translations = context.translations; + final message = attachments[currentPage].message; + + final senderName = message.user?.name ?? ''; + final sentAt = translations.sentAtText(date: message.createdAt, time: message.createdAt); + final pageCounter = translations.galleryPaginationText(currentPage: currentPage, totalPages: itemCount); + + return core.StreamMediaViewer( + showChrome: showChrome, + header: StreamMediaGalleryPreviewHeader( + title: Text(senderName), + subtitle: Text(sentAt), + ), + footer: StreamMediaGalleryPreviewFooter( + title: Text(pageCounter), + onSharePressed: () => _shareCurrentAttachment(context), + onGalleryPressed: () => _openGallery(context), + ), + child: pageView!, + ); + }, + ); + }, + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: () => _showChrome.value = !_showChrome.value, + child: PageView.builder( + controller: _pageController, + itemCount: itemCount, + onPageChanged: (page) => _currentPage.value = page, + itemBuilder: (_, index) { + final package = attachments[index]; + return StreamMediaGalleryPreviewItem( + attachment: package.attachment, + pageIndex: index, + autoplay: widget.props.autoplayVideos, + ); + }, + ), + ), + ); + + return Material( + type: MaterialType.transparency, + child: KeyboardShortcutRunner( + onEscapeKeypress: Navigator.of(context).maybePop, + onLeftArrowKeypress: () { + final index = _currentPage.value; + if (index > 0) _animateToPage(index - 1); + }, + onRightArrowKeypress: () { + final index = _currentPage.value; + if (index < itemCount - 1) _animateToPage(index + 1); + }, + child: StreamMediaGalleryPreviewScope._( + activeIndex: _currentPage, + child: gallery, + ), + ), + ); + } +} + +/// Exposes the active page index of the enclosing [StreamMediaGalleryPreview] +/// to descendants. +/// +/// Per-page widgets that need to react when their page is no longer +/// visible — e.g. video players that pause themselves while off-screen — +/// read [activeIndex] from this scope and compare it against their own +/// page index. +/// +/// {@tool snippet} +/// +/// ```dart +/// final scope = StreamMediaGalleryPreviewScope.of(context); +/// final isActive = scope.activeIndex.value == myPageIndex; +/// ``` +/// {@end-tool} +class StreamMediaGalleryPreviewScope extends InheritedWidget { + const StreamMediaGalleryPreviewScope._({ + required this.activeIndex, + required super.child, + }); + + /// The active page index of the enclosing preview. + final ValueListenable activeIndex; + + /// Returns the [StreamMediaGalleryPreviewScope] of the nearest enclosing + /// [StreamMediaGalleryPreview], or `null` if there isn't one. + /// + /// Prefer [of] when the absence of the scope is a programmer error. + static StreamMediaGalleryPreviewScope? maybeOf(BuildContext context) { + return context.dependOnInheritedWidgetOfExactType(); + } + + /// Returns the [StreamMediaGalleryPreviewScope] of the nearest enclosing + /// [StreamMediaGalleryPreview]. + /// + /// Throws a [FlutterError] when no scope is in scope — typically because + /// the calling widget is rendered outside the preview's page tree. + /// Use [maybeOf] when the absence is a recoverable case. + static StreamMediaGalleryPreviewScope of(BuildContext context) { + final scope = maybeOf(context); + if (scope != null) return scope; + + throw FlutterError.fromParts([ + ErrorSummary( + 'StreamMediaGalleryPreviewScope.of() called with a context that ' + 'does not contain a StreamMediaGalleryPreview.', + ), + ErrorDescription( + 'No StreamMediaGalleryPreview ancestor could be found starting ' + 'from the context that was passed to ' + 'StreamMediaGalleryPreviewScope.of(). This usually means the ' + 'caller is being built outside the page tree owned by the ' + 'preview, or the context predates the StreamMediaGalleryPreview ' + 'itself.', + ), + ErrorHint( + 'The scope is only available inside widgets rendered by the ' + 'preview — typically a [StreamMediaGalleryPreviewItem] or a ' + 'replacement provided via the component factory. If you need to ' + 'react to gallery activity from elsewhere, lift the state out of ' + 'the preview instead.', + ), + context.describeElement('The context used was'), + ]); + } + + @override + bool updateShouldNotify(StreamMediaGalleryPreviewScope old) => activeIndex != old.activeIndex; +} diff --git a/packages/stream_chat_flutter/lib/src/media_gallery_preview/stream_media_gallery_preview_footer.dart b/packages/stream_chat_flutter/lib/src/media_gallery_preview/stream_media_gallery_preview_footer.dart new file mode 100644 index 0000000000..b9a07ba71d --- /dev/null +++ b/packages/stream_chat_flutter/lib/src/media_gallery_preview/stream_media_gallery_preview_footer.dart @@ -0,0 +1,83 @@ +import 'package:flutter/material.dart'; +import 'package:stream_chat_flutter/stream_chat_flutter.dart'; + +/// Bottom chrome bar for a [StreamMediaGalleryPreview]. +/// +/// Wraps [StreamBottomAppBar] with gallery-specific defaults: +/// +/// - The leading slot is fixed to a share icon button that invokes +/// [onSharePressed]. +/// - The [title] slot accepts any [Widget] but typically renders the +/// localised page counter (e.g. "1 of 9"). +/// - The trailing slot is fixed to a gallery-grid icon button that invokes +/// [onGalleryPressed]. +/// +/// {@tool snippet} +/// +/// Build the footer from the active page index inside a preview builder: +/// +/// ```dart +/// StreamMediaGalleryPreviewFooter( +/// title: Text( +/// context.translations.galleryPaginationText( +/// currentPage: currentPage, +/// totalPages: totalPages, +/// ), +/// ), +/// onSharePressed: shareCurrentAttachment, +/// onGalleryPressed: openThumbnailSheet, +/// ) +/// ``` +/// {@end-tool} +/// +/// See also: +/// +/// * [StreamMediaGalleryPreview], which renders this footer in its chrome. +/// * [StreamMediaGalleryPreviewHeader], the matching top chrome. +/// * [StreamBottomAppBar], the underlying bottom app bar this widget wraps. +class StreamMediaGalleryPreviewFooter extends StatelessWidget implements PreferredSizeWidget { + /// Creates a [StreamMediaGalleryPreviewFooter]. + const StreamMediaGalleryPreviewFooter({ + super.key, + this.title, + this.onSharePressed, + this.onGalleryPressed, + }); + + /// {@macro StreamBottomAppBar.title} + final Widget? title; + + /// Called when the share button is pressed. + /// + /// When null, the button is rendered disabled. + final VoidCallback? onSharePressed; + + /// Called when the gallery-grid button is pressed. + /// + /// When null, the button is rendered disabled. + final VoidCallback? onGalleryPressed; + + @override + Size get preferredSize => const Size.fromHeight(kStreamToolbarHeight); + + @override + Widget build(BuildContext context) { + final icons = context.streamIcons; + + return StreamBottomAppBar( + leading: StreamButton.icon( + type: StreamButtonType.ghost, + style: StreamButtonStyle.secondary, + icon: Icon(icons.export), + onPressed: onSharePressed, + ), + title: title, + trailing: StreamButton.icon( + type: StreamButtonType.ghost, + style: StreamButtonStyle.secondary, + icon: Icon(icons.gallery), + onPressed: onGalleryPressed, + ), + ); + } +} diff --git a/packages/stream_chat_flutter/lib/src/media_gallery_preview/stream_media_gallery_preview_header.dart b/packages/stream_chat_flutter/lib/src/media_gallery_preview/stream_media_gallery_preview_header.dart new file mode 100644 index 0000000000..dbb61b78b9 --- /dev/null +++ b/packages/stream_chat_flutter/lib/src/media_gallery_preview/stream_media_gallery_preview_header.dart @@ -0,0 +1,59 @@ +import 'package:flutter/material.dart'; +import 'package:stream_chat_flutter/stream_chat_flutter.dart'; + +/// Top chrome bar for a [StreamMediaGalleryPreview]. +/// +/// Wraps [StreamAppBar] with gallery-specific defaults — the optional +/// [title] / [subtitle] slots accept any [Widget] but typically render the +/// sender's name and the localised sent timestamp. +/// +/// A back affordance is auto-implied on the leading slot from the +/// enclosing route — see [StreamAppBar] for the platform-aware resolution. +/// +/// {@tool snippet} +/// +/// Build the header from the active package inside a preview builder: +/// +/// ```dart +/// StreamMediaGalleryPreviewHeader( +/// title: Text(message.user?.name ?? ''), +/// subtitle: Text( +/// context.translations.sentAtText( +/// date: message.createdAt, +/// time: message.createdAt, +/// ), +/// ), +/// ) +/// ``` +/// {@end-tool} +/// +/// See also: +/// +/// * [StreamMediaGalleryPreview], which renders this header in its chrome. +/// * [StreamMediaGalleryPreviewFooter], the matching bottom chrome. +/// * [StreamAppBar], the underlying app bar this widget wraps. +class StreamMediaGalleryPreviewHeader extends StatelessWidget implements PreferredSizeWidget { + /// Creates a [StreamMediaGalleryPreviewHeader]. + const StreamMediaGalleryPreviewHeader({ + super.key, + this.title, + this.subtitle, + }); + + /// {@macro StreamAppBar.title} + final Widget? title; + + /// {@macro StreamAppBar.subtitle} + final Widget? subtitle; + + @override + Size get preferredSize => const Size.fromHeight(kStreamToolbarHeight); + + @override + Widget build(BuildContext context) { + return StreamAppBar( + title: title, + subtitle: subtitle, + ); + } +} diff --git a/packages/stream_chat_flutter/lib/src/media_gallery_preview/stream_media_gallery_preview_item.dart b/packages/stream_chat_flutter/lib/src/media_gallery_preview/stream_media_gallery_preview_item.dart new file mode 100644 index 0000000000..b8fc3ed584 --- /dev/null +++ b/packages/stream_chat_flutter/lib/src/media_gallery_preview/stream_media_gallery_preview_item.dart @@ -0,0 +1,81 @@ +import 'package:flutter/material.dart'; +import 'package:photo_view/photo_view.dart'; +import 'package:stream_chat_flutter/src/misc/empty_widget.dart'; +import 'package:stream_chat_flutter/stream_chat_flutter.dart'; + +/// One page in a [StreamMediaGalleryPreview]. +/// +/// Renders [attachment] based on its type: +/// +/// - image / giphy → [PhotoView] over a [StreamMediaAttachmentThumbnail] +/// for pinch-to-zoom and pan. +/// - video → [StreamVideoPlayer] — picks the right backend per platform +/// and pauses itself when this page is no longer active. +/// - anything else → an empty widget. +/// +/// {@tool snippet} +/// +/// Use inside a custom preview page builder: +/// +/// ```dart +/// PageView.builder( +/// itemCount: attachments.length, +/// itemBuilder: (_, i) => StreamMediaGalleryPreviewItem( +/// attachment: attachments[i].attachment, +/// pageIndex: i, +/// autoplay: true, +/// ), +/// ) +/// ``` +/// {@end-tool} +/// +/// See also: +/// +/// * [StreamMediaGalleryPreview], the host viewer. +/// * [StreamVideoPlayer], the video backend used for video attachments. +/// * [StreamMediaAttachmentThumbnail], the image / video poster widget. +class StreamMediaGalleryPreviewItem extends StatelessWidget { + /// Creates a [StreamMediaGalleryPreviewItem]. + const StreamMediaGalleryPreviewItem({ + super.key, + required this.attachment, + this.pageIndex = 0, + this.autoplay = false, + }); + + /// The attachment to render. + final Attachment attachment; + + /// The 0-based index of this page in the enclosing preview. Forwarded to + /// [StreamVideoPlayer] so it can decide whether to play or pause based on + /// the active page from [StreamMediaGalleryPreviewScope]. + final int pageIndex; + + /// Whether to start video playback automatically when this page becomes + /// active. No effect for non-video attachments. + final bool autoplay; + + @override + Widget build(BuildContext context) { + final type = attachment.type; + + if (type == .image || type == .giphy) { + return PhotoView.customChild( + maxScale: PhotoViewComputedScale.covered, + minScale: PhotoViewComputedScale.contained, + backgroundDecoration: const BoxDecoration(color: Colors.transparent), + child: StreamMediaAttachmentThumbnail(media: attachment), + ); + } + + if (type == .video) { + return StreamVideoPlayer( + autoplay: autoplay, + pageIndex: pageIndex, + attachment: attachment, + ); + } + + return const Empty(); + } +} diff --git a/packages/stream_chat_flutter/lib/src/media_gallery_preview/video_player/stream_video_player.dart b/packages/stream_chat_flutter/lib/src/media_gallery_preview/video_player/stream_video_player.dart new file mode 100644 index 0000000000..c71a4234e5 --- /dev/null +++ b/packages/stream_chat_flutter/lib/src/media_gallery_preview/video_player/stream_video_player.dart @@ -0,0 +1,72 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/widgets.dart'; +import 'package:stream_chat_flutter/src/media_gallery_preview/video_player/stream_video_player_default.dart'; +import 'package:stream_chat_flutter/src/media_gallery_preview/video_player/stream_video_player_desktop.dart'; +import 'package:stream_chat_flutter/stream_chat_flutter.dart'; + +/// Plays a chat video attachment inside a [StreamMediaGalleryPreview]. +/// +/// Hosts a platform-appropriate playback backend behind a single widget; +/// the player pauses itself when its page is no longer the active one in +/// the enclosing preview, and resumes if the user had it playing before +/// (or on first activation when [autoplay] is true). +/// +/// Must be hosted inside a [StreamMediaGalleryPreview] — playback relies +/// on the preview to know which page is currently visible. +/// +/// {@tool snippet} +/// +/// Wire from a custom preview item builder: +/// +/// ```dart +/// StreamVideoPlayer( +/// attachment: attachment, +/// pageIndex: index, +/// autoplay: true, +/// ) +/// ``` +/// {@end-tool} +/// +/// See also: +/// +/// * [StreamMediaGalleryPreview], the host viewer this widget plays into. +/// * [StreamMediaGalleryPreviewItem], which routes video attachments here. +class StreamVideoPlayer extends StatelessWidget { + /// Creates a [StreamVideoPlayer]. + const StreamVideoPlayer({ + super.key, + required this.attachment, + required this.pageIndex, + this.autoplay = false, + }); + + /// The video attachment to play. + final Attachment attachment; + + /// The 0-based index of this page in the enclosing + /// [StreamMediaGalleryPreview]. + /// + /// Forwarded to the backend so it can compare against the gallery's + /// active index and pause when off-screen. + final int pageIndex; + + /// Whether playback should auto-start the first time this page becomes + /// active. Already-paused-by-user state is preserved on re-activation. + final bool autoplay; + + @override + Widget build(BuildContext context) { + if (!kIsWeb && isDesktopVideoPlayerSupported) { + return StreamVideoPlayerDesktop( + attachment: attachment, + pageIndex: pageIndex, + autoplay: autoplay, + ); + } + return StreamVideoPlayerDefault( + attachment: attachment, + pageIndex: pageIndex, + autoplay: autoplay, + ); + } +} diff --git a/packages/stream_chat_flutter/lib/src/media_gallery_preview/video_player/stream_video_player_activity_mixin.dart b/packages/stream_chat_flutter/lib/src/media_gallery_preview/video_player/stream_video_player_activity_mixin.dart new file mode 100644 index 0000000000..6bd6c41c02 --- /dev/null +++ b/packages/stream_chat_flutter/lib/src/media_gallery_preview/video_player/stream_video_player_activity_mixin.dart @@ -0,0 +1,91 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/widgets.dart'; +import 'package:stream_chat_flutter/stream_chat_flutter.dart'; + +/// State mixin for playback controllers (video / audio) that want to +/// behave well inside a [StreamMediaGalleryPreview]. +/// +/// When the widget is hosted inside a preview, the mixin subscribes to the +/// enclosing [StreamMediaGalleryPreviewScope]'s active page index and +/// toggles playback so: +/// +/// - When the page becomes active again, playback resumes if the user had +/// it playing before swiping away — or if [autoplay] is true on the +/// first activation. +/// - When the page goes off-screen, playback pauses; the prior state is +/// remembered so swiping back doesn't drop the user's position or +/// force-restart paused videos. +/// +/// When there is no preview ancestor, the mixin treats the widget as +/// always active — [autoplay] kicks in on init and the caller controls +/// play / pause manually after that. This lets the same player class be +/// reused outside the gallery without breaking. +mixin StreamVideoPlayerActivityMixin on State { + ValueListenable? _activeIndex; + bool _wasPlayingBeforeInactive = false; + + /// The 0-based index of this page in the enclosing + /// [StreamMediaGalleryPreview]. Ignored when no preview ancestor is in + /// scope. + int get pageIndex; + + /// Whether playback should auto-start when this page first becomes + /// active. Already-paused-by-user state is preserved on re-activation + /// regardless of this flag. + bool get autoplay; + + /// Whether the underlying controller is ready for [play] / [pause] + /// calls. The mixin gates its sync on this flag so subclasses can + /// safely return false during async initialisation. + bool get isPlayerReady; + + /// Whether the underlying controller is currently playing. + bool get isPlaying; + + /// Starts playback. Called only when [isPlayerReady] is true. + void play(); + + /// Pauses playback. Called only when [isPlayerReady] is true. + void pause(); + + /// True when this page is currently the active gallery page. + /// + /// Treated as always active when no [StreamMediaGalleryPreviewScope] + /// ancestor is in scope, so the player still auto-plays / can be + /// controlled manually outside the gallery. + bool get isActive => _activeIndex == null || _activeIndex!.value == pageIndex; + + /// Subclasses should call this when their async initialisation completes + /// so the mixin can apply the right initial play / pause state. + void syncPlayState() => _syncPlayState(); + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + final next = StreamMediaGalleryPreviewScope.maybeOf(context)?.activeIndex; + if (_activeIndex != next) { + _activeIndex?.removeListener(_syncPlayState); + _activeIndex = next?..addListener(_syncPlayState); + _syncPlayState(); + } + } + + @override + void dispose() { + _activeIndex?.removeListener(_syncPlayState); + super.dispose(); + } + + void _syncPlayState() { + if (!isPlayerReady) return; + if (isActive) { + if (autoplay || _wasPlayingBeforeInactive) { + play(); + _wasPlayingBeforeInactive = false; + } + } else if (isPlaying) { + _wasPlayingBeforeInactive = true; + pause(); + } + } +} diff --git a/packages/stream_chat_flutter/lib/src/media_gallery_preview/video_player/stream_video_player_default.dart b/packages/stream_chat_flutter/lib/src/media_gallery_preview/video_player/stream_video_player_default.dart new file mode 100644 index 0000000000..e91a5a1b0e --- /dev/null +++ b/packages/stream_chat_flutter/lib/src/media_gallery_preview/video_player/stream_video_player_default.dart @@ -0,0 +1,159 @@ +import 'dart:io'; + +import 'package:chewie/chewie.dart'; +import 'package:flutter/material.dart'; +import 'package:stream_chat_flutter/src/media_gallery_preview/video_player/stream_video_player_activity_mixin.dart'; +import 'package:stream_chat_flutter/stream_chat_flutter.dart'; +import 'package:video_player/video_player.dart'; + +/// Video player used on Android, iOS, macOS, and web inside a +/// [StreamMediaGalleryPreview]. +/// +/// Pauses itself when the enclosing preview swipes away from this page +/// and resumes on return if the user had it playing before. +/// +/// Routed to internally by [StreamVideoPlayer]; you generally don't need +/// to construct this directly. +class StreamVideoPlayerDefault extends StatefulWidget { + /// Creates a [StreamVideoPlayerDefault]. + const StreamVideoPlayerDefault({ + super.key, + required this.attachment, + required this.pageIndex, + this.autoplay = false, + }); + + /// The video attachment to play. + final Attachment attachment; + + /// The 0-based index of this page in the enclosing + /// [StreamMediaGalleryPreview]. + final int pageIndex; + + /// Whether playback should auto-start when this page first becomes + /// active. + final bool autoplay; + + @override + State createState() => _StreamVideoPlayerDefaultState(); +} + +class _StreamVideoPlayerDefaultState extends State + with + StreamVideoPlayerActivityMixin, + AutomaticKeepAliveClientMixin { + final _player = _ChewieVideoPlayer(); + Object? _error; + + @override + int get pageIndex => widget.pageIndex; + + @override + bool get autoplay => widget.autoplay; + + @override + bool get isPlayerReady => _player.isReady; + + @override + bool get isPlaying => _player.isPlaying; + + @override + void play() => _player.play(); + + @override + void pause() => _player.pause(); + + // Keep the page mounted in the enclosing PageView once the controller is + // ready, so swiping away and back doesn't re-initialise and replay the + // loading spinner. + @override + bool get wantKeepAlive => _player.isReady; + + @override + void initState() { + super.initState(); + _initialize(); + } + + Future _initialize() async { + try { + await _player.open(widget.attachment); + if (!mounted) return; + setState(() {}); + updateKeepAlive(); + syncPlayState(); + } catch (error) { + if (!mounted) return; + setState(() => _error = error); + } + } + + @override + void dispose() { + _player.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + super.build(context); + if (_error != null) return const Center(child: StreamImageErrorPlaceholder()); + if (_player.controller case final controller?) return Chewie(controller: controller); + return const Center(child: StreamScrollViewLoadingWidget()); + } +} + +/// Holds the [VideoPlayerController] / [ChewieController] pair so the +/// state class doesn't have to coordinate them inline. +/// +/// [controller] is the public ready signal — non-null once [open] +/// resolves successfully, null while loading or after [dispose]. +class _ChewieVideoPlayer { + VideoPlayerController? _video; + ChewieController? _chewie; + + /// The chewie controller used for playback once initialisation has + /// completed. `null` until [open] resolves. + ChewieController? get controller => _chewie; + + /// Whether the underlying video player is ready for playback. + bool get isReady => _chewie != null; + + /// Whether the underlying video player is currently playing. + bool get isPlaying => _video?.value.isPlaying ?? false; + + /// Initialises the controller pair for [attachment]. Throws when there + /// is no usable source URL. + Future open(Attachment attachment) async { + final localUri = attachment.localUri; + final assetUrl = attachment.assetUrl; + if (localUri == null && assetUrl == null) { + throw StateError('No video source on attachment ${attachment.id}'); + } + + // Local files take precedence over the network asset; on web the + // local branch is unreachable since `localUri` is always null there. + final video = localUri != null + ? VideoPlayerController.file(File.fromUri(localUri)) + : VideoPlayerController.networkUrl(Uri.parse(assetUrl!)); + _video = video; + await video.initialize(); + _chewie = ChewieController( + videoPlayerController: video, + autoInitialize: true, + aspectRatio: video.value.aspectRatio, + ); + } + + /// Starts playback. No-op if not yet ready. + void play() => _chewie?.play(); + + /// Pauses playback. No-op if not yet ready. + void pause() => _chewie?.pause(); + + /// Releases both controllers. + void dispose() { + _chewie?.dispose(); + _video?.dispose(); + } +} diff --git a/packages/stream_chat_flutter/lib/src/media_gallery_preview/video_player/stream_video_player_desktop.dart b/packages/stream_chat_flutter/lib/src/media_gallery_preview/video_player/stream_video_player_desktop.dart new file mode 100644 index 0000000000..3f69f77288 --- /dev/null +++ b/packages/stream_chat_flutter/lib/src/media_gallery_preview/video_player/stream_video_player_desktop.dart @@ -0,0 +1,146 @@ +import 'package:flutter/material.dart'; +import 'package:media_kit/media_kit.dart'; +import 'package:media_kit_video/media_kit_video.dart'; +import 'package:stream_chat_flutter/src/media_gallery_preview/video_player/stream_video_player_activity_mixin.dart'; +import 'package:stream_chat_flutter/stream_chat_flutter.dart'; + +/// Video player used on Linux and Windows inside a +/// [StreamMediaGalleryPreview]. +/// +/// Pauses itself when the enclosing preview swipes away from this page +/// and resumes on return if the user had it playing before. +/// +/// Routed to internally by [StreamVideoPlayer]; you generally don't need +/// to construct this directly. +class StreamVideoPlayerDesktop extends StatefulWidget { + /// Creates a [StreamVideoPlayerDesktop]. + const StreamVideoPlayerDesktop({ + super.key, + required this.attachment, + required this.pageIndex, + this.autoplay = false, + }); + + /// The video attachment to play. + final Attachment attachment; + + /// The 0-based index of this page in the enclosing + /// [StreamMediaGalleryPreview]. + final int pageIndex; + + /// Whether playback should auto-start when this page first becomes + /// active. + final bool autoplay; + + @override + State createState() => _StreamVideoPlayerDesktopState(); +} + +class _StreamVideoPlayerDesktopState extends State + with + StreamVideoPlayerActivityMixin, + AutomaticKeepAliveClientMixin { + final _player = _MediaKitVideoPlayer(); + Object? _error; + + @override + int get pageIndex => widget.pageIndex; + + @override + bool get autoplay => widget.autoplay; + + @override + bool get isPlayerReady => _player.isReady; + + @override + bool get isPlaying => _player.isPlaying; + + @override + void play() => _player.play(); + + @override + void pause() => _player.pause(); + + // Keep the page mounted in the enclosing PageView once the controller is + // ready, so swiping away and back doesn't re-initialise and replay the + // loading spinner. + @override + bool get wantKeepAlive => _player.isReady; + + @override + void initState() { + super.initState(); + _initialize(); + } + + Future _initialize() async { + try { + await _player.open(widget.attachment); + if (!mounted) return; + setState(() {}); + updateKeepAlive(); + syncPlayState(); + } catch (error) { + if (!mounted) return; + setState(() => _error = error); + } + } + + @override + void dispose() { + _player.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + super.build(context); + if (_error != null) return const Center(child: StreamImageErrorPlaceholder()); + if (_player.controller case final controller?) return Video(controller: controller); + return const Center(child: StreamScrollViewLoadingWidget()); + } +} + +/// Holds the [Player] / [VideoController] pair so the state class doesn't +/// have to coordinate them inline. +/// +/// [controller] is the public ready signal — non-null once [open] +/// resolves successfully, null while loading or after [dispose]. +class _MediaKitVideoPlayer { + Player? _player; + VideoController? _controller; + + /// The video controller used for rendering once initialisation has + /// completed. `null` until [open] resolves. + VideoController? get controller => _controller; + + /// Whether the underlying media player is ready for playback. + bool get isReady => _controller != null; + + /// Whether the underlying media player is currently playing. + bool get isPlaying => _player?.state.playing ?? false; + + /// Initialises the player for [attachment]. Throws when there is no + /// usable source URL. + Future open(Attachment attachment) async { + final assetUrl = attachment.assetUrl; + if (assetUrl == null) { + throw StateError('No video source on attachment ${attachment.id}'); + } + + final player = Player(); + _player = player; + final controller = VideoController(player); + await player.open(Media(assetUrl), play: false); + _controller = controller; + } + + /// Starts playback. No-op if not yet ready. + void play() => _player?.play(); + + /// Pauses playback. No-op if not yet ready. + void pause() => _player?.pause(); + + /// Releases the player. + void dispose() => _player?.dispose(); +} diff --git a/packages/stream_chat_flutter/lib/src/message_action/message_action.dart b/packages/stream_chat_flutter/lib/src/message_action/message_action.dart new file mode 100644 index 0000000000..27f950eae8 --- /dev/null +++ b/packages/stream_chat_flutter/lib/src/message_action/message_action.dart @@ -0,0 +1,138 @@ +import 'package:flutter/widgets.dart'; +import 'package:stream_chat_flutter_core/stream_chat_flutter_core.dart'; +import 'package:stream_core_flutter/stream_core_flutter.dart'; + +/// {@template messageActionsBuilder} +/// Signature for a builder that customizes the list of message actions shown +/// in the long-press context menu. +/// +/// [defaultActions] are the pre-built actions already filtered by the widget's +/// `show*` flags. Return a modified list to add, remove, or reorder actions. +/// +/// The return type is [List] so any widget — including +/// [StreamContextMenuSeparator] or fully custom items — can be mixed in +/// alongside the default [StreamContextMenuAction] items. +/// {@endtemplate} +typedef MessageActionsBuilder = + List Function( + BuildContext context, + List> defaultActions, + ); + +/// {@template messageAction} +/// A sealed class that represents different actions that can be performed on a +/// message. +/// {@endtemplate} +sealed class MessageAction { + /// {@macro messageAction} + const MessageAction({required this.message}); + + /// The message this action applies to. + final Message message; +} + +/// Action to show reaction selector for adding reactions to a message +final class SelectReaction extends MessageAction { + /// Create a new select reaction action + const SelectReaction({ + required super.message, + required this.reaction, + this.enforceUnique = false, + }); + + /// The reaction to be added or removed from the message. + final Reaction reaction; + + /// Whether to enforce unique reactions. + final bool enforceUnique; +} + +/// Action to copy message content to clipboard +final class CopyMessage extends MessageAction { + /// Create a new copy message action + const CopyMessage({required super.message}); +} + +/// Action to delete a message from the conversation +final class DeleteMessage extends MessageAction { + /// Create a new delete message action + const DeleteMessage({required super.message}); +} + +/// Action to hard delete a message permanently from the conversation +final class HardDeleteMessage extends MessageAction { + /// Create a new hard delete message action + const HardDeleteMessage({required super.message}); +} + +/// Action to modify content of an existing message +final class EditMessage extends MessageAction { + /// Create a new edit message action + const EditMessage({required super.message}); +} + +/// Action to flag a message for moderator review +final class FlagMessage extends MessageAction { + /// Create a new flag message action + const FlagMessage({required super.message}); +} + +/// Action to mark a message as unread for later viewing +final class MarkUnread extends MessageAction { + /// Create a new mark unread action + const MarkUnread({required super.message}); +} + +/// Action to mute a user to prevent notifications from their messages +final class MuteUser extends MessageAction { + /// Create a new mute user action + const MuteUser({ + required super.message, + required this.user, + }); + + /// The user to be muted. + final User user; +} + +/// Action to unmute a user to receive notifications from their messages +final class UnmuteUser extends MessageAction { + /// Create a new unmute user action + const UnmuteUser({ + required super.message, + required this.user, + }); + + /// The user to be unmuted. + final User user; +} + +/// Action to pin a message to make it prominently visible in the channel +final class PinMessage extends MessageAction { + /// Create a new pin message action + const PinMessage({required super.message}); +} + +/// Action to remove a previously pinned message +final class UnpinMessage extends MessageAction { + /// Create a new unpin message action + const UnpinMessage({required super.message}); +} + +/// Action to attempt to resend a message that failed to send +final class ResendMessage extends MessageAction { + /// Create a new resend message action + const ResendMessage({required super.message}); +} + +/// Action to create a reply with quoted original message content +final class QuotedReply extends MessageAction { + /// Create a new quoted reply action + const QuotedReply({required super.message}); +} + +/// Action to start a threaded conversation from a message +final class ThreadReply extends MessageAction { + /// Create a new thread reply action + const ThreadReply({required super.message}); +} diff --git a/packages/stream_chat_flutter/lib/src/message_action/message_actions_builder.dart b/packages/stream_chat_flutter/lib/src/message_action/message_actions_builder.dart new file mode 100644 index 0000000000..815f299e97 --- /dev/null +++ b/packages/stream_chat_flutter/lib/src/message_action/message_actions_builder.dart @@ -0,0 +1,265 @@ +import 'package:flutter/material.dart'; +import 'package:stream_chat_flutter/src/message_action/message_action.dart'; +import 'package:stream_chat_flutter/src/theme/stream_chat_theme.dart'; +import 'package:stream_chat_flutter/src/utils/extensions.dart'; +import 'package:stream_chat_flutter_core/stream_chat_flutter_core.dart'; +import 'package:stream_core_flutter/stream_core_flutter.dart'; + +/// {@template streamMessageActionsBuilder} +/// A utility class that provides a builder for message actions +/// which can be reused across mobile platforms. +/// {@endtemplate} +class StreamMessageActionsBuilder { + /// Private constructor to prevent instantiation + StreamMessageActionsBuilder._(); + + /// Returns a list of message actions for the "bounced with error" state. + /// + /// This method builds a list of [StreamContextMenuAction]s that are + /// applicable to + /// the given [message] when it is in the "bounced with error" state. + /// + /// The actions include options to retry sending the message, edit or delete + /// the message. + static List> buildBouncedErrorActions({ + required BuildContext context, + required Message message, + }) { + // If the message is not bounced with an error, we don't show any actions. + if (!message.isBouncedWithError) return []; + + final icons = context.streamIcons; + + return >[ + StreamContextMenuAction( + value: ResendMessage(message: message), + label: Text(context.translations.sendAnywayLabel), + leading: Icon( + icons.send, + color: StreamChatTheme.of(context).colorTheme.accentPrimary, + ), + ), + StreamContextMenuAction( + value: EditMessage(message: message), + label: Text(context.translations.editMessageLabel), + leading: Icon(icons.edit), + ), + StreamContextMenuAction.destructive( + value: HardDeleteMessage(message: message), + label: Text(context.translations.deleteMessageLabel), + leading: Icon(icons.delete), + ), + ]; + } + + /// Returns a list of message actions based on the provided message and + /// channel capabilities. + /// + /// This method builds a list of [StreamContextMenuAction]s that are + /// applicable to + /// the given [message] in the [channel], considering the permissions of the + /// [currentUser] and the current state of the message. + static List> buildActions({ + required BuildContext context, + required Message message, + required Channel channel, + OwnUser? currentUser, + }) { + final messageState = message.state; + + // If the message is deleted, we don't show any actions. + if (messageState.isDeleted) return []; + + final icons = context.streamIcons; + + if (messageState.isFailed) { + return [ + if (messageState.isSendingFailed || messageState.isUpdatingFailed) ...[ + StreamContextMenuAction( + value: ResendMessage(message: message), + leading: Icon(icons.send), + label: Text( + context.translations.toggleResendOrResendEditedMessage( + isUpdateFailed: messageState.isUpdatingFailed, + ), + ), + ), + if (messageState.isSendingFailed) + StreamContextMenuAction.destructive( + value: HardDeleteMessage(message: message), + leading: Icon(icons.delete), + label: Text( + context.translations.toggleDeleteRetryDeleteMessageText( + isDeleteFailed: false, + ), + ), + ), + ], + if (message.state.isDeletingFailed) + StreamContextMenuAction.destructive( + value: ResendMessage(message: message), + leading: Icon(icons.delete), + label: Text( + context.translations.toggleDeleteRetryDeleteMessageText( + isDeleteFailed: true, + ), + ), + ), + ]; + } + + final listKind = StreamMessageLayout.listKindOf(context); + + final isSentByCurrentUser = message.user?.id == currentUser?.id; + final isInThreadView = listKind == .thread; + final isThreadMessage = message.parentId != null; + final isParentMessage = (message.replyCount ?? 0) > 0; + final canShowInChannel = message.showInChannel ?? true; + final isPrivateMessage = message.hasRestrictedVisibility; + final canSendReply = channel.canSendReply; + final canPinMessage = channel.canPinMessage; + final canQuoteMessage = channel.canQuoteMessage; + final canReceiveReadEvents = channel.canUseReadReceipts; + final canUpdateAnyMessage = channel.canUpdateAnyMessage; + final canUpdateOwnMessage = channel.canUpdateOwnMessage; + final canDeleteAnyMessage = channel.canDeleteAnyMessage; + final canDeleteOwnMessage = channel.canDeleteOwnMessage; + final containsPoll = message.poll != null; + final containsGiphy = message.attachments.any( + (attachment) => attachment.type == AttachmentType.giphy, + ); + + final messageActions = >[]; + + if (canQuoteMessage) { + messageActions.add( + StreamContextMenuAction( + value: QuotedReply(message: message), + label: Text(context.translations.replyLabel), + leading: Icon(icons.reply), + ), + ); + } + + // Thread reply action is only available for parent messages that are not in a + // thread view, as replying in a thread that is already being viewed doesn't make sense. + // Additionally, the channel needs to support sending replies. + if (canSendReply && !isThreadMessage && !isInThreadView) { + messageActions.add( + StreamContextMenuAction( + value: ThreadReply(message: message), + label: Text(context.translations.threadReplyLabel), + leading: Icon(icons.thread), + ), + ); + } + + // Mark unread action is only available for other users' messages. + if (canReceiveReadEvents && !isSentByCurrentUser) { + StreamContextMenuAction markUnreadAction() { + return StreamContextMenuAction( + value: MarkUnread(message: message), + label: Text(context.translations.markAsUnreadLabel), + leading: Icon(icons.notification), + ); + } + + // If message is a parent message, it can be marked unread independent of + // other logic. + if (isParentMessage) { + messageActions.add(markUnreadAction()); + } + // If the message is in the channel view, only other user messages can be + // marked unread. + else if (!isThreadMessage || canShowInChannel) { + messageActions.add(markUnreadAction()); + } + } + + if (message.text case final text? when text.isNotEmpty) { + messageActions.add( + StreamContextMenuAction( + value: CopyMessage(message: message), + label: Text(context.translations.copyMessageLabel), + leading: Icon(icons.copy), + ), + ); + } + + if (!containsPoll && !containsGiphy) { + if (canUpdateAnyMessage || (canUpdateOwnMessage && isSentByCurrentUser)) { + messageActions.add( + StreamContextMenuAction( + value: EditMessage(message: message), + label: Text(context.translations.editMessageLabel), + leading: Icon(icons.edit), + ), + ); + } + } + + // Pinning a private message is not allowed, simply because pinning a + // message is meant to bring attention to that message, that is not possible + // with a message that is only visible to a subset of users. + if (canPinMessage && !isPrivateMessage) { + final isPinned = message.pinned; + final label = context.translations.togglePinUnpinText; + + final action = switch (isPinned) { + true => UnpinMessage(message: message), + false => PinMessage(message: message), + }; + + messageActions.add( + StreamContextMenuAction( + value: action, + label: Text(label.call(pinned: isPinned)), + leading: Icon(icons.pin), + ), + ); + } + + if (canDeleteAnyMessage || (canDeleteOwnMessage && isSentByCurrentUser)) { + final label = context.translations.toggleDeleteRetryDeleteMessageText; + + messageActions.add( + StreamContextMenuAction.destructive( + value: DeleteMessage(message: message), + leading: Icon(icons.delete), + label: Text(label.call(isDeleteFailed: false)), + ), + ); + } + + if (!isSentByCurrentUser) { + messageActions.add( + StreamContextMenuAction( + value: FlagMessage(message: message), + label: Text(context.translations.flagMessageLabel), + leading: Icon(icons.flag), + ), + ); + } + + if (message.user case final messageUser? when channel.config?.mutes == true && !isSentByCurrentUser) { + final mutedUsers = currentUser?.mutes.map((mute) => mute.target.id); + final isMuted = mutedUsers?.contains(messageUser.id) ?? false; + final label = context.translations.toggleMuteUnmuteUserText; + + final action = switch (isMuted) { + true => UnmuteUser(message: message, user: messageUser), + false => MuteUser(message: message, user: messageUser), + }; + + messageActions.add( + StreamContextMenuAction( + value: action, + label: Text(label.call(isMuted: isMuted)), + leading: Icon(icons.mute), + ), + ); + } + + return messageActions; + } +} diff --git a/packages/stream_chat_flutter/lib/src/message_actions_modal/copy_message_button.dart b/packages/stream_chat_flutter/lib/src/message_actions_modal/copy_message_button.dart deleted file mode 100644 index cdb7415f21..0000000000 --- a/packages/stream_chat_flutter/lib/src/message_actions_modal/copy_message_button.dart +++ /dev/null @@ -1,43 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:stream_chat_flutter/stream_chat_flutter.dart'; - -/// {@template copyMessageButton} -/// Allows a user to copy the text of a message. -/// -/// Used by [MessageActionsModal]. Should not be used by itself. -/// {@endtemplate} -class CopyMessageButton extends StatelessWidget { - /// {@macro copyMessageButton} - const CopyMessageButton({ - super.key, - required this.onTap, - }); - - /// The callback to perform when the button is tapped. - final VoidCallback onTap; - - @override - Widget build(BuildContext context) { - final streamChatThemeData = StreamChatTheme.of(context); - return InkWell( - onTap: onTap, - child: Padding( - padding: const EdgeInsets.symmetric(vertical: 11, horizontal: 16), - child: Row( - children: [ - StreamSvgIcon( - size: 24, - icon: StreamSvgIcons.copy, - color: streamChatThemeData.primaryIconTheme.color, - ), - const SizedBox(width: 16), - Text( - context.translations.copyMessageLabel, - style: streamChatThemeData.textTheme.body, - ), - ], - ), - ), - ); - } -} diff --git a/packages/stream_chat_flutter/lib/src/message_actions_modal/delete_message_button.dart b/packages/stream_chat_flutter/lib/src/message_actions_modal/delete_message_button.dart deleted file mode 100644 index 45e0477a5d..0000000000 --- a/packages/stream_chat_flutter/lib/src/message_actions_modal/delete_message_button.dart +++ /dev/null @@ -1,50 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:stream_chat_flutter/stream_chat_flutter.dart'; - -/// {@template deleteMessageButton} -/// A button that allows a user to delete the selected message. -/// -/// Used by [MessageActionsModal]. Should not be used by itself. -/// {@endtemplate} -class DeleteMessageButton extends StatelessWidget { - /// {@macro deleteMessageButton} - const DeleteMessageButton({ - super.key, - required this.isDeleteFailed, - required this.onTap, - }); - - /// Indicates whether the deletion has failed or not. - final bool isDeleteFailed; - - /// The action (deleting the message) to be performed on tap. - final VoidCallback onTap; - - @override - Widget build(BuildContext context) { - final theme = StreamChatTheme.of(context); - return InkWell( - onTap: onTap, - child: Padding( - padding: const EdgeInsets.symmetric(vertical: 11, horizontal: 16), - child: Row( - children: [ - StreamSvgIcon( - icon: StreamSvgIcons.delete, - color: theme.colorTheme.accentError, - ), - const SizedBox(width: 16), - Text( - context.translations.toggleDeleteRetryDeleteMessageText( - isDeleteFailed: isDeleteFailed, - ), - style: theme.textTheme.body.copyWith( - color: theme.colorTheme.accentError, - ), - ), - ], - ), - ), - ); - } -} diff --git a/packages/stream_chat_flutter/lib/src/message_actions_modal/edit_message_button.dart b/packages/stream_chat_flutter/lib/src/message_actions_modal/edit_message_button.dart deleted file mode 100644 index 33165ac8a4..0000000000 --- a/packages/stream_chat_flutter/lib/src/message_actions_modal/edit_message_button.dart +++ /dev/null @@ -1,42 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:stream_chat_flutter/stream_chat_flutter.dart'; - -/// {@template editMessageButton} -/// Allows a user to edit a message. -/// -/// Used by [MessageActionsModal]. Should not be used by itself. -/// {@endtemplate} -class EditMessageButton extends StatelessWidget { - /// {@macro editMessageButton} - const EditMessageButton({ - super.key, - required this.onTap, - }); - - /// The callback to perform when the button is tapped. - final VoidCallback? onTap; - - @override - Widget build(BuildContext context) { - final streamChatThemeData = StreamChatTheme.of(context); - return InkWell( - onTap: onTap, - child: Padding( - padding: const EdgeInsets.symmetric(vertical: 11, horizontal: 16), - child: Row( - children: [ - StreamSvgIcon( - icon: StreamSvgIcons.edit, - color: streamChatThemeData.primaryIconTheme.color, - ), - const SizedBox(width: 16), - Text( - context.translations.editMessageLabel, - style: streamChatThemeData.textTheme.body, - ), - ], - ), - ), - ); - } -} diff --git a/packages/stream_chat_flutter/lib/src/message_actions_modal/flag_message_button.dart b/packages/stream_chat_flutter/lib/src/message_actions_modal/flag_message_button.dart deleted file mode 100644 index 5c57547108..0000000000 --- a/packages/stream_chat_flutter/lib/src/message_actions_modal/flag_message_button.dart +++ /dev/null @@ -1,42 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:stream_chat_flutter/stream_chat_flutter.dart'; - -/// {@template flagMessageButton} -/// Allows a user to flag a message. -/// -/// Used by [MessageActionsModal]. Should not be used by itself. -/// {@endtemplate} -class FlagMessageButton extends StatelessWidget { - /// {@macro flagMessageButton} - const FlagMessageButton({ - super.key, - required this.onTap, - }); - - /// The callback to perform when the button is tapped. - final VoidCallback onTap; - - @override - Widget build(BuildContext context) { - final streamChatThemeData = StreamChatTheme.of(context); - return InkWell( - onTap: onTap, - child: Padding( - padding: const EdgeInsets.symmetric(vertical: 11, horizontal: 16), - child: Row( - children: [ - StreamSvgIcon( - icon: StreamSvgIcons.flag, - color: streamChatThemeData.primaryIconTheme.color, - ), - const SizedBox(width: 16), - Text( - context.translations.flagMessageLabel, - style: streamChatThemeData.textTheme.body, - ), - ], - ), - ), - ); - } -} diff --git a/packages/stream_chat_flutter/lib/src/message_actions_modal/mam_widgets.dart b/packages/stream_chat_flutter/lib/src/message_actions_modal/mam_widgets.dart deleted file mode 100644 index e756d682b2..0000000000 --- a/packages/stream_chat_flutter/lib/src/message_actions_modal/mam_widgets.dart +++ /dev/null @@ -1,8 +0,0 @@ -export 'copy_message_button.dart'; -export 'delete_message_button.dart'; -export 'edit_message_button.dart'; -export 'flag_message_button.dart'; -export 'pin_message_button.dart'; -export 'reply_button.dart'; -export 'resend_message_button.dart'; -export 'thread_reply_button.dart'; diff --git a/packages/stream_chat_flutter/lib/src/message_actions_modal/mark_unread_message_button.dart b/packages/stream_chat_flutter/lib/src/message_actions_modal/mark_unread_message_button.dart deleted file mode 100644 index 12c38d0210..0000000000 --- a/packages/stream_chat_flutter/lib/src/message_actions_modal/mark_unread_message_button.dart +++ /dev/null @@ -1,43 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:stream_chat_flutter/stream_chat_flutter.dart'; - -/// {@template markUnreadMessageButton} -/// Allows a user to mark message (and all messages onwards) as unread. -/// -/// Used by [MessageActionsModal]. Should not be used by itself. -/// {@endtemplate} -class MarkUnreadMessageButton extends StatelessWidget { - /// {@macro markUnreadMessageButton} - const MarkUnreadMessageButton({ - super.key, - required this.onTap, - }); - - /// The callback to perform when the button is tapped. - final VoidCallback onTap; - - @override - Widget build(BuildContext context) { - final streamChatThemeData = StreamChatTheme.of(context); - return InkWell( - onTap: onTap, - child: Padding( - padding: const EdgeInsets.symmetric(vertical: 11, horizontal: 16), - child: Row( - children: [ - StreamSvgIcon( - icon: StreamSvgIcons.messageUnread, - color: streamChatThemeData.primaryIconTheme.color, - size: 24, - ), - const SizedBox(width: 16), - Text( - context.translations.markAsUnreadLabel, - style: streamChatThemeData.textTheme.body, - ), - ], - ), - ), - ); - } -} diff --git a/packages/stream_chat_flutter/lib/src/message_actions_modal/message_action.dart b/packages/stream_chat_flutter/lib/src/message_actions_modal/message_action.dart deleted file mode 100644 index f3acaac964..0000000000 --- a/packages/stream_chat_flutter/lib/src/message_actions_modal/message_action.dart +++ /dev/null @@ -1,23 +0,0 @@ -import 'package:flutter/widgets.dart'; -import 'package:stream_chat_flutter/src/utils/typedefs.dart'; - -/// {@template streamMessageAction} -/// Class describing a message action -/// {@endtemplate} -class StreamMessageAction { - /// {@macro streamMessageAction} - StreamMessageAction({ - this.leading, - this.title, - this.onTap, - }); - - /// leading widget - final Widget? leading; - - /// title widget - final Widget? title; - - /// {@macro onMessageTap} - final OnMessageTap? onTap; -} diff --git a/packages/stream_chat_flutter/lib/src/message_actions_modal/message_actions_modal.dart b/packages/stream_chat_flutter/lib/src/message_actions_modal/message_actions_modal.dart deleted file mode 100644 index 069c1aea51..0000000000 --- a/packages/stream_chat_flutter/lib/src/message_actions_modal/message_actions_modal.dart +++ /dev/null @@ -1,425 +0,0 @@ -import 'dart:ui'; - -import 'package:flutter/material.dart' hide ButtonStyle; -import 'package:stream_chat_flutter/src/message_actions_modal/mam_widgets.dart'; -import 'package:stream_chat_flutter/src/message_actions_modal/mark_unread_message_button.dart'; -import 'package:stream_chat_flutter/src/message_widget/reactions/reactions_align.dart'; -import 'package:stream_chat_flutter/src/misc/empty_widget.dart'; -import 'package:stream_chat_flutter/stream_chat_flutter.dart'; - -/// {@template messageActionsModal} -/// Constructs a modal with actions for a message -/// {@endtemplate} -class MessageActionsModal extends StatefulWidget { - /// {@macro messageActionsModal} - const MessageActionsModal({ - super.key, - required this.message, - required this.messageWidget, - required this.messageTheme, - this.showReactionPicker = true, - this.showDeleteMessage = true, - this.showEditMessage = true, - this.onReplyTap, - this.onEditMessageTap, - this.onConfirmDeleteTap, - this.onThreadReplyTap, - this.showCopyMessage = true, - this.showReplyMessage = true, - this.showResendMessage = true, - this.showThreadReplyMessage = true, - this.showMarkUnreadMessage = true, - this.showFlagButton = true, - this.showPinButton = true, - this.editMessageInputBuilder, - this.reverse = false, - this.customActions = const [], - this.onCopyTap, - }); - - /// Widget that shows the message - final Widget messageWidget; - - /// Builder for edit message - final EditMessageInputBuilder? editMessageInputBuilder; - - /// The action to perform when "thread reply" is tapped - final OnMessageTap? onThreadReplyTap; - - /// The action to perform when "reply" is tapped - final OnMessageTap? onReplyTap; - - /// The action to perform when "Edit Message" is tapped. - final OnMessageTap? onEditMessageTap; - - /// The action to perform when delete confirmation button is tapped. - final Future Function(Message)? onConfirmDeleteTap; - - /// Message in focus for actions - final Message message; - - /// [StreamMessageThemeData] for message - final StreamMessageThemeData messageTheme; - - /// Flag for showing reaction picker. - final bool showReactionPicker; - - /// Callback when copy is tapped - final OnMessageTap? onCopyTap; - - /// Callback when delete is tapped - final bool showDeleteMessage; - - /// Flag for showing copy action - final bool showCopyMessage; - - /// Flag for showing edit action - final bool showEditMessage; - - /// Flag for showing resend action - final bool showResendMessage; - - /// Flag for showing mark unread action - final bool showMarkUnreadMessage; - - /// Flag for showing reply action - final bool showReplyMessage; - - /// Flag for showing thread reply action - final bool showThreadReplyMessage; - - /// Flag for showing flag action - final bool showFlagButton; - - /// Flag for showing pin action - final bool showPinButton; - - /// Flag for reversing message - final bool reverse; - - /// List of custom actions - final List customActions; - - @override - _MessageActionsModalState createState() => _MessageActionsModalState(); -} - -class _MessageActionsModalState extends State { - bool _showActions = true; - - @override - Widget build(BuildContext context) { - final mediaQueryData = MediaQuery.of(context); - final user = StreamChat.of(context).currentUser; - final orientation = mediaQueryData.orientation; - - final fontSize = widget.messageTheme.messageTextStyle?.fontSize; - final streamChatThemeData = StreamChatTheme.of(context); - - final channel = StreamChannel.of(context).channel; - - final canSendReaction = channel.canSendReaction; - - final child = Center( - child: SingleChildScrollView( - child: SafeArea( - child: Padding( - padding: const EdgeInsets.all(8), - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - if (widget.showReactionPicker && canSendReaction) - LayoutBuilder( - builder: (context, constraints) { - return Align( - alignment: Alignment( - calculateReactionsHorizontalAlignment( - user, - widget.message, - constraints, - fontSize, - orientation, - ), - 0, - ), - child: StreamReactionPicker( - message: widget.message, - ), - ); - }, - ), - const SizedBox(height: 10), - IgnorePointer( - child: widget.messageWidget, - ), - const SizedBox(height: 8), - Padding( - padding: EdgeInsets.only( - left: widget.reverse ? 0 : 40, - ), - child: SizedBox( - width: mediaQueryData.size.width * 0.75, - child: Material( - color: streamChatThemeData.colorTheme.appBg, - clipBehavior: Clip.hardEdge, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(16), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - if (widget.showReplyMessage && - widget.message.state.isCompleted) - ReplyButton( - onTap: () { - Navigator.of(context).pop(); - if (widget.onReplyTap != null) { - widget.onReplyTap?.call(widget.message); - } - }, - ), - if (widget.showThreadReplyMessage && - (widget.message.state.isCompleted) && - widget.message.parentId == null) - ThreadReplyButton( - message: widget.message, - onThreadReplyTap: widget.onThreadReplyTap, - ), - if (widget.showMarkUnreadMessage) - MarkUnreadMessageButton(onTap: () async { - try { - await channel.markUnread(widget.message.id); - } catch (ex) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text( - context.translations.markUnreadError, - ), - ), - ); - } - - Navigator.of(context).pop(); - }), - if (widget.showResendMessage) - ResendMessageButton( - message: widget.message, - channel: channel, - ), - if (widget.showEditMessage) - EditMessageButton( - onTap: switch (widget.onEditMessageTap) { - final onTap? => () => onTap(widget.message), - _ => null, - }, - ), - if (widget.showCopyMessage) - CopyMessageButton( - onTap: () { - widget.onCopyTap?.call(widget.message); - }, - ), - if (widget.showFlagButton) - FlagMessageButton( - onTap: _showFlagDialog, - ), - if (widget.showPinButton) - PinMessageButton( - onTap: _togglePin, - pinned: widget.message.pinned, - ), - if (widget.showDeleteMessage) - DeleteMessageButton( - isDeleteFailed: - widget.message.state.isDeletingFailed, - onTap: _showDeleteBottomSheet, - ), - ...widget.customActions - .map((action) => _buildCustomAction( - context, - action, - )), - ].insertBetween( - Container( - height: 1, - color: streamChatThemeData.colorTheme.borders, - ), - ), - ), - ), - ), - ), - ], - ), - ), - ), - ), - ); - - return GestureDetector( - behavior: HitTestBehavior.translucent, - onTap: () => Navigator.of(context).maybePop(), - child: Stack( - children: [ - Positioned.fill( - child: BackdropFilter( - filter: ImageFilter.blur( - sigmaX: 10, - sigmaY: 10, - ), - child: ColoredBox( - color: streamChatThemeData.colorTheme.overlay, - ), - ), - ), - if (_showActions) - TweenAnimationBuilder( - tween: Tween(begin: 0, end: 1), - duration: const Duration(milliseconds: 300), - curve: Curves.easeInOutBack, - builder: (context, val, child) => Transform.scale( - scale: val, - child: child, - ), - child: child, - ), - ], - ), - ); - } - - InkWell _buildCustomAction( - BuildContext context, - StreamMessageAction messageAction, - ) { - return InkWell( - onTap: () => messageAction.onTap?.call(widget.message), - child: Padding( - padding: const EdgeInsets.symmetric(vertical: 11, horizontal: 16), - child: Row( - children: [ - messageAction.leading ?? const Empty(), - const SizedBox(width: 16), - messageAction.title ?? const Empty(), - ], - ), - ), - ); - } - - Future _showFlagDialog() async { - final client = StreamChat.of(context).client; - - final streamChatThemeData = StreamChatTheme.of(context); - final answer = await showConfirmationBottomSheet( - context, - title: context.translations.flagMessageLabel, - icon: StreamSvgIcon( - icon: StreamSvgIcons.flag, - color: streamChatThemeData.colorTheme.accentError, - size: 24, - ), - question: context.translations.flagMessageQuestion, - okText: context.translations.flagLabel, - cancelText: context.translations.cancelLabel, - ); - - final theme = streamChatThemeData; - if (answer == true) { - try { - await client.flagMessage(widget.message.id); - await showInfoBottomSheet( - context, - icon: StreamSvgIcon( - icon: StreamSvgIcons.flag, - color: theme.colorTheme.accentError, - size: 24, - ), - details: context.translations.flagMessageSuccessfulText, - title: context.translations.flagMessageSuccessfulLabel, - okText: context.translations.okLabel, - ); - } catch (err) { - if (err is StreamChatNetworkError && - err.errorCode == ChatErrorCode.inputError) { - await showInfoBottomSheet( - context, - icon: StreamSvgIcon( - icon: StreamSvgIcons.flag, - color: theme.colorTheme.accentError, - size: 24, - ), - details: context.translations.flagMessageSuccessfulText, - title: context.translations.flagMessageSuccessfulLabel, - okText: context.translations.okLabel, - ); - } else { - _showErrorAlertBottomSheet(); - } - } - } - } - - Future _togglePin() async { - final channel = StreamChannel.of(context).channel; - - Navigator.of(context).pop(); - try { - if (!widget.message.pinned) { - await channel.pinMessage(widget.message); - } else { - await channel.unpinMessage(widget.message); - } - } catch (e) { - _showErrorAlertBottomSheet(); - } - } - - /// Shows a "delete message" bottom sheet on mobile platforms. - Future _showDeleteBottomSheet() async { - setState(() => _showActions = false); - final answer = await showConfirmationBottomSheet( - context, - title: context.translations.deleteMessageLabel, - icon: StreamSvgIcon( - icon: StreamSvgIcons.flag, - color: StreamChatTheme.of(context).colorTheme.accentError, - size: 24, - ), - question: context.translations.deleteMessageQuestion, - okText: context.translations.deleteLabel, - cancelText: context.translations.cancelLabel, - ); - - if (answer == true) { - try { - Navigator.of(context).pop(); - final onConfirmDeleteTap = widget.onConfirmDeleteTap; - if (onConfirmDeleteTap != null) { - await onConfirmDeleteTap(widget.message); - } else { - await StreamChannel.of(context).channel.deleteMessage(widget.message); - } - } catch (err) { - _showErrorAlertBottomSheet(); - } - } else { - setState(() => _showActions = true); - } - } - - void _showErrorAlertBottomSheet() { - showInfoBottomSheet( - context, - icon: StreamSvgIcon( - icon: StreamSvgIcons.error, - color: StreamChatTheme.of(context).colorTheme.accentError, - size: 24, - ), - details: context.translations.operationCouldNotBeCompletedText, - title: context.translations.somethingWentWrongError, - okText: context.translations.okLabel, - ); - } -} diff --git a/packages/stream_chat_flutter/lib/src/message_actions_modal/moderated_message_actions_modal.dart b/packages/stream_chat_flutter/lib/src/message_actions_modal/moderated_message_actions_modal.dart deleted file mode 100644 index 6d9669e5d1..0000000000 --- a/packages/stream_chat_flutter/lib/src/message_actions_modal/moderated_message_actions_modal.dart +++ /dev/null @@ -1,99 +0,0 @@ -import 'dart:ui'; - -import 'package:flutter/material.dart'; -import 'package:stream_chat_flutter/src/icons/stream_svg_icon.dart'; -import 'package:stream_chat_flutter/src/theme/stream_chat_theme.dart'; -import 'package:stream_chat_flutter/src/utils/extensions.dart'; - -/// {@template moderatedMessageActionsModal} -/// A modal that is shown when a message is flagged by moderation policies. -/// -/// This modal allows users to: -/// - Send the message anyway, overriding the moderation warning -/// - Edit the message to comply with community guidelines -/// - Delete the message -/// -/// The modal provides clear guidance to users about the moderation issue -/// and options to address it. -/// {@endtemplate} -class ModeratedMessageActionsModal extends StatelessWidget { - /// {@macro moderatedMessageActionsModal} - const ModeratedMessageActionsModal({ - super.key, - this.onSendAnyway, - this.onEditMessage, - this.onDeleteMessage, - }); - - /// Callback function called when the user chooses to send the message - /// despite the moderation warning. - final VoidCallback? onSendAnyway; - - /// Callback function called when the user chooses to edit the message. - final VoidCallback? onEditMessage; - - /// Callback function called when the user chooses to delete the message. - final VoidCallback? onDeleteMessage; - - @override - Widget build(BuildContext context) { - final theme = StreamChatTheme.of(context); - final textTheme = theme.textTheme; - final colorTheme = theme.colorTheme; - - final actions = [ - TextButton( - onPressed: onSendAnyway, - style: TextButton.styleFrom( - textStyle: theme.textTheme.body, - foregroundColor: theme.colorTheme.accentPrimary, - disabledForegroundColor: theme.colorTheme.disabled, - ), - child: Text(context.translations.sendAnywayLabel), - ), - TextButton( - onPressed: onEditMessage, - style: TextButton.styleFrom( - textStyle: theme.textTheme.body, - foregroundColor: theme.colorTheme.accentPrimary, - disabledForegroundColor: theme.colorTheme.disabled, - ), - child: Text(context.translations.editMessageLabel), - ), - TextButton( - onPressed: onDeleteMessage, - style: TextButton.styleFrom( - textStyle: theme.textTheme.body, - foregroundColor: theme.colorTheme.accentPrimary, - disabledForegroundColor: theme.colorTheme.disabled, - ), - child: Text(context.translations.deleteMessageLabel), - ), - ]; - - return BackdropFilter( - filter: ImageFilter.blur(sigmaX: 5, sigmaY: 5), - child: AlertDialog( - clipBehavior: Clip.antiAlias, - backgroundColor: colorTheme.appBg, - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), - icon: const StreamSvgIcon(icon: StreamSvgIcons.flag), - iconColor: colorTheme.accentPrimary, - title: Text(context.translations.moderationReviewModalTitle), - titleTextStyle: textTheme.headline.copyWith( - color: colorTheme.textHighEmphasis, - ), - content: Text( - context.translations.moderationReviewModalDescription, - textAlign: TextAlign.center, - ), - contentTextStyle: textTheme.body.copyWith( - color: colorTheme.textLowEmphasis, - ), - actions: actions, - actionsAlignment: MainAxisAlignment.center, - actionsOverflowAlignment: OverflowBarAlignment.center, - ), - ); - } -} diff --git a/packages/stream_chat_flutter/lib/src/message_actions_modal/pin_message_button.dart b/packages/stream_chat_flutter/lib/src/message_actions_modal/pin_message_button.dart deleted file mode 100644 index 07313065ae..0000000000 --- a/packages/stream_chat_flutter/lib/src/message_actions_modal/pin_message_button.dart +++ /dev/null @@ -1,49 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:stream_chat_flutter/stream_chat_flutter.dart'; - -/// {@template pinMessageButton} -/// Allows a user to pin or unpin a message. -/// -/// Used by [MessageActionsModal]. Should not be used by itself. -/// {@endtemplate} -class PinMessageButton extends StatelessWidget { - /// {@macro pinMessageButton} - const PinMessageButton({ - super.key, - required this.onTap, - required this.pinned, - }); - - /// The callback to perform when the button is tapped. - final VoidCallback onTap; - - /// Whether the selected message is currently pinned or not. - final bool pinned; - - @override - Widget build(BuildContext context) { - final streamChatThemeData = StreamChatTheme.of(context); - return InkWell( - onTap: onTap, - child: Padding( - padding: const EdgeInsets.symmetric(vertical: 11, horizontal: 16), - child: Row( - children: [ - StreamSvgIcon( - icon: StreamSvgIcons.pin, - color: streamChatThemeData.primaryIconTheme.color, - size: 24, - ), - const SizedBox(width: 16), - Text( - context.translations.togglePinUnpinText( - pinned: pinned, - ), - style: streamChatThemeData.textTheme.body, - ), - ], - ), - ), - ); - } -} diff --git a/packages/stream_chat_flutter/lib/src/message_actions_modal/reply_button.dart b/packages/stream_chat_flutter/lib/src/message_actions_modal/reply_button.dart deleted file mode 100644 index b4340d8f96..0000000000 --- a/packages/stream_chat_flutter/lib/src/message_actions_modal/reply_button.dart +++ /dev/null @@ -1,42 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:stream_chat_flutter/stream_chat_flutter.dart'; - -/// {@template replyButton} -/// Allows a user to reply to a message. -/// -/// Used by [MessageActionsModal]. Should not be used by itself. -/// {@endtemplate} -class ReplyButton extends StatelessWidget { - /// {@macro replyButton} - const ReplyButton({ - super.key, - required this.onTap, - }); - - /// The callback to perform when the button is tapped. - final VoidCallback onTap; - - @override - Widget build(BuildContext context) { - final streamChatThemeData = StreamChatTheme.of(context); - return InkWell( - onTap: onTap, - child: Padding( - padding: const EdgeInsets.symmetric(vertical: 11, horizontal: 16), - child: Row( - children: [ - StreamSvgIcon( - icon: StreamSvgIcons.reply, - color: streamChatThemeData.primaryIconTheme.color, - ), - const SizedBox(width: 16), - Text( - context.translations.replyLabel, - style: streamChatThemeData.textTheme.body, - ), - ], - ), - ), - ); - } -} diff --git a/packages/stream_chat_flutter/lib/src/message_actions_modal/resend_message_button.dart b/packages/stream_chat_flutter/lib/src/message_actions_modal/resend_message_button.dart deleted file mode 100644 index 8c94c621ad..0000000000 --- a/packages/stream_chat_flutter/lib/src/message_actions_modal/resend_message_button.dart +++ /dev/null @@ -1,52 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:stream_chat_flutter/stream_chat_flutter.dart'; - -/// {@template resendMessageButton} -/// Allows a user to resend a message that has failed to be sent. -/// -/// Used by [MessageActionsModal]. Should not be used by itself. -/// {@endtemplate} -class ResendMessageButton extends StatelessWidget { - /// {@macro resendMessageButton} - const ResendMessageButton({ - super.key, - required this.message, - required this.channel, - }); - - /// The message to resend. - final Message message; - - /// The [StreamChannel] above this widget. - final Channel channel; - - @override - Widget build(BuildContext context) { - final isUpdateFailed = message.state.isUpdatingFailed; - final streamChatThemeData = StreamChatTheme.of(context); - return InkWell( - onTap: () { - Navigator.of(context).pop(); - channel.retryMessage(message); - }, - child: Padding( - padding: const EdgeInsets.symmetric(vertical: 11, horizontal: 16), - child: Row( - children: [ - StreamSvgIcon( - icon: StreamSvgIcons.circleUp, - color: streamChatThemeData.colorTheme.accentPrimary, - ), - const SizedBox(width: 16), - Text( - context.translations.toggleResendOrResendEditedMessage( - isUpdateFailed: isUpdateFailed, - ), - style: streamChatThemeData.textTheme.body, - ), - ], - ), - ), - ); - } -} diff --git a/packages/stream_chat_flutter/lib/src/message_actions_modal/thread_reply_button.dart b/packages/stream_chat_flutter/lib/src/message_actions_modal/thread_reply_button.dart deleted file mode 100644 index f5ffb4a357..0000000000 --- a/packages/stream_chat_flutter/lib/src/message_actions_modal/thread_reply_button.dart +++ /dev/null @@ -1,51 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:stream_chat_flutter/stream_chat_flutter.dart'; - -/// {@template threadReplyButton} -/// Allows a user to start a thread reply to a message. -/// -/// Used by [MessageActionsModal]. Should not be used by itself. -/// {@endtemplate} -class ThreadReplyButton extends StatelessWidget { - /// {@macro threadReplyButton} - const ThreadReplyButton({ - super.key, - required this.message, - this.onThreadReplyTap, - }); - - /// The message to start a thread reply to. - final Message message; - - /// The action to perform when "thread reply" is tapped - final OnMessageTap? onThreadReplyTap; - - @override - Widget build(BuildContext context) { - final streamChatThemeData = StreamChatTheme.of(context); - return InkWell( - onTap: () { - Navigator.of(context).pop(); - if (onThreadReplyTap != null) { - onThreadReplyTap?.call(message); - } - }, - child: Padding( - padding: const EdgeInsets.symmetric(vertical: 11, horizontal: 16), - child: Row( - children: [ - StreamSvgIcon( - icon: StreamSvgIcons.threadReply, - color: streamChatThemeData.primaryIconTheme.color, - ), - const SizedBox(width: 16), - Text( - context.translations.threadReplyLabel, - style: streamChatThemeData.textTheme.body, - ), - ], - ), - ), - ); - } -} diff --git a/packages/stream_chat_flutter/lib/src/message_input/attachment_button.dart b/packages/stream_chat_flutter/lib/src/message_input/attachment_button.dart deleted file mode 100644 index e77465572c..0000000000 --- a/packages/stream_chat_flutter/lib/src/message_input/attachment_button.dart +++ /dev/null @@ -1,59 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:stream_chat_flutter/src/message_input/stream_message_input_icon_button.dart'; -import 'package:stream_chat_flutter/stream_chat_flutter.dart'; - -/// {@template attachmentButton} -/// A button for adding attachments to a chat on mobile. -/// {@endtemplate} -class AttachmentButton extends StatelessWidget { - /// {@macro attachmentButton} - const AttachmentButton({ - super.key, - required this.onPressed, - this.color, - this.icon, - this.size = kDefaultMessageInputIconSize, - }); - - /// The color of the button. - /// Should be set if no [icon] is provided. - final Color? color; - - /// The callback to perform when the button is tapped or clicked. - final VoidCallback onPressed; - - /// The icon to display inside the button. - /// if not provided, a default icon will be used - /// and [color] property should be set. - final Widget? icon; - - /// The size of the button and splash radius. - final double size; - - /// Returns a copy of this object with the given fields updated. - AttachmentButton copyWith({ - Key? key, - Color? color, - VoidCallback? onPressed, - Widget? icon, - double? size, - }) { - return AttachmentButton( - key: key ?? this.key, - color: color ?? this.color, - onPressed: onPressed ?? this.onPressed, - icon: icon ?? this.icon, - size: size ?? this.size, - ); - } - - @override - Widget build(BuildContext context) { - return StreamMessageInputIconButton( - color: color, - iconSize: size, - onPressed: onPressed, - icon: icon ?? const StreamSvgIcon(icon: StreamSvgIcons.attach), - ); - } -} diff --git a/packages/stream_chat_flutter/lib/src/message_input/attachment_picker/options/options.dart b/packages/stream_chat_flutter/lib/src/message_input/attachment_picker/options/options.dart index 2e34169c56..f7c187f3ee 100644 --- a/packages/stream_chat_flutter/lib/src/message_input/attachment_picker/options/options.dart +++ b/packages/stream_chat_flutter/lib/src/message_input/attachment_picker/options/options.dart @@ -1,3 +1,4 @@ +export 'stream_command_picker.dart'; export 'stream_file_picker.dart'; export 'stream_gallery_picker.dart'; export 'stream_image_picker.dart'; diff --git a/packages/stream_chat_flutter/lib/src/message_input/attachment_picker/options/stream_command_icon.dart b/packages/stream_chat_flutter/lib/src/message_input/attachment_picker/options/stream_command_icon.dart new file mode 100644 index 0000000000..f0274326f0 --- /dev/null +++ b/packages/stream_chat_flutter/lib/src/message_input/attachment_picker/options/stream_command_icon.dart @@ -0,0 +1,33 @@ +import 'package:flutter/material.dart'; +import 'package:stream_chat_flutter/stream_chat_flutter.dart'; + +/// An icon widget for a chat command. +/// +/// Displays a 20px icon matching the given [command] name. +class StreamCommandIcon extends StatelessWidget { + /// Creates a [StreamCommandIcon]. + const StreamCommandIcon({super.key, required this.command}); + + /// The command whose icon is displayed. + final Command command; + + @override + Widget build(BuildContext context) { + final icons = context.streamIcons; + final colorScheme = context.streamColorScheme; + + return IconTheme.merge( + data: IconThemeData(size: 20, color: colorScheme.textSecondary), + child: switch (command.name) { + 'giphy' => StreamSvgIcon(icon: icons.giphy), + 'imgur' => StreamSvgIcon(icon: icons.imgur), + 'ban' => Icon(icons.userRemove), + 'flag' => Icon(icons.flag), + 'mute' => Icon(icons.mute), + 'unban' => Icon(icons.userAdd), + 'unmute' => Icon(icons.audio), + _ => Icon(icons.bolt), + }, + ); + } +} diff --git a/packages/stream_chat_flutter/lib/src/message_input/attachment_picker/options/stream_command_picker.dart b/packages/stream_chat_flutter/lib/src/message_input/attachment_picker/options/stream_command_picker.dart new file mode 100644 index 0000000000..ed6c5b8bdf --- /dev/null +++ b/packages/stream_chat_flutter/lib/src/message_input/attachment_picker/options/stream_command_picker.dart @@ -0,0 +1,76 @@ +import 'package:flutter/material.dart'; +import 'package:stream_chat_flutter/src/message_input/attachment_picker/options/stream_command_icon.dart'; +import 'package:stream_chat_flutter/stream_chat_flutter.dart'; + +/// Widget shown in the attachment picker for browsing and selecting commands. +class StreamCommandPicker extends StatelessWidget { + /// Creates a [StreamCommandPicker] widget. + const StreamCommandPicker({ + super.key, + this.onCommandSelected, + }); + + /// Callback called when a command is selected. + final ValueSetter? onCommandSelected; + + @override + Widget build(BuildContext context) { + final channel = StreamChannel.of(context).channel; + final commands = channel.config?.commands ?? const []; + + final spacing = context.streamSpacing; + + final textTheme = context.streamTextTheme; + final colorScheme = context.streamColorScheme; + + return OptionDrawer( + margin: EdgeInsets.zero, + child: Material( + type: .transparency, + child: Column( + spacing: spacing.md, + crossAxisAlignment: .start, + children: [ + Padding( + padding: .symmetric(horizontal: spacing.md), + child: Text(context.translations.instantCommandsLabel, style: textTheme.headingSm), + ), + Expanded( + child: ListView.builder( + padding: .symmetric(horizontal: spacing.xxs, vertical: spacing.xxxs), + itemCount: commands.length, + itemBuilder: (context, index) { + final command = commands[index]; + return InkWell( + onTap: onCommandSelected == null ? null : () => onCommandSelected!(command), + child: Padding( + padding: .symmetric(horizontal: spacing.sm, vertical: spacing.xs), + child: Row( + spacing: spacing.sm, + children: [ + StreamCommandIcon(command: command), + Text( + command.name.sentenceCase, + style: textTheme.bodyEmphasis.copyWith(color: colorScheme.textPrimary), + ), + Expanded( + child: Text( + '/${command.name} ${command.args}', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: textTheme.bodyDefault.copyWith(color: colorScheme.textTertiary), + ), + ), + ], + ), + ), + ); + }, + ), + ), + ], + ), + ), + ); + } +} diff --git a/packages/stream_chat_flutter/lib/src/message_input/attachment_picker/options/stream_file_picker.dart b/packages/stream_chat_flutter/lib/src/message_input/attachment_picker/options/stream_file_picker.dart index c410532d2d..1999882a06 100644 --- a/packages/stream_chat_flutter/lib/src/message_input/attachment_picker/options/stream_file_picker.dart +++ b/packages/stream_chat_flutter/lib/src/message_input/attachment_picker/options/stream_file_picker.dart @@ -1,12 +1,10 @@ import 'package:file_picker/file_picker.dart'; import 'package:flutter/material.dart'; -import 'package:photo_manager/photo_manager.dart'; import 'package:stream_chat_flutter/src/attachment/handler/stream_attachment_handler.dart'; -import 'package:stream_chat_flutter/src/icons/stream_svg_icon.dart'; import 'package:stream_chat_flutter/src/message_input/attachment_picker/stream_attachment_picker.dart'; -import 'package:stream_chat_flutter/src/theme/stream_chat_theme.dart'; import 'package:stream_chat_flutter/src/utils/utils.dart'; import 'package:stream_chat_flutter_core/stream_chat_flutter_core.dart'; +import 'package:stream_core_flutter/stream_core_flutter.dart'; /// Widget used to pick files from the device class StreamFilePicker extends StatelessWidget { @@ -19,7 +17,7 @@ class StreamFilePicker extends StatelessWidget { this.type = FileType.any, this.allowedExtensions, this.onFileLoading, - this.allowCompression = true, + this.compressionQuality = 0, this.withData = false, this.withReadStream = false, this.lockParentWindow = false, @@ -43,8 +41,8 @@ class StreamFilePicker extends StatelessWidget { /// Callback called when the file picker is loading a file. final Function(FilePickerStatus)? onFileLoading; - /// Whether to allow compression of the file. - final bool allowCompression; + /// The compression quality for the file. + final int compressionQuality; /// Whether to include the file data in the [Attachment]. final bool withData; @@ -57,60 +55,59 @@ class StreamFilePicker extends StatelessWidget { @override Widget build(BuildContext context) { - final theme = StreamChatTheme.of(context); + final spacing = context.streamSpacing; + final textTheme = context.streamTextTheme; + final colorScheme = context.streamColorScheme; + + Future onPickFile() async { + final pickedFile = await runInPermissionRequestLock(() { + return StreamAttachmentHandler.instance.pickFile( + dialogTitle: dialogTitle, + initialDirectory: initialDirectory, + type: type, + allowedExtensions: allowedExtensions, + onFileLoading: onFileLoading, + compressionQuality: compressionQuality, + withData: withData, + withReadStream: withReadStream, + lockParentWindow: lockParentWindow, + ); + }); + + return onFilePicked.call(pickedFile); + } + return OptionDrawer( child: EndOfFrameCallbackWidget( - child: StreamSvgIcon( - size: 240, - icon: StreamSvgIcons.files, - color: theme.colorTheme.disabled, - ), - onEndOfFrame: (_) async { - final pickedFile = await runInPermissionRequestLock(() { - return StreamAttachmentHandler.instance.pickFile( - dialogTitle: dialogTitle, - initialDirectory: initialDirectory, - type: type, - allowedExtensions: allowedExtensions, - onFileLoading: onFileLoading, - allowCompression: allowCompression, - withData: withData, - withReadStream: withReadStream, - lockParentWindow: lockParentWindow, - ); - }); - - onFilePicked.call(pickedFile); - }, - errorBuilder: (context, error, stacktrace) { - return Column( + child: Center( + child: Column( + crossAxisAlignment: CrossAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center, children: [ - StreamSvgIcon( - size: 240, - icon: StreamSvgIcons.files, - color: theme.colorTheme.disabled, + Icon( + size: 32, + context.streamIcons.file, + color: colorScheme.textTertiary, ), + SizedBox(height: spacing.xs), Text( - context.translations.enablePhotoAndVideoAccessMessage, - style: theme.textTheme.body.copyWith( - color: theme.colorTheme.textLowEmphasis, + context.translations.selectFilesToShareLabel, + style: textTheme.bodyDefault.copyWith( + color: colorScheme.textSecondary, ), textAlign: TextAlign.center, ), - const SizedBox(height: 8), - TextButton( - onPressed: PhotoManager.openSetting, - child: Text( - context.translations.allowGalleryAccessMessage, - style: theme.textTheme.bodyBold.copyWith( - color: theme.colorTheme.accentPrimary, - ), - ), + SizedBox(height: spacing.md), + StreamButton( + type: .outline, + style: .secondary, + onPressed: onPickFile, + child: Text(context.translations.openFilesLabel), ), ], - ); - }, + ), + ), + onEndOfFrame: (_) => onPickFile(), ), ); } diff --git a/packages/stream_chat_flutter/lib/src/message_input/attachment_picker/options/stream_gallery_picker.dart b/packages/stream_chat_flutter/lib/src/message_input/attachment_picker/options/stream_gallery_picker.dart index 905df5f48c..c6d5f19a30 100644 --- a/packages/stream_chat_flutter/lib/src/message_input/attachment_picker/options/stream_gallery_picker.dart +++ b/packages/stream_chat_flutter/lib/src/message_input/attachment_picker/options/stream_gallery_picker.dart @@ -4,17 +4,17 @@ import 'dart:math' as math; import 'package:flutter/material.dart'; import 'package:path_provider/path_provider.dart'; import 'package:photo_manager/photo_manager.dart'; -import 'package:stream_chat_flutter/src/icons/stream_svg_icon.dart'; import 'package:stream_chat_flutter/src/message_input/attachment_picker/stream_attachment_picker.dart'; +import 'package:stream_chat_flutter/src/message_input/attachment_picker/stream_attachment_picker_controller.dart'; import 'package:stream_chat_flutter/src/misc/empty_widget.dart'; import 'package:stream_chat_flutter/src/scroll_view/photo_gallery/stream_photo_gallery.dart'; import 'package:stream_chat_flutter/src/scroll_view/photo_gallery/stream_photo_gallery_controller.dart'; -import 'package:stream_chat_flutter/src/theme/stream_chat_theme.dart'; import 'package:stream_chat_flutter/src/utils/utils.dart'; import 'package:stream_chat_flutter_core/stream_chat_flutter_core.dart'; +import 'package:stream_core_flutter/stream_core_flutter.dart'; /// Max image resolution which can be resized by the CDN. -// Taken from https://getstream.io/chat/docs/flutter-dart/file_uploads/?language=dart#image-resizing +/// Taken from https://getstream.io/chat/docs/flutter-dart/file_uploads/?language=dart#image-resizing const maxCDNImageResolution = 16800000; /// Widget used to pick media from the device gallery. @@ -23,42 +23,23 @@ class StreamGalleryPicker extends StatefulWidget { const StreamGalleryPicker({ super.key, this.limit = 50, + GalleryPickerConfig? config, required this.selectedMediaItems, required this.onMediaItemSelected, - this.mediaThumbnailSize = const ThumbnailSize(400, 400), - this.mediaThumbnailFormat = ThumbnailFormat.jpeg, - this.mediaThumbnailQuality = 100, - this.mediaThumbnailScale = 1, - }); + }) : config = config ?? const GalleryPickerConfig(); /// Maximum number of media items that can be selected. final int limit; + /// Configuration for the gallery picker. + final GalleryPickerConfig config; + /// List of selected media items. final Iterable selectedMediaItems; /// Callback called when an media item is selected. final ValueSetter onMediaItemSelected; - /// Size of the attachment thumbnails. - /// - /// Defaults to (400, 400). - final ThumbnailSize mediaThumbnailSize; - - /// Format of the attachment thumbnails. - /// - /// Defaults to [ThumbnailFormat.jpeg]. - final ThumbnailFormat mediaThumbnailFormat; - - /// The quality value for the attachment thumbnails. - /// - /// Valid from 1 to 100. - /// Defaults to 100. - final int mediaThumbnailQuality; - - /// The scale to apply on the [attachmentThumbnailSize]. - final double mediaThumbnailScale; - @override State createState() => _StreamGalleryPickerState(); } @@ -71,9 +52,7 @@ class _StreamGalleryPickerState extends State { void initState() { super.initState(); _controller = StreamPhotoGalleryController(limit: widget.limit); - requestPermission = runInPermissionRequestLock( - PhotoManager.requestPermissionExtend, - ); + requestPermission = runInPermissionRequestLock(PhotoManager.requestPermissionExtend); } @override @@ -98,9 +77,9 @@ class _StreamGalleryPickerState extends State { builder: (context, snapshot) { if (!snapshot.hasData) return const Empty(); - final theme = StreamChatTheme.of(context); - final textTheme = theme.textTheme; - final colorTheme = theme.colorTheme; + final spacing = context.streamSpacing; + final textTheme = context.streamTextTheme; + final colorScheme = context.streamColorScheme; // Available on both Android and iOS. final isAuthorized = snapshot.data == PermissionState.authorized; @@ -110,46 +89,35 @@ class _StreamGalleryPickerState extends State { final isPermissionGranted = isAuthorized || isLimited; return OptionDrawer( - actions: [ - if (isLimited) - IconButton( - color: colorTheme.accentPrimary, - icon: const Icon(Icons.add_circle_outline_rounded), - onPressed: () async { - await PhotoManager.presentLimited(); - _controller.doInitialLoad(); - }, - ), - ], + margin: .zero, child: Builder( builder: (context) { if (!isPermissionGranted) { - return Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - StreamSvgIcon( - size: 240, - icon: StreamSvgIcons.pictures, - color: colorTheme.disabled, - ), - Text( - context.translations.enablePhotoAndVideoAccessMessage, - style: textTheme.body.copyWith( - color: colorTheme.textLowEmphasis, + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + size: 32, + context.streamIcons.imageLarge, + color: colorScheme.textTertiary, ), - textAlign: TextAlign.center, - ), - const SizedBox(height: 8), - TextButton( - onPressed: PhotoManager.openSetting, - child: Text( - context.translations.allowGalleryAccessMessage, - style: textTheme.bodyBold.copyWith( - color: colorTheme.accentPrimary, - ), + SizedBox(height: spacing.xs), + Text( + context.translations.enablePhotoAndVideoAccessMessage, + style: textTheme.bodyDefault.copyWith(color: colorScheme.textSecondary), + textAlign: TextAlign.center, ), - ), - ], + SizedBox(height: spacing.md), + StreamButton( + size: .medium, + type: .outline, + style: .secondary, + onPressed: PhotoManager.openSetting, + child: Text(context.translations.allowGalleryAccessMessage), + ), + ], + ), ); } @@ -158,11 +126,16 @@ class _StreamGalleryPickerState extends State { controller: _controller, onMediaTap: widget.onMediaItemSelected, loadMoreTriggerIndex: 10, - padding: const EdgeInsets.all(2), - thumbnailSize: widget.mediaThumbnailSize, - thumbnailFormat: widget.mediaThumbnailFormat, - thumbnailQuality: widget.mediaThumbnailQuality, - thumbnailScale: widget.mediaThumbnailScale, + thumbnailSize: widget.config.mediaThumbnailSize, + thumbnailFormat: widget.config.mediaThumbnailFormat, + thumbnailQuality: widget.config.mediaThumbnailQuality, + thumbnailScale: widget.config.mediaThumbnailScale, + addMoreBuilder: switch (isLimited) { + true => (context) => _AddMoreTile( + onTap: () => PhotoManager.presentLimited().then((_) => _controller.doInitialLoad()).ignore(), + ), + _ => null, + }, itemBuilder: (context, mediaItems, index, defaultWidget) { final media = mediaItems[index]; return defaultWidget.copyWith( @@ -178,6 +151,76 @@ class _StreamGalleryPickerState extends State { } } +class _AddMoreTile extends StatelessWidget { + const _AddMoreTile({required this.onTap}); + + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + final colorScheme = context.streamColorScheme; + final textTheme = context.streamTextTheme; + final spacing = context.streamSpacing; + + return Material( + color: colorScheme.backgroundSurfaceCard, + child: InkWell( + onTap: onTap, + overlayColor: WidgetStateProperty.resolveWith((states) { + if (states.contains(WidgetState.pressed)) return colorScheme.backgroundPressed; + if (states.contains(WidgetState.hovered)) return colorScheme.backgroundHover; + return StreamColors.transparent; + }), + child: Padding( + padding: .all(spacing.xs), + child: Column( + spacing: spacing.xs, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + size: 20, + context.streamIcons.plus, + color: colorScheme.textTertiary, + ), + Text( + context.translations.addMoreFilesLabel, + style: textTheme.captionEmphasis.copyWith(color: colorScheme.textTertiary), + textAlign: TextAlign.center, + ), + ], + ), + ), + ), + ); + } +} + +/// Configuration for the [StreamGalleryPicker]. +class GalleryPickerConfig { + /// Creates a [GalleryPickerConfig] instance. + const GalleryPickerConfig({ + this.mediaThumbnailSize, + this.mediaThumbnailFormat = ThumbnailFormat.jpeg, + this.mediaThumbnailQuality = 100, + this.mediaThumbnailScale = 1, + }); + + /// Size of the attachment thumbnails in pixels. + /// + /// When null (the default), each tile auto-calculates its size from its + /// own layout constraints and the device pixel ratio. + final ThumbnailSize? mediaThumbnailSize; + + /// Format of the attachment thumbnails. + final ThumbnailFormat mediaThumbnailFormat; + + /// The quality value for the attachment thumbnails. + final int mediaThumbnailQuality; + + /// The scale to apply on the [mediaThumbnailSize]. + final double mediaThumbnailScale; +} + /// extension StreamImagePickerX on StreamAttachmentPickerController { /// @@ -227,6 +270,10 @@ extension StreamImagePickerX on StreamAttachmentPickerController { extraDataMap['file_size'] = file.size!; + if (type == AssetType.video) { + extraDataMap['duration'] = asset.videoDuration.inSeconds; + } + final attachment = Attachment( id: asset.id, file: file, @@ -243,8 +290,7 @@ extension StreamImagePickerX on StreamAttachmentPickerController { final image = await asset.originFile; if (image != null) { final tempDir = await getTemporaryDirectory(); - final cachedFile = - File('${tempDir.path}/${image.path.split('/').last}'); + final cachedFile = File('${tempDir.path}/${image.path.split('/').last}'); if (cachedFile.existsSync()) { cachedFile.deleteSync(); } diff --git a/packages/stream_chat_flutter/lib/src/message_input/attachment_picker/options/stream_image_picker.dart b/packages/stream_chat_flutter/lib/src/message_input/attachment_picker/options/stream_image_picker.dart index a9130ea73b..de36f89ce8 100644 --- a/packages/stream_chat_flutter/lib/src/message_input/attachment_picker/options/stream_image_picker.dart +++ b/packages/stream_chat_flutter/lib/src/message_input/attachment_picker/options/stream_image_picker.dart @@ -36,52 +36,74 @@ class StreamImagePicker extends StatelessWidget { @override Widget build(BuildContext context) { - final theme = StreamChatTheme.of(context); + final spacing = context.streamSpacing; + final textTheme = context.streamTextTheme; + final colorScheme = context.streamColorScheme; + + Future onPickImage() async { + final pickedImage = await runInPermissionRequestLock(() { + return StreamAttachmentHandler.instance.pickImage( + source: source, + maxWidth: maxWidth, + maxHeight: maxHeight, + imageQuality: imageQuality, + preferredCameraDevice: preferredCameraDevice, + ); + }); + + return onImagePicked.call(pickedImage); + } + return OptionDrawer( child: EndOfFrameCallbackWidget( - child: StreamSvgIcon( - size: 240, - icon: StreamSvgIcons.camera, - color: theme.colorTheme.disabled, + child: Center( + child: Column( + crossAxisAlignment: CrossAxisAlignment.center, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + size: 32, + context.streamIcons.cameraLarge, + color: colorScheme.textTertiary, + ), + SizedBox(height: spacing.xs), + Text( + context.translations.takePhotoAndShareLabel, + style: textTheme.bodyDefault.copyWith(color: colorScheme.textSecondary), + textAlign: TextAlign.center, + ), + SizedBox(height: spacing.md), + StreamButton( + type: .outline, + style: .secondary, + onPressed: onPickImage, + child: Text(context.translations.openCameraLabel), + ), + ], + ), ), - onEndOfFrame: (_) async { - final pickedImage = await runInPermissionRequestLock(() { - return StreamAttachmentHandler.instance.pickImage( - source: source, - maxWidth: maxWidth, - maxHeight: maxHeight, - imageQuality: imageQuality, - preferredCameraDevice: preferredCameraDevice, - ); - }); - - onImagePicked.call(pickedImage); - }, + onEndOfFrame: (_) => onPickImage(), errorBuilder: (context, error, stacktrace) { return Column( mainAxisAlignment: MainAxisAlignment.center, children: [ - StreamSvgIcon( - size: 240, - icon: StreamSvgIcons.camera, - color: theme.colorTheme.disabled, + Icon( + size: 32, + context.streamIcons.cameraLarge, + color: colorScheme.textTertiary, ), + SizedBox(height: spacing.xs), Text( context.translations.enablePhotoAndVideoAccessMessage, - style: theme.textTheme.body.copyWith( - color: theme.colorTheme.textLowEmphasis, - ), + style: textTheme.bodyDefault.copyWith(color: colorScheme.textSecondary), textAlign: TextAlign.center, ), - const SizedBox(height: 8), - TextButton( + SizedBox(height: spacing.md), + StreamButton( + type: .outline, + style: .secondary, onPressed: PhotoManager.openSetting, - child: Text( - context.translations.allowGalleryAccessMessage, - style: theme.textTheme.bodyBold.copyWith( - color: theme.colorTheme.accentPrimary, - ), - ), + child: Text(context.translations.allowGalleryAccessMessage), ), ], ); diff --git a/packages/stream_chat_flutter/lib/src/message_input/attachment_picker/options/stream_poll_creator.dart b/packages/stream_chat_flutter/lib/src/message_input/attachment_picker/options/stream_poll_creator.dart index ecf95fe96d..539b1219e7 100644 --- a/packages/stream_chat_flutter/lib/src/message_input/attachment_picker/options/stream_poll_creator.dart +++ b/packages/stream_chat_flutter/lib/src/message_input/attachment_picker/options/stream_poll_creator.dart @@ -22,48 +22,49 @@ class StreamPollCreator extends StatelessWidget { @override Widget build(BuildContext context) { - final theme = StreamChatTheme.of(context); + final spacing = context.streamSpacing; + final textTheme = context.streamTextTheme; + final colorScheme = context.streamColorScheme; Future _openCreatePollFlow() async { - final result = await showStreamPollCreatorDialog( + final result = await showStreamPollCreatorSheet( context: context, poll: poll, config: config, ); - onPollCreated?.call(result); + return onPollCreated?.call(result); } return OptionDrawer( child: EndOfFrameCallbackWidget( - child: StreamSvgIcon( - size: 180, - icon: StreamSvgIcons.polls, - color: theme.colorTheme.disabled, - ), - onEndOfFrame: (_) => _openCreatePollFlow(), - errorBuilder: (context, error, stacktrace) { - return Column( + child: Center( + child: Column( + crossAxisAlignment: CrossAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center, children: [ - StreamSvgIcon( - size: 240, - icon: StreamSvgIcons.polls, - color: theme.colorTheme.disabled, + Icon( + size: 32, + context.streamIcons.pollLarge, + color: colorScheme.textTertiary, + ), + SizedBox(height: spacing.xs), + Text( + context.translations.createPollPromptLabel, + style: textTheme.bodyDefault.copyWith(color: colorScheme.textSecondary), + textAlign: TextAlign.center, ), - const SizedBox(height: 8), - TextButton( + SizedBox(height: spacing.md), + StreamButton( + type: .outline, + style: .secondary, onPressed: _openCreatePollFlow, - child: Text( - context.translations.createPollLabel(isNew: true), - style: theme.textTheme.bodyBold.copyWith( - color: theme.colorTheme.accentPrimary, - ), - ), + child: Text(context.translations.createPollLabel()), ), ], - ); - }, + ), + ), + onEndOfFrame: (_) => _openCreatePollFlow(), ), ); } diff --git a/packages/stream_chat_flutter/lib/src/message_input/attachment_picker/options/stream_video_picker.dart b/packages/stream_chat_flutter/lib/src/message_input/attachment_picker/options/stream_video_picker.dart index 4ba4e8f7b6..8b275ae9ca 100644 --- a/packages/stream_chat_flutter/lib/src/message_input/attachment_picker/options/stream_video_picker.dart +++ b/packages/stream_chat_flutter/lib/src/message_input/attachment_picker/options/stream_video_picker.dart @@ -28,50 +28,74 @@ class StreamVideoPicker extends StatelessWidget { @override Widget build(BuildContext context) { - final theme = StreamChatTheme.of(context); + final spacing = context.streamSpacing; + final textTheme = context.streamTextTheme; + final colorScheme = context.streamColorScheme; + + Future onPickVideo() async { + final pickedVideo = await runInPermissionRequestLock(() { + return StreamAttachmentHandler.instance.pickVideo( + source: source, + preferredCameraDevice: preferredCameraDevice, + maxDuration: maxDuration, + ); + }); + + return onVideoPicked.call(pickedVideo); + } + return OptionDrawer( child: EndOfFrameCallbackWidget( - child: StreamSvgIcon( - size: 240, - icon: StreamSvgIcons.record, - color: theme.colorTheme.disabled, + child: Center( + child: Column( + crossAxisAlignment: CrossAxisAlignment.center, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + size: 32, + context.streamIcons.video, + color: colorScheme.textTertiary, + ), + SizedBox(height: spacing.xs), + Text( + context.translations.takeVideoAndShareLabel, + style: textTheme.bodyDefault.copyWith(color: colorScheme.textSecondary), + textAlign: TextAlign.center, + ), + SizedBox(height: spacing.md), + StreamButton( + type: .outline, + style: .secondary, + onPressed: onPickVideo, + child: Text(context.translations.openCameraLabel), + ), + ], + ), ), - onEndOfFrame: (_) async { - final pickedVideo = await runInPermissionRequestLock(() { - return StreamAttachmentHandler.instance.pickVideo( - source: source, - preferredCameraDevice: preferredCameraDevice, - maxDuration: maxDuration, - ); - }); - - onVideoPicked.call(pickedVideo); - }, + onEndOfFrame: (_) => onPickVideo(), errorBuilder: (context, error, stacktrace) { return Column( mainAxisAlignment: MainAxisAlignment.center, children: [ - StreamSvgIcon( - size: 240, - icon: StreamSvgIcons.record, - color: theme.colorTheme.disabled, + Icon( + size: 32, + context.streamIcons.video, + color: colorScheme.textTertiary, ), + SizedBox(height: spacing.xs), Text( context.translations.enablePhotoAndVideoAccessMessage, - style: theme.textTheme.body.copyWith( - color: theme.colorTheme.textLowEmphasis, + style: textTheme.bodyDefault.copyWith( + color: colorScheme.textSecondary, ), textAlign: TextAlign.center, ), - const SizedBox(height: 8), - TextButton( + SizedBox(height: spacing.md), + StreamButton( + type: .outline, + style: .secondary, onPressed: PhotoManager.openSetting, - child: Text( - context.translations.allowGalleryAccessMessage, - style: theme.textTheme.bodyBold.copyWith( - color: theme.colorTheme.accentPrimary, - ), - ), + child: Text(context.translations.allowGalleryAccessMessage), ), ], ); diff --git a/packages/stream_chat_flutter/lib/src/message_input/attachment_picker/stream_attachment_picker.dart b/packages/stream_chat_flutter/lib/src/message_input/attachment_picker/stream_attachment_picker.dart index 97f78ae088..0204df1e01 100644 --- a/packages/stream_chat_flutter/lib/src/message_input/attachment_picker/stream_attachment_picker.dart +++ b/packages/stream_chat_flutter/lib/src/message_input/attachment_picker/stream_attachment_picker.dart @@ -1,468 +1,127 @@ import 'dart:async'; -import 'package:collection/collection.dart'; +import 'package:file_picker/file_picker.dart' show FileType; import 'package:flutter/material.dart'; import 'package:stream_chat_flutter/src/message_input/attachment_picker/options/options.dart'; import 'package:stream_chat_flutter/src/misc/empty_widget.dart'; import 'package:stream_chat_flutter/stream_chat_flutter.dart'; -/// The default maximum size for media attachments. -const kDefaultMaxAttachmentSize = 100 * 1024 * 1024; // 100MB in Bytes - -/// The default maximum number of media attachments. -const kDefaultMaxAttachmentCount = 10; - -/// Value class for [AttachmentPickerController]. +/// Inline widget for the system attachment picker interface. /// -/// This class holds the list of [Poll] and [Attachment] objects. -class AttachmentPickerValue { - /// Creates a new instance of [AttachmentPickerValue]. - const AttachmentPickerValue({ - this.poll, - this.attachments = const [], - }); - - /// The poll object. - final Poll? poll; - - /// The list of [Attachment] objects. - final List attachments; - - /// Returns a copy of this object with the provided values. - AttachmentPickerValue copyWith({ - Poll? poll, - List? attachments, - }) { - return AttachmentPickerValue( - poll: poll ?? this.poll, - attachments: attachments ?? this.attachments, - ); - } -} - -/// Controller class for [StreamAttachmentPicker]. -class StreamAttachmentPickerController - extends ValueNotifier { - /// Creates a new instance of [StreamAttachmentPickerController]. - StreamAttachmentPickerController({ - this.initialPoll, - this.initialAttachments, - this.maxAttachmentSize = kDefaultMaxAttachmentSize, - this.maxAttachmentCount = kDefaultMaxAttachmentCount, - }) : assert( - (initialAttachments?.length ?? 0) <= maxAttachmentCount, - '''The initial attachments count must be less than or equal to maxAttachmentCount''', - ), - super( - AttachmentPickerValue( - poll: initialPoll, - attachments: initialAttachments ?? const [], - ), - ); - - /// The max attachment size allowed in bytes. - final int maxAttachmentSize; - - /// The max attachment count allowed. - final int maxAttachmentCount; - - /// The initial poll. - final Poll? initialPoll; - - /// The initial attachments. - final List? initialAttachments; - - @override - set value(AttachmentPickerValue newValue) { - if (newValue.attachments.length > maxAttachmentCount) { - throw ArgumentError( - 'The maximum number of attachments is $maxAttachmentCount.', - ); - } - super.value = newValue; - } - - /// Adds a new [poll] to the message. - set poll(Poll poll) { - value = value.copyWith(poll: poll); - } - - Future _saveToCache(AttachmentFile file) async { - // Cache the attachment in a temporary file. - return StreamAttachmentHandler.instance.saveAttachmentFile( - attachmentFile: file, - ); - } - - Future _removeFromCache(AttachmentFile file) { - // Remove the cached attachment file. - return StreamAttachmentHandler.instance.deleteAttachmentFile( - attachmentFile: file, - ); - } - - /// Adds a new attachment to the message. - Future addAttachment(Attachment attachment) async { - assert(attachment.fileSize != null, ''); - if (attachment.fileSize! > maxAttachmentSize) { - throw ArgumentError( - 'The size of the attachment is ${attachment.fileSize} bytes, ' - 'but the maximum size allowed is $maxAttachmentSize bytes.', - ); - } - - final file = attachment.file; - final uploadState = attachment.uploadState; - - // No need to cache the attachment if it's already uploaded - // or we are on web. - if (file == null || uploadState.isSuccess || isWeb) { - value = value.copyWith(attachments: [...value.attachments, attachment]); - return; - } - - // Cache the attachment in a temporary file. - final tempFilePath = await _saveToCache(file); - - value = value.copyWith(attachments: [ - ...value.attachments, - attachment.copyWith( - file: file.copyWith( - path: tempFilePath, - ), - ), - ]); - } - - /// Removes the specified [attachment] from the message. - Future removeAttachment(Attachment attachment) async { - final file = attachment.file; - final uploadState = attachment.uploadState; - - if (file == null || uploadState.isSuccess || isWeb) { - final updatedAttachments = [...value.attachments]..remove(attachment); - value = value.copyWith(attachments: updatedAttachments); - return; - } - - // Remove the cached attachment file. - await _removeFromCache(file); - - final updatedAttachments = [...value.attachments]..remove(attachment); - value = value.copyWith(attachments: updatedAttachments); - } - - /// Remove the attachment with the given [attachmentId]. - void removeAttachmentById(String attachmentId) { - final attachment = value.attachments.firstWhereOrNull( - (attachment) => attachment.id == attachmentId, - ); - - if (attachment == null) return; - - removeAttachment(attachment); - } - - /// Clears all the attachments. - Future clear() async { - final attachments = [...value.attachments]; - for (final attachment in attachments) { - final file = attachment.file; - final uploadState = attachment.uploadState; - - if (file == null || uploadState.isSuccess || isWeb) continue; - - await _removeFromCache(file); - } - value = const AttachmentPickerValue(); - } - - /// Resets the controller to its initial state. - Future reset() async { - final attachments = [...value.attachments]; - for (final attachment in attachments) { - final file = attachment.file; - final uploadState = attachment.uploadState; - - if (file == null || uploadState.isSuccess || isWeb) continue; - - await _removeFromCache(file); - } - - value = AttachmentPickerValue( - poll: initialPoll, - attachments: initialAttachments ?? const [], - ); - } -} - -/// The possible picker types of the attachment picker. -enum AttachmentPickerType { - /// The attachment picker will only allow to pick images. - images, - - /// The attachment picker will only allow to pick videos. - videos, - - /// The attachment picker will only allow to pick audios. - audios, - - /// The attachment picker will only allow to pick files or documents. - files, - - /// The attachment picker will only allow to create poll. - poll, -} - -/// Function signature for building the attachment picker option view. -typedef AttachmentPickerOptionViewBuilder = Widget Function( - BuildContext context, - StreamAttachmentPickerController controller, -); - -/// Model class for the attachment picker options. -class AttachmentPickerOption { - /// Creates a new instance of [AttachmentPickerOption]. - const AttachmentPickerOption({ - this.key, - required this.supportedTypes, - required this.icon, - this.title, - this.optionViewBuilder, - }); - - /// A key to identify the option. - final String? key; - - /// The icon of the option. - final Widget icon; - - /// The title of the option. - final String? title; - - /// The supported types of the option. - final Iterable supportedTypes; - - /// The option view builder. - final AttachmentPickerOptionViewBuilder? optionViewBuilder; - - @override - bool operator ==(Object other) => - identical(this, other) || - other is AttachmentPickerOption && - runtimeType == other.runtimeType && - key == other.key && - const IterableEquality().equals(supportedTypes, other.supportedTypes); - - @override - int get hashCode => - key.hashCode ^ const IterableEquality().hash(supportedTypes); -} - -/// The attachment picker option for the web or desktop platforms. -class WebOrDesktopAttachmentPickerOption extends AttachmentPickerOption { - /// Creates a new instance of [WebOrDesktopAttachmentPickerOption]. - WebOrDesktopAttachmentPickerOption({ - super.key, - required AttachmentPickerType type, - required super.icon, - required super.title, - }) : super(supportedTypes: [type]); - - /// Creates a new instance of [WebOrDesktopAttachmentPickerOption] from - /// [option]. - factory WebOrDesktopAttachmentPickerOption.fromAttachmentPickerOption( - AttachmentPickerOption option, - ) { - return WebOrDesktopAttachmentPickerOption( - key: option.key, - type: option.supportedTypes.first, - icon: option.icon, - title: option.title, - ); - } - - @override - String get title => super.title!; - - /// Type of the option. - AttachmentPickerType get type => supportedTypes.first; -} - -/// Helpful extensions for [StreamAttachmentPickerController]. -extension AttachmentPickerOptionTypeX on StreamAttachmentPickerController { - /// Returns the list of available attachment picker options. - Set get currentAttachmentPickerTypes { - final attach = value.attachments; - final containsImage = attach.any((it) => it.type == AttachmentType.image); - final containsVideo = attach.any((it) => it.type == AttachmentType.video); - final containsAudio = attach.any((it) => it.type == AttachmentType.audio); - final containsFile = attach.any((it) => it.type == AttachmentType.file); - final containsPoll = value.poll != null; - - return { - if (containsImage) AttachmentPickerType.images, - if (containsVideo) AttachmentPickerType.videos, - if (containsAudio) AttachmentPickerType.audios, - if (containsFile) AttachmentPickerType.files, - if (containsPoll) AttachmentPickerType.poll, - }; - } - - /// Returns the list of enabled picker types. - Set filterEnabledTypes({ - required Iterable options, - }) { - final availableTypes = currentAttachmentPickerTypes; - final enabledTypes = {}; - for (final option in options) { - final supportedTypes = option.supportedTypes; - if (availableTypes.any(supportedTypes.contains)) { - enabledTypes.addAll(supportedTypes); - } - } - return enabledTypes; - } - - /// Returns true if the [initialAttachments] are changed. - bool get isValueChanged { - final isPollEqual = value.poll == initialPoll; - final areAttachmentsEqual = UnorderedIterableEquality( - EqualityBy((attachment) => attachment.id), - ).equals(value.attachments, initialAttachments); - - return !isPollEqual || !areAttachmentsEqual; - } -} - -/// Function signature for the callback when the web or desktop attachment -/// picker option gets tapped. -typedef OnWebOrDesktopAttachmentPickerOptionTap = void Function( - BuildContext context, - StreamAttachmentPickerController controller, - WebOrDesktopAttachmentPickerOption option, -); - -/// Bottom sheet widget for the web or desktop version of the attachment picker. -class StreamWebOrDesktopAttachmentPickerBottomSheet extends StatelessWidget { - /// Creates a new instance of [StreamWebOrDesktopAttachmentPickerBottomSheet]. - const StreamWebOrDesktopAttachmentPickerBottomSheet({ +/// Shows a list of options that launch native platform dialogs. +/// Selections are applied in real-time via the [controller]. +class StreamSystemAttachmentPicker extends StatelessWidget { + /// Creates a new instance of [StreamSystemAttachmentPicker]. + const StreamSystemAttachmentPicker({ super.key, required this.options, required this.controller, - this.onOptionTap, }); /// The list of options. - final Set options; + final Set options; /// The controller of the attachment picker. final StreamAttachmentPickerController controller; - /// The callback when the option gets tapped. - final OnWebOrDesktopAttachmentPickerOptionTap? onOptionTap; - @override Widget build(BuildContext context) { - final enabledTypes = controller.filterEnabledTypes(options: options); - return ListView( - shrinkWrap: true, - children: [ - ...options.map((option) { - VoidCallback? onOptionTap; - if (this.onOptionTap != null) { - onOptionTap = () { - this.onOptionTap?.call(context, controller, option); - }; - } - - final enabled = enabledTypes.isEmpty || - enabledTypes.any((it) => it == option.type); + return ValueListenableBuilder( + valueListenable: controller, + builder: (context, value, child) { + final enabledTypes = value.filterEnabledTypes(options: options); - return ListTile( - enabled: enabled, - leading: option.icon, - title: Text(option.title), - onTap: onOptionTap, - ); - }), - ], + return ListView( + shrinkWrap: true, + children: [ + ...options.map( + (option) { + final supported = option.supportedTypes; + final isEnabled = enabledTypes.any(supported.contains); + + return ListTile( + enabled: isEnabled, + leading: Icon(option.icon), + title: Text(option.title), + onTap: () => option.onTap(context, controller), + ); + }, + ), + ], + ); + }, ); } } -/// Bottom sheet widget for the mobile version of the attachment picker. -class StreamMobileAttachmentPickerBottomSheet extends StatefulWidget { - /// Creates a new instance of [StreamMobileAttachmentPickerBottomSheet]. - const StreamMobileAttachmentPickerBottomSheet({ +/// Inline widget for the tabbed attachment picker interface. +/// +/// Displays a tabbed interface with horizontal tabs for different attachment +/// types (gallery, camera, files, etc.). Each tab shows a specialized +/// interface for selecting that type of attachment. +/// +/// Selections are applied in real-time via the [controller] rather than +/// through a modal result pattern. +class StreamTabbedAttachmentPicker extends StatefulWidget { + /// Creates a new instance of [StreamTabbedAttachmentPicker]. + const StreamTabbedAttachmentPicker({ super.key, required this.options, required this.controller, this.initialOption, - this.onSendValue, }); /// The list of options. - final Set options; + final Set options; /// The initial option to be selected. - final AttachmentPickerOption? initialOption; + final TabbedAttachmentPickerOption? initialOption; /// The controller of the attachment picker. final StreamAttachmentPickerController controller; - /// The callback when the send button gets tapped. - final ValueSetter? onSendValue; - @override - State createState() => - _StreamMobileAttachmentPickerBottomSheetState(); + State createState() => _StreamTabbedAttachmentPickerState(); } -class _StreamMobileAttachmentPickerBottomSheetState - extends State { - late AttachmentPickerOption _currentOption; +class _StreamTabbedAttachmentPickerState extends State { + late var _currentOption = _calculateInitialOption(); + TabbedAttachmentPickerOption _calculateInitialOption() { + if (widget.initialOption case final option?) return option; - @override - void initState() { - super.initState(); - if (widget.initialOption == null) { - final enabledTypes = widget.controller.filterEnabledTypes( - options: widget.options, - ); - if (enabledTypes.isNotEmpty) { - _currentOption = widget.options.firstWhere((it) { - return it.supportedTypes.contains(enabledTypes.first); - }); - } else { - _currentOption = widget.options.first; - } - } else { - _currentOption = widget.initialOption!; - } + final options = widget.options; + final currentValue = widget.controller.value; + final enabledTypes = currentValue.filterEnabledTypes(options: options); + + if (enabledTypes.isEmpty) return options.first; + + return options.firstWhere( + (it) => enabledTypes.any(it.supportedTypes.contains), + ); } @override Widget build(BuildContext context) { return ValueListenableBuilder( valueListenable: widget.controller, - builder: (context, attachments, _) { + builder: (context, value, _) { return Column( + crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ - _AttachmentPickerOptions( + _TabbedAttachmentPickerOptions( controller: widget.controller, options: widget.options, currentOption: _currentOption, - onSendValue: widget.onSendValue, onOptionSelected: (option) async { setState(() => _currentOption = option); }, ), Expanded( - child: _currentOption.optionViewBuilder - ?.call(context, widget.controller) ?? - const Empty(), + child: _currentOption.optionViewBuilder( + context, + widget.controller, + ), ), ], ); @@ -471,102 +130,79 @@ class _StreamMobileAttachmentPickerBottomSheetState } } -class _AttachmentPickerOptions extends StatelessWidget { - const _AttachmentPickerOptions({ +class _TabbedAttachmentPickerOptions extends StatelessWidget { + const _TabbedAttachmentPickerOptions({ required this.options, required this.currentOption, required this.controller, this.onOptionSelected, - this.onSendValue, }); - final Iterable options; - final AttachmentPickerOption currentOption; + final Iterable options; + final TabbedAttachmentPickerOption currentOption; final StreamAttachmentPickerController controller; - final ValueSetter? onOptionSelected; - final ValueSetter? onSendValue; + final ValueSetter? onOptionSelected; @override Widget build(BuildContext context) { - final colorTheme = StreamChatTheme.of(context).colorTheme; + final spacing = context.streamSpacing; + return ValueListenableBuilder( valueListenable: controller, - builder: (context, attachments, __) { - final enabledTypes = controller.filterEnabledTypes(options: options); - return Row( - children: [ - Expanded( - child: SingleChildScrollView( - scrollDirection: Axis.horizontal, - child: Row( - children: [ - ...options.map( - (option) { - final supportedTypes = option.supportedTypes; - - final isSelected = option == currentOption; - final isEnabled = enabledTypes.isEmpty || - enabledTypes.any(supportedTypes.contains); - - final color = isSelected - ? colorTheme.accentPrimary - : colorTheme.textLowEmphasis; - - final onPressed = - isEnabled ? () => onOptionSelected!(option) : null; - - return IconButton( - color: color, - disabledColor: colorTheme.disabled, - icon: option.icon, - onPressed: onPressed, - ); - }, - ), - ], - ), + builder: (context, value, __) { + final enabledTypes = value.filterEnabledTypes(options: options); + + return SingleChildScrollView( + scrollDirection: Axis.horizontal, + padding: EdgeInsets.symmetric(horizontal: spacing.md, vertical: spacing.sm), + child: Row( + spacing: spacing.xxxs, + children: [ + ...options.map( + (option) { + final supported = option.supportedTypes; + // An option with no supportedTypes is always enabled. + final isEnabled = supported.isEmpty || enabledTypes.any(supported.contains); + final isSelected = option == currentOption; + + final onPressed = switch (isEnabled) { + true => () => onOptionSelected?.call(option), + _ => null, + }; + + return StreamButton.icon( + style: StreamButtonStyle.secondary, + type: StreamButtonType.ghost, + size: StreamButtonSize.large, + icon: Icon(option.icon), + onPressed: onPressed, + isSelected: isSelected, + ); + }, ), - ), - Builder( - builder: (context) { - final VoidCallback? onPressed; - if (onSendValue != null && controller.isValueChanged) { - onPressed = () => onSendValue!(attachments); - } else { - onPressed = null; - } - - return IconButton( - color: colorTheme.accentPrimary, - disabledColor: colorTheme.disabled, - icon: const StreamSvgIcon( - icon: StreamSvgIcons.emptyCircleRight, - ), - onPressed: onPressed, - ); - }, - ), - ], + ], + ), ); }, ); } } -/// Signature used by [EndOfFrameCallbackWidget.errorBuilder] to create a -/// replacement widget to render. -typedef EndOfFrameCallbackErrorWidgetBuilder = Widget Function( - BuildContext context, - Object error, - StackTrace? stackTrace, -); +/// Signature used by [EndOfFrameCallbackWidget.errorBuilder] to build a +/// replacement widget when [EndOfFrameCallbackWidget.onEndOfFrame] throws. +typedef EndOfFrameCallbackErrorWidgetBuilder = + Widget Function( + BuildContext context, + Object error, + StackTrace? stackTrace, + ); -/// Function signature for a callback that is called when the end of the frame -/// is reached. +/// Signature for callbacks invoked by [EndOfFrameCallbackWidget] once the +/// first frame has been rendered. typedef EndOfFrameCallback = FutureOr Function(BuildContext context); -/// A widget that calls the given [callback] when the end of the frame is -/// reached. +/// A widget that invokes [onEndOfFrame] once after the first frame has been +/// rendered. class EndOfFrameCallbackWidget extends StatefulWidget { /// Creates a new instance of [EndOfFrameCallbackWidget]. const EndOfFrameCallbackWidget({ @@ -579,16 +215,19 @@ class EndOfFrameCallbackWidget extends StatefulWidget { /// The widget below this widget in the tree. final Widget? child; - /// The callback that is called when the end of the frame is reached.x + /// Called once after the first frame has been rendered. final EndOfFrameCallback onEndOfFrame; - /// The callback that will be called if the [onEndOfFrame] callback throws an - /// error. + /// Called to build a replacement widget when [onEndOfFrame] throws. + /// + /// If null, [child] is shown when [onEndOfFrame] throws and the error is + /// forwarded to [FlutterError.reportError] so it remains visible in the + /// console and to any error handlers registered by the host app (e.g. + /// Crashlytics, Sentry). final EndOfFrameCallbackErrorWidgetBuilder? errorBuilder; @override - State createState() => - _EndOfFrameCallbackWidgetState(); + State createState() => _EndOfFrameCallbackWidgetState(); } class _EndOfFrameCallbackWidgetState extends State { @@ -599,48 +238,44 @@ class _EndOfFrameCallbackWidgetState extends State { void initState() { super.initState(); WidgetsBinding.instance.endOfFrame.then((_) async { - if (mounted) { - try { - await widget.onEndOfFrame(context); - } catch (error, stackTrace) { - setState(() { - _error = error; - _stackTrace = stackTrace; - }); + if (!mounted) return; + try { + await widget.onEndOfFrame(context); + } catch (error, stackTrace) { + if (!mounted) return; + // When no errorBuilder is provided we fall back to [child], so the + // error is otherwise invisible. Forward it through Flutter's error + // plumbing once so host apps (Crashlytics / Sentry / console) still + // see it. When an errorBuilder is provided, caller owns presentation. + if (widget.errorBuilder == null) { + FlutterError.reportError( + FlutterErrorDetails( + exception: error, + stack: stackTrace, + library: 'stream_chat_flutter', + context: ErrorDescription('EndOfFrameCallbackWidget.onEndOfFrame threw; falling back to child'), + ), + ); } + setState(() { + _error = error; + _stackTrace = stackTrace; + }); } }); } @override Widget build(BuildContext context) { - final error = _error; - final stackTrace = _stackTrace; - - if (error != null) { - final errorBuilder = widget.errorBuilder; - if (errorBuilder != null) { - return errorBuilder(context, error, stackTrace); + if (_error case final error?) { + if (widget.errorBuilder case final errorBuilder?) { + return errorBuilder(context, error, _stackTrace); } - return const Text('An error occurred'); } - - // Reset the error and stack trace so that we don't keep showing the same - // error over and over. - _error = null; - _stackTrace = null; - return widget.child ?? const Empty(); } } -const _kDefaultOptionDrawerShape = RoundedRectangleBorder( - borderRadius: BorderRadius.only( - topLeft: Radius.circular(16), - topRight: Radius.circular(16), - ), -); - /// A widget that will be shown in the attachment picker. /// It can be used to show a custom view for each attachment picker option. class OptionDrawer extends StatelessWidget { @@ -648,317 +283,283 @@ class OptionDrawer extends StatelessWidget { const OptionDrawer({ super.key, required this.child, - this.color, - this.elevation = 2, - this.margin = EdgeInsets.zero, - this.clipBehavior = Clip.hardEdge, - this.shape = _kDefaultOptionDrawerShape, - this.title, - this.actions = const [], + this.margin, }); /// The widget below this widget in the tree. final Widget child; - /// The background color of the options card. - /// - /// Defaults to [StreamColorTheme.barsBg]. - final Color? color; - - /// The elevation of the options card. - /// - /// The default value is 2. - final double elevation; - /// The margin of the options card. - /// - /// The default value is [EdgeInsets.zero]. - final EdgeInsetsGeometry margin; - - /// The clip behavior of the options card. - /// - /// The default value is [Clip.hardEdge]. - final Clip clipBehavior; - - /// The shape of the options card. - final ShapeBorder shape; - - /// The title of the options card. - final Widget? title; - - /// The actions available for the options card. - final List actions; + final EdgeInsetsGeometry? margin; @override Widget build(BuildContext context) { - final colorTheme = StreamChatTheme.of(context).colorTheme; - - var height = 20.0; - if (title != null || actions.isNotEmpty) { - height = 40.0; - } - - final leading = title ?? const Empty(); - - Widget trailing; - if (actions.isNotEmpty) { - trailing = Row( - mainAxisSize: MainAxisSize.min, - mainAxisAlignment: MainAxisAlignment.end, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: actions, - ); - } else { - trailing = const Empty(); - } + final spacing = context.streamSpacing; + final effectiveMargin = margin ?? .symmetric(horizontal: spacing.md, vertical: spacing.xxxl); - return Card( - elevation: elevation, - color: color ?? colorTheme.barsBg, - margin: margin, - shape: shape, - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - SizedBox( - height: height, - child: Row( - children: [ - Expanded(child: leading), - Container( - height: 4, - width: 40, - decoration: BoxDecoration( - color: colorTheme.disabled, - borderRadius: BorderRadius.circular(6), - ), - ), - Expanded(child: trailing), - ], - ), - ), - Expanded(child: child), - ], - ), + return Container( + margin: effectiveMargin, + child: child, ); } } -/// Returns the mobile version of the attachment picker. -Widget mobileAttachmentPickerBuilder({ +/// Builds a tabbed attachment picker with custom interfaces for different +/// attachment types. +/// +/// Shows horizontal tabs for gallery, files, camera, video, and polls. Each +/// tab displays a specialized interface for selecting that type of +/// attachment. Tabs get enabled or disabled based on what you've already +/// selected. +/// +/// Selections are applied in real-time via the [controller]. +/// +/// The [onError] callback is invoked when an error occurs during attachment +/// selection (e.g., file too large or attachment limit reached). +/// +/// The [onPollCreated] callback is invoked when a poll is created, allowing +/// the caller to handle poll-specific logic. +Widget tabbedAttachmentPickerBuilder({ required BuildContext context, required StreamAttachmentPickerController controller, - Poll? initialPoll, PollConfig? pollConfig, - Iterable? customOptions, + GalleryPickerConfig? galleryPickerConfig, List allowedTypes = AttachmentPickerType.values, - ThumbnailSize attachmentThumbnailSize = const ThumbnailSize(400, 400), - ThumbnailFormat attachmentThumbnailFormat = ThumbnailFormat.jpeg, - int attachmentThumbnailQuality = 100, - double attachmentThumbnailScale = 1, - ErrorListener? onError, + AttachmentPickerOptionsBuilder? optionsBuilder, + ValueSetter? onError, + ValueSetter? onPollCreated, + ValueSetter? onCommandSelected, }) { - return StreamMobileAttachmentPickerBottomSheet( - controller: controller, - onSendValue: Navigator.of(context).pop, - options: { - ...{ - if (customOptions != null) ...customOptions, - AttachmentPickerOption( - key: 'gallery-picker', - icon: const StreamSvgIcon(icon: StreamSvgIcons.pictures), - supportedTypes: [ - AttachmentPickerType.images, - AttachmentPickerType.videos, - ], - optionViewBuilder: (context, controller) { - final attachment = controller.value.attachments; - final selectedIds = attachment.map((it) => it.id); - return StreamGalleryPicker( - selectedMediaItems: selectedIds, - mediaThumbnailSize: attachmentThumbnailSize, - mediaThumbnailFormat: attachmentThumbnailFormat, - mediaThumbnailQuality: attachmentThumbnailQuality, - mediaThumbnailScale: attachmentThumbnailScale, - onMediaItemSelected: (media) async { - try { - if (selectedIds.contains(media.id)) { - return await controller.removeAssetAttachment(media); - } - return await controller.addAssetAttachment(media); - } catch (e, stk) { - if (onError != null) return onError.call(e, stk); - rethrow; - } - }, - ); - }, - ), - AttachmentPickerOption( - key: 'file-picker', - icon: const StreamSvgIcon(icon: StreamSvgIcons.files), - supportedTypes: [AttachmentPickerType.files], - optionViewBuilder: (context, controller) { - return StreamFilePicker( - onFilePicked: (file) async { - try { - if (file != null) await controller.addAttachment(file); - return Navigator.pop(context, controller.value); - } catch (e, stk) { - Navigator.pop(context, controller.value); - if (onError != null) return onError.call(e, stk); - - rethrow; - } - }, - ); + final defaultOptions = [ + TabbedAttachmentPickerOption( + key: 'gallery-picker', + icon: context.streamIcons.image, + supportedTypes: [ + AttachmentPickerType.images, + AttachmentPickerType.videos, + ], + optionViewBuilder: (context, controller) { + final attachment = controller.value.attachments; + final selectedIds = attachment.map((it) => it.id); + return StreamGalleryPicker( + config: galleryPickerConfig, + selectedMediaItems: selectedIds, + onMediaItemSelected: (media) async { + try { + if (selectedIds.contains(media.id)) { + return await controller.removeAssetAttachment(media); + } + return await controller.addAssetAttachment(media); + } catch (e, stk) { + onError?.call( + AttachmentPickerError(error: e, stackTrace: stk), + ); + } }, - ), - AttachmentPickerOption( - key: 'image-picker', - icon: const StreamSvgIcon(icon: StreamSvgIcons.camera), - supportedTypes: [AttachmentPickerType.images], - optionViewBuilder: (context, controller) { - return StreamImagePicker( - onImagePicked: (image) async { - try { - if (image != null) { - await controller.addAttachment(image); - } - return Navigator.pop(context, controller.value); - } catch (e, stk) { - Navigator.pop(context, controller.value); - if (onError != null) return onError.call(e, stk); - - rethrow; - } - }, + ); + }, + ), + TabbedAttachmentPickerOption( + key: 'image-picker', + icon: context.streamIcons.camera, + supportedTypes: [AttachmentPickerType.images], + optionViewBuilder: (context, controller) => StreamImagePicker( + onImagePicked: (image) async { + try { + if (image != null) await controller.addAttachment(image); + } catch (e, stk) { + onError?.call( + AttachmentPickerError(error: e, stackTrace: stk), ); - }, - ), - AttachmentPickerOption( - key: 'video-picker', - icon: const StreamSvgIcon(icon: StreamSvgIcons.record), - supportedTypes: [AttachmentPickerType.videos], - optionViewBuilder: (context, controller) { - return StreamVideoPicker( - onVideoPicked: (video) async { - try { - if (video != null) { - await controller.addAttachment(video); - } - return Navigator.pop(context, controller.value); - } catch (e, stk) { - Navigator.pop(context, controller.value); - if (onError != null) return onError.call(e, stk); - - rethrow; - } - }, + } + }, + ), + ), + TabbedAttachmentPickerOption( + key: 'video-picker', + icon: context.streamIcons.video, + supportedTypes: [AttachmentPickerType.videos], + optionViewBuilder: (context, controller) => StreamVideoPicker( + onVideoPicked: (video) async { + try { + if (video != null) await controller.addAttachment(video); + } catch (e, stk) { + onError?.call( + AttachmentPickerError(error: e, stackTrace: stk), ); - }, - ), - AttachmentPickerOption( - key: 'poll-creator', - icon: const StreamSvgIcon(icon: StreamSvgIcons.polls), - supportedTypes: [AttachmentPickerType.poll], - optionViewBuilder: (context, controller) { - final initialPoll = controller.value.poll; - return StreamPollCreator( - poll: initialPoll, - config: pollConfig, - onPollCreated: (poll) { - try { - if (poll != null) controller.poll = poll; - return Navigator.pop(context, controller.value); - } catch (e, stk) { - Navigator.pop(context, controller.value); - if (onError != null) return onError.call(e, stk); - - rethrow; - } - }, + } + }, + ), + ), + TabbedAttachmentPickerOption( + key: 'file-picker', + icon: context.streamIcons.file, + supportedTypes: [AttachmentPickerType.files], + optionViewBuilder: (context, controller) => StreamFilePicker( + onFilePicked: (file) async { + try { + if (file != null) await controller.addAttachment(file); + } catch (e, stk) { + onError?.call( + AttachmentPickerError(error: e, stackTrace: stk), ); + } + }, + ), + ), + TabbedAttachmentPickerOption( + key: 'poll-creator', + icon: context.streamIcons.poll, + supportedTypes: [AttachmentPickerType.poll], + optionViewBuilder: (context, controller) { + final initialPoll = controller.value.poll; + return StreamPollCreator( + poll: initialPoll, + config: pollConfig, + onPollCreated: (poll) { + if (poll == null) return; + controller.poll = poll; + onPollCreated?.call(poll); }, - ), - }.where((option) => option.supportedTypes.every(allowedTypes.contains)), + ); + }, + ), + TabbedAttachmentPickerOption( + key: 'command-picker', + icon: context.streamIcons.command, + supportedTypes: [AttachmentPickerType.command], + optionViewBuilder: (context, controller) => StreamCommandPicker( + onCommandSelected: onCommandSelected, + ), + ), + ]; + + final allOptions = switch (optionsBuilder) { + final builder? => builder(context, defaultOptions), + _ => defaultOptions, + }; + + final validOptions = allOptions.whereType(); + + if (validOptions.length < allOptions.length) { + throw ArgumentError( + 'custom options must be of type TabbedAttachmentPickerOption when using ' + 'the tabbed attachment picker (default on mobile).', + ); + } + + return StreamTabbedAttachmentPicker( + controller: controller, + options: { + ...validOptions.where( + (option) => option.supportedTypes.every(allowedTypes.contains), + ), }, ); } -/// Returns the web or desktop version of the attachment picker. -Widget webOrDesktopAttachmentPickerBuilder({ +/// Builds a system attachment picker that opens native platform dialogs. +/// +/// Shows a simple list of options that immediately launch your device's +/// built-in file browser, camera app, or other native tools instead of +/// custom interfaces. +/// +/// Selections are applied in real-time via the [controller]. +/// +/// The [onError] callback is invoked when an error occurs during attachment +/// selection. +/// +/// The [onPollCreated] callback is invoked when a poll is created. +Widget systemAttachmentPickerBuilder({ required BuildContext context, required StreamAttachmentPickerController controller, - Poll? initialPoll, - PollConfig? pollConfig, - Iterable? customOptions, + PollConfig? pollConfig = const PollConfig(), + GalleryPickerConfig? galleryPickerConfig = const GalleryPickerConfig(), List allowedTypes = AttachmentPickerType.values, - ThumbnailSize attachmentThumbnailSize = const ThumbnailSize(400, 400), - ThumbnailFormat attachmentThumbnailFormat = ThumbnailFormat.jpeg, - int attachmentThumbnailQuality = 100, - double attachmentThumbnailScale = 1, - ErrorListener? onError, + AttachmentPickerOptionsBuilder? optionsBuilder, + ValueSetter? onError, + ValueSetter? onPollCreated, }) { - return StreamWebOrDesktopAttachmentPickerBottomSheet( - controller: controller, - options: { - ...{ - if (customOptions != null) ...customOptions, - WebOrDesktopAttachmentPickerOption( - key: 'image-picker', - type: AttachmentPickerType.images, - icon: const StreamSvgIcon(icon: StreamSvgIcons.pictures), - title: context.translations.uploadAPhotoLabel, - ), - WebOrDesktopAttachmentPickerOption( - key: 'video-picker', - type: AttachmentPickerType.videos, - icon: const StreamSvgIcon(icon: StreamSvgIcons.record), - title: context.translations.uploadAVideoLabel, - ), - WebOrDesktopAttachmentPickerOption( - key: 'file-picker', - type: AttachmentPickerType.files, - icon: const StreamSvgIcon(icon: StreamSvgIcons.files), - title: context.translations.uploadAFileLabel, - ), - WebOrDesktopAttachmentPickerOption( - key: 'poll-creator', - type: AttachmentPickerType.poll, - icon: const StreamSvgIcon(icon: StreamSvgIcons.polls), - title: context.translations.createPollLabel(isNew: true), - ), - }.where((option) => option.supportedTypes.every(allowedTypes.contains)), - }, - onOptionTap: (context, controller, option) async { - // Handle the polls type option separately - if (option.type case AttachmentPickerType.poll) { - final poll = await showStreamPollCreatorDialog( + Future pickSystemFile( + StreamAttachmentPickerController controller, + FileType type, + ) async { + try { + final file = await StreamAttachmentHandler.instance.pickFile(type: type); + if (file != null) await controller.addAttachment(file); + } catch (e, stk) { + onError?.call(AttachmentPickerError(error: e, stackTrace: stk)); + } + } + + final defaultOptions = [ + SystemAttachmentPickerOption( + key: 'image-picker', + supportedTypes: [AttachmentPickerType.images], + icon: context.streamIcons.image, + title: context.translations.uploadAPhotoLabel, + onTap: (context, controller) async { + await pickSystemFile(controller, FileType.image); + }, + ), + SystemAttachmentPickerOption( + key: 'video-picker', + supportedTypes: [AttachmentPickerType.videos], + icon: context.streamIcons.video, + title: context.translations.uploadAVideoLabel, + onTap: (context, controller) async { + await pickSystemFile(controller, FileType.video); + }, + ), + SystemAttachmentPickerOption( + key: 'file-picker', + supportedTypes: [AttachmentPickerType.files], + icon: context.streamIcons.file, + title: context.translations.uploadAFileLabel, + onTap: (context, controller) async { + await pickSystemFile(controller, FileType.any); + }, + ), + SystemAttachmentPickerOption( + key: 'poll-creator', + supportedTypes: [AttachmentPickerType.poll], + icon: context.streamIcons.poll, + title: context.translations.createPollLabel(isNew: true), + onTap: (context, controller) async { + final initialPoll = controller.value.poll; + final poll = await showStreamPollCreatorSheet( context: context, poll: initialPoll, config: pollConfig, ); - if (poll != null) controller.poll = poll; - return Navigator.pop(context, controller.value); - } + if (poll == null) return; + controller.poll = poll; + onPollCreated?.call(poll); + }, + ), + ]; - // Handle the remaining option types. - try { - final attachment = await StreamAttachmentHandler.instance.pickFile( - type: option.type.fileType, - ); - if (attachment != null) { - await controller.addAttachment(attachment); - } - return Navigator.pop(context, controller.value); - } catch (e, stk) { - Navigator.pop(context, controller.value); - if (onError != null) return onError.call(e, stk); + final allOptions = switch (optionsBuilder) { + final builder? => builder(context, defaultOptions), + _ => defaultOptions, + }; - rethrow; - } + final validOptions = allOptions.whereType(); + + if (validOptions.length < allOptions.length) { + throw ArgumentError( + 'custom options must be of type SystemAttachmentPickerOption when using ' + 'the system attachment picker (enabled explicitly or on web/desktop).', + ); + } + + return StreamSystemAttachmentPicker( + controller: controller, + options: { + ...validOptions.where( + (option) => option.supportedTypes.every(allowedTypes.contains), + ), }, ); } diff --git a/packages/stream_chat_flutter/lib/src/message_input/attachment_picker/stream_attachment_picker_bottom_sheet.dart b/packages/stream_chat_flutter/lib/src/message_input/attachment_picker/stream_attachment_picker_bottom_sheet.dart index cbd164120e..4ae640eb6d 100644 --- a/packages/stream_chat_flutter/lib/src/message_input/attachment_picker/stream_attachment_picker_bottom_sheet.dart +++ b/packages/stream_chat_flutter/lib/src/message_input/attachment_picker/stream_attachment_picker_bottom_sheet.dart @@ -1,261 +1,12 @@ -import 'dart:async'; - -import 'package:flutter/material.dart'; -import 'package:stream_chat_flutter/stream_chat_flutter.dart'; - -/// Shows a modal material design bottom sheet. -/// -/// A modal bottom sheet is an alternative to a menu or a dialog and prevents -/// the user from interacting with the rest of the app. -/// -/// A closely related widget is a persistent bottom sheet, which shows -/// information that supplements the primary content of the app without -/// preventing the use from interacting with the app. Persistent bottom sheets -/// can be created and displayed with the [showBottomSheet] function or the -/// [ScaffoldState.showBottomSheet] method. -/// -/// The `context` argument is used to look up the [Navigator] and [Theme] for -/// the bottom sheet. It is only used when the method is called. Its -/// corresponding widget can be safely removed from the tree before the bottom -/// sheet is closed. -/// -/// The `isScrollControlled` parameter specifies whether this is a route for -/// a bottom sheet that will utilize [DraggableScrollableSheet]. If you wish -/// to have a bottom sheet that has a scrollable child such as a [ListView] or -/// a [GridView] and have the bottom sheet be draggable, you should set this -/// parameter to true. -/// -/// The `useRootNavigator` parameter ensures that the root navigator is used to -/// display the [BottomSheet] when set to `true`. This is useful in the case -/// that a modal [BottomSheet] needs to be displayed above all other content -/// but the caller is inside another [Navigator]. -/// -/// The [isDismissible] parameter specifies whether the bottom sheet will be -/// dismissed when user taps on the scrim. -/// -/// The [enableDrag] parameter specifies whether the bottom sheet can be -/// dragged up and down and dismissed by swiping downwards. -/// -/// The optional [backgroundColor], [elevation], [shape], [clipBehavior], -/// [constraints] and [transitionAnimationController] -/// parameters can be passed in to customize the appearance and behavior of -/// modal bottom sheets (see the documentation for these on [BottomSheet] -/// for more details). -/// -/// The [transitionAnimationController] controls the bottom sheet's entrance and -/// exit animations if provided. -/// -/// The optional `routeSettings` parameter sets the [RouteSettings] -/// of the modal bottom sheet sheet. -/// This is particularly useful in the case that a user wants to observe -/// [PopupRoute]s within a [NavigatorObserver]. -/// -/// Returns a `Future` that resolves to the value (if any) that was passed to -/// [Navigator.pop] when the modal bottom sheet was closed. -/// -/// See also: -/// -/// * [BottomSheet], which becomes the parent of the widget returned by the -/// function passed as the `builder` argument to [showModalBottomSheet]. -/// * [showBottomSheet] and [ScaffoldState.showBottomSheet], for showing -/// non-modal bottom sheets. -/// * [DraggableScrollableSheet], which allows you to create a bottom sheet -/// that grows and then becomes scrollable once it reaches its maximum size. -/// * -Future showStreamAttachmentPickerModalBottomSheet({ - required BuildContext context, - Iterable? customOptions, - List allowedTypes = AttachmentPickerType.values, - Poll? initialPoll, - PollConfig? pollConfig, - List? initialAttachments, - StreamAttachmentPickerController? controller, - ErrorListener? onError, - Color? backgroundColor, - double? elevation, - BoxConstraints? constraints, - Color? barrierColor, - bool isScrollControlled = false, - bool useRootNavigator = false, - bool isDismissible = true, - bool enableDrag = true, - bool useSystemAttachmentPicker = false, - @Deprecated("Use 'useSystemAttachmentPicker' instead.") - bool useNativeAttachmentPickerOnMobile = false, - RouteSettings? routeSettings, - AnimationController? transitionAnimationController, - Clip? clipBehavior = Clip.hardEdge, - ShapeBorder? shape, - ThumbnailSize attachmentThumbnailSize = const ThumbnailSize(400, 400), - ThumbnailFormat attachmentThumbnailFormat = ThumbnailFormat.jpeg, - int attachmentThumbnailQuality = 100, - double attachmentThumbnailScale = 1, -}) { - final colorTheme = StreamChatTheme.of(context).colorTheme; - final color = backgroundColor ?? colorTheme.inputBg; - - return showModalBottomSheet( - context: context, - backgroundColor: color, - elevation: elevation, - shape: shape, - clipBehavior: clipBehavior, - constraints: constraints, - barrierColor: barrierColor, - isScrollControlled: isScrollControlled, - useRootNavigator: useRootNavigator, - isDismissible: isDismissible, - enableDrag: enableDrag, - routeSettings: routeSettings, - transitionAnimationController: transitionAnimationController, - builder: (BuildContext context) { - return StreamPlatformAttachmentPickerBottomSheetBuilder( - controller: controller, - initialPoll: initialPoll, - initialAttachments: initialAttachments, - builder: (context, controller, child) { - final isWebOrDesktop = switch (CurrentPlatform.type) { - PlatformType.web || - PlatformType.macOS || - PlatformType.linux || - PlatformType.windows => - true, - _ => false, - }; - - final useSystemPicker = useSystemAttachmentPicker || // - useNativeAttachmentPickerOnMobile; - - if (useSystemPicker || isWebOrDesktop) { - return webOrDesktopAttachmentPickerBuilder.call( - context: context, - onError: onError, - controller: controller, - allowedTypes: allowedTypes, - customOptions: customOptions?.map( - WebOrDesktopAttachmentPickerOption.fromAttachmentPickerOption, - ), - initialPoll: initialPoll, - pollConfig: pollConfig, - attachmentThumbnailSize: attachmentThumbnailSize, - attachmentThumbnailFormat: attachmentThumbnailFormat, - attachmentThumbnailQuality: attachmentThumbnailQuality, - attachmentThumbnailScale: attachmentThumbnailScale, - ); - } - - return mobileAttachmentPickerBuilder.call( - context: context, - onError: onError, - controller: controller, - allowedTypes: allowedTypes, - customOptions: customOptions, - initialPoll: initialPoll, - pollConfig: pollConfig, - attachmentThumbnailSize: attachmentThumbnailSize, - attachmentThumbnailFormat: attachmentThumbnailFormat, - attachmentThumbnailQuality: attachmentThumbnailQuality, - attachmentThumbnailScale: attachmentThumbnailScale, - ); - }, - ); - }, - ); -} - -/// Builds the attachment picker bottom sheet. -class StreamPlatformAttachmentPickerBottomSheetBuilder extends StatefulWidget { - /// Creates a new instance of the widget. - const StreamPlatformAttachmentPickerBottomSheetBuilder({ - super.key, - this.customOptions, - this.initialPoll, - this.initialAttachments, - this.child, - this.controller, - required this.builder, - }); - - /// The child widget. - final Widget? child; - - /// Builder for the attachment picker bottom sheet. - final Widget Function( - BuildContext context, - StreamAttachmentPickerController controller, - Widget? child, - ) builder; - - /// The custom options to be displayed in the attachment picker. - final List? customOptions; - - /// The initial poll. - final Poll? initialPoll; - - /// The initial attachments. - final List? initialAttachments; - - /// The controller. - final StreamAttachmentPickerController? controller; - - @override - State createState() => - _StreamPlatformAttachmentPickerBottomSheetBuilderState(); -} - -class _StreamPlatformAttachmentPickerBottomSheetBuilderState - extends State { - late StreamAttachmentPickerController _controller; - - @override - void initState() { - super.initState(); - _controller = widget.controller ?? - StreamAttachmentPickerController( - initialPoll: widget.initialPoll, - initialAttachments: widget.initialAttachments, - ); - } - - // Handle a potential change in StreamAttachmentPickerController by properly - // disposing of the old one and setting up the new one, if needed. - void _updateAttachmentPickerController( - StreamAttachmentPickerController? old, - StreamAttachmentPickerController? current, - ) { - if ((old == null && current == null) || old == current) return; - if (old == null) { - _controller.dispose(); - _controller = current!; - } else if (current == null) { - _controller = StreamAttachmentPickerController( - initialPoll: widget.initialPoll, - initialAttachments: widget.initialAttachments, - ); - } else { - _controller = current; - } - } - - @override - void didUpdateWidget( - StreamPlatformAttachmentPickerBottomSheetBuilder oldWidget, - ) { - super.didUpdateWidget(oldWidget); - _updateAttachmentPickerController( - oldWidget.controller, - widget.controller, - ); - } - - @override - void dispose() { - if (widget.controller == null) _controller.dispose(); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - return widget.builder(context, _controller, widget.child); - } -} +import 'package:flutter/widgets.dart'; +import 'package:stream_chat_flutter/src/message_input/attachment_picker/stream_attachment_picker_option.dart'; + +/// {@template streamAttachmentPickerOptionsBuilder} +/// Signature for a function that creates a list of [AttachmentPickerOption]s +/// to be used in the attachment picker. +/// +/// The function receives the [BuildContext] and a list of [defaultOptions] +/// that can be modified or extended. +/// {@endtemplate} +typedef AttachmentPickerOptionsBuilder = + List Function(BuildContext context, List defaultOptions); diff --git a/packages/stream_chat_flutter/lib/src/message_input/attachment_picker/stream_attachment_picker_controller.dart b/packages/stream_chat_flutter/lib/src/message_input/attachment_picker/stream_attachment_picker_controller.dart new file mode 100644 index 0000000000..69f47d4b27 --- /dev/null +++ b/packages/stream_chat_flutter/lib/src/message_input/attachment_picker/stream_attachment_picker_controller.dart @@ -0,0 +1,339 @@ +import 'dart:async'; + +import 'package:collection/collection.dart'; +import 'package:flutter/foundation.dart'; +import 'package:stream_chat_flutter/src/attachment/handler/stream_attachment_handler.dart'; +import 'package:stream_chat_flutter/src/message_input/attachment_picker/stream_attachment_picker_result.dart'; +import 'package:stream_chat_flutter_core/stream_chat_flutter_core.dart'; + +/// The default maximum size for media attachments. +const kDefaultMaxAttachmentSize = 100 * 1024 * 1024; // 100MB in Bytes + +/// Controller class for [StreamAttachmentPicker]. +class StreamAttachmentPickerController extends ValueNotifier { + /// Creates a new instance of [StreamAttachmentPickerController]. + factory StreamAttachmentPickerController({ + Poll? initialPoll, + List? initialAttachments, + Map? initialExtraData, + int maxAttachmentSize = kDefaultMaxAttachmentSize, + int? maxAttachmentCount, + }) { + return StreamAttachmentPickerController._fromValue( + AttachmentPickerValue( + poll: initialPoll, + attachments: initialAttachments ?? const [], + extraData: initialExtraData ?? const {}, + ), + maxAttachmentSize: maxAttachmentSize, + maxAttachmentCount: maxAttachmentCount, + ); + } + + StreamAttachmentPickerController._fromValue( + this.initialValue, { + this.maxAttachmentSize = kDefaultMaxAttachmentSize, + this.maxAttachmentCount, + }) : assert( + maxAttachmentCount == null || initialValue.attachments.length <= maxAttachmentCount, + 'The initial attachments count must be less than or equal to maxAttachmentCount', + ), + super(initialValue); + + final _customResultController = StreamController.broadcast(); + + /// Initial value for the controller. + final AttachmentPickerValue initialValue; + + /// The max attachment size allowed in bytes. + final int maxAttachmentSize; + + /// The max attachment count allowed, or `null` for no limit. + final int? maxAttachmentCount; + + @override + set value(AttachmentPickerValue newValue) { + if (maxAttachmentCount case final maxCount? when newValue.attachments.length > maxCount) { + throw AttachmentLimitReachedError(maxCount: maxCount); + } + super.value = newValue; + } + + /// Adds a new [poll] to the message. + set poll(Poll? poll) => value = value.copyWith(poll: poll); + + /// Sets the extra data value for the controller. + /// + /// This can be used to store custom attachment values in case a custom + /// attachment picker option is used. + set extraData(Map? extraData) { + value = value.copyWith(extraData: extraData); + } + + /// A stream of custom attachment picker results emitted via + /// [notifyCustomResult]. + Stream get customResults => _customResultController.stream; + + /// Emits a [CustomAttachmentPickerResult] to notify the parent widget + /// (e.g. [StreamMessageComposer]) that a custom attachment has been picked. + /// + /// Use this from a [TabbedAttachmentPickerOption.optionViewBuilder] instead + /// of calling `Navigator.pop` — the picker is an inline widget, not a modal + /// route, so popping the navigator would close the wrong page. + void notifyCustomResult(CustomAttachmentPickerResult result) { + if (!_customResultController.isClosed) _customResultController.add(result); + } + + @override + void dispose() { + _customResultController.close(); + super.dispose(); + } + + Future _saveToCache(AttachmentFile file) async { + // Cache the attachment in a temporary file. + return StreamAttachmentHandler.instance.saveAttachmentFile( + attachmentFile: file, + ); + } + + Future _removeFromCache(AttachmentFile file) { + // Remove the cached attachment file. + return StreamAttachmentHandler.instance.deleteAttachmentFile( + attachmentFile: file, + ); + } + + /// Adds a new attachment to the message. + Future addAttachment(Attachment attachment) async { + assert(attachment.fileSize != null, ''); + if (attachment.fileSize! > maxAttachmentSize) { + throw AttachmentTooLargeError( + fileSize: attachment.fileSize!, + maxSize: maxAttachmentSize, + ); + } + + final file = attachment.file; + final uploadState = attachment.uploadState; + + // No need to cache the attachment if it's already uploaded + // or we are on web. + if (file == null || uploadState.isSuccess || CurrentPlatform.isWeb) { + value = value.copyWith(attachments: [...value.attachments, attachment]); + return; + } + + // Cache the attachment in a temporary file. + final tempFilePath = await _saveToCache(file); + + value = value.copyWith( + attachments: [ + ...value.attachments, + attachment.copyWith( + file: file.copyWith( + path: tempFilePath, + ), + ), + ], + ); + } + + /// Removes the specified [attachment] from the message. + Future removeAttachment(Attachment attachment) async { + final file = attachment.file; + final uploadState = attachment.uploadState; + + if (file == null || uploadState.isSuccess || CurrentPlatform.isWeb) { + final updatedAttachments = [...value.attachments]..remove(attachment); + value = value.copyWith(attachments: updatedAttachments); + return; + } + + // Remove the cached attachment file. + await _removeFromCache(file); + + final updatedAttachments = [...value.attachments]..remove(attachment); + value = value.copyWith(attachments: updatedAttachments); + } + + /// Remove the attachment with the given [attachmentId]. + void removeAttachmentById(String attachmentId) { + final attachment = value.attachments.firstWhereOrNull( + (attachment) => attachment.id == attachmentId, + ); + + if (attachment == null) return; + + removeAttachment(attachment); + } + + /// Clears all the attachments. + Future clear() async { + final attachments = [...value.attachments]; + for (final attachment in attachments) { + final file = attachment.file; + final uploadState = attachment.uploadState; + + if (file == null || uploadState.isSuccess || CurrentPlatform.isWeb) { + continue; + } + + await _removeFromCache(file); + } + value = const AttachmentPickerValue(); + } + + /// Resets the controller to its initial state. + Future reset() async { + final attachments = [...value.attachments]; + for (final attachment in attachments) { + final file = attachment.file; + final uploadState = attachment.uploadState; + + if (file == null || uploadState.isSuccess || CurrentPlatform.isWeb) { + continue; + } + + await _removeFromCache(file); + } + + value = initialValue; + } +} + +const _nullConst = Object(); + +/// Value class for [AttachmentPickerController]. +/// +/// This class holds the list of [Poll] and [Attachment] objects. +class AttachmentPickerValue { + /// Creates a new instance of [AttachmentPickerValue]. + const AttachmentPickerValue({ + this.poll, + this.attachments = const [], + this.extraData = const {}, + }); + + /// The poll object. + final Poll? poll; + + /// The list of [Attachment] objects. + final List attachments; + + /// Extra data that can be used to store custom attachment values. + final Map extraData; + + /// Returns true if the value is empty, meaning it has no poll, + /// no attachments and no extra data set. + bool get isEmpty { + if (poll != null) return false; + if (attachments.isNotEmpty) return false; + if (extraData.isNotEmpty) return false; + + return true; + } + + /// Returns a copy of this object with the provided values. + AttachmentPickerValue copyWith({ + Object? poll = _nullConst, + List? attachments, + Map? extraData, + }) { + return AttachmentPickerValue( + poll: poll == _nullConst ? this.poll : poll as Poll?, + attachments: attachments ?? this.attachments, + extraData: extraData ?? this.extraData, + ); + } + + @override + bool operator ==(Object other) { + if (identical(this, other)) return true; + if (other.runtimeType != runtimeType) return false; + if (other is! AttachmentPickerValue) return false; + + final isPollEqual = other.poll == poll; + + final areAttachmentsEqual = UnorderedIterableEquality( + EqualityBy((attachment) => attachment.id), + ).equals(other.attachments, attachments); + + final isExtraDataEqual = const DeepCollectionEquality.unordered().equals( + other.extraData, + extraData, + ); + + return isPollEqual && areAttachmentsEqual && isExtraDataEqual; + } + + @override + int get hashCode { + final attachmentsHash = UnorderedIterableEquality( + EqualityBy((attachment) => attachment.id), + ).hash(attachments); + + final extraDataHash = const DeepCollectionEquality.unordered().hash( + extraData, + ); + + return poll.hashCode ^ attachmentsHash ^ extraDataHash; + } +} + +/// Error thrown when an attachment exceeds the maximum allowed file size. +/// +/// This occurs when calling [StreamAttachmentPickerController.addAttachment] +/// with a file whose size is greater than [maxAttachmentSize]. +/// +/// The error includes both the actual file size and the configured limit, +/// allowing you to provide specific feedback about the size violation. +class AttachmentTooLargeError extends StreamChatError { + /// Creates a new [AttachmentTooLargeError]. + const AttachmentTooLargeError({ + required this.fileSize, + required this.maxSize, + }) : super( + 'The size of the attachment is $fileSize bytes, ' + 'but the maximum size allowed is $maxSize bytes.', + ); + + /// The actual size of the attachment in bytes. + final int fileSize; + + /// The maximum allowed size in bytes. + final int maxSize; + + @override + List get props => [...super.props, fileSize, maxSize]; + + @override + String toString() => + 'AttachmentTooLargeError: ' + 'The size of the attachment is $fileSize bytes, ' + 'but the maximum size allowed is $maxSize bytes.'; +} + +/// Error thrown when the attachment count exceeds the maximum allowed. +/// +/// This occurs when setting [StreamAttachmentPickerController.value] with +/// more attachments than [maxAttachmentCount] allows. +/// +/// The error includes the configured attachment limit. +class AttachmentLimitReachedError extends StreamChatError { + /// Creates a new [AttachmentLimitReachedError]. + const AttachmentLimitReachedError({ + required this.maxCount, + }) : super('The maximum number of attachments is $maxCount.'); + + /// The maximum allowed number of attachments. + final int maxCount; + + @override + List get props => [...super.props, maxCount]; + + @override + String toString() => + 'AttachmentLimitReachedError: ' + 'The maximum number of attachments is $maxCount.'; +} diff --git a/packages/stream_chat_flutter/lib/src/message_input/attachment_picker/stream_attachment_picker_option.dart b/packages/stream_chat_flutter/lib/src/message_input/attachment_picker/stream_attachment_picker_option.dart new file mode 100644 index 0000000000..6001a5405f --- /dev/null +++ b/packages/stream_chat_flutter/lib/src/message_input/attachment_picker/stream_attachment_picker_option.dart @@ -0,0 +1,209 @@ +import 'package:collection/collection.dart'; +import 'package:flutter/material.dart'; +import 'package:stream_chat_flutter/src/message_input/attachment_picker/stream_attachment_picker_controller.dart'; + +/// Function signature for building the attachment picker option view. +typedef AttachmentPickerOptionViewBuilder = + Widget Function( + BuildContext context, + StreamAttachmentPickerController controller, + ); + +/// Function signature for system attachment picker option callback. +typedef OnSystemAttachmentPickerOptionTap = + Future Function( + BuildContext context, + StreamAttachmentPickerController controller, + ); + +/// Base class for attachment picker options. +abstract class AttachmentPickerOption { + /// Creates a new instance of [AttachmentPickerOption]. + const AttachmentPickerOption({ + this.key, + required this.supportedTypes, + required this.icon, + this.title, + this.isEnabled = _defaultIsEnabled, + }); + + /// A key to identify the option. + final String? key; + + /// The icon of the option. + final IconData icon; + + /// The title of the option. + final String? title; + + /// The supported types of the option. + final Iterable supportedTypes; + + /// Determines if this option is enabled based on the current value. + /// + /// If not provided, defaults to always returning true. + final bool Function(AttachmentPickerValue value) isEnabled; + static bool _defaultIsEnabled(AttachmentPickerValue value) => true; + + @override + bool operator ==(Object other) { + if (identical(this, other)) return true; + if (other is! AttachmentPickerOption) return false; + if (runtimeType != other.runtimeType) return false; + + final areSupportedTypesEqual = const UnorderedIterableEquality().equals( + supportedTypes, + other.supportedTypes, + ); + + return key == other.key && areSupportedTypesEqual; + } + + @override + int get hashCode { + final supportedTypesHash = const UnorderedIterableEquality().hash( + supportedTypes, + ); + + return key.hashCode ^ supportedTypesHash; + } +} + +/// Attachment picker option that shows custom UI inside the attachment picker's +/// tabbed interface. Use this when you want to display your own custom +/// interface for selecting attachments. +/// +/// This option is used when the attachment picker displays a tabbed interface +/// (typically on mobile when useSystemAttachmentPicker is false). +class TabbedAttachmentPickerOption extends AttachmentPickerOption { + /// Creates a new instance of [TabbedAttachmentPickerOption]. + const TabbedAttachmentPickerOption({ + super.key, + required super.supportedTypes, + required super.icon, + required this.optionViewBuilder, + super.title, + super.isEnabled, + }); + + /// The option view builder for custom UI. + final AttachmentPickerOptionViewBuilder optionViewBuilder; +} + +/// Attachment picker option that uses system integration +/// (file dialogs, camera, etc.). +/// +/// Use this when you want to open system pickers or perform system actions. +class SystemAttachmentPickerOption extends AttachmentPickerOption { + /// Creates a new instance of [SystemAttachmentPickerOption]. + const SystemAttachmentPickerOption({ + super.key, + required super.supportedTypes, + required super.icon, + required this.title, + required this.onTap, + super.isEnabled, + }); + + @override + final String title; + + /// The callback that is called when the option is tapped. + final OnSystemAttachmentPickerOptionTap onTap; +} + +/// Helpful extensions for [StreamAttachmentPickerController]. +extension AttachmentPickerOptionTypeX on AttachmentPickerValue { + /// Returns the list of enabled picker types. + Set filterEnabledTypes({ + required Iterable options, + }) { + final enabledTypes = {}; + for (final option in options) { + if (option.isEnabled.call(this)) { + enabledTypes.addAll(option.supportedTypes); + } + } + return enabledTypes; + } +} + +/// {@template streamAttachmentPickerType} +/// A sealed class that represents different types of attachment which a picker +/// option can support. +/// {@endtemplate} +sealed class AttachmentPickerType { + const AttachmentPickerType(); + + /// The option will allow to pick images. + static const images = ImagesPickerType(); + + /// The option will allow to pick videos. + static const videos = VideosPickerType(); + + /// The option will allow to pick audios. + static const audios = AudiosPickerType(); + + /// The option will allow to pick files or documents. + static const files = FilesPickerType(); + + /// The option will allow to create a poll. + static const poll = PollPickerType(); + + /// The option will allow to pick commands. + static const command = CommandPickerType(); + + /// A list of all predefined attachment picker types. + static const values = [images, videos, audios, files, poll, command]; +} + +/// A predefined attachment picker type that allows picking images. +final class ImagesPickerType extends AttachmentPickerType { + /// Creates a new images picker type. + const ImagesPickerType(); +} + +/// A predefined attachment picker type that allows picking videos. +final class VideosPickerType extends AttachmentPickerType { + /// Creates a new videos picker type. + const VideosPickerType(); +} + +/// A predefined attachment picker type that allows picking audios. +final class AudiosPickerType extends AttachmentPickerType { + /// Creates a new audios picker type. + const AudiosPickerType(); +} + +/// A predefined attachment picker type that allows picking files or documents. +final class FilesPickerType extends AttachmentPickerType { + /// Creates a new files picker type. + const FilesPickerType(); +} + +/// A predefined attachment picker type that allows creating polls. +final class PollPickerType extends AttachmentPickerType { + /// Creates a new poll picker type. + const PollPickerType(); +} + +/// A predefined attachment picker type that allows picking commands. +final class CommandPickerType extends AttachmentPickerType { + /// Creates a new command picker type. + const CommandPickerType(); +} + +/// A custom picker type that can be extended to support custom types of +/// attachments. This allows developers to create their own attachment picker +/// options for specialized content types. +/// +/// Example: +/// ```dart +/// class DocumentPickerType extends CustomAttachmentPickerType { +/// const DocumentPickerType(); +/// } +/// ``` +abstract class CustomAttachmentPickerType extends AttachmentPickerType { + /// Creates a new custom picker type. + const CustomAttachmentPickerType(); +} diff --git a/packages/stream_chat_flutter/lib/src/message_input/attachment_picker/stream_attachment_picker_result.dart b/packages/stream_chat_flutter/lib/src/message_input/attachment_picker/stream_attachment_picker_result.dart new file mode 100644 index 0000000000..dc4bbf563c --- /dev/null +++ b/packages/stream_chat_flutter/lib/src/message_input/attachment_picker/stream_attachment_picker_result.dart @@ -0,0 +1,54 @@ +import 'dart:async'; + +import 'package:stream_chat_flutter_core/stream_chat_flutter_core.dart'; + +/// Signature for a function that is called when a attachment picker result +/// is received. +typedef OnAttachmentPickerResult = FutureOr Function(T result); + +/// {@template streamAttachmentPickerAction} +/// A sealed class that represents different results that can be returned +/// from the attachment picker. +/// {@endtemplate} +sealed class StreamAttachmentPickerResult { + const StreamAttachmentPickerResult(); +} + +/// A result indicating that the attachment picker was met with an error. +final class AttachmentPickerError extends StreamAttachmentPickerResult { + /// Create a new attachment picker error result + const AttachmentPickerError({required this.error, this.stackTrace}); + + /// The error that occurred in the attachment picker. + final Object error; + + /// The stack trace associated with the error, if available. + final StackTrace? stackTrace; +} + +/// A result indicating that some attachments were picked using the media +/// related options in the attachment picker (e.g., camera, gallery). +final class AttachmentsPicked extends StreamAttachmentPickerResult { + /// Create a new attachments picked result + const AttachmentsPicked({required this.attachments}); + + /// The list of attachments that were picked. + final List attachments; +} + +/// A result indicating that a poll was created using the create poll option +/// in the attachment picker. +final class PollCreated extends StreamAttachmentPickerResult { + /// Create a new poll created result + const PollCreated({required this.poll}); + + /// The poll that was created. + final Poll poll; +} + +/// A custom attachment picker result that can be extended to support +/// custom type of results from the attachment picker. +class CustomAttachmentPickerResult extends StreamAttachmentPickerResult { + /// Create a new custom attachment picker result + const CustomAttachmentPickerResult(); +} diff --git a/packages/stream_chat_flutter/lib/src/message_input/audio_recorder/audio_recorder_controller.dart b/packages/stream_chat_flutter/lib/src/message_input/audio_recorder/audio_recorder_controller.dart index 1c34f3b9fc..ba5fec625f 100644 --- a/packages/stream_chat_flutter/lib/src/message_input/audio_recorder/audio_recorder_controller.dart +++ b/packages/stream_chat_flutter/lib/src/message_input/audio_recorder/audio_recorder_controller.dart @@ -30,9 +30,9 @@ class StreamAudioRecorderController extends ValueNotifier { config: switch (config) { final config? => config, _ => const RecordConfig( - numChannels: 1, - encoder: kIsWeb ? AudioEncoder.wav : AudioEncoder.aacLc, - ), + numChannels: 1, + encoder: kIsWeb ? AudioEncoder.wav : AudioEncoder.aacLc, + ), }, ); } @@ -44,8 +44,8 @@ class StreamAudioRecorderController extends ValueNotifier { required AudioRecorder recorder, AudioRecorderState initialState = const RecordStateIdle(), Duration amplitudeInterval = const Duration(milliseconds: 100), - }) : _recorder = recorder, - super(initialState) { + }) : _recorder = recorder, + super(initialState) { // Listen to the recorder amplitude changes _recorderAmplitudeSubscription = _recorder .onAmplitudeChanged(amplitudeInterval) // @@ -61,8 +61,13 @@ class StreamAudioRecorderController extends ValueNotifier { // Only start the recorder if it is currently idle. if (value case RecordStateIdle()) { // Return if the recorder does not have permission to record audio. - final hasPermission = await _recorder.hasPermission(); - if (!hasPermission) return; + final hasPermission = await _recorder.hasPermission(request: false); + if (!hasPermission) { + /// Request permission to record audio. + /// User has to start the recording session again to record audio. + await _recorder.hasPermission(request: true); + return; + } // Start the recording session. final tempPath = await _getOutputFilePath(config.encoder); diff --git a/packages/stream_chat_flutter/lib/src/message_input/audio_recorder/audio_recorder_feedback.dart b/packages/stream_chat_flutter/lib/src/message_input/audio_recorder/audio_recorder_feedback.dart index 9bf98d2e1b..d33ce45657 100644 --- a/packages/stream_chat_flutter/lib/src/message_input/audio_recorder/audio_recorder_feedback.dart +++ b/packages/stream_chat_flutter/lib/src/message_input/audio_recorder/audio_recorder_feedback.dart @@ -175,13 +175,13 @@ class AudioRecorderFeedbackWrapper extends AudioRecorderFeedback { _FeedbackCallback? onCancel, _FeedbackCallback? onStartCancel, _FeedbackCallback? onStop, - }) : _onStop = onStop, - _onStartCancel = onStartCancel, - _onCancel = onCancel, - _onLock = onLock, - _onFinish = onFinish, - _onPause = onPause, - _onStart = onStart; + }) : _onStop = onStop, + _onStartCancel = onStartCancel, + _onCancel = onCancel, + _onLock = onLock, + _onFinish = onFinish, + _onPause = onPause, + _onStart = onStart; // Callback for when recording starts. final _FeedbackCallback? _onStart; diff --git a/packages/stream_chat_flutter/lib/src/message_input/audio_recorder/audio_recorder_state.dart b/packages/stream_chat_flutter/lib/src/message_input/audio_recorder/audio_recorder_state.dart index 95bdeff0bb..96bb1f00cc 100644 --- a/packages/stream_chat_flutter/lib/src/message_input/audio_recorder/audio_recorder_state.dart +++ b/packages/stream_chat_flutter/lib/src/message_input/audio_recorder/audio_recorder_state.dart @@ -51,13 +51,13 @@ sealed class RecordStateRecording extends AudioRecorderState { }) { return switch (this) { RecordStateRecordingHold() => RecordStateRecordingHold( - duration: duration ?? this.duration, - waveform: waveform ?? this.waveform, - ), + duration: duration ?? this.duration, + waveform: waveform ?? this.waveform, + ), RecordStateRecordingLocked() => RecordStateRecordingLocked( - duration: duration ?? this.duration, - waveform: waveform ?? this.waveform, - ), + duration: duration ?? this.duration, + waveform: waveform ?? this.waveform, + ), }; } } diff --git a/packages/stream_chat_flutter/lib/src/message_input/audio_recorder/stream_audio_recorder.dart b/packages/stream_chat_flutter/lib/src/message_input/audio_recorder/stream_audio_recorder.dart index 241b1f5efb..4f410c1c3e 100644 --- a/packages/stream_chat_flutter/lib/src/message_input/audio_recorder/stream_audio_recorder.dart +++ b/packages/stream_chat_flutter/lib/src/message_input/audio_recorder/stream_audio_recorder.dart @@ -5,14 +5,13 @@ import 'package:flutter_portal/flutter_portal.dart'; import 'package:stream_chat_flutter/src/audio/audio_playlist_controller.dart'; import 'package:stream_chat_flutter/src/audio/audio_playlist_state.dart'; import 'package:stream_chat_flutter/src/audio/audio_sampling.dart'; -import 'package:stream_chat_flutter/src/icons/stream_svg_icon.dart'; import 'package:stream_chat_flutter/src/message_input/audio_recorder/audio_recorder_feedback.dart'; import 'package:stream_chat_flutter/src/message_input/audio_recorder/audio_recorder_state.dart'; import 'package:stream_chat_flutter/src/message_input/stream_message_input_icon_button.dart'; -import 'package:stream_chat_flutter/src/misc/audio_waveform.dart'; import 'package:stream_chat_flutter/src/misc/empty_widget.dart'; import 'package:stream_chat_flutter/src/theme/stream_chat_theme.dart'; import 'package:stream_chat_flutter/src/utils/extensions.dart'; +import 'package:stream_core_flutter/stream_core_flutter.dart'; /// {@template audioRecorderBuilder} /// A builder function for constructing the audio recorder UI. @@ -21,11 +20,12 @@ import 'package:stream_chat_flutter/src/utils/extensions.dart'; /// - [StreamAudioRecorderButton], which uses this builder function. /// - [StreamAudioRecorderState], which provides the state of the recorder. /// {@endtemplate} -typedef AudioRecorderBuilder = Widget Function( - BuildContext, - AudioRecorderState, - Widget, -); +typedef AudioRecorderBuilder = + Widget Function( + BuildContext, + AudioRecorderState, + Widget, + ); /// {@template streamAudioRecorderButton} /// A configurable audio recording button with interactive states and gestures. @@ -172,49 +172,49 @@ class StreamAudioRecorderButton extends StatelessWidget { state: recordState, button: RecordButton( onPressed: () {}, // Allows showing ripple effect on tap. - icon: const StreamSvgIcon(icon: StreamSvgIcons.mic), + icon: Icon(context.streamIcons.voice), ), builder: (context, state, recordButton) => switch (state) { // Show only the record button if the recording is not in progress. RecordStateIdle() => RecordStateIdleContent( - state: state, - recordButton: recordButton, - ), + state: state, + recordButton: recordButton, + ), RecordStateRecordingHold() => RecordStateHoldRecordingContent( - state: state, - recordButton: recordButton, - cancelThreshold: cancelRecordThreshold, - ), + state: state, + recordButton: recordButton, + cancelThreshold: cancelRecordThreshold, + ), RecordStateRecordingLocked() => RecordStateLockedRecordingContent( - state: state, - onRecordEnd: () async { - await feedback.onRecordFinish(context); - return onRecordFinish?.call(); - }, - onRecordPause: () async { - await feedback.onRecordPause(context); - return onRecordPause?.call(); - }, - onRecordCancel: () async { - await feedback.onRecordCancel(context); - return onRecordCancel?.call(); - }, - onRecordStop: () async { - await feedback.onRecordStop(context); - return onRecordStop?.call(); - }, - ), + state: state, + onRecordEnd: () async { + await feedback.onRecordFinish(context); + return onRecordFinish?.call(); + }, + onRecordPause: () async { + await feedback.onRecordPause(context); + return onRecordPause?.call(); + }, + onRecordCancel: () async { + await feedback.onRecordCancel(context); + return onRecordCancel?.call(); + }, + onRecordStop: () async { + await feedback.onRecordStop(context); + return onRecordStop?.call(); + }, + ), RecordStateStopped() => RecordStateStoppedContent( - state: state, - onRecordCancel: () async { - await feedback.onRecordCancel(context); - return onRecordCancel?.call(); - }, - onRecordFinish: () async { - await feedback.onRecordFinish(context); - return onRecordFinish?.call(); - }, - ), + state: state, + onRecordCancel: () async { + await feedback.onRecordCancel(context); + return onRecordCancel?.call(); + }, + onRecordFinish: () async { + await feedback.onRecordFinish(context); + return onRecordFinish?.call(); + }, + ), }, ), ); @@ -448,20 +448,20 @@ class RecordStateLockedRecordingContent extends StatelessWidget { mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ StreamMessageInputIconButton( - icon: const StreamSvgIcon(icon: StreamSvgIcons.delete), + icon: Icon(context.streamIcons.delete), color: theme.colorTheme.accentPrimary, onPressed: onRecordCancel, ), StreamMessageInputIconButton( - icon: const StreamSvgIcon(icon: StreamSvgIcons.stop), + icon: Icon(context.streamIcons.stopFill), color: theme.colorTheme.accentError, onPressed: onRecordStop, ), StreamMessageInputIconButton( - icon: const StreamSvgIcon(icon: StreamSvgIcons.checkSend), + icon: Icon(context.streamIcons.checkmark), color: theme.colorTheme.accentPrimary, onPressed: onRecordEnd, - ) + ), ], ), ], @@ -498,8 +498,7 @@ class RecordStateStoppedContent extends StatefulWidget { final VoidCallback? onRecordFinish; @override - State createState() => - _RecordStateStoppedContentState(); + State createState() => _RecordStateStoppedContentState(); } class _RecordStateStoppedContentState extends State { @@ -607,15 +606,15 @@ class _RecordStateStoppedContentState extends State { mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ StreamMessageInputIconButton( - icon: const StreamSvgIcon(icon: StreamSvgIcons.delete), + icon: Icon(context.streamIcons.delete), color: theme.colorTheme.accentPrimary, onPressed: widget.onRecordCancel, ), StreamMessageInputIconButton( - icon: const StreamSvgIcon(icon: StreamSvgIcons.checkSend), + icon: Icon(context.streamIcons.checkmark), color: theme.colorTheme.accentPrimary, onPressed: widget.onRecordFinish, - ) + ), ], ), ], @@ -643,29 +642,31 @@ class SwipeToLockButton extends StatelessWidget { @override Widget build(BuildContext context) { - final theme = StreamChatTheme.of(context); + final streamIcons = context.streamIcons; + final colorScheme = context.streamColorScheme; + return Container( - padding: const EdgeInsets.all(8), + padding: const EdgeInsets.all(10), decoration: BoxDecoration( borderRadius: BorderRadius.circular(24), - color: theme.colorTheme.inputBg, + color: colorScheme.backgroundElevation1, + border: Border.all(color: colorScheme.borderDefault), + boxShadow: context.streamBoxShadow.elevation1, ), child: Column( spacing: 8, mainAxisSize: MainAxisSize.min, children: [ - StreamSvgIcon( - icon: StreamSvgIcons.lock, - size: kDefaultMessageInputIconSize, - color: switch (isLocked) { - true => theme.colorTheme.accentPrimary, - false => theme.colorTheme.textLowEmphasis, - }, + Icon( + isLocked ? streamIcons.lock : streamIcons.unlock, + size: 20, + color: colorScheme.textPrimary, ), if (!isLocked) ...[ - StreamSvgIcon( - icon: StreamSvgIcons.up, - color: theme.colorTheme.textLowEmphasis, + Icon( + streamIcons.chevronUp, + size: 20, + color: colorScheme.textPrimary, ), ], ], @@ -718,24 +719,24 @@ class PlaybackControlButton extends StatelessWidget { }, icon: switch (state) { TrackState.loading => Builder( - builder: (context) { - final iconTheme = IconTheme.of(context); - return SizedBox.fromSize( - size: Size.square(iconTheme.size!), - child: Padding( - padding: const EdgeInsets.all(8), - child: CircularProgressIndicator.adaptive( - valueColor: AlwaysStoppedAnimation( - theme.colorTheme.accentPrimary, - ), + builder: (context) { + final iconTheme = IconTheme.of(context); + return SizedBox.fromSize( + size: Size.square(iconTheme.size!), + child: Padding( + padding: const EdgeInsets.all(8), + child: CircularProgressIndicator.adaptive( + valueColor: AlwaysStoppedAnimation( + theme.colorTheme.accentPrimary, ), ), - ); - }, - ), - TrackState.idle => const StreamSvgIcon(icon: StreamSvgIcons.play), - TrackState.paused => const StreamSvgIcon(icon: StreamSvgIcons.play), - TrackState.playing => const StreamSvgIcon(icon: StreamSvgIcons.pause), + ), + ); + }, + ), + TrackState.idle => Icon(context.streamIcons.playFill), + TrackState.paused => Icon(context.streamIcons.playFill), + TrackState.playing => Icon(context.streamIcons.pauseFill), }, ); } @@ -763,8 +764,8 @@ class PlaybackTimerIndicator extends StatelessWidget { final theme = StreamChatTheme.of(context); return Row( children: [ - StreamSvgIcon( - icon: StreamSvgIcons.mic, + Icon( + context.streamIcons.voice, size: kDefaultMessageInputIconSize, color: switch (duration.inSeconds) { > 0 => theme.colorTheme.accentError, @@ -808,11 +809,9 @@ class PlaybackTimerText extends StatelessWidget { @override Widget build(BuildContext context) { + final remaining = duration - position; return Text( - switch (position.inMilliseconds > 0) { - true => position.toMinutesAndSeconds(), - false => duration.toMinutesAndSeconds(), - }, + remaining.toMinutesAndSeconds(), style: style?.copyWith( // Use mono space for each num character. fontFeatures: [const FontFeature.tabularFigures()], @@ -854,8 +853,8 @@ class SlideToCancelIndicator extends StatelessWidget { ), ), const SizedBox(width: 8), - StreamSvgIcon( - icon: StreamSvgIcons.left, + Icon( + context.streamIcons.chevronLeft, color: theme.colorTheme.textLowEmphasis, ), ], @@ -949,13 +948,16 @@ class _SlideTransitionWidgetState extends State @override Widget build(BuildContext context) { - final position = Tween( - begin: widget.begin, - end: widget.end, - ).animate(CurvedAnimation( - parent: _controller, - curve: widget.curve, - )); + final position = + Tween( + begin: widget.begin, + end: widget.end, + ).animate( + CurvedAnimation( + parent: _controller, + curve: widget.curve, + ), + ); return SlideTransition( position: position, @@ -983,30 +985,23 @@ class HoldToRecordInfoTooltip extends StatelessWidget { @override Widget build(BuildContext context) { - final theme = StreamChatTheme.of(context); - - const recordButtonWidth = kDefaultMessageInputIconSize + - kDefaultMessageInputIconPadding * 2; // right, left padding. - - const arrowSize = Size(recordButtonWidth / 2, 6); + final spacing = context.streamSpacing; + final colorScheme = context.streamColorScheme; + final radius = context.streamRadius; + final textTheme = context.streamTextTheme; - return Padding( - padding: EdgeInsets.only(bottom: arrowSize.height), - child: CustomPaint( - painter: TooltipPainter( - arrowSize: arrowSize, - arrowMargin: arrowSize.width / 2, - color: theme.colorTheme.textLowEmphasis, - borderRadius: BorderRadius.circular(24), - ), - child: Padding( - padding: const EdgeInsets.symmetric(vertical: 4, horizontal: 12), - child: Text( - message, - style: theme.textTheme.body.copyWith( - color: theme.colorTheme.barsBg, - ), - ), + return Container( + margin: const EdgeInsets.symmetric(horizontal: 16), + padding: EdgeInsets.symmetric(vertical: spacing.sm, horizontal: spacing.xs * 2), + decoration: ShapeDecoration( + color: colorScheme.backgroundInverse, + shape: RoundedSuperellipseBorder(borderRadius: .all(radius.max)), + ), + child: Text( + message, + textAlign: TextAlign.center, + style: textTheme.captionDefault.copyWith( + color: colorScheme.textOnInverse, ), ), ); diff --git a/packages/stream_chat_flutter/lib/src/message_input/clear_input_item_button.dart b/packages/stream_chat_flutter/lib/src/message_input/clear_input_item_button.dart index ddfb0c20e0..1217f4172f 100644 --- a/packages/stream_chat_flutter/lib/src/message_input/clear_input_item_button.dart +++ b/packages/stream_chat_flutter/lib/src/message_input/clear_input_item_button.dart @@ -34,9 +34,9 @@ class ClearInputItemButton extends StatelessWidget { // ignore: deprecated_member_use _streamChatTheme.colorTheme.textHighEmphasis.withOpacity(0.5), child: Center( - child: StreamSvgIcon( + child: Icon( + context.streamIcons.xmark, size: 24, - icon: StreamSvgIcons.close, color: _streamChatTheme.colorTheme.barsBg, ), ), diff --git a/packages/stream_chat_flutter/lib/src/message_input/command_button.dart b/packages/stream_chat_flutter/lib/src/message_input/command_button.dart index 1e8c050cb0..7b767c775d 100644 --- a/packages/stream_chat_flutter/lib/src/message_input/command_button.dart +++ b/packages/stream_chat_flutter/lib/src/message_input/command_button.dart @@ -1,6 +1,6 @@ import 'package:flutter/material.dart'; -import 'package:stream_chat_flutter/src/icons/stream_svg_icon.dart'; import 'package:stream_chat_flutter/src/message_input/stream_message_input_icon_button.dart'; +import 'package:stream_core_flutter/stream_core_flutter.dart'; /// {@template commandButton} /// The button that allows a user to use commands in a chat. @@ -53,7 +53,7 @@ class CommandButton extends StatelessWidget { color: color, iconSize: size, onPressed: onPressed, - icon: icon ?? const StreamSvgIcon(icon: StreamSvgIcons.lightning), + icon: icon ?? Icon(context.streamIcons.bolt), ); } } diff --git a/packages/stream_chat_flutter/lib/src/message_input/dm_checkbox.dart b/packages/stream_chat_flutter/lib/src/message_input/dm_checkbox.dart deleted file mode 100644 index 0a69b8d5fe..0000000000 --- a/packages/stream_chat_flutter/lib/src/message_input/dm_checkbox.dart +++ /dev/null @@ -1,78 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:stream_chat_flutter/stream_chat_flutter.dart'; - -/// {@template dmCheckbox} -/// Prompts the user to send a reply to a message thread as a DM. -/// {@endtemplate} -@Deprecated("Use 'DmCheckboxListTile' instead.") -class DmCheckbox extends StatelessWidget { - /// {@macro dmCheckbox} - const DmCheckbox({ - super.key, - required this.foregroundDecoration, - required this.color, - required this.onTap, - required this.crossFadeState, - }); - - /// The decoration to use for the button's foreground. - final BoxDecoration foregroundDecoration; - - /// The color to use for the button. - final Color color; - - /// The action to perform when the button is tapped or clicked. - final VoidCallback onTap; - - /// The [CrossFadeState] of the animation. - final CrossFadeState crossFadeState; - - @override - Widget build(BuildContext context) { - final _streamChatTheme = StreamChatTheme.of(context); - return Row( - mainAxisSize: MainAxisSize.min, - children: [ - Container( - height: 16, - width: 16, - foregroundDecoration: foregroundDecoration, - child: Center( - child: Material( - borderRadius: BorderRadius.circular(3), - color: color, - child: InkWell( - onTap: onTap, - child: AnimatedCrossFade( - duration: const Duration(milliseconds: 300), - reverseDuration: const Duration(milliseconds: 300), - crossFadeState: crossFadeState, - firstChild: StreamSvgIcon( - size: 16, - icon: StreamSvgIcons.check, - color: _streamChatTheme.colorTheme.barsBg, - ), - secondChild: const SizedBox( - height: 16, - width: 16, - ), - ), - ), - ), - ), - ), - Padding( - padding: const EdgeInsets.symmetric(horizontal: 12), - child: Text( - context.translations.alsoSendAsDirectMessageLabel, - style: _streamChatTheme.textTheme.footnote.copyWith( - color: - // ignore: deprecated_member_use - _streamChatTheme.colorTheme.textHighEmphasis.withOpacity(0.5), - ), - ), - ), - ], - ); - } -} diff --git a/packages/stream_chat_flutter/lib/src/message_input/dm_checkbox_list_tile.dart b/packages/stream_chat_flutter/lib/src/message_input/dm_checkbox_list_tile.dart index e9b0d7c372..d6a6c5fc74 100644 --- a/packages/stream_chat_flutter/lib/src/message_input/dm_checkbox_list_tile.dart +++ b/packages/stream_chat_flutter/lib/src/message_input/dm_checkbox_list_tile.dart @@ -1,6 +1,5 @@ import 'package:flutter/material.dart'; -import 'package:stream_chat_flutter/src/theme/stream_chat_theme.dart'; -import 'package:stream_chat_flutter/src/utils/extensions.dart'; +import 'package:stream_chat_flutter/stream_chat_flutter.dart'; /// {@template dmCheckboxListTile} /// A widget that represents a checkbox list tile for direct message input. @@ -25,37 +24,24 @@ class DmCheckboxListTile extends StatelessWidget { @override Widget build(BuildContext context) { - final theme = StreamChatTheme.of(context); - final textTheme = theme.textTheme; - final colorTheme = theme.colorTheme; + final spacing = context.streamSpacing; + final textTheme = context.streamTextTheme; + final colorScheme = context.streamColorScheme; - const visualDensity = VisualDensity( - vertical: VisualDensity.minimumDensity, - horizontal: VisualDensity.minimumDensity, - ); + // NOTE: Only squeeze the vertical axis. `ListTile` computes its effective + // horizontal title gap as `horizontalTitleGap + visualDensity.horizontal * 2` + // so a negative horizontal density would silently cancel out our `xs` gap + // (and even produce a negative gap, overlapping the checkbox and title). + const visualDensity = VisualDensity(vertical: VisualDensity.minimumDensity); final checkbox = ExcludeFocus( - child: CheckboxTheme( - data: CheckboxThemeData( - overlayColor: WidgetStatePropertyAll( - colorTheme.accentPrimary.withAlpha(kRadialReactionAlpha), - ), - ), - child: Checkbox( - value: value, - visualDensity: visualDensity, - activeColor: colorTheme.accentPrimary, - materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, - side: BorderSide(width: 2, color: colorTheme.textLowEmphasis), - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(3)), - onChanged: switch (onChanged) { - final onChanged? => (value) { - if (value == null) return; - return onChanged.call(value); - }, - _ => null, - }, - ), + child: StreamCheckbox( + value: value, + size: StreamCheckboxSize.sm, + onChanged: switch (onChanged) { + final onChanged? => onChanged.call, + _ => null, + }, ), ); @@ -63,17 +49,15 @@ class DmCheckboxListTile extends StatelessWidget { child: ListTile( dense: true, leading: checkbox, - horizontalTitleGap: 16, + minLeadingWidth: 0, + horizontalTitleGap: spacing.xs, visualDensity: visualDensity, enabled: onChanged != null, onTap: () => onChanged?.call(!value), contentPadding: contentPadding, title: Text( context.translations.alsoSendAsDirectMessageLabel, - style: textTheme.footnote.copyWith( - // ignore: deprecated_member_use - color: colorTheme.textHighEmphasis.withOpacity(0.5), - ), + style: textTheme.metadataDefault.copyWith(color: colorScheme.textPrimary), ), ), ); diff --git a/packages/stream_chat_flutter/lib/src/message_input/enums.dart b/packages/stream_chat_flutter/lib/src/message_input/enums.dart index 5b34b7f132..7fcd315490 100644 --- a/packages/stream_chat_flutter/lib/src/message_input/enums.dart +++ b/packages/stream_chat_flutter/lib/src/message_input/enums.dart @@ -1,4 +1,4 @@ -/// Location for actions on the [StreamMessageInput]. +/// Location for actions on the [StreamMessageComposer]. enum ActionsLocation { /// Align to left left, diff --git a/packages/stream_chat_flutter/lib/src/bottom_sheets/error_alert_sheet.dart b/packages/stream_chat_flutter/lib/src/message_input/error_alert_sheet.dart similarity index 96% rename from packages/stream_chat_flutter/lib/src/bottom_sheets/error_alert_sheet.dart rename to packages/stream_chat_flutter/lib/src/message_input/error_alert_sheet.dart index 744664dd8f..bd1a4ee224 100644 --- a/packages/stream_chat_flutter/lib/src/bottom_sheets/error_alert_sheet.dart +++ b/packages/stream_chat_flutter/lib/src/message_input/error_alert_sheet.dart @@ -25,8 +25,8 @@ class ErrorAlertSheet extends StatelessWidget { const SizedBox( height: 26, ), - StreamSvgIcon( - icon: StreamSvgIcons.error, + Icon( + context.streamIcons.exclamationCircleFill, color: _streamChatTheme.colorTheme.accentError, size: 24, ), diff --git a/packages/stream_chat_flutter/lib/src/message_input/message_input_placeholder.dart b/packages/stream_chat_flutter/lib/src/message_input/message_input_placeholder.dart new file mode 100644 index 0000000000..5563ccb896 --- /dev/null +++ b/packages/stream_chat_flutter/lib/src/message_input/message_input_placeholder.dart @@ -0,0 +1,141 @@ +import 'package:flutter/widgets.dart'; +import 'package:stream_chat_flutter/stream_chat_flutter.dart'; + +/// Sealed hierarchy describing why a particular placeholder is shown in +/// [StreamMessageComposer]. +/// +/// The state is resolved once per rebuild from the current +/// [StreamMessageComposerController] using [MessageInputPlaceholder.resolve], +/// then handed to a [MessageInputPlaceholderBuilder] to produce the actual +/// placeholder string that gets passed down to the underlying +/// [StreamChatMessageInput]. +/// +/// Each case carries the contextual data relevant to that state — for example +/// [SlowModePlaceholder.cooldownTimeOut] for the remaining cooldown, or +/// [AttachmentsPlaceholder.attachments] for the pending attachments — so a +/// custom builder can use it to render rich, context-aware placeholders. +/// +/// Use an exhaustive `switch` over the cases in your custom builder so that +/// adding a new case in a future SDK release becomes a compile error you can +/// easily fix: +/// +/// ```dart +/// String? myPlaceholderBuilder( +/// BuildContext context, +/// MessageInputPlaceholder placeholder, +/// ) { +/// final translations = context.translations; +/// return switch (placeholder) { +/// SlowModePlaceholder() => translations.slowModeOnLabel, +/// CommandPlaceholder(command: 'giphy') => translations.searchGifLabel, +/// CommandPlaceholder(command: 'mute' || 'unmute' || 'ban' || 'unban') => +/// translations.commandUsernameLabel, +/// CommandPlaceholder(command: 'weather') => 'Type a city name', +/// CommandPlaceholder() => translations.writeAMessageLabel, +/// AttachmentsPlaceholder() => translations.addACommentOrSendLabel, +/// WriteMessagePlaceholder() => translations.writeAMessageLabel, +/// }; +/// } +/// ``` +sealed class MessageInputPlaceholder { + /// Creates a new instance of [MessageInputPlaceholder]. + const MessageInputPlaceholder(); + + /// Resolves the appropriate placeholder for the current state of the + /// [controller]. + /// + /// Precedence (highest to lowest): + /// 1. [SlowModePlaceholder] when the channel is in slow mode for the + /// current user. + /// 2. [CommandPlaceholder] when [StreamMessageComposerController.message] has + /// an active command. + /// 3. [AttachmentsPlaceholder] when there are pending attachments but no + /// text yet. + /// 4. [WriteMessagePlaceholder] otherwise. + factory MessageInputPlaceholder.resolve( + StreamMessageComposerController controller, + ) { + if (controller.isSlowModeActive) { + return SlowModePlaceholder(cooldownTimeOut: controller.cooldownTimeOut); + } + if (controller.message.command case final command?) { + return CommandPlaceholder(command: command); + } + if (controller.attachments.isNotEmpty) { + return AttachmentsPlaceholder(attachments: controller.attachments); + } + return WriteMessagePlaceholder(isEditing: controller.isEditing); + } +} + +/// Default placeholder shown when the input is idle and no other state +/// ([SlowModePlaceholder], [CommandPlaceholder], [AttachmentsPlaceholder]) +/// applies. +/// +/// [isEditing] is `true` when the input is editing an existing message rather +/// than composing a new one. Consumers can use this to swap the placeholder +/// text when in edit mode. +final class WriteMessagePlaceholder extends MessageInputPlaceholder { + /// Creates a new instance of [WriteMessagePlaceholder]. + const WriteMessagePlaceholder({this.isEditing = false}); + + /// Whether the input is editing an existing message. + final bool isEditing; +} + +/// Placeholder shown when the channel has slow mode active for the current +/// user. +/// +/// The placeholder text typically tells the user how long they need to wait +/// before sending another message. The remaining cooldown is exposed both as +/// raw seconds via [cooldownTimeOut] and as a [Duration] via [cooldown] for +/// convenience when formatting timer strings. +final class SlowModePlaceholder extends MessageInputPlaceholder { + /// Creates a new instance of [SlowModePlaceholder]. + const SlowModePlaceholder({required this.cooldownTimeOut}); + + /// The remaining slow-mode cooldown in seconds. + /// + /// Mirrors [StreamMessageComposerController.cooldownTimeOut]. + final int cooldownTimeOut; + + /// The remaining slow-mode cooldown as a [Duration]. + Duration get cooldown => Duration(seconds: cooldownTimeOut); +} + +/// Placeholder shown when the input has an active command. +/// +/// The [command] is the command name as stored on [Message.command] (for +/// example `'mute'`, `'giphy'`, or any backend-defined command). Use this to +/// render command-specific guidance, for example `@username` for `/mute` or +/// `Search GIFs` for `/giphy`. +final class CommandPlaceholder extends MessageInputPlaceholder { + /// Creates a new instance of [CommandPlaceholder]. + const CommandPlaceholder({required this.command}); + + /// The active command name. + final String command; +} + +/// Placeholder shown when the input has pending attachments but no text yet. +/// +/// The pending [attachments] are exposed so consumers can render a +/// context-aware placeholder (for example "Add a comment to your photo" when +/// the only attachment is an image). +final class AttachmentsPlaceholder extends MessageInputPlaceholder { + /// Creates a new instance of [AttachmentsPlaceholder]. + const AttachmentsPlaceholder({required this.attachments}); + + /// The pending attachments currently held by the input. + /// + /// OG link previews are still included here — filter them out via + /// [Attachment.ogScrapeUrl] if you want only user-added attachments. + final List attachments; +} + +/// Returns the placeholder string shown inside [StreamMessageComposer]'s text +/// field. +/// +/// Receives the current [MessageInputPlaceholder] state and may return a +/// localized string or `null` to show no placeholder. +typedef MessageInputPlaceholderBuilder = String? Function(BuildContext, MessageInputPlaceholder); diff --git a/packages/stream_chat_flutter/lib/src/message_input/quoted_message_widget.dart b/packages/stream_chat_flutter/lib/src/message_input/quoted_message_widget.dart deleted file mode 100644 index 24611a0a13..0000000000 --- a/packages/stream_chat_flutter/lib/src/message_input/quoted_message_widget.dart +++ /dev/null @@ -1,347 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:stream_chat_flutter/src/message_input/clear_input_item_button.dart'; -import 'package:stream_chat_flutter/src/misc/empty_widget.dart'; -import 'package:stream_chat_flutter/stream_chat_flutter.dart'; - -typedef _Builders = Map; - -/// {@template streamQuotedMessage} -/// Widget for the quoted message. -/// {@endtemplate} -class StreamQuotedMessageWidget extends StatelessWidget { - /// {@macro streamQuotedMessage} - const StreamQuotedMessageWidget({ - super.key, - required this.message, - required this.messageTheme, - this.reverse = false, - this.showBorder = false, - this.textLimit = 170, - this.textBuilder, - this.attachmentThumbnailBuilders, - this.padding = const EdgeInsets.all(8), - this.onQuotedMessageClear, - }); - - /// The message - final Message message; - - /// The message theme - final StreamMessageThemeData messageTheme; - - /// If true the widget will be mirrored - final bool reverse; - - /// If true the message will show a grey border - final bool showBorder; - - /// limit of the text message shown - final int textLimit; - - /// Map that defines a thumbnail builder for an attachment type - final _Builders? attachmentThumbnailBuilders; - - /// Padding around the widget - final EdgeInsetsGeometry padding; - - /// Callback for clearing quoted messages. - final VoidCallback? onQuotedMessageClear; - - /// {@macro textBuilder} - final Widget Function(BuildContext, Message)? textBuilder; - - @override - Widget build(BuildContext context) { - final children = [ - Flexible( - child: _QuotedMessage( - message: message, - textLimit: textLimit, - messageTheme: messageTheme, - showBorder: showBorder, - reverse: reverse, - textBuilder: textBuilder, - onQuotedMessageClear: onQuotedMessageClear, - attachmentThumbnailBuilders: attachmentThumbnailBuilders, - ), - ), - const SizedBox(width: 8), - if (message.user != null) - StreamUserAvatar( - user: message.user!, - constraints: const BoxConstraints.tightFor( - height: 24, - width: 24, - ), - showOnlineStatus: false, - ), - ]; - return Padding( - padding: padding, - child: Row( - crossAxisAlignment: CrossAxisAlignment.end, - mainAxisSize: MainAxisSize.min, - children: reverse ? children.reversed.toList() : children, - ), - ); - } -} - -class _QuotedMessage extends StatelessWidget { - const _QuotedMessage({ - required this.message, - required this.textLimit, - required this.messageTheme, - required this.showBorder, - required this.reverse, - this.textBuilder, - this.onQuotedMessageClear, - this.attachmentThumbnailBuilders, - }); - - final Message message; - final int textLimit; - final VoidCallback? onQuotedMessageClear; - final StreamMessageThemeData messageTheme; - final bool showBorder; - final bool reverse; - final Widget Function(BuildContext, Message)? textBuilder; - - final _Builders? attachmentThumbnailBuilders; - - bool get _hasAttachments => message.attachments.isNotEmpty; - - bool get _containsText => message.text?.isNotEmpty == true; - - bool get _containsLinkAttachment => - message.attachments.any((it) => it.type == AttachmentType.urlPreview); - - bool get _isGiphy => message.attachments - .any((element) => element.type == AttachmentType.giphy); - - bool get _isDeleted => message.isDeleted || message.deletedAt != null; - - bool get _isPoll => message.poll != null; - - @override - Widget build(BuildContext context) { - final isOnlyEmoji = message.text!.isOnlyEmoji; - var msg = _hasAttachments && !_containsText - ? message.copyWith(text: message.attachments.last.title ?? '') - : message; - if (msg.text!.length > textLimit) { - msg = msg.copyWith(text: '${msg.text!.substring(0, textLimit - 3)}...'); - } - - List children; - if (_isDeleted) { - // Show deleted message text - children = [ - Text( - context.translations.messageDeletedLabel, - style: messageTheme.messageTextStyle?.copyWith( - fontStyle: FontStyle.italic, - color: messageTheme.createdAtStyle?.color, - ), - ), - ]; - } else if (_isPoll) { - // Show poll message - children = [ - Flexible( - child: Text( - '📊 ${message.poll?.name}', - style: messageTheme.messageTextStyle?.copyWith( - fontSize: 12, - ), - ), - ), - ]; - } else { - // Show quoted message - children = [ - if (_hasAttachments) - _ParseAttachments( - message: message, - messageTheme: messageTheme, - attachmentThumbnailBuilders: attachmentThumbnailBuilders, - ), - if (msg.text!.isNotEmpty && !_isGiphy) - Flexible( - child: textBuilder?.call(context, msg) ?? - StreamMessageText( - message: msg, - messageTheme: isOnlyEmoji && _containsText - ? messageTheme.copyWith( - messageTextStyle: - messageTheme.messageTextStyle?.copyWith( - fontSize: 32, - ), - ) - : messageTheme.copyWith( - messageTextStyle: - messageTheme.messageTextStyle?.copyWith( - fontSize: 12, - ), - ), - ), - ), - ]; - } - - // Add clear button if needed. - if (isDesktopDeviceOrWeb && onQuotedMessageClear != null) { - children.insert( - 0, - ClearInputItemButton(onTap: onQuotedMessageClear), - ); - } - - return Container( - decoration: BoxDecoration( - color: _getBackgroundColor(context), - border: showBorder - ? Border.all( - color: StreamChatTheme.of(context).colorTheme.disabled, - ) - : null, - borderRadius: BorderRadius.only( - topRight: const Radius.circular(12), - topLeft: const Radius.circular(12), - bottomRight: reverse ? const Radius.circular(12) : Radius.zero, - bottomLeft: reverse ? Radius.zero : const Radius.circular(12), - ), - ), - padding: const EdgeInsets.all(8), - child: Row( - spacing: 8, - mainAxisSize: MainAxisSize.min, - mainAxisAlignment: - reverse ? MainAxisAlignment.end : MainAxisAlignment.start, - children: reverse ? children.reversed.toList() : children, - ), - ); - } - - Color? _getBackgroundColor(BuildContext context) { - if (_containsLinkAttachment && !_isDeleted) { - return messageTheme.urlAttachmentBackgroundColor; - } - return messageTheme.messageBackgroundColor; - } -} - -class _ParseAttachments extends StatelessWidget { - const _ParseAttachments({ - required this.message, - required this.messageTheme, - this.attachmentThumbnailBuilders, - }); - - final Message message; - final StreamMessageThemeData messageTheme; - final _Builders? attachmentThumbnailBuilders; - - @override - Widget build(BuildContext context) { - final attachment = message.attachments.first; - - var attachmentBuilders = attachmentThumbnailBuilders; - attachmentBuilders ??= _createDefaultAttachmentBuilders(); - - // Build the attachment widget using the builder for the attachment type. - final attachmentWidget = attachmentBuilders[attachment.type]?.call( - context, - attachment, - ); - - // Return empty container if no attachment widget is returned. - if (attachmentWidget == null) return const Empty(); - - final colorTheme = StreamChatTheme.of(context).colorTheme; - - var clipBehavior = Clip.none; - ShapeDecoration? decoration; - if (attachment.type != AttachmentType.file && - attachment.type != AttachmentType.voiceRecording) { - clipBehavior = Clip.hardEdge; - decoration = ShapeDecoration( - shape: RoundedRectangleBorder( - side: BorderSide( - color: colorTheme.borders, - strokeAlign: BorderSide.strokeAlignOutside, - ), - borderRadius: BorderRadius.circular(8), - ), - ); - } - - return Container( - key: Key(attachment.id), - clipBehavior: clipBehavior, - decoration: decoration, - constraints: const BoxConstraints.tightFor(width: 36, height: 36), - child: AbsorbPointer(child: attachmentWidget), - ); - } - - _Builders _createDefaultAttachmentBuilders() { - Widget _createMediaThumbnail(BuildContext context, Attachment media) { - return StreamImageAttachmentThumbnail( - image: media, - width: double.infinity, - height: double.infinity, - fit: BoxFit.cover, - ); - } - - Widget _createUrlThumbnail(BuildContext context, Attachment media) { - return StreamImageAttachmentThumbnail( - image: media, - width: double.infinity, - height: double.infinity, - fit: BoxFit.cover, - ); - } - - Widget _createFileThumbnail(BuildContext context, Attachment file) { - Widget thumbnail = StreamFileAttachmentThumbnail( - file: file, - width: double.infinity, - height: double.infinity, - fit: BoxFit.cover, - ); - - final mediaType = file.title?.mediaType; - final isImage = mediaType?.type == AttachmentType.image; - final isVideo = mediaType?.type == AttachmentType.video; - if (isImage || isVideo) { - final colorTheme = StreamChatTheme.of(context).colorTheme; - thumbnail = Container( - clipBehavior: Clip.hardEdge, - decoration: ShapeDecoration( - shape: RoundedRectangleBorder( - side: BorderSide( - color: colorTheme.borders, - strokeAlign: BorderSide.strokeAlignOutside, - ), - borderRadius: BorderRadius.circular(8), - ), - ), - child: thumbnail, - ); - } - - return thumbnail; - } - - return { - AttachmentType.image: _createMediaThumbnail, - AttachmentType.giphy: _createMediaThumbnail, - AttachmentType.video: _createMediaThumbnail, - AttachmentType.urlPreview: _createUrlThumbnail, - AttachmentType.file: _createFileThumbnail, - AttachmentType.voiceRecording: _createFileThumbnail, - }; - } -} diff --git a/packages/stream_chat_flutter/lib/src/message_input/quoting_message_top_area.dart b/packages/stream_chat_flutter/lib/src/message_input/quoting_message_top_area.dart index 74f26d9dac..70a165adc0 100644 --- a/packages/stream_chat_flutter/lib/src/message_input/quoting_message_top_area.dart +++ b/packages/stream_chat_flutter/lib/src/message_input/quoting_message_top_area.dart @@ -37,7 +37,7 @@ class QuotingMessageTopArea extends StatelessWidget { StreamMessageInputIconButton( iconSize: 24, color: _streamChatTheme.colorTheme.disabled, - icon: const StreamSvgIcon(icon: StreamSvgIcons.reply), + icon: Icon(context.streamIcons.reply), onPressed: null, ), Text( @@ -47,7 +47,7 @@ class QuotingMessageTopArea extends StatelessWidget { StreamMessageInputIconButton( iconSize: 24, color: _streamChatTheme.colorTheme.textLowEmphasis, - icon: const StreamSvgIcon(icon: StreamSvgIcons.closeSmall), + icon: Icon(context.streamIcons.xmark), onPressed: onQuotedMessageCleared?.call, ), ], diff --git a/packages/stream_chat_flutter/lib/src/message_input/stream_chat_message_input.dart b/packages/stream_chat_flutter/lib/src/message_input/stream_chat_message_input.dart new file mode 100644 index 0000000000..f9e59ead74 --- /dev/null +++ b/packages/stream_chat_flutter/lib/src/message_input/stream_chat_message_input.dart @@ -0,0 +1,323 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_portal/flutter_portal.dart'; +import 'package:stream_chat_flutter/src/components/message_composer/message_composer_input.dart'; +import 'package:stream_chat_flutter/src/components/message_composer/message_composer_leading.dart'; +import 'package:stream_chat_flutter/src/components/message_composer/message_composer_trailing.dart'; +import 'package:stream_chat_flutter/stream_chat_flutter.dart'; + +/// A widget that shows the message composer. +/// Uses the factory to show custom components or the default implementation. +class StreamChatMessageInput extends StatefulWidget { + /// Creates a new instance of [StreamChatMessageInput]. + /// [controller] is the controller for the message composer. + /// [onSendPressed] is the callback for when the send button is pressed. + /// [onAttachmentButtonPressed] is the callback for when the attachment button is pressed. + /// [focusNode] is the focus node for the message composer. + /// [currentUserId] is the current user id. + /// [placeholder] is the placeholder text of the message composer. + const StreamChatMessageInput({ + super.key, + this.controller, + required this.onSendPressed, + this.onAttachmentButtonPressed, + this.isPickerOpen = false, + this.focusNode, + this.currentUserId, + this.placeholder, + this.audioRecorderController, + this.sendVoiceRecordingAutomatically = false, + this.feedback = const AudioRecorderFeedback(), + this.canAlsoSendToChannel = false, + this.onQuotedMessageCleared, + this.textInputAction, + this.keyboardType, + this.textCapitalization = TextCapitalization.sentences, + this.autofocus = false, + this.autocorrect = true, + this.isFloating = false, + }); + + /// The controller for the message composer. + final StreamMessageComposerController? controller; + + /// The callback for when the send button is pressed. + final VoidCallback onSendPressed; + + /// The callback for when the attachment button is pressed. + final VoidCallback? onAttachmentButtonPressed; + + /// Whether the inline attachment picker is currently open. + final bool isPickerOpen; + + /// The focus node for the message composer. + final FocusNode? focusNode; + + /// The current user id. + final String? currentUserId; + + /// The placeholder text of the message composer. + /// + /// May be `null` to render the input with no placeholder. The wrapping + /// [StreamMessageComposer] resolves this string reactively from its + /// [StreamMessageComposerController] via [MessageInputPlaceholder.resolve] and + /// [StreamMessageComposer.placeholderBuilder]; when using + /// [StreamChatMessageInput] directly, supply the string yourself. + final String? placeholder; + + /// The audio recorder controller. + final StreamAudioRecorderController? audioRecorderController; + + /// Whether the voice recording should be sent automatically when recording stops. + final bool sendVoiceRecordingAutomatically; + + /// The feedback handler for voice recording interactions. + final AudioRecorderFeedback feedback; + + /// Whether to show the "also send to channel" checkbox. + /// Usually used in threads. + final bool canAlsoSendToChannel; + + /// Callback for when the quoted message is cleared. + final VoidCallback? onQuotedMessageCleared; + + /// The type of action button to use for the keyboard. + final TextInputAction? textInputAction; + + /// The type of keyboard to use for editing the text. + final TextInputType? keyboardType; + + /// {@macro flutter.widgets.editableText.textCapitalization} + final TextCapitalization textCapitalization; + + /// Whether the text field should be focused initially. + final bool autofocus; + + /// Whether to enable autocorrect. + final bool autocorrect; + + /// Whether the message composer is floating. + final bool isFloating; + + @override + State createState() => _StreamChatMessageInputState(); +} + +class _StreamChatMessageInputState extends State { + late StreamMessageComposerController _controller; + + @override + void initState() { + super.initState(); + _initController(); + } + + @override + void didUpdateWidget(StreamChatMessageInput oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.controller != oldWidget.controller) { + if (oldWidget.controller == null) _controller.dispose(); + _initController(); + } + } + + @override + void dispose() { + if (widget.controller == null) _controller.dispose(); + super.dispose(); + } + + void _initController() { + _controller = widget.controller ?? StreamMessageComposerController(); + } + + @override + Widget build(BuildContext context) { + final audioRecorderController = widget.audioRecorderController; + if (audioRecorderController == null) { + return _StreamChatMessageInputContent( + widget: widget, + inputController: _controller, + ); + } + + return ValueListenableBuilder( + valueListenable: audioRecorderController, + builder: (context, state, _) { + final streamSpacing = context.streamSpacing; + final textDirection = Directionality.maybeOf(context); + + const targetAlignment = AlignmentDirectional.topEnd; + const followerAlignment = AlignmentDirectional.bottomEnd; + + final idleMessage = state is RecordStateIdle ? state.message : null; + final showIdleTooltip = idleMessage != null && idleMessage.isNotEmpty; + + return PortalTarget( + visible: showIdleTooltip, + anchor: Aligned( + target: Alignment.topCenter, + follower: Alignment.bottomCenter, + offset: Offset(0, -streamSpacing.md), + ), + portalFollower: showIdleTooltip ? HoldToRecordInfoTooltip(message: idleMessage) : const SizedBox.shrink(), + child: PortalTarget( + anchor: Aligned( + target: targetAlignment.resolve(textDirection), + follower: followerAlignment.resolve(textDirection), + offset: Offset(-streamSpacing.md, -streamSpacing.md).directional(textDirection), + ), + visible: state is RecordStateRecording, + portalFollower: SwipeToLockButton(isLocked: state is RecordStateRecordingLocked), + child: _StreamChatMessageInputContent( + widget: widget, + inputController: _controller, + audioRecorderState: state, + ), + ), + ); + }, + ); + } +} + +extension on StreamAudioRecorderController { + bool get isRecording => value is RecordStateRecording; + bool get isLocked => isRecording && value is! RecordStateRecordingHold; +} + +// The actual UI content of the message composer. +class _StreamChatMessageInputContent extends StatelessWidget { + const _StreamChatMessageInputContent({ + required this.widget, + required this.inputController, + this.audioRecorderState = const RecordStateIdle(), + }); + + final StreamChatMessageInput widget; + final StreamMessageComposerController inputController; + final AudioRecorderState audioRecorderState; + + static const double _lockRecordThreshold = 50; + static const double _cancelRecordThreshold = 75; + + @override + Widget build(BuildContext context) { + final componentProps = MessageComposerComponentProps( + controller: inputController, + isFloating: widget.isFloating, + currentUserId: widget.currentUserId, + onSendPressed: widget.onSendPressed, + voiceRecordingCallback: _createVoiceRecordingCallback(context), + onAttachmentButtonPressed: widget.onAttachmentButtonPressed, + isPickerOpen: widget.isPickerOpen, + audioRecorderState: audioRecorderState, + focusNode: widget.focusNode, + onQuotedMessageCleared: widget.onQuotedMessageCleared, + ); + + final inputProps = MessageComposerInputProps.from( + componentProps, + placeholder: widget.placeholder, + textInputAction: widget.textInputAction, + keyboardType: widget.keyboardType, + textCapitalization: widget.textCapitalization, + autofocus: widget.autofocus, + autocorrect: widget.autocorrect, + canAlsoSendToChannel: widget.canAlsoSendToChannel, + audioRecorderController: widget.audioRecorderController, + feedback: widget.feedback, + sendVoiceRecordingAutomatically: widget.sendVoiceRecordingAutomatically, + ); + + final spacing = context.streamSpacing; + + return Container( + padding: EdgeInsets.only(top: spacing.md), + decoration: widget.isFloating + ? null + : BoxDecoration( + border: Border( + top: BorderSide(color: context.streamColorScheme.borderDefault), + ), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + SizedBox(width: spacing.md), + StreamMessageComposerLeading(props: componentProps), + Expanded( + child: StreamMessageComposerInput(props: inputProps), + ), + StreamMessageComposerTrailing(props: componentProps), + SizedBox(width: spacing.md), + ], + ), + ); + } + + VoiceRecordingCallback? _createVoiceRecordingCallback(BuildContext context) { + if (widget.audioRecorderController case final audioRecorderController?) { + return VoiceRecordingCallback( + onLongPressStart: () async { + // Return if the recording is already started. + if (audioRecorderController.isRecording) return; + + await widget.feedback.onRecordStart(context); + return audioRecorderController.startRecord(); + }, + onLongPressEnd: (_) async { + // Return if the recording not yet started or already locked. + if (!audioRecorderController.isRecording || audioRecorderController.isLocked) return; + + await widget.feedback.onRecordFinish(context); + final audio = await audioRecorderController.finishRecord(); + if (audio != null) { + inputController.addAttachment(audio); + } + + // Once the recording is finished, cancel the recorder. + audioRecorderController.cancelRecord(discardTrack: false); + + // Send the message if the user has enabled the option to + // send the voice recording automatically. + if (widget.sendVoiceRecordingAutomatically) { + return widget.onSendPressed.call(); + } + }, + onLongPressCancel: () async { + // Return if the recording is already started. + if (audioRecorderController.isRecording) return; + + // Capture the label before the async gap to avoid using a potentially + // unmounted BuildContext after awaiting. + final holdLabel = context.translations.holdToRecordLabel; + + // Notify the parent that the recorder is canceled before it starts. + await widget.feedback.onRecordStartCancel(context); + // Show a message to the user to hold to record. + audioRecorderController.showInfo(holdLabel); + }, + onLongPressMoveUpdate: (details) async { + // Return if the recording not yet started or already locked. + if (!audioRecorderController.isRecording || audioRecorderController.isLocked) return; + final dragOffset = details.offsetFromOrigin; + + // Lock recording if the drag offset is greater than the threshold. + if (dragOffset.dy <= -_lockRecordThreshold) { + await widget.feedback.onRecordLock(context); + return audioRecorderController.lockRecord(); + } + // Cancel recording if the drag offset is greater than the threshold. + if (dragOffset.dx <= -_cancelRecordThreshold) { + await widget.feedback.onRecordCancel(context); + return audioRecorderController.cancelRecord(); + } + + // Update the drag offset. + return audioRecorderController.dragRecord(dragOffset); + }, + ); + } + return null; + } +} diff --git a/packages/stream_chat_flutter/lib/src/message_input/stream_message_composer.dart b/packages/stream_chat_flutter/lib/src/message_input/stream_message_composer.dart new file mode 100644 index 0000000000..2f2f933465 --- /dev/null +++ b/packages/stream_chat_flutter/lib/src/message_input/stream_message_composer.dart @@ -0,0 +1,1376 @@ +import 'dart:async'; +import 'dart:math' as math; + +import 'package:desktop_drop/desktop_drop.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:stream_chat_flutter/src/message_input/error_alert_sheet.dart'; +import 'package:stream_chat_flutter/src/message_input/stream_chat_message_input.dart'; +import 'package:stream_chat_flutter/src/message_input/tld.dart'; +import 'package:stream_chat_flutter/stream_chat_flutter.dart'; + +const _kCommandTrigger = '/'; +const _kMentionTrigger = '@'; + +/// Signature for the function that determines if a [matchedUri] should be +/// previewed as an OG Attachment. +typedef OgPreviewFilter = bool Function(Uri matchedUri, String messageText); + +/// Inactive state: +/// +/// ![screenshot](https://raw.githubusercontent.com/GetStream/stream-chat-flutter/master/packages/stream_chat_flutter/screenshots/message_input.png) +/// ![screenshot](https://raw.githubusercontent.com/GetStream/stream-chat-flutter/master/packages/stream_chat_flutter/screenshots/message_input_paint.png) +/// +/// Focused state: +/// +/// ![screenshot](https://raw.githubusercontent.com/GetStream/stream-chat-flutter/master/packages/stream_chat_flutter/screenshots/message_input2.png) +/// ![screenshot](https://raw.githubusercontent.com/GetStream/stream-chat-flutter/master/packages/stream_chat_flutter/screenshots/message_input2_paint.png) +/// +/// Widget used to enter a message and add attachments: +/// +/// ```dart +/// class ChannelPage extends StatelessWidget { +/// const ChannelPage({ +/// Key? key, +/// }) : super(key: key); +/// +/// @override +/// Widget build(BuildContext context) => Scaffold( +/// appBar: const StreamChannelHeader(), +/// body: Column( +/// children: [ +/// Expanded( +/// child: StreamMessageListView( +/// threadBuilder: (_, parentMessage) => ThreadPage( +/// parent: parentMessage, +/// ), +/// ), +/// ), +/// const StreamMessageComposer(), +/// ], +/// ), +/// ); +/// } +/// ``` +/// +/// You usually put this widget in the same page of a [StreamMessageListView] +/// as the bottom widget. +/// +/// The widget renders the ui based on the first ancestor of +/// type [StreamChatTheme]. Modify it to change the widget appearance. +class StreamMessageComposer extends StatelessWidget { + /// Instantiate a new StreamMessageComposer + StreamMessageComposer({ + super.key, + void Function(Message)? onMessageSent, + FutureOr Function(Message)? preMessageSending, + StreamMessageComposerController? messageComposerController, + FocusNode? focusNode, + bool disableAttachments = false, + int maxAttachmentSize = kDefaultMaxAttachmentSize, + bool canAlsoSendToChannelFromThread = true, + bool enableVoiceRecording = false, + bool sendVoiceRecordingAutomatically = false, + AudioRecorderFeedback voiceRecordingFeedback = const AudioRecorderFeedback(), + UserMentionTileBuilder? userMentionsTileBuilder, + ErrorListener? onError, + int? attachmentLimit, + List allowedAttachmentPickerTypes = AttachmentPickerType.values, + AttachmentLimitExceedListener? onAttachmentLimitExceed, + Iterable customAutocompleteTriggers = const [], + bool mentionAllAppUsers = false, + bool? shouldKeepFocusAfterMessage, + MessageValidator validator = MessageComposerProps._defaultValidator, + String? restorationId, + bool? enableSafeArea, + bool enableMentionsOverlay = true, + VoidCallback? onQuotedMessageCleared, + OgPreviewFilter ogPreviewFilter = MessageComposerProps._defaultOgPreviewFilter, + MessageInputPlaceholderBuilder placeholderBuilder = MessageComposerProps._defaultPlaceholderBuilder, + bool useSystemAttachmentPicker = false, + PollConfig? pollConfig, + AttachmentPickerOptionsBuilder? attachmentPickerOptionsBuilder, + OnAttachmentPickerResult? onAttachmentPickerResult, + KeyEventPredicate sendMessageKeyPredicate = MessageComposerProps._defaultSendMessageKeyPredicate, + KeyEventPredicate clearQuotedMessageKeyPredicate = MessageComposerProps._defaultClearQuotedMessageKeyPredicate, + TextInputAction? textInputAction, + TextInputType? keyboardType, + TextCapitalization textCapitalization = TextCapitalization.sentences, + bool autofocus = false, + bool autoCorrect = true, + }) : props = MessageComposerProps( + onMessageSent: onMessageSent, + preMessageSending: preMessageSending, + messageComposerController: messageComposerController, + focusNode: focusNode, + disableAttachments: disableAttachments, + maxAttachmentSize: maxAttachmentSize, + canAlsoSendToChannelFromThread: canAlsoSendToChannelFromThread, + enableVoiceRecording: enableVoiceRecording, + sendVoiceRecordingAutomatically: sendVoiceRecordingAutomatically, + voiceRecordingFeedback: voiceRecordingFeedback, + userMentionsTileBuilder: userMentionsTileBuilder, + onError: onError, + attachmentLimit: attachmentLimit, + allowedAttachmentPickerTypes: allowedAttachmentPickerTypes, + onAttachmentLimitExceed: onAttachmentLimitExceed, + customAutocompleteTriggers: customAutocompleteTriggers, + mentionAllAppUsers: mentionAllAppUsers, + shouldKeepFocusAfterMessage: shouldKeepFocusAfterMessage, + validator: validator, + restorationId: restorationId, + enableSafeArea: enableSafeArea, + enableMentionsOverlay: enableMentionsOverlay, + onQuotedMessageCleared: onQuotedMessageCleared, + ogPreviewFilter: ogPreviewFilter, + placeholderBuilder: placeholderBuilder, + useSystemAttachmentPicker: useSystemAttachmentPicker, + pollConfig: pollConfig, + attachmentPickerOptionsBuilder: attachmentPickerOptionsBuilder, + onAttachmentPickerResult: onAttachmentPickerResult, + sendMessageKeyPredicate: sendMessageKeyPredicate, + clearQuotedMessageKeyPredicate: clearQuotedMessageKeyPredicate, + textInputAction: textInputAction, + keyboardType: keyboardType, + textCapitalization: textCapitalization, + autofocus: autofocus, + autoCorrect: autoCorrect, + ); + + /// Creates a [StreamMessageComposer] from a pre-built [MessageComposerProps]. + /// + /// Use this constructor when you have already assembled a [MessageComposerProps] + /// instance and want to avoid re-specifying every field individually. + const StreamMessageComposer.fromProps({ + super.key, + required this.props, + }); + + /// The properties for the message composer. + final MessageComposerProps props; + + @override + Widget build(BuildContext context) { + return context.chatComponentBuilder()?.call(context, props) ?? + DefaultStreamMessageComposer(props: props); + } +} + +/// Properties for [StreamMessageComposer] and [DefaultStreamMessageComposer]. +class MessageComposerProps { + /// Creates a new instance of [MessageComposerProps]. + const MessageComposerProps({ + this.onMessageSent, + this.preMessageSending, + this.messageComposerController, + this.focusNode, + this.disableAttachments = false, + this.maxAttachmentSize = kDefaultMaxAttachmentSize, + this.canAlsoSendToChannelFromThread = true, + this.enableVoiceRecording = false, + this.sendVoiceRecordingAutomatically = false, + this.voiceRecordingFeedback = const AudioRecorderFeedback(), + this.userMentionsTileBuilder, + this.onError, + this.attachmentLimit, + this.allowedAttachmentPickerTypes = AttachmentPickerType.values, + this.onAttachmentLimitExceed, + this.customAutocompleteTriggers = const [], + this.mentionAllAppUsers = false, + this.shouldKeepFocusAfterMessage, + this.validator = _defaultValidator, + this.restorationId, + this.enableSafeArea, + this.enableMentionsOverlay = true, + this.onQuotedMessageCleared, + this.ogPreviewFilter = _defaultOgPreviewFilter, + this.placeholderBuilder = _defaultPlaceholderBuilder, + this.useSystemAttachmentPicker = false, + this.pollConfig, + this.attachmentPickerOptionsBuilder, + this.onAttachmentPickerResult, + this.sendMessageKeyPredicate = _defaultSendMessageKeyPredicate, + this.clearQuotedMessageKeyPredicate = _defaultClearQuotedMessageKeyPredicate, + this.textInputAction, + this.keyboardType, + this.textCapitalization = TextCapitalization.sentences, + this.autofocus = false, + this.autoCorrect = true, + }); + + /// Function called after sending the message. + final void Function(Message)? onMessageSent; + + /// Function called right before sending the message. + /// + /// Use this to transform the message. + final FutureOr Function(Message)? preMessageSending; + + /// The controller for the message composer. + final StreamMessageComposerController? messageComposerController; + + /// The focus node associated to the TextField. + final FocusNode? focusNode; + + /// If true the attachments button will not be displayed. + /// + /// Defaults to false. + final bool disableAttachments; + + /// Max attachment size in bytes. + /// + /// Defaults to 100 MB. + final int maxAttachmentSize; + + /// Show the checkbox to send the message as a direct message to the channel. + /// + /// Defaults to true. + final bool canAlsoSendToChannelFromThread; + + /// If true the voice recording button will be displayed. + /// + /// Defaults to false. + final bool enableVoiceRecording; + + /// If True, the voice recording will be sent automatically after the user + /// releases the microphone button. + /// + /// Defaults to false. + final bool sendVoiceRecordingAutomatically; + + /// The feedback handler for voice recording interactions. + /// + /// Defaults to [AudioRecorderFeedback] with feedback enabled. + /// + /// To disable feedback: + /// ```dart + /// StreamMessageComposer( + /// voiceRecordingFeedback: const AudioRecorderFeedback.disabled(), + /// ) + /// ``` + /// + /// To customize feedback, extend [AudioRecorderFeedback] and override + /// the desired methods: + /// ```dart + /// class CustomFeedback extends AudioRecorderFeedback { + /// @override + /// Future onRecordStart(BuildContext context) async { + /// // Haptic feedback + /// await HapticFeedback.heavyImpact(); + /// // Or system sound + /// // await SystemSound.play(SystemSoundType.click); + /// } + /// } + /// + /// StreamMessageComposer( + /// voiceRecordingFeedback: CustomFeedback(), + /// ) + /// ``` + final AudioRecorderFeedback voiceRecordingFeedback; + + /// Customize the tile for the mentions overlay. + final UserMentionTileBuilder? userMentionsTileBuilder; + + /// A callback for error reporting + final ErrorListener? onError; + + /// A limit for the no. of attachments that can be sent with a single message. + final int? attachmentLimit; + + /// The list of allowed attachment types which can be picked using the + /// attachment button. + /// + /// By default, all the attachment types are allowed. + final List allowedAttachmentPickerTypes; + + /// A callback for when the [attachmentLimit] is exceeded. + /// + /// This will override the default error alert behaviour. + final AttachmentLimitExceedListener? onAttachmentLimitExceed; + + /// List of triggers for showing autocomplete. + final Iterable customAutocompleteTriggers; + + /// When enabled mentions search users across the entire app. + /// + /// Defaults to false. + final bool mentionAllAppUsers; + + /// Defines if the [StreamMessageComposer] loses focuses after a message is sent. + /// The default behaviour keeps focus until a command is enabled. + final bool? shouldKeepFocusAfterMessage; + + /// A callback function that validates the message. + final MessageValidator validator; + + /// Restoration ID to save and restore the state of the MessageInput. + final String? restorationId; + + /// Wrap [StreamMessageComposer] with a [SafeArea widget] + final bool? enableSafeArea; + + /// Disable the mentions overlay by passing false + /// Enabled by default + final bool enableMentionsOverlay; + + /// Callback for when the quoted message is cleared + final VoidCallback? onQuotedMessageCleared; + + /// The filter used to determine if a link should be shown as an OpenGraph + /// preview. + final OgPreviewFilter ogPreviewFilter; + + /// Resolves the placeholder text shown inside the input field. + /// + /// Receives the current [MessageInputPlaceholder] state (resolved from the + /// active [StreamMessageComposerController]) and returns the string to display. + /// Override this callback to provide custom placeholders for + /// backend-defined commands or any other input state — pattern-match + /// exhaustively over the sealed [MessageInputPlaceholder] cases: + /// + /// ```dart + /// placeholderBuilder: (context, placeholder) { + /// final translations = context.translations; + /// return switch (placeholder) { + /// SlowModePlaceholder() => translations.slowModeOnLabel, + /// CommandPlaceholder(command: 'weather') => 'Type a city name', + /// CommandPlaceholder() => translations.writeAMessageLabel, + /// AttachmentsPlaceholder() => translations.addACommentOrSendLabel, + /// WriteMessagePlaceholder() => translations.writeAMessageLabel, + /// }; + /// } + /// ``` + final MessageInputPlaceholderBuilder placeholderBuilder; + + /// If True, allows you to use the system's default media picker instead of + /// the custom media picker provided by the library. This can be beneficial + /// for several reasons: + /// + /// 1. Consistency: Provides a consistent user experience by using the + /// familiar system media picker. + /// 2. Permissions: Reduces the need for additional permissions, as the system + /// media picker handles permissions internally. + /// 3. Simplicity: Simplifies the implementation by leveraging the built-in + /// functionality of the system media picker. + final bool useSystemAttachmentPicker; + + /// The configuration to use while creating a poll. + /// + /// If not provided, the default configuration is used. + final PollConfig? pollConfig; + + /// Builder for customizing the attachment picker options. + /// + /// The builder receives the [BuildContext] and a list of default options + /// that can be modified or extended. + /// + /// If not provided, the default options are presented. + final AttachmentPickerOptionsBuilder? attachmentPickerOptionsBuilder; + + /// Callback that is called when the attachment picker result is received. + /// + /// Return `true` if the result is handled. Otherwise, return `false` to + /// allow the result to be handled internally. + final OnAttachmentPickerResult? onAttachmentPickerResult; + + /// Predicate to determine if the current key event should trigger sending + /// the message. Defaults to Enter on non-mobile platforms (without Shift). + final KeyEventPredicate sendMessageKeyPredicate; + + /// Predicate to determine if the current key event should clear the quoted + /// message. Defaults to Escape on non-mobile platforms. + final KeyEventPredicate clearQuotedMessageKeyPredicate; + + /// The type of action button to use for the keyboard. + final TextInputAction? textInputAction; + + /// The keyboard type assigned to the TextField. + final TextInputType? keyboardType; + + /// {@macro flutter.widgets.editableText.textCapitalization} + final TextCapitalization textCapitalization; + + /// Autofocus property passed to the TextField. + final bool autofocus; + + /// Whether to enable autocorrect. + /// + /// Defaults to true. + final bool autoCorrect; + + static bool _defaultSendMessageKeyPredicate(FocusNode node, KeyEvent event) { + // Do not handle the event if the user is using a mobile device. + if (CurrentPlatform.isAndroid || CurrentPlatform.isIos) return false; + + // Do not send the message if the shift key is pressed. Generally, this + // means the user is trying to add a new line. + if (HardwareKeyboard.instance.isShiftPressed) return false; + + // Otherwise, send the message when the user presses the enter key. + return event.logicalKey == .enter && event is KeyDownEvent; + } + + static bool _defaultClearQuotedMessageKeyPredicate(FocusNode node, KeyEvent event) { + // Do not handle the event if the user is using a mobile device. + if (CurrentPlatform.isAndroid || CurrentPlatform.isIos) return false; + + // Otherwise, Clear the quoted message when the user presses the escape key. + return event.logicalKey == .escape && event is KeyDownEvent; + } + + static bool _defaultOgPreviewFilter( + Uri matchedUri, + String messageText, + ) { + // Show the preview for all links + return true; + } + + static bool _defaultValidator(Message message) { + final hasText = message.text?.trim().isNotEmpty == true; + final hasAttachments = message.attachments.isNotEmpty; + final hasPoll = message.pollId != null; + + return hasText || hasAttachments || hasPoll; + } + + static String? _defaultPlaceholderBuilder( + BuildContext context, + MessageInputPlaceholder placeholder, + ) { + final translations = context.translations; + return switch (placeholder) { + SlowModePlaceholder() => translations.slowModeOnLabel, + CommandPlaceholder(command: 'giphy') => translations.searchGifLabel, + CommandPlaceholder(command: 'mute' || 'unmute' || 'ban' || 'unban') => translations.commandUsernameLabel, + CommandPlaceholder() || AttachmentsPlaceholder() || WriteMessagePlaceholder() => translations.writeAMessageLabel, + }; + } +} + +/// Default implementation of [StreamMessageComposer]. +/// +/// Contains the full stateful implementation. To provide a custom composer, +/// register a [StreamComponentBuilder] for [MessageComposerProps] via +/// [StreamComponentFactory] instead of subclassing this widget. +class DefaultStreamMessageComposer extends StatefulWidget { + /// Creates a new instance of [DefaultStreamMessageComposer]. + const DefaultStreamMessageComposer({super.key, required this.props}); + + /// The properties for the message composer. + final MessageComposerProps props; + + @override + DefaultStreamMessageComposerState createState() => DefaultStreamMessageComposerState(); +} + +/// State of [DefaultStreamMessageComposer]. +class DefaultStreamMessageComposerState extends State + with RestorationMixin, SingleTickerProviderStateMixin { + bool get _commandEnabled => _effectiveController.message.command != null; + + bool get _isPickerVisible => _pickerController != null; + StreamAttachmentPickerController? _pickerController; + StreamSubscription? _customResultSubscription; + bool _isSyncingControllers = false; + + late final AnimationController _pickerAnimationController; + late final CurvedAnimation _pickerAnimation; + + late StreamChatThemeData _streamChatTheme; + + bool get _isEditing => _effectiveController.isEditing; + + late final _audioRecorderController = StreamAudioRecorderController(); + + FocusNode get _effectiveFocusNode => widget.props.focusNode ?? (_focusNode ??= FocusNode()); + FocusNode? _focusNode; + + StreamMessageComposerController get _effectiveController => + widget.props.messageComposerController ?? _controller!.value; + StreamRestorableMessageComposerController? _controller; + + void _createLocalController([Message? message]) { + assert(_controller == null, ''); + _controller = StreamRestorableMessageComposerController(message: message); + } + + void _registerController() { + assert(_controller != null, ''); + + registerForRestoration(_controller!, 'messageComposerController'); + _initialiseEffectiveController(); + } + + void _initialiseEffectiveController() { + _effectiveController + ..removeListener(_onChangedThrottled) + ..removeListener(_onChangedDebounced) + ..addListener(_onChangedThrottled) + ..addListener(_onChangedDebounced); + } + + StreamSubscription? _draftStreamSubscription; + StreamSubscription? _messageUpdatedSubscription; + StreamSubscription? _messageDeletedSubscription; + + @override + void initState() { + super.initState(); + _pickerAnimationController = AnimationController( + duration: const Duration(milliseconds: 200), + vsync: this, + ); + _pickerAnimation = CurvedAnimation( + parent: _pickerAnimationController, + curve: Curves.easeInOut, + ); + if (widget.props.messageComposerController == null) { + _createLocalController(); + } else { + _initialiseEffectiveController(); + } + _effectiveFocusNode.addListener(_focusNodeListener); + _audioRecorderController.addListener(() { + if (_audioRecorderController.value is RecordStateRecordingLocked) { + _hidePicker(); + } + }); + + WidgetsBinding.instance.endOfFrame.then((_) { + if (mounted) return _initializeState(); + }); + } + + void _initializeState() { + // Call the listener once to make sure the initial state is reflected + // correctly in the UI. + _onChangedDebounced.call(); + + final channel = StreamChannel.of(context).channel; + final config = StreamChatConfiguration.of(context); + + // Resumes the cooldown if the channel has currently an active cooldown. + if (!_isEditing && channel.state != null) { + _effectiveController.startCooldown(channel.getRemainingCooldown()); + } + + // Starts listening to the draft stream for the current channel/thread. + if (!_isEditing && config.draftMessagesEnabled) { + final draftStream = switch (_effectiveController.message.parentId) { + final parentId? => channel.state?.threadDraftStream(parentId), + _ => channel.state?.draftStream, + }; + + _draftStreamSubscription = draftStream?.distinct().listen(_onDraftUpdate); + } + + // Keeps the composer in sync with remote message changes. + _messageUpdatedSubscription = channel.on(EventType.messageUpdated).listen(_onMessageUpdated); + _messageDeletedSubscription = channel.on(EventType.messageDeleted).listen(_onMessageDeleted); + } + + void _onMessageUpdated(Event event) { + final updatedMessage = event.message; + if (updatedMessage == null) return; + + if (_effectiveController.message.quotedMessageId == updatedMessage.id) { + _effectiveController.quotedMessage = updatedMessage; + } + + if (_isEditing && _effectiveController.message.id == updatedMessage.id) { + _effectiveController.editMessage(updatedMessage); + } + } + + void _onMessageDeleted(Event event) { + final deletedMessageId = event.message?.id; + if (deletedMessageId == null) return; + + if (_effectiveController.message.quotedMessageId == deletedMessageId) { + widget.props.onQuotedMessageCleared?.call(); + } + + if (_isEditing && _effectiveController.message.id == deletedMessageId) { + _effectiveController.cancelEditMessage(); + } + } + + void _onDraftUpdate(Draft? draft) { + // Don't let draft changes clobber an in-progress edit. + if (_isEditing) return; + + // If the draft is removed, reset the controller. + if (draft == null) return _effectiveController.reset(); + + // Otherwise, update the controller with the draft message. + if (draft.message case final draftMessage) { + _effectiveController.message = draftMessage + .copyWith( + quotedMessage: draftMessage.quotedMessage ?? draft.quotedMessage, + parentId: draftMessage.parentId ?? draft.parentId, + ) + .toMessage(); + } + } + + @override + void didChangeDependencies() { + _streamChatTheme = StreamChatTheme.of(context); + super.didChangeDependencies(); + } + + @override + void didUpdateWidget(covariant DefaultStreamMessageComposer oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.props.messageComposerController == null && oldWidget.props.messageComposerController != null) { + _createLocalController(oldWidget.props.messageComposerController!.message); + } else if (widget.props.messageComposerController != null && oldWidget.props.messageComposerController == null) { + unregisterFromRestoration(_controller!); + _controller!.dispose(); + _controller = null; + _initialiseEffectiveController(); + } else if (widget.props.messageComposerController != null && + oldWidget.props.messageComposerController != null && + widget.props.messageComposerController != oldWidget.props.messageComposerController) { + // External controller instance was swapped — detach all listeners from + // the old instance and rebind them to the new one. + oldWidget.props.messageComposerController! + ..removeListener(_onChangedThrottled) + ..removeListener(_onChangedDebounced) + ..removeListener(_syncMessageToPicker); + _initialiseEffectiveController(); + } + + // Update _focusNode + if (widget.props.focusNode != oldWidget.props.focusNode) { + (oldWidget.props.focusNode ?? _focusNode)?.removeListener(_focusNodeListener); + (widget.props.focusNode ?? _focusNode)?.addListener(_focusNodeListener); + } + } + + @override + void restoreState(RestorationBucket? oldBucket, bool initialRestore) { + if (_controller != null) { + _registerController(); + } + } + + @override + String? get restorationId => widget.props.restorationId; + + void _focusNodeListener() { + if (_effectiveFocusNode.hasFocus && _isPickerVisible) { + _hidePicker(); + } + } + + KeyEventResult _handleKeyPressed(FocusNode node, KeyEvent event) { + if (widget.props.sendMessageKeyPredicate(node, event)) { + sendMessage(); + return KeyEventResult.handled; + } + if (widget.props.clearQuotedMessageKeyPredicate(node, event)) { + final hasQuote = _effectiveController.message.quotedMessage != null; + if (hasQuote && _effectiveController.text.isEmpty) { + _effectiveController.clearQuotedMessage(); + widget.props.onQuotedMessageCleared?.call(); + return KeyEventResult.handled; + } + return KeyEventResult.ignored; + } + return KeyEventResult.ignored; + } + + @override + Widget build(BuildContext context) { + bool canSendOrUpdateMessage(List capabilities) { + var result = capabilities.contains(ChannelCapability.sendMessage); + + final insideThread = _effectiveController.message.parentId != null; + if (insideThread) { + result |= capabilities.contains(ChannelCapability.sendReply); + } + + if (_isEditing) { + result |= capabilities.contains(ChannelCapability.updateOwnMessage); + result |= capabilities.contains(ChannelCapability.updateAnyMessage); + } + + return result; + } + + final channel = StreamChannel.of(context).channel; + final messageInput = switch (_buildAutocompleteMessageInput(context)) { + final messageInput when channel.state != null => BetterStreamBuilder( + stream: channel.ownCapabilitiesStream.map(canSendOrUpdateMessage), + initialData: canSendOrUpdateMessage(channel.ownCapabilities), + builder: (context, enabled) { + // Allow the user to send messages if the user has the permission to + // send messages or if the user is editing a message. + if (enabled) return messageInput; + + // Otherwise, show the no permission message. + return _buildNoPermissionMessage(context); + }, + ), + final messageInput => messageInput, + }; + + final spacing = context.streamSpacing; + final safeAreaEnabled = widget.props.enableSafeArea ?? true; + final viewPadding = MediaQuery.paddingOf(context); + + return Material( + child: DecoratedBox( + decoration: BoxDecoration( + color: context.streamColorScheme.backgroundElevation1, + ), + child: AnimatedBuilder( + animation: _pickerAnimation, + builder: (context, child) { + final safeAreaPadding = safeAreaEnabled + ? EdgeInsets.lerp( + EdgeInsets.only( + left: viewPadding.left, + top: viewPadding.top, + right: viewPadding.right, + bottom: math.max(viewPadding.bottom, spacing.md), + ), + EdgeInsets.zero, + _pickerAnimation.value, + )! + : EdgeInsets.zero; + return Padding(padding: safeAreaPadding, child: child); + }, + child: Center(heightFactor: 1, child: messageInput), + ), + ), + ); + } + + Widget _buildAutocompleteMessageInput(BuildContext context) { + return StreamAutocomplete( + focusNode: _effectiveFocusNode, + messageComposerController: _effectiveController, + fieldViewBuilder: _buildMessageInput, + autocompleteTriggers: [ + ...widget.props.customAutocompleteTriggers, + StreamAutocompleteTrigger( + trigger: _kCommandTrigger, + triggerOnlyAtStart: true, + optionsViewBuilder: + ( + context, + autocompleteQuery, + messageComposerController, + ) { + final query = autocompleteQuery.query; + return StreamCommandAutocompleteOptions( + query: query, + channel: StreamChannel.of(context).channel, + onCommandSelected: (command) { + _effectiveController.command = command.name; + // removing the overlay after the command is selected + StreamAutocomplete.of(context).closeSuggestions(); + }, + ); + }, + ), + if (widget.props.enableMentionsOverlay) + StreamAutocompleteTrigger( + trigger: _kMentionTrigger, + optionsViewBuilder: + ( + context, + autocompleteQuery, + messageComposerController, + ) { + final query = autocompleteQuery.query; + return StreamMentionAutocompleteOptions( + query: query, + channel: StreamChannel.of(context).channel, + mentionAllAppUsers: widget.props.mentionAllAppUsers, + mentionsTileBuilder: widget.props.userMentionsTileBuilder, + onMentionUserTap: (user) { + // adding the mentioned user to the controller. + _effectiveController.addMentionedUser(user); + + // accepting the autocomplete option. + StreamAutocomplete.of(context).acceptAutocompleteOption(user.name); + }, + ); + }, + ), + ], + ); + } + + Widget _buildMessageInput( + BuildContext context, + StreamMessageComposerController controller, + FocusNode focusNode, + ) { + final currentUserId = StreamChat.of(context).currentUser?.id; + + return StreamMessageValueListenableBuilder( + valueListenable: controller, + builder: (context, value, _) => PopScope( + canPop: !_isPickerVisible, + onPopInvokedWithResult: (didPop, _) { + if (!didPop) _hidePicker(); + }, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + DropTarget( + onDragDone: (details) async { + final attachments = []; + for (final file in details.files) { + attachments.add(await file.toAttachment(type: AttachmentType.file)); + } + if (attachments.isNotEmpty) _addAttachments(attachments); + }, + onDragEntered: (_) {}, + onDragExited: (_) {}, + child: Focus( + skipTraversal: true, + onKeyEvent: _handleKeyPressed, + child: StreamChatMessageInput( + controller: controller, + currentUserId: currentUserId, + onAttachmentButtonPressed: widget.props.disableAttachments ? null : _onAttachmentButtonPressed, + isPickerOpen: _isPickerVisible, + placeholder: _buildPlaceholder(context), + focusNode: focusNode, + onSendPressed: sendMessage, + canAlsoSendToChannel: _shouldShowSendToChannelCheckbox(), + audioRecorderController: widget.props.enableVoiceRecording ? _audioRecorderController : null, + sendVoiceRecordingAutomatically: widget.props.sendVoiceRecordingAutomatically, + feedback: widget.props.voiceRecordingFeedback, + onQuotedMessageCleared: () { + _effectiveController.clearQuotedMessage(); + widget.props.onQuotedMessageCleared?.call(); + }, + textInputAction: widget.props.textInputAction, + keyboardType: widget.props.keyboardType, + textCapitalization: widget.props.textCapitalization, + autofocus: widget.props.autofocus, + autocorrect: widget.props.autoCorrect, + ), + ), + ), + SizeTransition( + sizeFactor: _pickerAnimation, + axisAlignment: -1, + child: _buildInlineAttachmentPicker(context), + ), + ], + ), + ), + ); + } + + Widget _buildInlineAttachmentPicker(BuildContext context) { + if (!_isPickerVisible) return const SizedBox.shrink(); + + final allowedTypes = _getAllowedAttachmentPickerTypes(); + + final isWebOrDesktop = switch (CurrentPlatform.type) { + PlatformType.android || PlatformType.ios => false, + _ => true, + }; + final useSystemPicker = widget.props.useSystemAttachmentPicker || isWebOrDesktop; + + final child = useSystemPicker + ? systemAttachmentPickerBuilder( + context: context, + controller: _pickerController!, + allowedTypes: allowedTypes, + pollConfig: widget.props.pollConfig, + optionsBuilder: widget.props.attachmentPickerOptionsBuilder, + onError: _onPickerError, + onPollCreated: _onPollCreated, + ) + : tabbedAttachmentPickerBuilder( + context: context, + controller: _pickerController!, + allowedTypes: allowedTypes, + pollConfig: widget.props.pollConfig, + optionsBuilder: widget.props.attachmentPickerOptionsBuilder, + onError: _onPickerError, + onPollCreated: _onPollCreated, + onCommandSelected: _onCommandSelectedFromPicker, + ); + + return SizedBox(height: 333, child: child); + } + + void _onCommandSelectedFromPicker(Command command) { + _hidePicker(); + _effectiveController.command = command.name; + _effectiveFocusNode.requestFocus(); + } + + bool _shouldShowSendToChannelCheckbox() { + if (!widget.props.canAlsoSendToChannelFromThread) return false; + + final insideThread = _effectiveController.message.parentId != null; + return insideThread; + } + + Widget _buildNoPermissionMessage(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 15), + child: Text( + context.translations.sendMessagePermissionError, + style: context.streamTextInputTheme.style?.textStyle, + ), + ); + } + + Future _onPollCreated(Poll poll) async { + _hidePicker(); + + final channel = StreamChannel.maybeOf(context)?.channel; + if (channel == null) return; + + return channel.sendPoll(poll).ignore(); + } + + // Returns the list of allowed attachment picker types based on the + // current channel configuration and context. + List _getAllowedAttachmentPickerTypes() { + final allowedTypes = widget.props.allowedAttachmentPickerTypes.where((type) { + if (type != AttachmentPickerType.poll) return true; + + // We don't allow editing polls. + if (_isEditing) return false; + // We don't allow creating polls in threads. + if (_effectiveController.message.parentId != null) return false; + + // Otherwise, check if the user has the permission to send polls. + final channel = StreamChannel.of(context).channel; + return channel.config?.polls == true && channel.canSendPoll; + }); + + return allowedTypes.toList(growable: false); + } + + /// Toggles the inline attachment picker visibility. + void _onAttachmentButtonPressed() => _isPickerVisible ? _hidePicker() : _showPicker(); + + void _showPicker() { + if (_isPickerVisible) { + _pickerAnimationController.forward(); + return; + } + + setState(() { + _pickerController = StreamAttachmentPickerController( + initialAttachments: _effectiveController.attachments, + initialPoll: _effectiveController.poll, + maxAttachmentCount: widget.props.attachmentLimit, + maxAttachmentSize: widget.props.maxAttachmentSize, + ); + + _startPickerSync(); + + if (_effectiveFocusNode.hasFocus) { + _effectiveFocusNode.unfocus(); + } + }); + + _pickerAnimationController.forward(); + } + + void _hidePicker() { + if (!_isPickerVisible) return; + + _stopPickerSync(); + _pickerAnimationController.reverse().then((_) { + if (mounted) setState(_disposePickerController); + }); + } + + void _startPickerSync() { + _pickerController?.addListener(_syncPickerToMessage); + _effectiveController.addListener(_syncMessageToPicker); + _customResultSubscription = _pickerController?.customResults.listen(_onCustomResult); + } + + void _stopPickerSync() { + _customResultSubscription?.cancel(); + _customResultSubscription = null; + _pickerController?.removeListener(_syncPickerToMessage); + _effectiveController.removeListener(_syncMessageToPicker); + } + + void _disposePickerController() { + _pickerController?.dispose(); + _pickerController = null; + } + + Future _onCustomResult(CustomAttachmentPickerResult result) async { + final handled = await widget.props.onAttachmentPickerResult?.call(result) ?? false; + if (handled && mounted) _hidePicker(); + } + + /// Copies picker attachments into the message controller when the user + /// selects or removes items in the picker. + void _syncPickerToMessage() { + if (_isSyncingControllers) return; + _isSyncingControllers = true; + + try { + _effectiveController.attachments = _pickerController?.value.attachments ?? []; + } finally { + _isSyncingControllers = false; + } + } + + /// Removes picker selections that the user deleted from the composer preview. + void _syncMessageToPicker() { + if (_isSyncingControllers) return; + + final pickerController = _pickerController; + if (pickerController == null) return; + + final messageIds = _effectiveController.attachments.map((a) => a.id).toSet(); + final pickerIds = pickerController.value.attachments.map((a) => a.id).toSet(); + + final removedIds = pickerIds.difference(messageIds); + final addedIds = messageIds.difference(pickerIds); + + if (removedIds.isEmpty && addedIds.isEmpty) return; + + final addedAttachments = addedIds + .map((id) => _effectiveController.value.attachments.firstWhere((a) => a.id == id)) + .toList(); + + _isSyncingControllers = true; + try { + for (final id in removedIds) { + pickerController.removeAttachmentById(id); + } + for (final attachment in addedAttachments) { + pickerController.addAttachment(attachment); + } + } finally { + _isSyncingControllers = false; + } + } + + void _onPickerError(AttachmentPickerError error) { + widget.props.onError?.call(error.error, error.stackTrace); + } + + late final _onChangedThrottled = throttle( + () { + if (!mounted) return; + + final channel = StreamChannel.maybeOf(context)?.channel; + if (channel == null) return; + + final value = _effectiveController.text.trim(); + if (value.isNotEmpty && channel.canUseTypingEvents) { + channel.keyStroke(_effectiveController.message.parentId).onError( + (error, stackTrace) { + widget.props.onError?.call(error!, stackTrace); + }, + ); + } + }, + const Duration(milliseconds: 350), + ); + + late final _onChangedDebounced = debounce( + () { + if (!mounted) return; + + final channel = StreamChannel.maybeOf(context)?.channel; + if (channel == null) return; + + final value = _effectiveController.text.trim(); + _checkContainsUrl(value, channel); + }, + const Duration(milliseconds: 350), + leading: true, + ); + + String? _buildPlaceholder(BuildContext context) { + final state = MessageInputPlaceholder.resolve(_effectiveController); + return widget.props.placeholderBuilder.call(context, state); + } + + String? _lastSearchedContainsUrlText; + CancelableOperation? _enrichUrlOperation; + final _urlRegex = RegExp( + r'https?://(www\.)?[-a-zA-Z0-9@:%._+~#=]{2,256}\.[a-z]{2,4}\b([-a-zA-Z0-9@:%_+.~#?&//=]*)', + caseSensitive: false, + ); + + void _checkContainsUrl(String value, Channel channel) async { + // Cancel the previous operation if it's still running + _enrichUrlOperation?.cancel(); + + // If the text is same as the last time, don't do anything + if (_lastSearchedContainsUrlText == value) return; + _lastSearchedContainsUrlText = value; + + final matchedUrls = _urlRegex.allMatches(value).where((it) { + final _parsedMatch = Uri.tryParse(it.group(0) ?? '')?.withScheme; + if (_parsedMatch == null) return false; + + return _parsedMatch.host.split('.').last.isValidTLD() && widget.props.ogPreviewFilter.call(_parsedMatch, value); + }).toList(); + + // Reset the og attachment if the text doesn't contain any url + if (matchedUrls.isEmpty || !channel.canSendLinks) { + return _effectiveController.clearOGAttachment(); + } + + final firstMatchedUrl = matchedUrls.first.group(0)!; + + // If the parsed url matches the ogAttachment url, don't do anything + if (_effectiveController.ogAttachment?.titleLink == firstMatchedUrl) { + return; + } + + final client = StreamChat.maybeOf(context)?.client; + if (client == null) return; + + _enrichUrlOperation = + CancelableOperation.fromFuture( + _enrichUrl(firstMatchedUrl, client), + ).then( + (ogAttachment) { + final attachment = Attachment.fromOGAttachment(ogAttachment); + _effectiveController.setOGAttachment(attachment); + }, + onError: (error, stackTrace) { + // Reset the ogAttachment if there was an error + _effectiveController.clearOGAttachment(); + widget.props.onError?.call(error, stackTrace); + }, + ); + } + + final _ogAttachmentCache = {}; + + Future _enrichUrl( + String url, + StreamChatClient client, + ) async { + var response = _ogAttachmentCache[url]; + if (response == null) { + try { + response = await client.enrichUrl(url); + _ogAttachmentCache[url] = response; + } catch (e, stk) { + return Future.error(e, stk); + } + } + return response; + } + + /// Adds an attachment to the [messageComposerController.attachments] list + void _addAttachments(Iterable attachments) { + if (widget.props.attachmentLimit case final limit?) { + final length = _effectiveController.attachments.length + attachments.length; + if (length > limit) { + final onAttachmentLimitExceed = widget.props.onAttachmentLimitExceed; + if (onAttachmentLimitExceed != null) { + return onAttachmentLimitExceed( + limit, + context.translations.attachmentLimitExceedError(limit), + ); + } + return _showErrorAlert( + context.translations.attachmentLimitExceedError(limit), + ); + } + } + for (final attachment in attachments) { + _effectiveController.addAttachment(attachment); + } + } + + /// Sends the current message + Future sendMessage() async { + if (_effectiveController.isSlowModeActive) return; + if (!widget.props.validator(_effectiveController.message)) return; + + _hidePicker(); + + final streamChannel = StreamChannel.maybeOf(context); + if (streamChannel == null) return; + + final channel = streamChannel.channel; + var message = _effectiveController.value; + + if (!channel.canSendLinks && + _urlRegex + .allMatches(message.text ?? '') + .any((element) => element.group(0)?.split('.').last.isValidTLD() == true)) { + showInfoBottomSheet( + context, + icon: Icon( + context.streamIcons.exclamationCircleFill, + color: StreamChatTheme.of(context).colorTheme.accentError, + size: 24, + ), + title: context.translations.linkDisabledError, + details: context.translations.linkDisabledDetails, + okText: context.translations.okLabel, + ); + return; + } + + _maybeDeleteDraftMessage(message, channel); + widget.props.onQuotedMessageCleared?.call(); + _effectiveController.reset(); + + if (widget.props.preMessageSending case final onPreMessageSending?) { + message = await onPreMessageSending.call(message); + } + + // If the channel is not up to date, we should reload it before sending + // the message. + if (!channel.state!.isUpToDate) { + await streamChannel.reloadChannel(); + + // We need to wait for the frame to be rendered with the updated channel + // state before sending the message. + await WidgetsBinding.instance.endOfFrame; + } + + await _sendOrUpdateMessage(message: message, channel: channel); + + if (mounted) { + if (widget.props.shouldKeepFocusAfterMessage ?? !_commandEnabled) { + FocusScope.of(context).requestFocus(_effectiveFocusNode); + } else { + FocusScope.of(context).unfocus(); + } + } + } + + Future _sendOrUpdateMessage({ + required Message message, + required Channel channel, + }) async { + try { + // A message is considered fresh if it doesn't have a remoteCreatedAt. + final isFreshMessage = message.remoteCreatedAt == null; + + // Note: edited messages which are bounced back with an error needs to be + // sent as new messages as the backend doesn't store them. + final resp = await switch (!isFreshMessage && !message.isBouncedWithError) { + true => channel.updateMessage(message), + false => channel.sendMessage(message), + }; + + _effectiveController.startCooldown(channel.getRemainingCooldown()); + widget.props.onMessageSent?.call(resp.message); + } catch (e, stk) { + if (widget.props.onError != null) { + return widget.props.onError?.call(e, stk); + } + + rethrow; + } + } + + void _showErrorAlert(String description) { + showModalBottomSheet( + backgroundColor: _streamChatTheme.colorTheme.barsBg, + context: context, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.only( + topLeft: Radius.circular(16), + topRight: Radius.circular(16), + ), + ), + builder: (context) => ErrorAlertSheet( + errorDescription: context.translations.somethingWentWrongError, + ), + ); + } + + void _maybeUpdateOrDeleteDraftMessage() { + final channel = StreamChannel.maybeOf(context)?.channel; + if (channel == null) return; + + final message = _effectiveController.message; + final isMessageValid = widget.props.validator.call(message); + + // If the message is valid, we need to create or update it as a draft + // message for the channel or thread. + if (isMessageValid) return _maybeUpdateDraftMessage(message, channel); + + // Otherwise, we need to delete the draft message. + return _maybeDeleteDraftMessage(message, channel); + } + + void _maybeUpdateDraftMessage(Message message, Channel channel) { + final draft = switch (message.parentId) { + final parentId? => channel.state?.threadDraft(parentId), + null => channel.state?.draft, + }; + + final draftMessage = message.toDraftMessage(); + + // If the draft message is not valid, we don't need to update it. + final isDraftValid = widget.props.validator.call(draftMessage.toMessage()); + if (!isDraftValid) return; + + // If the draft message didn't change, we don't need to update it. + if (draft?.message == draftMessage) return; + + return channel.createDraft(draftMessage).ignore(); + } + + void _maybeDeleteDraftMessage(Message message, Channel channel) { + final draft = switch (message.parentId) { + final parentId? => channel.state?.threadDraft(parentId), + null => channel.state?.draft, + }; + + // If there is no draft message, we don't need to delete it. + if (draft == null) return; + + return channel.deleteDraft(parentId: message.parentId).ignore(); + } + + @override + void deactivate() { + final config = StreamChatConfiguration.of(context); + if (!_isEditing && config.draftMessagesEnabled) { + _maybeUpdateOrDeleteDraftMessage(); + } + + super.deactivate(); + } + + @override + void dispose() { + _pickerAnimation.dispose(); + _pickerAnimationController.dispose(); + _stopPickerSync(); + _disposePickerController(); + _effectiveController + ..removeListener(_onChangedThrottled) + ..removeListener(_onChangedDebounced); + _controller?.dispose(); + _effectiveFocusNode.removeListener(_focusNodeListener); + _focusNode?.dispose(); + _onChangedDebounced.cancel(); + _onChangedThrottled.cancel(); + _audioRecorderController.dispose(); + _draftStreamSubscription?.cancel(); + _messageUpdatedSubscription?.cancel(); + _messageDeletedSubscription?.cancel(); + super.dispose(); + } +} diff --git a/packages/stream_chat_flutter/lib/src/message_input/stream_message_composer_attachment.dart b/packages/stream_chat_flutter/lib/src/message_input/stream_message_composer_attachment.dart new file mode 100644 index 0000000000..3364c50307 --- /dev/null +++ b/packages/stream_chat_flutter/lib/src/message_input/stream_message_composer_attachment.dart @@ -0,0 +1,237 @@ +part of 'stream_message_composer_attachment_list.dart'; + +/// A widget that renders a single attachment in the message composer. +/// +/// Delegates rendering to a custom builder registered via +/// [StreamChatComponentBuilder], or falls back to +/// [DefaultMessageComposerAttachment]. +class StreamMessageComposerAttachment extends StatelessWidget { + /// Creates a [StreamMessageComposerAttachment]. + StreamMessageComposerAttachment({ + super.key, + required Attachment attachment, + ValueSetter? onRemovePressed, + StreamAudioPlaylistController? audioPlaylistController, + }) : props = StreamMessageComposerAttachmentProps( + attachment: attachment, + onRemovePressed: onRemovePressed, + audioPlaylistController: audioPlaylistController, + ); + + /// The properties for the message composer attachment. + final StreamMessageComposerAttachmentProps props; + + @override + Widget build(BuildContext context) { + return context.chatComponentBuilder()?.call(context, props) ?? + DefaultMessageComposerAttachment(props: props); + } +} + +/// Properties passed to [StreamMessageComposerAttachment] and its default +/// implementation [DefaultMessageComposerAttachment]. +class StreamMessageComposerAttachmentProps { + /// Creates a [StreamMessageComposerAttachmentProps]. + const StreamMessageComposerAttachmentProps({ + required this.attachment, + required this.onRemovePressed, + required this.audioPlaylistController, + }); + + /// The attachment to display. + final Attachment attachment; + + /// Callback called when the remove button is pressed. + final ValueSetter? onRemovePressed; + + /// Controller used for audio/voice-recording attachment playback. + final StreamAudioPlaylistController? audioPlaylistController; +} + +/// Default implementation of a message composer attachment widget. +/// +/// Renders file, audio/voice-recording, or media attachments depending on the +/// attachment type. +class DefaultMessageComposerAttachment extends StatelessWidget { + /// Creates a [DefaultMessageComposerAttachment]. + const DefaultMessageComposerAttachment({super.key, required this.props}); + + /// The properties used to render this attachment. + final StreamMessageComposerAttachmentProps props; + + /// The attachment to display. + Attachment get attachment => props.attachment; + + /// Callback called when the remove button is pressed. + ValueSetter? get onRemovePressed => props.onRemovePressed; + + /// Controller used for audio/voice-recording attachment playback. + StreamAudioPlaylistController? get audioPlaylistController => props.audioPlaylistController; + + // Adapts the [ValueSetter] callback shape used in this package + // to the [VoidCallback] shape expected by core composer attachments. + VoidCallback? get _onRemoveAttachment { + final callback = onRemovePressed; + if (callback == null) return null; + return () => callback(attachment); + } + + @override + Widget build(BuildContext context) { + return switch (attachment.type) { + .file => _buildFileAttachment(context), + .audio || .voiceRecording => _buildVoiceRecordingAttachment(context), + .image || .video || .giphy => _buildMediaAttachment(context), + _ => _buildUnsupportedAttachment(context), + }; + } + + Widget _buildFileAttachment(BuildContext context) { + final fileSizeBytes = attachment.file?.size ?? attachment.extraData['file_size']; + final mimeType = attachment.file?.mediaType?.mimeType; + + return StreamMessageComposerFileAttachment( + title: Text(attachment.title ?? context.translations.fileText), + subtitle: Text(fileSize(fileSizeBytes)), + fileTypeIcon: .fromMimeType(mimeType: mimeType), + onRemovePressed: _onRemoveAttachment, + ); + } + + Widget _buildVoiceRecordingAttachment(BuildContext context) { + final controller = audioPlaylistController; + if (controller == null) return const SizedBox.shrink(); + + final trackIndex = controller.value.tracks.indexWhere((it) => it.key == attachment); + if (trackIndex < 0) return const SizedBox.shrink(); + + return MessageInputVoiceRecordingAttachment( + attachment: attachment, + index: trackIndex, + controller: controller, + onRemovePressed: onRemovePressed, + ); + } + + Widget _buildMediaAttachment(BuildContext context) { + return StreamMediaAttachmentBuilder( + attachment: attachment, + onRemovePressed: onRemovePressed, + ); + } + + Widget _buildUnsupportedAttachment(BuildContext context) { + return StreamMessageComposerUnsupportedAttachment( + label: Text(context.translations.unsupportedAttachmentLabel), + onRemovePressed: _onRemoveAttachment, + ); + } +} + +/// Widget used to display the list of voice recording type attachments added to +/// the message input. +class MessageInputVoiceRecordingAttachment extends StatelessWidget { + /// Creates a new MessageInputVoiceRecordingAttachments widget. + const MessageInputVoiceRecordingAttachment({ + super.key, + required this.attachment, + required this.index, + required this.controller, + this.onRemovePressed, + }); + + /// Attachment to display. + final Attachment attachment; + + /// Index of the track in the playlist. + final int index; + + /// Controller to use to control the audio playback. + final StreamAudioPlaylistController controller; + + /// Callback called when the remove button is pressed. + final ValueSetter? onRemovePressed; + + @override + Widget build(BuildContext context) { + return ValueListenableBuilder( + valueListenable: controller, + builder: (context, state, _) { + final track = state.tracks.firstWhereOrNull((it) => it.key == attachment); + if (track == null) return const SizedBox.shrink(); + + return core.StreamMessageComposerAttachment( + onRemovePressed: switch (onRemovePressed) { + final callback? => () => callback(attachment), + _ => null, + }, + child: StreamVoiceRecordingAttachment( + title: context.translations.voiceRecordingText, + showTitle: true, + track: track, + speed: state.speed, + onTrackPause: controller.pause, + onChangeSpeed: controller.setSpeed, + onTrackPlay: () async { + // Play the track directly if it is already loaded. + if (state.currentIndex == index) return controller.play(); + // Otherwise, load the track first and then play it. + return controller.skipToItem(index); + }, + // Only allow seeking if the current track is the one being + // interacted with. + onTrackSeekStart: (_) async { + if (state.currentIndex != index) return; + return controller.pause(); + }, + onTrackSeekEnd: (_) async { + if (state.currentIndex != index) return; + return controller.play(); + }, + onTrackSeekChanged: (progress) async { + if (state.currentIndex != index) return; + + final duration = track.duration.inMicroseconds; + final seekPosition = (duration * progress).toInt(); + final seekDuration = Duration(microseconds: seekPosition); + + return controller.seek(seekDuration); + }, + ), + ); + }, + ); + } +} + +/// Widget used to display a media type attachment item. +class StreamMediaAttachmentBuilder extends StatelessWidget { + /// Creates a new media attachment item. + const StreamMediaAttachmentBuilder({super.key, required this.attachment, this.onRemovePressed}); + + /// The media attachment to display. + final Attachment attachment; + + /// Callback called when the remove button is pressed. + final ValueSetter? onRemovePressed; + + @override + Widget build(BuildContext context) { + final durationSecs = attachment.extraData['duration'] as num?; + final videoDuration = durationSecs != null ? Duration(seconds: durationSecs.round()) : null; + + Widget? effectiveMediaBadge; + if (attachment.type == .video) { + effectiveMediaBadge = StreamMediaBadge(type: .video, duration: videoDuration); + } + + return Container( + key: Key(attachment.id), + child: StreamMessageComposerMediaAttachment( + mediaBadge: effectiveMediaBadge, + onRemovePressed: onRemovePressed != null ? () => onRemovePressed!(attachment) : null, + child: StreamMediaAttachmentThumbnail(media: attachment, fit: BoxFit.cover), + ), + ); + } +} diff --git a/packages/stream_chat_flutter/lib/src/message_input/stream_message_composer_attachment_list.dart b/packages/stream_chat_flutter/lib/src/message_input/stream_message_composer_attachment_list.dart new file mode 100644 index 0000000000..581f4e425b --- /dev/null +++ b/packages/stream_chat_flutter/lib/src/message_input/stream_message_composer_attachment_list.dart @@ -0,0 +1,212 @@ +import 'package:collection/collection.dart'; +import 'package:flutter/material.dart'; +import 'package:stream_chat_flutter/src/audio/audio_playlist_controller.dart'; +import 'package:stream_chat_flutter/stream_chat_flutter.dart'; +import 'package:stream_core_flutter/stream_core_flutter.dart'; +// The local [StreamMessageComposerAttachment] (chat-domain wrapper) shadows +// the same-named container from `stream_core_flutter`; this prefixed import +// lets us reach the container by its core name without renaming either side. +import 'package:stream_core_flutter/stream_core_flutter.dart' as core show StreamMessageComposerAttachment; + +part 'stream_message_composer_attachment.dart'; + +/// {@template stream_message_input_attachment_list} +/// Widget used to display the list of attachments added to the message input. +/// +/// By default, it displays the list of file attachments and media attachments +/// separately. +/// +/// You can customize the list of file attachments and media attachments using +/// [fileAttachmentListBuilder] and [attachmentListBuilder] respectively. +/// +/// You can also customize the attachment item using [fileAttachmentBuilder] and +/// [mediaAttachmentBuilder] respectively. +/// +/// You can override the default action of removing an attachment by providing +/// [onRemovePressed]. +/// {@endtemplate} +class StreamMessageComposerAttachmentList extends StatelessWidget { + /// {@macro stream_message_input_attachment_list} + StreamMessageComposerAttachmentList({ + super.key, + required Iterable attachments, + ValueSetter? onRemovePressed, + }) : props = StreamMessageComposerAttachmentListProps(attachments: attachments, onRemovePressed: onRemovePressed); + + /// List of attachments to display thumbnails for. + /// + /// Open graph should be filtered out. + final StreamMessageComposerAttachmentListProps props; + + @override + Widget build(BuildContext context) { + return context.chatComponentBuilder()?.call(context, props) ?? + DefaultMessageComposerAttachmentList(props: props); + } +} + +/// Properties for [StreamMessageComposerAttachmentList]. +class StreamMessageComposerAttachmentListProps { + /// Creates a new instance of [StreamMessageComposerAttachmentListProps]. + const StreamMessageComposerAttachmentListProps({ + required this.attachments, + this.onRemovePressed, + }); + + /// List of attachments to display thumbnails for. + /// + /// Open graph should be filtered out. + final Iterable attachments; + + /// Callback called when the remove button is pressed. + final ValueSetter? onRemovePressed; +} + +/// Default implementation of [StreamMessageComposerAttachmentList]. +class DefaultMessageComposerAttachmentList extends StatefulWidget { + /// {@macro stream_message_composer_attachment_list} + const DefaultMessageComposerAttachmentList({ + super.key, + required this.props, + }); + + /// Properties for the [DefaultMessageComposerAttachmentList]. + final StreamMessageComposerAttachmentListProps props; + + /// List of attachments to display thumbnails for. + /// + /// Open graph should be filtered out. + Iterable get attachments => props.attachments; + + /// Callback called when the remove button is pressed. + ValueSetter? get onRemovePressed => props.onRemovePressed; + + List get _audioAttachments => + attachments.where((it) => it.type == AttachmentType.audio || it.type == AttachmentType.voiceRecording).toList(); + + @override + State createState() => _DefaultMessageComposerAttachmentListState(); +} + +class _DefaultMessageComposerAttachmentListState extends State { + late List _audioAttachments = widget._audioAttachments; + + StreamAudioPlaylistController? _controller; + late final _scrollController = ScrollController(); + + @override + void initState() { + super.initState(); + _updateController(); + } + + @override + void didUpdateWidget( + covariant DefaultMessageComposerAttachmentList oldWidget, + ) { + super.didUpdateWidget(oldWidget); + final equals = const ListEquality().equals; + final newAudioAttachments = widget._audioAttachments; + if (!equals(newAudioAttachments, _audioAttachments)) { + // If the attachments have changed, update the playlist. + _audioAttachments = newAudioAttachments; + _updateController(); + } + if (oldWidget.attachments.length < widget.attachments.length) { + // If an attachment has been added, scroll to the end. + WidgetsBinding.instance.addPostFrameCallback( + (_) { + if (!_scrollController.hasClients) return; + _scrollController.animateTo( + _scrollController.position.maxScrollExtent, + duration: const Duration(milliseconds: 200), + curve: Curves.easeOut, + ); + }, + ); + } + } + + void _updateController() { + if (_audioAttachments.isNotEmpty) { + if (_controller == null) { + _controller = StreamAudioPlaylistController(_audioAttachments.toPlaylist()); + _controller!.initialize(); + } else { + _controller!.updatePlaylist(_audioAttachments.toPlaylist()); + } + } else if (_controller != null) { + _controller!.dispose(); + _controller = null; + } + } + + @override + void dispose() { + _controller?.dispose(); + _scrollController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final attachmentsList = widget.attachments.toList(); + + return MessageInputMediaAttachments( + scrollController: _scrollController, + attachments: attachmentsList, + audioPlaylistController: _controller, + onRemovePressed: widget.onRemovePressed, + ); + } +} + +/// Widget used to display the list of media type attachments added to the +/// message input. +class MessageInputMediaAttachments extends StatelessWidget { + /// Creates a new MediaAttachments widget. + const MessageInputMediaAttachments({ + super.key, + required this.attachments, + this.audioPlaylistController, + this.onRemovePressed, + this.scrollController, + }); + + /// List of media type attachments to display thumbnails for. + /// + /// Only attachments of type `image`, `video` and `giphy` are supported. Shows + /// a placeholder for other types of attachments. + final List attachments; + + /// Callback called when the remove button is pressed. + final ValueSetter? onRemovePressed; + + /// Controller to use to control the audio playback. + final StreamAudioPlaylistController? audioPlaylistController; + + /// Scroll controller to use to control the scroll position. + final ScrollController? scrollController; + + @override + Widget build(BuildContext context) { + return SizedBox( + height: 80, + child: ListView( + controller: scrollController, + scrollDirection: Axis.horizontal, + padding: EdgeInsets.symmetric(horizontal: context.streamSpacing.xs), + cacheExtent: 104 * 10, // Cache 10 items ahead. + children: attachments + .map( + (attachment) => StreamMessageComposerAttachment( + attachment: attachment, + onRemovePressed: onRemovePressed, + audioPlaylistController: audioPlaylistController, + ), + ) + .toList(), + ), + ); + } +} diff --git a/packages/stream_chat_flutter/lib/src/message_input/stream_message_input.dart b/packages/stream_chat_flutter/lib/src/message_input/stream_message_input.dart deleted file mode 100644 index 2277f8a511..0000000000 --- a/packages/stream_chat_flutter/lib/src/message_input/stream_message_input.dart +++ /dev/null @@ -1,1746 +0,0 @@ -import 'dart:async'; -import 'dart:math'; - -import 'package:desktop_drop/desktop_drop.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; -import 'package:stream_chat_flutter/platform_widget_builder/src/platform_widget_builder.dart'; -import 'package:stream_chat_flutter/src/message_input/attachment_button.dart'; -import 'package:stream_chat_flutter/src/message_input/command_button.dart'; -import 'package:stream_chat_flutter/src/message_input/dm_checkbox_list_tile.dart'; -import 'package:stream_chat_flutter/src/message_input/quoting_message_top_area.dart'; -import 'package:stream_chat_flutter/src/message_input/stream_message_input_icon_button.dart'; -import 'package:stream_chat_flutter/src/message_input/tld.dart'; -import 'package:stream_chat_flutter/src/misc/empty_widget.dart'; -import 'package:stream_chat_flutter/src/misc/gradient_box_border.dart'; -import 'package:stream_chat_flutter/src/misc/simple_safe_area.dart'; -import 'package:stream_chat_flutter/stream_chat_flutter.dart'; - -const _kCommandTrigger = '/'; -const _kMentionTrigger = '@'; - -/// Signature for the function that determines if a [matchedUri] should be -/// previewed as an OG Attachment. -typedef OgPreviewFilter = bool Function( - Uri matchedUri, - String messageText, -); - -/// Different types of hints that can be shown in [StreamMessageInput]. -enum HintType { - /// Hint for [StreamMessageInput] when the command is enabled and the command - /// is 'giphy'. - searchGif, - - /// Hint for [StreamMessageInput] when there are attachments. - addACommentOrSend, - - /// Hint for [StreamMessageInput] when slow mode is enabled. - slowModeOn, - - /// Hint for [StreamMessageInput] when other conditions are not met. - writeAMessage, -} - -/// Function that returns the hint text for [StreamMessageInput] based on -/// [type]. -typedef HintGetter = String? Function(BuildContext context, HintType type); - -/// The signature for the function that builds the list of actions. -typedef ActionsBuilder = List Function( - BuildContext context, - List defaultActions, -); - -/// Inactive state: -/// -/// ![screenshot](https://raw.githubusercontent.com/GetStream/stream-chat-flutter/master/packages/stream_chat_flutter/screenshots/message_input.png) -/// ![screenshot](https://raw.githubusercontent.com/GetStream/stream-chat-flutter/master/packages/stream_chat_flutter/screenshots/message_input_paint.png) -/// -/// Focused state: -/// -/// ![screenshot](https://raw.githubusercontent.com/GetStream/stream-chat-flutter/master/packages/stream_chat_flutter/screenshots/message_input2.png) -/// ![screenshot](https://raw.githubusercontent.com/GetStream/stream-chat-flutter/master/packages/stream_chat_flutter/screenshots/message_input2_paint.png) -/// -/// Widget used to enter a message and add attachments: -/// -/// ```dart -/// class ChannelPage extends StatelessWidget { -/// const ChannelPage({ -/// Key? key, -/// }) : super(key: key); -/// -/// @override -/// Widget build(BuildContext context) => Scaffold( -/// appBar: const StreamChannelHeader(), -/// body: Column( -/// children: [ -/// Expanded( -/// child: StreamMessageListView( -/// threadBuilder: (_, parentMessage) => ThreadPage( -/// parent: parentMessage, -/// ), -/// ), -/// ), -/// const StreamMessageInput(), -/// ], -/// ), -/// ); -/// } -/// ``` -/// -/// You usually put this widget in the same page of a [StreamMessageListView] -/// as the bottom widget. -/// -/// The widget renders the ui based on the first ancestor of -/// type [StreamChatTheme]. Modify it to change the widget appearance. -class StreamMessageInput extends StatefulWidget { - /// Instantiate a new MessageInput - const StreamMessageInput({ - super.key, - this.onMessageSent, - this.preMessageSending, - this.maxHeight = 150, - this.maxLines, - this.minLines, - this.textInputAction, - this.keyboardType, - this.textCapitalization = TextCapitalization.sentences, - this.disableAttachments = false, - this.messageInputController, - this.actionsBuilder, - this.spaceBetweenActions = 0, - this.actionsLocation = ActionsLocation.left, - this.attachmentListBuilder, - this.fileAttachmentListBuilder, - this.mediaAttachmentListBuilder, - this.voiceRecordingAttachmentListBuilder, - this.fileAttachmentBuilder, - this.mediaAttachmentBuilder, - this.voiceRecordingAttachmentBuilder, - this.focusNode, - this.sendButtonLocation = SendButtonLocation.outside, - this.autofocus = false, - this.hideSendAsDm = false, - this.enableVoiceRecording = false, - this.sendVoiceRecordingAutomatically = false, - this.voiceRecordingFeedback = const AudioRecorderFeedback(), - Widget? idleSendIcon, - @Deprecated("Use 'idleSendIcon' instead") Widget? idleSendButton, - Widget? activeSendIcon, - @Deprecated("Use 'activeSendIcon' instead") Widget? activeSendButton, - this.showCommandsButton = true, - this.userMentionsTileBuilder, - this.maxAttachmentSize = kDefaultMaxAttachmentSize, - this.onError, - this.attachmentLimit = 10, - this.allowedAttachmentPickerTypes = AttachmentPickerType.values, - this.onAttachmentLimitExceed, - this.attachmentButtonBuilder, - this.commandButtonBuilder, - this.customAutocompleteTriggers = const [], - this.mentionAllAppUsers = false, - this.sendButtonBuilder, - this.quotedMessageBuilder, - this.quotedMessageAttachmentThumbnailBuilders, - this.shouldKeepFocusAfterMessage, - this.validator = _defaultValidator, - this.restorationId, - this.enableSafeArea, - this.elevation, - this.shadow, - this.autoCorrect = true, - this.enableMentionsOverlay = true, - this.onQuotedMessageCleared, - this.enableActionAnimation = true, - this.sendMessageKeyPredicate = _defaultSendMessageKeyPredicate, - this.clearQuotedMessageKeyPredicate = - _defaultClearQuotedMessageKeyPredicate, - this.ogPreviewFilter = _defaultOgPreviewFilter, - this.hintGetter = _defaultHintGetter, - this.contentInsertionConfiguration, - bool useSystemAttachmentPicker = false, - @Deprecated( - 'Use useSystemAttachmentPicker instead. ' - 'This feature was deprecated after v9.4.0', - ) - bool useNativeAttachmentPickerOnMobile = false, - this.pollConfig, - this.padding = const EdgeInsets.all(8), - this.textInputMargin, - }) : assert( - idleSendIcon == null || idleSendButton == null, - 'idleSendIcon and idleSendButton cannot be used together', - ), - idleSendIcon = idleSendIcon ?? idleSendButton, - assert( - activeSendIcon == null || activeSendButton == null, - 'activeSendIcon and activeSendButton cannot be used together', - ), - activeSendIcon = activeSendIcon ?? activeSendButton, - useSystemAttachmentPicker = useSystemAttachmentPicker || // - useNativeAttachmentPickerOnMobile; - - /// The predicate used to send a message on desktop/web - final KeyEventPredicate sendMessageKeyPredicate; - - /// The predicate used to clear the quoted message on desktop/web - final KeyEventPredicate clearQuotedMessageKeyPredicate; - - /// If true the message input will animate the actions while you type - final bool enableActionAnimation; - - /// List of triggers for showing autocomplete. - final Iterable customAutocompleteTriggers; - - /// Max attachment size in bytes: - /// - Defaults to 20 MB - /// - Do not set it if you're using our default CDN - final int maxAttachmentSize; - - /// Function called after sending the message. - final void Function(Message)? onMessageSent; - - /// Function called right before sending the message. - /// - /// Use this to transform the message. - final FutureOr Function(Message)? preMessageSending; - - /// Maximum Height for the TextField to grow before it starts scrolling. - final double maxHeight; - - /// The maximum lines of text the input can span. - final int? maxLines; - - /// The minimum lines of text the input can span. - final int? minLines; - - /// The type of action button to use for the keyboard. - final TextInputAction? textInputAction; - - /// The keyboard type assigned to the TextField. - final TextInputType? keyboardType; - - /// {@macro flutter.widgets.editableText.textCapitalization} - final TextCapitalization textCapitalization; - - /// If true the attachments button will not be displayed. - final bool disableAttachments; - - /// Use this property to hide/show the commands button. - final bool showCommandsButton; - - /// Hide send as dm checkbox. - final bool hideSendAsDm; - - /// If true the voice recording button will be displayed. - /// - /// Defaults to true. - final bool enableVoiceRecording; - - /// If True, the voice recording will be sent automatically after the user - /// releases the microphone button. - /// - /// Defaults to false. - final bool sendVoiceRecordingAutomatically; - - /// The feedback handler for voice recording interactions. - /// - /// Defaults to [AudioRecorderFeedback] with feedback enabled. - /// - /// To disable feedback: - /// ```dart - /// StreamMessageInput( - /// voiceRecordingFeedback: const AudioRecorderFeedback.disabled(), - /// ) - /// ``` - /// - /// To customize feedback, extend [AudioRecorderFeedback] and override - /// the desired methods: - /// ```dart - /// class CustomFeedback extends AudioRecorderFeedback { - /// @override - /// Future onRecordStart(BuildContext context) async { - /// // Haptic feedback - /// await HapticFeedback.heavyImpact(); - /// // Or system sound - /// // await SystemSound.play(SystemSoundType.click); - /// } - /// } - /// - /// StreamMessageInput( - /// voiceRecordingFeedback: CustomFeedback(), - /// ) - /// ``` - final AudioRecorderFeedback voiceRecordingFeedback; - - /// The text controller of the TextField. - final StreamMessageInputController? messageInputController; - - /// List of action widgets. - final ActionsBuilder? actionsBuilder; - - /// Space between the actions. - final double spaceBetweenActions; - - /// The location of the custom actions. - final ActionsLocation actionsLocation; - - /// Builder used to build the attachment list present in the message input. - /// - /// In case you want to customize only sub-parts of the attachment list, - /// consider using [fileAttachmentListBuilder], [mediaAttachmentListBuilder]. - final AttachmentListBuilder? attachmentListBuilder; - - /// Builder used to build the file type attachment list. - /// - /// In case you want to customize the attachment item, consider using - /// [fileAttachmentBuilder]. - final AttachmentListBuilder? fileAttachmentListBuilder; - - /// Builder used to build the media type attachment list. - /// - /// In case you want to customize the attachment item, consider using - /// [mediaAttachmentBuilder]. - final AttachmentListBuilder? mediaAttachmentListBuilder; - - /// Builder used to build the voice recording attachment list. - /// - /// In case you want to customize the attachment item, consider using - /// [voiceRecordingAttachmentBuilder]. - final AttachmentListBuilder? voiceRecordingAttachmentListBuilder; - - /// Builder used to build the file attachment item. - final AttachmentItemBuilder? fileAttachmentBuilder; - - /// Builder used to build the media attachment item. - final AttachmentItemBuilder? mediaAttachmentBuilder; - - /// Builder used to build the voice recording attachment item. - final AttachmentItemBuilder? voiceRecordingAttachmentBuilder; - - /// Map that defines a thumbnail builder for an attachment type. - /// - /// This is used to build the thumbnail for the attachment in the quoted - /// message. - final Map? - quotedMessageAttachmentThumbnailBuilders; - - /// The focus node associated to the TextField. - final FocusNode? focusNode; - - /// The location of the send button - final SendButtonLocation sendButtonLocation; - - /// Autofocus property passed to the TextField - final bool autofocus; - - /// Send button widget in an idle state - final Widget? idleSendIcon; - - /// Send button widget in an idle state - @Deprecated("Use 'idleSendIcon' instead") - Widget? get idleSendButton => idleSendIcon; - - /// Send button widget in an active state - final Widget? activeSendIcon; - - /// Send button widget in an active state - @Deprecated("Use 'activeSendIcon' instead") - Widget? get activeSendButton => activeSendIcon; - - /// Customize the tile for the mentions overlay. - final UserMentionTileBuilder? userMentionsTileBuilder; - - /// A callback for error reporting - final ErrorListener? onError; - - /// A limit for the no. of attachments that can be sent with a single message. - final int attachmentLimit; - - /// The list of allowed attachment types which can be picked using the - /// attachment button. - /// - /// By default, all the attachment types are allowed. - final List allowedAttachmentPickerTypes; - - /// A callback for when the [attachmentLimit] is exceeded. - /// - /// This will override the default error alert behaviour. - final AttachmentLimitExceedListener? onAttachmentLimitExceed; - - /// Builder for customizing the attachment button. - /// - /// The builder contains the default [AttachmentButton] that can be customized - /// by calling `.copyWith`. - final AttachmentButtonBuilder? attachmentButtonBuilder; - - /// Builder for customizing the command button. - /// - /// The builder contains the default [CommandButton] that can be customized by - /// calling `.copyWith`. - final CommandButtonBuilder? commandButtonBuilder; - - /// When enabled mentions search users across the entire app. - /// - /// Defaults to false. - final bool mentionAllAppUsers; - - /// Builder for creating send button - final MessageRelatedBuilder? sendButtonBuilder; - - /// Builder for building quoted message - final Widget Function(BuildContext, Message)? quotedMessageBuilder; - - /// Defines if the [StreamMessageInput] loses focuses after a message is sent. - /// The default behaviour keeps focus until a command is enabled. - final bool? shouldKeepFocusAfterMessage; - - /// A callback function that validates the message. - final MessageValidator validator; - - /// Restoration ID to save and restore the state of the MessageInput. - final String? restorationId; - - /// Wrap [StreamMessageInput] with a [SafeArea widget] - final bool? enableSafeArea; - - /// Elevation of the [StreamMessageInput] - final double? elevation; - - /// Shadow for the [StreamMessageInput] widget - final BoxShadow? shadow; - - /// Disable autoCorrect by passing false - /// autoCorrect is enabled by default - final bool autoCorrect; - - /// Disable the mentions overlay by passing false - /// Enabled by default - final bool enableMentionsOverlay; - - /// Callback for when the quoted message is cleared - final VoidCallback? onQuotedMessageCleared; - - /// The filter used to determine if a link should be shown as an OpenGraph - /// preview. - final OgPreviewFilter ogPreviewFilter; - - /// Returns the hint text for the message input. - final HintGetter hintGetter; - - /// {@macro flutter.widgets.editableText.contentInsertionConfiguration} - final ContentInsertionConfiguration? contentInsertionConfiguration; - - /// If True, allows you to use the system’s default media picker instead of - /// the custom media picker provided by the library. This can be beneficial - /// for several reasons: - /// - /// 1. Consistency: Provides a consistent user experience by using the - /// familiar system media picker. - /// 2. Permissions: Reduces the need for additional permissions, as the system - /// media picker handles permissions internally. - /// 3. Simplicity: Simplifies the implementation by leveraging the built-in - /// functionality of the system media picker. - final bool useSystemAttachmentPicker; - - /// Forces use of native attachment picker on mobile instead of the custom - /// Stream attachment picker. - @Deprecated( - 'Use useSystemAttachmentPicker instead. ' - 'This feature was deprecated after v9.4.0', - ) - bool get useNativeAttachmentPickerOnMobile => useSystemAttachmentPicker; - - /// The configuration to use while creating a poll. - /// - /// If not provided, the default configuration is used. - final PollConfig? pollConfig; - - /// Padding for the message input. - /// - /// Defaults to `EdgeInsets.all(8)`. - final EdgeInsets padding; - - /// Margin for the message input. Allows overriding the default computed - /// margin. - /// - /// Defaults to null, and margin is applied based on action and send button - /// locations. - final EdgeInsets? textInputMargin; - - static String? _defaultHintGetter( - BuildContext context, - HintType type, - ) { - switch (type) { - case HintType.searchGif: - return context.translations.searchGifLabel; - case HintType.addACommentOrSend: - return context.translations.addACommentOrSendLabel; - case HintType.slowModeOn: - return context.translations.slowModeOnLabel; - case HintType.writeAMessage: - return context.translations.writeAMessageLabel; - } - } - - static bool _defaultOgPreviewFilter( - Uri matchedUri, - String messageText, - ) { - // Show the preview for all links - return true; - } - - static bool _defaultValidator(Message message) { - final hasText = message.text?.trim().isNotEmpty == true; - final hasAttachments = message.attachments.isNotEmpty; - final hasPoll = message.pollId != null; - - return hasText || hasAttachments || hasPoll; - } - - static bool _defaultSendMessageKeyPredicate( - FocusNode node, - KeyEvent event, - ) { - // Do not handle the event if the user is using a mobile device. - if (CurrentPlatform.isAndroid || CurrentPlatform.isIos) return false; - - // Do not send the message if the shift key is pressed. Generally, this - // means the user is trying to add a new line. - if (HardwareKeyboard.instance.isShiftPressed) return false; - - // Otherwise, send the message when the user presses the enter key. - final isEnterKeyPressed = event.logicalKey == LogicalKeyboardKey.enter; - return isEnterKeyPressed && event is KeyDownEvent; - } - - static bool _defaultClearQuotedMessageKeyPredicate( - FocusNode node, - KeyEvent event, - ) { - // Do not handle the event if the user is using a mobile device. - if (CurrentPlatform.isAndroid || CurrentPlatform.isIos) return false; - - // Otherwise, Clear the quoted message when the user presses the escape key. - final isEscapeKeyPressed = event.logicalKey == LogicalKeyboardKey.escape; - return isEscapeKeyPressed && event is KeyDownEvent; - } - - @override - StreamMessageInputState createState() => StreamMessageInputState(); -} - -/// State of [StreamMessageInput] -class StreamMessageInputState extends State - with RestorationMixin { - bool get _commandEnabled => _effectiveController.message.command != null; - - bool _actionsShrunk = false; - - late StreamChatThemeData _streamChatTheme; - late StreamMessageInputThemeData _messageInputTheme; - - bool get _hasQuotedMessage => - _effectiveController.message.quotedMessage != null; - - bool get _isEditing => !_effectiveController.message.state.isInitial; - - late final _audioRecorderController = StreamAudioRecorderController(); - - FocusNode get _effectiveFocusNode => - widget.focusNode ?? (_focusNode ??= FocusNode()); - FocusNode? _focusNode; - - StreamMessageInputController get _effectiveController => - widget.messageInputController ?? _controller!.value; - StreamRestorableMessageInputController? _controller; - - void _createLocalController([Message? message]) { - assert(_controller == null, ''); - _controller = StreamRestorableMessageInputController(message: message); - } - - void _registerController() { - assert(_controller != null, ''); - - registerForRestoration(_controller!, 'messageInputController'); - _initialiseEffectiveController(); - } - - void _initialiseEffectiveController() { - _effectiveController - ..removeListener(_onChangedDebounced) - ..addListener(_onChangedDebounced); - } - - StreamSubscription? _draftStreamSubscription; - - @override - void initState() { - super.initState(); - if (widget.messageInputController == null) { - _createLocalController(); - } else { - _initialiseEffectiveController(); - } - _effectiveFocusNode.addListener(_focusNodeListener); - - WidgetsBinding.instance.endOfFrame.then((_) { - if (mounted) return _initializeState(); - }); - } - - void _initializeState() { - // Call the listener once to make sure the initial state is reflected - // correctly in the UI. - _onChangedDebounced.call(); - - final channel = StreamChannel.of(context).channel; - final config = StreamChatConfiguration.of(context); - - // Resumes the cooldown if the channel has currently an active cooldown. - if (!_isEditing && channel.state != null) { - _effectiveController.startCooldown(channel.getRemainingCooldown()); - } - - // Starts listening to the draft stream for the current channel/thread. - if (!_isEditing && config.draftMessagesEnabled) { - final draftStream = switch (_effectiveController.message.parentId) { - final parentId? => channel.state?.threadDraftStream(parentId), - _ => channel.state?.draftStream, - }; - - _draftStreamSubscription = draftStream?.distinct().listen(_onDraftUpdate); - } - } - - void _onDraftUpdate(Draft? draft) { - // If the draft is removed, reset the controller. - if (draft == null) return _effectiveController.reset(); - - // Otherwise, update the controller with the draft message. - if (draft.message case final draftMessage) { - _effectiveController.message = draftMessage.toMessage(); - } - } - - @override - void didChangeDependencies() { - _streamChatTheme = StreamChatTheme.of(context); - _messageInputTheme = StreamMessageInputTheme.of(context); - super.didChangeDependencies(); - } - - @override - void didUpdateWidget(covariant StreamMessageInput oldWidget) { - super.didUpdateWidget(oldWidget); - if (widget.messageInputController == null && - oldWidget.messageInputController != null) { - _createLocalController(oldWidget.messageInputController!.message); - } else if (widget.messageInputController != null && - oldWidget.messageInputController == null) { - unregisterFromRestoration(_controller!); - _controller!.dispose(); - _controller = null; - _initialiseEffectiveController(); - } - - // Update _focusNode - if (widget.focusNode != oldWidget.focusNode) { - (oldWidget.focusNode ?? _focusNode)?.removeListener(_focusNodeListener); - (widget.focusNode ?? _focusNode)?.addListener(_focusNodeListener); - } - } - - @override - void restoreState(RestorationBucket? oldBucket, bool initialRestore) { - if (_controller != null) { - _registerController(); - } - } - - @override - String? get restorationId => widget.restorationId; - - // ignore: no-empty-block - void _focusNodeListener() {} - - @override - Widget build(BuildContext context) { - bool canSendOrUpdateMessage(List capabilities) { - var result = capabilities.contains(ChannelCapability.sendMessage); - if (_isEditing) { - result |= capabilities.contains(ChannelCapability.updateOwnMessage); - result |= capabilities.contains(ChannelCapability.updateAnyMessage); - } - - return result; - } - - final channel = StreamChannel.of(context).channel; - final messageInput = switch (_buildAutocompleteMessageInput(context)) { - final messageInput when channel.state != null => BetterStreamBuilder( - stream: channel.ownCapabilitiesStream.map(canSendOrUpdateMessage), - initialData: canSendOrUpdateMessage(channel.ownCapabilities), - builder: (context, enabled) { - // Allow the user to send messages if the user has the permission to - // send messages or if the user is editing a message. - if (enabled) return messageInput; - - // Otherwise, show the no permission message. - return _buildNoPermissionMessage(context); - }, - ), - final messageInput => messageInput, - }; - - final shadow = widget.shadow ?? _messageInputTheme.shadow; - final elevation = widget.elevation ?? _messageInputTheme.elevation; - return Material( - elevation: elevation ?? 8, - child: DecoratedBox( - decoration: BoxDecoration( - color: _messageInputTheme.inputBackgroundColor, - boxShadow: [if (shadow != null) shadow], - ), - child: SimpleSafeArea( - enabled: widget.enableSafeArea ?? _messageInputTheme.enableSafeArea, - child: Center(heightFactor: 1, child: messageInput), - ), - ), - ); - } - - Widget _buildAutocompleteMessageInput(BuildContext context) { - return StreamAutocomplete( - focusNode: _effectiveFocusNode, - messageEditingController: _effectiveController, - fieldViewBuilder: _buildMessageInput, - autocompleteTriggers: [ - ...widget.customAutocompleteTriggers, - StreamAutocompleteTrigger( - trigger: _kCommandTrigger, - triggerOnlyAtStart: true, - optionsViewBuilder: ( - context, - autocompleteQuery, - messageEditingController, - ) { - final query = autocompleteQuery.query; - return StreamCommandAutocompleteOptions( - query: query, - channel: StreamChannel.of(context).channel, - onCommandSelected: (command) { - _effectiveController.command = command.name; - // removing the overlay after the command is selected - StreamAutocomplete.of(context).closeSuggestions(); - }, - ); - }, - ), - if (widget.enableMentionsOverlay) - StreamAutocompleteTrigger( - trigger: _kMentionTrigger, - optionsViewBuilder: ( - context, - autocompleteQuery, - messageEditingController, - ) { - final query = autocompleteQuery.query; - return StreamMentionAutocompleteOptions( - query: query, - channel: StreamChannel.of(context).channel, - mentionAllAppUsers: widget.mentionAllAppUsers, - mentionsTileBuilder: widget.userMentionsTileBuilder, - onMentionUserTap: (user) { - // adding the mentioned user to the controller. - _effectiveController.addMentionedUser(user); - - // accepting the autocomplete option. - StreamAutocomplete.of(context) - .acceptAutocompleteOption(user.name); - }, - ); - }, - ), - ], - ); - } - - Widget _buildMessageInput( - BuildContext context, - StreamMessageEditingController controller, - FocusNode focusNode, - ) { - return StreamMessageValueListenableBuilder( - valueListenable: controller, - builder: (context, value, _) => Padding( - padding: widget.padding, - child: Column( - spacing: 8, - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - _buildTopMessageArea(context), - _buildTextField(context), - _buildDmCheckbox(context), - ].nonNulls.toList(), - ), - ), - ); - } - - Widget? _buildTopMessageArea(BuildContext context) { - if (_hasQuotedMessage && !_isEditing) { - // Ensure this doesn't show on web & desktop - return PlatformWidgetBuilder( - mobile: (context, child) => child, - child: QuotingMessageTopArea( - hasQuotedMessage: _hasQuotedMessage, - onQuotedMessageCleared: widget.onQuotedMessageCleared, - ), - ); - } - - if (_effectiveController.ogAttachment != null) { - return OGAttachmentPreview( - attachment: _effectiveController.ogAttachment!, - onDismissPreviewPressed: () { - _effectiveController.clearOGAttachment(); - _effectiveFocusNode.unfocus(); - }, - ); - } - - return null; - } - - Widget? _buildDmCheckbox(BuildContext context) { - if (widget.hideSendAsDm) return null; - - final insideThread = _effectiveController.message.parentId != null; - if (!insideThread) return null; - - return DmCheckboxListTile( - value: _effectiveController.showInChannel, - contentPadding: const EdgeInsets.symmetric(horizontal: 8), - onChanged: (value) => _effectiveController.showInChannel = value, - ); - } - - Widget _buildNoPermissionMessage(BuildContext context) { - return Padding( - padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 15), - child: Text( - context.translations.sendMessagePermissionError, - style: _messageInputTheme.inputTextStyle, - ), - ); - } - - Widget _buildTextField(BuildContext context) { - return ValueListenableBuilder( - valueListenable: _audioRecorderController, - builder: (context, state, _) { - final isAudioRecordingFlowActive = state is! RecordStateIdle; - - return Row( - children: [ - if (!isAudioRecordingFlowActive) ...[ - if (!_commandEnabled && - widget.actionsLocation == ActionsLocation.left) - _buildExpandActionsButton(context), - const SizedBox(width: 4), - Expanded(child: _buildTextInput(context)), - const SizedBox(width: 4), - if (!_commandEnabled && - widget.actionsLocation == ActionsLocation.right) - _buildExpandActionsButton(context), - if (widget.sendButtonLocation == SendButtonLocation.outside) - _buildSendButton(context), - ], - if (widget.enableVoiceRecording) - Expanded( - // This is to make sure the audio recorder button will be given - // the full width when it's visible. - flex: isAudioRecordingFlowActive ? 1 : 0, - child: StreamAudioRecorderButton( - recordState: state, - feedback: widget.voiceRecordingFeedback, - onRecordStart: _audioRecorderController.startRecord, - onRecordCancel: _audioRecorderController.cancelRecord, - onRecordStop: _audioRecorderController.stopRecord, - onRecordLock: _audioRecorderController.lockRecord, - onRecordDragUpdate: _audioRecorderController.dragRecord, - onRecordStartCancel: () { - // Show a message to the user to hold to record. - _audioRecorderController.showInfo( - context.translations.holdToRecordLabel, - ); - }, - onRecordFinish: () async { - // Finish the recording session and add the audio to the - // message input controller. - final audio = await _audioRecorderController.finishRecord(); - if (audio != null) { - _effectiveController.addAttachment(audio); - } - - // Once the recording is finished, cancel the recorder. - _audioRecorderController.cancelRecord(discardTrack: false); - - // Send the message if the user has enabled the option to - // send the voice recording automatically. - if (widget.sendVoiceRecordingAutomatically) { - return sendMessage(); - } - }, - ), - ), - ], - ); - }, - ); - } - - Widget _buildSendButton(BuildContext context) { - if (widget.sendButtonBuilder case final builder?) { - return builder(context, _effectiveController); - } - - return StreamMessageSendButton( - onSendMessage: sendMessage, - timeOut: _effectiveController.cooldownTimeOut, - isIdle: !widget.validator(_effectiveController.message), - idleSendIcon: widget.idleSendIcon, - activeSendIcon: widget.activeSendIcon, - ); - } - - Widget _buildExpandActionsButton(BuildContext context) { - return AnimatedCrossFade( - duration: const Duration(milliseconds: 200), - crossFadeState: switch (widget.enableActionAnimation && _actionsShrunk) { - true => CrossFadeState.showFirst, - false => CrossFadeState.showSecond, - }, - layoutBuilder: (top, topKey, bottom, bottomKey) => Stack( - clipBehavior: Clip.none, - alignment: Alignment.center, - children: [ - Positioned(key: bottomKey, top: 0, child: bottom), - Positioned(key: topKey, child: top), - ], - ), - firstChild: StreamMessageInputIconButton( - color: _messageInputTheme.expandButtonColor, - icon: Transform.rotate( - angle: (widget.actionsLocation == ActionsLocation.right || - widget.actionsLocation == ActionsLocation.rightInside) - ? pi - : 0, - child: const StreamSvgIcon(icon: StreamSvgIcons.emptyCircleRight), - ), - onPressed: () { - if (_actionsShrunk) { - setState(() => _actionsShrunk = false); - } - }, - ), - secondChild: widget.disableAttachments && - !widget.showCommandsButton && - !(widget.actionsBuilder != null) - ? const Empty() - : Row( - spacing: widget.spaceBetweenActions, - mainAxisSize: MainAxisSize.min, - children: _actionsList(), - ), - ); - } - - List _actionsList() { - final channel = StreamChannel.of(context).channel; - final defaultActions = [ - if (!widget.disableAttachments && channel.canUploadFile) - _buildAttachmentButton(context), - if (widget.showCommandsButton && - !_isEditing && - channel.state != null && - channel.config?.commands.isNotEmpty == true) - _buildCommandButton(context), - ]; - - if (widget.actionsBuilder case final builder?) { - return builder(context, defaultActions); - } - - return defaultActions; - } - - Widget _buildAttachmentButton(BuildContext context) { - final defaultButton = AttachmentButton( - color: _messageInputTheme.actionButtonIdleColor, - onPressed: _onAttachmentButtonPressed, - ); - - return widget.attachmentButtonBuilder?.call(context, defaultButton) ?? - defaultButton; - } - - Future _sendPoll(Poll poll, Channel channel) { - return channel.sendPoll(poll); - } - - Future _updatePoll(Poll poll, Channel channel) { - return channel.updatePoll(poll); - } - - Future _deletePoll(Poll poll, Channel channel) { - return channel.deletePoll(poll); - } - - Future _createOrUpdatePoll( - Poll? old, - Poll? current, - ) async { - final channel = StreamChannel.maybeOf(context)?.channel; - if (channel == null) return; - - // If both are null or the same, return - if ((old == null && current == null) || old == current) return; - - // If old is null, i.e., there was no poll before, create the poll. - if (old == null) return _sendPoll(current!, channel); - - // If current is null, i.e., the poll is removed, delete the poll. - if (current == null) return _deletePoll(old, channel); - - // Otherwise, update the poll. - return _updatePoll(current, channel); - } - - /// Handle the platform-specific logic for selecting files. - /// - /// On mobile, this will open the file selection bottom sheet. On desktop, - /// this will open the native file system and allow the user to select one - /// or more files. - Future _onAttachmentButtonPressed() async { - final initialPoll = _effectiveController.poll; - final initialAttachments = _effectiveController.attachments; - - // Remove AttachmentPickerType.poll if the user doesn't have the permission - // to send a poll or if this is a thread message. - final allowedTypes = [...widget.allowedAttachmentPickerTypes] - ..removeWhere((it) { - if (it != AttachmentPickerType.poll) return false; - if (_effectiveController.message.parentId != null) return true; - - final channel = StreamChannel.maybeOf(context)?.channel; - if (channel == null) return true; - - if (channel.config?.polls == true && channel.canSendPoll) return false; - - return true; - }); - - final messageInputTheme = StreamMessageInputTheme.of(context); - final useSystemPicker = widget.useSystemAttachmentPicker || - (messageInputTheme.useSystemAttachmentPicker ?? false); - - final value = await showStreamAttachmentPickerModalBottomSheet( - context: context, - onError: widget.onError, - allowedTypes: allowedTypes, - pollConfig: widget.pollConfig, - initialPoll: initialPoll, - initialAttachments: initialAttachments, - useSystemAttachmentPicker: useSystemPicker, - ); - - if (value == null || value is! AttachmentPickerValue) return; - - // Add the attachments to the controller. - _effectiveController.attachments = value.attachments; - - // Create or update the poll. - await _createOrUpdatePoll(initialPoll, value.poll); - } - - Widget _buildTextInput(BuildContext context) { - final margin = (widget.sendButtonLocation == SendButtonLocation.inside - ? const EdgeInsets.only(right: 8) - : EdgeInsets.zero) + - (widget.actionsLocation != ActionsLocation.left || _commandEnabled - ? const EdgeInsets.only(left: 8) - : EdgeInsets.zero); - - return DropTarget( - onDragDone: (details) async { - final files = details.files; - final attachments = []; - for (final file in files) { - final attachment = await file.toAttachment(type: AttachmentType.file); - attachments.add(attachment); - } - - if (attachments.isNotEmpty) _addAttachments(attachments); - }, - onDragEntered: (details) { - setState(() {}); - }, - onDragExited: (details) {}, - child: Container( - margin: widget.textInputMargin ?? margin, - clipBehavior: Clip.hardEdge, - decoration: BoxDecoration( - borderRadius: _messageInputTheme.borderRadius, - color: _messageInputTheme.inputBackgroundColor, - border: GradientBoxBorder( - gradient: _effectiveFocusNode.hasFocus - ? _messageInputTheme.activeBorderGradient! - : _messageInputTheme.idleBorderGradient!, - ), - ), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - _buildReplyToMessage(), - _buildAttachments(), - LimitedBox( - maxHeight: widget.maxHeight, - child: Focus( - skipTraversal: true, - onKeyEvent: _handleKeyPressed, - child: StreamMessageTextField( - key: const Key('messageInputText'), - maxLines: widget.maxLines, - minLines: widget.minLines, - textInputAction: widget.textInputAction, - onSubmitted: (_) => sendMessage(), - keyboardType: widget.keyboardType, - controller: _effectiveController, - focusNode: _effectiveFocusNode, - style: _messageInputTheme.inputTextStyle, - autofocus: widget.autofocus, - textAlignVertical: TextAlignVertical.center, - decoration: _getInputDecoration(context), - textCapitalization: widget.textCapitalization, - autocorrect: widget.autoCorrect, - contentInsertionConfiguration: - widget.contentInsertionConfiguration, - ), - ), - ), - ], - ), - ), - ); - } - - KeyEventResult _handleKeyPressed(FocusNode node, KeyEvent event) { - // Check for send message key. - if (widget.sendMessageKeyPredicate(node, event)) { - sendMessage(); - return KeyEventResult.handled; - } - - // Check for clear quoted message key. - if (widget.clearQuotedMessageKeyPredicate(node, event)) { - if (_hasQuotedMessage && _effectiveController.text.isEmpty) { - widget.onQuotedMessageCleared?.call(); - } - return KeyEventResult.handled; - } - - // Return ignored to allow other key events to be handled. - return KeyEventResult.ignored; - } - - InputDecoration _getInputDecoration(BuildContext context) { - final passedDecoration = _messageInputTheme.inputDecoration; - return InputDecoration( - isDense: true, - hintText: _getHint(context), - hintStyle: _messageInputTheme.inputTextStyle!.copyWith( - color: _streamChatTheme.colorTheme.textLowEmphasis, - ), - border: const OutlineInputBorder( - borderSide: BorderSide( - color: Colors.transparent, - ), - ), - focusedBorder: const OutlineInputBorder( - borderSide: BorderSide( - color: Colors.transparent, - ), - ), - enabledBorder: const OutlineInputBorder( - borderSide: BorderSide( - color: Colors.transparent, - ), - ), - errorBorder: const OutlineInputBorder( - borderSide: BorderSide( - color: Colors.transparent, - ), - ), - disabledBorder: const OutlineInputBorder( - borderSide: BorderSide( - color: Colors.transparent, - ), - ), - contentPadding: const EdgeInsets.symmetric(vertical: 6, horizontal: 16), - prefixIcon: _commandEnabled - ? Container( - margin: const EdgeInsets.all(6), - padding: const EdgeInsets.symmetric(vertical: 4, horizontal: 8), - decoration: BoxDecoration( - color: _streamChatTheme.colorTheme.accentPrimary, - borderRadius: _messageInputTheme.borderRadius?.add( - BorderRadius.circular(6), - ), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - const StreamSvgIcon( - size: 16, - color: Colors.white, - icon: StreamSvgIcons.lightning, - ), - Text( - _effectiveController.message.command!.toUpperCase(), - style: _streamChatTheme.textTheme.footnoteBold.copyWith( - color: Colors.white, - ), - ), - ], - ), - ) - : (widget.actionsLocation == ActionsLocation.leftInside - ? Row( - mainAxisSize: MainAxisSize.min, - children: [_buildExpandActionsButton(context)], - ) - : null), - suffixIconConstraints: const BoxConstraints.tightFor(height: 40), - prefixIconConstraints: const BoxConstraints.tightFor(height: 40), - suffixIcon: Row( - mainAxisSize: MainAxisSize.min, - children: [ - if (_commandEnabled) - Padding( - padding: const EdgeInsets.only(right: 8), - child: StreamMessageInputIconButton( - iconSize: 24, - color: _messageInputTheme.actionButtonIdleColor, - icon: const StreamSvgIcon(icon: StreamSvgIcons.closeSmall), - onPressed: _effectiveController.clear, - ), - ), - if (!_commandEnabled && - widget.actionsLocation == ActionsLocation.rightInside) - _buildExpandActionsButton(context), - if (widget.sendButtonLocation == SendButtonLocation.inside) - _buildSendButton(context), - ].nonNulls.toList(), - ), - ).merge(passedDecoration); - } - - late final _onChangedDebounced = debounce( - () { - if (!mounted) return; - - final channel = StreamChannel.maybeOf(context)?.channel; - if (channel == null) return; - - final value = _effectiveController.text.trim(); - if (value.isNotEmpty && channel.canUseTypingEvents) { - // Notify the server that the user started typing. - channel.keyStroke(_effectiveController.message.parentId).onError( - (error, stackTrace) { - widget.onError?.call(error!, stackTrace); - }, - ); - } - - int actionsLength; - if (widget.actionsBuilder != null) { - actionsLength = widget.actionsBuilder!(context, []).length; - } else { - actionsLength = 0; - } - if (widget.showCommandsButton) actionsLength += 1; - if (!widget.disableAttachments) actionsLength += 1; - - setState(() => _actionsShrunk = value.isNotEmpty && actionsLength > 1); - - _checkContainsUrl(value, channel); - }, - const Duration(milliseconds: 350), - leading: true, - ); - - String? _getHint(BuildContext context) { - HintType hintType; - - if (_commandEnabled && _effectiveController.message.command == 'giphy') { - hintType = HintType.searchGif; - } else if (_effectiveController.attachments.isNotEmpty) { - hintType = HintType.addACommentOrSend; - } else if (_effectiveController.isSlowModeActive) { - hintType = HintType.slowModeOn; - } else { - hintType = HintType.writeAMessage; - } - - return widget.hintGetter.call(context, hintType); - } - - String? _lastSearchedContainsUrlText; - CancelableOperation? _enrichUrlOperation; - final _urlRegex = RegExp( - r'https?://(www\.)?[-a-zA-Z0-9@:%._+~#=]{2,256}\.[a-z]{2,4}\b([-a-zA-Z0-9@:%_+.~#?&//=]*)', - caseSensitive: false, - ); - - void _checkContainsUrl(String value, Channel channel) async { - // Cancel the previous operation if it's still running - _enrichUrlOperation?.cancel(); - - // If the text is same as the last time, don't do anything - if (_lastSearchedContainsUrlText == value) return; - _lastSearchedContainsUrlText = value; - - final matchedUrls = _urlRegex.allMatches(value).where((it) { - final _parsedMatch = Uri.tryParse(it.group(0) ?? '')?.withScheme; - if (_parsedMatch == null) return false; - - return _parsedMatch.host.split('.').last.isValidTLD() && - widget.ogPreviewFilter.call(_parsedMatch, value); - }).toList(); - - // Reset the og attachment if the text doesn't contain any url - if (matchedUrls.isEmpty || !channel.canSendLinks) { - return _effectiveController.clearOGAttachment(); - } - - final firstMatchedUrl = matchedUrls.first.group(0)!; - - // If the parsed url matches the ogAttachment url, don't do anything - if (_effectiveController.ogAttachment?.titleLink == firstMatchedUrl) { - return; - } - - final client = StreamChat.maybeOf(context)?.client; - if (client == null) return; - - _enrichUrlOperation = CancelableOperation.fromFuture( - _enrichUrl(firstMatchedUrl, client), - ).then( - (ogAttachment) { - final attachment = Attachment.fromOGAttachment(ogAttachment); - _effectiveController.setOGAttachment(attachment); - }, - onError: (error, stackTrace) { - // Reset the ogAttachment if there was an error - _effectiveController.clearOGAttachment(); - widget.onError?.call(error, stackTrace); - }, - ); - } - - final _ogAttachmentCache = {}; - - Future _enrichUrl( - String url, - StreamChatClient client, - ) async { - var response = _ogAttachmentCache[url]; - if (response == null) { - try { - response = await client.enrichUrl(url); - _ogAttachmentCache[url] = response; - } catch (e, stk) { - return Future.error(e, stk); - } - } - return response; - } - - Widget _buildReplyToMessage() { - if (!_hasQuotedMessage) return const Empty(); - final quotedMessage = _effectiveController.message.quotedMessage!; - - final quotedMessageBuilder = widget.quotedMessageBuilder; - if (quotedMessageBuilder != null) { - return quotedMessageBuilder( - context, - _effectiveController.message.quotedMessage!, - ); - } - - final containsUrl = quotedMessage.attachments.any((it) { - return it.type == AttachmentType.urlPreview; - }); - - return StreamQuotedMessageWidget( - reverse: true, - showBorder: !containsUrl, - message: quotedMessage, - messageTheme: _streamChatTheme.otherMessageTheme, - onQuotedMessageClear: widget.onQuotedMessageCleared, - attachmentThumbnailBuilders: - widget.quotedMessageAttachmentThumbnailBuilders, - ); - } - - Widget _buildAttachments() { - final attachments = _effectiveController.attachments; - final nonOGAttachments = attachments.where((it) { - return it.titleLink == null; - }).toList(growable: false); - - // If there are no attachments, return an empty widget - if (nonOGAttachments.isEmpty) return const Empty(); - - // If the user has provided a custom attachment list builder, use that. - final attachmentListBuilder = widget.attachmentListBuilder; - if (attachmentListBuilder != null) { - return attachmentListBuilder( - context, - nonOGAttachments, - _onAttachmentRemovePressed, - ); - } - - // Otherwise, use the default attachment list builder. - return LimitedBox( - maxHeight: 240, - child: StreamMessageInputAttachmentList( - attachments: nonOGAttachments, - onRemovePressed: _onAttachmentRemovePressed, - fileAttachmentListBuilder: widget.fileAttachmentListBuilder, - mediaAttachmentListBuilder: widget.mediaAttachmentListBuilder, - voiceRecordingAttachmentBuilder: widget.voiceRecordingAttachmentBuilder, - fileAttachmentBuilder: widget.fileAttachmentBuilder, - mediaAttachmentBuilder: widget.mediaAttachmentBuilder, - voiceRecordingAttachmentListBuilder: - widget.voiceRecordingAttachmentListBuilder, - ), - ); - } - - // Default callback for removing an attachment. - Future _onAttachmentRemovePressed(Attachment attachment) async { - final file = attachment.file; - final uploadState = attachment.uploadState; - - if (file != null && !uploadState.isSuccess && !isWeb) { - await StreamAttachmentHandler.instance.deleteAttachmentFile( - attachmentFile: file, - ); - } - - _effectiveController.removeAttachmentById(attachment.id); - } - - Widget _buildCommandButton(BuildContext context) { - final s = _effectiveController.text.trim(); - final isCommandOptionsVisible = s.startsWith(_kCommandTrigger); - final defaultButton = CommandButton( - color: s.isNotEmpty - ? _streamChatTheme.colorTheme.disabled - : (isCommandOptionsVisible - ? _messageInputTheme.actionButtonColor! - : _messageInputTheme.actionButtonIdleColor!), - onPressed: () async { - // Clear the text if the commands options are already visible. - if (isCommandOptionsVisible) { - _effectiveController.clear(); - _effectiveFocusNode.unfocus(); - } else { - // This triggers the [StreamAutocomplete] to show the command trigger. - _effectiveController.textEditingValue = const TextEditingValue( - text: _kCommandTrigger, - selection: TextSelection.collapsed(offset: _kCommandTrigger.length), - ); - _effectiveFocusNode.requestFocus(); - } - }, - ); - - return widget.commandButtonBuilder?.call(context, defaultButton) ?? - defaultButton; - } - - /// Adds an attachment to the [messageInputController.attachments] map - void _addAttachments(Iterable attachments) { - final limit = widget.attachmentLimit; - final length = _effectiveController.attachments.length + attachments.length; - if (length > limit) { - final onAttachmentLimitExceed = widget.onAttachmentLimitExceed; - if (onAttachmentLimitExceed != null) { - return onAttachmentLimitExceed( - widget.attachmentLimit, - context.translations.attachmentLimitExceedError(limit), - ); - } - return _showErrorAlert( - context.translations.attachmentLimitExceedError(limit), - ); - } - for (final attachment in attachments) { - _effectiveController.addAttachment(attachment); - } - } - - /// Sends the current message - Future sendMessage() async { - if (_effectiveController.isSlowModeActive) return; - if (!widget.validator(_effectiveController.message)) return; - - final streamChannel = StreamChannel.maybeOf(context); - if (streamChannel == null) return; - - final channel = streamChannel.channel; - var message = _effectiveController.value; - - if (!channel.canSendLinks && - _urlRegex.allMatches(message.text ?? '').any((element) => - element.group(0)?.split('.').last.isValidTLD() == true)) { - showInfoBottomSheet( - context, - icon: StreamSvgIcon( - icon: StreamSvgIcons.error, - color: StreamChatTheme.of(context).colorTheme.accentError, - size: 24, - ), - title: context.translations.linkDisabledError, - details: context.translations.linkDisabledDetails, - okText: context.translations.okLabel, - ); - return; - } - - _maybeDeleteDraftMessage(message, channel); - widget.onQuotedMessageCleared?.call(); - _effectiveController.reset(); - - if (widget.preMessageSending case final onPreMessageSending?) { - message = await onPreMessageSending.call(message); - } - - // If the channel is not up to date, we should reload it before sending - // the message. - if (!channel.state!.isUpToDate) { - await streamChannel.reloadChannel(); - - // We need to wait for the frame to be rendered with the updated channel - // state before sending the message. - await WidgetsBinding.instance.endOfFrame; - } - - await _sendOrUpdateMessage(message: message, channel: channel); - - if (mounted) { - if (widget.shouldKeepFocusAfterMessage ?? !_commandEnabled) { - FocusScope.of(context).requestFocus(_effectiveFocusNode); - } else { - FocusScope.of(context).unfocus(); - } - } - } - - Future _sendOrUpdateMessage({ - required Message message, - required Channel channel, - }) async { - try { - // Note: edited messages which are bounced back with an error needs to be - // sent as new messages as the backend doesn't store them. - final resp = await switch (_isEditing && !message.isBouncedWithError) { - true => channel.updateMessage(message), - false => channel.sendMessage(message), - }; - - // We don't want to start the cooldown if an already sent message is - // being edited. - if (!_isEditing) { - _effectiveController.startCooldown(channel.getRemainingCooldown()); - } - - widget.onMessageSent?.call(resp.message); - } catch (e, stk) { - if (widget.onError != null) { - return widget.onError?.call(e, stk); - } - - rethrow; - } - } - - void _showErrorAlert(String description) { - showModalBottomSheet( - backgroundColor: _streamChatTheme.colorTheme.barsBg, - context: context, - shape: const RoundedRectangleBorder( - borderRadius: BorderRadius.only( - topLeft: Radius.circular(16), - topRight: Radius.circular(16), - ), - ), - builder: (context) => ErrorAlertSheet( - errorDescription: context.translations.somethingWentWrongError, - ), - ); - } - - void _maybeUpdateOrDeleteDraftMessage() { - final channel = StreamChannel.maybeOf(context)?.channel; - if (channel == null) return; - - final message = _effectiveController.message; - final isMessageValid = widget.validator.call(message); - - // If the message is valid, we need to create or update it as a draft - // message for the channel or thread. - if (isMessageValid) return _maybeUpdateDraftMessage(message, channel); - - // Otherwise, we need to delete the draft message. - return _maybeDeleteDraftMessage(message, channel); - } - - void _maybeUpdateDraftMessage(Message message, Channel channel) { - final draft = switch (message.parentId) { - final parentId? => channel.state?.threadDraft(parentId), - null => channel.state?.draft, - }; - - final draftMessage = message.toDraftMessage(); - - // If the draft message is not valid, we don't need to update it. - final isDraftValid = widget.validator.call(draftMessage.toMessage()); - if (!isDraftValid) return; - - // If the draft message didn't change, we don't need to update it. - if (draft?.message == draftMessage) return; - - return channel.createDraft(draftMessage).ignore(); - } - - void _maybeDeleteDraftMessage(Message message, Channel channel) { - final draft = switch (message.parentId) { - final parentId? => channel.state?.threadDraft(parentId), - null => channel.state?.draft, - }; - - // If there is no draft message, we don't need to delete it. - if (draft == null) return; - - return channel.deleteDraft(parentId: message.parentId).ignore(); - } - - @override - void deactivate() { - final config = StreamChatConfiguration.of(context); - if (!_isEditing && config.draftMessagesEnabled) { - _maybeUpdateOrDeleteDraftMessage(); - } - - super.deactivate(); - } - - @override - void dispose() { - _effectiveController.removeListener(_onChangedDebounced); - _controller?.dispose(); - _effectiveFocusNode.removeListener(_focusNodeListener); - _focusNode?.dispose(); - _onChangedDebounced.cancel(); - _audioRecorderController.dispose(); - _draftStreamSubscription?.cancel(); - super.dispose(); - } -} - -/// Preview of an Open Graph attachment. -class OGAttachmentPreview extends StatelessWidget { - /// Returns a new instance of [OGAttachmentPreview] - const OGAttachmentPreview({ - super.key, - required this.attachment, - this.onDismissPreviewPressed, - }); - - /// The attachment to be rendered. - final Attachment attachment; - - /// Called when the dismiss button is pressed. - final VoidCallback? onDismissPreviewPressed; - - @override - Widget build(BuildContext context) { - final chatTheme = StreamChatTheme.of(context); - final textTheme = chatTheme.textTheme; - final colorTheme = chatTheme.colorTheme; - - final attachmentTitle = attachment.title; - final attachmentText = attachment.text; - - return Row( - children: [ - Padding( - padding: const EdgeInsets.all(8), - child: StreamSvgIcon( - icon: StreamSvgIcons.link, - color: colorTheme.accentPrimary, - ), - ), - Expanded( - child: Container( - decoration: BoxDecoration( - border: Border( - left: BorderSide( - color: colorTheme.accentPrimary, - width: 2, - ), - ), - ), - padding: const EdgeInsets.only(left: 6), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - if (attachmentTitle != null) - Text( - attachmentTitle.trim(), - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: textTheme.body.copyWith(fontWeight: FontWeight.w700), - ), - if (attachmentText != null) - Text( - attachmentText, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: textTheme.body.copyWith(fontWeight: FontWeight.w400), - ), - ], - ), - ), - ), - IconButton( - visualDensity: VisualDensity.compact, - icon: const StreamSvgIcon(icon: StreamSvgIcons.closeSmall), - onPressed: onDismissPreviewPressed, - ), - ], - ); - } -} diff --git a/packages/stream_chat_flutter/lib/src/message_input/stream_message_input_attachment_list.dart b/packages/stream_chat_flutter/lib/src/message_input/stream_message_input_attachment_list.dart deleted file mode 100644 index b3fa41e960..0000000000 --- a/packages/stream_chat_flutter/lib/src/message_input/stream_message_input_attachment_list.dart +++ /dev/null @@ -1,478 +0,0 @@ -import 'package:collection/collection.dart'; -import 'package:flutter/material.dart'; -import 'package:stream_chat_flutter/src/attachment/file_attachment.dart'; -import 'package:stream_chat_flutter/src/attachment/thumbnail/media_attachment_thumbnail.dart'; -import 'package:stream_chat_flutter/src/attachment/voice_recording_attachment.dart'; -import 'package:stream_chat_flutter/src/audio/audio_playlist_controller.dart'; -import 'package:stream_chat_flutter/src/icons/stream_svg_icon.dart'; -import 'package:stream_chat_flutter/src/misc/empty_widget.dart'; -import 'package:stream_chat_flutter/src/theme/stream_chat_theme.dart'; -import 'package:stream_chat_flutter/src/utils/utils.dart'; -import 'package:stream_chat_flutter_core/stream_chat_flutter_core.dart'; - -/// WidgetBuilder used to build the message input attachment list. -/// -/// see more: -/// - [StreamMessageInputAttachmentList] -typedef AttachmentListBuilder = Widget Function( - BuildContext context, - List attachments, - ValueSetter? onRemovePressed, -); - -/// WidgetBuilder used to build the message input attachment item. -/// -/// see more: -/// - [StreamMessageInputAttachmentList] -typedef AttachmentItemBuilder = Widget Function( - BuildContext context, - Attachment attachment, - ValueSetter? onRemovePressed, -); - -/// {@template stream_message_input_attachment_list} -/// Widget used to display the list of attachments added to the message input. -/// -/// By default, it displays the list of file attachments and media attachments -/// separately. -/// -/// You can customize the list of file attachments and media attachments using -/// [fileAttachmentListBuilder] and [mediaAttachmentListBuilder] respectively. -/// -/// You can also customize the attachment item using [fileAttachmentBuilder] and -/// [mediaAttachmentBuilder] respectively. -/// -/// You can override the default action of removing an attachment by providing -/// [onRemovePressed]. -/// {@endtemplate} -class StreamMessageInputAttachmentList extends StatelessWidget { - /// {@macro stream_message_input_attachment_list} - const StreamMessageInputAttachmentList({ - super.key, - required this.attachments, - this.onRemovePressed, - this.fileAttachmentBuilder, - this.mediaAttachmentBuilder, - this.voiceRecordingAttachmentBuilder, - this.fileAttachmentListBuilder, - this.mediaAttachmentListBuilder, - this.voiceRecordingAttachmentListBuilder, - }); - - /// List of attachments to display thumbnails for. - /// - /// Open graph should be filtered out. - final Iterable attachments; - - /// Builder used to build the file attachment item. - final AttachmentItemBuilder? fileAttachmentBuilder; - - /// Builder used to build the media attachment item. - final AttachmentItemBuilder? mediaAttachmentBuilder; - - /// Builder used to build the voice recording attachment item. - final AttachmentItemBuilder? voiceRecordingAttachmentBuilder; - - /// Builder used to build the file attachment list. - final AttachmentListBuilder? fileAttachmentListBuilder; - - /// Builder used to build the media attachment list. - final AttachmentListBuilder? mediaAttachmentListBuilder; - - /// Builder used to build the voice recording attachment list. - final AttachmentListBuilder? voiceRecordingAttachmentListBuilder; - - /// Callback called when the remove button is pressed. - final ValueSetter? onRemovePressed; - - @override - Widget build(BuildContext context) { - final groupedAttachments = attachments.groupListsBy((it) => it.type); - final (:files, :media, :voices) = ( - files: [...?groupedAttachments[AttachmentType.file]], - voices: [...?groupedAttachments[AttachmentType.voiceRecording]], - media: [ - ...?groupedAttachments[AttachmentType.image], - ...?groupedAttachments[AttachmentType.video], - ...?groupedAttachments[AttachmentType.giphy], - ...?groupedAttachments[AttachmentType.audio], - ], - ); - - // If there are no attachments, return an empty widget. - if (files.isEmpty && media.isEmpty && voices.isEmpty) { - return const Empty(); - } - - return SingleChildScrollView( - padding: const EdgeInsets.only(top: 6), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - if (media.isNotEmpty) - Flexible( - child: switch (mediaAttachmentListBuilder) { - final builder? => builder(context, media, onRemovePressed), - _ => MessageInputMediaAttachments( - attachments: media, - attachmentBuilder: mediaAttachmentBuilder, - onRemovePressed: onRemovePressed, - ), - }, - ), - if (voices.isNotEmpty) - Flexible( - child: switch (voiceRecordingAttachmentListBuilder) { - final builder? => builder(context, voices, onRemovePressed), - _ => MessageInputVoiceRecordingAttachments( - attachments: voices, - attachmentBuilder: voiceRecordingAttachmentBuilder, - onRemovePressed: onRemovePressed, - ), - }, - ), - if (files.isNotEmpty) - Flexible( - child: switch (fileAttachmentListBuilder) { - final builder? => builder(context, files, onRemovePressed), - _ => MessageInputFileAttachments( - attachments: files, - attachmentBuilder: fileAttachmentBuilder, - onRemovePressed: onRemovePressed, - ), - }, - ), - ].insertBetween( - Divider( - height: 16, - indent: 16, - endIndent: 16, - thickness: 1, - color: StreamChatTheme.of(context).colorTheme.disabled, - ), - ), - ), - ); - } -} - -/// Widget used to display the list of file type attachments added to the -/// message input. -class MessageInputFileAttachments extends StatelessWidget { - /// Creates a new FileAttachments widget. - const MessageInputFileAttachments({ - super.key, - required this.attachments, - this.attachmentBuilder, - this.onRemovePressed, - }); - - /// List of file type attachments to display thumbnails for. - final List attachments; - - /// Builder used to build the file type attachment item. - final AttachmentItemBuilder? attachmentBuilder; - - /// Callback called when the remove button is pressed. - final ValueSetter? onRemovePressed; - - @override - Widget build(BuildContext context) { - return ListView( - reverse: true, - shrinkWrap: true, - physics: const NeverScrollableScrollPhysics(), - padding: const EdgeInsets.symmetric(horizontal: 8), - children: attachments.reversed.map( - (attachment) { - // If a custom builder is provided, use it. - final builder = attachmentBuilder; - if (builder != null) { - return builder(context, attachment, onRemovePressed); - } - - // Otherwise, use the default builder. - return StreamFileAttachment( - message: Message(), // Dummy message - file: attachment, - constraints: BoxConstraints.loose(Size( - MediaQuery.of(context).size.width * 0.65, - 56, - )), - trailing: Padding( - padding: const EdgeInsets.all(8), - child: RemoveAttachmentButton( - onPressed: onRemovePressed != null - ? () => onRemovePressed!(attachment) - : null, - ), - ), - ); - }, - ).insertBetween(const SizedBox(height: 8)), - ); - } -} - -/// Widget used to display the list of voice recording type attachments added to -/// the message input. -class MessageInputVoiceRecordingAttachments extends StatefulWidget { - /// Creates a new MessageInputVoiceRecordingAttachments widget. - const MessageInputVoiceRecordingAttachments({ - super.key, - required this.attachments, - this.attachmentBuilder, - this.onRemovePressed, - }); - - /// List of voice recording type attachments to display thumbnails for. - /// - /// Only attachments of type [AttachmentType.voiceRecording] are supported. - final List attachments; - - /// Builder used to build the voice recording type attachment item. - final AttachmentItemBuilder? attachmentBuilder; - - /// Callback called when the remove button is pressed. - final ValueSetter? onRemovePressed; - - @override - State createState() => - _MessageInputVoiceRecordingAttachmentsState(); -} - -class _MessageInputVoiceRecordingAttachmentsState - extends State { - late final _controller = StreamAudioPlaylistController( - widget.attachments.toPlaylist(), - ); - - @override - void initState() { - super.initState(); - _controller.initialize(); - } - - @override - void didUpdateWidget( - covariant MessageInputVoiceRecordingAttachments oldWidget, - ) { - super.didUpdateWidget(oldWidget); - final equals = const ListEquality().equals; - if (!equals(widget.attachments, oldWidget.attachments)) { - // If the attachments have changed, update the playlist. - _controller.updatePlaylist(widget.attachments.toPlaylist()); - } - } - - @override - void dispose() { - _controller.dispose(); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - return ValueListenableBuilder( - valueListenable: _controller, - builder: (context, state, _) { - return MediaQuery.removePadding( - context: context, - // Workaround for the bottom padding issue. - // Link: https://github.com/flutter/flutter/issues/156149 - removeTop: true, - removeBottom: true, - child: ListView.separated( - shrinkWrap: true, - padding: const EdgeInsets.symmetric(horizontal: 8), - physics: const NeverScrollableScrollPhysics(), - itemCount: state.tracks.length, - separatorBuilder: (_, __) => const SizedBox(height: 8), - itemBuilder: (context, index) { - final track = state.tracks[index]; - - return StreamVoiceRecordingAttachment( - track: track, - speed: state.speed, - trailingBuilder: (_, __, ___, ____) { - final attachment = widget.attachments[index]; - return RemoveAttachmentButton( - onPressed: switch (widget.onRemovePressed) { - final callback? => () => callback(attachment), - _ => null, - }, - ); - }, - onTrackPause: _controller.pause, - onChangeSpeed: _controller.setSpeed, - onTrackPlay: () async { - // Play the track directly if it is already loaded. - if (state.currentIndex == index) return _controller.play(); - // Otherwise, load the track first and then play it. - return _controller.skipToItem(index); - }, - // Only allow seeking if the current track is the one being - // interacted with. - onTrackSeekStart: (_) async { - if (state.currentIndex != index) return; - return _controller.pause(); - }, - onTrackSeekEnd: (_) async { - if (state.currentIndex != index) return; - return _controller.play(); - }, - onTrackSeekChanged: (progress) async { - if (state.currentIndex != index) return; - - final duration = track.duration.inMicroseconds; - final seekPosition = (duration * progress).toInt(); - final seekDuration = Duration(microseconds: seekPosition); - - return _controller.seek(seekDuration); - }, - ); - }, - ), - ); - }, - ); - } -} - -/// Widget used to display the list of media type attachments added to the -/// message input. -class MessageInputMediaAttachments extends StatelessWidget { - /// Creates a new MediaAttachments widget. - const MessageInputMediaAttachments({ - super.key, - required this.attachments, - this.attachmentBuilder, - this.onRemovePressed, - }); - - /// List of media type attachments to display thumbnails for. - /// - /// Only attachments of type `image`, `video` and `giphy` are supported. Shows - /// a placeholder for other types of attachments. - final List attachments; - - /// Builder used to build the media type attachment item. - final AttachmentItemBuilder? attachmentBuilder; - - /// Callback called when the remove button is pressed. - final ValueSetter? onRemovePressed; - - @override - Widget build(BuildContext context) { - return SizedBox( - height: 104, - child: ListView( - scrollDirection: Axis.horizontal, - padding: const EdgeInsets.symmetric(vertical: 2, horizontal: 8), - cacheExtent: 104 * 10, // Cache 10 items ahead. - children: attachments.map( - (attachment) { - // If a custom builder is provided, use it. - final builder = attachmentBuilder; - if (builder != null) { - return builder(context, attachment, onRemovePressed); - } - - return StreamMediaAttachmentBuilder( - attachment: attachment, - onRemovePressed: onRemovePressed, - ); - }, - ).insertBetween(const SizedBox(width: 8)), - ), - ); - } -} - -/// Widget used to display a media type attachment item. -class StreamMediaAttachmentBuilder extends StatelessWidget { - /// Creates a new media attachment item. - const StreamMediaAttachmentBuilder( - {super.key, required this.attachment, this.onRemovePressed}); - - /// The media attachment to display. - final Attachment attachment; - - /// Callback called when the remove button is pressed. - final ValueSetter? onRemovePressed; - - @override - Widget build(BuildContext context) { - final colorTheme = StreamChatTheme.of(context).colorTheme; - final shape = RoundedRectangleBorder( - side: BorderSide( - color: colorTheme.borders, - strokeAlign: BorderSide.strokeAlignOutside, - ), - borderRadius: BorderRadius.circular(14), - ); - - return Container( - key: Key(attachment.id), - clipBehavior: Clip.hardEdge, - decoration: ShapeDecoration(shape: shape), - child: AspectRatio( - aspectRatio: 1, - child: Stack( - alignment: Alignment.center, - children: [ - StreamMediaAttachmentThumbnail( - media: attachment, - width: double.infinity, - height: double.infinity, - fit: BoxFit.cover, - ), - if (attachment.type == AttachmentType.video) - const Positioned( - left: 8, - bottom: 8, - child: StreamSvgIcon(icon: StreamSvgIcons.videoCall), - ), - Positioned( - top: 8, - right: 8, - child: RemoveAttachmentButton( - onPressed: onRemovePressed != null - ? () => onRemovePressed!(attachment) - : null, - ), - ), - ], - ), - ), - ); - } -} - -/// Material Button used for removing attachments. -class RemoveAttachmentButton extends StatelessWidget { - /// Creates a new remove attachment button. - const RemoveAttachmentButton({super.key, this.onPressed}); - - /// Callback when the remove attachment button is pressed. - final VoidCallback? onPressed; - - @override - Widget build(BuildContext context) { - final theme = StreamChatTheme.of(context); - final colorTheme = theme.colorTheme; - - return IconButton.filled( - onPressed: onPressed, - color: colorTheme.barsBg, - padding: EdgeInsets.zero, - icon: const StreamSvgIcon(icon: StreamSvgIcons.close), - style: IconButton.styleFrom( - minimumSize: const Size(24, 24), - tapTargetSize: MaterialTapTargetSize.shrinkWrap, - // ignore: deprecated_member_use - backgroundColor: colorTheme.textHighEmphasis.withOpacity(0.6), - ), - ); - } -} diff --git a/packages/stream_chat_flutter/lib/src/message_input/stream_message_send_button.dart b/packages/stream_chat_flutter/lib/src/message_input/stream_message_send_button.dart deleted file mode 100644 index ac38e761b6..0000000000 --- a/packages/stream_chat_flutter/lib/src/message_input/stream_message_send_button.dart +++ /dev/null @@ -1,105 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:stream_chat_flutter/src/message_input/stream_message_input_icon_button.dart'; -import 'package:stream_chat_flutter/stream_chat_flutter.dart'; - -/// A widget that displays a sending button. -class StreamMessageSendButton extends StatelessWidget { - /// Returns a [StreamMessageSendButton] with the given [timeOut], [isIdle], - /// [isCommandEnabled], [isEditEnabled], [idleSendButton], [activeSendButton], - /// [onSendMessage]. - const StreamMessageSendButton({ - super.key, - this.timeOut = 0, - this.isIdle = true, - @Deprecated('Will be removed in the next major version') - this.isCommandEnabled = false, - @Deprecated('Will be removed in the next major version') - this.isEditEnabled = false, - Widget? idleSendIcon, - @Deprecated("Use 'idleSendIcon' instead") Widget? idleSendButton, - Widget? activeSendIcon, - @Deprecated("Use 'activeSendIcon' instead") Widget? activeSendButton, - required this.onSendMessage, - }) : assert( - idleSendIcon == null || idleSendButton == null, - 'idleSendIcon and idleSendButton cannot be used together', - ), - idleSendIcon = idleSendIcon ?? idleSendButton, - assert( - activeSendIcon == null || activeSendButton == null, - 'activeSendIcon and activeSendButton cannot be used together', - ), - activeSendIcon = activeSendIcon ?? activeSendButton; - - /// Time out related to slow mode. - final int timeOut; - - /// If true the button will be disabled. - final bool isIdle; - - /// True if a command is being sent. - @Deprecated('It will be removed in the next major version') - final bool isCommandEnabled; - - /// True if in editing mode. - @Deprecated('It will be removed in the next major version') - final bool isEditEnabled; - - /// The icon to display when the button is idle. - final Widget? idleSendIcon; - - /// The widget to display when the button is disabled. - @Deprecated("Use 'idleSendIcon' instead") - Widget? get idleSendButton => idleSendIcon; - - /// The icon to display when the button is active. - final Widget? activeSendIcon; - - /// The widget to display when the button is enabled. - @Deprecated("Use 'activeSendIcon' instead") - Widget? get activeSendButton => activeSendIcon; - - /// The callback to call when the button is pressed. - final VoidCallback onSendMessage; - - @override - Widget build(BuildContext context) { - final theme = StreamMessageInputTheme.of(context); - - final button = _buildButton(context); - return AnimatedSwitcher( - duration: theme.sendAnimationDuration!, - child: button, - ); - } - - Widget _buildButton(BuildContext context) { - if (timeOut > 0) { - return StreamCountdownButton( - key: const Key('countdown_button'), - count: timeOut, - ); - } - - final idleIcon = switch (idleSendIcon) { - final idleIcon? => idleIcon, - _ => const StreamSvgIcon(icon: StreamSvgIcons.sendMessage), - }; - - final activeIcon = switch (activeSendIcon) { - final activeIcon? => activeIcon, - _ => const StreamSvgIcon(icon: StreamSvgIcons.circleUp), - }; - - final theme = StreamMessageInputTheme.of(context); - final icon = isIdle ? idleIcon : activeIcon; - final onPressed = isIdle ? null : onSendMessage; - return StreamMessageInputIconButton( - key: const Key('send_button'), - icon: icon, - color: theme.sendButtonColor, - disabledColor: theme.sendButtonIdleColor, - onPressed: onPressed, - ); - } -} diff --git a/packages/stream_chat_flutter/lib/src/message_input/stream_message_text_field.dart b/packages/stream_chat_flutter/lib/src/message_input/stream_message_text_field.dart index 0279be811d..72bc824496 100644 --- a/packages/stream_chat_flutter/lib/src/message_input/stream_message_text_field.dart +++ b/packages/stream_chat_flutter/lib/src/message_input/stream_message_text_field.dart @@ -9,12 +9,7 @@ import 'package:flutter/services.dart'; import 'package:stream_chat_flutter/stream_chat_flutter.dart'; export 'package:flutter/services.dart' - show - TextInputType, - TextInputAction, - TextCapitalization, - SmartQuotesType, - SmartDashesType; + show TextInputType, TextInputAction, TextCapitalization, SmartQuotesType, SmartDashesType; /// A widget the wraps the [TextField] and adds some StreamChat specifics. class StreamMessageTextField extends StatefulWidget { @@ -119,47 +114,41 @@ class StreamMessageTextField extends StatefulWidget { this.scribbleEnabled = true, this.enableIMEPersonalizedLearning = true, this.contentInsertionConfiguration, - }) : assert(obscuringCharacter.length == 1, ''), - smartDashesType = smartDashesType ?? - (obscureText ? SmartDashesType.disabled : SmartDashesType.enabled), - smartQuotesType = smartQuotesType ?? - (obscureText ? SmartQuotesType.disabled : SmartQuotesType.enabled), - assert(maxLines == null || maxLines > 0, ''), - assert(minLines == null || minLines > 0, ''), - assert( - (maxLines == null) || (minLines == null) || (maxLines >= minLines), - "minLines can't be greater than maxLines", - ), - assert( - !expands || (maxLines == null && minLines == null), - 'minLines and maxLines must be null when expands is true.', - ), - assert(!obscureText || maxLines == 1, - 'Obscured fields cannot be multiline.'), - assert( - maxLength == null || - maxLength == TextField.noMaxLength || - maxLength > 0, - 'maxLength must be null or a positive integer.'), - - // Assert the following instead of setting it directly to avoid - // surprising the user by silently changing the value they set. - assert( - !identical(textInputAction, TextInputAction.newline) || - maxLines == 1 || - !identical(keyboardType, TextInputType.text), - 'Use keyboardType TextInputType.multiline when using ' - 'TextInputAction.newline on a multiline TextField.', - ), - keyboardType = keyboardType ?? - (maxLines == 1 ? TextInputType.text : TextInputType.multiline), - enableInteractiveSelection = - enableInteractiveSelection ?? (!readOnly || !obscureText); + }) : assert(obscuringCharacter.length == 1, ''), + smartDashesType = smartDashesType ?? (obscureText ? SmartDashesType.disabled : SmartDashesType.enabled), + smartQuotesType = smartQuotesType ?? (obscureText ? SmartQuotesType.disabled : SmartQuotesType.enabled), + assert(maxLines == null || maxLines > 0, ''), + assert(minLines == null || minLines > 0, ''), + assert( + (maxLines == null) || (minLines == null) || (maxLines >= minLines), + "minLines can't be greater than maxLines", + ), + assert( + !expands || (maxLines == null && minLines == null), + 'minLines and maxLines must be null when expands is true.', + ), + assert(!obscureText || maxLines == 1, 'Obscured fields cannot be multiline.'), + assert( + maxLength == null || maxLength == TextField.noMaxLength || maxLength > 0, + 'maxLength must be null or a positive integer.', + ), + + // Assert the following instead of setting it directly to avoid + // surprising the user by silently changing the value they set. + assert( + !identical(textInputAction, TextInputAction.newline) || + maxLines == 1 || + !identical(keyboardType, TextInputType.text), + 'Use keyboardType TextInputType.multiline when using ' + 'TextInputAction.newline on a multiline TextField.', + ), + keyboardType = keyboardType ?? (maxLines == 1 ? TextInputType.text : TextInputType.multiline), + enableInteractiveSelection = enableInteractiveSelection ?? (!readOnly || !obscureText); /// Controls the message being edited. /// - /// If null, this widget will create its own [StreamMessageInputController]. - final StreamMessageInputController? controller; + /// If null, this widget will create its own [StreamMessageComposerController]. + final StreamMessageComposerController? controller; /// Defines the keyboard focus for this widget. /// @@ -522,94 +511,79 @@ class StreamMessageTextField extends StatefulWidget { void debugFillProperties(DiagnosticPropertiesBuilder properties) { super.debugFillProperties(properties); properties - ..add(DiagnosticsProperty( - 'controller', controller, - defaultValue: null)) - ..add(DiagnosticsProperty('focusNode', focusNode, - defaultValue: null)) + ..add(DiagnosticsProperty('controller', controller, defaultValue: null)) + ..add(DiagnosticsProperty('focusNode', focusNode, defaultValue: null)) ..add(DiagnosticsProperty('enabled', enabled, defaultValue: null)) - ..add(DiagnosticsProperty('decoration', decoration, - defaultValue: const InputDecoration())) - ..add(DiagnosticsProperty('keyboardType', keyboardType, - defaultValue: TextInputType.text)) + ..add(DiagnosticsProperty('decoration', decoration, defaultValue: const InputDecoration())) + ..add(DiagnosticsProperty('keyboardType', keyboardType, defaultValue: TextInputType.text)) ..add(DiagnosticsProperty('style', style, defaultValue: null)) - ..add(DiagnosticsProperty('autofocus', autofocus, - defaultValue: false)) - ..add(DiagnosticsProperty( - 'obscuringCharacter', obscuringCharacter, - defaultValue: '•')) - ..add(DiagnosticsProperty('obscureText', obscureText, - defaultValue: false)) - ..add(DiagnosticsProperty('autocorrect', autocorrect, - defaultValue: true)) - ..add(EnumProperty('smartDashesType', smartDashesType, - defaultValue: - obscureText ? SmartDashesType.disabled : SmartDashesType.enabled)) - ..add(EnumProperty('smartQuotesType', smartQuotesType, - defaultValue: - obscureText ? SmartQuotesType.disabled : SmartQuotesType.enabled)) - ..add(DiagnosticsProperty('enableSuggestions', enableSuggestions, - defaultValue: true)) + ..add(DiagnosticsProperty('autofocus', autofocus, defaultValue: false)) + ..add(DiagnosticsProperty('obscuringCharacter', obscuringCharacter, defaultValue: '•')) + ..add(DiagnosticsProperty('obscureText', obscureText, defaultValue: false)) + ..add(DiagnosticsProperty('autocorrect', autocorrect, defaultValue: true)) + ..add( + EnumProperty( + 'smartDashesType', + smartDashesType, + defaultValue: obscureText ? SmartDashesType.disabled : SmartDashesType.enabled, + ), + ) + ..add( + EnumProperty( + 'smartQuotesType', + smartQuotesType, + defaultValue: obscureText ? SmartQuotesType.disabled : SmartQuotesType.enabled, + ), + ) + ..add(DiagnosticsProperty('enableSuggestions', enableSuggestions, defaultValue: true)) ..add(IntProperty('maxLines', maxLines, defaultValue: 1)) ..add(IntProperty('minLines', minLines, defaultValue: null)) ..add(DiagnosticsProperty('expands', expands, defaultValue: false)) ..add(IntProperty('maxLength', maxLength, defaultValue: null)) - ..add(EnumProperty( - 'maxLengthEnforcement', maxLengthEnforcement, - defaultValue: null)) - ..add(EnumProperty('textInputAction', textInputAction, - defaultValue: null)) - ..add(EnumProperty( - 'textCapitalization', textCapitalization, - defaultValue: TextCapitalization.none)) - ..add(EnumProperty('textAlign', textAlign, - defaultValue: TextAlign.start)) - ..add(DiagnosticsProperty( - 'textAlignVertical', textAlignVertical, - defaultValue: null)) - ..add(EnumProperty('textDirection', textDirection, - defaultValue: null)) + ..add(EnumProperty('maxLengthEnforcement', maxLengthEnforcement, defaultValue: null)) + ..add(EnumProperty('textInputAction', textInputAction, defaultValue: null)) + ..add( + EnumProperty( + 'textCapitalization', + textCapitalization, + defaultValue: TextCapitalization.none, + ), + ) + ..add(EnumProperty('textAlign', textAlign, defaultValue: TextAlign.start)) + ..add(DiagnosticsProperty('textAlignVertical', textAlignVertical, defaultValue: null)) + ..add(EnumProperty('textDirection', textDirection, defaultValue: null)) ..add(DoubleProperty('cursorWidth', cursorWidth, defaultValue: 2.0)) ..add(DoubleProperty('cursorHeight', cursorHeight, defaultValue: null)) - ..add(DiagnosticsProperty('cursorRadius', cursorRadius, - defaultValue: null)) + ..add(DiagnosticsProperty('cursorRadius', cursorRadius, defaultValue: null)) ..add(ColorProperty('cursorColor', cursorColor, defaultValue: null)) - ..add(DiagnosticsProperty( - 'keyboardAppearance', keyboardAppearance, - defaultValue: null)) - ..add(DiagnosticsProperty( - 'scrollPadding', scrollPadding, - defaultValue: const EdgeInsets.all(20))) - ..add(FlagProperty('selectionEnabled', - value: selectionEnabled, - defaultValue: true, - ifFalse: 'selection disabled')) - ..add(DiagnosticsProperty( - 'selectionControls', selectionControls, - defaultValue: null)) - ..add(DiagnosticsProperty( - 'scrollController', scrollController, - defaultValue: null)) - ..add(DiagnosticsProperty('scrollPhysics', scrollPhysics, - defaultValue: null)) - ..add(DiagnosticsProperty('clipBehavior', clipBehavior, - defaultValue: Clip.hardEdge)) - ..add(DiagnosticsProperty('scribbleEnabled', scribbleEnabled, - defaultValue: true)) - ..add(DiagnosticsProperty( - 'enableIMEPersonalizedLearning', enableIMEPersonalizedLearning, - defaultValue: true)) - ..add(DiagnosticsProperty( - 'contentInsertionConfiguration', contentInsertionConfiguration, - defaultValue: null)); + ..add(DiagnosticsProperty('keyboardAppearance', keyboardAppearance, defaultValue: null)) + ..add( + DiagnosticsProperty('scrollPadding', scrollPadding, defaultValue: const EdgeInsets.all(20)), + ) + ..add( + FlagProperty('selectionEnabled', value: selectionEnabled, defaultValue: true, ifFalse: 'selection disabled'), + ) + ..add(DiagnosticsProperty('selectionControls', selectionControls, defaultValue: null)) + ..add(DiagnosticsProperty('scrollController', scrollController, defaultValue: null)) + ..add(DiagnosticsProperty('scrollPhysics', scrollPhysics, defaultValue: null)) + ..add(DiagnosticsProperty('clipBehavior', clipBehavior, defaultValue: Clip.hardEdge)) + ..add(DiagnosticsProperty('scribbleEnabled', scribbleEnabled, defaultValue: true)) + ..add( + DiagnosticsProperty('enableIMEPersonalizedLearning', enableIMEPersonalizedLearning, defaultValue: true), + ) + ..add( + DiagnosticsProperty( + 'contentInsertionConfiguration', + contentInsertionConfiguration, + defaultValue: null, + ), + ); } } -class _StreamMessageTextFieldState extends State - with RestorationMixin { - StreamMessageInputController get _effectiveController => - widget.controller ?? _controller!.value; - StreamRestorableMessageInputController? _controller; +class _StreamMessageTextFieldState extends State with RestorationMixin { + StreamMessageComposerController get _effectiveController => widget.controller ?? _controller!.value; + StreamRestorableMessageComposerController? _controller; @override void initState() { @@ -621,7 +595,7 @@ class _StreamMessageTextFieldState extends State void _createLocalController([Message? message]) { assert(_controller == null, ''); - _controller = StreamRestorableMessageInputController(message: message); + _controller = StreamRestorableMessageComposerController(message: message); } @override @@ -653,63 +627,62 @@ class _StreamMessageTextFieldState extends State @override Widget build(BuildContext context) => TextField( - controller: _effectiveController.textFieldController, - focusNode: widget.focusNode, - decoration: widget.decoration, - keyboardType: widget.keyboardType, - textInputAction: widget.textInputAction ?? - (widget.keyboardType == TextInputType.multiline - ? TextInputAction.newline - : TextInputAction.send), - textCapitalization: widget.textCapitalization, - style: widget.style, - strutStyle: widget.strutStyle, - textAlign: widget.textAlign, - textAlignVertical: widget.textAlignVertical, - textDirection: widget.textDirection, - readOnly: widget.readOnly, - showCursor: widget.showCursor, - autofocus: widget.autofocus, - obscuringCharacter: widget.obscuringCharacter, - obscureText: widget.obscureText, - autocorrect: widget.autocorrect, - smartDashesType: widget.smartDashesType, - smartQuotesType: widget.smartQuotesType, - enableSuggestions: widget.enableSuggestions, - maxLines: widget.maxLines, - minLines: widget.minLines, - expands: widget.expands, - maxLength: widget.maxLength, - maxLengthEnforcement: widget.maxLengthEnforcement, - onEditingComplete: widget.onEditingComplete, - onSubmitted: widget.onSubmitted, - onAppPrivateCommand: widget.onAppPrivateCommand, - inputFormatters: widget.inputFormatters, - enabled: widget.enabled, - cursorWidth: widget.cursorWidth, - cursorHeight: widget.cursorHeight, - cursorRadius: widget.cursorRadius, - cursorColor: widget.cursorColor, - selectionHeightStyle: widget.selectionHeightStyle, - selectionWidthStyle: widget.selectionWidthStyle, - keyboardAppearance: widget.keyboardAppearance, - scrollPadding: widget.scrollPadding, - dragStartBehavior: widget.dragStartBehavior, - enableInteractiveSelection: widget.enableInteractiveSelection, - selectionControls: widget.selectionControls, - onTap: widget.onTap, - mouseCursor: widget.mouseCursor, - buildCounter: widget.buildCounter, - scrollController: widget.scrollController, - scrollPhysics: widget.scrollPhysics, - autofillHints: widget.autofillHints, - clipBehavior: widget.clipBehavior, - restorationId: widget.restorationId, - // ignore: deprecated_member_use - scribbleEnabled: widget.scribbleEnabled, - enableIMEPersonalizedLearning: widget.enableIMEPersonalizedLearning, - contentInsertionConfiguration: widget.contentInsertionConfiguration, - ); + controller: _effectiveController.textFieldController, + focusNode: widget.focusNode, + decoration: widget.decoration, + keyboardType: widget.keyboardType, + textInputAction: + widget.textInputAction ?? + (widget.keyboardType == TextInputType.multiline ? TextInputAction.newline : TextInputAction.send), + textCapitalization: widget.textCapitalization, + style: widget.style, + strutStyle: widget.strutStyle, + textAlign: widget.textAlign, + textAlignVertical: widget.textAlignVertical, + textDirection: widget.textDirection, + readOnly: widget.readOnly, + showCursor: widget.showCursor, + autofocus: widget.autofocus, + obscuringCharacter: widget.obscuringCharacter, + obscureText: widget.obscureText, + autocorrect: widget.autocorrect, + smartDashesType: widget.smartDashesType, + smartQuotesType: widget.smartQuotesType, + enableSuggestions: widget.enableSuggestions, + maxLines: widget.maxLines, + minLines: widget.minLines, + expands: widget.expands, + maxLength: widget.maxLength, + maxLengthEnforcement: widget.maxLengthEnforcement, + onEditingComplete: widget.onEditingComplete, + onSubmitted: widget.onSubmitted, + onAppPrivateCommand: widget.onAppPrivateCommand, + inputFormatters: widget.inputFormatters, + enabled: widget.enabled, + cursorWidth: widget.cursorWidth, + cursorHeight: widget.cursorHeight, + cursorRadius: widget.cursorRadius, + cursorColor: widget.cursorColor, + selectionHeightStyle: widget.selectionHeightStyle, + selectionWidthStyle: widget.selectionWidthStyle, + keyboardAppearance: widget.keyboardAppearance, + scrollPadding: widget.scrollPadding, + dragStartBehavior: widget.dragStartBehavior, + enableInteractiveSelection: widget.enableInteractiveSelection, + selectionControls: widget.selectionControls, + onTap: widget.onTap, + mouseCursor: widget.mouseCursor, + buildCounter: widget.buildCounter, + scrollController: widget.scrollController, + scrollPhysics: widget.scrollPhysics, + autofillHints: widget.autofillHints, + clipBehavior: widget.clipBehavior, + restorationId: widget.restorationId, + // ignore: deprecated_member_use + scribbleEnabled: widget.scribbleEnabled, + enableIMEPersonalizedLearning: widget.enableIMEPersonalizedLearning, + contentInsertionConfiguration: widget.contentInsertionConfiguration, + ); @override void dispose() { diff --git a/packages/stream_chat_flutter/lib/src/message_input/tld.dart b/packages/stream_chat_flutter/lib/src/message_input/tld.dart index 2bfa52578f..f340af4719 100644 --- a/packages/stream_chat_flutter/lib/src/message_input/tld.dart +++ b/packages/stream_chat_flutter/lib/src/message_input/tld.dart @@ -2,9 +2,7 @@ extension TLDString on String { /// Returns true if the string is a valid TLD. bool isValidTLD() => - isNotEmpty && - tlds.containsKey(this[0].toUpperCase()) && - tlds[this[0].toUpperCase()]!.contains(toUpperCase()); + isNotEmpty && tlds.containsKey(this[0].toUpperCase()) && tlds[this[0].toUpperCase()]!.contains(toUpperCase()); } /// List of valid TLDs. diff --git a/packages/stream_chat_flutter/lib/src/message_list_view/floating_date_divider.dart b/packages/stream_chat_flutter/lib/src/message_list_view/floating_date_divider.dart index 9802da666a..908d0d9dda 100644 --- a/packages/stream_chat_flutter/lib/src/message_list_view/floating_date_divider.dart +++ b/packages/stream_chat_flutter/lib/src/message_list_view/floating_date_divider.dart @@ -1,3 +1,5 @@ +import 'dart:math'; + import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:stream_chat_flutter/scrollable_positioned_list/scrollable_positioned_list.dart'; @@ -16,14 +18,12 @@ class FloatingDateDivider extends StatelessWidget { required this.reverse, required this.messages, required this.itemCount, - @Deprecated('No longer used, Will be removed in future versions.') - this.isThreadConversation = false, + this.fadeNearInlineDivider = true, this.dateDividerBuilder, }); - /// true if this is a thread conversation - @Deprecated('No longer used, Will be removed in future versions.') - final bool isThreadConversation; + /// Viewport-fraction over which the floating divider fades out + static const _fadeRange = 0.05; /// A [ValueListenable] that provides the positions of items in the list view. final ValueListenable> itemPositionListener; @@ -38,6 +38,12 @@ class FloatingDateDivider extends StatelessWidget { /// loaders, headers, and footers. final int itemCount; + /// Whether this divider fades out when an inline date divider for the same + /// date approaches it in the viewport. + /// + /// Defaults to true. + final bool fadeNearInlineDivider; + /// A optional builder function that creates a widget to display the date /// divider. /// @@ -64,18 +70,130 @@ class FloatingDateDivider extends StatelessWidget { // Offset the index to account for two extra items // (loader and footer) at the bottom of the ListView. - final message = messages.elementAtOrNull(index - 2); + final messageIndex = index - 2; + final message = messages.elementAtOrNull(messageIndex); if (message == null) return const Empty(); - if (dateDividerBuilder case final builder?) { - return builder.call(message.createdAt.toLocal()); - } + final divider = switch (dateDividerBuilder) { + final builder? => builder.call(message.createdAt.toLocal()), + _ => StreamDateDivider(dateTime: message.createdAt.toLocal()), + }; + + if (!fadeNearInlineDivider) return divider; + + final opacity = _floatingDividerOpacity( + positions, + index, + messageIndex, + ); - return StreamDateDivider(dateTime: message.createdAt.toLocal()); + if (opacity <= 0) return const Empty(); + if (opacity >= 1) return divider; + + return Opacity(opacity: opacity, child: divider); }, ); } + double _floatingDividerOpacity( + Iterable positions, + int itemIndex, + int messageIndex, + ) { + final messageDate = messages[messageIndex].createdAt.toLocal(); + + final bool hasDateDividerAbove; + final bool hasDateDividerBelow; + + if (reverse) { + hasDateDividerAbove = + messageIndex >= messages.length - 1 || + !_isSameDay( + messageDate, + messages[messageIndex + 1].createdAt.toLocal(), + ); + hasDateDividerBelow = + messageIndex > 0 && + !_isSameDay( + messageDate, + messages[messageIndex - 1].createdAt.toLocal(), + ); + } else { + hasDateDividerAbove = + messageIndex > 0 && + !_isSameDay( + messageDate, + messages[messageIndex - 1].createdAt.toLocal(), + ); + hasDateDividerBelow = + messageIndex < messages.length - 1 && + !_isSameDay( + messageDate, + messages[messageIndex + 1].createdAt.toLocal(), + ); + } + + if (!hasDateDividerAbove && !hasDateDividerBelow) return 1; + + for (final p in positions) { + if (p.index != itemIndex) continue; + + var opacity = 1.0; + + if (reverse) { + // Fade as the inline divider ABOVE becomes visible + // (trailing edge = top of item, 1.0 = viewport top). + if (hasDateDividerAbove && p.itemTrailingEdge < 1) { + opacity = clampDouble( + (p.itemTrailingEdge - (1.0 - _fadeRange)) / _fadeRange, + 0, + 1, + ); + } + + // Fade as the inline divider BELOW approaches the viewport top + // (leading edge = bottom of item, approaching 1.0). + if (hasDateDividerBelow) { + final t = clampDouble( + ((1.0 - _fadeRange) - p.itemLeadingEdge) / _fadeRange, + 0, + 1, + ); + opacity = min(opacity, t); + } + } else { + // Fade as the inline divider ABOVE becomes visible + // (leading edge = top of item, 0.0 = viewport top). + if (hasDateDividerAbove && p.itemLeadingEdge > 0) { + opacity = clampDouble( + (_fadeRange - p.itemLeadingEdge) / _fadeRange, + 0, + 1, + ); + } + + // Fade as the inline divider BELOW approaches the viewport top + // (trailing edge = bottom of item, approaching 0.0). + if (hasDateDividerBelow) { + final t = clampDouble( + (p.itemTrailingEdge - _fadeRange) / _fadeRange, + 0, + 1, + ); + opacity = min(opacity, t); + } + } + + return opacity; + } + + return 1; + } + + static bool _isSameDay(DateTime a, DateTime b) { + return a.year == b.year && a.month == b.month && a.day == b.day; + } + // Returns True if the item index is a valid message index and not one of the // special items (like header, footer, loaders, etc.). bool _isValidMessageIndex(int index) { diff --git a/packages/stream_chat_flutter/lib/src/message_list_view/message_details.dart b/packages/stream_chat_flutter/lib/src/message_list_view/message_details.dart index 36ffd887bf..cc5c99eccc 100644 --- a/packages/stream_chat_flutter/lib/src/message_list_view/message_details.dart +++ b/packages/stream_chat_flutter/lib/src/message_list_view/message_details.dart @@ -13,10 +13,8 @@ class MessageDetails { this.index, ) { isMyMessage = message.user?.id == currentUserId; - isLastUser = index + 1 < messages.length && - message.user?.id == messages[index + 1].user?.id; - isNextUser = - index - 1 >= 0 && message.user!.id == messages[index - 1].user?.id; + isLastUser = index + 1 < messages.length && message.user?.id == messages[index + 1].user?.id; + isNextUser = index - 1 >= 0 && message.user!.id == messages[index - 1].user?.id; } /// True if the message belongs to the current user diff --git a/packages/stream_chat_flutter/lib/src/message_list_view/message_list_view.dart b/packages/stream_chat_flutter/lib/src/message_list_view/message_list_view.dart index 24fcaa3725..c54ba01ffb 100644 --- a/packages/stream_chat_flutter/lib/src/message_list_view/message_list_view.dart +++ b/packages/stream_chat_flutter/lib/src/message_list_view/message_list_view.dart @@ -1,4 +1,3 @@ -// ignore_for_file: lines_longer_than_80_chars import 'dart:async'; import 'dart:math'; @@ -9,12 +8,14 @@ import 'package:stream_chat_flutter/scrollable_positioned_list/scrollable_positi import 'package:stream_chat_flutter/src/message_list_view/floating_date_divider.dart'; import 'package:stream_chat_flutter/src/message_list_view/loading_indicator.dart'; import 'package:stream_chat_flutter/src/message_list_view/mlv_utils.dart'; +import 'package:stream_chat_flutter/src/message_list_view/stream_message_list_empty_state.dart'; +import 'package:stream_chat_flutter/src/message_list_view/stream_message_list_skeleton_loading.dart'; import 'package:stream_chat_flutter/src/message_list_view/thread_separator.dart'; -import 'package:stream_chat_flutter/src/message_list_view/unread_indicator_button.dart'; import 'package:stream_chat_flutter/src/message_list_view/unread_messages_separator.dart'; -import 'package:stream_chat_flutter/src/message_widget/ephemeral_message.dart'; +import 'package:stream_chat_flutter/src/message_widget/stream_ephemeral_message.dart'; import 'package:stream_chat_flutter/src/misc/empty_widget.dart'; import 'package:stream_chat_flutter/stream_chat_flutter.dart'; +import 'package:stream_core_flutter/stream_core_flutter.dart'; /// Spacing Types (These are properties of a message to help inform the decision /// of how much space / which widget to build after it) @@ -36,6 +37,21 @@ enum SpacingType { defaultSpacing, } +/// Signature for a function that builds a message widget from its +/// [StreamMessageItemProps]. +/// +/// Receives the [BuildContext], the [Message] data, and the pre-configured +/// [StreamMessageItemProps] with all list-level callbacks already wired in. +/// +/// Use [DefaultStreamMessageItem] to build the default UI, optionally modifying +/// the props via [StreamMessageItemProps.copyWith] first. +typedef StreamMessageItemBuilder = + Widget Function( + BuildContext context, + Message message, + StreamMessageItemProps defaultProps, + ); + /// {@template streamMessageListView} /// ![screenshot](https://raw.githubusercontent.com/GetStream/stream-chat-flutter/master/packages/stream_chat_flutter/screenshots/message_listview.png) /// ![screenshot](https://raw.githubusercontent.com/GetStream/stream-chat-flutter/master/packages/stream_chat_flutter/screenshots/message_listview_paint.png) @@ -63,7 +79,7 @@ enum SpacingType { /// }, /// ), /// ), -/// StreamMessageInput(), +/// StreamMessageComposer(), /// ], /// ), /// ); @@ -84,16 +100,24 @@ class StreamMessageListView extends StatefulWidget { const StreamMessageListView({ super.key, this.showScrollToBottom = true, - this.showUnreadCountOnScrollToBottom = false, + this.showUnreadCountOnScrollToBottom = true, this.scrollToBottomBuilder, this.showUnreadIndicator = true, - this.unreadIndicatorBuilder, this.markReadWhenAtTheBottom = true, this.messageBuilder, this.parentMessageBuilder, this.parentMessage, this.threadBuilder, this.onThreadTap, + this.onViewInChannelTap, + this.onEditMessageTap, + this.onReplyTap, + this.swipeToReply = false, + this.onUserAvatarTap, + this.onReactionsTap, + this.onQuotedMessageTap, + this.onMessageLinkTap, + this.onUserMentionTap, this.dateDividerBuilder, this.floatingDateDividerBuilder, // we need to use ClampingScrollPhysics to avoid the list view to bounce @@ -123,6 +147,7 @@ class StreamMessageListView extends StatefulWidget { this.onModeratedMessageTap, this.onMessageLongPress, this.showFloatingDateDivider = true, + this.fadeFloatingDateDividerNearInline = true, this.threadSeparatorBuilder, this.unreadMessagesSeparatorBuilder, this.messageListController, @@ -138,8 +163,22 @@ class StreamMessageListView extends StatefulWidget { /// dismiss the keyboard automatically. final ScrollViewKeyboardDismissBehavior keyboardDismissBehavior; - /// {@macro messageBuilder} - final MessageBuilder? messageBuilder; + /// Optional builder for per-instance message customization. + /// + /// When set, this builder is called for each regular message with + /// pre-configured [StreamMessageItemProps] that have all list-level + /// callbacks already wired in. Use [StreamMessageItemProps.copyWith] + /// to modify properties, and [DefaultStreamMessageItem] to build the default + /// widget. + /// + /// For app-wide customization, use [StreamComponentFactory] instead. + final StreamMessageItemBuilder? messageBuilder; + + /// Optional builder for the parent message at the top of a thread. + /// + /// Works the same as [messageBuilder] but is called for the parent + /// message only. + final StreamMessageItemBuilder? parentMessageBuilder; /// Whether the view scrolls in the reading direction. /// @@ -168,9 +207,6 @@ class StreamMessageListView extends StatefulWidget { /// {@macro moderatedMessageBuilder} final ModeratedMessageBuilder? moderatedMessageBuilder; - /// {@macro parentMessageBuilder} - final ParentMessageBuilder? parentMessageBuilder; - /// {@macro threadBuilder} final ThreadBuilder? threadBuilder; @@ -180,6 +216,67 @@ class StreamMessageListView extends StatefulWidget { /// built using [threadBuilder] final ThreadTapCallback? onThreadTap; + /// Called when the "View" button on the "Also sent in channel" annotation + /// is tapped inside a thread view. + /// + /// Use this to navigate to the channel screen and scroll to / highlight + /// the given [Message]. + /// + /// When null and the thread was opened via the default [threadBuilder] + /// navigation, the thread screen is automatically popped and the channel + /// list scrolls to the message. Provide this callback to override that + /// behaviour — for example when the thread is opened from a thread list + /// or deep link where popping would not land on the channel screen. + final void Function(Message message)? onViewInChannelTap; + + /// {@macro onEditMessageTap} + /// + /// If provided, the inline edit flow is used instead of the edit bottom sheet. + final void Function(Message)? onEditMessageTap; + + /// Called when the reply action is triggered on a message. + /// + /// Forwarded to each [StreamMessageItem] in the list. + final void Function(Message)? onReplyTap; + + /// Whether swiping a message triggers a quoted-reply action. + /// + /// Forwarded to each [StreamMessageItem] in the list via + /// [StreamMessageItemProps.swipeToReply]. + /// + /// Defaults to false. + final bool swipeToReply; + + /// Called when a user avatar is tapped. + /// + /// Forwarded to each [StreamMessageItem] in the list. + final void Function(User)? onUserAvatarTap; + + /// Called when the message reactions are tapped. + /// + /// Forwarded to each [StreamMessageItem] in the list. + final void Function(Message)? onReactionsTap; + + /// Called when a quoted message is tapped. + /// + /// When provided, this callback is forwarded to each + /// [StreamMessageItem] in the list. + /// + /// When null (the default), tapping a quoted message scrolls to it in + /// the list, loading it if necessary. + final void Function(Message quotedMessage)? onQuotedMessageTap; + + /// Called when a link is tapped in message text. + /// + /// Receives the [Message] containing the link and the tapped URL. + /// Forwarded to each [StreamMessageItem] in the list. + final void Function(Message message, String url)? onMessageLinkTap; + + /// Called when a user mention is tapped in message text. + /// + /// Forwarded to each [StreamMessageItem] in the list. + final void Function(User user)? onUserMentionTap; + /// If true will show a scroll to bottom button when /// the scroll offset is not zero final bool showScrollToBottom; @@ -207,31 +304,15 @@ class StreamMessageListView extends StatefulWidget { final Widget Function( int unreadCount, Future Function(int) scrollToBottomDefaultTapAction, - )? scrollToBottomBuilder; + )? + scrollToBottomBuilder; - /// If true will show an indicator with number of unread messages - /// that will scroll to latest read message when tapped and mark - /// channel as read when dismissed - final bool showUnreadIndicator; - - /// Function used to build a custom unread indicator widget - /// - /// Provides the current unread messages count and a reference - /// to the function that is executed on tap to scroll to latest - /// read message by default + /// Whether to show the jump-to-unread indicator when there are unread + /// messages in the channel. /// - /// As an example: - /// ``` - /// MessageListView( - /// unreadIndicatorBuilder: (unreadCount, defaultTapAction, dismissAction) { - /// return InkWell( - /// onTap: () => defaultTapAction(unreadCount), - /// child: Text('Scroll To Unread'), - /// ); - /// }, - /// ), - /// ``` - final UnreadIndicatorBuilder? unreadIndicatorBuilder; + /// When true (the default), an indicator is shown allowing the user to + /// jump to the oldest unread message or dismiss it. + final bool showUnreadIndicator; /// If true will mark channel as read when the user scrolls to the bottom of the list final bool markReadWhenAtTheBottom; @@ -279,6 +360,12 @@ class StreamMessageListView extends StatefulWidget { /// Flag for showing the floating date divider final bool showFloatingDateDivider; + /// Whether the floating date divider fades out when an inline date divider + /// for the same date is near the top of the viewport. + /// + /// Only has an effect when [showFloatingDateDivider] is true. + final bool fadeFloatingDateDividerNearInline; + /// Function called when messages are fetched final Widget Function(BuildContext, List)? messageListBuilder; @@ -323,12 +410,10 @@ class StreamMessageListView extends StatefulWidget { final OnMessageLongPress? onMessageLongPress; /// Builder used to build the thread separator in case it's a thread view - final Function(BuildContext context, Message parentMessage)? - threadSeparatorBuilder; + final Function(BuildContext context, Message parentMessage)? threadSeparatorBuilder; /// Builder used to build the unread message separator - final Widget Function(BuildContext context, int unreadCount)? - unreadMessagesSeparatorBuilder; + final Widget Function(BuildContext context, int unreadCount)? unreadMessagesSeparatorBuilder; /// A [MessageListController] allows pagination. /// @@ -345,15 +430,12 @@ class StreamMessageListView extends StatefulWidget { BuildContext context, List spacingTypes, ) { - if (spacingTypes.contains(SpacingType.otherUser)) { - return const SizedBox(height: 8); - } else if (spacingTypes.contains(SpacingType.thread)) { - return const SizedBox(height: 8); - } else if (spacingTypes.contains(SpacingType.timeDiff)) { - return const SizedBox(height: 8); - } + final spacing = context.streamSpacing; - return const SizedBox(height: 2); + if (spacingTypes.contains(SpacingType.otherUser)) return SizedBox(height: spacing.md); + if (spacingTypes.contains(SpacingType.timeDiff)) return SizedBox(height: spacing.xs); + + return SizedBox(height: spacing.xxs); } @override @@ -362,7 +444,7 @@ class StreamMessageListView extends StatefulWidget { class _StreamMessageListViewState extends State { ItemScrollController? _scrollController; - void Function(Message)? _onThreadTap; + void Function(Message parentMessage, Message? threadMessage)? _onThreadTap; final ValueNotifier _showScrollToBottom = ValueNotifier(false); late final ItemPositionsListener _itemPositionListener; int? _messageListLength; @@ -388,14 +470,14 @@ class _StreamMessageListViewState extends State { List messages = []; Map messagesIndex = {}; - bool initialMessageHighlightComplete = false; + String? _highlightedMessageId; + int _highlightGeneration = 0; bool _inBetweenList = false; late final _defaultController = MessageListController(); - MessageListController get _messageListController => - widget.messageListController ?? _defaultController; + MessageListController get _messageListController => widget.messageListController ?? _defaultController; StreamSubscription? _messageNewListener; StreamSubscription? _userReadListener; @@ -407,10 +489,8 @@ class _StreamMessageListViewState extends State { super.initState(); _scrollController = widget.scrollController ?? ItemScrollController(); - _itemPositionListener = - widget.itemPositionListener ?? ItemPositionsListener.create(); - _itemPositionListener.itemPositions - .addListener(_handleItemPositionsChanged); + _itemPositionListener = widget.itemPositionListener ?? ItemPositionsListener.create(); + _itemPositionListener.itemPositions.addListener(_handleItemPositionsChanged); _getOnThreadTap(); } @@ -433,13 +513,28 @@ class _StreamMessageListViewState extends State { unreadCount = streamChannel?.channel.state?.unreadCount ?? 0; _firstUnreadMessage = streamChannel?.getFirstUnreadMessage(); - initialIndex = getInitialIndex( - widget.initialScrollIndex, - streamChannel!, - widget.messageFilter, - ); - - initialAlignment = _initialAlignment; + final highlightMessageId = widget.highlightInitialMessage + ? (streamChannel?.initialMessageId ?? _ThreadHighlightScope.of(context)) + : null; + + if (highlightMessageId != null) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + _moveToAndHighlight( + messages: messages, + messageId: highlightMessageId, + initialScrollIndex: widget.initialScrollIndex, + scrollTo: false, + ); + }); + } else { + initialIndex = getInitialIndex( + widget.initialScrollIndex, + streamChannel!, + widget.messageFilter, + ); + initialAlignment = _initialAlignment; + } if (_scrollController?.isAttached == true) { _scrollController?.jumpTo( @@ -448,14 +543,12 @@ class _StreamMessageListViewState extends State { ); } - _messageNewListener = - streamChannel!.channel.on(EventType.messageNew).listen((event) { + _messageNewListener = streamChannel!.channel.on(EventType.messageNew).listen((event) { if (_upToDate) { _bottomPaginationActive = false; } if (event.message?.parentId == widget.parentMessage?.id && - event.message!.user!.id == - streamChannel!.channel.client.state.currentUser!.id) { + event.message!.user!.id == streamChannel!.channel.client.state.currentUser!.id) { setState(() => unreadCount = 0); WidgetsBinding.instance.addPostFrameCallback((_) { @@ -481,49 +574,141 @@ class _StreamMessageListViewState extends State { debouncedMarkThreadRead.cancel(); _messageNewListener?.cancel(); _userReadListener?.cancel(); - _itemPositionListener.itemPositions - .removeListener(_handleItemPositionsChanged); + _itemPositionListener.itemPositions.removeListener(_handleItemPositionsChanged); super.dispose(); } + // Duration of the programmatic scroll triggered by [_moveToAndHighlight]. + static const _kScrollToDuration = Duration(seconds: 1); + + // The highlight pulses on the target message after a jump: it stays at full + // color for [_kHighlightHoldDuration], then fades to transparent over + // [_kHighlightFadeDuration]. Tuned to feel like Slack's permalink jump — + // a clearly visible hold so the user can confirm "this is the message", + // followed by a graceful fade. + static const _kHighlightHoldDuration = Duration(seconds: 1); + static const _kHighlightFadeDuration = Duration(seconds: 1); + + void _highlightMessage(String messageId) { + setState(() { + _highlightedMessageId = messageId; + _highlightGeneration++; + }); + } + + Future _moveToAndHighlight({ + required List messages, + String? messageId, + int? initialScrollIndex, + bool scrollTo = true, + }) async { + if (messageId != null) { + // In a thread the parent message lives outside the `messages` list and + // is rendered as the very last item, so search for it explicitly when a + // thread reply quotes it. + final isThreadParent = _isThreadConversation && messageId == widget.parentMessage?.id; + final index = isThreadParent ? messages.length + 2 : messages.indexWhere((m) => m.id == messageId); + + if (index >= 0) { + // Wait for the scroll to settle before flagging the message as + // highlighted; otherwise the highlight tween fires while the list is + // still animating (or before the target item is even mounted) and the + // user only sees the tail end of the fade. + if (scrollTo) { + await _scrollController?.scrollTo( + index: index + 2, // +2 to account for loader and footer + duration: _kScrollToDuration, + curve: Curves.easeInOut, + alignment: 0.1, + ); + } else { + _scrollController?.jumpTo( + index: index + 2, // +2 to account for loader and footer + alignment: 0.1, + ); + } + } else { + await streamChannel!.loadChannelAtMessage(messageId).then((_) async { + initialIndex = getInitialIndex( + initialScrollIndex, + streamChannel!, + widget.messageFilter, + messageId: messageId, + ); + initialAlignment = 0.1; + }); + } + } else if (initialScrollIndex != null) { + _scrollController?.jumpTo( + index: initialScrollIndex, + alignment: initialAlignment, + ); + } + + if (messageId != null && mounted) { + _highlightMessage(messageId); + } + } + + // Wraps [child] in the highlight pulse if [message] is the currently + // highlighted message. Holds at full color for [_kHighlightHoldDuration], + // then fades to transparent over [_kHighlightFadeDuration]. + Widget _maybeWrapWithHighlight({required Message message, required Widget child}) { + if (_highlightedMessageId != message.id) return child; + + final colorScheme = context.streamColorScheme; + final highlightColor = widget.messageHighlightColor ?? colorScheme.backgroundHighlight; + + // Drive the whole sequence (hold + fade) with a single tween whose curve is + // clamped to the trailing fade window — this gives us the hold for free. + final totalMs = _kHighlightHoldDuration.inMilliseconds + _kHighlightFadeDuration.inMilliseconds; + final fadeStart = _kHighlightHoldDuration.inMilliseconds / totalMs; + + return TweenAnimationBuilder( + key: ValueKey('highlight-$_highlightGeneration'), + tween: ColorTween(begin: highlightColor, end: highlightColor.withValues(alpha: 0)), + duration: Duration(milliseconds: totalMs), + curve: Interval(fadeStart, 1, curve: Curves.easeOut), + onEnd: () { + if (_highlightedMessageId == message.id) { + setState(() => _highlightedMessageId = null); + } + }, + builder: (_, color, child) => ColoredBox(color: color!, child: child), + child: child, + ); + } + @override Widget build(BuildContext context) { + // TODO: Revisit this nested Portal setup during desktop reactions refactor + // and remove the extra layer if a dedicated message-list portal label is + // no longer required. return Portal( labels: const [kPortalMessageListViewLabel], - child: ScaffoldMessenger( - child: MessageListCore( - paginationLimit: widget.paginationLimit, - messageFilter: widget.messageFilter, - loadingBuilder: widget.loadingBuilder ?? - (context) => const Center( - child: CircularProgressIndicator.adaptive(), - ), - emptyBuilder: widget.emptyBuilder ?? - (context) => Center( - child: Text( - context.translations.emptyChatMessagesText, - style: _streamTheme.textTheme.footnote.copyWith( - color: _streamTheme.colorTheme.textHighEmphasis - // ignore: deprecated_member_use - .withOpacity(0.5), - ), - ), - ), - messageListBuilder: widget.messageListBuilder ?? - (context, list) => _buildListView(list), - messageListController: _messageListController, - parentMessage: widget.parentMessage, - errorBuilder: widget.errorBuilder ?? - (BuildContext context, Object error) => Center( - child: Text( - context.translations.genericErrorText, - style: _streamTheme.textTheme.footnote.copyWith( - color: _streamTheme.colorTheme.textHighEmphasis - // ignore: deprecated_member_use - .withOpacity(0.5), - ), + child: Portal( + child: ScaffoldMessenger( + child: MessageListCore( + paginationLimit: widget.paginationLimit, + messageFilter: widget.messageFilter, + loadingBuilder: widget.loadingBuilder ?? (context) => const StreamMessageListSkeletonLoading(), + emptyBuilder: widget.emptyBuilder ?? (context) => const StreamMessageListEmptyState(), + messageListBuilder: widget.messageListBuilder ?? (context, list) => _buildListView(list), + messageListController: _messageListController, + parentMessage: widget.parentMessage, + errorBuilder: + widget.errorBuilder ?? + (BuildContext context, Object error) => Center( + child: Text( + context.translations.genericErrorText, + style: _streamTheme.textTheme.footnote.copyWith( + color: _streamTheme.colorTheme.textHighEmphasis + // ignore: deprecated_member_use + .withOpacity(0.5), ), ), + ), + ), ), ), ); @@ -543,8 +728,7 @@ class _StreamMessageListViewState extends State { final first = _itemPositionListener.itemPositions.value.first; final diff = newMessagesListLength - _messageListLength!; if (diff > 0) { - if (messages[0].user?.id != - streamChannel!.channel.client.state.currentUser?.id) { + if (messages[0].user?.id != streamChannel!.channel.client.state.currentUser?.id) { initialIndex = first.index + diff; initialAlignment = first.itemLeadingEdge; } @@ -555,10 +739,11 @@ class _StreamMessageListViewState extends State { _messageListLength = newMessagesListLength; - final itemCount = messages.length + // total messages - 2 + // top + bottom loading indicator - 2 + // header + footer - 1 // parent message + final itemCount = + messages.length + // total messages + 2 + // top + bottom loading indicator + 2 + // header + footer + 1 // parent message ; final child = Stack( @@ -612,6 +797,7 @@ class _StreamMessageListViewState extends State { key: (initialIndex != 0 && initialAlignment != 0) ? ValueKey('$initialIndex-$initialAlignment') : null, + padding: .symmetric(vertical: context.streamSpacing.sm), keyboardDismissBehavior: widget.keyboardDismissBehavior, itemPositionsListener: _itemPositionListener, initialScrollIndex: initialIndex, @@ -666,7 +852,6 @@ class _StreamMessageListViewState extends State { // BottomLoader -> 1 (count-7) // Separator(Footer -> 8??30) -> 0 (count-8) // Footer -> 0 (count-8) - separatorBuilder: (context, i) { Widget maybeBuildWithUnreadMessagesSeparator({ required Message message, @@ -674,9 +859,7 @@ class _StreamMessageListViewState extends State { }) { if (unreadCount == 0) return separator; if (_isThreadConversation) return separator; - if (_firstUnreadMessage?.id != message.id) { - return separator; - } + if (_firstUnreadMessage?.id != message.id) return separator; return Column( crossAxisAlignment: CrossAxisAlignment.stretch, @@ -693,18 +876,15 @@ class _StreamMessageListViewState extends State { } if (widget.threadSeparatorBuilder != null) { - return widget.threadSeparatorBuilder! - .call(context, widget.parentMessage!); + return widget.threadSeparatorBuilder!.call(context, widget.parentMessage!); } return ThreadSeparator( - parentMessage: widget.parentMessage, + parentMessage: widget.parentMessage!, ); } if (i == itemCount - 3) { - if (widget.reverse - ? widget.headerBuilder == null - : widget.footerBuilder == null) { + if (widget.reverse ? widget.headerBuilder == null : widget.footerBuilder == null) { if (messages.isNotEmpty) { final message = messages.last; return maybeBuildWithUnreadMessagesSeparator( @@ -713,16 +893,13 @@ class _StreamMessageListViewState extends State { ); } - if (_isThreadConversation) return const Empty(); - return const SizedBox(height: 52); + return const Empty(); } return const SizedBox(height: 8); } if (i == 0) { - if (widget.reverse - ? widget.footerBuilder == null - : widget.headerBuilder == null) { - return const SizedBox(height: 30); + if (widget.reverse ? widget.footerBuilder == null : widget.headerBuilder == null) { + return const Empty(); } return const SizedBox(height: 8); } @@ -740,44 +917,15 @@ class _StreamMessageListViewState extends State { Widget separator; - final isPartOfThread = message.replyCount! > 0 || - message.showInChannel == true; - - final createdAt = Jiffy.parseFromDateTime( - message.createdAt.toLocal(), - ); - - final nextCreatedAt = Jiffy.parseFromDateTime( - nextMessage.createdAt.toLocal(), + final spacingRules = _resolveSpacingRules( + message: message, + nextMessage: nextMessage, ); - if (!createdAt.isSame(nextCreatedAt, unit: Unit.day)) { + if (spacingRules == null) { separator = _buildDateDivider(nextMessage); } else { - final hasTimeDiff = !createdAt.isSame( - nextCreatedAt, - unit: Unit.minute, - ); - - final isNextUserSame = - message.user!.id == nextMessage.user?.id; - final isDeleted = message.isDeleted; - - final spacingRules = [ - if (hasTimeDiff) SpacingType.timeDiff, - if (!isNextUserSame) SpacingType.otherUser, - if (isPartOfThread) SpacingType.thread, - if (isDeleted) SpacingType.deleted, - ]; - - if (spacingRules.isEmpty) { - spacingRules.add(SpacingType.defaultSpacing); - } - - separator = widget.spacingWidgetBuilder.call( - context, - spacingRules, - ); + separator = widget.spacingWidgetBuilder.call(context, spacingRules); } return maybeBuildWithUnreadMessagesSeparator( @@ -795,16 +943,13 @@ class _StreamMessageListViewState extends State { if (i == itemCount - 2) { if (widget.reverse) { - return widget.headerBuilder?.call(context) ?? - const Empty(); + return widget.headerBuilder?.call(context) ?? const Empty(); } else { - return widget.footerBuilder?.call(context) ?? - const Empty(); + return widget.footerBuilder?.call(context) ?? const Empty(); } } - final indicatorBuilder = - widget.paginationLoadingIndicatorBuilder; + final indicatorBuilder = widget.paginationLoadingIndicatorBuilder; if (i == itemCount - 3) { return LoadingIndicator( @@ -828,11 +973,9 @@ class _StreamMessageListViewState extends State { if (i == 0) { if (widget.reverse) { - return widget.footerBuilder?.call(context) ?? - const Empty(); + return widget.footerBuilder?.call(context) ?? const Empty(); } else { - return widget.headerBuilder?.call(context) ?? - const Empty(); + return widget.headerBuilder?.call(context) ?? const Empty(); } } @@ -853,10 +996,11 @@ class _StreamMessageListViewState extends State { ), if (widget.showFloatingDateDivider) Positioned( - top: 20, + top: context.streamSpacing.sm, child: FloatingDateDivider( itemCount: itemCount, reverse: widget.reverse, + fadeNearInlineDivider: widget.fadeFloatingDateDividerNearInline, itemPositionListener: _itemPositionListener.itemPositions, messages: messages, dateDividerBuilder: switch (widget.floatingDateDividerBuilder) { @@ -873,29 +1017,24 @@ class _StreamMessageListViewState extends State { valueListenable: _showScrollToBottom, child: _buildScrollToBottom(), builder: (context, value, child) { - if (!snapshot || value) { - return child!; - } + if (!snapshot || value) return child!; return const Empty(); }, ), ), if (widget.showUnreadIndicator && !_isThreadConversation) Positioned( - top: 8, + top: context.streamSpacing.sm, child: UnreadIndicatorButton( + onJumpTap: scrollToUnreadDefaultTapAction, onDismissTap: _markMessagesAsRead, - onTap: scrollToUnreadDefaultTapAction, - unreadIndicatorBuilder: widget.unreadIndicatorBuilder, ), ), ], ); - final backgroundColor = - StreamMessageListViewTheme.of(context).backgroundColor; - final backgroundImage = - StreamMessageListViewTheme.of(context).backgroundImage; + final backgroundColor = StreamMessageListViewTheme.of(context).backgroundColor; + final backgroundImage = StreamMessageListViewTheme.of(context).backgroundImage; if (backgroundColor != null || backgroundImage != null) { return DecoratedBox( @@ -913,15 +1052,14 @@ class _StreamMessageListViewState extends State { Widget _buildUnreadMessagesSeparator(int unreadCount) { final unreadMessagesSeparator = widget.unreadMessagesSeparatorBuilder?.call(context, unreadCount) ?? - UnreadMessagesSeparator(unreadCount: unreadCount); + UnreadMessagesSeparator(unreadCount: unreadCount); return unreadMessagesSeparator; } Future _paginateData( StreamChannelState? channel, QueryDirection direction, - ) => - _messageListController.paginateData!(direction: direction); + ) => _messageListController.paginateData!(direction: direction); Future scrollToBottomDefaultTapAction(int unreadCount) async { // If the channel is not up to date, we need to reload it before scrolling @@ -987,113 +1125,103 @@ class _StreamMessageListViewState extends State { } } + // Determines the applicable [SpacingType]s between two adjacent messages. + // + // Returns `null` when the messages fall on different days, indicating a + // date divider should be shown instead of spacing. + // + // Otherwise, evaluates multiple conditions and returns the list of spacing + // rules that apply. If no specific rule matches, [SpacingType.defaultSpacing] + // is returned. + // + // The rules are evaluated in the following order: + // - [SpacingType.timeDiff] — messages are more than a minute apart. + // - [SpacingType.otherUser] — messages belong to different groups. A group + // boundary is forced when either message is a system or moderated message. + // - [SpacingType.thread] — the current message is part of a thread. + // - [SpacingType.deleted] — the current message has been deleted. + List? _resolveSpacingRules({ + required Message message, + required Message nextMessage, + }) { + final createdAt = Jiffy.parseFromDateTime(message.createdAt.toLocal()); + final nextCreatedAt = Jiffy.parseFromDateTime(nextMessage.createdAt.toLocal()); + + // Different days — a date divider is needed instead of spacing. + if (!createdAt.isSame(nextCreatedAt, unit: Unit.day)) return null; + + // Time-based: messages are more than a minute apart. + final hasTimeDiff = !createdAt.isSame(nextCreatedAt, unit: Unit.minute); + + // System messages always form their own group. + final isSystem = message.isSystem || nextMessage.isSystem; + // Moderated (non-bounced error) messages always form their own group. + final isModerated = (message.isError && !message.isBounced) || (nextMessage.isError && !nextMessage.isBounced); + + // Two messages are from the same user group only if neither is system/moderated and they share the same sender. + final isNextUserSame = !isSystem && !isModerated && message.user!.id == nextMessage.user?.id; + + // Thread messages shown in channel are part of a thread. + final isPartOfThread = message.replyCount! > 0 || message.showInChannel == true; + + final rules = [ + if (hasTimeDiff) SpacingType.timeDiff, + if (!isNextUserSame) SpacingType.otherUser, + if (isPartOfThread) SpacingType.thread, + if (message.isDeleted) SpacingType.deleted, + ]; + + return rules.isEmpty ? [SpacingType.defaultSpacing] : rules; + } + Widget _buildDateDivider(Message message) { final createdAt = message.createdAt.toLocal(); return switch (widget.dateDividerBuilder) { final builder? => builder(createdAt), - _ => Padding( - padding: const EdgeInsets.symmetric(vertical: 12), - child: StreamDateDivider(dateTime: createdAt), - ), + _ => StreamDateDivider(dateTime: createdAt), }; } Widget buildParentMessage( Message message, ) { - final isMyMessage = - message.user!.id == StreamChat.of(context).currentUser!.id; - final isOnlyEmoji = message.text?.isOnlyEmoji ?? false; - final currentUser = StreamChat.of(context).currentUser; - final members = StreamChannel.of(context).channel.state?.members ?? []; - final currentUserMember = - members.firstWhereOrNull((e) => e.user!.id == currentUser!.id); - - final hasFileAttachment = - message.attachments.any((it) => it.type == AttachmentType.file); - - final hasUrlAttachment = - message.attachments.any((it) => it.type == AttachmentType.urlPreview); - - final attachmentBorderRadius = hasUrlAttachment - ? 8.0 - : hasFileAttachment - ? 12.0 - : 14.0; - - final borderSide = isOnlyEmoji ? BorderSide.none : null; - - final defaultMessageWidget = StreamMessageWidget( - showReplyMessage: false, - showResendMessage: false, - showThreadReplyMessage: false, - showCopyMessage: false, - showDeleteMessage: false, - showEditMessage: false, - showMarkUnreadMessage: false, + final parentMessageProps = StreamMessageItemProps( message: message, - reverse: isMyMessage, - showUsername: !isMyMessage, - padding: const EdgeInsets.all(8), - showSendingIndicator: false, - attachmentPadding: EdgeInsets.all( - hasUrlAttachment - ? 8 - : hasFileAttachment - ? 4 - : 2, - ), - attachmentShape: RoundedRectangleBorder( - side: BorderSide( - color: _streamTheme.colorTheme.borders, - strokeAlign: BorderSide.strokeAlignOutside, - ), - borderRadius: BorderRadius.only( - topLeft: Radius.circular(attachmentBorderRadius), - bottomLeft: isMyMessage - ? Radius.circular(attachmentBorderRadius) - : Radius.zero, - topRight: Radius.circular(attachmentBorderRadius), - bottomRight: isMyMessage - ? Radius.zero - : Radius.circular(attachmentBorderRadius), - ), - ), - borderRadiusGeometry: BorderRadius.only( - topLeft: const Radius.circular(16), - bottomLeft: isMyMessage ? const Radius.circular(16) : Radius.zero, - topRight: const Radius.circular(16), - bottomRight: isMyMessage ? Radius.zero : const Radius.circular(16), - ), - textPadding: EdgeInsets.symmetric( - vertical: 8, - horizontal: isOnlyEmoji ? 0 : 16.0, - ), - borderSide: borderSide, - showUserAvatar: isMyMessage ? DisplayWidget.gone : DisplayWidget.show, - messageTheme: isMyMessage - ? _streamTheme.ownMessageTheme - : _streamTheme.otherMessageTheme, + swipeToReply: widget.swipeToReply, + onThreadTap: _onThreadTap, onMessageTap: widget.onMessageTap, onMessageLongPress: widget.onMessageLongPress, - showPinButton: currentUserMember != null && - streamChannel?.channel.canPinMessage == true && - // Pinning a restricted visibility message is not allowed, simply - // because pinning a message is meant to bring attention to that - // message, that is not possible with a message that is only visible - // to a subset of users. - !message.hasRestrictedVisibility, + onEditMessageTap: widget.onEditMessageTap, + onReplyTap: widget.onReplyTap, + onUserAvatarTap: widget.onUserAvatarTap, + onReactionsTap: widget.onReactionsTap, + onQuotedMessageTap: widget.onQuotedMessageTap, + onMessageLinkTap: widget.onMessageLinkTap, + onUserMentionTap: widget.onUserMentionTap, ); - if (widget.parentMessageBuilder != null) { - return widget.parentMessageBuilder!.call( - context, - widget.parentMessage, - defaultMessageWidget, - ); - } + final userId = StreamChat.of(context).currentUser!.id; + final isMyMessage = message.user?.id == userId; + + final contentKind = resolveContentKind(message); + final isInThread = widget.parentMessage != null; + + final layout = StreamMessageLayout( + data: StreamMessageLayoutData( + stackPosition: .single, + alignment: isMyMessage ? .end : .start, + listKind: isInThread ? .thread : .channel, + contentKind: contentKind, + ), + child: Builder( + builder: (context) => switch (widget.parentMessageBuilder) { + final builder? => builder.call(context, message, parentMessageProps), + _ => StreamMessageItem.fromProps(props: parentMessageProps), + }, + ), + ); - return defaultMessageWidget; + return _maybeWrapWithHighlight(message: message, child: layout); } Widget _buildScrollToBottom() { @@ -1112,64 +1240,35 @@ class _StreamMessageListViewState extends State { scrollToBottomDefaultTapAction, ); } - final showUnreadCount = unreadCount > 0 && - streamChannel!.channel.state!.members.any((e) => - e.userId == - streamChannel!.channel.client.state.currentUser!.id); - - return Positioned( - bottom: 8, - right: 8, - width: 40, - height: 40, - child: Stack( - clipBehavior: Clip.none, - children: [ - FloatingActionButton( - backgroundColor: _streamTheme.colorTheme.barsBg, - onPressed: () async { - return scrollToBottomDefaultTapAction(unreadCount); - }, - child: widget.reverse - ? StreamSvgIcon( - icon: StreamSvgIcons.down, - color: _streamTheme.colorTheme.textHighEmphasis, - ) - : StreamSvgIcon( - icon: StreamSvgIcons.up, - color: _streamTheme.colorTheme.textHighEmphasis, - ), - ), - if (showUnreadCount && widget.showUnreadCountOnScrollToBottom) - Positioned( - left: 0, - right: 0, - top: -10, - child: Center( - child: Material( - borderRadius: BorderRadius.circular(8), - color: - StreamChatTheme.of(context).colorTheme.accentPrimary, - child: Padding( - padding: const EdgeInsets.only( - left: 5, - right: 5, - top: 2, - bottom: 2, - ), - child: Text( - '${unreadCount > 99 ? '99+' : unreadCount}', - style: const TextStyle( - fontSize: 11, - color: Colors.white, - ), - ), - ), - ), - ), - ), - ], - ), + final showUnreadCount = + unreadCount > 0 && + streamChannel!.channel.state!.members.any( + (e) => e.userId == streamChannel!.channel.client.state.currentUser!.id, + ); + + Widget button = StreamButton.icon( + style: .secondary, + type: .outline, + size: .medium, + isFloating: true, + icon: switch (widget.reverse) { + true => Icon(context.streamIcons.arrowDown), + false => Icon(context.streamIcons.arrowUp), + }, + onPressed: () => scrollToBottomDefaultTapAction(unreadCount), + ); + + if (showUnreadCount && widget.showUnreadCountOnScrollToBottom) { + button = StreamBadgeNotification( + label: '${unreadCount > 99 ? '99+' : unreadCount}', + child: button, + ); + } + + return PositionedDirectional( + bottom: 16, + end: 16, + child: button, ); }, ); @@ -1221,230 +1320,55 @@ class _StreamMessageListViewState extends State { return buildModeratedMessage(message); } - final userId = StreamChat.of(context).currentUser!.id; - final isMyMessage = message.user?.id == userId; - final nextMessage = index - 1 >= 0 ? messages[index - 1] : null; - final isNextUserSame = - nextMessage != null && message.user!.id == nextMessage.user!.id; - - var hasTimeDiff = false; - if (nextMessage != null) { - final createdAt = Jiffy.parseFromDateTime(message.createdAt.toLocal()); - final nextCreatedAt = Jiffy.parseFromDateTime( - nextMessage.createdAt.toLocal(), - ); - - hasTimeDiff = !createdAt.isSame(nextCreatedAt, unit: Unit.minute); - } - - final hasVoiceRecordingAttachment = message.attachments - .any((it) => it.type == AttachmentType.voiceRecording); - - final hasFileAttachment = - message.attachments.any((it) => it.type == AttachmentType.file); - - final hasUrlAttachment = - message.attachments.any((it) => it.type == AttachmentType.urlPreview); - - final isThreadMessage = - message.parentId != null && message.showInChannel == true; - - final hasReplies = message.replyCount! > 0; - - final attachmentBorderRadius = hasUrlAttachment - ? 8.0 - : hasFileAttachment - ? 12.0 - : 14.0; - - final showTimeStamp = (!isThreadMessage || _isThreadConversation) && - !hasReplies && - (hasTimeDiff || !isNextUserSame); - - final showUsername = !isMyMessage && - (!isThreadMessage || _isThreadConversation) && - !hasReplies && - (hasTimeDiff || !isNextUserSame); - - final showMarkUnread = streamChannel?.channel.config?.readEvents == true && - !isMyMessage && - (!isThreadMessage || _isThreadConversation); - - final showUserAvatar = isMyMessage - ? DisplayWidget.gone - : (hasTimeDiff || !isNextUserSame) - ? DisplayWidget.show - : DisplayWidget.hide; - - final showSendingIndicator = - isMyMessage && (index == 0 || hasTimeDiff || !isNextUserSame); - - final showInChannelIndicator = !_isThreadConversation && isThreadMessage; - final showThreadReplyIndicator = !_isThreadConversation && hasReplies; - final isOnlyEmoji = message.text?.isOnlyEmoji ?? false; - - final borderSide = isOnlyEmoji ? BorderSide.none : null; - - final currentUser = StreamChat.of(context).currentUser; - final members = StreamChannel.of(context).channel.state?.members ?? []; - final currentUserMember = - members.firstWhereOrNull((e) => e.user!.id == currentUser!.id); - - Widget messageWidget = StreamMessageWidget( + final messageItemProps = StreamMessageItemProps( message: message, - reverse: isMyMessage, - showReactions: !message.isDeleted, - padding: const EdgeInsets.symmetric(horizontal: 8), - showInChannelIndicator: showInChannelIndicator, - showThreadReplyIndicator: showThreadReplyIndicator, - showUsername: showUsername, - showTimestamp: showTimeStamp, - showSendingIndicator: showSendingIndicator, - showUserAvatar: showUserAvatar, - showMarkUnreadMessage: showMarkUnread, - onQuotedMessageTap: (quotedMessageId) async { - if (messages.map((e) => e.id).contains(quotedMessageId)) { - final index = messages.indexWhere((m) => m.id == quotedMessageId); - _scrollController?.scrollTo( - index: index + 2, // +2 to account for loader and footer - duration: const Duration(seconds: 1), - curve: Curves.easeInOut, - alignment: 0.1, - ); - } else { - await streamChannel! - .loadChannelAtMessage(quotedMessageId) - .then((_) async { - initialIndex = 21; // 19 + 2 | 19 is the index of the message - initialAlignment = 0.1; - }); - } - }, - showEditMessage: isMyMessage, - showDeleteMessage: isMyMessage, - showThreadReplyMessage: - !isThreadMessage && streamChannel?.channel.canSendReply == true, - showFlagButton: !isMyMessage, - borderSide: borderSide, + swipeToReply: widget.swipeToReply, onThreadTap: _onThreadTap, - attachmentShape: RoundedRectangleBorder( - side: BorderSide( - color: _streamTheme.colorTheme.borders, - strokeAlign: BorderSide.strokeAlignOutside, - ), - borderRadius: BorderRadius.only( - topLeft: Radius.circular(attachmentBorderRadius), - bottomLeft: isMyMessage - ? Radius.circular(attachmentBorderRadius) - : Radius.circular( - (hasTimeDiff || !isNextUserSame) && - !(hasReplies || - isThreadMessage || - hasFileAttachment || - hasVoiceRecordingAttachment) - ? 0 - : attachmentBorderRadius, - ), - topRight: Radius.circular(attachmentBorderRadius), - bottomRight: isMyMessage - ? Radius.circular( - (hasTimeDiff || !isNextUserSame) && - !(hasReplies || - isThreadMessage || - hasFileAttachment || - hasVoiceRecordingAttachment) - ? 0 - : attachmentBorderRadius, - ) - : Radius.circular(attachmentBorderRadius), - ), - ), - attachmentPadding: EdgeInsets.all( - hasUrlAttachment - ? 8 - : hasFileAttachment || hasVoiceRecordingAttachment - ? 4 - : 2, - ), - borderRadiusGeometry: BorderRadius.only( - topLeft: const Radius.circular(16), - bottomLeft: isMyMessage - ? const Radius.circular(16) - : Radius.circular( - (hasTimeDiff || !isNextUserSame) && - !(hasReplies || isThreadMessage) - ? 0 - : 16, - ), - topRight: const Radius.circular(16), - bottomRight: isMyMessage - ? Radius.circular( - (hasTimeDiff || !isNextUserSame) && - !(hasReplies || isThreadMessage) - ? 0 - : 16, - ) - : const Radius.circular(16), - ), - textPadding: EdgeInsets.symmetric( - vertical: 8, - horizontal: isOnlyEmoji ? 0 : 16.0, - ), - messageTheme: isMyMessage - ? _streamTheme.ownMessageTheme - : _streamTheme.otherMessageTheme, + onViewInChannelTap: _isThreadConversation + ? widget.onViewInChannelTap ?? (message) => Navigator.of(context).pop(message.id) + : null, onMessageTap: widget.onMessageTap, onMessageLongPress: widget.onMessageLongPress, - showPinButton: currentUserMember != null && - streamChannel?.channel.canPinMessage == true && - // Pinning a restricted visibility message is not allowed, simply - // because pinning a message is meant to bring attention to that - // message, that is not possible with a message that is only visible - // to a subset of users. - !message.hasRestrictedVisibility, + onEditMessageTap: widget.onEditMessageTap, + onReplyTap: widget.onReplyTap, + onUserAvatarTap: widget.onUserAvatarTap, + onReactionsTap: widget.onReactionsTap, + onMessageLinkTap: widget.onMessageLinkTap, + onUserMentionTap: widget.onUserMentionTap, + onQuotedMessageTap: switch (widget.onQuotedMessageTap) { + final onTap? => onTap, + _ => (quotedMessage) => _moveToAndHighlight( + messageId: quotedMessage.id, + messages: messages, + ), + }, ); - if (widget.messageBuilder != null) { - messageWidget = widget.messageBuilder!( - context, - MessageDetails( - userId, - message, - messages, - index, - ), - messages, - messageWidget as StreamMessageWidget, - ); - } + final userId = StreamChat.of(context).currentUser!.id; + final isMyMessage = message.user?.id == userId; + final nextMessage = index - 1 >= 0 ? messages[index - 1] : null; + final prevMessage = index + 1 < messages.length ? messages[index + 1] : null; - var child = messageWidget; - if (!initialMessageHighlightComplete && - widget.highlightInitialMessage && - isInitialMessage(message.id, streamChannel)) { - final colorTheme = _streamTheme.colorTheme; - final highlightColor = - widget.messageHighlightColor ?? colorTheme.highlight; - child = TweenAnimationBuilder( - tween: ColorTween( - begin: highlightColor, - // ignore: deprecated_member_use - end: colorTheme.barsBg.withOpacity(0), - ), - duration: const Duration(seconds: 3), - onEnd: () => initialMessageHighlightComplete = true, - builder: (_, color, child) => ColoredBox( - color: color!, - child: child, - ), - child: Padding( - padding: const EdgeInsets.symmetric(vertical: 4), - child: child, - ), - ); - } + final contentKind = resolveContentKind(message); + final isInThread = widget.parentMessage != null; + final stackPosition = computeStackPosition(message: message, previous: prevMessage, next: nextMessage); + + final layout = StreamMessageLayout( + data: StreamMessageLayoutData( + stackPosition: stackPosition, + alignment: isMyMessage ? .end : .start, + listKind: isInThread ? .thread : .channel, + contentKind: contentKind, + ), + child: Builder( + builder: (context) => switch (widget.messageBuilder) { + final builder? => builder.call(context, message, messageItemProps), + _ => StreamMessageItem.fromProps(props: messageItemProps), + }, + ), + ); - return child; + return _maybeWrapWithHighlight(message: message, child: layout); } void _handleItemPositionsChanged() { @@ -1518,31 +1442,68 @@ class _StreamMessageListViewState extends State { } void _getOnThreadTap() { - if (widget.onThreadTap != null) { - _onThreadTap = (Message message) { - final threadBuilder = widget.threadBuilder; - widget.onThreadTap!( - message, - threadBuilder != null ? threadBuilder(context, message) : null, + _onThreadTap = switch ((widget.onThreadTap, widget.threadBuilder)) { + // Case 1: widget.onThreadTap is provided. + // The created callback will use widget.onThreadTap, passing the result + // of widget.threadBuilder (if provided) as the second argument. + (final onThreadTap?, final threadBuilder) => (Message parentMessage, Message? threadMessage) { + onThreadTap( + parentMessage, + threadBuilder?.call(context, parentMessage), ); - }; - } else if (widget.threadBuilder != null) { - _onThreadTap = (Message message) { - Navigator.of(context).push( - MaterialPageRoute( - builder: (_) => BetterStreamBuilder( - stream: streamChannel!.channel.state!.messagesStream.map( - (messages) => messages.firstWhere((m) => m.id == message.id), - ), - initialData: message, - builder: (_, data) => StreamChannel( - channel: streamChannel!.channel, - child: widget.threadBuilder!(context, data), + }, + // Case 2: widget.onThreadTap is null, but widget.threadBuilder is provided. + // The created callback will perform the default navigation action, + // using widget.threadBuilder to build the thread page. + (null, final threadBuilder?) => (Message parentMessage, Message? threadMessage) async { + Widget threadPage = StreamChatConfiguration( + // This is needed to provide the nearest reaction icons to the + // StreamMessageReactionsModal. + data: StreamChatConfiguration.of(context), + child: StreamChannel( + channel: streamChannel!.channel, + child: BetterStreamBuilder( + initialData: parentMessage, + stream: streamChannel!.channel.state?.messagesStream.map( + (it) => it.firstWhere((m) => m.id == parentMessage.id), ), + builder: (_, data) => threadBuilder(context, data), ), ), ); - }; - } + + if (threadMessage != null) { + threadPage = _ThreadHighlightScope( + messageId: threadMessage.id, + child: threadPage, + ); + } + + final result = await Navigator.of(context).push( + MaterialPageRoute(builder: (_) => threadPage), + ); + + if (result != null && mounted) { + _moveToAndHighlight(messageId: result, messages: messages); + } + }, + _ => null, + }; } } + +class _ThreadHighlightScope extends InheritedWidget { + const _ThreadHighlightScope({ + required this.messageId, + required super.child, + }); + + final String messageId; + + static String? of(BuildContext context) { + return context.findAncestorWidgetOfExactType<_ThreadHighlightScope>()?.messageId; + } + + @override + bool updateShouldNotify(_ThreadHighlightScope oldWidget) => messageId != oldWidget.messageId; +} diff --git a/packages/stream_chat_flutter/lib/src/message_list_view/mlv_utils.dart b/packages/stream_chat_flutter/lib/src/message_list_view/mlv_utils.dart index 00177e0fb5..642fac0df9 100644 --- a/packages/stream_chat_flutter/lib/src/message_list_view/mlv_utils.dart +++ b/packages/stream_chat_flutter/lib/src/message_list_view/mlv_utils.dart @@ -7,8 +7,9 @@ import 'package:stream_chat_flutter/stream_chat_flutter.dart'; int getInitialIndex( int? initialScrollIndex, StreamChannelState channelState, - bool Function(Message)? messageFilter, -) { + bool Function(Message)? messageFilter, { + String? messageId, +}) { if (initialScrollIndex != null) return initialScrollIndex; final channel = channelState.channel; @@ -16,17 +17,17 @@ int getInitialIndex( if (currentUser == null) return 0; final messages = [ - ...channelState.channel.state!.messages - .where(messageFilter ?? defaultMessageFilter(currentUser.id)) + ...channelState.channel.state!.messages.where(messageFilter ?? defaultMessageFilter(currentUser.id)), ].reversed.toList(growable: false); - // Return the initial message index if available. - if (channelState.initialMessageId case final initialMessageId?) { - final initialMessageIndex = messages.indexWhere( - (it) => it.id == initialMessageId, + // Return the target message index if available. + final targetMessageId = messageId ?? channelState.initialMessageId; + if (targetMessageId != null) { + final targetMessageIndex = messages.indexWhere( + (it) => it.id == targetMessageId, ); - if (initialMessageIndex != -1) return initialMessageIndex + 2; + if (targetMessageIndex != -1) return targetMessageIndex + 2; } // Otherwise, return the first unread message index if available. @@ -101,3 +102,68 @@ bool isElementAtIndexVisible( bool isInitialMessage(String id, StreamChannelState? channelState) { return channelState!.initialMessageId == id; } + +/// Computes the [StreamMessageStackPosition] for [message] based on its +/// [previous] and [next] neighbors in the message list. +/// +/// A new group starts when: +/// - The neighbor is null (first/last message) +/// - The sender changes +/// - The timestamps fall in different calendar minutes +/// - The neighbor is a system, ephemeral, or error message +StreamMessageStackPosition computeStackPosition({ + required Message message, + Message? previous, + Message? next, +}) { + final isFirst = _isGroupBoundary(message, previous); + final isLast = _isGroupBoundary(message, next); + + return switch ((isFirst, isLast)) { + (true, true) => StreamMessageStackPosition.single, + (true, false) => StreamMessageStackPosition.top, + (false, false) => StreamMessageStackPosition.middle, + (false, true) => StreamMessageStackPosition.bottom, + }; +} + +bool _isGroupBoundary(Message message, Message? neighbor) { + if (neighbor == null) return true; + if (message.user?.id != neighbor.user?.id) return true; + if (neighbor.isSystem || neighbor.isEphemeral || neighbor.isError) return true; + + final createdAt = Jiffy.parseFromDateTime(message.createdAt.toLocal()); + final neighborCreatedAt = Jiffy.parseFromDateTime(neighbor.createdAt.toLocal()); + if (!createdAt.isSame(neighborCreatedAt, unit: Unit.minute)) return true; + + return false; +} + +/// Returns the [StreamMessageContentKind] for [message] based on its text, +/// attachments, poll, and quoted reply. +/// +/// The result is [StreamMessageContentKind.singleAttachment] when: +/// - There is no text and no quoted reply +/// - There is exactly one attachment or a poll +/// +/// The result is [StreamMessageContentKind.jumbomoji] when: +/// - There is no quoted reply, no poll, and no attachments +/// - The text contains only 1-3 emoji graphemes +StreamMessageContentKind resolveContentKind(Message message) { + final hasText = message.text?.isNotEmpty == true; + final hasQuote = message.quotedMessage != null; + final hasPoll = message.poll != null; + final hasSharedLocation = message.sharedLocation != null; + final attachmentCount = message.attachments.length; + + if (!hasText && !hasQuote && (hasPoll || hasSharedLocation || attachmentCount == 1)) { + return .singleAttachment; + } + + if (!hasQuote && attachmentCount == 0) { + final emojiCount = StreamMessageText.emojiCount(message.text); + if (emojiCount != null && emojiCount <= 3) return .jumbomoji; + } + + return .standard; +} diff --git a/packages/stream_chat_flutter/lib/src/message_list_view/stream_message_list_empty_state.dart b/packages/stream_chat_flutter/lib/src/message_list_view/stream_message_list_empty_state.dart new file mode 100644 index 0000000000..dc07d97761 --- /dev/null +++ b/packages/stream_chat_flutter/lib/src/message_list_view/stream_message_list_empty_state.dart @@ -0,0 +1,26 @@ +import 'package:flutter/material.dart'; +import 'package:stream_chat_flutter/stream_chat_flutter.dart'; + +/// A widget that is used to display the empty state of the message list. +class StreamMessageListEmptyState extends StatelessWidget { + /// Creates a new instance of the [StreamMessageListEmptyState]. + const StreamMessageListEmptyState({super.key}); + + @override + Widget build(BuildContext context) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Icon( + context.streamIcons.messageBubbleLarge, + size: 32, + ), + SizedBox(height: context.streamSpacing.sm), + Text(context.translations.sendMessageToStartConversationText), + ], + ), + ); + } +} diff --git a/packages/stream_chat_flutter/lib/src/message_list_view/stream_message_list_skeleton_loading.dart b/packages/stream_chat_flutter/lib/src/message_list_view/stream_message_list_skeleton_loading.dart new file mode 100644 index 0000000000..7a1c7f3780 --- /dev/null +++ b/packages/stream_chat_flutter/lib/src/message_list_view/stream_message_list_skeleton_loading.dart @@ -0,0 +1,117 @@ +import 'package:flutter/material.dart'; +import 'package:stream_core_flutter/stream_core_flutter.dart'; + +/// A shimmer loading placeholder for the message list view. +/// +/// Displays a skeleton UI with shimmer animation that mimics a chat +/// conversation with incoming (left-aligned) and outgoing (right-aligned) +/// message bubbles using [StreamSkeletonLoading] and [StreamSkeletonBox]. +class StreamMessageListSkeletonLoading extends StatelessWidget { + /// Creates a new instance of [StreamMessageListSkeletonLoading]. + const StreamMessageListSkeletonLoading({super.key}); + + @override + Widget build(BuildContext context) { + final spacing = context.streamSpacing; + + return StreamSkeletonLoading( + child: LayoutBuilder( + builder: (context, constraints) { + return Padding( + padding: EdgeInsets.all(spacing.md), + child: Column( + children: [ + _IncomingBubble(), + SizedBox(height: spacing.lg), + _OutgoingBubble(), + SizedBox(height: spacing.lg), + _IncomingBubble(), + SizedBox(height: spacing.lg), + _OutgoingBubble(), + SizedBox(height: spacing.lg), + _IncomingBubble(), + SizedBox(height: spacing.md), + ], + ), + ); + }, + ), + ); + } +} + +class _IncomingBubble extends StatelessWidget { + @override + Widget build(BuildContext context) { + final spacing = context.streamSpacing; + + return Row( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + const StreamSkeletonBox.circular(radius: 16), + SizedBox(width: spacing.xs), + Expanded( + flex: 3, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + StreamSkeletonBox( + height: 56, + borderRadius: BorderRadius.only( + topRight: context.streamRadius.xl, + bottomRight: context.streamRadius.xl, + topLeft: context.streamRadius.xl, + ), + ), + SizedBox(height: spacing.xs), + StreamSkeletonBox( + width: 56, + height: 12, + borderRadius: BorderRadius.all(context.streamRadius.max), + ), + SizedBox(height: spacing.xs), + ], + ), + ), + const Spacer( + flex: 1, + ), + ], + ); + } +} + +class _OutgoingBubble extends StatelessWidget { + @override + Widget build(BuildContext context) { + final spacing = context.streamSpacing; + + return Row( + children: [ + const Spacer( + flex: 1, + ), + Expanded( + flex: 2, + child: Column( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + StreamSkeletonBox( + height: 56, + borderRadius: BorderRadius.all( + context.streamRadius.xl, + ), + ), + SizedBox(height: spacing.xs), + StreamSkeletonBox( + width: 56, + height: 12, + borderRadius: BorderRadius.all(context.streamRadius.max), + ), + ], + ), + ), + ], + ); + } +} diff --git a/packages/stream_chat_flutter/lib/src/message_list_view/thread_separator.dart b/packages/stream_chat_flutter/lib/src/message_list_view/thread_separator.dart index 186c4f75a6..c00e1f9c1f 100644 --- a/packages/stream_chat_flutter/lib/src/message_list_view/thread_separator.dart +++ b/packages/stream_chat_flutter/lib/src/message_list_view/thread_separator.dart @@ -1,33 +1,116 @@ import 'package:flutter/material.dart'; import 'package:stream_chat_flutter/stream_chat_flutter.dart'; +import 'package:stream_core_flutter/stream_core_flutter.dart' as core; /// {@template threadSeparator} -/// A widget that separates messages in a thread. Not intended for use outside -/// of [StreamMessageWidget]. +/// A full-width banner that separates the parent message from its thread +/// replies in a [StreamMessageListView]. +/// +/// [ThreadSeparator] displays a localised reply-count label (e.g. "2 Replies") +/// inside a subtle container with top and bottom borders. +/// +/// {@tool snippet} +/// +/// Display a thread separator with default styling: +/// +/// ```dart +/// ThreadSeparator( +/// parentMessage: message, +/// ) +/// ``` +/// {@end-tool} +/// +/// See also: +/// +/// * [UnreadMessagesSeparator], which separates read from unread messages. +/// * [StreamMessageListView], which hosts this separator in a thread view. /// {@endtemplate} class ThreadSeparator extends StatelessWidget { - ///{@macro threadSeparator} + /// Creates a thread separator widget. + /// + /// The [parentMessage] is required. All other parameters are optional. const ThreadSeparator({ super.key, - this.parentMessage, + required this.parentMessage, + this.margin, + this.contentPadding, + this.textStyle, + this.backgroundColor, + this.borderColor, }); - // ignore: public_member_api_docs - final Message? parentMessage; + /// The parent message of the thread. + final Message parentMessage; + + /// Outer margin around the separator. + /// + /// When non-null, takes precedence over the theme default. + /// + /// When null (the default), uses vertical [core.StreamSpacing.xs]. + final EdgeInsetsGeometry? margin; + + /// Inner padding inside the separator. + /// + /// When non-null, takes precedence over the theme default. + /// + /// When null (the default), uses horizontal [core.StreamSpacing.md] and + /// vertical [core.StreamSpacing.xs]. + final EdgeInsetsGeometry? contentPadding; + + /// Text style for the reply count label. + /// + /// When non-null, takes precedence over the theme default. + /// + /// When null (the default), uses [core.StreamTextTheme.metadataEmphasis] + /// with [core.StreamColorScheme.textSecondary] as the text color. + final TextStyle? textStyle; + + /// Background color of the separator. + /// + /// When non-null, takes precedence over the theme default. + /// + /// When null (the default), uses + /// [core.StreamColorScheme.backgroundSurfaceSubtle]. + final Color? backgroundColor; + + /// Border color for the top and bottom edges. + /// + /// When non-null, takes precedence over the theme default. + /// + /// When null (the default), uses [core.StreamColorScheme.borderSubtle]. + final Color? borderColor; @override Widget build(BuildContext context) { - final replyCount = parentMessage!.replyCount!; - return DecoratedBox( - decoration: BoxDecoration( - gradient: StreamChatTheme.of(context).colorTheme.bgGradient, - ), - child: Padding( - padding: const EdgeInsets.all(8), - child: Text( - context.translations.threadSeparatorText(replyCount), - textAlign: TextAlign.center, - style: StreamChannelHeaderTheme.of(context).subtitleStyle, + final replyCount = parentMessage.replyCount ?? 0; + + final spacing = context.streamSpacing; + final colorScheme = context.streamColorScheme; + final textTheme = context.streamTextTheme; + + final effectiveMargin = margin ?? .symmetric(vertical: spacing.xs); + final effectiveContentPadding = contentPadding ?? .symmetric(horizontal: spacing.md, vertical: spacing.xs); + final effectiveTextStyle = textStyle ?? textTheme.metadataEmphasis.copyWith(color: colorScheme.textSecondary); + final effectiveBackgroundColor = backgroundColor ?? colorScheme.backgroundSurfaceSubtle; + final effectiveBorderColor = borderColor ?? colorScheme.borderSubtle; + + return Padding( + padding: effectiveMargin, + child: DecoratedBox( + decoration: BoxDecoration( + color: effectiveBackgroundColor, + border: Border( + top: BorderSide(color: effectiveBorderColor), + bottom: BorderSide(color: effectiveBorderColor), + ), + ), + child: Padding( + padding: effectiveContentPadding, + child: Text( + context.translations.threadSeparatorText(replyCount), + textAlign: TextAlign.center, + style: effectiveTextStyle, + ), ), ), ); diff --git a/packages/stream_chat_flutter/lib/src/message_list_view/unread_indicator_button.dart b/packages/stream_chat_flutter/lib/src/message_list_view/unread_indicator_button.dart index 6c66c118ce..13c026855f 100644 --- a/packages/stream_chat_flutter/lib/src/message_list_view/unread_indicator_button.dart +++ b/packages/stream_chat_flutter/lib/src/message_list_view/unread_indicator_button.dart @@ -1,59 +1,54 @@ import 'package:flutter/material.dart'; -import 'package:stream_chat_flutter/src/icons/stream_svg_icon.dart'; import 'package:stream_chat_flutter/src/misc/empty_widget.dart'; -import 'package:stream_chat_flutter/src/theme/stream_chat_theme.dart'; import 'package:stream_chat_flutter/src/utils/extensions.dart'; import 'package:stream_chat_flutter_core/stream_chat_flutter_core.dart'; -import 'package:svg_icon_widget/svg_icon_widget.dart'; - -/// Function signature for handling the dismiss action on the unread indicator. -typedef OnUnreadIndicatorDismissTap = Future Function(); - -/// Function signature for handling taps on the unread indicator. -/// [lastReadMessageId] is the ID of the last read message. -typedef OnUnreadIndicatorTap = Future Function(String? lastReadMessageId); - -/// Function signature for building a custom unread indicator. -/// -/// [unreadCount] is the number of unread messages. -/// [onTap] is called when the indicator is tapped. -/// [onDismissTap] is called when the dismiss action is triggered. -typedef UnreadIndicatorBuilder = Widget Function( - int unreadCount, - OnUnreadIndicatorTap onTap, - OnUnreadIndicatorDismissTap onDismissTap, -); +import 'package:stream_core_flutter/stream_core_flutter.dart' as core; /// {@template unreadIndicatorButton} /// A button that displays the number of unread messages in a channel. /// -/// This widget listens to the current user's read state and shows -/// an indicator when there are unread messages. Users can tap on the -/// indicator to navigate to the oldest unread message or dismiss it. +/// [UnreadIndicatorButton] listens to the current user's read state and shows +/// a jump-to-unread button when there are unread messages. Users can tap to +/// navigate to the oldest unread message or dismiss the indicator. +/// +/// {@tool snippet} +/// +/// Typical usage inside a message list: +/// +/// ```dart +/// UnreadIndicatorButton( +/// onJumpTap: (lastReadMessageId) async { +/// // scroll to the unread message +/// }, +/// onDismissTap: () async { +/// // mark channel as read +/// }, +/// ) +/// ``` +/// {@end-tool} +/// +/// See also: +/// +/// * [StreamMessageListView], which hosts this widget. /// {@endtemplate} class UnreadIndicatorButton extends StatelessWidget { - /// {@macro unreadIndicatorButton} + /// Creates an unread indicator button. const UnreadIndicatorButton({ super.key, - required this.onTap, + required this.onJumpTap, required this.onDismissTap, - this.unreadIndicatorBuilder, }); - /// Callback triggered when the indicator is tapped. - /// - /// This is typically used to navigate to the oldest unread message. - final OnUnreadIndicatorTap onTap; - - /// Callback triggered when the dismiss button is tapped. + /// Called when the jump-to-unread area is tapped. /// - /// This is typically used to mark all messages as read. - final OnUnreadIndicatorDismissTap onDismissTap; + /// Receives the ID of the last message the current user has read, + /// which can be used to scroll to that position. + final Future Function(String? lastReadMessageId) onJumpTap; - /// Optional builder for customizing the appearance of the unread indicator. + /// Called when the dismiss button is tapped. /// - /// If not provided, a default indicator will be built. - final UnreadIndicatorBuilder? unreadIndicatorBuilder; + /// Typically used to mark all messages as read. + final Future Function() onDismissTap; @override Widget build(BuildContext context) { @@ -67,49 +62,10 @@ class UnreadIndicatorButton extends StatelessWidget { final unreadCount = currentUserRead.unreadMessages; if (unreadCount <= 0) return const Empty(); - if (unreadIndicatorBuilder case final builder?) { - return builder(unreadCount, onTap, onDismissTap); - } - - final theme = StreamChatTheme.of(context); - final textTheme = theme.textTheme; - final colorTheme = theme.colorTheme; - - return Material( - elevation: 4, - clipBehavior: Clip.antiAlias, - color: colorTheme.textLowEmphasis, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(18), - ), - child: InkWell( - onTap: () => onTap(currentUserRead.lastReadMessageId), - child: Padding( - padding: const EdgeInsets.fromLTRB(16, 2, 8, 2), - child: Row( - children: [ - Text( - context.translations.unreadCountIndicatorLabel( - unreadCount: unreadCount, - ), - style: textTheme.body.copyWith(color: colorTheme.barsBg), - ), - const SizedBox(width: 12), - IconButton( - iconSize: 24, - icon: const SvgIcon(StreamSvgIcons.close), - padding: const EdgeInsets.all(4), - style: IconButton.styleFrom( - foregroundColor: colorTheme.barsBg, - minimumSize: const Size.square(24), - tapTargetSize: MaterialTapTargetSize.shrinkWrap, - ), - onPressed: onDismissTap, - ), - ], - ), - ), - ), + return core.StreamJumpToUnreadButton( + label: context.translations.unreadCountIndicatorLabel(unreadCount: unreadCount), + onJumpPressed: () => onJumpTap(currentUserRead.lastReadMessageId), + onDismissPressed: onDismissTap, ); }, ); diff --git a/packages/stream_chat_flutter/lib/src/message_list_view/unread_messages_separator.dart b/packages/stream_chat_flutter/lib/src/message_list_view/unread_messages_separator.dart index b32c36d8eb..54c8b67dcf 100644 --- a/packages/stream_chat_flutter/lib/src/message_list_view/unread_messages_separator.dart +++ b/packages/stream_chat_flutter/lib/src/message_list_view/unread_messages_separator.dart @@ -1,32 +1,114 @@ import 'package:flutter/material.dart'; import 'package:stream_chat_flutter/stream_chat_flutter.dart'; +import 'package:stream_core_flutter/stream_core_flutter.dart' as core; /// {@template unreadMessagesSeparator} +/// A full-width banner that marks the boundary between read and unread +/// messages in a [StreamMessageListView]. +/// +/// [UnreadMessagesSeparator] displays a localised "Unread Messages" label +/// inside a subtle container with top and bottom borders. +/// +/// {@tool snippet} +/// +/// Display an unread messages separator with default styling: +/// +/// ```dart +/// UnreadMessagesSeparator( +/// unreadCount: 5, +/// ) +/// ``` +/// {@end-tool} +/// +/// See also: +/// +/// * [ThreadSeparator], which separates the parent message from thread +/// replies. +/// * [StreamMessageListView], which hosts this separator in the chat list. /// {@endtemplate} class UnreadMessagesSeparator extends StatelessWidget { - /// {@macro unreadMessagesSeparator} + /// Creates an unread messages separator widget. + /// + /// The [unreadCount] is required. All other parameters are optional. const UnreadMessagesSeparator({ super.key, required this.unreadCount, + this.margin, + this.contentPadding, + this.textStyle, + this.backgroundColor, + this.borderColor, }); /// Number of unread messages. final int unreadCount; + /// Outer margin around the separator. + /// + /// When non-null, takes precedence over the theme default. + /// + /// When null (the default), uses vertical [core.StreamSpacing.xs]. + final EdgeInsetsGeometry? margin; + + /// Inner padding inside the separator. + /// + /// When non-null, takes precedence over the theme default. + /// + /// When null (the default), uses horizontal [core.StreamSpacing.md] and + /// vertical [core.StreamSpacing.xs]. + final EdgeInsetsGeometry? contentPadding; + + /// Text style for the unread label. + /// + /// When non-null, takes precedence over the theme default. + /// + /// When null (the default), uses [core.StreamTextTheme.metadataEmphasis] + /// with [core.StreamColorScheme.textSecondary] as the text color. + final TextStyle? textStyle; + + /// Background color of the separator. + /// + /// When non-null, takes precedence over the theme default. + /// + /// When null (the default), uses + /// [core.StreamColorScheme.backgroundSurfaceSubtle]. + final Color? backgroundColor; + + /// Border color for the top and bottom edges. + /// + /// When non-null, takes precedence over the theme default. + /// + /// When null (the default), uses [core.StreamColorScheme.borderSubtle]. + final Color? borderColor; + @override Widget build(BuildContext context) { + final spacing = context.streamSpacing; + final colorScheme = context.streamColorScheme; + final textTheme = context.streamTextTheme; + + final effectiveMargin = margin ?? .symmetric(vertical: spacing.xs); + final effectiveContentPadding = contentPadding ?? .symmetric(horizontal: spacing.md, vertical: spacing.xs); + final effectiveTextStyle = textStyle ?? textTheme.metadataEmphasis.copyWith(color: colorScheme.textSecondary); + final effectiveBackgroundColor = backgroundColor ?? colorScheme.backgroundSurfaceSubtle; + final effectiveBorderColor = borderColor ?? colorScheme.borderSubtle; + return Padding( - padding: const EdgeInsets.symmetric(vertical: 8), + padding: effectiveMargin, child: DecoratedBox( decoration: BoxDecoration( - gradient: StreamChatTheme.of(context).colorTheme.bgGradient, + color: effectiveBackgroundColor, + border: Border( + top: BorderSide(color: effectiveBorderColor), + bottom: BorderSide(color: effectiveBorderColor), + ), ), child: Padding( - padding: const EdgeInsets.all(8), + padding: effectiveContentPadding, child: Text( context.translations.unreadMessagesSeparatorText(), textAlign: TextAlign.center, - style: StreamChannelHeaderTheme.of(context).subtitleStyle, + style: effectiveTextStyle, ), ), ), diff --git a/packages/stream_chat_flutter/lib/src/message_modal/message_action_confirmation_modal.dart b/packages/stream_chat_flutter/lib/src/message_modal/message_action_confirmation_modal.dart new file mode 100644 index 0000000000..04b71057aa --- /dev/null +++ b/packages/stream_chat_flutter/lib/src/message_modal/message_action_confirmation_modal.dart @@ -0,0 +1,112 @@ +import 'package:flutter/material.dart'; +import 'package:stream_chat_flutter/src/utils/extensions.dart'; +import 'package:stream_core_flutter/stream_core_flutter.dart'; + +/// {@template streamMessageActionConfirmationModal} +/// A confirmation modal dialog for message actions in Stream Chat. +/// +/// This widget creates a platform-adaptive confirmation dialog that can be used +/// when a user attempts to perform an action on a message that requires +/// confirmation (like delete, flag, etc). +/// +/// The dialog presents two options: cancel and confirm, with customizable text +/// for both actions. The confirm action can be styled as destructive for +/// actions like deletion. +/// +/// Example usage: +/// +/// ```dart +/// showDialog( +/// context: context, +/// builder: (context) => StreamMessageActionConfirmationModal( +/// title: Text('Delete Message'), +/// content: Text('Are you sure you want to delete this message?'), +/// confirmActionTitle: Text('Delete'), +/// isDestructiveAction: true, +/// ), +/// ).then((confirmed) { +/// if (confirmed == true) { +/// // Perform the action +/// } +/// }); +/// ``` +/// {@endtemplate} +class StreamMessageActionConfirmationModal extends StatelessWidget { + /// Creates a message action confirmation modal. + /// + /// [cancelActionTitle] defaults to a [Text] with the localized + /// [Translations.cancelLabel]. + /// [confirmActionTitle] defaults to a [Text] with the localized + /// [Translations.confirmLabel]. + /// Set [isDestructiveAction] to true for actions like deletion that should + /// be highlighted as destructive. + const StreamMessageActionConfirmationModal({ + super.key, + this.title, + this.content, + this.cancelActionTitle, + this.confirmActionTitle, + this.isDestructiveAction = false, + }); + + /// The title of the dialog. + /// + /// Typically a [Text] widget. + final Widget? title; + + /// The content of the dialog, displayed below the title. + /// + /// Typically a [Text] widget that provides more details about the action. + final Widget? content; + + /// The widget to display as the cancel action button. + /// + /// When null, falls back to a [Text] with the localized + /// [Translations.cancelLabel]. When pressed, this action dismisses the + /// dialog and returns false. + final Widget? cancelActionTitle; + + /// The widget to display as the confirm action button. + /// + /// When null, falls back to a [Text] with the localized + /// [Translations.confirmLabel]. When pressed, this action dismisses the + /// dialog and returns true. + final Widget? confirmActionTitle; + + /// Whether the confirm action is destructive (like deletion). + /// + /// When true, the confirm action will be styled accordingly + /// (e.g., in red on iOS/macOS). + final bool isDestructiveAction; + + @override + Widget build(BuildContext context) { + final colorScheme = context.streamColorScheme; + + final translations = context.translations; + + final actions = [ + StreamButton( + type: .ghost, + style: .secondary, + size: .small, + onPressed: () => Navigator.of(context).maybePop(false), + child: cancelActionTitle ?? Text(translations.cancelLabel), + ), + StreamButton( + type: .solid, + style: isDestructiveAction ? .destructive : .primary, + size: .small, + onPressed: () => Navigator.of(context).maybePop(true), + child: confirmActionTitle ?? Text(translations.confirmLabel), + ), + ]; + + return AlertDialog( + title: title, + content: content, + actions: actions, + backgroundColor: colorScheme.backgroundElevation1, + ); + } +} diff --git a/packages/stream_chat_flutter/lib/src/message_modal/message_actions_modal.dart b/packages/stream_chat_flutter/lib/src/message_modal/message_actions_modal.dart new file mode 100644 index 0000000000..680d98e86a --- /dev/null +++ b/packages/stream_chat_flutter/lib/src/message_modal/message_actions_modal.dart @@ -0,0 +1,95 @@ +import 'package:flutter/material.dart'; +import 'package:stream_chat_flutter/stream_chat_flutter.dart'; + +/// {@template streamMessageActionsModal} +/// A modal that displays a list of actions that can be performed on a message. +/// +/// This widget presents a customizable menu of actions for a message, such as +/// reply, edit, delete, etc., along with an optional reaction picker. +/// +/// Typically used when a user long-presses on a message to see available +/// actions. +/// {@endtemplate} +class StreamMessageActionsModal extends StatelessWidget { + /// {@macro streamMessageActionsModal} + const StreamMessageActionsModal({ + super.key, + required this.message, + required this.messageActions, + required this.messageWidget, + this.alignment, + this.showReactionPicker = false, + this.leadingInset = 0, + }); + + /// The message object that actions will be performed on. + /// + /// This is the message the user selected to see available actions. + final Message message; + + /// List of widgets that will be displayed as actions in the modal. + /// + /// Typically built by [StreamMessageActionsBuilder] and optionally modified + /// by [StreamMessageItem.actionsBuilder]. Each item is rendered directly + /// as a child of [StreamContextMenu]. + final List messageActions; + + /// The widget representing the message being acted upon. + /// + /// This is typically displayed in the content section of the modal as a + /// reference for the user. + final Widget messageWidget; + + /// Alignment of the modal content. + /// + /// When null (the default), falls back to + /// [StreamMessagePlacement.alignmentDirectionalOf]. + final AlignmentGeometry? alignment; + + /// Controls whether to show the reaction picker at the top of the modal. + /// + /// When `true`, users can add reactions directly from the modal. + /// When `false`, the reaction picker is hidden. + /// + /// Defaults to `false`. + final bool showReactionPicker; + + /// Horizontal offset applied to the header (reaction picker) and footer (actions menu) + /// to align them with the message bubble content rather than the full message row. + /// + /// Defaults to `0` (no offset). + final double leadingInset; + + @override + Widget build(BuildContext context) { + final spacing = context.streamSpacing; + final effectiveAlignment = alignment ?? StreamMessageLayout.alignmentDirectionalOf(context); + + void onReactionPicked(Reaction reaction) { + final action = SelectReaction(message: message, reaction: reaction); + return Navigator.pop(context, action); + } + + final insetPadding = EdgeInsetsDirectional.only(start: leadingInset); + + return StreamMessageDialog( + spacing: spacing.xs, + alignment: effectiveAlignment, + headerBuilder: switch (showReactionPicker) { + true => (context) => Padding( + padding: insetPadding, + child: StreamMessageReactionPicker( + message: message, + onReactionPicked: onReactionPicked, + ), + ), + false => null, + }, + contentBuilder: (context) => IgnorePointer(child: messageWidget), + footerBuilder: (context) => Padding( + padding: insetPadding, + child: StreamContextMenu(children: messageActions), + ), + ); + } +} diff --git a/packages/stream_chat_flutter/lib/src/message_modal/message_modal.dart b/packages/stream_chat_flutter/lib/src/message_modal/message_modal.dart new file mode 100644 index 0000000000..55d3de761a --- /dev/null +++ b/packages/stream_chat_flutter/lib/src/message_modal/message_modal.dart @@ -0,0 +1,125 @@ +import 'package:flutter/material.dart'; +import 'package:stream_chat_flutter/src/utils/extensions.dart'; + +/// {@template streamMessageDialog} +/// A customizable modal dialog for displaying message-related content. +/// +/// This widget provides a consistent container for message actions and other +/// message-related dialog content. It handles layout, animation, and keyboard +/// adjustments automatically. +/// +/// The dialog is laid out as a [Column] with three optional sections: +/// header, content, and footer. It adjusts its position when the keyboard +/// appears. +/// {@endtemplate} +class StreamMessageDialog extends StatelessWidget { + /// Creates a Stream message dialog. + /// + /// The [contentBuilder] parameter is required to build the main content + /// of the dialog. The [headerBuilder] and [footerBuilder] are optional and + /// can be used to add sections above and below the main content. + const StreamMessageDialog({ + super.key, + this.spacing = 8.0, + this.headerBuilder, + required this.contentBuilder, + this.footerBuilder, + this.useSafeArea = true, + this.insetAnimationDuration = const Duration(milliseconds: 100), + this.insetAnimationCurve = Curves.decelerate, + this.insetPadding = const EdgeInsets.all(16), + this.alignment = Alignment.center, + }); + + /// Vertical spacing between sections. + final double spacing; + + /// Optional builder for the header section of the dialog. + final WidgetBuilder? headerBuilder; + + /// Required builder for the main content of the dialog. + final WidgetBuilder contentBuilder; + + /// Optional builder for the footer section of the dialog. + final WidgetBuilder? footerBuilder; + + /// Whether to use a [SafeArea] to avoid system UI intrusions. + /// + /// Defaults to `true`. + final bool useSafeArea; + + /// The duration of the animation to show when the system keyboard intrudes + /// into the space that the dialog is placed in. + /// + /// Defaults to 100 milliseconds. + final Duration insetAnimationDuration; + + /// The curve to use for the animation shown when the system keyboard intrudes + /// into the space that the dialog is placed in. + /// + /// Defaults to [Curves.decelerate]. + final Curve insetAnimationCurve; + + /// The amount of padding added to [MediaQueryData.viewInsets] on the outside + /// of the dialog. This defines the minimum space between the screen's edges + /// and the dialog. + /// + /// Defaults to `EdgeInsets.zero`. + final EdgeInsets insetPadding; + + /// How to align the [StreamMessageDialog]. + /// + /// Defaults to [Alignment.center]. + final AlignmentGeometry alignment; + + @override + Widget build(BuildContext context) { + final effectivePadding = MediaQuery.viewInsetsOf(context) + insetPadding; + + final dialogChild = Align( + alignment: alignment, + child: ConstrainedBox( + constraints: const BoxConstraints(minWidth: 280), + child: Material( + type: MaterialType.transparency, + child: Column( + spacing: spacing, + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: alignment.toColumnCrossAxisAlignment(), + children: [ + if (headerBuilder case final builder?) builder(context), + contentBuilder(context), + if (footerBuilder case final builder?) Flexible(child: builder(context)), + ], + ), + ), + ), + ); + + Widget dialog = AnimatedPadding( + padding: effectivePadding, + duration: insetAnimationDuration, + curve: insetAnimationCurve, + child: MediaQuery.removeViewInsets( + removeLeft: true, + removeTop: true, + removeRight: true, + removeBottom: true, + context: context, + child: dialogChild, + ), + ); + + if (useSafeArea) { + dialog = Align( + alignment: alignment, + child: SingleChildScrollView( + hitTestBehavior: HitTestBehavior.translucent, + child: SafeArea(child: dialog), + ), + ); + } + + return dialog; + } +} diff --git a/packages/stream_chat_flutter/lib/src/message_modal/moderated_message_actions_modal.dart b/packages/stream_chat_flutter/lib/src/message_modal/moderated_message_actions_modal.dart new file mode 100644 index 0000000000..a81fa15acc --- /dev/null +++ b/packages/stream_chat_flutter/lib/src/message_modal/moderated_message_actions_modal.dart @@ -0,0 +1,75 @@ +import 'package:flutter/material.dart'; +import 'package:stream_chat_flutter/src/misc/adaptive_dialog_action.dart'; +import 'package:stream_chat_flutter/src/theme/stream_chat_theme.dart'; +import 'package:stream_chat_flutter/src/utils/extensions.dart'; +import 'package:stream_chat_flutter_core/stream_chat_flutter_core.dart'; +import 'package:stream_core_flutter/stream_core_flutter.dart'; + +/// {@template moderatedMessageActionsModal} +/// A modal that is shown when a message is flagged by moderation policies. +/// +/// This modal allows users to: +/// - Send the message anyway, overriding the moderation warning +/// - Edit the message to comply with community guidelines +/// - Delete the message +/// +/// The modal provides clear guidance to users about the moderation issue +/// and options to address it. +/// {@endtemplate} +class ModeratedMessageActionsModal extends StatelessWidget { + /// {@macro moderatedMessageActionsModal} + const ModeratedMessageActionsModal({ + super.key, + required this.message, + required this.messageActions, + }); + + /// The message object that actions will be performed on. + /// + /// This is the message the user selected to see available actions. + final Message message; + + /// List of custom actions that will be displayed in the modal. + /// + /// Each action is represented by a [StreamContextMenuAction] object. + final List messageActions; + + @override + Widget build(BuildContext context) { + final theme = StreamChatTheme.of(context); + final textTheme = theme.textTheme; + final colorTheme = theme.colorTheme; + + final actions = [ + ...messageActions.map( + (action) => AdaptiveDialogAction( + onPressed: () => Navigator.pop(context, action.props.value), + isDestructiveAction: action.props.isDestructive, + child: action.props.label, + ), + ), + ]; + + return AlertDialog.adaptive( + clipBehavior: Clip.antiAlias, + backgroundColor: colorTheme.barsBg, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), + icon: Icon(context.streamIcons.flag), + iconColor: colorTheme.accentPrimary, + title: Text(context.translations.moderationReviewModalTitle), + titleTextStyle: textTheme.headline.copyWith( + color: colorTheme.textHighEmphasis, + ), + content: Text( + context.translations.moderationReviewModalDescription, + textAlign: TextAlign.center, + ), + contentTextStyle: textTheme.body.copyWith( + color: colorTheme.textLowEmphasis, + ), + actions: actions, + actionsAlignment: MainAxisAlignment.center, + actionsOverflowAlignment: OverflowBarAlignment.center, + ); + } +} diff --git a/packages/stream_chat_flutter/lib/src/message_widget/bottom_row.dart b/packages/stream_chat_flutter/lib/src/message_widget/bottom_row.dart deleted file mode 100644 index 0b408aa750..0000000000 --- a/packages/stream_chat_flutter/lib/src/message_widget/bottom_row.dart +++ /dev/null @@ -1,302 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:stream_chat_flutter/src/message_widget/sending_indicator_builder.dart'; -import 'package:stream_chat_flutter/src/message_widget/thread_painter.dart'; -import 'package:stream_chat_flutter/src/message_widget/thread_participants.dart'; -import 'package:stream_chat_flutter/src/message_widget/username.dart'; -import 'package:stream_chat_flutter/src/misc/timestamp.dart'; -import 'package:stream_chat_flutter/stream_chat_flutter.dart'; - -/// {@template bottomRow} -/// The bottom row of a [StreamMessageWidget]. -/// -/// Used in [MessageWidgetContent]. Should not be used elsewhere. -/// {@endtemplate} -class BottomRow extends StatelessWidget { - /// {@macro bottomRow} - const BottomRow({ - super.key, - required this.isDeleted, - required this.message, - required this.showThreadReplyIndicator, - required this.showInChannel, - required this.showTimeStamp, - required this.showUsername, - required this.showEditedLabel, - required this.reverse, - required this.showSendingIndicator, - required this.hasUrlAttachments, - required this.isGiphy, - required this.isOnlyEmoji, - required this.messageTheme, - required this.streamChatTheme, - required this.hasNonUrlAttachments, - required this.streamChat, - this.deletedBottomRowBuilder, - this.onThreadTap, - this.usernameBuilder, - this.sendingIndicatorBuilder, - }); - - /// {@macro messageIsDeleted} - final bool isDeleted; - - /// {@macro deletedBottomRowBuilder} - final Widget Function(BuildContext, Message)? deletedBottomRowBuilder; - - /// {@macro message} - final Message message; - - /// {@macro showThreadReplyIndicator} - final bool showThreadReplyIndicator; - - /// {@macro showInChannelIndicator} - final bool showInChannel; - - /// {@macro showTimestamp} - final bool showTimeStamp; - - /// {@macro showUsername} - final bool showUsername; - - /// {@macro showEdited} - final bool showEditedLabel; - - /// {@macro reverse} - final bool reverse; - - /// {@macro showSendingIndicator} - final bool showSendingIndicator; - - /// {@macro hasUrlAttachments} - final bool hasUrlAttachments; - - /// {@macro isGiphy} - final bool isGiphy; - - /// {@macro isOnlyEmoji} - final bool isOnlyEmoji; - - /// {@macro hasNonUrlAttachments} - final bool hasNonUrlAttachments; - - /// {@macro messageTheme} - final StreamMessageThemeData messageTheme; - - /// {@macro onThreadTap} - final void Function(Message)? onThreadTap; - - /// {@macro streamChatThemeData} - final StreamChatThemeData streamChatTheme; - - /// {@macro streamChat} - final StreamChatState streamChat; - - /// {@macro usernameBuilder} - final Widget Function(BuildContext, Message)? usernameBuilder; - - /// {@macro sendingIndicatorBuilder} - final Widget Function(BuildContext, Message)? sendingIndicatorBuilder; - - /// {@template copyWith} - /// Creates a copy of [BottomRow] with specified attributes - /// overridden. - /// {@endtemplate} - BottomRow copyWith({ - Key? key, - bool? isDeleted, - Message? message, - bool? showThreadReplyIndicator, - bool? showInChannel, - bool? showTimeStamp, - bool? showUsername, - bool? showEditedLabel, - bool? reverse, - bool? showSendingIndicator, - bool? hasUrlAttachments, - bool? isGiphy, - bool? isOnlyEmoji, - StreamMessageThemeData? messageTheme, - StreamChatThemeData? streamChatTheme, - bool? hasNonUrlAttachments, - StreamChatState? streamChat, - Widget Function(BuildContext, Message)? deletedBottomRowBuilder, - void Function(Message)? onThreadTap, - Widget Function(BuildContext, Message)? usernameBuilder, - Widget Function(BuildContext, Message)? sendingIndicatorBuilder, - }) => - BottomRow( - key: key ?? this.key, - isDeleted: isDeleted ?? this.isDeleted, - message: message ?? this.message, - showThreadReplyIndicator: - showThreadReplyIndicator ?? this.showThreadReplyIndicator, - showInChannel: showInChannel ?? this.showInChannel, - showTimeStamp: showTimeStamp ?? this.showTimeStamp, - showUsername: showUsername ?? this.showUsername, - showEditedLabel: showEditedLabel ?? this.showEditedLabel, - reverse: reverse ?? this.reverse, - showSendingIndicator: showSendingIndicator ?? this.showSendingIndicator, - hasUrlAttachments: hasUrlAttachments ?? this.hasUrlAttachments, - isGiphy: isGiphy ?? this.isGiphy, - isOnlyEmoji: isOnlyEmoji ?? this.isOnlyEmoji, - messageTheme: messageTheme ?? this.messageTheme, - streamChatTheme: streamChatTheme ?? this.streamChatTheme, - hasNonUrlAttachments: hasNonUrlAttachments ?? this.hasNonUrlAttachments, - streamChat: streamChat ?? this.streamChat, - deletedBottomRowBuilder: - deletedBottomRowBuilder ?? this.deletedBottomRowBuilder, - onThreadTap: onThreadTap ?? this.onThreadTap, - usernameBuilder: usernameBuilder ?? this.usernameBuilder, - sendingIndicatorBuilder: - sendingIndicatorBuilder ?? this.sendingIndicatorBuilder, - ); - - @override - Widget build(BuildContext context) { - if (isDeleted) { - final deletedBottomRowBuilder = this.deletedBottomRowBuilder; - if (deletedBottomRowBuilder != null) { - return deletedBottomRowBuilder(context, message); - } - } - - final threadParticipants = message.threadParticipants?.take(2); - final showThreadParticipants = threadParticipants?.isNotEmpty == true; - final replyCount = message.replyCount; - final isEdited = message.messageTextUpdatedAt != null; - - var msg = context.translations.threadReplyLabel; - if (showThreadReplyIndicator && replyCount! > 1) { - msg = context.translations.threadReplyCountText(replyCount); - } - - Future _onThreadTap() async { - try { - var message = this.message; - if (showInChannel) { - final channel = StreamChannel.of(context); - message = await channel.getMessage(message.parentId!); - } - return onThreadTap?.call(message); - } catch (e, stk) { - debugPrint('Error while fetching message: $e, $stk'); - } - } - - const usernameKey = Key('username'); - - final children = [ - if (showSendingIndicator) - switch (sendingIndicatorBuilder) { - final builder? => builder(context, message), - _ => SendingIndicatorBuilder( - messageTheme: messageTheme, - message: message, - hasNonUrlAttachments: hasNonUrlAttachments, - streamChat: streamChat, - streamChatTheme: streamChatTheme, - ), - }, - if (showUsername) - switch (usernameBuilder) { - final builder? => builder(context, message), - _ => Username( - key: usernameKey, - message: message, - messageTheme: messageTheme, - ), - }, - if (showEditedLabel && isEdited) - Text( - context.translations.editedMessageLabel, - style: messageTheme.createdAtStyle, - ), - if (showTimeStamp) - StreamTimestamp( - date: message.createdAt.toLocal(), - style: messageTheme.createdAtStyle, - formatter: (context, date) { - if (messageTheme.createdAtFormatter case final formatter?) { - return formatter.call(context, date); - } - - return Jiffy.parseFromDateTime(date).jm; - }, - ), - ]; - - final showThreadTail = - (showThreadReplyIndicator || showInChannel) && !isOnlyEmoji; - - final threadIndicatorWidgets = [ - if (showThreadTail) - // Added builder to use the nearest context to get the right - // textScaleFactor value. - Builder( - builder: (context) { - return Padding( - padding: EdgeInsets.only( - bottom: context.textScaleFactor * - ((messageTheme.repliesStyle?.fontSize ?? 1) / 2), - ), - child: CustomPaint( - size: const Size(16, 32) * context.textScaleFactor, - painter: ThreadReplyPainter( - context: context, - color: messageTheme.messageBorderColor, - reverse: reverse, - ), - ), - ); - }, - ), - if (showInChannel || showThreadReplyIndicator) ...[ - if (showThreadParticipants) - SizedBox.fromSize( - size: Size((threadParticipants!.length * 8.0) + 8, 16), - child: ThreadParticipants( - threadParticipants: threadParticipants, - streamChatTheme: streamChatTheme, - ), - ), - MouseRegion( - cursor: SystemMouseCursors.click, - child: GestureDetector( - onTap: _onThreadTap, - child: Text(msg, style: messageTheme.repliesStyle), - ), - ), - ], - ]; - - if (reverse) { - children.addAll(threadIndicatorWidgets.reversed); - } else { - children.insertAll(0, threadIndicatorWidgets); - } - - return Text.rich( - TextSpan( - children: [ - ...children.insertBetween(const SizedBox(width: 8)).map((child) { - final mediaQueryData = MediaQuery.of(context); - return WidgetSpan( - child: MediaQuery( - // Hardcoding the textScaleFactor to 1 to avoid the multiple - // resizing of the text. This is needed because the - // textScaleFactor is already applied to the textSpan. - // - // issue: https://github.com/GetStream/stream-chat-flutter/issues/1250 - // ignore: deprecated_member_use - data: mediaQueryData.copyWith(textScaleFactor: 1), - child: child, - ), - ); - }), - ], - ), - maxLines: 1, - textAlign: reverse ? TextAlign.right : TextAlign.left, - ); - } -} diff --git a/packages/stream_chat_flutter/lib/src/message_widget/components/stream_message_content.dart b/packages/stream_chat_flutter/lib/src/message_widget/components/stream_message_content.dart new file mode 100644 index 0000000000..8d52bcf7b4 --- /dev/null +++ b/packages/stream_chat_flutter/lib/src/message_widget/components/stream_message_content.dart @@ -0,0 +1,201 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_markdown/flutter_markdown.dart'; +import 'package:stream_chat_flutter/src/attachment/builder/attachment_widget_builder.dart'; +import 'package:stream_chat_flutter/src/message_widget/components/stream_message_deleted.dart'; +import 'package:stream_chat_flutter/src/message_widget/components/stream_message_reactions.dart'; +import 'package:stream_chat_flutter/src/message_widget/components/stream_message_text.dart'; +import 'package:stream_chat_flutter/src/message_widget/stream_message_attachments.dart'; +import 'package:stream_chat_flutter/src/message_widget/stream_quoted_message.dart'; +import 'package:stream_chat_flutter_core/stream_chat_flutter_core.dart'; +import 'package:stream_core_flutter/stream_core_flutter.dart' as core; + +/// Composes the main message content including the bubble, attachments, text, +/// and reactions. +/// +/// For deleted messages a [StreamMessageDeleted] placeholder is shown. +/// Otherwise the content displays attachments, message text, and reactions. +/// +/// The [header], [footer], and [replies] slots are passed in from +/// [DefaultStreamMessageItem] and rendered in the appropriate positions via +/// the core [core.StreamMessageContent] layout. +/// +/// When the message consists of three or fewer emoji-only characters, the +/// bubble background is hidden so the emoji appear at a larger visual size. +/// +/// See also: +/// +/// * [StreamMessageReactions], which renders reactions around the bubble. +/// * [StreamMessageText], which renders the markdown message text. +/// * [DefaultStreamMessageItem], which hosts this widget. +class StreamMessageContent extends StatefulWidget { + /// Creates a message content widget for the given [message]. + const StreamMessageContent({ + super.key, + required this.message, + this.header, + this.errorBadge, + this.footer, + this.replies, + this.attachmentBuilders, + this.onLinkTap, + this.onMentionTap, + this.onReactionsTap, + this.onQuotedMessageTap, + this.reactionSorting, + }); + + /// The message to display. + final Message message; + + /// Optional header widget displayed above the message content column. + /// + /// Typically a [StreamMessageHeader] containing pinned, reminder, + /// or show-in-channel annotations. + final Widget? header; + + /// Optional error badge widget overlaid on the message bubble. + /// + /// When non-null, the badge is positioned at the top-end corner of the + /// bubble using a [Stack] with [PositionedDirectional]. + final Widget? errorBadge; + + /// Optional footer widget displayed below the message content column. + /// + /// Typically a [StreamMessageFooter] containing the author name, timestamp, + /// and sending status. + final Widget? footer; + + /// Optional replies indicator widget displayed below the bubble. + /// + /// Typically a [core.StreamMessageReplies] showing reply count and + /// participant avatars. + final Widget? replies; + + /// Custom attachment builders for rendering message attachments. + /// + /// When non-null, these builders are passed to [StreamMessageAttachments] + /// and take priority over the default builders. + final List? attachmentBuilders; + + /// Called when a link is tapped in the rendered message text. + /// + /// If null, tapping a link has no effect. + final MarkdownTapLinkCallback? onLinkTap; + + /// Called when a `@mention` is tapped in the rendered message text. + /// + /// If null, tapping a mention has no effect. + final core.MarkdownTapMentionCallback? onMentionTap; + + /// Called when the reactions area is tapped. + /// + /// If null, tapping reactions has no effect. + final VoidCallback? onReactionsTap; + + /// Called when the quoted message is tapped. + /// + /// If null, tapping the quoted message has no effect. + final void Function(Message quotedMessage)? onQuotedMessageTap; + + /// Controls how reaction groups are sorted when displayed. + /// + /// Passed through to [StreamMessageReactions.sorting]. + final Comparator? reactionSorting; + + @override + State createState() => _StreamMessageContentState(); +} + +class _StreamMessageContentState extends State { + // Tracks the rendered width of the attachments to constrain the bubble. + double? widthLimit; + late final attachmentsKey = GlobalKey(debugLabel: 'StreamMessageAttachments'); + + // Measures the attachment width after layout and constrains the bubble. + void _updateWidthLimit() { + final attachmentContext = attachmentsKey.currentContext; + final renderBox = attachmentContext?.findRenderObject() as RenderBox?; + final attachmentsWidth = renderBox?.size.width; + + if (attachmentsWidth == null || attachmentsWidth == 0) return; + if (mounted) setState(() => widthLimit = attachmentsWidth); + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + WidgetsBinding.instance.addPostFrameCallback((_) => _updateWidthLimit()); + } + + @override + Widget build(BuildContext context) { + final spacing = context.streamSpacing; + final crossAxisAlignment = core.StreamMessageLayout.crossAxisAlignmentOf(context); + + if (widget.message.isDeleted) return const StreamMessageDeleted(); + + return core.StreamMessageContent( + header: widget.header, + footer: widget.footer, + child: core.StreamColumn( + mainAxisSize: .min, + crossAxisAlignment: crossAxisAlignment, + children: [ + StreamMessageReactions( + message: widget.message, + sorting: widget.reactionSorting, + onPressed: widget.onReactionsTap, + child: Builder( + builder: (context) { + final bubbleContent = ConstrainedBox( + constraints: const BoxConstraints().copyWith(maxWidth: widthLimit), + child: core.StreamColumn( + mainAxisSize: .min, + spacing: spacing.xs, + crossAxisAlignment: .start, + children: [ + if (widget.message.quotedMessage case final quotedMessage?) + StreamQuotedMessage( + quotedMessage: quotedMessage, + onTap: switch (widget.onQuotedMessageTap) { + final onTap? => () => onTap(quotedMessage), + _ => null, + }, + ), + StreamMessageAttachments( + key: attachmentsKey, + message: widget.message, + attachmentBuilders: widget.attachmentBuilders, + ), + if (widget.message.text case final text? when text.isNotEmpty) + StreamMessageText( + message: widget.message, + onLinkTap: widget.onLinkTap, + onMentionTap: widget.onMentionTap, + ), + ], + ), + ); + + final bubble = core.StreamMessageBubble(child: bubbleContent); + + if (widget.errorBadge case final errorBadge?) { + return Stack( + clipBehavior: .none, + children: [ + bubble, + PositionedDirectional(top: 8, end: -12, child: errorBadge), + ], + ); + } + + return bubble; + }, + ), + ), + if (widget.replies case final replies?) replies, + ], + ), + ); + } +} diff --git a/packages/stream_chat_flutter/lib/src/message_widget/components/stream_message_deleted.dart b/packages/stream_chat_flutter/lib/src/message_widget/components/stream_message_deleted.dart new file mode 100644 index 0000000000..41224f9693 --- /dev/null +++ b/packages/stream_chat_flutter/lib/src/message_widget/components/stream_message_deleted.dart @@ -0,0 +1,37 @@ +import 'package:flutter/material.dart'; +import 'package:stream_chat_flutter/src/utils/extensions.dart'; +import 'package:stream_core_flutter/stream_core_flutter.dart' as core; + +/// Displays a "Message deleted" indicator inside a message bubble. +/// +/// Shown in place of the normal message content when [Message.isDeleted] +/// is true. +/// +/// See also: +/// +/// * [StreamMessageScaffold], which shows this widget for deleted messages. +class StreamMessageDeleted extends StatelessWidget { + /// Creates a deleted message widget. + const StreamMessageDeleted({super.key}); + + @override + Widget build(BuildContext context) { + final icons = context.streamIcons; + final spacing = context.streamSpacing; + + return core.StreamMessageBubble( + padding: .symmetric( + horizontal: spacing.sm, + vertical: spacing.xs, + ), + child: Row( + spacing: spacing.xxs, + mainAxisSize: .min, + children: [ + Icon(icons.noSign, size: 16), + core.StreamMessageText(padding: .zero, context.translations.messageDeletedLabel), + ], + ), + ); + } +} diff --git a/packages/stream_chat_flutter/lib/src/message_widget/components/stream_message_footer.dart b/packages/stream_chat_flutter/lib/src/message_widget/components/stream_message_footer.dart new file mode 100644 index 0000000000..1e6746dd2a --- /dev/null +++ b/packages/stream_chat_flutter/lib/src/message_widget/components/stream_message_footer.dart @@ -0,0 +1,68 @@ +import 'package:flutter/material.dart'; +import 'package:jiffy/jiffy.dart'; +import 'package:stream_chat_flutter/src/message_widget/components/stream_message_sending_status.dart'; +import 'package:stream_chat_flutter/src/misc/timestamp.dart'; +import 'package:stream_chat_flutter/src/stream_chat.dart'; +import 'package:stream_chat_flutter/src/utils/extensions.dart'; +import 'package:stream_chat_flutter_core/stream_chat_flutter_core.dart'; +import 'package:stream_core_flutter/stream_core_flutter.dart' as core; + +/// Displays the row below the message bubble containing the author name, +/// sending status, creation timestamp, and an edited indicator. +/// +/// The footer can show up to four pieces depending on the message: +/// +/// * **Username** — for messages from other users. +/// * **Sending status** — for the current user's own messages. +/// * **Timestamp** — always shown, formatted as a short time string. +/// * **Edited label** — when the message text has been updated. +/// +/// See also: +/// +/// * [StreamMessageHeader], the symmetric slot above the message bubble. +/// * [StreamMessageSendingStatus], which renders the sent/delivered/read +/// indicator. +/// * [DefaultStreamMessageItem], which controls footer visibility. +class StreamMessageFooter extends StatelessWidget { + /// Creates a message footer for the given [message]. + const StreamMessageFooter({super.key, required this.message}); + + /// The message whose metadata to display. + final Message message; + + @override + Widget build(BuildContext context) { + final currentUser = StreamChat.of(context).currentUser; + final channelKind = core.StreamMessageLayout.channelKindOf(context); + + Widget? usernameWidget; + if (message.user case final user? when channelKind == .group && user.id != currentUser?.id) { + usernameWidget = Text(user.name, maxLines: 1, overflow: .ellipsis); + } + + Widget? statusWidget; + if (message.user case final user? when user.id == currentUser?.id) { + statusWidget = StreamMessageSendingStatus(message: message); + } + + final Widget timestampWidget; + if (message.createdAt case final createdAt) { + timestampWidget = StreamTimestamp( + date: createdAt.toLocal(), + formatter: (context, date) => Jiffy.parseFromDateTime(date).jm, + ); + } + + Widget? editedWidget; + if (message.messageTextUpdatedAt != null) { + editedWidget = Text(context.translations.editedMessageLabel); + } + + return core.StreamMessageMetadata( + username: usernameWidget, + status: statusWidget, + timestamp: timestampWidget, + edited: editedWidget, + ); + } +} diff --git a/packages/stream_chat_flutter/lib/src/message_widget/components/stream_message_header.dart b/packages/stream_chat_flutter/lib/src/message_widget/components/stream_message_header.dart new file mode 100644 index 0000000000..1f0b7d8712 --- /dev/null +++ b/packages/stream_chat_flutter/lib/src/message_widget/components/stream_message_header.dart @@ -0,0 +1,110 @@ +import 'package:flutter/material.dart'; +import 'package:jiffy/jiffy.dart'; +import 'package:stream_chat_flutter/src/stream_chat.dart'; +import 'package:stream_chat_flutter/src/utils/extensions.dart'; +import 'package:stream_chat_flutter_core/stream_chat_flutter_core.dart'; +import 'package:stream_core_flutter/stream_core_flutter.dart' as core; + +/// Displays contextual annotations above the message bubble for the given +/// [message]. +/// +/// Annotations are shown in the following order when applicable: +/// +/// 1. **Saved for later** — when a reminder exists without a scheduled time. +/// 2. **Pinned** — when [Message.pinned] is true, showing who pinned it. +/// 3. **Show in channel / Replied to thread** — when [Message.showInChannel] +/// is true. The label adapts based on whether the message list is a +/// channel or thread view, and includes a tappable "View" link that +/// invokes [onViewChannelTap]. +/// 4. **Reminder** — when a reminder exists with a scheduled time. +/// +/// Returns `null` when no annotations apply, allowing [StreamColumn] to +/// collapse the widget and skip spacing automatically. +/// +/// See also: +/// +/// * [StreamMessageFooter], the symmetric slot below the message bubble. +/// * [DefaultStreamMessageItem], which controls header visibility. +class StreamMessageHeader extends core.NullableStatelessWidget { + /// Creates a message header for the given [message]. + const StreamMessageHeader({ + super.key, + required this.message, + this.onViewChannelTap, + }); + + /// The message whose annotations to display. + final Message message; + + /// Called when the "View" link in the show-in-channel annotation is tapped. + final VoidCallback? onViewChannelTap; + + @override + Widget? nullableBuild(BuildContext context) { + final translations = context.translations; + final icons = context.streamIcons; + final colorScheme = context.streamColorScheme; + final crossAxisAlignment = core.StreamMessageLayout.crossAxisAlignmentOf(context); + + Widget? savedForLaterAnnotation; + if (message.reminder case final reminder? when reminder.remindAt == null) { + savedForLaterAnnotation = core.StreamMessageAnnotation( + leading: Icon(icons.save), + label: Text(translations.savedForLaterLabel), + style: .from(textColor: colorScheme.accentPrimary, iconColor: colorScheme.accentPrimary), + ); + } + + Widget? pinnedAnnotation; + if (message.pinned case true) { + final currentUser = StreamChat.of(context).currentUser!; + final pinnedBy = message.pinnedBy ?? currentUser; + + pinnedAnnotation = core.StreamMessageAnnotation( + leading: Icon(icons.pin), + label: Text(translations.pinnedByUserText(pinnedBy: pinnedBy, currentUser: currentUser)), + ); + } + + Widget? showInChannelAnnotation; + if (message.showInChannel case true) { + final listKind = core.StreamMessageLayout.listKindOf(context); + final annotationLabel = switch (listKind) { + .channel => '${translations.repliedToThreadAnnotationLabel} ·', + .thread => '${translations.alsoSentInChannelAnnotationLabel} ·', + }; + + showInChannelAnnotation = core.StreamMessageAnnotation( + onTap: onViewChannelTap, + leading: Icon(icons.arrowUpRight), + label: Text(annotationLabel), + trailing: Text(translations.viewLabel), + style: .from(trailingTextColor: colorScheme.textLink), + ); + } + + Widget? reminderAnnotation; + if (message.reminder?.remindAt?.toLocal() case final remindAt?) { + reminderAnnotation = core.StreamMessageAnnotation( + leading: Icon(icons.bell), + label: Text('${translations.reminderSetLabel} ·'), + trailing: Text(translations.reminderAtText(Jiffy.parseFromDateTime(remindAt).jm)), + ); + } + + final children = [ + ?savedForLaterAnnotation, + ?pinnedAnnotation, + ?showInChannelAnnotation, + ?reminderAnnotation, + ]; + + if (children.isEmpty) return null; + + return core.StreamColumn( + mainAxisSize: .min, + crossAxisAlignment: crossAxisAlignment, + children: children, + ); + } +} diff --git a/packages/stream_chat_flutter/lib/src/message_widget/components/stream_message_reactions.dart b/packages/stream_chat_flutter/lib/src/message_widget/components/stream_message_reactions.dart new file mode 100644 index 0000000000..c3f155e7f9 --- /dev/null +++ b/packages/stream_chat_flutter/lib/src/message_widget/components/stream_message_reactions.dart @@ -0,0 +1,95 @@ +import 'package:collection/collection.dart'; +import 'package:flutter/material.dart'; +import 'package:stream_chat_flutter/src/stream_chat_configuration.dart'; +import 'package:stream_chat_flutter/src/utils/device_segmentation.dart'; +import 'package:stream_chat_flutter_core/stream_chat_flutter_core.dart'; +import 'package:stream_core_flutter/stream_core_flutter.dart' as core; + +/// Displays reaction groups for a message as emoji chips overlaid on, or +/// placed beneath, the [child] widget. +/// +/// Reaction icons are resolved through the +/// [StreamChatConfigurationData.reactionIconResolver]. Groups are sorted +/// using [sorting] (defaults to [ReactionSorting.byFirstReactionAt]). +/// +/// See also: +/// +/// * [StreamMessageScaffold], which hosts this widget around the bubble. +/// * [StreamChatConfigurationData.reactionIconResolver], which maps reaction +/// type strings to emoji content models. +class StreamMessageReactions extends StatelessWidget { + /// Creates a message reactions widget for the given [message]. + const StreamMessageReactions({ + super.key, + required this.message, + this.type, + this.position, + this.overlap, + this.sorting, + this.onPressed, + this.child, + }); + + /// The message whose reactions to display. + final Message message; + + /// The visual type of the reactions display. + /// + /// Defaults to [core.StreamReactionsType.segmented] when null. + final core.StreamReactionsType? type; + + /// Where the reactions appear relative to the message bubble. + /// + /// Defaults to [core.StreamReactionsPosition.footer] on desktop and web, + /// and [core.StreamReactionsPosition.header] on mobile. + final core.StreamReactionsPosition? position; + + /// Whether reactions overlap the message bubble edge. + /// + /// When null, defaults to `true` on mobile and `false` on desktop and web. + final bool? overlap; + + /// Controls how reaction groups are sorted when displayed. + /// + /// Defaults to [ReactionSorting.byFirstReactionAt] when null. + final Comparator? sorting; + + /// Called when the reactions area is pressed. + /// + /// If null, pressing the reactions area has no effect. + final VoidCallback? onPressed; + + /// The child widget (typically the message bubble) that reactions are + /// displayed on. + final Widget? child; + + @override + Widget build(BuildContext context) { + final config = StreamChatConfiguration.of(context); + final resolver = config.reactionIconResolver; + + final effectiveType = type ?? config.reactionType ?? core.StreamReactionsType.segmented; + final effectivePosition = position ?? config.reactionPosition ?? core.StreamReactionsPosition.header; + final effectiveOverlap = overlap ?? config.reactionOverlap ?? !isDesktopDeviceOrWeb; + + final reactionGroups = message.reactionGroups?.entries; + final effectiveReactionSorting = sorting ?? ReactionSorting.byFirstReactionAt; + final sortedReactionGroups = reactionGroups?.sortedByCompare((it) => it.value, effectiveReactionSorting); + + final items = sortedReactionGroups?.map( + (group) => core.StreamReactionsItem( + count: group.value.count, + emoji: resolver.resolve(group.key), + ), + ); + + return core.StreamReactions( + type: effectiveType, + position: effectivePosition, + overlap: effectiveOverlap, + onPressed: onPressed, + items: [...?items], + child: child, + ); + } +} diff --git a/packages/stream_chat_flutter/lib/src/message_widget/components/stream_message_sending_status.dart b/packages/stream_chat_flutter/lib/src/message_widget/components/stream_message_sending_status.dart new file mode 100644 index 0000000000..684218553c --- /dev/null +++ b/packages/stream_chat_flutter/lib/src/message_widget/components/stream_message_sending_status.dart @@ -0,0 +1,72 @@ +import 'package:flutter/material.dart'; +import 'package:stream_chat_flutter/src/indicators/sending_indicator.dart'; +import 'package:stream_chat_flutter/src/utils/extensions.dart'; +import 'package:stream_chat_flutter_core/stream_chat_flutter_core.dart'; + +/// Displays the sending status of a message, including attachment upload +/// progress and sent/delivered/read indicators. +/// +/// While attachments are still uploading, a textual progress label is shown. +/// Once the message is fully sent, an icon indicates whether it has been +/// sent, delivered, or read. +/// +/// This widget is typically used inside [StreamMessageFooter] and is only +/// shown for messages sent by the current user. +/// +/// See also: +/// +/// * [StreamSendingIndicator], which renders the sent/delivered/read icon. +/// * [StreamMessageFooter], which hosts this widget. +class StreamMessageSendingStatus extends StatelessWidget { + /// Creates a sending status widget for the given [message]. + const StreamMessageSendingStatus({ + super.key, + required this.message, + }); + + /// The message whose sending status to display. + final Message message; + + @override + Widget build(BuildContext context) { + final attachments = message.attachments; + + final hasNonUrlAttachments = attachments.any((it) => it.type != AttachmentType.urlPreview); + + if (hasNonUrlAttachments && message.state.isOutgoing) { + final attachments = message.attachments; + + final totalAttachments = attachments.length; + final uploadedCount = attachments.where((it) => it.uploadState.isSuccess).length; + + if (uploadedCount < totalAttachments) { + return Text( + context.translations.attachmentsUploadProgressText( + total: totalAttachments, + completed: uploadedCount, + ), + ); + } + } + + final channel = StreamChannel.maybeOf(context)?.channel; + + return BetterStreamBuilder>( + stream: channel?.state?.readStream, + initialData: channel?.state?.read, + builder: (context, data) { + final readList = data.readsOf(message: message); + final isMessageRead = readList.isNotEmpty; + + final deliveriesList = data.deliveriesOf(message: message); + final isMessageDelivered = deliveriesList.isNotEmpty; + + return StreamSendingIndicator( + message: message, + isMessageRead: isMessageRead, + isMessageDelivered: isMessageDelivered, + ); + }, + ); + } +} diff --git a/packages/stream_chat_flutter/lib/src/message_widget/components/stream_message_text.dart b/packages/stream_chat_flutter/lib/src/message_widget/components/stream_message_text.dart new file mode 100644 index 0000000000..f273f32ea2 --- /dev/null +++ b/packages/stream_chat_flutter/lib/src/message_widget/components/stream_message_text.dart @@ -0,0 +1,69 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_markdown/flutter_markdown.dart'; +import 'package:stream_chat_flutter/src/misc/empty_widget.dart'; +import 'package:stream_chat_flutter/src/stream_chat.dart'; +import 'package:stream_chat_flutter/src/utils/device_segmentation.dart'; +import 'package:stream_chat_flutter/src/utils/extensions.dart'; +import 'package:stream_chat_flutter_core/stream_chat_flutter_core.dart'; +import 'package:stream_core_flutter/stream_core_flutter.dart' as core; + +/// Displays the translated markdown message text, reacting to the current +/// user's language preference. +/// +/// The message text is translated into the current user's language, mention +/// syntax is replaced with display names, and the result is rendered as +/// markdown. +/// +/// The widget rebuilds automatically when the current user's language +/// changes, ensuring the displayed text stays in sync. +/// +/// On desktop and web the text is selectable; on mobile it is not. +/// +/// See also: +/// +/// * [StreamMessageScaffold], which hosts this widget inside a message bubble. +class StreamMessageText extends StatelessWidget { + /// Creates a message text widget for the given [message]. + const StreamMessageText({ + super.key, + required this.message, + this.onLinkTap, + this.onMentionTap, + }); + + /// The message whose text to display. + final Message message; + + /// Called when a link in the rendered markdown is tapped. + /// + /// If null, tapping a link has no effect. + final MarkdownTapLinkCallback? onLinkTap; + + /// Called when a `@mention` in the rendered markdown is tapped. + /// + /// Mentions use the `[text](mention:id)` format in the raw markdown. + /// If null, tapping a mention has no effect. + final core.MarkdownTapMentionCallback? onMentionTap; + + @override + Widget build(BuildContext context) { + final streamChat = StreamChat.of(context); + + return BetterStreamBuilder( + initialData: streamChat.currentUser?.language ?? 'en', + stream: streamChat.currentUserStream.map((it) => it?.language ?? 'en'), + builder: (context, language) { + final messageText = message.translate(language).replaceMentions().text?.replaceAll('\n', '\n\n').trim(); + + if (messageText == null || messageText.trim().isEmpty) return const Empty(); + + return core.StreamMessageText( + messageText, + selectable: isDesktopDeviceOrWeb, + onTapLink: onLinkTap, + onTapMention: onMentionTap, + ); + }, + ); + } +} diff --git a/packages/stream_chat_flutter/lib/src/message_widget/deleted_message.dart b/packages/stream_chat_flutter/lib/src/message_widget/deleted_message.dart deleted file mode 100644 index 10004802fa..0000000000 --- a/packages/stream_chat_flutter/lib/src/message_widget/deleted_message.dart +++ /dev/null @@ -1,57 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:stream_chat_flutter/stream_chat_flutter.dart'; - -/// {@template streamDeletedMessage} -/// Displays that a message was deleted at this position in the message list. -/// {@endtemplate} -class StreamDeletedMessage extends StatelessWidget { - /// {@macro streamDeletedMessage} - const StreamDeletedMessage({ - super.key, - required this.messageTheme, - this.borderRadiusGeometry, - this.shape, - this.borderSide, - this.reverse = false, - }); - - /// The theme of the message - final StreamMessageThemeData messageTheme; - - /// The border radius of the message text - final BorderRadiusGeometry? borderRadiusGeometry; - - /// The shape of the message text - final ShapeBorder? shape; - - /// The [BorderSide] of the message text - final BorderSide? borderSide; - - /// If true the widget will be mirrored - final bool reverse; - - @override - Widget build(BuildContext context) { - return Material( - color: messageTheme.messageBackgroundColor, - shape: shape ?? - RoundedRectangleBorder( - borderRadius: borderRadiusGeometry ?? BorderRadius.zero, - side: borderSide ?? - BorderSide( - color: messageTheme.messageBorderColor ?? Colors.transparent, - ), - ), - child: Padding( - padding: const EdgeInsets.symmetric( - vertical: 8, - horizontal: 16, - ), - child: Text( - context.translations.messageDeletedLabel, - style: messageTheme.messageDeletedStyle, - ), - ), - ); - } -} diff --git a/packages/stream_chat_flutter/lib/src/message_widget/giphy_ephemeral_message.dart b/packages/stream_chat_flutter/lib/src/message_widget/giphy_ephemeral_message.dart deleted file mode 100644 index dea2cc8e2c..0000000000 --- a/packages/stream_chat_flutter/lib/src/message_widget/giphy_ephemeral_message.dart +++ /dev/null @@ -1,235 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:jiffy/jiffy.dart'; -import 'package:stream_chat_flutter/src/attachment/thumbnail/giphy_attachment_thumbnail.dart'; -import 'package:stream_chat_flutter/src/icons/stream_svg_icon.dart'; -import 'package:stream_chat_flutter/src/misc/timestamp.dart'; -import 'package:stream_chat_flutter/src/misc/visible_footnote.dart'; -import 'package:stream_chat_flutter/src/theme/stream_chat_theme.dart'; -import 'package:stream_chat_flutter/src/utils/extensions.dart'; -import 'package:stream_chat_flutter_core/stream_chat_flutter_core.dart'; - -/// Signature for the action callback passed to [GiphyEphemeralMessage]. -/// -/// Used by [GiphyEphemeralMessage.onActionPressed]. -typedef GiffyAction = void Function(String name, String value); - -/// {@template giphyEphemeralMessage} -/// Shows an ephemeral message of type giphy in a [MessageWidget]. -/// {@endtemplate} -class GiphyEphemeralMessage extends StatelessWidget { - /// {@macro giphyEphemeralMessage} - const GiphyEphemeralMessage({ - super.key, - required this.message, - this.onActionPressed, - }); - - /// The underlying [Message] object which this widget represents. - final Message message; - - /// Callback called when an action is pressed. - final GiffyAction? onActionPressed; - - @override - Widget build(BuildContext context) { - final giphy = message.attachments.first; - - final actions = giphy.actions; - assert(actions != null && actions.isNotEmpty, 'actions cannot be null'); - - final chatTheme = StreamChatTheme.of(context); - final textTheme = chatTheme.textTheme; - final colorTheme = chatTheme.colorTheme; - - final divider = Divider(thickness: 1, height: 0, color: colorTheme.borders); - - return Padding( - padding: const EdgeInsets.all(8), - child: Align( - alignment: Alignment.centerRight, - child: SizedBox( - width: 304, - height: 343, - child: Column( - children: [ - Expanded( - child: Card( - elevation: 2, - color: colorTheme.barsBg, - margin: EdgeInsets.zero, - shape: const RoundedRectangleBorder( - borderRadius: BorderRadius.only( - topRight: Radius.circular(16), - topLeft: Radius.circular(16), - bottomLeft: Radius.circular(16), - ), - ), - child: Column( - children: [ - Padding( - padding: const EdgeInsets.all(8), - child: GiphyHeader(title: giphy.title), - ), - divider, - Expanded( - child: Padding( - padding: const EdgeInsets.all(2), - child: ClipRRect( - borderRadius: BorderRadius.circular(2), - child: StreamGiphyAttachmentThumbnail( - giphy: giphy, - width: double.infinity, - height: double.infinity, - ), - ), - ), - ), - divider, - SizedBox( - height: 48, - child: Padding( - padding: const EdgeInsets.all(2), - child: GiphyActions( - giphy: giphy, - onActionPressed: onActionPressed, - ), - ), - ), - ], - ), - ), - ), - const SizedBox(height: 4), - Row( - mainAxisAlignment: MainAxisAlignment.end, - children: [ - const StreamVisibleFootnote(), - const SizedBox(width: 8), - StreamTimestamp( - date: message.createdAt.toLocal(), - formatter: (_, date) => Jiffy.parseFromDateTime(date).jm, - style: textTheme.footnote.copyWith( - color: colorTheme.textLowEmphasis, - ), - ), - ], - ), - ], - ), - ), - ), - ); - } -} - -/// {@template giphyActions} -/// Shows the actions for a giphy ephemeral message. -/// {@endtemplate} -class GiphyActions extends StatelessWidget { - /// {@macro giphyActions} - const GiphyActions({ - super.key, - required this.giphy, - required this.onActionPressed, - }); - - /// The underlying [Attachment] object which this widget represents. - final Attachment giphy; - - /// Callback called when an action is pressed. - final GiffyAction? onActionPressed; - - @override - Widget build(BuildContext context) { - final theme = StreamChatTheme.of(context); - final textTheme = theme.textTheme; - final colorTheme = theme.colorTheme; - - return Row( - crossAxisAlignment: CrossAxisAlignment.stretch, - mainAxisAlignment: MainAxisAlignment.spaceEvenly, - children: [ - Expanded( - child: TextButton( - onPressed: switch (onActionPressed) { - final onPressed? => () => onPressed('image_action', 'cancel'), - _ => null, - }, - style: TextButton.styleFrom( - textStyle: textTheme.bodyBold, - foregroundColor: colorTheme.textLowEmphasis, - ), - child: Text(context.translations.cancelLabel.capitalize()), - ), - ), - VerticalDivider(thickness: 1, width: 4, color: colorTheme.borders), - Expanded( - child: TextButton( - onPressed: switch (onActionPressed) { - final onPressed? => () => onPressed('image_action', 'shuffle'), - _ => null, - }, - style: TextButton.styleFrom( - textStyle: textTheme.bodyBold, - foregroundColor: colorTheme.textLowEmphasis, - ), - child: Text(context.translations.shuffleLabel.capitalize()), - ), - ), - VerticalDivider(thickness: 1, width: 4, color: colorTheme.borders), - Expanded( - child: TextButton( - onPressed: switch (onActionPressed) { - final onPressed? => () => onPressed('image_action', 'send'), - _ => null, - }, - style: TextButton.styleFrom( - textStyle: textTheme.bodyBold, - foregroundColor: colorTheme.accentPrimary, - ), - child: Text(context.translations.sendLabel.capitalize()), - ), - ), - ], - ); - } -} - -/// {@template giphyHeader} -/// Shows the header for a giphy ephemeral message. -/// {@endtemplate} -class GiphyHeader extends StatelessWidget { - /// {@macro giphyHeader} - const GiphyHeader({super.key, this.title}); - - /// The title of the giphy. - final String? title; - - @override - Widget build(BuildContext context) { - final colorTheme = StreamChatTheme.of(context).colorTheme; - return Row( - children: [ - const StreamSvgIcon(icon: StreamSvgIcons.giphy), - const SizedBox(width: 8), - Text( - context.translations.giphyLabel, - style: const TextStyle(fontWeight: FontWeight.bold), - ), - const SizedBox(width: 8), - if (title != null) - Expanded( - child: Text( - title!, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: TextStyle( - // ignore: deprecated_member_use - color: colorTheme.textHighEmphasis.withOpacity(0.5), - ), - ), - ), - ], - ); - } -} diff --git a/packages/stream_chat_flutter/lib/src/message_widget/message_card.dart b/packages/stream_chat_flutter/lib/src/message_widget/message_card.dart deleted file mode 100644 index 5858becd78..0000000000 --- a/packages/stream_chat_flutter/lib/src/message_widget/message_card.dart +++ /dev/null @@ -1,271 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:stream_chat_flutter/stream_chat_flutter.dart'; - -/// {@template messageCard} -/// The widget containing a quoted message. -/// -/// Used in [MessageWidgetContent]. Should not be used elsewhere. -/// {@endtemplate} -class MessageCard extends StatefulWidget { - /// {@macro messageCard} - const MessageCard({ - super.key, - required this.message, - required this.isFailedState, - required this.showUserAvatar, - required this.messageTheme, - required this.hasQuotedMessage, - required this.hasUrlAttachments, - required this.hasNonUrlAttachments, - required this.hasPoll, - required this.isOnlyEmoji, - required this.isGiphy, - required this.attachmentBuilders, - required this.attachmentPadding, - required this.attachmentShape, - required this.onAttachmentTap, - required this.onShowMessage, - required this.onReplyTap, - required this.attachmentActionsModalBuilder, - required this.textPadding, - required this.reverse, - this.shape, - this.borderSide, - this.borderRadiusGeometry, - this.textBuilder, - this.quotedMessageBuilder, - this.onLinkTap, - this.onMentionTap, - this.onQuotedMessageTap, - }); - - /// {@macro isFailedState} - final bool isFailedState; - - /// {@macro showUserAvatar} - final DisplayWidget showUserAvatar; - - /// {@macro shape} - final ShapeBorder? shape; - - /// {@macro borderSide} - final BorderSide? borderSide; - - /// {@macro messageTheme} - final StreamMessageThemeData messageTheme; - - /// {@macro borderRadiusGeometry} - final BorderRadiusGeometry? borderRadiusGeometry; - - /// {@macro hasQuotedMessage} - final bool hasQuotedMessage; - - /// {@macro hasUrlAttachments} - final bool hasUrlAttachments; - - /// {@macro hasNonUrlAttachments} - final bool hasNonUrlAttachments; - - /// {@macro hasPoll} - final bool hasPoll; - - /// {@macro isOnlyEmoji} - final bool isOnlyEmoji; - - /// {@macro isGiphy} - final bool isGiphy; - - /// {@macro message} - final Message message; - - /// {@macro attachmentBuilders} - final List? attachmentBuilders; - - /// {@macro attachmentPadding} - final EdgeInsetsGeometry attachmentPadding; - - /// {@macro attachmentShape} - final ShapeBorder? attachmentShape; - - /// {@macro onAttachmentTap} - final StreamAttachmentWidgetTapCallback? onAttachmentTap; - - /// {@macro onShowMessage} - final ShowMessageCallback? onShowMessage; - - /// {@macro onReplyTap} - final void Function(Message)? onReplyTap; - - /// {@macro attachmentActionsBuilder} - final AttachmentActionsBuilder? attachmentActionsModalBuilder; - - /// {@macro textPadding} - final EdgeInsets textPadding; - - /// {@macro textBuilder} - final Widget Function(BuildContext, Message)? textBuilder; - - /// {@macro quotedMessageBuilder} - final Widget Function(BuildContext, Message)? quotedMessageBuilder; - - /// {@macro onLinkTap} - final void Function(String)? onLinkTap; - - /// {@macro onMentionTap} - final void Function(User)? onMentionTap; - - /// {@macro onQuotedMessageTap} - final OnQuotedMessageTap? onQuotedMessageTap; - - /// {@macro reverse} - final bool reverse; - - @override - State createState() => _MessageCardState(); -} - -class _MessageCardState extends State { - final attachmentsKey = GlobalKey(); - double? widthLimit; - - bool get hasAttachments { - return widget.hasUrlAttachments || widget.hasNonUrlAttachments; - } - - void _updateWidthLimit() { - final attachmentContext = attachmentsKey.currentContext; - final renderBox = attachmentContext?.findRenderObject() as RenderBox?; - final attachmentsWidth = renderBox?.size.width; - - if (attachmentsWidth == null || attachmentsWidth == 0) return; - - if (mounted) { - setState(() => widthLimit = attachmentsWidth); - } - } - - @override - void didChangeDependencies() { - super.didChangeDependencies(); - // If there is an attachment, we need to wait for the attachment to be - // rendered to get the width of the attachment and set it as the width - // limit of the message card. - if (hasAttachments) { - WidgetsBinding.instance.addPostFrameCallback((_) { - _updateWidthLimit(); - }); - } - } - - @override - Widget build(BuildContext context) { - final onQuotedMessageTap = widget.onQuotedMessageTap; - final quotedMessageBuilder = widget.quotedMessageBuilder; - - return Container( - constraints: const BoxConstraints().copyWith(maxWidth: widthLimit), - margin: EdgeInsets.symmetric( - horizontal: (widget.isFailedState ? 12.0 : 0.0) + - (widget.showUserAvatar == DisplayWidget.gone ? 0 : 4.0), - ), - clipBehavior: Clip.hardEdge, - decoration: _buildDecoration(widget.messageTheme), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - if (widget.hasQuotedMessage) - InkWell( - onTap: !widget.message.quotedMessage!.isDeleted && - onQuotedMessageTap != null - ? () => onQuotedMessageTap(widget.message.quotedMessageId) - : null, - child: quotedMessageBuilder?.call( - context, - widget.message.quotedMessage!, - ) ?? - QuotedMessage( - message: widget.message, - textBuilder: widget.textBuilder, - hasNonUrlAttachments: widget.hasNonUrlAttachments, - ), - ), - if (hasAttachments) - ParseAttachments( - key: attachmentsKey, - message: widget.message, - attachmentBuilders: widget.attachmentBuilders, - attachmentPadding: widget.attachmentPadding, - attachmentShape: widget.attachmentShape, - onAttachmentTap: widget.onAttachmentTap, - onShowMessage: widget.onShowMessage, - onReplyTap: widget.onReplyTap, - attachmentActionsModalBuilder: - widget.attachmentActionsModalBuilder, - ), - if (widget.hasPoll) - PollMessage( - message: widget.message, - ), - TextBubble( - messageTheme: widget.messageTheme, - message: widget.message, - textPadding: widget.textPadding, - textBuilder: widget.textBuilder, - isOnlyEmoji: widget.isOnlyEmoji, - hasQuotedMessage: widget.hasQuotedMessage, - hasUrlAttachments: widget.hasUrlAttachments, - onLinkTap: widget.onLinkTap, - onMentionTap: widget.onMentionTap, - ), - ], - ), - ); - } - - ShapeDecoration _buildDecoration(StreamMessageThemeData theme) { - final gradient = _getBackgroundGradient(theme); - final color = gradient == null ? _getBackgroundColor(theme) : null; - - final borderColor = theme.messageBorderColor ?? Colors.transparent; - final borderRadius = widget.borderRadiusGeometry ?? BorderRadius.zero; - - return ShapeDecoration( - color: color, - gradient: gradient, - shape: switch (widget.shape) { - final shape? => shape, - _ => RoundedRectangleBorder( - borderRadius: borderRadius, - side: switch (widget.borderSide) { - final side? => side, - _ => BorderSide(color: borderColor), - }, - ), - }, - ); - } - - Color? _getBackgroundColor(StreamMessageThemeData theme) { - if (widget.hasQuotedMessage) { - return theme.messageBackgroundColor; - } - - final containsOnlyUrlAttachment = - widget.hasUrlAttachments && !widget.hasNonUrlAttachments; - - if (containsOnlyUrlAttachment) { - return theme.urlAttachmentBackgroundColor; - } - - if (widget.isOnlyEmoji) return null; - - return theme.messageBackgroundColor; - } - - Gradient? _getBackgroundGradient(StreamMessageThemeData theme) { - if (widget.isOnlyEmoji) return null; - - return theme.messageBackgroundGradient; - } -} diff --git a/packages/stream_chat_flutter/lib/src/message_widget/message_text.dart b/packages/stream_chat_flutter/lib/src/message_widget/message_text.dart deleted file mode 100644 index e46e4fc420..0000000000 --- a/packages/stream_chat_flutter/lib/src/message_widget/message_text.dart +++ /dev/null @@ -1,74 +0,0 @@ -import 'package:collection/collection.dart' show IterableExtension; -import 'package:flutter/material.dart'; -import 'package:stream_chat_flutter/stream_chat_flutter.dart'; - -/// {@template streamMessageText} -/// The text content of a message. -/// {@endtemplate} -class StreamMessageText extends StatelessWidget { - /// {@macro streamMessageText} - const StreamMessageText({ - super.key, - required this.message, - required this.messageTheme, - this.onMentionTap, - this.onLinkTap, - }); - - /// Message whose text is to be displayed - final Message message; - - /// The action to perform when a mention is tapped - final void Function(User)? onMentionTap; - - /// The action to perform when a link is tapped - final void Function(String)? onLinkTap; - - /// [StreamMessageThemeData] whose text theme is to be applied - final StreamMessageThemeData messageTheme; - - @override - Widget build(BuildContext context) { - final streamChat = StreamChat.of(context); - assert(streamChat.currentUser != null, ''); - return BetterStreamBuilder( - stream: streamChat.currentUserStream.map((it) => it!.language ?? 'en'), - initialData: streamChat.currentUser!.language ?? 'en', - builder: (context, language) { - final messageText = message - .translate(language) - .replaceMentions() - .text - ?.replaceAll('\n', '\n\n') - .trim(); - - return StreamMarkdownMessage( - data: messageText ?? '', - messageTheme: messageTheme, - selectable: isDesktopDeviceOrWeb, - onTapLink: ( - String text, - String? href, - String title, - ) { - if (text.startsWith('@')) { - final mentionedUser = message.mentionedUsers.firstWhereOrNull( - (u) => '@${u.name}' == text, - ); - - if (mentionedUser == null) return; - - onMentionTap?.call(mentionedUser); - } else if (href != null) { - if (onLinkTap != null) { - onLinkTap!(href); - } else { - launchURL(context, href); - } - } - }, - ); - }, - ); - } -} diff --git a/packages/stream_chat_flutter/lib/src/message_widget/message_widget.dart b/packages/stream_chat_flutter/lib/src/message_widget/message_widget.dart deleted file mode 100644 index ebf18428fc..0000000000 --- a/packages/stream_chat_flutter/lib/src/message_widget/message_widget.dart +++ /dev/null @@ -1,1249 +0,0 @@ -import 'package:flutter/material.dart' hide ButtonStyle; -import 'package:flutter/services.dart'; -import 'package:flutter_portal/flutter_portal.dart'; -import 'package:stream_chat_flutter/conditional_parent_builder/conditional_parent_builder.dart'; -import 'package:stream_chat_flutter/platform_widget_builder/platform_widget_builder.dart'; -import 'package:stream_chat_flutter/src/context_menu/context_menu.dart'; -import 'package:stream_chat_flutter/src/context_menu/context_menu_region.dart'; -import 'package:stream_chat_flutter/src/context_menu_items/context_menu_reaction_picker.dart'; -import 'package:stream_chat_flutter/src/context_menu_items/stream_chat_context_menu_item.dart'; -import 'package:stream_chat_flutter/src/dialogs/dialogs.dart'; -import 'package:stream_chat_flutter/src/message_actions_modal/message_actions_modal.dart'; -import 'package:stream_chat_flutter/src/message_actions_modal/moderated_message_actions_modal.dart'; -import 'package:stream_chat_flutter/src/message_widget/message_widget_content.dart'; -import 'package:stream_chat_flutter/stream_chat_flutter.dart'; - -/// The display behaviour of a widget -enum DisplayWidget { - /// Hides the widget replacing its space with a spacer - hide, - - /// Hides the widget not replacing its space - gone, - - /// Shows the widget normally - show, -} - -/// {@template messageWidget} -/// ![screenshot](https://raw.githubusercontent.com/GetStream/stream-chat-flutter/master/packages/stream_chat_flutter/screenshots/message_widget.png) -/// ![screenshot](https://raw.githubusercontent.com/GetStream/stream-chat-flutter/master/packages/stream_chat_flutter/screenshots/message_widget_paint.png) -/// -/// Shows a message with reactions, replies and user avatar. -/// -/// Usually you don't use this widget as it's the default message widget used by -/// [MessageListView]. -/// -/// The widget components render the ui based on the first ancestor of type -/// [StreamChatTheme]. -/// Modify it to change the widget appearance. -/// {@endtemplate} -class StreamMessageWidget extends StatefulWidget { - /// {@macro messageWidget} - const StreamMessageWidget({ - super.key, - required this.message, - required this.messageTheme, - this.reverse = false, - this.translateUserAvatar = true, - this.shape, - this.borderSide, - this.borderRadiusGeometry, - this.attachmentShape, - this.onMentionTap, - this.onMessageTap, - this.onMessageLongPress, - this.onReactionsTap, - this.onReactionsHover, - this.showReactionPicker = true, - this.showReactionTail, - this.showUserAvatar = DisplayWidget.show, - this.showSendingIndicator = true, - this.showThreadReplyIndicator = false, - this.showInChannelIndicator = false, - this.onReplyTap, - this.onThreadTap, - this.onConfirmDeleteTap, - this.showUsername = true, - this.showTimestamp = true, - this.showEditedLabel = true, - this.showReactions = true, - this.showDeleteMessage = true, - this.showEditMessage = true, - this.showReplyMessage = true, - this.showThreadReplyMessage = true, - this.showMarkUnreadMessage = true, - this.showResendMessage = true, - this.showCopyMessage = true, - this.showFlagButton = true, - this.showPinButton = true, - this.showPinHighlight = true, - this.onUserAvatarTap, - this.onLinkTap, - this.onMessageActions, - this.onBouncedErrorMessageActions, - this.onShowMessage, - this.userAvatarBuilder, - this.quotedMessageBuilder, - this.editMessageInputBuilder, - this.textBuilder, - this.bottomRowBuilderWithDefaultWidget, - this.attachmentBuilders, - this.padding, - this.textPadding = const EdgeInsets.symmetric( - horizontal: 16, - vertical: 8, - ), - this.attachmentPadding = EdgeInsets.zero, - this.widthFactor = 0.78, - this.onQuotedMessageTap, - this.customActions = const [], - this.onAttachmentTap, - this.imageAttachmentThumbnailSize = const Size(400, 400), - this.imageAttachmentThumbnailResizeType = 'clip', - this.imageAttachmentThumbnailCropType = 'center', - this.attachmentActionsModalBuilder, - }); - - /// {@template onMentionTap} - /// Function called on mention tap - /// {@endtemplate} - final void Function(User)? onMentionTap; - - /// {@template onThreadTap} - /// The function called when tapping on threads - /// {@endtemplate} - final void Function(Message)? onThreadTap; - - /// {@template onReplyTap} - /// The function called when tapping on replies - /// {@endtemplate} - final void Function(Message)? onReplyTap; - - /// {@template onDeleteTap} - /// The function called when delete confirmation button is tapped. - /// {@endtemplate} - final Future Function(Message)? onConfirmDeleteTap; - - /// {@template editMessageInputBuilder} - /// Widget builder for edit message layout - /// {@endtemplate} - final Widget Function(BuildContext, Message)? editMessageInputBuilder; - - /// {@template textBuilder} - /// Widget builder for building text - /// {@endtemplate} - final Widget Function(BuildContext, Message)? textBuilder; - - /// {@template onMessageActions} - /// Function called when a message is long-pressed to show actions. - /// If provided, this callback will be called instead of showing the default - /// message actions modal dialog. - /// {@endtemplate} - final void Function(BuildContext, Message)? onMessageActions; - - /// {@template onBouncedErrorMessageActions} - /// Function called when a message that has bounced with an error is long - /// pressed. If provided, this callback will be called instead of showing the - /// default bounced error message actions dialog. - /// {@endtemplate} - final void Function(BuildContext, Message)? onBouncedErrorMessageActions; - - /// {@template bottomRowBuilderWithDefaultWidget} - /// Widget builder for building a bottom row below the message. - /// Also contains the default bottom row widget. - /// {@endtemplate} - final BottomRowBuilderWithDefaultWidget? bottomRowBuilderWithDefaultWidget; - - /// {@template userAvatarBuilder} - /// Widget builder for building user avatar - /// {@endtemplate} - final Widget Function(BuildContext, User)? userAvatarBuilder; - - /// {@template quotedMessageBuilder} - /// Widget builder for building quoted message - /// {@endtemplate} - final Widget Function(BuildContext, Message)? quotedMessageBuilder; - - /// {@template message} - /// The message to display. - /// {@endtemplate} - final Message message; - - /// {@template messageTheme} - /// The message theme - /// {@endtemplate} - final StreamMessageThemeData messageTheme; - - /// {@template reverse} - /// If true the widget will be mirrored - /// {@endtemplate} - final bool reverse; - - /// {@template shape} - /// The shape of the message text - /// {@endtemplate} - final ShapeBorder? shape; - - /// {@template attachmentShape} - /// The shape of an attachment - /// {@endtemplate} - final ShapeBorder? attachmentShape; - - /// {@template borderSide} - /// The borderSide of the message text - /// {@endtemplate} - final BorderSide? borderSide; - - /// {@template borderRadiusGeometry} - /// The border radius of the message text - /// {@endtemplate} - final BorderRadiusGeometry? borderRadiusGeometry; - - /// {@template padding} - /// The padding of the widget - /// {@endtemplate} - final EdgeInsetsGeometry? padding; - - /// {@template textPadding} - /// The internal padding of the message text - /// {@endtemplate} - final EdgeInsets textPadding; - - /// {@template attachmentPadding} - /// The internal padding of an attachment - /// {@endtemplate} - final EdgeInsetsGeometry attachmentPadding; - - /// {@template widthFactor} - /// The percentage of the available width the message content should take - /// {@endtemplate} - final double widthFactor; - - /// {@template showUserAvatar} - /// It controls the display behaviour of the user avatar - /// {@endtemplate} - final DisplayWidget showUserAvatar; - - /// {@template showSendingIndicator} - /// It controls the display behaviour of the sending indicator - /// {@endtemplate} - final bool showSendingIndicator; - - /// {@template showReactions} - /// If `true` the message's reactions will be shown. - /// {@endtemplate} - final bool showReactions; - - /// {@template showThreadReplyIndicator} - /// If true the widget will show the thread reply indicator - /// {@endtemplate} - final bool showThreadReplyIndicator; - - /// {@template showInChannelIndicator} - /// If true the widget will show the show in channel indicator - /// {@endtemplate} - final bool showInChannelIndicator; - - /// {@template onUserAvatarTap} - /// The function called when tapping on UserAvatar - /// {@endtemplate} - final void Function(User)? onUserAvatarTap; - - /// {@template onLinkTap} - /// The function called when tapping on a link - /// {@endtemplate} - final void Function(String)? onLinkTap; - - /// {@template showReactionPicker} - /// Whether or not to show the reaction picker. - /// Used in [StreamMessageReactionsModal] and [MessageActionsModal]. - /// {@endtemplate} - final bool showReactionPicker; - - /// {@template showReactionPickerTail} - /// Whether or not to show the reaction picker tail. - /// This is calculated internally in most cases and does not need to be set. - /// {@endtemplate} - final bool? showReactionTail; - - /// {@template onShowMessage} - /// Callback when show message is tapped - /// {@endtemplate} - final ShowMessageCallback? onShowMessage; - - /// {@template showUsername} - /// If true show the users username next to the timestamp of the message - /// {@endtemplate} - final bool showUsername; - - /// {@template showTimestamp} - /// Show message timestamp - /// {@endtemplate} - final bool showTimestamp; - - /// {@template showTimestamp} - /// Show edited label if message is edited - /// {@endtemplate} - final bool showEditedLabel; - - /// {@template showReplyMessage} - /// Show reply action - /// {@endtemplate} - final bool showReplyMessage; - - /// {@template showThreadReplyMessage} - /// Show thread reply action - /// {@endtemplate} - final bool showThreadReplyMessage; - - /// {@template showMarkUnreadMessage} - /// Show mark unread action - /// {@endtemplate} - final bool showMarkUnreadMessage; - - /// {@template showEditMessage} - /// Show edit action - /// {@endtemplate} - final bool showEditMessage; - - /// {@template showCopyMessage} - /// Show copy action - /// {@endtemplate} - final bool showCopyMessage; - - /// {@template showDeleteMessage} - /// Show delete action - /// {@endtemplate} - final bool showDeleteMessage; - - /// {@template showResendMessage} - /// Show resend action - /// {@endtemplate} - final bool showResendMessage; - - /// {@template showFlagButton} - /// Show flag action - /// {@endtemplate} - final bool showFlagButton; - - /// {@template showPinButton} - /// Show pin action - /// {@endtemplate} - final bool showPinButton; - - /// {@template showPinHighlight} - /// Display Pin Highlight - /// {@endtemplate} - final bool showPinHighlight; - - /// {@template attachmentBuilders} - /// List of attachment builders for rendering attachment widgets pre-defined - /// and custom attachment types. - /// - /// If null, the widget will create a default list of attachment builders - /// based on the [Attachment.type] of the attachment. - /// {@endtemplate} - final List? attachmentBuilders; - - /// {@template translateUserAvatar} - /// Center user avatar with bottom of the message - /// {@endtemplate} - final bool translateUserAvatar; - - /// {@macro onQuotedMessageTap} - final OnQuotedMessageTap? onQuotedMessageTap; - - /// {@macro onMessageTap} - final OnMessageTap? onMessageTap; - - /// {@macro onMessageLongPress} - final OnMessageLongPress? onMessageLongPress; - - /// {@macro onReactionsTap} - /// - /// Note: Only used in mobile devices (iOS and Android). Do not confuse this - /// with the tap action on the reactions picker. - final OnReactionsTap? onReactionsTap; - - /// {@macro onReactionsHover} - /// - /// Note: Only used in desktop devices (web and desktop). - final OnReactionsHover? onReactionsHover; - - /// {@template customActions} - /// List of custom actions shown on message long tap - /// {@endtemplate} - final List customActions; - - /// {@macro onMessageWidgetAttachmentTap} - final StreamAttachmentWidgetTapCallback? onAttachmentTap; - - /// {@macro attachmentActionsBuilder} - final AttachmentActionsBuilder? attachmentActionsModalBuilder; - - /// Size of the image attachment thumbnail. - final Size imageAttachmentThumbnailSize; - - /// Resize type of the image attachment thumbnail. - /// - /// Defaults to [crop] - final String /*clip|crop|scale|fill*/ imageAttachmentThumbnailResizeType; - - /// Crop type of the image attachment thumbnail. - /// - /// Defaults to [center] - final String /*center|top|bottom|left|right*/ - imageAttachmentThumbnailCropType; - - /// {@template copyWith} - /// Creates a copy of [StreamMessageWidget] with specified attributes - /// overridden. - /// {@endtemplate} - StreamMessageWidget copyWith({ - Key? key, - void Function(User)? onMentionTap, - void Function(Message)? onThreadTap, - void Function(Message)? onReplyTap, - Future Function(Message)? onConfirmDeleteTap, - Widget Function(BuildContext, Message)? editMessageInputBuilder, - Widget Function(BuildContext, Message)? textBuilder, - Widget Function(BuildContext, Message)? quotedMessageBuilder, - BottomRowBuilderWithDefaultWidget? bottomRowBuilderWithDefaultWidget, - void Function(BuildContext, Message)? onMessageActions, - void Function(BuildContext, Message)? onBouncedErrorMessageActions, - Message? message, - StreamMessageThemeData? messageTheme, - bool? reverse, - ShapeBorder? shape, - ShapeBorder? attachmentShape, - BorderSide? borderSide, - BorderRadiusGeometry? borderRadiusGeometry, - EdgeInsetsGeometry? padding, - EdgeInsets? textPadding, - EdgeInsetsGeometry? attachmentPadding, - double? widthFactor, - DisplayWidget? showUserAvatar, - bool? showSendingIndicator, - bool? showReactions, - bool? allRead, - bool? showThreadReplyIndicator, - bool? showInChannelIndicator, - void Function(User)? onUserAvatarTap, - void Function(String)? onLinkTap, - bool? showReactionBrowser, - bool? showReactionPicker, - bool? showReactionTail, - List? readList, - ShowMessageCallback? onShowMessage, - bool? showUsername, - bool? showTimestamp, - bool? showEditedLabel, - bool? showReplyMessage, - bool? showThreadReplyMessage, - bool? showEditMessage, - bool? showCopyMessage, - bool? showDeleteMessage, - bool? showResendMessage, - bool? showFlagButton, - bool? showPinButton, - bool? showPinHighlight, - bool? showMarkUnreadMessage, - List? attachmentBuilders, - bool? translateUserAvatar, - OnQuotedMessageTap? onQuotedMessageTap, - OnMessageTap? onMessageTap, - OnMessageLongPress? onMessageLongPress, - OnReactionsTap? onReactionsTap, - OnReactionsHover? onReactionsHover, - List? customActions, - void Function(Message message, Attachment attachment)? onAttachmentTap, - Widget Function(BuildContext, User)? userAvatarBuilder, - Size? imageAttachmentThumbnailSize, - String? imageAttachmentThumbnailResizeType, - String? imageAttachmentThumbnailCropType, - AttachmentActionsBuilder? attachmentActionsModalBuilder, - }) { - return StreamMessageWidget( - key: key ?? this.key, - onMentionTap: onMentionTap ?? this.onMentionTap, - onThreadTap: onThreadTap ?? this.onThreadTap, - onReplyTap: onReplyTap ?? this.onReplyTap, - onConfirmDeleteTap: onConfirmDeleteTap ?? this.onConfirmDeleteTap, - editMessageInputBuilder: - editMessageInputBuilder ?? this.editMessageInputBuilder, - textBuilder: textBuilder ?? this.textBuilder, - quotedMessageBuilder: quotedMessageBuilder ?? this.quotedMessageBuilder, - bottomRowBuilderWithDefaultWidget: bottomRowBuilderWithDefaultWidget ?? - this.bottomRowBuilderWithDefaultWidget, - onMessageActions: onMessageActions ?? this.onMessageActions, - onBouncedErrorMessageActions: - onBouncedErrorMessageActions ?? this.onBouncedErrorMessageActions, - message: message ?? this.message, - messageTheme: messageTheme ?? this.messageTheme, - reverse: reverse ?? this.reverse, - shape: shape ?? this.shape, - attachmentShape: attachmentShape ?? this.attachmentShape, - borderSide: borderSide ?? this.borderSide, - borderRadiusGeometry: borderRadiusGeometry ?? this.borderRadiusGeometry, - padding: padding ?? this.padding, - textPadding: textPadding ?? this.textPadding, - attachmentPadding: attachmentPadding ?? this.attachmentPadding, - widthFactor: widthFactor ?? this.widthFactor, - showUserAvatar: showUserAvatar ?? this.showUserAvatar, - showSendingIndicator: showSendingIndicator ?? this.showSendingIndicator, - showEditedLabel: showEditedLabel ?? this.showEditedLabel, - showReactions: showReactions ?? this.showReactions, - showThreadReplyIndicator: - showThreadReplyIndicator ?? this.showThreadReplyIndicator, - showInChannelIndicator: - showInChannelIndicator ?? this.showInChannelIndicator, - onUserAvatarTap: onUserAvatarTap ?? this.onUserAvatarTap, - onLinkTap: onLinkTap ?? this.onLinkTap, - showReactionPicker: showReactionPicker ?? this.showReactionPicker, - showReactionTail: showReactionTail ?? this.showReactionTail, - onShowMessage: onShowMessage ?? this.onShowMessage, - showUsername: showUsername ?? this.showUsername, - showTimestamp: showTimestamp ?? this.showTimestamp, - showReplyMessage: showReplyMessage ?? this.showReplyMessage, - showThreadReplyMessage: - showThreadReplyMessage ?? this.showThreadReplyMessage, - showEditMessage: showEditMessage ?? this.showEditMessage, - showCopyMessage: showCopyMessage ?? this.showCopyMessage, - showDeleteMessage: showDeleteMessage ?? this.showDeleteMessage, - showResendMessage: showResendMessage ?? this.showResendMessage, - showFlagButton: showFlagButton ?? this.showFlagButton, - showPinButton: showPinButton ?? this.showPinButton, - showPinHighlight: showPinHighlight ?? this.showPinHighlight, - showMarkUnreadMessage: - showMarkUnreadMessage ?? this.showMarkUnreadMessage, - attachmentBuilders: attachmentBuilders ?? this.attachmentBuilders, - translateUserAvatar: translateUserAvatar ?? this.translateUserAvatar, - onQuotedMessageTap: onQuotedMessageTap ?? this.onQuotedMessageTap, - onMessageTap: onMessageTap ?? this.onMessageTap, - onMessageLongPress: onMessageLongPress ?? this.onMessageLongPress, - onReactionsTap: onReactionsTap ?? this.onReactionsTap, - onReactionsHover: onReactionsHover ?? this.onReactionsHover, - customActions: customActions ?? this.customActions, - onAttachmentTap: onAttachmentTap ?? this.onAttachmentTap, - userAvatarBuilder: userAvatarBuilder ?? this.userAvatarBuilder, - imageAttachmentThumbnailSize: - imageAttachmentThumbnailSize ?? this.imageAttachmentThumbnailSize, - imageAttachmentThumbnailResizeType: imageAttachmentThumbnailResizeType ?? - this.imageAttachmentThumbnailResizeType, - imageAttachmentThumbnailCropType: imageAttachmentThumbnailCropType ?? - this.imageAttachmentThumbnailCropType, - attachmentActionsModalBuilder: - attachmentActionsModalBuilder ?? this.attachmentActionsModalBuilder, - ); - } - - @override - _StreamMessageWidgetState createState() => _StreamMessageWidgetState(); -} - -class _StreamMessageWidgetState extends State - with AutomaticKeepAliveClientMixin { - bool get showThreadReplyIndicator => widget.showThreadReplyIndicator; - - bool get showSendingIndicator => widget.showSendingIndicator; - - bool get isDeleted => widget.message.isDeleted; - - bool get showUsername => widget.showUsername; - - bool get showTimeStamp => widget.showTimestamp; - - bool get showEditedLabel => widget.showEditedLabel; - - bool get isTextEdited => widget.message.messageTextUpdatedAt != null; - - bool get showInChannel => widget.showInChannelIndicator; - - /// {@template hasQuotedMessage} - /// `true` if [StreamMessageWidget.quotedMessage] is not null. - /// {@endtemplate} - bool get hasQuotedMessage => widget.message.quotedMessage != null; - - bool get isSendFailed => widget.message.state.isSendingFailed; - - bool get isUpdateFailed => widget.message.state.isUpdatingFailed; - - bool get isDeleteFailed => widget.message.state.isDeletingFailed; - - bool get isBouncedWithError => widget.message.isBouncedWithError; - - /// {@template isFailedState} - /// Whether the message has failed to be sent, updated, deleted or is bounced - /// back with the message type as error. - /// {@endtemplate} - bool get isFailedState => - isSendFailed || isUpdateFailed || isDeleteFailed || isBouncedWithError; - - /// {@template isGiphy} - /// `true` if any of the [message]'s attachments are a giphy. - /// {@endtemplate} - bool get isGiphy => widget.message.attachments - .any((element) => element.type == AttachmentType.giphy); - - /// {@template isOnlyEmoji} - /// `true` if [message.text] contains only emoji. - /// {@endtemplate} - bool get isOnlyEmoji => widget.message.text?.isOnlyEmoji == true; - - /// {@template hasNonUrlAttachments} - /// `true` if any of the [message]'s attachments are a giphy and do not - /// have a [Attachment.titleLink]. - /// {@endtemplate} - bool get hasNonUrlAttachments => widget.message.attachments - .any((it) => it.type != AttachmentType.urlPreview); - - /// {@template hasPoll} - /// `true` if the [message] contains a poll. - /// {@endtemplate} - bool get hasPoll => widget.message.poll != null; - - /// {@template hasUrlAttachments} - /// `true` if any of the [message]'s attachments are a giphy with a - /// [Attachment.titleLink]. - /// {@endtemplate} - bool get hasUrlAttachments => widget.message.attachments - .any((it) => it.type == AttachmentType.urlPreview); - - /// {@template showBottomRow} - /// Show the [BottomRow] widget if any of the following are `true`: - /// * [StreamMessageWidget.showThreadReplyIndicator] - /// * [StreamMessageWidget.showUsername] - /// * [StreamMessageWidget.showTimestamp] - /// * [StreamMessageWidget.showInChannelIndicator] - /// * [StreamMessageWidget.showSendingIndicator] - /// * [StreamMessageWidget.message.isDeleted] - /// {@endtemplate} - bool get showBottomRow => - showThreadReplyIndicator || - showUsername || - showTimeStamp || - showInChannel || - showSendingIndicator || - isTextEdited; - - /// {@template isPinned} - /// Whether [StreamMessageWidget.message] is pinned or not. - /// {@endtemplate} - bool get isPinned => widget.message.pinned && !widget.message.isDeleted; - - /// {@template shouldShowReactions} - /// Should show message reactions if [StreamMessageWidget.showReactions] is - /// `true`, if there are reactions to show, and if the message is not deleted. - /// {@endtemplate} - bool get shouldShowReactions => - widget.showReactions && - (widget.message.latestReactions?.isNotEmpty == true) && - !widget.message.isDeleted; - - bool get shouldShowReplyAction => - widget.showReplyMessage && !isFailedState && widget.onReplyTap != null; - - bool get shouldShowEditAction => - widget.showEditMessage && - !isDeleteFailed && - !hasPoll && - !widget.message.attachments - .any((element) => element.type == AttachmentType.giphy); - - bool get shouldShowResendAction => - widget.showResendMessage && (isSendFailed || isUpdateFailed); - - bool get shouldShowCopyAction => - widget.showCopyMessage && - !isFailedState && - widget.message.text?.trim().isNotEmpty == true; - - bool get shouldShowThreadReplyAction => - widget.showThreadReplyMessage && - !isFailedState && - widget.onThreadTap != null; - - bool get shouldShowDeleteAction => widget.showDeleteMessage || isDeleteFailed; - - @override - bool get wantKeepAlive => widget.message.attachments.isNotEmpty; - - late StreamChatThemeData _streamChatTheme; - late StreamChatState _streamChat; - - @override - void didChangeDependencies() { - super.didChangeDependencies(); - _streamChatTheme = StreamChatTheme.of(context); - _streamChat = StreamChat.of(context); - } - - @override - Widget build(BuildContext context) { - super.build(context); - final avatarWidth = - widget.messageTheme.avatarTheme?.constraints.maxWidth ?? 40; - final bottomRowPadding = - widget.showUserAvatar != DisplayWidget.gone ? avatarWidth + 8.5 : 0.5; - - final showReactions = shouldShowReactions; - - return ConditionalParentBuilder( - builder: (context, child) { - final message = widget.message; - - // If the message is deleted or not yet sent, we don't want to show any - // context menu actions. - if (message.state.isDeleted || message.state.isOutgoing) return child; - - final menuItems = _buildDesktopOrWebActions(context, message); - if (menuItems.isEmpty) return child; - - return ContextMenuRegion( - contextMenuBuilder: (_, anchor) => ContextMenu( - anchor: anchor, - menuItems: menuItems, - ), - child: child, - ); - }, - child: Material( - type: MaterialType.transparency, - child: AnimatedContainer( - duration: const Duration(seconds: 1), - color: isPinned && widget.showPinHighlight - ? _streamChatTheme.colorTheme.highlight - // ignore: deprecated_member_use - : _streamChatTheme.colorTheme.barsBg.withOpacity(0), - child: Portal( - child: PlatformWidgetBuilder( - mobile: (context, child) { - final message = widget.message; - return InkWell( - onTap: switch (widget.onMessageTap) { - final onTap? => () => onTap(message), - _ => null, - }, - onLongPress: switch (widget.onMessageLongPress) { - final onLongPress? => () => onLongPress(message), - // If the message is not yet sent or deleted, we don't want - // to handle long press events by default. - _ when message.state.isDeleted => null, - _ when message.state.isOutgoing => null, - _ => () => _onMessageLongPressed(context, message), - }, - child: child, - ); - }, - desktop: (_, child) => MouseRegion(child: child), - web: (_, child) => MouseRegion(child: child), - child: Padding( - padding: widget.padding ?? const EdgeInsets.all(8), - child: FractionallySizedBox( - alignment: widget.reverse - ? Alignment.centerRight - : Alignment.centerLeft, - widthFactor: widget.widthFactor, - child: Builder(builder: (context) { - return MessageWidgetContent( - streamChatTheme: _streamChatTheme, - showUsername: showUsername, - showTimeStamp: showTimeStamp, - showEditedLabel: showEditedLabel, - showThreadReplyIndicator: showThreadReplyIndicator, - showSendingIndicator: showSendingIndicator, - showInChannel: showInChannel, - isGiphy: isGiphy, - isOnlyEmoji: isOnlyEmoji, - hasUrlAttachments: hasUrlAttachments, - messageTheme: widget.messageTheme, - reverse: widget.reverse, - message: widget.message, - hasNonUrlAttachments: hasNonUrlAttachments, - hasPoll: hasPoll, - hasQuotedMessage: hasQuotedMessage, - textPadding: widget.textPadding, - attachmentBuilders: widget.attachmentBuilders, - attachmentPadding: widget.attachmentPadding, - attachmentShape: widget.attachmentShape, - onAttachmentTap: widget.onAttachmentTap, - onReplyTap: widget.onReplyTap, - onThreadTap: widget.onThreadTap, - onShowMessage: widget.onShowMessage, - attachmentActionsModalBuilder: - widget.attachmentActionsModalBuilder, - avatarWidth: avatarWidth, - bottomRowPadding: bottomRowPadding, - isFailedState: isFailedState, - isPinned: isPinned, - messageWidget: widget, - showBottomRow: showBottomRow, - showPinHighlight: widget.showPinHighlight, - showReactionPickerTail: calculateReactionTailEnabled( - ReactionTailType.list, - ), - showReactions: showReactions, - onReactionsTap: () { - final message = widget.message; - return switch (widget.onReactionsTap) { - final onReactionsTap? => onReactionsTap(message), - _ => _showMessageReactionsModal(context, message), - }; - }, - onReactionsHover: widget.onReactionsHover, - showUserAvatar: widget.showUserAvatar, - streamChat: _streamChat, - translateUserAvatar: widget.translateUserAvatar, - shape: widget.shape, - borderSide: widget.borderSide, - borderRadiusGeometry: widget.borderRadiusGeometry, - textBuilder: widget.textBuilder, - quotedMessageBuilder: widget.quotedMessageBuilder, - onLinkTap: widget.onLinkTap, - onMentionTap: widget.onMentionTap, - onQuotedMessageTap: widget.onQuotedMessageTap, - bottomRowBuilderWithDefaultWidget: - widget.bottomRowBuilderWithDefaultWidget, - onUserAvatarTap: widget.onUserAvatarTap, - userAvatarBuilder: widget.userAvatarBuilder, - ); - }), - ), - ), - ), - ), - ), - ), - ); - } - - List _buildDesktopOrWebActions( - BuildContext context, - Message message, - ) { - if (isBouncedWithError) { - return _buildBouncedErrorMessageDesktopOrWebActions(context, message); - } - - return _buildMessageDesktopOrWebActions(context, message); - } - - List _buildBouncedErrorMessageDesktopOrWebActions( - BuildContext context, - Message message, - ) { - final theme = StreamChatTheme.of(context); - final channel = StreamChannel.of(context).channel; - - return [ - StreamChatContextMenuItem( - leading: StreamSvgIcon( - icon: StreamSvgIcons.circleUp, - color: theme.colorTheme.accentPrimary, - ), - title: Text(context.translations.sendAnywayLabel), - onClick: () { - Navigator.of(context, rootNavigator: true).pop(); - channel.sendMessage(message).ignore(); - }, - ), - StreamChatContextMenuItem( - leading: const StreamSvgIcon(icon: StreamSvgIcons.edit), - title: Text(context.translations.editMessageLabel), - onClick: () { - Navigator.of(context, rootNavigator: true).pop(); - showEditMessageSheet( - context: context, - channel: channel, - message: message, - editMessageInputBuilder: widget.editMessageInputBuilder, - ); - }, - ), - StreamChatContextMenuItem( - leading: StreamSvgIcon( - icon: StreamSvgIcons.delete, - color: theme.colorTheme.accentError, - ), - title: Text( - context.translations.deleteMessageLabel, - style: TextStyle(color: theme.colorTheme.accentError), - ), - onClick: () { - Navigator.of(context, rootNavigator: true).pop(); - channel.deleteMessage(message, hard: true).ignore(); - }, - ), - ]; - } - - List _buildMessageDesktopOrWebActions( - BuildContext context, - Message message, - ) { - final theme = StreamChatTheme.of(context); - final channel = StreamChannel.of(context).channel; - - return [ - if (widget.showReactionPicker) - StreamChatContextMenuItem( - child: StreamChannel( - channel: channel, - child: ContextMenuReactionPicker(message: message), - ), - ), - if (shouldShowReplyAction) ...[ - StreamChatContextMenuItem( - leading: const StreamSvgIcon(icon: StreamSvgIcons.reply), - title: Text(context.translations.replyLabel), - onClick: () { - Navigator.of(context, rootNavigator: true).pop(); - widget.onReplyTap?.call(message); - }, - ), - ], - if (widget.showMarkUnreadMessage) - StreamChatContextMenuItem( - leading: const StreamSvgIcon(icon: StreamSvgIcons.messageUnread), - title: Text(context.translations.markAsUnreadLabel), - onClick: () async { - Navigator.of(context, rootNavigator: true).pop(); - try { - await channel.markUnread(message.id); - } catch (ex) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text( - context.translations.markUnreadError, - ), - ), - ); - } - }, - ), - if (shouldShowThreadReplyAction) - StreamChatContextMenuItem( - leading: const StreamSvgIcon(icon: StreamSvgIcons.threadReply), - title: Text(context.translations.threadReplyLabel), - onClick: () { - Navigator.of(context, rootNavigator: true).pop(); - widget.onThreadTap?.call(message); - }, - ), - if (shouldShowCopyAction) - StreamChatContextMenuItem( - leading: const StreamSvgIcon(icon: StreamSvgIcons.copy), - title: Text(context.translations.copyMessageLabel), - onClick: () { - Navigator.of(context, rootNavigator: true).pop(); - final copiedMessage = message.replaceMentions(linkify: false); - if (copiedMessage.text case final text?) { - Clipboard.setData(ClipboardData(text: text)); - } - }, - ), - if (shouldShowEditAction) ...[ - StreamChatContextMenuItem( - leading: const StreamSvgIcon(icon: StreamSvgIcons.edit), - title: Text(context.translations.editMessageLabel), - onClick: () { - Navigator.of(context, rootNavigator: true).pop(); - showEditMessageSheet( - context: context, - channel: channel, - message: message, - editMessageInputBuilder: widget.editMessageInputBuilder, - ); - }, - ), - ], - if (widget.showPinButton) - StreamChatContextMenuItem( - leading: const StreamSvgIcon(icon: StreamSvgIcons.pin), - title: Text( - context.translations.togglePinUnpinText(pinned: isPinned), - ), - onClick: () async { - Navigator.of(context, rootNavigator: true).pop(); - try { - await switch (isPinned) { - true => channel.unpinMessage(message), - false => channel.pinMessage(message), - }; - } catch (e) { - throw Exception(e); - } - }, - ), - if (shouldShowResendAction) - StreamChatContextMenuItem( - leading: const StreamSvgIcon(icon: StreamSvgIcons.sendMessage), - title: Text( - context.translations.toggleResendOrResendEditedMessage( - isUpdateFailed: message.state.isUpdatingFailed, - ), - ), - onClick: () async { - Navigator.of(context, rootNavigator: true).pop(); - await channel.retryMessage(message); - }, - ), - if (shouldShowDeleteAction) - StreamChatContextMenuItem( - leading: StreamSvgIcon( - color: theme.colorTheme.accentError, - icon: StreamSvgIcons.delete, - ), - title: Text( - context.translations.deleteMessageLabel, - style: TextStyle(color: theme.colorTheme.accentError), - ), - onClick: () async { - Navigator.of(context, rootNavigator: true).pop(); - final deleted = await showDialog( - context: context, - barrierDismissible: false, - builder: (_) => const DeleteMessageDialog(), - ); - if (deleted == true) { - try { - await switch (widget.onConfirmDeleteTap) { - final onConfirmDeleteTap? => onConfirmDeleteTap(message), - _ => channel.deleteMessage(message), - }; - } catch (e) { - showDialog( - context: context, - builder: (_) => const MessageDialog(), - ); - } - } - }, - ), - ...widget.customActions.map( - (e) => StreamChatContextMenuItem( - leading: e.leading, - title: e.title, - onClick: () => e.onTap?.call(message), - ), - ), - ]; - } - - void _showMessageReactionsModal( - BuildContext context, - Message message, - ) { - final channel = StreamChannel.of(context).channel; - - showDialog( - useRootNavigator: false, - context: context, - useSafeArea: false, - barrierColor: _streamChatTheme.colorTheme.overlay, - builder: (context) => StreamChannel( - channel: channel, - child: StreamMessageReactionsModal( - message: message, - showReactionPicker: widget.showReactionPicker, - messageWidget: widget.copyWith( - key: const Key('MessageWidget'), - message: message.copyWith( - text: (message.text?.length ?? 0) > 200 - ? '${message.text!.substring(0, 200)}...' - : message.text, - ), - showReactions: false, - showReactionTail: calculateReactionTailEnabled( - ReactionTailType.reactions, - ), - showUsername: false, - showTimestamp: false, - translateUserAvatar: false, - showSendingIndicator: false, - padding: EdgeInsets.zero, - showReactionPicker: widget.showReactionPicker, - showPinHighlight: false, - showUserAvatar: - message.user!.id == channel.client.state.currentUser!.id - ? DisplayWidget.gone - : DisplayWidget.show, - ), - onUserAvatarTap: widget.onUserAvatarTap, - messageTheme: widget.messageTheme, - reverse: widget.reverse, - ), - ), - ); - } - - void _onMessageLongPressed( - BuildContext context, - Message message, - ) { - if (isBouncedWithError) { - return _onBouncedErrorMessageActions(context, message); - } - - return _onMessageActions(context, message); - } - - void _onBouncedErrorMessageActions( - BuildContext context, - Message message, - ) { - if (widget.onBouncedErrorMessageActions case final onActions?) { - return onActions(context, message); - } - - return _showBouncedErrorMessageActionsDialog(context, message); - } - - void _showBouncedErrorMessageActionsDialog( - BuildContext context, - Message message, - ) { - final channel = StreamChannel.of(context).channel; - - showDialog( - context: context, - builder: (context) { - return ModeratedMessageActionsModal( - onSendAnyway: () { - Navigator.of(context).pop(); - channel.sendMessage(widget.message).ignore(); - }, - onEditMessage: () { - Navigator.of(context).pop(); - showEditMessageSheet( - context: context, - channel: channel, - message: widget.message, - editMessageInputBuilder: widget.editMessageInputBuilder, - ); - }, - onDeleteMessage: () { - Navigator.of(context).pop(); - channel.deleteMessage(message, hard: true).ignore(); - }, - ); - }, - ); - } - - void _onMessageActions( - BuildContext context, - Message message, - ) { - if (widget.onMessageActions case final onActions?) { - return onActions(context, message); - } - - return _showMessageActionModalDialog(context, message); - } - - void _showMessageActionModalDialog( - BuildContext context, - Message message, - ) { - final channel = StreamChannel.of(context).channel; - - showDialog( - useRootNavigator: false, - context: context, - useSafeArea: false, - barrierColor: _streamChatTheme.colorTheme.overlay, - builder: (context) { - return StreamChannel( - channel: channel, - child: MessageActionsModal( - message: message, - messageWidget: widget.copyWith( - key: const Key('MessageWidget'), - message: message.copyWith( - text: (message.text?.length ?? 0) > 200 - ? '${message.text!.substring(0, 200)}...' - : message.text, - ), - showReactions: false, - showReactionTail: calculateReactionTailEnabled( - ReactionTailType.messageActions, - ), - showUsername: false, - showTimestamp: false, - translateUserAvatar: false, - showSendingIndicator: false, - padding: EdgeInsets.zero, - showPinHighlight: false, - showUserAvatar: - message.user!.id == channel.client.state.currentUser!.id - ? DisplayWidget.gone - : DisplayWidget.show, - ), - onEditMessageTap: (message) { - Navigator.of(context).pop(); - showEditMessageSheet( - context: context, - channel: channel, - message: message, - editMessageInputBuilder: widget.editMessageInputBuilder, - ); - }, - onCopyTap: (message) { - Navigator.of(context).pop(); - final copiedMessage = message.replaceMentions(linkify: false); - if (copiedMessage.text case final text?) { - Clipboard.setData(ClipboardData(text: text)); - } - }, - messageTheme: widget.messageTheme, - reverse: widget.reverse, - showDeleteMessage: shouldShowDeleteAction, - onConfirmDeleteTap: widget.onConfirmDeleteTap, - editMessageInputBuilder: widget.editMessageInputBuilder, - onReplyTap: widget.onReplyTap, - onThreadReplyTap: widget.onThreadTap, - showResendMessage: shouldShowResendAction, - showCopyMessage: shouldShowCopyAction, - showEditMessage: shouldShowEditAction, - showReactionPicker: widget.showReactionPicker, - showReplyMessage: shouldShowReplyAction, - showThreadReplyMessage: shouldShowThreadReplyAction, - showFlagButton: widget.showFlagButton, - showPinButton: widget.showPinButton, - showMarkUnreadMessage: widget.showMarkUnreadMessage, - customActions: widget.customActions, - ), - ); - }, - ); - } - - /// Calculates if the reaction picker tail should be enabled. - bool calculateReactionTailEnabled(ReactionTailType type) { - if (widget.showReactionTail != null) return widget.showReactionTail!; - - switch (type) { - case ReactionTailType.list: - return false; - case ReactionTailType.messageActions: - return widget.showReactionPicker; - case ReactionTailType.reactions: - return widget.showReactionPicker; - } - } -} - -/// Enum for declaring the location of the message for which the reaction picker -/// is to be enabled. -enum ReactionTailType { - /// Message is in the [StreamMessageListView] - list, - - /// Message is in the [MessageActionsModal] - messageActions, - - /// Message is in the message reactions modal - reactions, -} diff --git a/packages/stream_chat_flutter/lib/src/message_widget/message_widget_content.dart b/packages/stream_chat_flutter/lib/src/message_widget/message_widget_content.dart deleted file mode 100644 index a6ae651476..0000000000 --- a/packages/stream_chat_flutter/lib/src/message_widget/message_widget_content.dart +++ /dev/null @@ -1,471 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_portal/flutter_portal.dart'; -import 'package:meta/meta.dart'; -import 'package:stream_chat_flutter/src/message_widget/reactions/desktop_reactions_builder.dart'; -import 'package:stream_chat_flutter/stream_chat_flutter.dart'; - -/// Signature for the builder function that will be called when the message -/// bottom row is built. Includes the [Message]. -typedef BottomRowBuilder = Widget Function(BuildContext, Message); - -/// Signature for the builder function that will be called when the message -/// bottom row is built. Includes the [Message] and the default [BottomRow]. -typedef BottomRowBuilderWithDefaultWidget = Widget Function( - BuildContext, - Message, - BottomRow, -); - -/// {@template messageWidgetContent} -/// The main content of a [StreamMessageWidget]. -/// -/// Should not be used outside of [MessageWidget. -/// {@endtemplate} -@internal -class MessageWidgetContent extends StatelessWidget { - /// {@macro messageWidgetContent} - const MessageWidgetContent({ - super.key, - required this.reverse, - required this.isPinned, - required this.showPinHighlight, - required this.showBottomRow, - required this.message, - required this.showUserAvatar, - required this.avatarWidth, - required this.showReactions, - required this.onReactionsTap, - required this.onReactionsHover, - required this.messageTheme, - required this.streamChatTheme, - required this.isFailedState, - required this.hasQuotedMessage, - required this.hasUrlAttachments, - required this.hasNonUrlAttachments, - required this.hasPoll, - required this.isOnlyEmoji, - required this.isGiphy, - required this.attachmentBuilders, - required this.attachmentPadding, - required this.attachmentShape, - required this.onAttachmentTap, - required this.onShowMessage, - required this.onReplyTap, - required this.attachmentActionsModalBuilder, - required this.textPadding, - required this.showReactionPickerTail, - required this.translateUserAvatar, - required this.bottomRowPadding, - required this.showInChannel, - required this.streamChat, - required this.showSendingIndicator, - required this.showThreadReplyIndicator, - required this.showTimeStamp, - required this.showUsername, - required this.showEditedLabel, - required this.messageWidget, - required this.onThreadTap, - this.onUserAvatarTap, - this.borderRadiusGeometry, - this.borderSide, - this.shape, - this.onQuotedMessageTap, - this.onMentionTap, - this.onLinkTap, - this.textBuilder, - this.quotedMessageBuilder, - this.bottomRowBuilderWithDefaultWidget, - this.userAvatarBuilder, - }); - - /// {@macro reverse} - final bool reverse; - - /// {@macro isPinned} - final bool isPinned; - - /// {@macro showPinHighlight} - final bool showPinHighlight; - - /// {@macro showBottomRow} - final bool showBottomRow; - - /// {@macro message} - final Message message; - - /// {@macro showUserAvatar} - final DisplayWidget showUserAvatar; - - /// The width of the avatar. - final double avatarWidth; - - /// {@macro showReactions} - final bool showReactions; - - /// {@macro onReactionsTap} - final VoidCallback onReactionsTap; - - /// {@macro onReactionsHover} - final OnReactionsHover? onReactionsHover; - - /// {@macro messageTheme} - final StreamMessageThemeData messageTheme; - - /// {@macro onUserAvatarTap} - final void Function(User)? onUserAvatarTap; - - /// {@macro streamChatThemeData} - final StreamChatThemeData streamChatTheme; - - /// {@macro isFailedState} - final bool isFailedState; - - /// {@macro borderRadiusGeometry} - final BorderRadiusGeometry? borderRadiusGeometry; - - /// {@macro borderSide} - final BorderSide? borderSide; - - /// {@macro shape} - final ShapeBorder? shape; - - /// {@macro hasQuotedMessage} - final bool hasQuotedMessage; - - /// {@macro hasUrlAttachments} - final bool hasUrlAttachments; - - /// {@macro hasNonUrlAttachments} - final bool hasNonUrlAttachments; - - /// {@macro hasPoll} - final bool hasPoll; - - /// {@macro isOnlyEmoji} - final bool isOnlyEmoji; - - /// {@macro isGiphy} - final bool isGiphy; - - /// {@macro attachmentBuilders} - final List? attachmentBuilders; - - /// {@macro attachmentPadding} - final EdgeInsetsGeometry attachmentPadding; - - /// {@macro attachmentShape} - final ShapeBorder? attachmentShape; - - /// {@macro onAttachmentTap} - final StreamAttachmentWidgetTapCallback? onAttachmentTap; - - /// {@macro onShowMessage} - final ShowMessageCallback? onShowMessage; - - /// {@macro onReplyTap} - final void Function(Message)? onReplyTap; - - /// {@macro onThreadTap} - final void Function(Message)? onThreadTap; - - /// {@macro attachmentActionsBuilder} - final AttachmentActionsBuilder? attachmentActionsModalBuilder; - - /// {@macro textPadding} - final EdgeInsets textPadding; - - /// {@macro onQuotedMessageTap} - final OnQuotedMessageTap? onQuotedMessageTap; - - /// {@macro onMentionTap} - final void Function(User)? onMentionTap; - - /// {@macro onLinkTap} - final void Function(String)? onLinkTap; - - /// {@macro textBuilder} - final Widget Function(BuildContext, Message)? textBuilder; - - /// {@macro quotedMessageBuilder} - final Widget Function(BuildContext, Message)? quotedMessageBuilder; - - /// {@macro showReactionPickerTail} - final bool showReactionPickerTail; - - /// {@macro translateUserAvatar} - final bool translateUserAvatar; - - /// The padding to use for this widget. - final double bottomRowPadding; - - /// {@macro bottomRowBuilderWithDefaultWidget} - final BottomRowBuilderWithDefaultWidget? bottomRowBuilderWithDefaultWidget; - - /// {@macro showInChannelIndicator} - final bool showInChannel; - - /// {@macro streamChat} - final StreamChatState streamChat; - - /// {@macro showSendingIndicator} - final bool showSendingIndicator; - - /// {@macro showThreadReplyIndicator} - final bool showThreadReplyIndicator; - - /// {@macro showTimestamp} - final bool showTimeStamp; - - /// {@macro showUsername} - final bool showUsername; - - /// {@macro showEdited} - final bool showEditedLabel; - - /// {@macro messageWidget} - final StreamMessageWidget messageWidget; - - /// {@macro userAvatarBuilder} - final Widget Function(BuildContext, User)? userAvatarBuilder; - - @override - Widget build(BuildContext context) { - return Column( - crossAxisAlignment: - reverse ? CrossAxisAlignment.end : CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - Stack( - clipBehavior: Clip.none, - alignment: reverse - ? AlignmentDirectional.bottomEnd - : AlignmentDirectional.bottomStart, - children: [ - if (showBottomRow) - Padding( - padding: EdgeInsets.only( - left: !reverse ? bottomRowPadding : 0, - right: reverse ? bottomRowPadding : 0, - bottom: isPinned && showPinHighlight ? 6.0 : 0.0, - ), - child: _buildBottomRow(context), - ), - Padding( - padding: EdgeInsets.only( - bottom: isPinned && showPinHighlight ? 8.0 : 0.0, - ), - child: Column( - crossAxisAlignment: - reverse ? CrossAxisAlignment.end : CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - if (isPinned && message.pinnedBy != null && showPinHighlight) - PinnedMessage( - pinnedBy: message.pinnedBy!, - currentUser: streamChat.currentUser!, - ), - Row( - crossAxisAlignment: CrossAxisAlignment.end, - mainAxisSize: MainAxisSize.min, - children: [ - if (!reverse && - showUserAvatar == DisplayWidget.show && - message.user != null) ...[ - UserAvatarTransform( - onUserAvatarTap: onUserAvatarTap, - userAvatarBuilder: userAvatarBuilder, - translateUserAvatar: translateUserAvatar, - messageTheme: messageTheme, - message: message, - ), - const SizedBox(width: 4), - ], - if (showUserAvatar == DisplayWidget.hide) - SizedBox(width: avatarWidth + 4), - Flexible( - child: PortalTarget( - visible: isMobileDevice && showReactions, - portalFollower: isMobileDevice && showReactions - ? ReactionIndicator( - message: message, - messageTheme: messageTheme, - ownId: streamChat.currentUser!.id, - reverse: reverse, - onTap: onReactionsTap, - ) - : null, - anchor: Aligned( - follower: Alignment( - reverse ? 1 : -1, - -1, - ), - target: Alignment( - reverse ? -1 : 1, - -1, - ), - ), - child: Stack( - clipBehavior: Clip.none, - children: [ - Padding( - padding: showReactions - ? const EdgeInsets.only(top: 18) - : EdgeInsets.zero, - child: (message.isDeleted && !isFailedState) - ? Container( - margin: EdgeInsets.symmetric( - horizontal: showUserAvatar == - DisplayWidget.gone - ? 0 - : 4.0, - ), - child: StreamDeletedMessage( - borderRadiusGeometry: - borderRadiusGeometry, - borderSide: borderSide, - shape: shape, - messageTheme: messageTheme, - ), - ) - : MessageCard( - message: message, - isFailedState: isFailedState, - showUserAvatar: showUserAvatar, - messageTheme: messageTheme, - hasQuotedMessage: hasQuotedMessage, - hasUrlAttachments: hasUrlAttachments, - hasNonUrlAttachments: - hasNonUrlAttachments, - hasPoll: hasPoll, - isOnlyEmoji: isOnlyEmoji, - isGiphy: isGiphy, - attachmentBuilders: attachmentBuilders, - attachmentPadding: attachmentPadding, - attachmentShape: attachmentShape, - onAttachmentTap: onAttachmentTap, - onReplyTap: onReplyTap, - onShowMessage: onShowMessage, - attachmentActionsModalBuilder: - attachmentActionsModalBuilder, - textPadding: textPadding, - reverse: reverse, - onQuotedMessageTap: onQuotedMessageTap, - onMentionTap: onMentionTap, - onLinkTap: onLinkTap, - textBuilder: textBuilder, - quotedMessageBuilder: - quotedMessageBuilder, - borderRadiusGeometry: - borderRadiusGeometry, - borderSide: borderSide, - shape: shape, - ), - ), - // TODO: Make tail part of the Reaction Picker. - if (showReactionPickerTail) - Positioned( - right: reverse ? null : 4, - left: reverse ? 4 : null, - top: -8, - child: CustomPaint( - painter: ReactionBubblePainter( - streamChatTheme.colorTheme.barsBg, - Colors.transparent, - Colors.transparent, - tailCirclesSpace: 1, - flipTail: !reverse, - ), - ), - ), - ], - ), - ), - ), - if (reverse && - showUserAvatar == DisplayWidget.show && - message.user != null) ...[ - UserAvatarTransform( - onUserAvatarTap: onUserAvatarTap, - userAvatarBuilder: userAvatarBuilder, - translateUserAvatar: translateUserAvatar, - messageTheme: messageTheme, - message: message, - ), - const SizedBox(width: 4), - ], - if (showUserAvatar == DisplayWidget.hide) - SizedBox(width: avatarWidth + 4), - ], - ), - if (isDesktopDeviceOrWeb && showReactions) ...[ - Padding( - padding: showUserAvatar != DisplayWidget.gone - ? EdgeInsets.only( - left: avatarWidth + 4, - right: avatarWidth + 4, - ) - : EdgeInsets.zero, - child: DesktopReactionsBuilder( - message: message, - messageTheme: messageTheme, - onHover: onReactionsHover, - borderSide: borderSide, - reverse: reverse, - ), - ), - ], - if (showBottomRow) - SizedBox( - height: context.textScaleFactor * 18.0, - ), - ], - ), - ), - if (isFailedState) - Positioned( - right: reverse ? 0 : null, - left: reverse ? null : 0, - bottom: showBottomRow ? 18 : -2, - child: StreamSvgIcon( - icon: StreamSvgIcons.error, - color: streamChatTheme.colorTheme.accentError, - ), - ), - ], - ), - ], - ); - } - - Widget _buildBottomRow(BuildContext context) { - final defaultWidget = BottomRow( - onThreadTap: onThreadTap, - message: message, - reverse: reverse, - messageTheme: messageTheme, - hasUrlAttachments: hasUrlAttachments, - isOnlyEmoji: isOnlyEmoji, - isDeleted: message.isDeleted, - isGiphy: isGiphy, - showInChannel: showInChannel, - showSendingIndicator: showSendingIndicator, - showThreadReplyIndicator: showThreadReplyIndicator, - showTimeStamp: showTimeStamp, - showUsername: showUsername, - showEditedLabel: showEditedLabel, - streamChatTheme: streamChatTheme, - streamChat: streamChat, - hasNonUrlAttachments: hasNonUrlAttachments, - ); - - if (bottomRowBuilderWithDefaultWidget != null) { - return bottomRowBuilderWithDefaultWidget!( - context, - message, - defaultWidget, - ); - } - - return defaultWidget; - } -} diff --git a/packages/stream_chat_flutter/lib/src/message_widget/message_widget_content_components.dart b/packages/stream_chat_flutter/lib/src/message_widget/message_widget_content_components.dart deleted file mode 100644 index ccbd8a0e8a..0000000000 --- a/packages/stream_chat_flutter/lib/src/message_widget/message_widget_content_components.dart +++ /dev/null @@ -1,9 +0,0 @@ -export 'bottom_row.dart'; -export 'message_card.dart'; -export 'parse_attachments.dart'; -export 'pinned_message.dart'; -export 'quoted_message.dart'; -export 'reactions/message_reactions_modal.dart'; -export 'reactions/reaction_bubble.dart'; -export 'reactions/reaction_indicator.dart'; -export 'user_avatar_transform.dart'; diff --git a/packages/stream_chat_flutter/lib/src/message_widget/moderated_message.dart b/packages/stream_chat_flutter/lib/src/message_widget/moderated_message.dart deleted file mode 100644 index d2ffc2e187..0000000000 --- a/packages/stream_chat_flutter/lib/src/message_widget/moderated_message.dart +++ /dev/null @@ -1,57 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:stream_chat_flutter/src/theme/stream_chat_theme.dart'; -import 'package:stream_chat_flutter/src/utils/extensions.dart'; -import 'package:stream_chat_flutter/src/utils/typedefs.dart'; -import 'package:stream_chat_flutter_core/stream_chat_flutter_core.dart'; - -/// {@template streamModeratedMessage} -/// A widget that displays a message that has been moderated. -/// -/// This widget is responsible for rendering messages that have been flagged or -/// moderated according to content policies. It displays either the original -/// message text (if available) or a default message indicating the content -/// was blocked. -/// {@endtemplate} -class StreamModeratedMessage extends StatelessWidget { - /// {@macro streamModeratedMessage} - const StreamModeratedMessage({ - super.key, - required this.message, - this.onMessageTap, - }); - - /// The message which got moderated by the system. - final Message message; - - /// The action to perform when tapping on the message. - final OnMessageTap? onMessageTap; - - @override - Widget build(BuildContext context) { - final theme = StreamChatTheme.of(context); - - final message = this.message.replaceMentions(linkify: false); - final moderatedText = switch (message.text) { - final messageText? when messageText.isNotEmpty => messageText, - _ => context.translations.moderatedMessageBlockedText, - }; - - return Material( - type: MaterialType.transparency, - child: InkWell( - onTap: switch (onMessageTap) { - final onTap? => () => onTap(message), - _ => null, - }, - child: Text( - moderatedText, - softWrap: true, - textAlign: TextAlign.center, - style: theme.textTheme.captionBold.copyWith( - color: theme.colorTheme.textLowEmphasis, - ), - ), - ), - ); - } -} diff --git a/packages/stream_chat_flutter/lib/src/message_widget/parse_attachments.dart b/packages/stream_chat_flutter/lib/src/message_widget/parse_attachments.dart deleted file mode 100644 index b32d09426b..0000000000 --- a/packages/stream_chat_flutter/lib/src/message_widget/parse_attachments.dart +++ /dev/null @@ -1,139 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:stream_chat_flutter/src/attachment/attachment_widget_catalog.dart'; -import 'package:stream_chat_flutter/stream_chat_flutter.dart'; - -/// {@template parseAttachments} -/// Parses the attachments of a [StreamMessageWidget]. -/// -/// Used in [MessageCard]. Should not be used elsewhere. -/// {@endtemplate} -class ParseAttachments extends StatelessWidget { - /// {@macro parseAttachments} - const ParseAttachments({ - super.key, - required this.message, - required this.attachmentBuilders, - required this.attachmentPadding, - this.attachmentShape, - this.onAttachmentTap, - this.onShowMessage, - this.onReplyTap, - this.attachmentActionsModalBuilder, - }); - - /// {@macro message} - final Message message; - - /// {@macro attachmentBuilders} - final List? attachmentBuilders; - - /// {@macro attachmentPadding} - final EdgeInsetsGeometry attachmentPadding; - - /// {@macro attachmentShape} - final ShapeBorder? attachmentShape; - - /// {@macro onAttachmentTap} - final StreamAttachmentWidgetTapCallback? onAttachmentTap; - - /// {@macro onShowMessage} - final ShowMessageCallback? onShowMessage; - - /// {@macro onReplyTap} - final void Function(Message)? onReplyTap; - - /// {@macro attachmentActionsBuilder} - final AttachmentActionsBuilder? attachmentActionsModalBuilder; - - @override - Widget build(BuildContext context) { - // Create a default onAttachmentTap callback if not provided. - var onAttachmentTap = this.onAttachmentTap; - onAttachmentTap ??= (message, attachment) { - // If the current attachment is a url preview attachment, open the url - // in the browser. - final isUrlPreview = attachment.type == AttachmentType.urlPreview; - if (isUrlPreview) { - final url = attachment.ogScrapeUrl ?? ''; - launchURL(context, url); - return; - } - - final isImage = attachment.type == AttachmentType.image; - final isVideo = attachment.type == AttachmentType.video; - final isGiphy = attachment.type == AttachmentType.giphy; - - // If the current attachment is a media attachment, open the media - // attachment in full screen. - final isMedia = isImage || isVideo || isGiphy; - if (isMedia) { - final channel = StreamChannel.of(context).channel; - - final attachments = message.toAttachmentPackage( - filter: (it) { - final isImage = it.type == AttachmentType.image; - final isVideo = it.type == AttachmentType.video; - final isGiphy = it.type == AttachmentType.giphy; - return isImage || isVideo || isGiphy; - }, - ); - - Navigator.of(context).push( - MaterialPageRoute( - builder: (context) { - return StreamChannel( - channel: channel, - child: StreamFullScreenMediaBuilder( - userName: message.user!.name, - mediaAttachmentPackages: attachments, - startIndex: attachments.indexWhere( - (it) => it.attachment.id == attachment.id, - ), - onReplyMessage: onReplyTap, - onShowMessage: onShowMessage, - attachmentActionsModalBuilder: attachmentActionsModalBuilder, - ), - ); - }, - ), - ); - - return; - } - }; - - // Create a default attachmentBuilders list if not provided. - final builders = StreamAttachmentWidgetBuilder.defaultBuilders( - message: message, - shape: attachmentShape, - padding: attachmentPadding, - onAttachmentTap: onAttachmentTap, - customAttachmentBuilders: attachmentBuilders, - ); - - final catalog = AttachmentWidgetCatalog(builders: builders); - return catalog.build(context, message); - } -} - -extension on Message { - List toAttachmentPackage({ - bool Function(Attachment)? filter, - }) { - // Create a copy of the attachments list. - var attachments = [...this.attachments]; - if (filter != null) { - attachments = [...attachments.where(filter)]; - } - - // Create a list of StreamAttachmentPackage from the attachments list. - return [ - ...attachments.map((it) { - return StreamAttachmentPackage( - attachment: it, - message: this, - ); - }) - ]; - } -} diff --git a/packages/stream_chat_flutter/lib/src/message_widget/pinned_message.dart b/packages/stream_chat_flutter/lib/src/message_widget/pinned_message.dart deleted file mode 100644 index 49b9a4f219..0000000000 --- a/packages/stream_chat_flutter/lib/src/message_widget/pinned_message.dart +++ /dev/null @@ -1,52 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:stream_chat_flutter/stream_chat_flutter.dart'; - -/// {@template pinnedMessage} -/// A pinned message in a chat. -/// -/// Used in [MessageWidgetContent]. Should not be used elsewhere. -/// {@endtemplate} -class PinnedMessage extends StatelessWidget { - /// {@macro pinnedMessage} - const PinnedMessage({ - super.key, - required this.pinnedBy, - required this.currentUser, - }); - - /// The [User] who pinned this message. - final User pinnedBy; - - /// The current [User]. - final User currentUser; - - @override - Widget build(BuildContext context) { - return Padding( - padding: const EdgeInsets.only(left: 8, right: 8, top: 4, bottom: 8), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - const StreamSvgIcon( - size: 16, - icon: StreamSvgIcons.pin, - ), - const SizedBox( - width: 4, - ), - Text( - context.translations.pinnedByUserText( - pinnedBy: pinnedBy, - currentUser: currentUser, - ), - style: TextStyle( - color: StreamChatTheme.of(context).colorTheme.textLowEmphasis, - fontSize: 13, - fontWeight: FontWeight.w400, - ), - ), - ], - ), - ); - } -} diff --git a/packages/stream_chat_flutter/lib/src/message_widget/quoted_message.dart b/packages/stream_chat_flutter/lib/src/message_widget/quoted_message.dart deleted file mode 100644 index b6b7837b98..0000000000 --- a/packages/stream_chat_flutter/lib/src/message_widget/quoted_message.dart +++ /dev/null @@ -1,50 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:stream_chat_flutter/stream_chat_flutter.dart'; - -/// {@template quotedMessage} -/// A quoted message in a chat. -/// -/// Used in [QuotedMessageCard]. Should not be used elsewhere. -/// {@endtemplate} -class QuotedMessage extends StatelessWidget { - /// {@macro quotedMessage} - const QuotedMessage({ - super.key, - required this.message, - required this.hasNonUrlAttachments, - this.textBuilder, - }); - - /// {@macro message} - final Message message; - - /// {@macro hasNonUrlAttachments} - final bool hasNonUrlAttachments; - - /// {@macro textBuilder} - final Widget Function(BuildContext, Message)? textBuilder; - - @override - Widget build(BuildContext context) { - final streamChat = StreamChat.of(context); - final chatThemeData = StreamChatTheme.of(context); - - final isMyMessage = message.user?.id == streamChat.currentUser?.id; - final isMyQuotedMessage = - message.quotedMessage?.user?.id == streamChat.currentUser?.id; - return StreamQuotedMessageWidget( - message: message.quotedMessage!, - messageTheme: isMyMessage - ? chatThemeData.otherMessageTheme - : chatThemeData.ownMessageTheme, - reverse: !isMyQuotedMessage, - textBuilder: textBuilder, - padding: EdgeInsets.only( - right: 8, - left: 8, - top: 8, - bottom: hasNonUrlAttachments ? 8 : 0, - ), - ); - } -} diff --git a/packages/stream_chat_flutter/lib/src/message_widget/reactions/desktop_reactions_builder.dart b/packages/stream_chat_flutter/lib/src/message_widget/reactions/desktop_reactions_builder.dart deleted file mode 100644 index 753341083b..0000000000 --- a/packages/stream_chat_flutter/lib/src/message_widget/reactions/desktop_reactions_builder.dart +++ /dev/null @@ -1,254 +0,0 @@ -// ignore_for_file: cascade_invocations - -import 'package:collection/collection.dart'; -import 'package:flutter/foundation.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_portal/flutter_portal.dart'; -import 'package:stream_chat_flutter/src/message_widget/reactions/reactions_card.dart'; -import 'package:stream_chat_flutter/stream_chat_flutter.dart'; - -/// {@template desktopReactionsBuilder} -/// Builds a list of reactions to a message on desktop & web. -/// -/// Not intended for use outside of [MessageWidgetContent]. -/// {@endtemplate} -class DesktopReactionsBuilder extends StatefulWidget { - /// {@macro desktopReactionsBuilder} - const DesktopReactionsBuilder({ - super.key, - required this.message, - required this.messageTheme, - this.onHover, - this.borderSide, - required this.reverse, - }); - - /// The message to show reactions for. - final Message message; - - /// The theme to use for the reactions. - /// - /// [StreamMessageThemeData] is used because the design spec for desktop - /// reactions matches the design spec for messages. - final StreamMessageThemeData messageTheme; - - /// Callback to run when the mouse enters or exits the reactions. - final OnReactionsHover? onHover; - - /// {@macro borderSide} - final BorderSide? borderSide; - - /// {@macro reverse} - final bool reverse; - - @override - State createState() => - _DesktopReactionsBuilderState(); - - @override - void debugFillProperties(DiagnosticPropertiesBuilder properties) { - super.debugFillProperties(properties); - properties.add( - DiagnosticsProperty('message', message), - ); - properties.add( - DiagnosticsProperty( - 'messageTheme', - messageTheme, - ), - ); - properties.add( - DiagnosticsProperty('borderSide', borderSide), - ); - properties.add(DiagnosticsProperty('reverse', reverse)); - } -} - -class _DesktopReactionsBuilderState extends State { - bool _showReactionsPopup = false; - - @override - Widget build(BuildContext context) { - final streamChat = StreamChat.of(context); - final currentUser = streamChat.currentUser!; - final reactionIcons = StreamChatConfiguration.of(context).reactionIcons; - final streamChatTheme = StreamChatTheme.of(context); - - final reactionsMap = {}; - widget.message.latestReactions?.forEach((element) { - if (!reactionsMap.containsKey(element.type) || - element.user!.id == currentUser.id) { - reactionsMap[element.type] = element; - } - }); - - final reactionsList = reactionsMap.values.toList() - ..sort((a, b) => a.user!.id == currentUser.id ? 1 : -1); - - return PortalTarget( - visible: _showReactionsPopup, - portalCandidateLabels: const [kPortalMessageListViewLabel], - anchor: Aligned( - target: widget.reverse ? Alignment.topRight : Alignment.topLeft, - follower: widget.reverse ? Alignment.bottomRight : Alignment.bottomLeft, - shiftToWithinBound: const AxisFlag(y: true), - ), - portalFollower: MouseRegion( - onEnter: (_) => _onReactionsHover(true), - onExit: (_) => _onReactionsHover(false), - child: ConstrainedBox( - constraints: const BoxConstraints( - maxWidth: 336, - maxHeight: 342, - ), - child: ReactionsCard( - currentUser: currentUser, - message: widget.message, - messageTheme: widget.messageTheme, - ), - ), - ), - child: MouseRegion( - cursor: SystemMouseCursors.click, - onEnter: (_) => _onReactionsHover(true), - onExit: (_) => _onReactionsHover(false), - child: Padding( - padding: EdgeInsets.symmetric( - vertical: 2, - horizontal: widget.reverse ? 0 : 4, - ), - child: Wrap( - spacing: 4, - runSpacing: 4, - children: [ - ...reactionsList.map((reaction) { - final reactionIcon = reactionIcons.firstWhereOrNull( - (r) => r.type == reaction.type, - ); - - return _BottomReaction( - currentUser: currentUser, - reaction: reaction, - message: widget.message, - borderSide: widget.borderSide, - messageTheme: widget.messageTheme, - reactionIcon: reactionIcon, - streamChatTheme: streamChatTheme, - ); - }).toList(), - ], - ), - ), - ), - ); - } - - void _onReactionsHover(bool isHovering) { - if (widget.onHover != null) { - return widget.onHover!(isHovering); - } - - setState(() => _showReactionsPopup = isHovering); - } -} - -class _BottomReaction extends StatelessWidget { - const _BottomReaction({ - required this.currentUser, - required this.reaction, - required this.message, - required this.borderSide, - required this.messageTheme, - required this.reactionIcon, - required this.streamChatTheme, - }); - - final User currentUser; - final Reaction reaction; - final Message message; - final BorderSide? borderSide; - final StreamMessageThemeData? messageTheme; - final StreamReactionIcon? reactionIcon; - final StreamChatThemeData streamChatTheme; - - @override - Widget build(BuildContext context) { - final userId = currentUser.id; - - final backgroundColor = messageTheme?.reactionsBackgroundColor; - - return GestureDetector( - behavior: HitTestBehavior.opaque, - onTap: () { - if (reaction.userId == userId) { - StreamChannel.of(context).channel.deleteReaction( - message, - reaction, - ); - } else if (reactionIcon != null) { - StreamChannel.of(context).channel.sendReaction( - message, - reactionIcon!.type, - score: reaction.score + 1, - enforceUnique: - StreamChatConfiguration.of(context).enforceUniqueReactions, - ); - } - }, - child: Card( - margin: EdgeInsets.zero, - // Setting elevation as null when background color is transparent. - // This is done to avoid shadow when background color is transparent. - elevation: backgroundColor == Colors.transparent ? 0 : null, - shape: RoundedRectangleBorder( - side: borderSide ?? - BorderSide( - color: messageTheme?.reactionsBorderColor ?? Colors.transparent, - ), - borderRadius: BorderRadius.circular(10), - ), - color: backgroundColor, - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - ConstrainedBox( - constraints: BoxConstraints.tight( - const Size.square(14), - ), - child: reactionIcon?.builder( - context, - reaction.user?.id == userId, - 14, - ) ?? - Icon( - Icons.help_outline_rounded, - size: 14, - color: reaction.user?.id == userId - ? streamChatTheme.colorTheme.accentPrimary - : streamChatTheme.colorTheme.textLowEmphasis, - ), - ), - const SizedBox(width: 4), - Text( - '${reaction.score}', - style: const TextStyle( - fontSize: 10, - fontWeight: FontWeight.bold, - ), - ), - ], - ), - ), - ), - ); - } - - @override - void debugFillProperties(DiagnosticPropertiesBuilder properties) { - super.debugFillProperties(properties); - properties.add(DiagnosticsProperty('reaction', reaction)); - properties.add(DiagnosticsProperty('message', message)); - } -} diff --git a/packages/stream_chat_flutter/lib/src/message_widget/reactions/message_reactions_modal.dart b/packages/stream_chat_flutter/lib/src/message_widget/reactions/message_reactions_modal.dart deleted file mode 100644 index a3eed77874..0000000000 --- a/packages/stream_chat_flutter/lib/src/message_widget/reactions/message_reactions_modal.dart +++ /dev/null @@ -1,129 +0,0 @@ -import 'dart:ui'; - -import 'package:flutter/material.dart'; -import 'package:stream_chat_flutter/src/message_widget/reactions/reactions_align.dart'; -import 'package:stream_chat_flutter/src/message_widget/reactions/reactions_card.dart'; -import 'package:stream_chat_flutter/stream_chat_flutter.dart'; - -/// {@template streamMessageReactionsModal} -/// Modal widget for displaying message reactions -/// {@endtemplate} -class StreamMessageReactionsModal extends StatelessWidget { - /// {@macro streamMessageReactionsModal} - const StreamMessageReactionsModal({ - super.key, - required this.message, - required this.messageWidget, - required this.messageTheme, - this.showReactionPicker = true, - this.reverse = false, - this.onUserAvatarTap, - }); - - /// Widget that shows the message - final Widget messageWidget; - - /// Message to display reactions of - final Message message; - - /// [StreamMessageThemeData] to apply to [message] - final StreamMessageThemeData messageTheme; - - /// {@macro reverse} - final bool reverse; - - /// Flag for showing reaction picker. - final bool showReactionPicker; - - /// {@macro onUserAvatarTap} - final void Function(User)? onUserAvatarTap; - - @override - Widget build(BuildContext context) { - final user = StreamChat.of(context).currentUser; - final channel = StreamChannel.of(context).channel; - final orientation = MediaQuery.of(context).orientation; - final canSendReaction = channel.canSendReaction; - final fontSize = messageTheme.messageTextStyle?.fontSize; - - final child = Center( - child: SingleChildScrollView( - child: SafeArea( - child: Padding( - padding: const EdgeInsets.all(8), - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - if (showReactionPicker && canSendReaction) - LayoutBuilder( - builder: (context, constraints) { - return Align( - alignment: Alignment( - calculateReactionsHorizontalAlignment( - user, - message, - constraints, - fontSize, - orientation, - ), - 0, - ), - child: StreamReactionPicker( - message: message, - ), - ); - }, - ), - const SizedBox(height: 10), - IgnorePointer( - child: messageWidget, - ), - if (message.latestReactions?.isNotEmpty == true) ...[ - const SizedBox(height: 8), - ReactionsCard( - currentUser: user!, - message: message, - messageTheme: messageTheme, - ), - ], - ], - ), - ), - ), - ), - ); - - return GestureDetector( - behavior: HitTestBehavior.translucent, - onTap: () => Navigator.of(context).maybePop(), - child: Stack( - children: [ - Positioned.fill( - child: BackdropFilter( - filter: ImageFilter.blur( - sigmaX: 10, - sigmaY: 10, - ), - child: DecoratedBox( - decoration: BoxDecoration( - color: StreamChatTheme.of(context).colorTheme.overlay, - ), - ), - ), - ), - TweenAnimationBuilder( - tween: Tween(begin: 0, end: 1), - duration: const Duration(milliseconds: 300), - curve: Curves.easeInOutBack, - builder: (context, val, widget) => Transform.scale( - scale: val, - child: widget, - ), - child: child, - ), - ], - ), - ); - } -} diff --git a/packages/stream_chat_flutter/lib/src/message_widget/reactions/reaction_bubble.dart b/packages/stream_chat_flutter/lib/src/message_widget/reactions/reaction_bubble.dart deleted file mode 100644 index 82e5b34d02..0000000000 --- a/packages/stream_chat_flutter/lib/src/message_widget/reactions/reaction_bubble.dart +++ /dev/null @@ -1,335 +0,0 @@ -import 'dart:math'; - -import 'package:collection/collection.dart' show IterableExtension; -import 'package:flutter/material.dart'; -import 'package:stream_chat_flutter/stream_chat_flutter.dart'; - -/// {@template streamReactionBubble} -/// Creates a reaction bubble that displays over messages. -/// {@endtemplate} -class StreamReactionBubble extends StatelessWidget { - /// {@macro streamReactionBubble} - const StreamReactionBubble({ - super.key, - required this.reactions, - required this.borderColor, - required this.backgroundColor, - required this.maskColor, - this.reverse = false, - this.flipTail = false, - this.highlightOwnReactions = true, - this.tailCirclesSpacing = 0, - }); - - /// Reactions to show - final List reactions; - - /// Border color of bubble - final Color borderColor; - - /// Background color of bubble - final Color backgroundColor; - - /// Mask color - final Color maskColor; - - /// Reverse for other side - final bool reverse; - - /// Reverse tail for other side - final bool flipTail; - - /// Flag for highlighting own reactions - final bool highlightOwnReactions; - - /// Spacing for tail circles - final double tailCirclesSpacing; - - @override - Widget build(BuildContext context) { - final reactionIcons = StreamChatConfiguration.of(context).reactionIcons; - final totalReactions = reactions.length; - final offset = - totalReactions > 1 ? 16.0.mirrorConditionally(flipTail) : 2.0; - return Stack( - alignment: Alignment.center, - children: [ - Transform.translate( - offset: Offset(-offset, 0), - child: Container( - padding: const EdgeInsets.all(2), - decoration: BoxDecoration( - color: maskColor, - borderRadius: const BorderRadius.all(Radius.circular(16)), - ), - child: Container( - padding: EdgeInsets.symmetric( - vertical: 4, - horizontal: totalReactions > 1 ? 4.0 : 0, - ), - decoration: BoxDecoration( - border: Border.all( - color: borderColor, - ), - color: backgroundColor, - borderRadius: const BorderRadius.all(Radius.circular(14)), - ), - child: LayoutBuilder( - builder: (context, constraints) => Flex( - direction: Axis.horizontal, - mainAxisSize: MainAxisSize.min, - children: [ - if (constraints.maxWidth < double.infinity) - ...reactions - .take((constraints.maxWidth) ~/ 24) - .map((reaction) => _buildReaction( - reactionIcons, - reaction, - context, - )) - .toList(), - if (constraints.maxWidth == double.infinity) - ...reactions - .map((reaction) => _buildReaction( - reactionIcons, - reaction, - context, - )) - .toList(), - ], - ), - ), - ), - ), - ), - Positioned( - bottom: 2, - left: reverse ? null : 13, - right: reverse ? 13 : null, - child: _buildReactionsTail(context), - ), - ], - ); - } - - Widget _buildReaction( - List reactionIcons, - Reaction reaction, - BuildContext context, - ) { - final reactionIcon = reactionIcons.firstWhereOrNull( - (r) => r.type == reaction.type, - ); - - final chatThemeData = StreamChatTheme.of(context); - final userId = StreamChat.of(context).currentUser?.id; - return Padding( - padding: const EdgeInsets.symmetric(horizontal: 4), - child: reactionIcon != null - ? ConstrainedBox( - constraints: BoxConstraints.tight(const Size.square(14)), - child: reactionIcon.builder( - context, - highlightOwnReactions && reaction.user?.id == userId, - 16, - ), - ) - : Icon( - Icons.help_outline_rounded, - size: 14, - color: (highlightOwnReactions && reaction.user?.id == userId) - ? chatThemeData.colorTheme.accentPrimary - : chatThemeData.colorTheme.textLowEmphasis, - ), - ); - } - - Widget _buildReactionsTail(BuildContext context) { - final tail = CustomPaint( - painter: ReactionBubblePainter( - backgroundColor, - borderColor, - maskColor, - tailCirclesSpace: tailCirclesSpacing, - flipTail: !flipTail, - numberOfReactions: reactions.length, - ), - ); - return tail; - } -} - -/// Painter widget for a reaction bubble -class ReactionBubblePainter extends CustomPainter { - /// Constructor for creating a [ReactionBubblePainter] - ReactionBubblePainter( - this.color, - this.borderColor, - this.maskColor, { - this.tailCirclesSpace = 0, - this.flipTail = false, - this.numberOfReactions = 0, - }); - - /// Color of bubble - final Color color; - - /// Border color of bubble - final Color borderColor; - - /// Mask color - final Color maskColor; - - /// Tail circle space - final double tailCirclesSpace; - - /// Flip tail - final bool flipTail; - - /// Number of reactions on the page - final int numberOfReactions; - - @override - void paint(Canvas canvas, Size size) { - _drawOvalMask(size, canvas); - - _drawMask(size, canvas); - - _drawOval(size, canvas); - - _drawOvalBorder(size, canvas); - - _drawArc(size, canvas); - - _drawBorder(size, canvas); - } - - void _drawOvalMask(Size size, Canvas canvas) { - final paint = Paint() - ..color = maskColor - ..style = PaintingStyle.fill; - - final path = Path() - ..addOval( - Rect.fromCircle( - center: const Offset(4, 3).mirrorConditionally(flipTail) + - Offset(tailCirclesSpace, tailCirclesSpace) - .mirrorConditionally(flipTail), - radius: 4, - ), - ); - canvas.drawPath(path, paint); - } - - void _drawOvalBorder(Size size, Canvas canvas) { - final paint = Paint() - ..color = borderColor - ..strokeWidth = 1 - ..style = PaintingStyle.stroke; - - final path = Path() - ..addOval( - Rect.fromCircle( - center: const Offset(4, 3).mirrorConditionally(flipTail) + - Offset(tailCirclesSpace, tailCirclesSpace) - .mirrorConditionally(flipTail), - radius: 2, - ), - ); - canvas.drawPath(path, paint); - } - - void _drawOval(Size size, Canvas canvas) { - final paint = Paint() - ..color = color - ..strokeWidth = 1; - - final path = Path() - ..addOval(Rect.fromCircle( - center: const Offset(4, 3).mirrorConditionally(flipTail) + - Offset(tailCirclesSpace, tailCirclesSpace) - .mirrorConditionally(flipTail), - radius: 2, - )); - canvas.drawPath(path, paint); - } - - void _drawBorder(Size size, Canvas canvas) { - final paint = Paint() - ..color = borderColor - ..strokeWidth = 1 - ..style = PaintingStyle.stroke; - - const dy = -2.2; - final startAngle = flipTail ? -0.1 : 1.1; - final sweepAngle = flipTail ? -1.2 : (numberOfReactions > 1 ? 1.2 : 0.9); - final path = Path() - ..addArc( - Rect.fromCircle( - center: const Offset(1, dy).mirrorConditionally(flipTail), - radius: 4, - ), - -pi * startAngle, - -pi / sweepAngle, - ); - canvas.drawPath(path, paint); - } - - void _drawArc(Size size, Canvas canvas) { - final paint = Paint() - ..color = color - ..strokeWidth = 1; - - const dy = -2.2; - final startAngle = flipTail ? -0.0 : 1.0; - final sweepAngle = flipTail ? -1.3 : 1.3; - final path = Path() - ..addArc( - Rect.fromCircle( - center: const Offset(1, dy).mirrorConditionally(flipTail), - radius: 4, - ), - -pi * startAngle, - -pi * sweepAngle, - ); - canvas.drawPath(path, paint); - } - - void _drawMask(Size size, Canvas canvas) { - final paint = Paint() - ..color = maskColor - ..strokeWidth = 1 - ..style = PaintingStyle.fill; - - const dy = -2.2; - final startAngle = flipTail ? -0.1 : 1.1; - final sweepAngle = flipTail ? -1.2 : 1.2; - final path = Path() - ..addArc( - Rect.fromCircle( - center: const Offset(1, dy).mirrorConditionally(flipTail), - radius: 6, - ), - -pi * startAngle, - -pi / sweepAngle, - ); - canvas.drawPath(path, paint); - } - - @override - bool shouldRepaint(CustomPainter oldDelegate) => true; -} - -/// Extension on [Offset] -extension YTransformer on Offset { - /// Flips x coordinate when flip is true - // ignore: avoid_positional_boolean_parameters - Offset mirrorConditionally(bool flip) => Offset(flip ? -dx : dx, dy); -} - -/// Extension on [Offset] -extension IntTransformer on double { - /// Flips x coordinate when flip is true - // ignore: avoid_positional_boolean_parameters - double mirrorConditionally(bool flip) => flip ? -this : this; -} diff --git a/packages/stream_chat_flutter/lib/src/message_widget/reactions/reaction_indicator.dart b/packages/stream_chat_flutter/lib/src/message_widget/reactions/reaction_indicator.dart deleted file mode 100644 index 873d60f7a4..0000000000 --- a/packages/stream_chat_flutter/lib/src/message_widget/reactions/reaction_indicator.dart +++ /dev/null @@ -1,73 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:stream_chat_flutter/stream_chat_flutter.dart'; - -/// {@template reactionIndicator} -/// Indicates the reaction a [StreamMessageWidget] has. -/// -/// Used in [MessageWidgetContent]. -/// {@endtemplate} -class ReactionIndicator extends StatelessWidget { - /// {@macro reactionIndicator} - const ReactionIndicator({ - super.key, - required this.ownId, - required this.message, - required this.onTap, - required this.reverse, - required this.messageTheme, - }); - - /// The id of the current user. - final String ownId; - - /// {@macro message} - final Message message; - - /// The callback to perform when the widget is tapped or clicked. - final VoidCallback onTap; - - /// {@macro reverse} - final bool reverse; - - /// {@macro messageTheme} - final StreamMessageThemeData messageTheme; - - @override - Widget build(BuildContext context) { - final reactionsMap = {}; - message.latestReactions?.forEach((element) { - if (!reactionsMap.containsKey(element.type) || - element.user!.id == ownId) { - reactionsMap[element.type] = element; - } - }); - final reactionsList = reactionsMap.values.toList() - ..sort((a, b) => a.user!.id == ownId ? 1 : -1); - - return Transform( - transform: Matrix4.translationValues(reverse ? 12 : -12, 0, 0), - child: ConstrainedBox( - constraints: const BoxConstraints( - maxWidth: 22 * 6.0, - ), - child: AnimatedSwitcher( - duration: const Duration(milliseconds: 300), - child: GestureDetector( - onTap: onTap, - child: StreamReactionBubble( - key: ValueKey('${message.id}.reactions'), - reverse: reverse, - flipTail: reverse, - backgroundColor: - messageTheme.reactionsBackgroundColor ?? Colors.transparent, - borderColor: - messageTheme.reactionsBorderColor ?? Colors.transparent, - maskColor: messageTheme.reactionsMaskColor ?? Colors.transparent, - reactions: reactionsList, - ), - ), - ), - ), - ); - } -} diff --git a/packages/stream_chat_flutter/lib/src/message_widget/reactions/reaction_picker.dart b/packages/stream_chat_flutter/lib/src/message_widget/reactions/reaction_picker.dart deleted file mode 100644 index 5f61d1dfcc..0000000000 --- a/packages/stream_chat_flutter/lib/src/message_widget/reactions/reaction_picker.dart +++ /dev/null @@ -1,170 +0,0 @@ -import 'package:ezanimation/ezanimation.dart'; -import 'package:flutter/material.dart'; -import 'package:stream_chat_flutter/stream_chat_flutter.dart'; - -/// {@template streamReactionPicker} -/// ![screenshot](https://raw.githubusercontent.com/GetStream/stream-chat-flutter/master/packages/stream_chat_flutter/screenshots/reaction_picker.png) -/// ![screenshot](https://raw.githubusercontent.com/GetStream/stream-chat-flutter/master/packages/stream_chat_flutter/screenshots/reaction_picker_paint.png) -/// -/// Allows the user to select reactions to a message on mobile. -/// -/// It is not recommended to use this widget directly as it's one of the -/// default widgets used by [StreamMessageWidget.onMessageActions]. -/// {@endtemplate} -class StreamReactionPicker extends StatefulWidget { - /// {@macro streamReactionPicker} - const StreamReactionPicker({ - super.key, - required this.message, - }); - - /// Message to attach the reaction to - final Message message; - - @override - _StreamReactionPickerState createState() => _StreamReactionPickerState(); -} - -class _StreamReactionPickerState extends State - with TickerProviderStateMixin { - List animations = []; - - @override - Widget build(BuildContext context) { - final chatThemeData = StreamChatTheme.of(context); - final reactionIcons = StreamChatConfiguration.of(context).reactionIcons; - - if (animations.isEmpty && reactionIcons.isNotEmpty) { - reactionIcons.forEach((element) { - animations.add( - EzAnimation.tween( - Tween(begin: 0.0, end: 1.0), - const Duration(milliseconds: 500), - curve: Curves.easeInOutBack, - ), - ); - }); - - triggerAnimations(); - } - - final child = Material( - borderRadius: BorderRadius.circular(24), - color: chatThemeData.colorTheme.barsBg, - clipBehavior: Clip.hardEdge, - child: Padding( - padding: const EdgeInsets.symmetric( - horizontal: 16, - vertical: 8, - ), - child: Row( - spacing: 16, - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisAlignment: MainAxisAlignment.end, - mainAxisSize: MainAxisSize.min, - children: reactionIcons.map((reactionIcon) { - final ownReactionIndex = widget.message.ownReactions?.indexWhere( - (reaction) => reaction.type == reactionIcon.type, - ) ?? - -1; - final index = reactionIcons.indexOf(reactionIcon); - - final child = reactionIcon.builder( - context, - ownReactionIndex != -1, - 24, - ); - - return ConstrainedBox( - constraints: const BoxConstraints.tightFor( - height: 24, - width: 24, - ), - child: RawMaterialButton( - elevation: 0, - shape: ContinuousRectangleBorder( - borderRadius: BorderRadius.circular(16), - ), - constraints: const BoxConstraints.tightFor( - height: 24, - width: 24, - ), - onPressed: () { - if (ownReactionIndex != -1) { - removeReaction( - context, - widget.message.ownReactions![ownReactionIndex], - ); - } else { - sendReaction( - context, - reactionIcon.type, - ); - } - }, - child: AnimatedBuilder( - animation: animations[index], - builder: (context, child) => Transform.scale( - scale: animations[index].value, - child: child, - ), - child: child, - ), - ), - ); - }).toList(), - ), - ), - ); - - return TweenAnimationBuilder( - tween: Tween(begin: 0, end: 1), - curve: Curves.easeInOutBack, - duration: const Duration(milliseconds: 500), - builder: (context, val, widget) => Transform.scale( - scale: val, - child: widget, - ), - child: child, - ); - } - - Future triggerAnimations() async { - for (final a in animations) { - a.start(); - await Future.delayed(const Duration(milliseconds: 100)); - } - } - - Future pop() async { - for (final a in animations) { - a.stop(); - } - Navigator.of(context).pop(); - } - - /// Add a reaction to the message - void sendReaction(BuildContext context, String reactionType) { - StreamChannel.of(context).channel.sendReaction( - widget.message, - reactionType, - enforceUnique: - StreamChatConfiguration.of(context).enforceUniqueReactions, - ); - pop(); - } - - /// Remove a reaction from the message - void removeReaction(BuildContext context, Reaction reaction) { - StreamChannel.of(context).channel.deleteReaction(widget.message, reaction); - pop(); - } - - @override - void dispose() { - for (final a in animations) { - a.dispose(); - } - super.dispose(); - } -} diff --git a/packages/stream_chat_flutter/lib/src/message_widget/reactions/reactions_align.dart b/packages/stream_chat_flutter/lib/src/message_widget/reactions/reactions_align.dart deleted file mode 100644 index a7baad7eec..0000000000 --- a/packages/stream_chat_flutter/lib/src/message_widget/reactions/reactions_align.dart +++ /dev/null @@ -1,51 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:stream_chat_flutter/stream_chat_flutter.dart'; - -/// This method calculates the align that the modal of reactions should have. -/// This is an approximation based on the size of the message and the -/// available space in the screen. -double calculateReactionsHorizontalAlignment( - User? user, - Message message, - BoxConstraints constraints, - double? fontSize, - Orientation orientation, -) { - final maxWidth = constraints.maxWidth; - - final roughSentenceSize = message.roughMessageSize(fontSize); - final hasAttachments = message.attachments.isNotEmpty; - final isReply = message.quotedMessageId != null; - final isAttachment = hasAttachments && !isReply; - - // divFactor is the percentage of the available space that the message takes. - // When the divFactor is bigger than 0.5 that means that the messages is - // bigger than 50% of the available space and the modal should have an offset - // in the direction that the message grows. When the divFactor is smaller - // than 0.5 then the offset should be to he side opposite of the message - // growth. - // In resume, when divFactor > 0.5 then result > 0, when divFactor < 0.5 - // then result < 0. - var divFactor = 0.5; - - // When in portrait, attachments normally take 75% of the screen, when in - // landscape, attachments normally take 50% of the screen. - if (isAttachment) { - if (orientation == Orientation.portrait) { - divFactor = 0.75; - } else { - divFactor = 0.5; - } - } else { - divFactor = roughSentenceSize == 0 ? 0.5 : (roughSentenceSize / maxWidth); - } - - final signal = user?.id == message.user?.id ? 1 : -1; - final result = signal * (1 - divFactor * 2.0); - - // Ensure reactions don't get pushed past the edge of the screen. - // - // This happens if divFactor is really big. When this happens, we can simply - // move the model all the way to the end of screen. - return result.clamp(-1, 1); -} diff --git a/packages/stream_chat_flutter/lib/src/message_widget/reactions/reactions_card.dart b/packages/stream_chat_flutter/lib/src/message_widget/reactions/reactions_card.dart deleted file mode 100644 index 727b44affc..0000000000 --- a/packages/stream_chat_flutter/lib/src/message_widget/reactions/reactions_card.dart +++ /dev/null @@ -1,142 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:stream_chat_flutter/src/avatars/user_avatar.dart'; -import 'package:stream_chat_flutter/src/message_widget/reactions/reaction_bubble.dart'; -import 'package:stream_chat_flutter/src/theme/message_theme.dart'; -import 'package:stream_chat_flutter/src/theme/stream_chat_theme.dart'; -import 'package:stream_chat_flutter/src/utils/extensions.dart'; -import 'package:stream_chat_flutter_core/stream_chat_flutter_core.dart'; - -/// {@template reactionsCard} -/// A card that displays the reactions to a message. -/// -/// Used in [StreamMessageReactionsModal] and [DesktopReactionsBuilder]. -/// {@endtemplate} -class ReactionsCard extends StatelessWidget { - /// {@macro reactionsCard} - const ReactionsCard({ - super.key, - required this.currentUser, - required this.message, - required this.messageTheme, - this.onUserAvatarTap, - }); - - /// Current logged in user. - final User currentUser; - - /// Message to display reactions of. - final Message message; - - /// [StreamMessageThemeData] to apply to [message]. - final StreamMessageThemeData messageTheme; - - /// {@macro onUserAvatarTap} - final void Function(User)? onUserAvatarTap; - - @override - Widget build(BuildContext context) { - final chatThemeData = StreamChatTheme.of(context); - return Card( - color: chatThemeData.colorTheme.barsBg, - clipBehavior: Clip.hardEdge, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(16), - ), - margin: EdgeInsets.zero, - child: Padding( - padding: const EdgeInsets.all(16), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Text( - context.translations.messageReactionsLabel, - style: chatThemeData.textTheme.headlineBold, - ), - const SizedBox(height: 16), - Flexible( - child: SingleChildScrollView( - child: Wrap( - spacing: 16, - runSpacing: 16, - children: message.latestReactions! - .map((e) => _buildReaction( - e, - currentUser, - context, - )) - .toList(), - ), - ), - ), - ], - ), - ), - ); - } - - Widget _buildReaction( - Reaction reaction, - User currentUser, - BuildContext context, - ) { - final isCurrentUser = reaction.user?.id == currentUser.id; - final chatThemeData = StreamChatTheme.of(context); - final reverse = !isCurrentUser; - return ConstrainedBox( - constraints: BoxConstraints.loose( - const Size(64, 100), - ), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Stack( - clipBehavior: Clip.none, - children: [ - StreamUserAvatar( - onTap: onUserAvatarTap, - user: reaction.user!, - constraints: const BoxConstraints.tightFor( - height: 64, - width: 64, - ), - onlineIndicatorConstraints: const BoxConstraints.tightFor( - height: 12, - width: 12, - ), - borderRadius: BorderRadius.circular(32), - ), - Positioned( - bottom: 6, - left: !reverse ? -3 : null, - right: reverse ? -3 : null, - child: Align( - alignment: - reverse ? Alignment.centerRight : Alignment.centerLeft, - child: StreamReactionBubble( - reactions: [reaction], - reverse: !reverse, - flipTail: !reverse, - borderColor: - messageTheme.reactionsBorderColor ?? Colors.transparent, - backgroundColor: messageTheme.reactionsBackgroundColor ?? - Colors.transparent, - maskColor: chatThemeData.colorTheme.barsBg, - tailCirclesSpacing: 1, - ), - ), - ), - ], - ), - const SizedBox(height: 8), - Text( - reaction.user!.name.split(' ')[0], - style: chatThemeData.textTheme.footnoteBold, - textAlign: TextAlign.center, - overflow: TextOverflow.ellipsis, - maxLines: 1, - ), - ], - ), - ); - } -} diff --git a/packages/stream_chat_flutter/lib/src/message_widget/sending_indicator_builder.dart b/packages/stream_chat_flutter/lib/src/message_widget/sending_indicator_builder.dart deleted file mode 100644 index cb42429007..0000000000 --- a/packages/stream_chat_flutter/lib/src/message_widget/sending_indicator_builder.dart +++ /dev/null @@ -1,100 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:stream_chat_flutter/stream_chat_flutter.dart'; - -/// {@template sendingIndicatorWrapper} -/// Helper widget for building a [StreamSendingIndicator]. -/// -/// Used in [BottomRow]. Should not be used elsewhere. -/// {@endtemplate} -class SendingIndicatorBuilder extends StatelessWidget { - /// {@macro sendingIndicatorWrapper} - const SendingIndicatorBuilder({ - super.key, - required this.messageTheme, - required this.message, - required this.hasNonUrlAttachments, - required this.streamChat, - required this.streamChatTheme, - this.channel, - }); - - /// {@macro messageTheme} - final StreamMessageThemeData messageTheme; - - /// {@macro message} - final Message message; - - /// {@macro hasNonUrlAttachments} - final bool hasNonUrlAttachments; - - /// {@macro streamChat} - final StreamChatState streamChat; - - /// {@macro streamChatThemeData} - final StreamChatThemeData streamChatTheme; - - /// {@macro channel} - final Channel? channel; - - @override - Widget build(BuildContext context) { - final style = messageTheme.createdAtStyle; - final channel = this.channel ?? StreamChannel.of(context).channel; - final memberCount = channel.memberCount ?? 0; - - if (hasNonUrlAttachments && message.state.isOutgoing) { - final totalAttachments = message.attachments.length; - final attachmentsToUpload = message.attachments.where((it) { - return !it.uploadState.isSuccess; - }); - - if (attachmentsToUpload.isNotEmpty) { - return Text( - context.translations.attachmentsUploadProgressText( - remaining: attachmentsToUpload.length, - total: totalAttachments, - ), - style: style, - ); - } - } - - return BetterStreamBuilder>( - stream: channel.state?.readStream, - initialData: channel.state?.read, - builder: (context, data) { - final readList = data.readsOf(message: message); - final isMessageRead = readList.isNotEmpty; - - final deliveriesList = data.deliveriesOf(message: message); - final isMessageDelivered = deliveriesList.isNotEmpty; - - Widget child = StreamSendingIndicator( - message: message, - isMessageRead: isMessageRead, - isMessageDelivered: isMessageDelivered, - size: style?.fontSize, - ); - - if (isMessageRead) { - child = Row( - mainAxisSize: MainAxisSize.min, - children: [ - if (memberCount > 2) - Text( - readList.length.toString(), - style: style?.copyWith( - color: streamChatTheme.colorTheme.accentPrimary, - ), - ), - const SizedBox(width: 2), - child, - ], - ); - } - - return child; - }, - ); - } -} diff --git a/packages/stream_chat_flutter/lib/src/message_widget/ephemeral_message.dart b/packages/stream_chat_flutter/lib/src/message_widget/stream_ephemeral_message.dart similarity index 85% rename from packages/stream_chat_flutter/lib/src/message_widget/ephemeral_message.dart rename to packages/stream_chat_flutter/lib/src/message_widget/stream_ephemeral_message.dart index 076cb4117d..746c0ab3c8 100644 --- a/packages/stream_chat_flutter/lib/src/message_widget/ephemeral_message.dart +++ b/packages/stream_chat_flutter/lib/src/message_widget/stream_ephemeral_message.dart @@ -1,5 +1,5 @@ import 'package:flutter/material.dart'; -import 'package:stream_chat_flutter/src/message_widget/giphy_ephemeral_message.dart'; +import 'package:stream_chat_flutter/src/message_widget/stream_giphy_ephemeral_message.dart'; import 'package:stream_chat_flutter/src/misc/empty_widget.dart'; import 'package:stream_chat_flutter/src/utils/typedefs.dart'; import 'package:stream_chat_flutter_core/stream_chat_flutter_core.dart'; @@ -36,13 +36,12 @@ class StreamEphemeralMessage extends StatelessWidget { final onTap? => () => onTap(message), _ => null, }, - child: GiphyEphemeralMessage( + child: StreamGiphyEphemeralMessage( message: message, onActionPressed: (name, value) { - streamChannel.channel.sendAction( - message, - {name: value}, - ); + return streamChannel.channel.sendAction(message, { + name: value, + }).ignore(); }, ), ), diff --git a/packages/stream_chat_flutter/lib/src/message_widget/stream_giphy_ephemeral_message.dart b/packages/stream_chat_flutter/lib/src/message_widget/stream_giphy_ephemeral_message.dart new file mode 100644 index 0000000000..cf6372c0ce --- /dev/null +++ b/packages/stream_chat_flutter/lib/src/message_widget/stream_giphy_ephemeral_message.dart @@ -0,0 +1,166 @@ +import 'package:flutter/material.dart' hide Action; +import 'package:stream_chat_flutter/src/attachment/giphy_attachment.dart'; +import 'package:stream_chat_flutter/src/message_widget/components/stream_message_footer.dart'; +import 'package:stream_chat_flutter/src/utils/extensions.dart'; +import 'package:stream_chat_flutter_core/stream_chat_flutter_core.dart'; +import 'package:stream_core_flutter/stream_core_flutter.dart' as core; + +/// Signature for the action callback passed to [StreamGiphyEphemeralMessage]. +/// +/// Used by [StreamGiphyEphemeralMessage.onActionPressed]. +typedef GiffyAction = void Function(String name, String value); + +const _kDefaultGiphyConstraints = BoxConstraints(minWidth: 128); + +/// {@template streamGiphyEphemeralMessage} +/// Shows an ephemeral message of type giphy in a [MessageWidget]. +/// {@endtemplate} +class StreamGiphyEphemeralMessage extends StatelessWidget { + /// {@macro streamGiphyEphemeralMessage} + const StreamGiphyEphemeralMessage({ + super.key, + required this.message, + this.onActionPressed, + }); + + /// The underlying [Message] object which this widget represents. + final Message message; + + /// Callback called when an action is pressed. + final GiffyAction? onActionPressed; + + @override + Widget build(BuildContext context) { + final giphy = message.attachments.first; + + final actions = giphy.actions; + assert(actions != null && actions.isNotEmpty, 'actions cannot be null'); + + final spacing = context.streamSpacing; + + return core.StreamMessageLayout( + data: const core.StreamMessageLayoutData( + alignment: .end, + stackPosition: .single, + contentKind: .singleAttachment, + ), + child: Builder( + builder: (context) => Align( + alignment: core.StreamMessageLayout.alignmentDirectionalOf(context), + child: Padding( + padding: .symmetric(horizontal: spacing.md), + child: core.StreamMessageContent( + footer: StreamMessageFooter(message: message), + child: core.StreamMessageBubble( + child: core.StreamIntrinsicColumn( + crossAxisAlignment: .start, + children: [ + GiphyHeader(title: context.translations.onlyVisibleToYouText), + Center( + child: StreamGiphyAttachment( + message: message, + giphy: giphy, + constraints: _kDefaultGiphyConstraints, + ), + ), + core.StreamIntrinsicSizeCandidate( + child: GiphyActions(actions: actions!, onActionPressed: onActionPressed), + ), + ], + ), + ), + ), + ), + ), + ), + ); + } +} + +/// {@template giphyActions} +/// Shows the actions for a giphy ephemeral message. +/// {@endtemplate} +class GiphyActions extends StatelessWidget { + /// {@macro giphyActions} + const GiphyActions({ + super.key, + required this.actions, + required this.onActionPressed, + }); + + /// The underlying [Attachment] object which this widget represents. + final List actions; + + /// Callback called when an action is pressed. + final GiffyAction? onActionPressed; + + @override + Widget build(BuildContext context) { + final spacing = context.streamSpacing; + + return Padding( + padding: .symmetric(horizontal: spacing.xs), + child: Wrap( + alignment: .spaceEvenly, + children: [ + ...actions.map( + (action) { + final style = switch (action.style) { + 'primary' => core.StreamButtonStyle.primary, + _ => core.StreamButtonStyle.secondary, + }; + + return core.StreamButton( + style: style, + type: .ghost, + size: .small, + onPressed: switch (onActionPressed) { + final onPressed? => () => onPressed( + action.name.toLowerCase(), + action.text.toLowerCase(), + ), + _ => null, + }, + child: Text(action.text), + ); + }, + ), + ], + ), + ); + } +} + +/// {@template giphyHeader} +/// Shows the header for a giphy ephemeral message. +/// {@endtemplate} +class GiphyHeader extends StatelessWidget { + /// {@macro giphyHeader} + const GiphyHeader({super.key, required this.title}); + + /// The title of the giphy. + final String title; + + @override + Widget build(BuildContext context) { + final icons = context.streamIcons; + final spacing = context.streamSpacing; + final textTheme = context.streamTextTheme; + final colorScheme = context.streamColorScheme; + + return Padding( + padding: EdgeInsets.symmetric( + vertical: spacing.xs, + horizontal: spacing.sm, + ), + child: Row( + mainAxisSize: .min, + spacing: spacing.xs, + children: [ + Icon(icons.eyeFill, size: 16, color: colorScheme.brand.shade900), + Text(title, style: textTheme.captionEmphasis.copyWith(color: colorScheme.brand.shade900)), + ], + ), + ); + } +} diff --git a/packages/stream_chat_flutter/lib/src/message_widget/stream_message_attachments.dart b/packages/stream_chat_flutter/lib/src/message_widget/stream_message_attachments.dart new file mode 100644 index 0000000000..fcd9c8905c --- /dev/null +++ b/packages/stream_chat_flutter/lib/src/message_widget/stream_message_attachments.dart @@ -0,0 +1,144 @@ +import 'dart:async'; +import 'dart:math' as math; + +import 'package:flutter/material.dart'; +import 'package:stream_chat_flutter/src/attachment/attachment_widget_catalog.dart'; +import 'package:stream_chat_flutter/stream_chat_flutter.dart'; +import 'package:stream_core_flutter/stream_core_flutter.dart' as core; + +/// {@template onAttachmentWidgetTap} +/// A callback that is called when an attachment widget is tapped. +/// +/// Return `true` if the attachment was handled by your custom logic, +/// `false` to use the default handler which automatically: +/// - Opens URL previews in browser (or calls [onLinkTap] if provided) +/// - Opens images, videos, and giphys in full screen viewer +/// +/// Supports both synchronous and asynchronous operations via [FutureOr]. +/// +/// Example with custom location attachments: +/// ```dart +/// StreamMessageItem( +/// message: message, +/// onAttachmentTap: (context, message, attachment) async { +/// if (attachment.type == 'location') { +/// await showLocationDialog(context, attachment); +/// return true; // Handled by custom logic +/// } +/// return false; // Use default behavior for other types +/// }, +/// ) +/// ``` +/// {@endtemplate} +typedef OnAttachmentWidgetTap = FutureOr Function(BuildContext context, Message message, Attachment attachment); + +/// {@template streamMessageAttachments} +/// Renders the attachments of a [StreamMessageItem]. +/// +/// Used inside [StreamMessageContent]. Should not be used elsewhere. +/// {@endtemplate} +class StreamMessageAttachments extends core.NullableStatelessWidget { + /// {@macro streamMessageAttachments} + const StreamMessageAttachments({ + super.key, + required this.message, + this.attachmentBuilders, + this.onAttachmentTap, + this.onLinkTap, + }); + + /// {@macro message} + final Message message; + + /// {@macro attachmentBuilders} + final List? attachmentBuilders; + + /// {@macro onAttachmentTap} + final OnAttachmentWidgetTap? onAttachmentTap; + + /// {@macro onLinkTap} + final void Function(String)? onLinkTap; + + @override + Widget? nullableBuild(BuildContext context) { + Future effectiveOnAttachmentTap( + Message message, + Attachment attachment, + ) async { + // Try custom handler first. If it returns true, the attachment was + // handled. + final handled = await onAttachmentTap?.call(context, message, attachment); + if (handled ?? false) return; + + // Otherwise, use the default handler for standard attachment types. + return _defaultAttachmentTapHandler(context, message, attachment); + } + + final config = StreamChatConfiguration.maybeOf(context); + final effectiveAttachmentBuilder = attachmentBuilders ?? config?.attachmentBuilders; + + // Create a default attachmentBuilders list if not provided. + final builders = StreamAttachmentWidgetBuilder.defaultBuilders( + message: message, + onAttachmentTap: effectiveOnAttachmentTap, + customAttachmentBuilders: effectiveAttachmentBuilder, + ); + + final catalog = AttachmentWidgetCatalog(builders: builders); + return catalog.build(context, message); + } + + Future _defaultAttachmentTapHandler( + BuildContext context, + Message message, + Attachment attachment, + ) async { + // If the current attachment is a url preview attachment, open the url + // in the browser. + final isFile = attachment.type == AttachmentType.file; + final isUrlPreview = attachment.type == AttachmentType.urlPreview; + if (isFile || isUrlPreview) { + final url = attachment.assetUrl ?? attachment.ogScrapeUrl; + if (url == null) return; + + if (onLinkTap case final onTap?) return onTap(url); + return launchURL(context, url); + } + + final isImage = attachment.type == AttachmentType.image; + final isVideo = attachment.type == AttachmentType.video; + final isGiphy = attachment.type == AttachmentType.giphy; + + // If the current attachment is a media attachment, open the media + // attachment in full screen. + final isMedia = isImage || isVideo || isGiphy; + if (isMedia) { + final attachments = message.toMediaGalleryAttachments( + filter: (it) { + final isImage = it.type == AttachmentType.image; + final isVideo = it.type == AttachmentType.video; + final isGiphy = it.type == AttachmentType.giphy; + return isImage || isVideo || isGiphy; + }, + ); + + final navigator = Navigator.of(context); + final channel = StreamChannel.of(context).channel; + final initialIndex = attachments.indexWhere( + (it) => it.attachment.id == attachment.id, + ); + + return navigator.push( + MaterialPageRoute( + builder: (_) => StreamChannel( + channel: channel, + child: StreamMediaGalleryPreview( + attachments: attachments, + initialIndex: math.max(0, initialIndex), + ), + ), + ), + ); + } + } +} diff --git a/packages/stream_chat_flutter/lib/src/message_widget/stream_message_item.dart b/packages/stream_chat_flutter/lib/src/message_widget/stream_message_item.dart new file mode 100644 index 0000000000..c6bab002b3 --- /dev/null +++ b/packages/stream_chat_flutter/lib/src/message_widget/stream_message_item.dart @@ -0,0 +1,1096 @@ +import 'dart:math' as math; + +import 'package:collection/collection.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:stream_chat_flutter/platform_widget_builder/src/platform_widget_builder.dart'; +import 'package:stream_chat_flutter/src/context_menu/context_menu.dart'; +import 'package:stream_chat_flutter/src/context_menu/context_menu_region.dart'; +import 'package:stream_chat_flutter/src/message_widget/components/stream_message_content.dart'; +import 'package:stream_chat_flutter/src/message_widget/components/stream_message_footer.dart'; +import 'package:stream_chat_flutter/src/message_widget/components/stream_message_header.dart'; +import 'package:stream_chat_flutter/stream_chat_flutter.dart'; +import 'package:stream_core_flutter/stream_core_flutter.dart' as core; + +/// A chat message widget that renders a single message with its attachments, +/// reactions, and interaction callbacks. +/// +/// [StreamMessageItem] displays a single [Message] within a chat message +/// list. It handles the complete message layout including the author avatar, +/// message content (text, attachments, polls, quoted messages), reactions, +/// thread indicators, and user interaction gestures such as tap, long-press, +/// and context menus. +/// +/// On mobile platforms, a long-press opens the [StreamMessageActionsModal] +/// with available actions (reply, edit, delete, pin, etc.). On desktop and +/// web, those same actions appear in a right-click context menu. +/// +/// This widget delegates rendering to either a custom builder registered via +/// [StreamComponentFactory], or [DefaultStreamMessageItem] when no custom builder +/// is provided. Register a custom builder through [StreamChatConfigurationData] +/// to fully replace the default message layout while still receiving the same +/// [StreamMessageItemProps]. +/// +/// {@tool snippet} +/// +/// Display a message with default settings: +/// +/// ```dart +/// StreamMessageItem( +/// message: message, +/// ) +/// ``` +/// {@end-tool} +/// +/// {@tool snippet} +/// +/// Customise interaction callbacks: +/// +/// ```dart +/// StreamMessageItem( +/// message: message, +/// onMessageTap: (msg) => print('Tapped: ${msg.id}'), +/// onThreadTap: (parent, threadMsg) => Navigator.push(...), +/// onUserAvatarTap: (user) => showProfile(user), +/// ) +/// ``` +/// {@end-tool} +/// +/// See also: +/// +/// * [StreamMessageItemProps], which holds every configurable property. +/// * [DefaultStreamMessageItem], the default implementation used when no custom +/// builder is registered. +/// * [StreamMessageActionsModal], the modal shown on long-press (mobile). +/// * [StreamMessageListView], which hosts a scrollable list of these widgets. +class StreamMessageItem extends StatelessWidget { + /// Creates a chat message widget. + /// + /// The [message] is required. All other parameters are optional and have + /// sensible defaults resolved from the ambient theme and message data. + StreamMessageItem({ + super.key, + required Message message, + EdgeInsetsGeometry? padding, + double? spacing, + Color? backgroundColor, + double maxWidth = 264, + bool swipeToReply = false, + void Function(Message)? onMessageTap, + void Function(Message)? onMessageLongPress, + void Function(User)? onUserAvatarTap, + void Function(Message message, String url)? onMessageLinkTap, + void Function(User user)? onUserMentionTap, + void Function(Message parentMessage, Message? threadMessage)? onThreadTap, + void Function(Message)? onViewInChannelTap, + void Function(Message)? onReplyTap, + void Function(Message)? onReactionsTap, + void Function(Message quotedMessage)? onQuotedMessageTap, + Comparator? reactionSorting, + MessageActionsBuilder? actionsBuilder, + void Function(BuildContext, Message)? onMessageActions, + void Function(BuildContext, Message)? onBouncedErrorMessageActions, + void Function(Message)? onEditMessageTap, + List? attachmentBuilders, + }) : props = .new( + message: message, + padding: padding, + spacing: spacing, + backgroundColor: backgroundColor, + maxWidth: maxWidth, + swipeToReply: swipeToReply, + onMessageTap: onMessageTap, + onMessageLongPress: onMessageLongPress, + onUserAvatarTap: onUserAvatarTap, + onMessageLinkTap: onMessageLinkTap, + onUserMentionTap: onUserMentionTap, + onThreadTap: onThreadTap, + onViewInChannelTap: onViewInChannelTap, + onReplyTap: onReplyTap, + onReactionsTap: onReactionsTap, + onQuotedMessageTap: onQuotedMessageTap, + reactionSorting: reactionSorting, + actionsBuilder: actionsBuilder, + onMessageActions: onMessageActions, + onBouncedErrorMessageActions: onBouncedErrorMessageActions, + onEditMessageTap: onEditMessageTap, + attachmentBuilders: attachmentBuilders, + ); + + /// Creates a chat message widget from pre-built [props]. + const StreamMessageItem.fromProps({super.key, required this.props}); + + /// The properties that configure this message widget. + final StreamMessageItemProps props; + + @override + Widget build(BuildContext context) { + final builder = context.chatComponentBuilder(); + if (builder != null) return builder(context, props); + return DefaultStreamMessageItem(props: props); + } +} + +/// Properties for configuring a [StreamMessageItem]. +/// +/// This class holds every configuration option for a chat message widget, +/// allowing them to be passed through the [StreamComponentFactory] when a +/// custom builder is registered. +/// +/// Visual properties such as [padding], [spacing], and [backgroundColor] +/// override the corresponding values from [StreamMessageItemThemeData] when +/// non-null. When left null, the theme values are used instead. +/// +/// See also: +/// +/// * [StreamMessageItem], which uses these properties. +/// * [DefaultStreamMessageItem], the default implementation. +class StreamMessageItemProps { + /// Creates properties for a chat message widget. + const StreamMessageItemProps({ + required this.message, + this.padding, + this.spacing, + this.backgroundColor, + this.maxWidth = 272, + this.swipeToReply = false, + this.onMessageTap, + this.onMessageLongPress, + this.onUserAvatarTap, + this.onMessageLinkTap, + this.onUserMentionTap, + this.onThreadTap, + this.onViewInChannelTap, + this.onReplyTap, + this.onReactionsTap, + this.onQuotedMessageTap, + this.reactionSorting, + this.actionsBuilder, + this.onMessageActions, + this.onBouncedErrorMessageActions, + this.onEditMessageTap, + this.attachmentBuilders, + }); + + /// The message to display. + final Message message; + + /// Outer padding around the entire message item. + /// + /// When non-null, takes precedence over the theme value from + /// [StreamMessageItemThemeData.padding]. + /// + /// When null (the default), the padding is determined by the theme. + final EdgeInsetsGeometry? padding; + + /// Horizontal spacing between the leading avatar and the content. + /// + /// When non-null, takes precedence over the theme value from + /// [StreamMessageItemThemeData.spacing]. + /// + /// When null (the default), the spacing is determined by the theme. + final double? spacing; + + /// Background color for the entire message item row. + /// + /// When non-null, takes precedence over the theme value from + /// [StreamMessageItemThemeData.backgroundColor]. + /// + /// When null (the default), the background color is determined by the theme. + final Color? backgroundColor; + + /// Maximum width of the message content column, in logical pixels. + /// + /// The content uses at most this width while still respecting the parent + /// [Flex] constraints. Use [double.infinity] to impose no cap from this + /// widget. Defaults to `264` when not specified. + final double maxWidth; + + /// Whether swiping the message triggers a quoted-reply action. + /// + /// When true, the message can be swiped from left to right to initiate a + /// reply. The swipe direction and reply icon position are always + /// start-to-end (left to right in LTR layouts), regardless of whether the + /// message belongs to the current user or another participant. + /// On completion, [onReplyTap] is invoked with the message. + /// + /// Swipe is disabled for deleted messages and messages in a failed state. + /// + /// Defaults to false. + final bool swipeToReply; + + /// Called when the message is tapped. + /// + /// If null, no tap gesture is registered on mobile. On desktop and web, + /// tap behaviour is unaffected because interactions are driven by the + /// context menu instead. + final void Function(Message message)? onMessageTap; + + /// Called when the message is long-pressed. + /// + /// If null, the default long-press behaviour is used, which opens the + /// [StreamMessageActionsModal] on mobile. Provide this callback to + /// override that behaviour entirely. + final void Function(Message message)? onMessageLongPress; + + /// Called when the author's avatar is tapped. + /// + /// If null, tapping the avatar has no effect. A common use is to navigate + /// to the user's profile screen. + final void Function(User user)? onUserAvatarTap; + + /// Called when a link is tapped in the message text. + /// + /// Receives the [Message] containing the link and the tapped URL string. + /// If null, the default link handling behaviour is used. + final void Function(Message message, String url)? onMessageLinkTap; + + /// Called when a `@mention` is tapped in the message text. + /// + /// Receives the mentioned [User] resolved from the message's + /// [Message.mentionedUsers] list. If null, tapping a mention has no effect. + final void Function(User user)? onUserMentionTap; + + /// Called when the thread reply indicator is tapped. + /// + /// [parentMessage] is the root message of the thread. When the tapped + /// message was shown in-channel via [Message.showInChannel], + /// [threadMessage] contains the original in-channel reply so that the + /// caller can scroll to / highlight it inside the thread view. + /// Otherwise [threadMessage] is null. + /// + /// If null, tapping the thread indicator has no effect. + final void Function(Message parentMessage, Message? threadMessage)? onThreadTap; + + /// Called when the "View" button on the "Also sent in channel" annotation + /// is tapped inside a thread view. + /// + /// Typically used to pop the thread screen and scroll to / highlight the + /// message in the parent channel list. + /// + /// When null, the "View" button falls back to [onThreadTap]. + final void Function(Message message)? onViewInChannelTap; + + /// Called when the quoted-reply action is selected from the actions list. + /// + /// Receives the [Message] that should be quoted. Typically used to set the + /// quoted message on the message input. + /// + /// If null, the quoted-reply action is still shown but has no effect. + final void Function(Message message)? onReplyTap; + + /// Called when the reactions row beneath the message bubble is tapped. + /// + /// If null, the default behaviour opens a [ReactionDetailSheet] showing + /// the full list of reactions. Provide this callback to replace that + /// default with custom handling. + final void Function(Message message)? onReactionsTap; + + /// Called when an inline quoted message is tapped. + /// + /// Receives the [Message] that was quoted. Typically used to scroll to + /// the original message in the list. + /// + /// If null, tapping the quoted message has no effect. + final void Function(Message quotedMessage)? onQuotedMessageTap; + + /// Controls how reaction groups are sorted when displayed. + /// + /// Defaults to [ReactionSorting.byFirstReactionAt]. + final Comparator? reactionSorting; + + /// Allows customizing the default message actions list. + /// + /// Receives the [BuildContext] and the default list of + /// [StreamContextMenuAction] items built by the widget. Return a modified + /// list to add, remove, or reorder actions. + final MessageActionsBuilder? actionsBuilder; + + /// Called when a normal message is long-pressed to show actions. + /// + /// When provided, this callback replaces the default behaviour of showing + /// the [StreamMessageActionsModal]. + final void Function(BuildContext context, Message message)? onMessageActions; + + /// Called when a bounced-error message is long-pressed. + /// + /// When provided, this callback replaces the default behaviour of showing + /// the [ModeratedMessageActionsModal]. + final void Function(BuildContext context, Message message)? onBouncedErrorMessageActions; + + /// Called when the edit-message action is selected. + final void Function(Message message)? onEditMessageTap; + + /// Custom attachment builders for rendering message attachments. + /// + /// When non-null, these builders are used instead of the default ones + /// provided by [StreamChatConfigurationData.attachmentBuilders]. + /// + /// Custom builders are prepended to the default builder list, so they take + /// priority for attachment types they can handle. + final List? attachmentBuilders; + + /// Returns a copy of this [StreamMessageItemProps] with the given fields + /// replaced with new values. + StreamMessageItemProps copyWith({ + Message? message, + EdgeInsetsGeometry? padding, + double? spacing, + Color? backgroundColor, + double? maxWidth, + bool? swipeToReply, + void Function(Message)? onMessageTap, + void Function(Message)? onMessageLongPress, + void Function(User)? onUserAvatarTap, + void Function(Message, String)? onMessageLinkTap, + void Function(User)? onUserMentionTap, + void Function(Message, Message?)? onThreadTap, + void Function(Message)? onViewInChannelTap, + void Function(Message)? onReplyTap, + void Function(Message)? onReactionsTap, + void Function(Message)? onQuotedMessageTap, + Comparator? reactionSorting, + MessageActionsBuilder? actionsBuilder, + void Function(BuildContext, Message)? onMessageActions, + void Function(BuildContext, Message)? onBouncedErrorMessageActions, + void Function(Message)? onEditMessageTap, + List? attachmentBuilders, + }) { + return StreamMessageItemProps( + message: message ?? this.message, + padding: padding ?? this.padding, + spacing: spacing ?? this.spacing, + backgroundColor: backgroundColor ?? this.backgroundColor, + maxWidth: maxWidth ?? this.maxWidth, + swipeToReply: swipeToReply ?? this.swipeToReply, + onMessageTap: onMessageTap ?? this.onMessageTap, + onMessageLongPress: onMessageLongPress ?? this.onMessageLongPress, + onUserAvatarTap: onUserAvatarTap ?? this.onUserAvatarTap, + onMessageLinkTap: onMessageLinkTap ?? this.onMessageLinkTap, + onUserMentionTap: onUserMentionTap ?? this.onUserMentionTap, + onThreadTap: onThreadTap ?? this.onThreadTap, + onViewInChannelTap: onViewInChannelTap ?? this.onViewInChannelTap, + onReplyTap: onReplyTap ?? this.onReplyTap, + onReactionsTap: onReactionsTap ?? this.onReactionsTap, + onQuotedMessageTap: onQuotedMessageTap ?? this.onQuotedMessageTap, + reactionSorting: reactionSorting ?? this.reactionSorting, + actionsBuilder: actionsBuilder ?? this.actionsBuilder, + onMessageActions: onMessageActions ?? this.onMessageActions, + onBouncedErrorMessageActions: onBouncedErrorMessageActions ?? this.onBouncedErrorMessageActions, + onEditMessageTap: onEditMessageTap ?? this.onEditMessageTap, + attachmentBuilders: attachmentBuilders ?? this.attachmentBuilders, + ); + } +} + +/// The default implementation of [StreamMessageItem]. +/// +/// Composes a full message row with an author avatar, content bubble, +/// header annotations, footer metadata, and platform-adaptive interaction +/// handling (tap and long-press on mobile, right-click context menu on +/// desktop and web). +/// +/// Message actions can be customised through +/// [StreamMessageItemProps.actionsBuilder]. +/// +/// See also: +/// +/// * [StreamMessageItem], the public API widget. +/// * [StreamMessageItemProps], which configures this widget. +/// * [StreamMessageItemTheme], provides theme data to this widget. +class DefaultStreamMessageItem extends StatelessWidget { + /// Creates a default chat message widget with the given [props]. + const DefaultStreamMessageItem({super.key, required this.props}); + + /// The properties that configure this widget. + final StreamMessageItemProps props; + + @override + Widget build(BuildContext context) { + final message = props.message; + + final placement = StreamMessageLayout.of(context); + final theme = core.StreamMessageItemTheme.of(context); + final defaults = _StreamMessageItemDefaults( + context, + isPinned: message.pinned, + isEdited: message.messageTextUpdatedAt != null, + isBouncedWithError: message.isBouncedWithError, + state: message.state, + ); + + final resolve = core.StreamMessageLayoutResolver(placement, [theme, defaults]); + + final effectivePadding = props.padding ?? theme.padding ?? defaults.padding; + final effectiveSpacing = props.spacing ?? theme.spacing ?? defaults.spacing; + final effectiveBackgroundColor = props.backgroundColor ?? theme.backgroundColor ?? defaults.backgroundColor; + final effectiveAvatarVisibility = resolve((theme) => theme?.avatarVisibility); + final effectiveAnnotationVisibility = resolve((theme) => theme?.annotationVisibility); + final effectiveErrorBadgeVisibility = resolve((theme) => theme?.errorBadgeVisibility); + final effectiveMetadataVisibility = resolve((theme) => theme?.metadataVisibility); + final effectiveRepliesVisibility = resolve((theme) => theme?.repliesVisibility); + + Widget? leadingWidget; + if (props.message.user case final user?) { + final effectiveAvatarSize = theme.avatarSize ?? defaults.avatarSize; + + leadingWidget = effectiveAvatarVisibility.apply( + core.StreamAvatarTheme( + data: .new(size: effectiveAvatarSize), + child: StreamUserAvatar(user: user, showOnlineIndicator: false), + ), + ); + } + + final headerWidget = effectiveAnnotationVisibility.apply( + StreamMessageHeader( + message: message, + onViewChannelTap: switch (props.onViewInChannelTap) { + final onTap? => () => onTap(message), + _ => () => _onViewThread(context, message), + }, + ), + ); + + final footerWidget = effectiveMetadataVisibility.apply( + StreamMessageFooter(message: message), + ); + + Widget? repliesWidget; + if (message.replyCount case final replyCount? when replyCount > 0) { + repliesWidget = effectiveRepliesVisibility.apply( + core.StreamMessageReplies( + maxAvatars: 3, + onTap: () => _onViewThread(context, message), + showConnector: placement.contentKind != .jumbomoji, + label: Text('$replyCount replies'), + avatars: message.threadParticipants?.map( + (user) => StreamUserAvatar(user: user, showOnlineIndicator: false), + ), + ), + ); + } + + final errorBadgeWidget = effectiveErrorBadgeVisibility.apply( + core.StreamErrorBadge(size: core.StreamErrorBadgeSize.sm), + ); + + final contentWidget = StreamMessageContent( + message: message, + header: headerWidget, + errorBadge: errorBadgeWidget, + footer: footerWidget, + replies: repliesWidget, + attachmentBuilders: props.attachmentBuilders, + reactionSorting: props.reactionSorting, + onQuotedMessageTap: props.onQuotedMessageTap, + onLinkTap: (_, href, __) { + if (href == null) return; + if (props.onMessageLinkTap case final onTap?) return onTap(message, href); + return launchURL(context, href).ignore(); + }, + onMentionTap: switch (props.onUserMentionTap) { + final onTap? => (_, id) { + final user = message.mentionedUsers.firstWhereOrNull((u) => u.id == id); + if (user != null) onTap(user); + }, + _ => null, + }, + onReactionsTap: switch (props.onReactionsTap) { + final onReactionsTap? => () => onReactionsTap(message), + _ => () => _showMessageReactionsModal(context, message), + }, + ); + + Widget result = Material( + animateColor: true, + color: effectiveBackgroundColor, + child: PlatformWidgetBuilder( + mobile: (context, child) => InkWell( + // Disable splash and highlight effects for the message row. + splashFactory: NoSplash.splashFactory, + overlayColor: .all(core.StreamColors.transparent), + onTap: switch (props.onMessageTap) { + final onMessageTap? => () => onMessageTap(message), + _ => null, + }, + onLongPress: switch (props.onMessageLongPress) { + final onMessageLongPress? => () => onMessageLongPress(message), + _ when message.state.isDeleted => null, + _ when message.state.isOutgoing => null, + _ => () => _onMessageLongPressed(context, message), + }, + child: child, + ), + desktopOrWeb: (context, child) { + final messageState = message.state; + + // If the message is deleted or not yet sent, we don't want to + // show any context menu actions. + if (messageState.isDeleted || messageState.isOutgoing) return child; + + final channel = StreamChannel.of(context).channel; + final menuItems = _buildDesktopOrWebActions(context, message); + if (menuItems.isEmpty) return MouseRegion(child: child); + + return ContextMenuRegion( + onSelected: (result) { + if (result is! MessageAction) return; + return _onActionTap(context, channel, result).ignore(); + }, + menuBuilder: (_, anchor) => ContextMenu( + anchor: anchor, + menuItems: menuItems, + ), + child: MouseRegion(child: child), + ); + }, + child: Align( + alignment: StreamMessageLayout.alignmentDirectionalOf(context), + child: Padding( + padding: effectivePadding, + child: Row( + mainAxisSize: .min, + spacing: effectiveSpacing, + crossAxisAlignment: .end, + children: [ + ?leadingWidget, + Flexible( + child: ConstrainedBox( + constraints: BoxConstraints(maxWidth: props.maxWidth), + child: contentWidget, + ), + ), + ], + ), + ), + ), + ), + ); + + if (props.swipeToReply && props.onReplyTap != null && !message.isDeleted && !message.state.isFailed) { + result = _SwipeToReplyWrapper( + message: message, + onReplyTap: props.onReplyTap!, + child: result, + ); + } + + return result; + } + + // Builds the action list for a bounced (moderation-error) message. + List _buildBouncedErrorMessageActions({ + required BuildContext context, + required Message message, + }) { + return StreamMessageActionsBuilder.buildBouncedErrorActions( + context: context, + message: message, + ); + } + + // Builds the standard action list, applying the custom actionsBuilder if set. + List _buildMessageActions({ + required BuildContext context, + required Message message, + required Channel channel, + OwnUser? currentUser, + }) { + final actions = StreamMessageActionsBuilder.buildActions( + context: context, + message: message, + channel: channel, + currentUser: currentUser, + ); + + if (props.actionsBuilder case final builder?) { + return builder(context, actions); + } + + return StreamContextMenuAction.partitioned(items: actions); + } + + // Dispatches to bounced-error or normal actions for desktop/web. + List _buildDesktopOrWebActions( + BuildContext context, + Message message, + ) { + if (message.isBouncedWithError) { + return _buildBouncedErrorMessageDesktopOrWebActions(context, message); + } + + return _buildMessageDesktopOrWebActions(context, message); + } + + // Builds partitioned bounced-error actions for the desktop/web context menu. + List _buildBouncedErrorMessageDesktopOrWebActions( + BuildContext context, + Message message, + ) { + final actions = StreamMessageActionsBuilder.buildBouncedErrorActions( + context: context, + message: message, + ); + + return StreamContextMenuAction.partitioned(items: actions); + } + + // Builds normal actions + reaction picker for the desktop/web context menu. + List _buildMessageDesktopOrWebActions( + BuildContext context, + Message message, + ) { + final channel = StreamChannel.of(context).channel; + final currentUser = channel.client.state.currentUser; + final showPicker = channel.canSendReaction; + + final actions = _buildMessageActions( + context: context, + message: message, + channel: channel, + currentUser: currentUser, + ); + + void onReactionPicked(Reaction reaction) { + final action = SelectReaction(message: message, reaction: reaction); + return Navigator.pop(context, action); // Pop the modal with the selected reaction action + } + + return [ + if (showPicker) StreamMessageReactionPicker(message: message, onReactionPicked: onReactionPicked), + ...actions, + ]; + } + + // Opens the reaction detail sheet and handles the returned action. + Future _showMessageReactionsModal( + BuildContext context, + Message message, + ) async { + final channel = StreamChannel.of(context).channel; + + final action = await ReactionDetailSheet.show( + context: context, + message: message, + ); + + if (action is! MessageAction) return; + return _onActionTap(context, channel, action).ignore(); + } + + // Resolves the thread parent (fetching if shown in-channel) and invokes + // the onThreadTap callback with both the parent and the original message. + Future _onViewThread( + BuildContext context, + Message message, + ) async { + try { + if (message.showInChannel case true) { + final streamChannel = StreamChannel.of(context); + final parentMessage = await streamChannel.getMessage(message.parentId!); + return props.onThreadTap?.call(parentMessage, message); + } + return props.onThreadTap?.call(message, null); + } catch (e, stk) { + debugPrint('Error while fetching message: $e, $stk'); + } + } + + // Routes a long-press to bounced-error or normal actions handler. + Future _onMessageLongPressed( + BuildContext context, + Message message, + ) { + if (message.isBouncedWithError) { + return _onBouncedErrorMessageActions(context, message); + } + + return _onMessageActions(context, message); + } + + // Delegates to the custom callback or falls back to the default dialog. + Future _onBouncedErrorMessageActions( + BuildContext context, + Message message, + ) async { + if (props.onBouncedErrorMessageActions case final onActions?) { + return onActions(context, message); + } + + return _showBouncedErrorMessageActionsDialog(context, message); + } + + // Shows the ModeratedMessageActionsModal for a bounced-error message. + Future _showBouncedErrorMessageActionsDialog( + BuildContext context, + Message message, + ) async { + final channel = StreamChannel.of(context).channel; + + final actions = _buildBouncedErrorMessageActions( + context: context, + message: message, + ); + + final action = await showStreamDialog( + context: context, + useRootNavigator: false, + builder: (_) => ModeratedMessageActionsModal( + message: message, + messageActions: actions, + ), + ); + + if (action is! MessageAction) return; + return _onActionTap(context, channel, action).ignore(); + } + + // Delegates to the custom callback or falls back to the default modal. + Future _onMessageActions( + BuildContext context, + Message message, + ) async { + if (props.onMessageActions case final onActions?) { + return onActions(context, message); + } + + return _showMessageActionModalDialog(context, message); + } + + // Shows the StreamMessageActionsModal with a reaction picker and actions. + Future _showMessageActionModalDialog( + BuildContext context, + Message message, + ) async { + final channel = StreamChannel.of(context).channel; + final currentUser = channel.client.state.currentUser; + final showPicker = channel.canSendReaction; + + final actions = _buildMessageActions( + context: context, + message: message, + channel: channel, + currentUser: currentUser, + ); + + final layout = StreamMessageLayout.of(context); + final theme = core.StreamMessageItemTheme.of(context); + final defaults = _StreamMessageItemDefaults( + context, + isPinned: message.pinned, + isEdited: message.messageTextUpdatedAt != null, + state: message.state, + ); + + final resolve = core.StreamMessageLayoutResolver(layout, [theme, defaults]); + final avatarVisibility = resolve((theme) => theme?.avatarVisibility); + + var leadingInset = 0.0; + if (avatarVisibility != core.StreamVisibility.gone) { + final effectiveAvatarSize = theme.avatarSize ?? defaults.avatarSize; + final effectiveSpacing = props.spacing ?? theme.spacing ?? defaults.spacing; + leadingInset = effectiveAvatarSize.value + effectiveSpacing; + } + + final action = await showStreamDialog( + context: context, + useRootNavigator: false, + builder: (_) => StreamChatConfiguration( + data: StreamChatConfiguration.of(context), + child: StreamMessageLayout( + data: layout, + child: StreamMessageActionsModal( + message: message, + messageActions: actions, + showReactionPicker: showPicker, + leadingInset: leadingInset, + messageWidget: StreamChannel( + channel: channel, + child: StreamMessageItem( + key: const Key('MessageItem'), + message: message.trimmed, + padding: EdgeInsets.zero, + backgroundColor: core.StreamColors.transparent, + ), + ), + ), + ), + ), + ); + + if (action is! MessageAction) return; + return _onActionTap(context, channel, action).ignore(); + } + + // Dispatches a MessageAction to the appropriate channel or callback handler. + Future _onActionTap( + BuildContext context, + Channel channel, + MessageAction action, + ) async => switch (action) { + SelectReaction() => _selectReaction(context, action.message, channel, action.reaction), + CopyMessage() => _copyMessage(action.message, channel), + DeleteMessage() => _maybeDeleteMessage(context, action.message, channel), + HardDeleteMessage() => channel.deleteMessage(action.message, hard: true), + EditMessage() => props.onEditMessageTap?.call(action.message), + FlagMessage() => _maybeFlagMessage(context, action.message, channel), + MarkUnread() => channel.markUnread(action.message.id), + MuteUser() => channel.client.muteUser(action.user.id), + UnmuteUser() => channel.client.unmuteUser(action.user.id), + PinMessage() => channel.pinMessage(action.message), + UnpinMessage() => channel.unpinMessage(action.message), + ResendMessage() => channel.retryMessage(action.message), + QuotedReply() => props.onReplyTap?.call(action.message), + ThreadReply() => props.onThreadTap?.call(action.message, null), + }; + + // Copies the message text (with mentions replaced) to the clipboard. + Future _copyMessage( + Message message, + Channel channel, + ) async { + final presentableMessage = message.replaceMentions(linkify: false); + + final messageText = presentableMessage.text; + if (messageText == null || messageText.isEmpty) return; + + return Clipboard.setData(ClipboardData(text: messageText)); + } + + // Shows a confirmation dialog before deleting the message. + Future _maybeDeleteMessage( + BuildContext context, + Message message, + Channel channel, + ) async { + final confirmDelete = await showStreamDialog( + context: context, + builder: (context) => StreamMessageActionConfirmationModal( + title: Text(context.translations.deleteMessageLabel), + content: Text(context.translations.deleteMessageQuestion), + cancelActionTitle: Text(context.translations.cancelLabel), + confirmActionTitle: Text(context.translations.deleteLabel), + isDestructiveAction: true, + ), + ); + + if (confirmDelete != true) return null; + + return channel.deleteMessage(message); + } + + // Shows a confirmation dialog before flagging the message. + Future _maybeFlagMessage( + BuildContext context, + Message message, + Channel channel, + ) async { + final confirmFlag = await showStreamDialog( + context: context, + builder: (context) => StreamMessageActionConfirmationModal( + title: Text(context.translations.flagMessageLabel), + content: Text(context.translations.flagMessageQuestion), + cancelActionTitle: Text(context.translations.cancelLabel), + confirmActionTitle: Text(context.translations.flagLabel), + isDestructiveAction: true, + ), + ); + + if (confirmFlag != true) return null; + + final messageId = message.id; + return channel.client.flagMessage(messageId); + } + + // Toggles a reaction: removes it if already present, otherwise sends it. + Future _selectReaction( + BuildContext context, + Message message, + Channel channel, + Reaction reaction, + ) { + final ownReactions = [...?message.ownReactions]; + final shouldDelete = ownReactions.any((it) => it.type == reaction.type); + + if (shouldDelete) { + return channel.deleteReaction(message, reaction); + } + + final configurations = StreamChatConfiguration.of(context); + final enforceUnique = configurations.enforceUniqueReactions; + + return channel.sendReaction( + message, + reaction, + enforceUnique: enforceUnique, + ); + } +} + +// Truncates long message text for display in the actions modal preview. +extension on Message { + // Returns a copy with text and nested content truncated to 100 characters. + Message get trimmed { + final trimmedText = switch (text) { + final text? when text.length > 100 => '${text.substring(0, 100)}...', + _ => text, + }; + + return copyWith( + text: trimmedText, + poll: poll?.trimmed, + quotedMessage: quotedMessage?.trimmed, + ); + } +} + +// Truncates long poll names for display in the actions modal preview. +extension on Poll { + // Returns a copy with name truncated to 100 characters. + Poll get trimmed { + final trimmedName = switch (name) { + final name when name.length > 100 => '${name.substring(0, 100)}...', + _ => name, + }; + + return copyWith(name: trimmedName); + } +} + +class _SwipeToReplyWrapper extends StatelessWidget { + const _SwipeToReplyWrapper({ + required this.message, + required this.onReplyTap, + required this.child, + }); + + final Message message; + final void Function(Message) onReplyTap; + final Widget child; + + static const _swipeThreshold = 0.2; + + @override + Widget build(BuildContext context) { + return Swipeable( + key: ValueKey('swipe-${message.id}'), + direction: SwipeDirection.startToEnd, + swipeThreshold: _swipeThreshold, + onSwiped: (_) => onReplyTap(message), + backgroundBuilder: (context, details) { + final colorScheme = context.streamColorScheme; + final textDirection = Directionality.of(context); + + final progress = math.min(details.progress, _swipeThreshold) / _swipeThreshold; + final offset = Offset.lerp( + const Offset(-24, 0).directional(textDirection), + const Offset(12, 0).directional(textDirection), + progress, + )!; + + return Align( + alignment: AlignmentDirectional.centerStart, + child: Transform.translate( + offset: offset, + child: Opacity( + opacity: progress, + child: SizedBox.square( + dimension: 32, + child: CustomPaint( + painter: AnimatedCircleBorderPainter( + progress: progress, + color: colorScheme.backgroundSurface, + ), + child: Center( + child: Icon( + context.streamIcons.reply, + color: colorScheme.textPrimary, + size: 20, + ), + ), + ), + ), + ), + ), + ); + }, + child: child, + ); + } +} + +// Built-in fallback theme values for [DefaultStreamMessageItem]. +// +// Used when neither the explicit props nor the ambient +// [StreamMessageItemThemeData] provide a value for a given property. +class _StreamMessageItemDefaults extends core.StreamMessageItemThemeData { + _StreamMessageItemDefaults( + this._context, { + this.isPinned = false, + this.isEdited = false, + this.isBouncedWithError = false, + required MessageState state, + }) : _messageState = state; + + final bool isPinned; + final bool isEdited; + final bool isBouncedWithError; + + final BuildContext _context; + final MessageState _messageState; + + late final core.StreamSpacing _spacing = _context.streamSpacing; + late final core.StreamColorScheme _colorScheme = _context.streamColorScheme; + + @override + double get spacing => _spacing.xs; + + @override + StreamAvatarSize get avatarSize => .md; + + @override + EdgeInsetsGeometry get padding => .symmetric(horizontal: _spacing.md); + + @override + Color? get backgroundColor { + if (isPinned && !_messageState.isDeleted) return _colorScheme.backgroundHighlight; + return core.StreamColors.transparent; + } + + @override + core.StreamMessageLayoutVisibility get avatarVisibility => .resolveWith( + (placement) => switch ((placement.channelKind, placement.alignment, placement.stackPosition)) { + (.direct, _, _) || (_, .end, _) => .gone, + (_, _, .top || .middle) => .hidden, + (_, _, .single || .bottom) => .visible, + }, + ); + + @override + core.StreamMessageLayoutVisibility get annotationVisibility => .all(.visible); + + @override + core.StreamMessageLayoutVisibility get errorBadgeVisibility => .all( + _messageState.isFailed || isBouncedWithError ? .visible : .gone, + ); + + @override + core.StreamMessageLayoutVisibility get metadataVisibility { + if (isEdited) return .all(.visible); + return .resolveWith( + (placement) => switch (placement.stackPosition) { + .single || .bottom => .visible, + _ => .gone, + }, + ); + } + + @override + core.StreamMessageLayoutVisibility get repliesVisibility => .resolveWith( + (layout) => switch (layout.listKind) { + .thread => .gone, + .channel => .visible, + }, + ); +} diff --git a/packages/stream_chat_flutter/lib/src/message_widget/stream_moderated_message.dart b/packages/stream_chat_flutter/lib/src/message_widget/stream_moderated_message.dart new file mode 100644 index 0000000000..0bbfb35212 --- /dev/null +++ b/packages/stream_chat_flutter/lib/src/message_widget/stream_moderated_message.dart @@ -0,0 +1,104 @@ +import 'package:flutter/material.dart'; +import 'package:stream_chat_flutter/src/message_widget/stream_system_message.dart'; +import 'package:stream_chat_flutter/src/utils/extensions.dart'; +import 'package:stream_chat_flutter/src/utils/typedefs.dart'; +import 'package:stream_chat_flutter_core/stream_chat_flutter_core.dart'; + +/// {@template streamModeratedMessage} +/// A widget that displays a message that has been moderated. +/// +/// [StreamModeratedMessage] renders messages that have been flagged or +/// moderated according to content policies. It displays either the original +/// message text (when available) or a localised fallback indicating the +/// content was blocked. +/// +/// {@tool snippet} +/// +/// Display a moderated message with default styling: +/// +/// ```dart +/// StreamModeratedMessage( +/// message: message, +/// ) +/// ``` +/// {@end-tool} +/// +/// See also: +/// +/// * [StreamSystemMessage], which displays system messages with the same +/// pill style. +/// * [StreamMessageListView], which hosts moderated messages in the chat list. +/// {@endtemplate} +class StreamModeratedMessage extends StatelessWidget { + /// Creates a moderated message widget. + /// + /// The [message] is required. All other parameters are optional. + const StreamModeratedMessage({ + super.key, + required this.message, + this.onMessageTap, + this.margin, + this.contentPadding, + this.textStyle, + this.backgroundColor, + this.borderColor, + this.borderRadius, + }); + + /// The moderated message to display. + final Message message; + + /// Called when the message is tapped. + /// + /// If null, no tap gesture is registered on the message. + final OnMessageTap? onMessageTap; + + /// Outer margin around the pill container. + /// + /// When non-null, takes precedence over the theme default. + final EdgeInsetsGeometry? margin; + + /// Inner padding inside the pill container. + /// + /// When non-null, takes precedence over the theme default. + final EdgeInsetsGeometry? contentPadding; + + /// Text style for the moderated message text. + /// + /// When non-null, takes precedence over the theme default. + final TextStyle? textStyle; + + /// Background color of the pill container. + /// + /// When non-null, takes precedence over the theme default. + final Color? backgroundColor; + + /// Border color of the pill container. + /// + /// When non-null, takes precedence over the theme default. + final Color? borderColor; + + /// Border radius of the pill container. + /// + /// When non-null, takes precedence over the theme default. + final BorderRadius? borderRadius; + + @override + Widget build(BuildContext context) { + final moderatedText = switch (message.text) { + final messageText? when messageText.isNotEmpty => messageText, + _ => context.translations.moderatedMessageBlockedText, + }; + + return StreamSystemMessage( + message: message.copyWith(text: moderatedText), + onMessageTap: onMessageTap, + margin: margin, + contentPadding: contentPadding, + textStyle: textStyle, + backgroundColor: backgroundColor, + borderColor: borderColor, + borderRadius: borderRadius, + ); + } +} diff --git a/packages/stream_chat_flutter/lib/src/message_widget/stream_quoted_message.dart b/packages/stream_chat_flutter/lib/src/message_widget/stream_quoted_message.dart new file mode 100644 index 0000000000..3acfb7b488 --- /dev/null +++ b/packages/stream_chat_flutter/lib/src/message_widget/stream_quoted_message.dart @@ -0,0 +1,296 @@ +import 'package:flutter/material.dart'; +import 'package:stream_chat_flutter/src/attachment/thumbnail/media_attachment_thumbnail.dart'; +import 'package:stream_chat_flutter/src/channel/stream_message_preview_text.dart'; +import 'package:stream_chat_flutter/src/components/stream_chat_component_builders.dart'; +import 'package:stream_chat_flutter/src/theme/quoted_message_theme.dart'; +import 'package:stream_chat_flutter_core/stream_chat_flutter_core.dart'; +import 'package:stream_core_flutter/stream_core_flutter.dart'; + +/// A preview of a quoted message rendered above a reply. +/// +/// [StreamQuotedMessage] shows the quoted message's author and a short text +/// preview, with an optional trailing thumbnail when the quoted message has +/// a media or file attachment. It is rendered above the body of a message +/// that has a non-null [Message.quotedMessage]. +/// +/// The card chrome (background, shape, outer padding) and the inner content +/// padding are all controlled via [StreamQuotedMessageThemeData]. +/// +/// {@tool snippet} +/// +/// Basic usage: +/// +/// ```dart +/// StreamQuotedMessage( +/// quotedMessage: message.quotedMessage!, +/// onTap: () => navigateToMessage(message.quotedMessage!), +/// ) +/// ``` +/// {@end-tool} +/// +/// See also: +/// +/// * [StreamQuotedMessageProps], which configures this widget. +/// * [DefaultStreamQuotedMessage], the default implementation. +/// * [StreamQuotedMessageTheme], for theming. +class StreamQuotedMessage extends StatelessWidget { + /// Creates a [StreamQuotedMessage]. + StreamQuotedMessage({ + super.key, + required Message quotedMessage, + BoxConstraints? constraints, + VoidCallback? onTap, + }) : props = .new( + quotedMessage: quotedMessage, + constraints: constraints, + onTap: onTap, + ); + + /// The properties that configure this widget. + final StreamQuotedMessageProps props; + + @override + Widget build(BuildContext context) { + final builder = context.chatComponentBuilder(); + if (builder != null) return builder(context, props); + return DefaultStreamQuotedMessage(props: props); + } +} + +/// Properties for configuring a [StreamQuotedMessage]. +/// +/// Holds all the configuration options for the quoted-message preview, +/// allowing them to be passed through the [StreamComponentFactory]. +/// +/// See also: +/// +/// * [StreamQuotedMessage], which uses these properties. +/// * [DefaultStreamQuotedMessage], the default implementation. +class StreamQuotedMessageProps { + /// Creates properties for a quoted-message preview. + const StreamQuotedMessageProps({ + required this.quotedMessage, + this.constraints, + this.onTap, + }); + + /// The message being quoted. + final Message quotedMessage; + + /// The constraints to use when displaying the preview. + final BoxConstraints? constraints; + + /// Called when the user taps the preview. + /// + /// Typically used to scroll to the quoted message in the message list. + final VoidCallback? onTap; +} + +const _kDefaultConstraints = BoxConstraints.tightFor(width: 272); + +const _kIndicatorWidth = 2.0; +const _kIndicatorVerticalMargin = 2.0; + +/// The default implementation of [StreamQuotedMessage]. +/// +/// Renders the quoted-message preview with a vertical color indicator on +/// the leading edge, the author name as the title, a short text preview as +/// the subtitle, and an optional 40×40 trailing thumbnail or file-type icon. +/// +/// Colors are picked directly off [StreamColorScheme] using the alignment +/// provided by the surrounding [StreamMessageLayout]. +/// +/// See also: +/// +/// * [StreamQuotedMessage], the public API widget. +/// * [StreamQuotedMessageProps], which configures this widget. +class DefaultStreamQuotedMessage extends StatelessWidget { + /// Creates a default Stream quoted-message preview. + const DefaultStreamQuotedMessage({super.key, required this.props}); + + /// The properties that configure this widget. + final StreamQuotedMessageProps props; + + @override + Widget build(BuildContext context) { + final quotedMessage = props.quotedMessage; + final spacing = context.streamSpacing; + final radius = context.streamRadius; + + final theme = StreamQuotedMessageTheme.of(context); + final defaults = _StreamQuotedMessageDefaults(context); + + final effectiveTitleTextStyle = theme.titleTextStyle ?? defaults.titleTextStyle; + final effectiveSubtitleTextStyle = theme.subtitleTextStyle ?? defaults.subtitleTextStyle; + final effectiveIndicatorColor = theme.indicatorColor ?? defaults.indicatorColor; + final effectiveBackgroundColor = theme.backgroundColor ?? defaults.backgroundColor; + + final effectiveSide = theme.side ?? defaults.side; + final effectiveShape = (theme.shape ?? defaults.shape).copyWith(side: effectiveSide); + final effectiveMargin = theme.margin ?? defaults.margin; + final effectivePadding = theme.padding ?? defaults.padding; + final effectiveThumbnailSide = theme.thumbnailSide ?? defaults.thumbnailSide; + final effectiveThumbnailShape = (theme.thumbnailShape ?? defaults.thumbnailShape).copyWith( + side: effectiveThumbnailSide, + ); + final effectiveThumbnailSize = theme.thumbnailSize ?? defaults.thumbnailSize; + + final canTap = !quotedMessage.isDeleted && props.onTap != null; + final constraints = props.constraints ?? _kDefaultConstraints; + + final effectiveTitle = DefaultTextStyle.merge( + style: effectiveTitleTextStyle, + maxLines: 1, + overflow: TextOverflow.ellipsis, + child: Text(quotedMessage.user?.name ?? ''), + ); + + final effectiveSubtitle = DefaultTextStyle.merge( + style: effectiveSubtitleTextStyle, + maxLines: 1, + overflow: TextOverflow.ellipsis, + child: StreamMessagePreviewText(message: quotedMessage), + ); + + Widget? effectiveThumbnail; + if (_buildThumbnail(context, quotedMessage) case final thumbnail?) { + effectiveThumbnail = SizedBox.fromSize( + size: effectiveThumbnailSize, + child: Material( + type: MaterialType.transparency, + clipBehavior: Clip.hardEdge, + shape: effectiveThumbnailShape, + child: thumbnail, + ), + ); + } + + return Padding( + padding: effectiveMargin, + child: Material( + clipBehavior: Clip.hardEdge, + shape: effectiveShape, + color: effectiveBackgroundColor, + child: ConstrainedBox( + constraints: constraints, + child: InkWell( + onTap: canTap ? props.onTap : null, + child: Padding( + padding: effectivePadding, + child: IntrinsicHeight( + child: Row( + mainAxisSize: .min, + spacing: spacing.xs, + children: [ + VerticalDivider( + width: _kIndicatorWidth, + thickness: _kIndicatorWidth, + indent: _kIndicatorVerticalMargin, + endIndent: _kIndicatorVerticalMargin, + radius: BorderRadius.all(radius.max), + color: effectiveIndicatorColor, + ), + Expanded( + child: Column( + mainAxisSize: .min, + spacing: spacing.xxxs, + mainAxisAlignment: .center, + crossAxisAlignment: .start, + children: [effectiveTitle, effectiveSubtitle], + ), + ), + ?effectiveThumbnail, + ], + ), + ), + ), + ), + ), + ), + ); + } + + Widget? _buildThumbnail(BuildContext context, Message message) { + final attachments = message.attachments; + if (attachments.isEmpty || attachments.length > 1) return null; + + final attachment = attachments.first; + final type = attachment.type; + + if (type == .image || type == .video || type == .giphy) { + return StreamMediaAttachmentThumbnail(media: attachment, fit: .cover); + } + + if (type == .file) { + // Only show a single file-type icon when every file shares a mime type. + final mimeType = attachment.mimeType; + if (mimeType == null) return null; + if (attachments.any((it) => it.mimeType != mimeType)) return null; + return StreamFileTypeIcon.fromMimeType(mimeType: mimeType, size: .lg); + } + + return null; + } +} + +// Default values for [StreamQuotedMessageThemeData] backed by stream design +// tokens. The incoming/outgoing palette is picked directly off +// [StreamColorScheme] using the alignment provided by [StreamMessageLayout]. +class _StreamQuotedMessageDefaults extends StreamQuotedMessageThemeData { + _StreamQuotedMessageDefaults(this._context); + + final BuildContext _context; + + late final _alignment = StreamMessageLayout.messageAlignmentOf(_context); + + late final StreamSpacing _spacing = _context.streamSpacing; + late final StreamRadius _radius = _context.streamRadius; + late final StreamColorScheme _colorScheme = _context.streamColorScheme; + late final StreamTextTheme _textTheme = _context.streamTextTheme; + + Color get _textColor => switch (_alignment) { + .start => _colorScheme.textPrimary, + .end => _colorScheme.brand.shade900, + }; + + @override + TextStyle get titleTextStyle => _textTheme.metadataEmphasis.copyWith(color: _textColor); + + @override + TextStyle get subtitleTextStyle => _textTheme.metadataDefault.copyWith(color: _textColor); + + @override + Color get indicatorColor => switch (_alignment) { + .start => _colorScheme.chrome.shade400, + .end => _colorScheme.brand.shade400, + }; + + @override + Color get backgroundColor => switch (_alignment) { + .start => _colorScheme.backgroundSurfaceStrong, + .end => _colorScheme.brand.shade150, + }; + + @override + OutlinedBorder get shape => RoundedSuperellipseBorder(borderRadius: .all(_radius.lg)); + + @override + BorderSide get side => BorderSide.none; + + @override + EdgeInsetsGeometry get margin => EdgeInsets.symmetric(horizontal: _spacing.xs); + + @override + EdgeInsetsGeometry get padding => EdgeInsetsDirectional.only( + start: _spacing.sm, + end: _spacing.xs, + top: _spacing.xs, + bottom: _spacing.xs, + ); + + @override + OutlinedBorder get thumbnailShape => RoundedSuperellipseBorder(borderRadius: .all(_radius.md)); + + @override + Size get thumbnailSize => const Size.square(40); +} diff --git a/packages/stream_chat_flutter/lib/src/message_widget/stream_system_message.dart b/packages/stream_chat_flutter/lib/src/message_widget/stream_system_message.dart new file mode 100644 index 0000000000..1ed3013a6b --- /dev/null +++ b/packages/stream_chat_flutter/lib/src/message_widget/stream_system_message.dart @@ -0,0 +1,161 @@ +import 'package:flutter/material.dart'; +import 'package:stream_chat_flutter/src/misc/empty_widget.dart'; +import 'package:stream_chat_flutter/stream_chat_flutter.dart'; +import 'package:stream_core_flutter/stream_core_flutter.dart' as core; + +/// {@template streamSystemMessage} +/// A widget that displays a system message as a centered pill-shaped container. +/// +/// [StreamSystemMessage] renders non-user messages such as "User X was added +/// to the group" or "User Y is now an admin". +/// +/// {@tool snippet} +/// +/// Display a system message with default styling: +/// +/// ```dart +/// StreamSystemMessage( +/// message: message, +/// ) +/// ``` +/// {@end-tool} +/// +/// {@tool snippet} +/// +/// Customise the appearance per-instance: +/// +/// ```dart +/// StreamSystemMessage( +/// message: message, +/// onMessageTap: (msg) => print('Tapped: ${msg.id}'), +/// backgroundColor: Colors.amber.shade50, +/// borderColor: Colors.amber.shade200, +/// ) +/// ``` +/// {@end-tool} +/// +/// See also: +/// +/// * [StreamModeratedMessage], which displays moderated messages with the +/// same pill style. +/// * [StreamMessageListView], which hosts system messages in the chat list. +/// {@endtemplate} +class StreamSystemMessage extends StatelessWidget { + /// Creates a system message widget. + /// + /// The [message] is required. All other parameters are optional. + const StreamSystemMessage({ + super.key, + required this.message, + this.onMessageTap, + this.margin, + this.contentPadding, + this.textStyle, + this.backgroundColor, + this.borderColor, + this.borderRadius, + }); + + /// The system message to display. + final Message message; + + /// Called when the message is tapped. + /// + /// If null, no tap gesture is registered on the message. + final OnMessageTap? onMessageTap; + + /// Outer margin around the pill container. + /// + /// When non-null, takes precedence over the theme default. + /// + /// When null (the default), uses horizontal [core.StreamSpacing.xxl]. + final EdgeInsetsGeometry? margin; + + /// Inner padding inside the pill container. + /// + /// When non-null, takes precedence over the theme default. + /// + /// When null (the default), uses horizontal [core.StreamSpacing.sm] and + /// vertical [core.StreamSpacing.xs]. + final EdgeInsetsGeometry? contentPadding; + + /// Text style for the system message text. + /// + /// When non-null, takes precedence over the theme default. + /// + /// When null (the default), uses [core.StreamTextTheme.metadataDefault] + /// with [core.StreamColorScheme.textSecondary] as the text color. + final TextStyle? textStyle; + + /// Background color of the pill container. + /// + /// When non-null, takes precedence over the theme default. + /// + /// When null (the default), uses + /// [core.StreamColorScheme.backgroundSurfaceSubtle]. + final Color? backgroundColor; + + /// Border color of the pill container. + /// + /// When non-null, takes precedence over the theme default. + /// + /// When null (the default), uses [core.StreamColorScheme.borderSubtle]. + final Color? borderColor; + + /// Border radius of the pill container. + /// + /// When non-null, takes precedence over the theme default. + /// + /// When null (the default), uses [core.StreamRadius.xl]. + final BorderRadius? borderRadius; + + @override + Widget build(BuildContext context) { + final message = this.message.replaceMentions(linkify: false); + + final messageText = message.text; + if (messageText == null) return const Empty(); + + final radius = context.streamRadius; + final spacing = context.streamSpacing; + final textTheme = context.streamTextTheme; + final colorScheme = context.streamColorScheme; + + final effectiveMargin = margin ?? .symmetric(horizontal: spacing.xxl); + final effectiveContentPadding = contentPadding ?? .symmetric(horizontal: spacing.sm, vertical: spacing.xs); + final effectiveTextStyle = textStyle ?? textTheme.metadataDefault.copyWith(color: colorScheme.textSecondary); + + final effectiveBorderColor = borderColor ?? colorScheme.borderSubtle; + final effectiveBorderRadius = borderRadius ?? BorderRadius.all(radius.xl); + final effectiveBackgroundColor = backgroundColor ?? colorScheme.backgroundSurfaceSubtle; + + return Material( + type: .transparency, + child: InkWell( + onTap: switch (onMessageTap) { + final onTap? => () => onTap(message), + _ => null, + }, + child: Center( + child: Container( + margin: effectiveMargin, + decoration: BoxDecoration( + color: effectiveBackgroundColor, + border: .all(color: effectiveBorderColor), + borderRadius: effectiveBorderRadius, + ), + child: Padding( + padding: effectiveContentPadding, + child: Text( + messageText, + softWrap: true, + textAlign: .center, + style: effectiveTextStyle, + ), + ), + ), + ), + ), + ); + } +} diff --git a/packages/stream_chat_flutter/lib/src/message_widget/system_message.dart b/packages/stream_chat_flutter/lib/src/message_widget/system_message.dart deleted file mode 100644 index 6908992025..0000000000 --- a/packages/stream_chat_flutter/lib/src/message_widget/system_message.dart +++ /dev/null @@ -1,47 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:stream_chat_flutter/src/misc/empty_widget.dart'; -import 'package:stream_chat_flutter/stream_chat_flutter.dart'; - -/// {@template streamSystemMessage} -/// {@endtemplate} -class StreamSystemMessage extends StatelessWidget { - /// {@macro streamSystemMessage} - const StreamSystemMessage({ - super.key, - required this.message, - this.onMessageTap, - }); - - /// The message to display. - final Message message; - - /// The action to perform when tapping on the message. - final OnMessageTap? onMessageTap; - - @override - Widget build(BuildContext context) { - final theme = StreamChatTheme.of(context); - final message = this.message.replaceMentions(linkify: false); - - final messageText = message.text; - if (messageText == null) return const Empty(); - - return Material( - type: MaterialType.transparency, - child: InkWell( - onTap: switch (onMessageTap) { - final onTap? => () => onTap(message), - _ => null, - }, - child: Text( - messageText, - softWrap: true, - textAlign: TextAlign.center, - style: theme.textTheme.captionBold.copyWith( - color: theme.colorTheme.textLowEmphasis, - ), - ), - ), - ); - } -} diff --git a/packages/stream_chat_flutter/lib/src/message_widget/text_bubble.dart b/packages/stream_chat_flutter/lib/src/message_widget/text_bubble.dart deleted file mode 100644 index 7874319ab7..0000000000 --- a/packages/stream_chat_flutter/lib/src/message_widget/text_bubble.dart +++ /dev/null @@ -1,73 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:stream_chat_flutter/src/misc/empty_widget.dart'; -import 'package:stream_chat_flutter/stream_chat_flutter.dart'; - -/// {@template textBubble} -/// The bubble around a [StreamMessageText]. -/// -/// Used in [MessageCard]. Should not be used elsewhere. -/// {@endtemplate} -class TextBubble extends StatelessWidget { - /// {@macro textBubble} - const TextBubble({ - super.key, - required this.message, - required this.isOnlyEmoji, - required this.textPadding, - required this.messageTheme, - required this.hasUrlAttachments, - required this.hasQuotedMessage, - this.textBuilder, - this.onLinkTap, - this.onMentionTap, - }); - - /// {@macro message} - final Message message; - - /// {@macro isOnlyEmoji} - final bool isOnlyEmoji; - - /// {@macro textPadding} - final EdgeInsets textPadding; - - /// {@macro textBuilder} - final Widget Function(BuildContext, Message)? textBuilder; - - /// {@macro onLinkTap} - final void Function(String)? onLinkTap; - - /// {@macro onMentionTap} - final void Function(User)? onMentionTap; - - /// {@macro messageTheme} - final StreamMessageThemeData messageTheme; - - /// {@macro hasUrlAttachments} - final bool hasUrlAttachments; - - /// {@macro hasQuotedMessage} - final bool hasQuotedMessage; - - @override - Widget build(BuildContext context) { - if (message.text?.trim().isEmpty ?? true) return const Empty(); - return Padding( - padding: isOnlyEmoji ? EdgeInsets.zero : textPadding, - child: textBuilder != null - ? textBuilder!(context, message) - : StreamMessageText( - onLinkTap: onLinkTap, - message: message, - onMentionTap: onMentionTap, - messageTheme: isOnlyEmoji - ? messageTheme.copyWith( - messageTextStyle: messageTheme.messageTextStyle!.copyWith( - fontSize: 42, - ), - ) - : messageTheme, - ), - ); - } -} diff --git a/packages/stream_chat_flutter/lib/src/message_widget/thread_painter.dart b/packages/stream_chat_flutter/lib/src/message_widget/thread_painter.dart deleted file mode 100644 index 09bb63c93f..0000000000 --- a/packages/stream_chat_flutter/lib/src/message_widget/thread_painter.dart +++ /dev/null @@ -1,53 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:stream_chat_flutter/stream_chat_flutter.dart'; - -/// {@template threadReplyPainter} -/// A custom painter used to render thread replies. -/// -/// Used in [BottomRow]. -/// {@endtemplate} -class ThreadReplyPainter extends CustomPainter { - /// {@macro threadReplyPainter} - const ThreadReplyPainter({ - this.context, - required this.color, - this.reverse = false, - }); - - /// The color to paint the thread reply with. - final Color? color; - - /// The [BuildContext] to use to retrieve the [StreamChatTheme]. - final BuildContext? context; - - /// {@macro reverse} - final bool reverse; - - @override - void paint(Canvas canvas, Size size) { - final paint = Paint() - ..color = color ?? StreamChatTheme.of(context!).colorTheme.disabled - ..style = PaintingStyle.stroke - ..strokeWidth = 1 - ..strokeCap = StrokeCap.round; - - final path = Path() - ..moveTo(reverse ? size.width : 0, 0) - ..quadraticBezierTo( - reverse ? size.width : 0, - size.height * 0.38, - reverse ? size.width : 0, - size.height * 0.5, - ) - ..quadraticBezierTo( - reverse ? size.width : 0, - size.height, - reverse ? 0 : size.width, - size.height, - ); - canvas.drawPath(path, paint); - } - - @override - bool shouldRepaint(covariant CustomPainter oldDelegate) => false; -} diff --git a/packages/stream_chat_flutter/lib/src/message_widget/thread_participants.dart b/packages/stream_chat_flutter/lib/src/message_widget/thread_participants.dart deleted file mode 100644 index 5068e2931e..0000000000 --- a/packages/stream_chat_flutter/lib/src/message_widget/thread_participants.dart +++ /dev/null @@ -1,49 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:stream_chat_flutter/stream_chat_flutter.dart'; - -/// {@template threadParticipants} -/// Shows the users participating in a thread. -/// -/// Used in [BottomRow]. -/// {@endtemplate} -class ThreadParticipants extends StatelessWidget { - /// {@macro threadParticipants} - const ThreadParticipants({ - super.key, - required StreamChatThemeData streamChatTheme, - required this.threadParticipants, - }) : _streamChatTheme = streamChatTheme; - - /// {@macro streamChatThemeData} - final StreamChatThemeData _streamChatTheme; - - /// The users participating in the thread. - final Iterable threadParticipants; - - @override - Widget build(BuildContext context) { - var padding = 0.0; - return Stack( - children: threadParticipants.map((user) { - padding += 8.0; - return Positioned( - right: padding - 8, - bottom: 0, - top: 0, - child: Container( - decoration: BoxDecoration( - shape: BoxShape.circle, - color: _streamChatTheme.colorTheme.barsBg, - ), - padding: const EdgeInsets.all(1), - child: StreamUserAvatar( - user: user, - constraints: BoxConstraints.tight(const Size.fromRadius(7)), - showOnlineStatus: false, - ), - ), - ); - }).toList(), - ); - } -} diff --git a/packages/stream_chat_flutter/lib/src/message_widget/user_avatar_transform.dart b/packages/stream_chat_flutter/lib/src/message_widget/user_avatar_transform.dart deleted file mode 100644 index 275d63e05e..0000000000 --- a/packages/stream_chat_flutter/lib/src/message_widget/user_avatar_transform.dart +++ /dev/null @@ -1,54 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:stream_chat_flutter/stream_chat_flutter.dart'; - -/// {@template userAvatarTransform} -/// Transforms a [StreamUserAvatar] according to the specified translation. -/// -/// Used in [MessageWidgetContent]. -/// {@endtemplate} -class UserAvatarTransform extends StatelessWidget { - /// {@macro userAvatarTransform} - const UserAvatarTransform({ - super.key, - required this.translateUserAvatar, - required this.messageTheme, - required this.message, - this.userAvatarBuilder, - this.onUserAvatarTap, - }); - - /// {@macro translateUserAvatar} - final bool translateUserAvatar; - - /// {@macro messageTheme} - final StreamMessageThemeData messageTheme; - - /// {@macro userAvatarBuilder} - final Widget Function(BuildContext, User)? userAvatarBuilder; - - /// {@macro message} - final Message message; - - /// {@macro onUserAvatarTap} - final void Function(User)? onUserAvatarTap; - - @override - Widget build(BuildContext context) { - return Transform.translate( - offset: Offset( - 0, - translateUserAvatar - ? (messageTheme.avatarTheme?.constraints.maxHeight ?? 40) / 2 - : 0, - ), - child: userAvatarBuilder?.call(context, message.user!) ?? - StreamUserAvatar( - user: message.user!, - onTap: onUserAvatarTap, - constraints: messageTheme.avatarTheme!.constraints, - borderRadius: messageTheme.avatarTheme!.borderRadius, - showOnlineStatus: false, - ), - ); - } -} diff --git a/packages/stream_chat_flutter/lib/src/message_widget/username.dart b/packages/stream_chat_flutter/lib/src/message_widget/username.dart deleted file mode 100644 index fad0ff6fb1..0000000000 --- a/packages/stream_chat_flutter/lib/src/message_widget/username.dart +++ /dev/null @@ -1,31 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:stream_chat_flutter/stream_chat_flutter.dart'; - -/// {@template username} -/// Displays the username of a particular message's sender. -/// {@endtemplate} -class Username extends StatelessWidget { - /// {@macro username} - const Username({ - super.key, - required this.message, - required this.messageTheme, - }); - - /// {@macro message} - final Message message; - - /// {@macro messageTheme} - final StreamMessageThemeData messageTheme; - - @override - Widget build(BuildContext context) { - return Text( - message.user?.name ?? '', - maxLines: 1, - key: key, - style: messageTheme.messageAuthorStyle, - overflow: TextOverflow.ellipsis, - ); - } -} diff --git a/packages/stream_chat_flutter/lib/src/misc/adaptive_dialog_action.dart b/packages/stream_chat_flutter/lib/src/misc/adaptive_dialog_action.dart new file mode 100644 index 0000000000..0a674e949d --- /dev/null +++ b/packages/stream_chat_flutter/lib/src/misc/adaptive_dialog_action.dart @@ -0,0 +1,71 @@ +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:stream_chat_flutter/src/theme/stream_chat_theme.dart'; + +/// A platform-adaptive dialog action button that renders appropriately based on +/// the platform. +/// +/// This widget uses [CupertinoDialogAction] on iOS and macOS platforms, +/// and [TextButton] on all other platforms, maintaining the appropriate +/// platform design language. +/// +/// The styling is influenced by the [StreamChatTheme] to ensure consistent +/// appearance with other Stream Chat components. +class AdaptiveDialogAction extends StatelessWidget { + /// Creates an adaptive dialog action. + const AdaptiveDialogAction({ + super.key, + this.onPressed, + this.isDefaultAction = false, + this.isDestructiveAction = false, + required this.child, + }); + + /// The callback that is called when the action is tapped. + final VoidCallback? onPressed; + + /// Whether this action is the default choice in the dialog. + /// + /// Default actions use emphasized styling (bold text) on iOS/macOS. + /// This has no effect on other platforms. + final bool isDefaultAction; + + /// Whether this action performs a destructive action like deletion. + /// + /// Destructive actions are displayed with red text on iOS/macOS. + /// This has no effect on other platforms. + final bool isDestructiveAction; + + /// The widget to display as the content of the action. + /// + /// Typically a [Text] widget. + final Widget child; + + @override + Widget build(BuildContext context) { + final theme = StreamChatTheme.of(context); + + return switch (Theme.of(context).platform) { + TargetPlatform.iOS || TargetPlatform.macOS => CupertinoTheme( + data: CupertinoTheme.of(context).copyWith( + primaryColor: theme.colorTheme.accentPrimary, + ), + child: CupertinoDialogAction( + onPressed: onPressed, + isDefaultAction: isDefaultAction, + isDestructiveAction: isDestructiveAction, + child: child, + ), + ), + _ => TextButton( + onPressed: onPressed, + style: TextButton.styleFrom( + textStyle: theme.textTheme.body, + foregroundColor: theme.colorTheme.accentPrimary, + disabledForegroundColor: theme.colorTheme.disabled, + ), + child: child, + ), + }; + } +} diff --git a/packages/stream_chat_flutter/lib/src/misc/audio_waveform.dart b/packages/stream_chat_flutter/lib/src/misc/audio_waveform.dart deleted file mode 100644 index e35f9277a5..0000000000 --- a/packages/stream_chat_flutter/lib/src/misc/audio_waveform.dart +++ /dev/null @@ -1,487 +0,0 @@ -import 'dart:math' as math; - -import 'package:collection/collection.dart'; -import 'package:flutter/foundation.dart'; -import 'package:flutter/material.dart'; -import 'package:stream_chat_flutter/src/theme/audio_waveform_slider_theme.dart'; -import 'package:stream_chat_flutter/src/theme/audio_waveform_theme.dart'; - -const _kAudioWaveformSliderThumbWidth = 4.0; -const _kAudioWaveformSliderThumbHeight = 28.0; - -/// {@template streamAudioWaveformSlider} -/// A widget that displays an audio waveform and allows the user to interact -/// with it using a slider. -/// {@endtemplate} -class StreamAudioWaveformSlider extends StatefulWidget { - /// {@macro streamAudioWaveformSlider} - const StreamAudioWaveformSlider({ - super.key, - required this.waveform, - this.onChangeStart, - required this.onChanged, - this.onChangeEnd, - this.limit = 100, - this.color, - this.progress = 0, - this.progressColor, - this.minBarHeight, - this.spacingRatio, - this.heightScale, - this.inverse = true, - this.thumbColor, - this.thumbBorderColor, - }); - - /// The waveform data to be drawn. - /// - /// Note: The values should be between 0 and 1. - final List waveform; - - /// Called when the thumb starts being dragged. - final ValueChanged? onChangeStart; - - /// Called while the thumb is being dragged. - final ValueChanged? onChanged; - - /// Called when the thumb stops being dragged. - final ValueChanged? onChangeEnd; - - /// The color of the wave bars. - /// - /// Defaults to [StreamAudioWaveformSliderThemeData.color]. - final Color? color; - - /// The number of wave bars that will be draw in the screen. When the length - /// of [waveform] is bigger than [limit] only the X last bars will be shown. - /// - /// Defaults to 100. - final int limit; - - /// The progress of the audio track. Used to show the progress of the audio. - /// - /// Defaults to 0. - final double progress; - - /// The color of the progressed wave bars. - /// - /// Defaults to [StreamAudioWaveformSliderThemeData.progressColor]. - final Color? progressColor; - - /// The minimum height of the bars. - /// - /// Defaults to [StreamAudioWaveformSliderThemeData.minBarHeight]. - final double? minBarHeight; - - /// The ratio of the spacing between the bars. - /// - /// Defaults to [StreamAudioWaveformSliderThemeData.spacingRatio]. - final double? spacingRatio; - - /// The scale of the height of the bars. - /// - /// Defaults to [StreamAudioWaveformSliderThemeData.heightScale]. - final double? heightScale; - - /// If true, the bars grow from right to left otherwise they grow from left - /// to right. - /// - /// Defaults to true. - final bool inverse; - - /// The color of the slider thumb. - /// - /// Defaults to [StreamAudioWaveformSliderThemeData.thumbColor]. - final Color? thumbColor; - - /// The color of the slider thumb border. - /// - /// Defaults to [StreamAudioWaveformSliderThemeData.thumbBorderColor]. - final Color? thumbBorderColor; - - @override - State createState() => - _StreamAudioWaveformSliderState(); -} - -class _StreamAudioWaveformSliderState extends State { - @override - Widget build(BuildContext context) { - final theme = StreamAudioWaveformSliderTheme.of(context); - final waveformTheme = theme.audioWaveformTheme; - - final color = widget.color ?? waveformTheme!.color!; - final progressColor = widget.progressColor ?? waveformTheme!.progressColor!; - final minBarHeight = widget.minBarHeight ?? waveformTheme!.minBarHeight!; - final spacingRatio = widget.spacingRatio ?? waveformTheme!.spacingRatio!; - final heightScale = widget.heightScale ?? waveformTheme!.heightScale!; - final thumbColor = widget.thumbColor ?? theme.thumbColor!; - final thumbBorderColor = widget.thumbBorderColor ?? theme.thumbBorderColor!; - - return HorizontalSlider( - onChangeStart: widget.onChangeStart, - onChanged: widget.onChanged, - onChangeEnd: widget.onChangeEnd, - child: LayoutBuilder( - builder: (context, constraints) => Stack( - fit: StackFit.expand, - clipBehavior: Clip.none, - alignment: Alignment.center, - children: [ - StreamAudioWaveform( - waveform: widget.waveform, - limit: widget.limit, - color: color, - progress: widget.progress, - progressColor: progressColor, - minBarHeight: minBarHeight, - spacingRatio: spacingRatio, - heightScale: heightScale, - inverse: widget.inverse, - ), - Builder( - // Just using it for the calculation of the thumb position. - builder: (context) { - final progressWidth = constraints.maxWidth * widget.progress; - return AnimatedPositioned( - curve: const ElasticOutCurve(1.05), - duration: const Duration(milliseconds: 300), - left: progressWidth - _kAudioWaveformSliderThumbWidth / 2, - child: StreamAudioWaveformSliderThumb( - color: thumbColor, - borderColor: thumbBorderColor, - height: constraints.maxHeight, - ), - ); - }, - ), - ], - ), - ), - ); - } -} - -/// {@template streamAudioWaveformSliderThumb} -/// A widget that represents the thumb of the [StreamAudioWaveformSlider]. -/// {@endtemplate} -class StreamAudioWaveformSliderThumb extends StatelessWidget { - /// {@macro streamAudioWaveformSliderThumb} - const StreamAudioWaveformSliderThumb({ - super.key, - this.width = _kAudioWaveformSliderThumbWidth, - this.height = _kAudioWaveformSliderThumbHeight, - this.color = Colors.white, - this.borderColor = const Color(0xffecebeb), - }); - - /// The width of the thumb. - final double width; - - /// The height of the thumb. - final double height; - - /// The color of the thumb. - final Color color; - - /// The border color of the thumb. - final Color borderColor; - - @override - Widget build(BuildContext context) { - return Container( - width: width, - height: height, - decoration: BoxDecoration( - color: color, - border: Border.all( - color: borderColor, - strokeAlign: BorderSide.strokeAlignOutside, - ), - borderRadius: BorderRadius.circular(2), - ), - ); - } -} - -/// {@template streamAudioWaveform} -/// A widget that displays an audio waveform. -/// -/// The waveform is drawn using the [waveform] data. The waveform is drawn -/// horizontally and the bars grow from right to left. -/// {@endtemplate} -class StreamAudioWaveform extends StatelessWidget { - /// {@macro streamAudioWaveform} - const StreamAudioWaveform({ - super.key, - required this.waveform, - this.limit = 100, - this.color, - this.progress = 0, - this.progressColor, - this.minBarHeight, - this.spacingRatio, - this.heightScale, - this.inverse = true, - }); - - /// The waveform data to be drawn. - /// - /// Note: The values should be between 0 and 1. - final List waveform; - - /// The color of the wave bars. - /// - /// Defaults to [StreamAudioWaveformThemeData.color]. - final Color? color; - - /// The number of wave bars that will be draw in the screen. When the length - /// of [waveform] is bigger than [limit] only the X last bars will be shown. - /// - /// Defaults to 100. - final int limit; - - /// The progress of the audio track. Used to show the progress of the audio. - /// - /// Defaults to 0. - final double progress; - - /// The color of the progressed wave bars. - /// - /// Defaults to [StreamAudioWaveformThemeData.progressColor]. - final Color? progressColor; - - /// The minimum height of the bars. - /// - /// Defaults to [StreamAudioWaveformThemeData.minBarHeight]. - final double? minBarHeight; - - /// The ratio of the spacing between the bars. - /// - /// Defaults to [StreamAudioWaveformThemeData.spacingRatio]. - final double? spacingRatio; - - /// The scale of the height of the bars. - /// - /// Defaults to [StreamAudioWaveformThemeData.heightScale]. - final double? heightScale; - - /// If true, the bars grow from right to left otherwise they grow from left - /// to right. - /// - /// Defaults to true. - final bool inverse; - - @override - Widget build(BuildContext context) { - final theme = StreamAudioWaveformTheme.of(context); - - final color = this.color ?? theme.color!; - final progressColor = this.progressColor ?? theme.progressColor!; - final minBarHeight = this.minBarHeight ?? theme.minBarHeight!; - final spacingRatio = this.spacingRatio ?? theme.spacingRatio!; - final heightScale = this.heightScale ?? theme.heightScale!; - - return CustomPaint( - willChange: true, - painter: _WaveformPainter( - waveform: waveform.reversed, - limit: limit, - color: color, - progress: progress, - progressColor: progressColor, - minBarHeight: minBarHeight, - spacingRatio: spacingRatio, - heightScale: heightScale, - inverse: inverse, - ), - ); - } -} - -class _WaveformPainter extends CustomPainter { - _WaveformPainter({ - required Iterable waveform, - this.limit = 100, - this.color = const Color(0xff7E828B), - this.progress = 0, - this.progressColor = const Color(0xff005FFF), - this.minBarHeight = 2, - double spacingRatio = 0.3, - this.heightScale = 1, - this.inverse = true, - }) : waveform = [ - ...waveform.take(limit), - if (waveform.length < limit) - // Fill the remaining bars with 0 value - ...List.filled(limit - waveform.length, 0) - ], - spacingRatio = spacingRatio.clamp(0, 1); - - final List waveform; - final Color color; - final int limit; - final double progress; - final Color progressColor; - final double minBarHeight; - final double spacingRatio; - final bool inverse; - final double heightScale; - - @override - void paint(Canvas canvas, Size size) { - final canvasWidth = size.width; - final canvasHeight = size.height; - - // The total spacing between the bars in the canvas. - final spacingWidth = canvasWidth * spacingRatio; - final barsWidth = canvasWidth - spacingWidth; - final barWidth = barsWidth / limit; - final barSpacing = spacingWidth / (limit - 1); - final progressWidth = progress * canvasWidth; - - void _paintBar(int index, double barValue) { - var dx = index * (barWidth + barSpacing) + barWidth / 2; - if (inverse) dx = canvasWidth - dx; - final dy = canvasHeight / 2; - - final barHeight = math.max(barValue * canvasHeight, minBarHeight); - - final rect = RRect.fromRectAndRadius( - Rect.fromCenter( - center: Offset(dx, dy), - width: barWidth, - height: barHeight, - ), - const Radius.circular(2), - ); - - final waveColor = switch (dx <= progressWidth) { - true => progressColor, - false => color, - }; - - final wavePaint = Paint() - ..color = waveColor - ..strokeCap = StrokeCap.round; - - canvas.drawRRect(rect, wavePaint); - } - - // Paint all the bars - waveform.forEachIndexed(_paintBar); - } - - @override - bool shouldRepaint(covariant _WaveformPainter oldDelegate) => - !const ListEquality().equals(waveform, oldDelegate.waveform) || - color != oldDelegate.color || - limit != oldDelegate.limit || - progress != oldDelegate.progress || - progressColor != oldDelegate.progressColor || - minBarHeight != oldDelegate.minBarHeight || - spacingRatio != oldDelegate.spacingRatio || - heightScale != oldDelegate.heightScale || - inverse != oldDelegate.inverse; -} - -/// {@template horizontalSlider} -/// A widget that allows interactive horizontal sliding gestures. -/// -/// The `HorizontalSlider` widget wraps a child widget and allows users to -/// perform sliding gestures horizontally. It can be configured with callbacks -/// to notify the parent widget about the changes in the horizontal value. -/// {@endtemplate} -class HorizontalSlider extends StatefulWidget { - /// Creates a horizontal slider. - const HorizontalSlider({ - super.key, - required this.child, - required this.onChanged, - this.onChangeStart, - this.onChangeEnd, - }); - - /// The child widget wrapped by the slider. - final Widget child; - - /// Called when the horizontal value starts changing. - final ValueChanged? onChangeStart; - - /// Called when the horizontal value changes. - final ValueChanged? onChanged; - - /// Called when the horizontal value stops changing. - final ValueChanged? onChangeEnd; - - @override - State createState() => _HorizontalSliderState(); -} - -class _HorizontalSliderState extends State { - bool _active = false; - - /// Returns true if the slider is interactive. - bool get isInteractive => widget.onChanged != null; - - /// Converts the visual position to a value based on the text direction. - double _getValueFromVisualPosition(double visualPosition) { - final textDirection = Directionality.of(context); - final value = switch (textDirection) { - TextDirection.rtl => 1.0 - visualPosition, - TextDirection.ltr => visualPosition, - }; - - return clampDouble(value, 0, 1); - } - - /// Converts the local position to a horizontal value. - double _getValueFromLocalPosition(Offset globalPosition) { - final box = context.findRenderObject()! as RenderBox; - final localPosition = box.globalToLocal(globalPosition); - final visualPosition = localPosition.dx / box.size.width; - return _getValueFromVisualPosition(visualPosition); - } - - void _handleDragStart(DragStartDetails details) { - if (!_active && isInteractive) { - _active = true; - final value = _getValueFromLocalPosition(details.globalPosition); - widget.onChangeStart?.call(value); - } - } - - void _handleDragUpdate(DragUpdateDetails details) { - _handleHorizontalDrag(details.globalPosition); - } - - void _handleDragEnd(DragEndDetails details) { - if (!mounted) return; - - if (_active && mounted) { - final value = _getValueFromLocalPosition(details.globalPosition); - widget.onChangeEnd?.call(value); - _active = false; - } - } - - /// Handles the sliding gesture. - void _handleHorizontalDrag(Offset globalPosition) { - if (!mounted) return; - - if (isInteractive) { - final value = _getValueFromLocalPosition(globalPosition); - widget.onChanged?.call(value); - } - } - - @override - Widget build(BuildContext context) { - return GestureDetector( - onHorizontalDragStart: _handleDragStart, - onHorizontalDragUpdate: _handleDragUpdate, - onHorizontalDragEnd: _handleDragEnd, - child: widget.child, - ); - } -} diff --git a/packages/stream_chat_flutter/lib/src/misc/back_button.dart b/packages/stream_chat_flutter/lib/src/misc/back_button.dart index a589a5921d..7b440227e2 100644 --- a/packages/stream_chat_flutter/lib/src/misc/back_button.dart +++ b/packages/stream_chat_flutter/lib/src/misc/back_button.dart @@ -24,33 +24,16 @@ class StreamBackButton extends StatelessWidget { @override Widget build(BuildContext context) { - final theme = StreamChatTheme.of(context); - - Widget icon = StreamSvgIcon( - size: 24, - icon: StreamSvgIcons.left, - color: theme.colorTheme.textHighEmphasis, - ); - - if (showUnreadCount) { - icon = Stack( - clipBehavior: Clip.none, - children: [ - icon, - PositionedDirectional( - top: -4, - start: 12, - child: switch (channelId) { - final cid? => StreamUnreadIndicator.channels(cid: cid), - _ => StreamUnreadIndicator(), - }, - ), - ], - ); - } - - return IconButton( - icon: icon, + final iconData = switch (Theme.of(context).platform) { + .iOS || .macOS => context.streamIcons.chevronLeft, + _ => context.streamIcons.arrowLeft, + }; + + Widget button = StreamButton.icon( + type: .ghost, + size: .medium, + style: .secondary, + icon: Icon(iconData), onPressed: () { if (onPressed case final onPressed?) { return onPressed(); @@ -59,5 +42,14 @@ class StreamBackButton extends StatelessWidget { Navigator.maybePop(context); }, ); + + if (showUnreadCount) { + button = switch (channelId) { + final cid? => StreamUnreadIndicator.channels(offset: .zero, cid: cid, child: button), + _ => StreamUnreadIndicator(offset: .zero, child: button), + }; + } + + return button; } } diff --git a/packages/stream_chat_flutter/lib/src/misc/connection_status_builder.dart b/packages/stream_chat_flutter/lib/src/misc/connection_status_builder.dart index e6703420b4..3a44e1465e 100644 --- a/packages/stream_chat_flutter/lib/src/misc/connection_status_builder.dart +++ b/packages/stream_chat_flutter/lib/src/misc/connection_status_builder.dart @@ -29,13 +29,11 @@ class StreamConnectionStatusBuilder extends StatelessWidget { final WidgetBuilder? loadingBuilder; /// The builder that will be used in case of data - final Widget Function(BuildContext context, ConnectionStatus status) - statusBuilder; + final Widget Function(BuildContext context, ConnectionStatus status) statusBuilder; @override Widget build(BuildContext context) { - final stream = connectionStatusStream ?? - StreamChat.of(context).client.wsConnectionStatusStream; + final stream = connectionStatusStream ?? StreamChat.of(context).client.wsConnectionStatusStream; final client = StreamChat.of(context).client; return BetterStreamBuilder( initialData: client.wsConnectionStatus, diff --git a/packages/stream_chat_flutter/lib/src/misc/date_divider.dart b/packages/stream_chat_flutter/lib/src/misc/date_divider.dart index b927fca54f..198d7f041d 100644 --- a/packages/stream_chat_flutter/lib/src/misc/date_divider.dart +++ b/packages/stream_chat_flutter/lib/src/misc/date_divider.dart @@ -1,62 +1,155 @@ import 'package:flutter/material.dart'; -import 'package:stream_chat_flutter/src/misc/timestamp.dart'; -import 'package:stream_chat_flutter/src/utils/date_formatter.dart'; import 'package:stream_chat_flutter/stream_chat_flutter.dart'; +import 'package:stream_core_flutter/stream_core_flutter.dart' as core; /// {@template streamDateDivider} -/// Shows a date divider depending on the date difference +/// A widget that displays a date label as a centered pill-shaped container. +/// +/// [StreamDateDivider] renders a formatted date string (e.g. "Today", +/// "Yesterday", "Mon, Jun 2") used to visually separate messages by day in a +/// [StreamMessageListView]. +/// +/// {@tool snippet} +/// +/// Display a date divider with default styling: +/// +/// ```dart +/// StreamDateDivider( +/// dateTime: DateTime.now(), +/// ) +/// ``` +/// {@end-tool} +/// +/// {@tool snippet} +/// +/// Customise the appearance per-instance: +/// +/// ```dart +/// StreamDateDivider( +/// dateTime: DateTime.now(), +/// uppercase: true, +/// backgroundColor: Colors.amber.shade50, +/// ) +/// ``` +/// {@end-tool} +/// +/// See also: +/// +/// * [StreamMessageListView], which hosts date dividers in the chat list. +/// * [StreamSystemMessage], which displays system messages with a similar +/// pill style. /// {@endtemplate} class StreamDateDivider extends StatelessWidget { - /// {@macro streamDateDivider} + /// Creates a date divider widget. + /// + /// The [dateTime] is required. All other parameters are optional. const StreamDateDivider({ super.key, required this.dateTime, this.uppercase = false, this.formatter, + this.margin, + this.contentPadding, + this.textStyle, + this.backgroundColor, + this.borderRadius, }); - /// [DateTime] to display + /// The date to display. final DateTime dateTime; - /// If text is uppercase + /// Whether the formatted date text should be uppercased. + /// + /// Defaults to `false`. final bool uppercase; - /// Custom formatter for the date + /// Custom formatter for the date. + /// + /// When non-null, overrides the default date formatting logic. final DateFormatter? formatter; + /// Outer margin around the pill container. + /// + /// When non-null, takes precedence over the theme default. + /// + /// When null (the default), uses vertical [core.StreamSpacing.xs]. + final EdgeInsetsGeometry? margin; + + /// Inner padding inside the pill container. + /// + /// When non-null, takes precedence over the theme default. + /// + /// When null (the default), uses horizontal [core.StreamSpacing.xs] and + /// vertical [core.StreamSpacing.xxs]. + final EdgeInsetsGeometry? contentPadding; + + /// Text style for the date label. + /// + /// When non-null, takes precedence over the theme default. + /// + /// When null (the default), uses [core.StreamTextTheme.metadataEmphasis] + /// with [core.StreamColorScheme.textSecondary] as the text color. + final TextStyle? textStyle; + + /// Background color of the pill container. + /// + /// When non-null, takes precedence over the theme default. + /// + /// When null (the default), uses + /// [core.StreamColorScheme.backgroundSurfaceSubtle]. + final Color? backgroundColor; + + /// Border radius of the pill container. + /// + /// When non-null, takes precedence over the theme default. + /// + /// When null (the default), uses [core.StreamRadius.max]. + final BorderRadius? borderRadius; + @override Widget build(BuildContext context) { - final chatThemeData = StreamChatTheme.of(context); + final radius = context.streamRadius; + final spacing = context.streamSpacing; + final textTheme = context.streamTextTheme; + final colorScheme = context.streamColorScheme; + + final effectiveMargin = margin ?? .symmetric(vertical: spacing.xs); + final effectiveContentPadding = contentPadding ?? .symmetric(horizontal: spacing.xs, vertical: spacing.xxs); + final effectiveTextStyle = textStyle ?? textTheme.metadataEmphasis.copyWith(color: colorScheme.textSecondary); + final effectiveBackgroundColor = backgroundColor ?? colorScheme.backgroundSurfaceSubtle; + final effectiveBorderRadius = borderRadius ?? BorderRadius.all(radius.max); + return Center( child: Container( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 1), + margin: effectiveMargin, decoration: BoxDecoration( - color: chatThemeData.colorTheme.overlayDark, - borderRadius: BorderRadius.circular(8), + color: effectiveBackgroundColor, + borderRadius: effectiveBorderRadius, ), - child: StreamTimestamp( - date: dateTime.toLocal(), - style: chatThemeData.textTheme.footnote.copyWith( - color: chatThemeData.colorTheme.barsBg, - ), - formatter: (context, date) { - if (formatter case final formatter?) { - final timestamp = formatter.call(context, date); - if (uppercase) return timestamp.toUpperCase(); - return timestamp; - } + child: Padding( + padding: effectiveContentPadding, + child: StreamTimestamp( + date: dateTime.toLocal(), + style: effectiveTextStyle, + formatter: (context, date) { + if (formatter case final formatter?) { + final timestamp = formatter.call(context, date); + if (uppercase) return timestamp.toUpperCase(); + return timestamp; + } - final timestamp = switch (date) { - _ when date.isToday => context.translations.todayLabel, - _ when date.isYesterday => context.translations.yesterdayLabel, - _ when date.isWithinAWeek => Jiffy.parseFromDateTime(date).EEEE, - _ when date.isWithinAYear => Jiffy.parseFromDateTime(date).MMMd, - _ => Jiffy.parseFromDateTime(date).yMMMd, - }; + final timestamp = switch (date) { + _ when date.isToday => context.translations.todayLabel, + _ when date.isYesterday => context.translations.yesterdayLabel, + _ when date.isWithinAWeek => Jiffy.parseFromDateTime(date).EEEE, + _ when date.isWithinAYear => Jiffy.parseFromDateTime(date).MMMd, + _ => Jiffy.parseFromDateTime(date).yMMMd, + }; - if (uppercase) return timestamp.toUpperCase(); - return timestamp; - }, + if (uppercase) return timestamp.toUpperCase(); + return timestamp; + }, + ), ), ), ); diff --git a/packages/stream_chat_flutter/lib/src/misc/flex_grid.dart b/packages/stream_chat_flutter/lib/src/misc/flex_grid.dart index 5210cf9aa1..ca0e8955a6 100644 --- a/packages/stream_chat_flutter/lib/src/misc/flex_grid.dart +++ b/packages/stream_chat_flutter/lib/src/misc/flex_grid.dart @@ -80,19 +80,19 @@ class FlexGrid extends StatelessWidget { this.reverse = false, this.spacing = 2.0, this.runSpacing = 2.0, - }) : assert( - pattern.count == children.length, - 'The number of children must match the number of cells in the matrix', - ), - assert( - maxChildren == null || maxChildren <= pattern.count, - 'The number of maxChildren must be less than or equal to the number ' - 'of cells in the matrix', - ), - assert( - maxChildren == null || overlayBuilder != null, - 'overlayBuilder must be provided when maxChildren is not null', - ); + }) : assert( + pattern.count == children.length, + 'The number of children must match the number of cells in the matrix', + ), + assert( + maxChildren == null || maxChildren <= pattern.count, + 'The number of maxChildren must be less than or equal to the number ' + 'of cells in the matrix', + ), + assert( + maxChildren == null || overlayBuilder != null, + 'overlayBuilder must be provided when maxChildren is not null', + ); /// The pattern of the grid. /// diff --git a/packages/stream_chat_flutter/lib/src/misc/flexible_fractionally_sized_box.dart b/packages/stream_chat_flutter/lib/src/misc/flexible_fractionally_sized_box.dart new file mode 100644 index 0000000000..c95ba8fae1 --- /dev/null +++ b/packages/stream_chat_flutter/lib/src/misc/flexible_fractionally_sized_box.dart @@ -0,0 +1,91 @@ +import 'package:flutter/material.dart'; + +/// A widget that sizes its child to a fraction of the total available space. +class FlexibleFractionallySizedBox extends StatelessWidget { + /// Creates a widget that sizes its child to a fraction of the total available + /// space. + /// + /// If non-null, the [widthFactor] and [heightFactor] arguments must be + /// non-negative. + const FlexibleFractionallySizedBox({ + super.key, + this.alignment = Alignment.center, + this.widthFactor, + this.heightFactor, + this.child, + }) : assert(widthFactor == null || widthFactor >= 0.0, ''), + assert(heightFactor == null || heightFactor >= 0.0, ''); + + /// The widget below this widget in the tree. + /// + /// {@macro flutter.widgets.ProxyWidget.child} + final Widget? child; + + /// If non-null, the fraction of the incoming width given to the child. + /// + /// If non-null, the child is given a tight width constraint that is the max + /// incoming width constraint multiplied by this factor. + /// + /// If null, the incoming width constraints are passed to the child + /// unmodified. + final double? widthFactor; + + /// If non-null, the fraction of the incoming height given to the child. + /// + /// If non-null, the child is given a tight height constraint that is the max + /// incoming height constraint multiplied by this factor. + /// + /// If null, the incoming height constraints are passed to the child + /// unmodified. + final double? heightFactor; + + /// How to align the child. + /// + /// The x and y values of the alignment control the horizontal and vertical + /// alignment, respectively. An x value of -1.0 means that the left edge of + /// the child is aligned with the left edge of the parent whereas an x value + /// of 1.0 means that the right edge of the child is aligned with the right + /// edge of the parent. Other values interpolate (and extrapolate) linearly. + /// For example, a value of 0.0 means that the center of the child is aligned + /// with the center of the parent. + /// + /// Defaults to [Alignment.center]. + /// + /// See also: + /// + /// * [Alignment], a class with convenient constants typically used to + /// specify an [AlignmentGeometry]. + /// * [AlignmentDirectional], like [Alignment] for specifying alignments + /// relative to text direction. + final AlignmentGeometry alignment; + + @override + Widget build(BuildContext context) { + return LayoutBuilder( + builder: (context, constraints) { + var maxWidth = constraints.maxWidth; + if (widthFactor case final widthFactor?) { + final width = maxWidth * widthFactor; + maxWidth = width; + } + + var maxHeight = constraints.maxHeight; + if (heightFactor case final heightFactor?) { + final height = maxHeight * heightFactor; + maxHeight = height; + } + + return UnconstrainedBox( + alignment: alignment, + child: ConstrainedBox( + constraints: BoxConstraints( + maxWidth: maxWidth, + maxHeight: maxHeight, + ), + child: child, + ), + ); + }, + ); + } +} diff --git a/packages/stream_chat_flutter/lib/src/misc/giphy_chip.dart b/packages/stream_chat_flutter/lib/src/misc/giphy_chip.dart index c956517ba5..771c652dbc 100644 --- a/packages/stream_chat_flutter/lib/src/misc/giphy_chip.dart +++ b/packages/stream_chat_flutter/lib/src/misc/giphy_chip.dart @@ -1,7 +1,7 @@ import 'package:flutter/material.dart'; -import 'package:stream_chat_flutter/src/icons/stream_svg_icon.dart'; import 'package:stream_chat_flutter/src/theme/stream_chat_theme.dart'; import 'package:stream_chat_flutter/src/utils/extensions.dart'; +import 'package:stream_core_flutter/stream_core_flutter.dart'; /// {@template giphy_chip} /// Simple widget which displays a Giphy attribution chip. @@ -21,9 +21,9 @@ class GiphyChip extends StatelessWidget { padding: const EdgeInsets.fromLTRB(4, 4, 8, 4), child: Row( children: [ - StreamSvgIcon( + Icon( + context.streamIcons.bolt, size: 16, - icon: StreamSvgIcons.lightning, color: colorTheme.barsBg, ), Text( diff --git a/packages/stream_chat_flutter/lib/src/misc/gradient_box_border.dart b/packages/stream_chat_flutter/lib/src/misc/gradient_box_border.dart index 7a881edd16..2ecf925e5d 100644 --- a/packages/stream_chat_flutter/lib/src/misc/gradient_box_border.dart +++ b/packages/stream_chat_flutter/lib/src/misc/gradient_box_border.dart @@ -4,15 +4,19 @@ import 'dart:ui' as ui show lerpDouble; import 'package:flutter/material.dart'; import 'package:flutter/widgets.dart'; -const _kDefaultGradient = LinearGradient(colors: [ - Color(0xFF000000), - Color(0xFF000000), -]); - -const _kTransparentGradient = LinearGradient(colors: [ - Color(0x00000000), - Color(0x00000000), -]); +const _kDefaultGradient = LinearGradient( + colors: [ + Color(0xFF000000), + Color(0xFF000000), + ], +); + +const _kTransparentGradient = LinearGradient( + colors: [ + Color(0x00000000), + Color(0x00000000), + ], +); /// {@template gradientBoxBorder} /// A border that draws a gradient instead of a solid color. @@ -109,8 +113,7 @@ class GradientBoxBorder extends BoxBorder { /// Two sides can be merged if one or both are zero-width with /// [GradientBoxBorder.none], or if they both have the same gradient and style bool canMerge(GradientBoxBorder b) { - if ((style == BorderStyle.none && width == 0.0) || - (b.style == BorderStyle.none && b.width == 0.0)) { + if ((style == BorderStyle.none && width == 0.0) || (b.style == BorderStyle.none && b.width == 0.0)) { return true; } return style == b.style && gradient == b.gradient; @@ -209,8 +212,7 @@ class GradientBoxBorder extends BoxBorder { /// Linearly interpolate between two gradient borders. /// /// {@macro dart.ui.shadow.lerp} - static GradientBoxBorder? lerp( - GradientBoxBorder? a, GradientBoxBorder? b, double t) { + static GradientBoxBorder? lerp(GradientBoxBorder? a, GradientBoxBorder? b, double t) { if (identical(a, b)) return a; if (a == null) return b!.scale(t); if (b == null) return a.scale(1.0 - t); @@ -270,10 +272,9 @@ class GradientBoxBorder extends BoxBorder { return switch (shape) { BoxShape.circle => _paintUniformBorderWithCircle(canvas, rect), BoxShape.rectangle => switch (borderRadius) { - final radius? when radius != BorderRadius.zero => - _paintUniformBorderWithRadius(canvas, rect, radius), - _ => _paintUniformBorderWithRectangle(canvas, rect), - }, + final radius? when radius != BorderRadius.zero => _paintUniformBorderWithRadius(canvas, rect, radius), + _ => _paintUniformBorderWithRectangle(canvas, rect), + }, }; } @@ -307,14 +308,16 @@ class GradientBoxBorder extends BoxBorder { Paint _getPaint(Rect rect) { return switch (style) { - BorderStyle.solid => Paint() - ..strokeWidth = width - ..style = PaintingStyle.stroke - ..shader = gradient.createShader(rect), - BorderStyle.none => Paint() - ..strokeWidth = 0.0 - ..style = PaintingStyle.stroke - ..shader = _kTransparentGradient.createShader(rect), + BorderStyle.solid => + Paint() + ..strokeWidth = width + ..style = PaintingStyle.stroke + ..shader = gradient.createShader(rect), + BorderStyle.none => + Paint() + ..strokeWidth = 0.0 + ..style = PaintingStyle.stroke + ..shader = _kTransparentGradient.createShader(rect), }; } diff --git a/packages/stream_chat_flutter/lib/src/misc/info_tile.dart b/packages/stream_chat_flutter/lib/src/misc/info_tile.dart index fd6106491a..0cd7ff9d52 100644 --- a/packages/stream_chat_flutter/lib/src/misc/info_tile.dart +++ b/packages/stream_chat_flutter/lib/src/misc/info_tile.dart @@ -50,13 +50,15 @@ class StreamInfoTile extends StatelessWidget { ), portalFollower: Container( height: 25, - color: backgroundColor ?? + color: + backgroundColor ?? // ignore: deprecated_member_use chatThemeData.colorTheme.textLowEmphasis.withOpacity(0.9), child: Center( child: Text( message, - style: textStyle ?? + style: + textStyle ?? chatThemeData.textTheme.body.copyWith( color: Colors.white, ), diff --git a/packages/stream_chat_flutter/lib/src/misc/markdown_message.dart b/packages/stream_chat_flutter/lib/src/misc/markdown_message.dart deleted file mode 100644 index 9b0926ed08..0000000000 --- a/packages/stream_chat_flutter/lib/src/misc/markdown_message.dart +++ /dev/null @@ -1,108 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_markdown/flutter_markdown.dart'; -import 'package:stream_chat_flutter/src/ai_assistant/streaming_message_view.dart'; -import 'package:stream_chat_flutter/src/theme/message_theme.dart'; - -import 'package:stream_chat_flutter/src/utils/device_segmentation.dart'; - -/// {@template streamMarkdownMessage} -/// A widget that displays a markdown message. This widget uses the markdown -/// package to parse the markdown data and display it. -/// -/// This widget is used by [StreamMessageText] and [StreamingMessageView] to -/// display the message text. -/// {@endtemplate} -class StreamMarkdownMessage extends StatelessWidget { - /// {@macro streamMarkdownMessage} - const StreamMarkdownMessage({ - super.key, - required this.data, - this.selectable, - this.onTapLink, - this.messageTheme, - this.styleSheet, - this.syntaxHighlighter, - this.builders = const {}, - this.paddingBuilders = const {}, - }); - - /// The markdown data to display. - final String data; - - /// Whether the text is selectable. - final bool? selectable; - - /// Called when the user taps a link. - final MarkdownTapLinkCallback? onTapLink; - - /// The theme to apply to the message text. - final StreamMessageThemeData? messageTheme; - - /// Optional style sheet to customize the markdown output. - /// - /// When provided, it will be merged with the default one. - final MarkdownStyleSheet? styleSheet; - - /// The syntax highlighter used to color text in `pre` elements. - /// - /// If null, the [MarkdownStyleSheet.code] style is used for `pre` elements. - final SyntaxHighlighter? syntaxHighlighter; - - /// Render certain tags, usually used with [extensionSet] - /// - /// For example, we will add support for `sub` tag: - /// - /// ```dart - /// builders: { - /// 'sub': SubscriptBuilder(), - /// } - /// ``` - /// - /// The `SubscriptBuilder` is a subclass of [MarkdownElementBuilder]. - final Map builders; - - /// Add padding for different tags (use only for block elements and img) - /// - /// For example, we will add padding for `img` tag: - /// - /// ```dart - /// paddingBuilders: { - /// 'img': ImgPaddingBuilder(), - /// } - /// ``` - /// - /// The `ImgPaddingBuilder` is a subclass of [MarkdownPaddingBuilder]. - final Map paddingBuilders; - - @override - Widget build(BuildContext context) { - final themeData = Theme.of(context); - - return MarkdownBody( - data: data, - selectable: selectable ?? isDesktopDeviceOrWeb, - onTapText: () {}, - onSelectionChanged: (val, selection, cause) {}, - onTapLink: onTapLink, - syntaxHighlighter: syntaxHighlighter, - builders: builders, - paddingBuilders: paddingBuilders, - styleSheet: MarkdownStyleSheet.fromTheme( - themeData.copyWith( - textTheme: themeData.textTheme.apply( - bodyColor: messageTheme?.messageTextStyle?.color, - decoration: messageTheme?.messageTextStyle?.decoration, - decorationColor: messageTheme?.messageTextStyle?.decorationColor, - decorationStyle: messageTheme?.messageTextStyle?.decorationStyle, - fontFamily: messageTheme?.messageTextStyle?.fontFamily, - ), - ), - ) - .copyWith( - a: messageTheme?.messageLinksStyle, - p: messageTheme?.messageTextStyle, - ) - .merge(styleSheet), - ); - } -} diff --git a/packages/stream_chat_flutter/lib/src/misc/option_list_tile.dart b/packages/stream_chat_flutter/lib/src/misc/option_list_tile.dart index 2c093c9db3..3307a31bbb 100644 --- a/packages/stream_chat_flutter/lib/src/misc/option_list_tile.dart +++ b/packages/stream_chat_flutter/lib/src/misc/option_list_tile.dart @@ -60,15 +60,13 @@ class StreamOptionListTile extends StatelessWidget { onTap: onTap, child: Row( children: [ - if (leading != null) - Center(child: leading) - else - const SizedBox(width: 16), + if (leading != null) Center(child: leading) else const SizedBox(width: 16), Expanded( flex: 4, child: Text( title, - style: titleTextStyle ?? + style: + titleTextStyle ?? (titleColor == null ? chatThemeData.textTheme.bodyBold : chatThemeData.textTheme.bodyBold.copyWith( diff --git a/packages/stream_chat_flutter/lib/src/misc/reaction_icon.dart b/packages/stream_chat_flutter/lib/src/misc/reaction_icon.dart deleted file mode 100644 index 60ac8fe9c2..0000000000 --- a/packages/stream_chat_flutter/lib/src/misc/reaction_icon.dart +++ /dev/null @@ -1,28 +0,0 @@ -import 'package:flutter/material.dart'; - -/// {@template reactionIconBuilder} -/// Signature for a function that builds a reaction icon. -/// {@endtemplate} -typedef ReactionIconBuilder = Widget Function( - BuildContext context, - // ignore: avoid_positional_boolean_parameters - bool isHighlighted, - double iconSize, -); - -/// {@template streamReactionIcon} -/// Reaction icon data -/// {@endtemplate} -class StreamReactionIcon { - /// {@macro streamReactionIcon} - const StreamReactionIcon({ - required this.type, - required this.builder, - }); - - /// Type of reaction - final String type; - - /// {@macro reactionIconBuilder} - final ReactionIconBuilder builder; -} diff --git a/packages/stream_chat_flutter/lib/src/misc/reaction_icon_resolver.dart b/packages/stream_chat_flutter/lib/src/misc/reaction_icon_resolver.dart new file mode 100644 index 0000000000..777d631a54 --- /dev/null +++ b/packages/stream_chat_flutter/lib/src/misc/reaction_icon_resolver.dart @@ -0,0 +1,89 @@ +import 'package:stream_core_flutter/stream_core_flutter.dart'; + +/// Maps reaction type strings to [StreamEmojiContent] models. +/// +/// [ReactionIconResolver] provides the set of supported and default reactions +/// and resolves each type into a content model that [StreamEmoji] can render. +/// The resolver returns pure data; the SDK owns rendering. +/// +/// See also: +/// +/// * [DefaultReactionIconResolver], the built-in implementation. +/// * [StreamEmojiContent], the sealed content model returned by [resolve]. +/// * [StreamChatConfigurationData.reactionIconResolver], where the resolver +/// is configured. +abstract class ReactionIconResolver { + /// Creates a [ReactionIconResolver]. + const ReactionIconResolver(); + + /// A small set of commonly used reaction types shown for quick access + /// in the reaction picker bar. + Set get defaultReactions; + + /// All supported reaction types, in display order. + /// + /// Iteration order is used as the reaction display order in default + /// components. Implementations should use a [LinkedHashSet] or + /// equivalent to preserve insertion order. + Set get supportedReactions; + + /// Returns the emoji code for the given reaction [type], or `null` + /// if the type is not supported. + String? emojiCode(String type); + + /// Resolves the given reaction [type] into a content model. + /// + /// Override to return [StreamImageEmoji] for custom emoji. + StreamEmojiContent resolve(String type); +} + +/// Default [ReactionIconResolver] backed by [streamSupportedEmojis]. +/// +/// {@tool snippet} +/// +/// Use custom image emoji (e.g. Twemoji) by extending and overriding +/// [resolve]: +/// +/// ```dart +/// class TwemojiReactionResolver extends DefaultReactionIconResolver { +/// @override +/// StreamEmojiContent resolve(String type) { +/// if (emojiCode(type) != null) { +/// return StreamImageEmoji( +/// url: Uri.parse('https://cdn.example.com/twemoji/$type.png'), +/// ); +/// } +/// return super.resolve(type); +/// } +/// } +/// ``` +/// {@end-tool} +/// +/// See also: +/// +/// * [ReactionIconResolver], the abstract contract. +/// * [streamSupportedEmojis], the emoji catalog used by this resolver. +class DefaultReactionIconResolver extends ReactionIconResolver { + /// Creates a [DefaultReactionIconResolver]. + const DefaultReactionIconResolver(); + + static const _defaultQuickReactions = {'like', 'haha', 'love', 'wow', 'sad'}; + + @override + Set get defaultReactions => _defaultQuickReactions; + + @override + Set get supportedReactions => streamSupportedEmojis.keys.toSet(); + + @override + String? emojiCode(String type) => streamSupportedEmojis[type]?.emoji; + + @override + StreamEmojiContent resolve(String type) { + if (emojiCode(type) case final emoji?) { + return StreamUnicodeEmoji(emoji); + } + + return const StreamUnicodeEmoji('❓'); + } +} diff --git a/packages/stream_chat_flutter/lib/src/misc/separated_reorderable_list_view.dart b/packages/stream_chat_flutter/lib/src/misc/separated_reorderable_list_view.dart index 24e09455d8..bd0e672eb6 100644 --- a/packages/stream_chat_flutter/lib/src/misc/separated_reorderable_list_view.dart +++ b/packages/stream_chat_flutter/lib/src/misc/separated_reorderable_list_view.dart @@ -33,53 +33,51 @@ class SeparatedReorderableListView extends ReorderableListView { super.restorationId, super.clipBehavior, }) : super.builder( - buildDefaultDragHandles: false, - itemCount: math.max(0, itemCount * 2 - 1), - itemBuilder: (BuildContext context, int index) { - final itemIndex = index ~/ 2; - if (index.isEven) { - final listItem = itemBuilder(context, itemIndex); - return ReorderableDelayedDragStartListener( - key: listItem.key, - index: index, - child: listItem, - ); - } + buildDefaultDragHandles: false, + itemCount: math.max(0, itemCount * 2 - 1), + itemBuilder: (BuildContext context, int index) { + final itemIndex = index ~/ 2; + if (index.isEven) { + final listItem = itemBuilder(context, itemIndex); + return ReorderableDelayedDragStartListener( + key: listItem.key, + index: index, + child: listItem, + ); + } - final separator = separatorBuilder(context, itemIndex); - if (separator.key == null) { - return KeyedSubtree( - key: ValueKey('reorderable_separator_$itemIndex'), - child: IgnorePointer(child: separator), - ); - } + final separator = separatorBuilder(context, itemIndex); + if (separator.key == null) { + return KeyedSubtree( + key: ValueKey('reorderable_separator_$itemIndex'), + child: IgnorePointer(child: separator), + ); + } - return separator; - }, - onReorder: (int oldIndex, int newIndex) { - // Adjust the indexes due to an issue in the ReorderableListView - // which isn't going to be fixed in the near future. - // - // issue: https://github.com/flutter/flutter/issues/24786 - if (newIndex > oldIndex) { - newIndex -= 1; - } + return separator; + }, + onReorder: (int oldIndex, int newIndex) { + // Adjust the indexes due to an issue in the ReorderableListView + // which isn't going to be fixed in the near future. + // + // issue: https://github.com/flutter/flutter/issues/24786 + if (newIndex > oldIndex) { + newIndex -= 1; + } - // Ideally should never happen as separators are wrapped in the - // IgnorePointer widget. This is just a safety check. - if (oldIndex % 2 == 1) return; + // Ideally should never happen as separators are wrapped in the + // IgnorePointer widget. This is just a safety check. + if (oldIndex % 2 == 1) return; - // The item moved behind the top/bottom separator we should not - // reorder it. - if ((oldIndex - newIndex).abs() == 1) return; + // The item moved behind the top/bottom separator we should not + // reorder it. + if ((oldIndex - newIndex).abs() == 1) return; - // Calculate the updated indexes - final updatedOldIndex = oldIndex ~/ 2; - final updatedNewIndex = oldIndex > newIndex && newIndex % 2 == 1 - ? (newIndex + 1) ~/ 2 - : newIndex ~/ 2; + // Calculate the updated indexes + final updatedOldIndex = oldIndex ~/ 2; + final updatedNewIndex = oldIndex > newIndex && newIndex % 2 == 1 ? (newIndex + 1) ~/ 2 : newIndex ~/ 2; - return onReorder(updatedOldIndex, updatedNewIndex); - }, - ); + return onReorder(updatedOldIndex, updatedNewIndex); + }, + ); } diff --git a/packages/stream_chat_flutter/lib/src/misc/simple_safe_area.dart b/packages/stream_chat_flutter/lib/src/misc/simple_safe_area.dart index a691b568ee..b43c1216e5 100644 --- a/packages/stream_chat_flutter/lib/src/misc/simple_safe_area.dart +++ b/packages/stream_chat_flutter/lib/src/misc/simple_safe_area.dart @@ -1,27 +1,90 @@ import 'package:flutter/material.dart'; -/// A [SafeArea] with an enabled toggle +/// A simple wrapper around Flutter's [SafeArea] widget. +/// +/// [SimpleSafeArea] provides a convenient way to avoid system intrusions +/// (such as notches, status, and navigation bars) on all or specific sides. +/// +/// By default, all sides are enabled. Use [SimpleSafeArea.only] to specify +/// specific sides to avoid. +/// +/// See also: +/// - [SafeArea], which this widget wraps. +/// class SimpleSafeArea extends StatelessWidget { - /// Constructor for [SimpleSafeArea] + /// Creates a [SimpleSafeArea] that avoids system intrusions either on all + /// sides or none. const SimpleSafeArea({ super.key, - this.enabled = true, + bool? enabled, + this.minimum = EdgeInsets.zero, + this.maintainBottomViewPadding = false, + required this.child, + }) : left = enabled ?? true, + top = enabled ?? true, + right = enabled ?? true, + bottom = enabled ?? true; + + /// Creates a [SimpleSafeArea] that avoids system intrusions only on the + /// specified sides. + const SimpleSafeArea.only({ + super.key, + this.left = false, + this.top = false, + this.right = false, + this.bottom = false, + this.minimum = EdgeInsets.zero, + this.maintainBottomViewPadding = false, required this.child, }); - /// Wrap [child] with [SafeArea] - final bool? enabled; + /// Whether to avoid system intrusions on the left. + final bool left; + + /// Whether to avoid system intrusions at the top of the screen, typically the + /// system status bar. + final bool top; + + /// Whether to avoid system intrusions on the right. + final bool right; + + /// Whether to avoid system intrusions on the bottom side of the screen. + final bool bottom; + + /// This minimum padding to apply. + /// + /// The greater of the minimum insets and the media padding will be applied. + final EdgeInsets minimum; + + /// Specifies whether the [SafeArea] should maintain the bottom + /// [MediaQueryData.viewPadding] instead of the bottom + /// [MediaQueryData.padding], defaults to false. + /// + /// For example, if there is an onscreen keyboard displayed above the + /// SafeArea, the padding can be maintained below the obstruction rather than + /// being consumed. This can be helpful in cases where your layout contains + /// flexible widgets, which could visibly move when opening a software + /// keyboard due to the change in the padding value. Setting this to true will + /// avoid the UI shift. + final bool maintainBottomViewPadding; - /// Child widget to wrap + /// The widget below this widget in the tree. + /// + /// The padding on the [MediaQuery] for the [child] will be suitably adjusted + /// to zero out any sides that were avoided by this widget. + /// + /// {@macro flutter.widgets.ProxyWidget.child} final Widget child; @override Widget build(BuildContext context) { return SafeArea( - left: enabled ?? true, - top: enabled ?? true, - right: enabled ?? true, - bottom: enabled ?? true, + left: left, + top: top, + right: right, + bottom: bottom, + minimum: minimum, + maintainBottomViewPadding: maintainBottomViewPadding, child: child, ); } diff --git a/packages/stream_chat_flutter/lib/src/misc/size_change_listener.dart b/packages/stream_chat_flutter/lib/src/misc/size_change_listener.dart new file mode 100644 index 0000000000..1e6a2adfcd --- /dev/null +++ b/packages/stream_chat_flutter/lib/src/misc/size_change_listener.dart @@ -0,0 +1,42 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; + +/// A widget that calls the callback when the layout dimensions of +/// its child change. +class SizeChangeListener extends SingleChildRenderObjectWidget { + /// Creates a new instance of [SizeChangeListener]. + const SizeChangeListener({ + super.key, + required this.onSizeChanged, + super.child, + }); + + /// The action to perform when the size of child widget changes. + final ValueChanged onSizeChanged; + + @override + RenderObject createRenderObject(BuildContext context) { + return _RenderSizeChangedWithCallback(onSizeChanged: onSizeChanged); + } +} + +class _RenderSizeChangedWithCallback extends RenderProxyBox { + _RenderSizeChangedWithCallback({ + required this.onSizeChanged, + }); + + final ValueChanged onSizeChanged; + Size? _oldSize; + + @override + void performLayout() { + super.performLayout(); + if (size != _oldSize) { + _oldSize = size; + WidgetsBinding.instance.addPostFrameCallback((_) { + // Call the callback with the new size + onSizeChanged.call(size); + }); + } + } +} diff --git a/packages/stream_chat_flutter/lib/src/misc/staggered_scale_transition.dart b/packages/stream_chat_flutter/lib/src/misc/staggered_scale_transition.dart new file mode 100644 index 0000000000..8dc1dddd40 --- /dev/null +++ b/packages/stream_chat_flutter/lib/src/misc/staggered_scale_transition.dart @@ -0,0 +1,148 @@ +import 'package:ezanimation/ezanimation.dart'; +import 'package:flutter/material.dart'; + +/// {@template staggeredScaleTransition} +/// A widget that scales in its [children] with a staggered animation. +/// +/// Each child pops in sequentially with a configurable [staggerDelay]. +/// +/// By default, children animate from last to first. Set [animateReversed] +/// to `false` to animate from first to last. +/// +/// {@tool snippet} +/// +/// ```dart +/// StaggeredScaleTransition( +/// children: [ +/// Icon(Icons.star), +/// Icon(Icons.favorite), +/// Icon(Icons.thumb_up), +/// ], +/// ) +/// ``` +/// {@end-tool} +/// {@endtemplate} +class StaggeredScaleTransition extends StatefulWidget { + /// {@macro staggeredScaleTransition} + const StaggeredScaleTransition({ + super.key, + required this.children, + this.staggerDelay = const Duration(milliseconds: 30), + this.animateReversed = true, + }); + + /// The widgets to display with staggered scale-in animation. + final List children; + + /// The delay between the start of each child's animation. + /// + /// Defaults to 30 milliseconds. + final Duration staggerDelay; + + /// Whether to animate children in reversed list order. + /// + /// When `true`, children animate from last to first in list order. + /// When `false`, children animate from first to last in list order. + /// + /// Defaults to `true`. + final bool animateReversed; + + @override + State createState() => _StaggeredScaleTransitionState(); +} + +class _StaggeredScaleTransitionState extends State { + List _animations = []; + + void _initAnimations() { + _animations = List.generate( + widget.children.length, + (index) => EzAnimation.tween( + Tween(begin: 0.0, end: 1.0), + const Duration(milliseconds: 120), + curve: Curves.bounceOut, + ), + ); + } + + void _triggerAnimations() async { + final iterable = switch (widget.animateReversed) { + true => _animations.reversed, + false => _animations, + }; + + for (final animation in iterable) { + if (!mounted) return; + animation.start(); + await Future.delayed(widget.staggerDelay); + } + } + + void _dismissAnimations() { + for (final animation in _animations) { + animation.stop(); + } + } + + void _disposeAnimations() { + for (final animation in _animations) { + animation.dispose(); + } + } + + @override + void initState() { + super.initState(); + _initAnimations(); + + // Trigger animations at the end of the frame to avoid jank. + WidgetsBinding.instance.endOfFrame.then((_) => _triggerAnimations()); + } + + @override + void didUpdateWidget(covariant StaggeredScaleTransition oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.children.length != widget.children.length) { + // Dismiss and dispose old animations. + _dismissAnimations(); + _disposeAnimations(); + + // Initialize new animations. + _initAnimations(); + + // Trigger animations at the end of the frame to avoid jank. + WidgetsBinding.instance.endOfFrame.then((_) => _triggerAnimations()); + } + } + + @override + void dispose() { + _dismissAnimations(); + _disposeAnimations(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + for (var i = 0; i < widget.children.length; i++) + AnimatedBuilder( + animation: _animations[i], + builder: (context, child) { + final value = _animations[i].value; + // Width grows at 2x the scale rate so the row reaches full + // width before the bounce oscillation starts. + return Align( + widthFactor: (value * 2.0).clamp(0.0, 1.0), + heightFactor: 1, + child: Transform.scale(scale: value, child: child), + ); + }, + child: widget.children[i], + ), + ], + ); + } +} diff --git a/packages/stream_chat_flutter/lib/src/misc/stream_modal.dart b/packages/stream_chat_flutter/lib/src/misc/stream_modal.dart new file mode 100644 index 0000000000..03665115a0 --- /dev/null +++ b/packages/stream_chat_flutter/lib/src/misc/stream_modal.dart @@ -0,0 +1,62 @@ +import 'dart:ui'; + +import 'package:flutter/material.dart'; +import 'package:flutter_portal/flutter_portal.dart'; +import 'package:stream_chat_flutter/src/theme/stream_chat_theme.dart'; + +/// Shows a modal dialog with customized transitions and backdrop effects. +/// +/// This function is a wrapper around [showGeneralDialog] that provides +/// a consistent look and feel for modals in Stream Chat. +/// +/// Returns a [Future] that resolves to the value passed to [Navigator.pop] +/// when the dialog is closed. +Future showStreamDialog({ + required BuildContext context, + required WidgetBuilder builder, + bool barrierDismissible = true, + String? barrierLabel, + Color? barrierColor, + Duration transitionDuration = const Duration(milliseconds: 335), + RouteTransitionsBuilder? transitionBuilder, + bool useRootNavigator = true, + RouteSettings? routeSettings, + Offset? anchorPoint, +}) { + assert(debugCheckHasMaterialLocalizations(context), ''); + final localizations = MaterialLocalizations.of(context); + + final theme = StreamChatTheme.of(context); + final colorTheme = theme.colorTheme; + + final capturedThemes = InheritedTheme.capture( + from: context, + to: Navigator.of(context, rootNavigator: useRootNavigator).context, + ); + + return showGeneralDialog( + context: context, + useRootNavigator: useRootNavigator, + anchorPoint: anchorPoint, + routeSettings: routeSettings, + transitionDuration: transitionDuration, + barrierDismissible: barrierDismissible, + barrierColor: barrierColor ?? colorTheme.overlay, + barrierLabel: barrierLabel ?? localizations.modalBarrierDismissLabel, + transitionBuilder: (context, animation, secondaryAnimation, child) { + final sigma = 10 * animation.value; + final scaleAnimation = Tween(begin: 0, end: 1).animate( + CurvedAnimation(parent: animation, curve: Curves.easeOutBack), + ); + + return BackdropFilter( + filter: ImageFilter.blur(sigmaX: sigma, sigmaY: sigma), + child: ScaleTransition(scale: scaleAnimation, child: child), + ); + }, + pageBuilder: (context, animation, secondaryAnimation) { + final pageChild = Portal(child: Builder(builder: builder)); + return StreamChatTheme(data: theme, child: capturedThemes.wrap(pageChild)); + }, + ); +} diff --git a/packages/stream_chat_flutter/lib/src/misc/swipeable.dart b/packages/stream_chat_flutter/lib/src/misc/swipeable.dart index 45b9b2dceb..8fd78e8c9e 100644 --- a/packages/stream_chat_flutter/lib/src/misc/swipeable.dart +++ b/packages/stream_chat_flutter/lib/src/misc/swipeable.dart @@ -13,10 +13,11 @@ typedef SwipeDirectionCallback = void Function(SwipeDirection direction); /// dismissing action. /// /// Used by [Swipeable.backgroundBuilder]. -typedef BackgroundWidgetBuilder = Widget Function( - BuildContext context, - SwipeUpdateDetails details, -); +typedef BackgroundWidgetBuilder = + Widget Function( + BuildContext context, + SwipeUpdateDetails details, + ); /// The direction in which a [Swipeable] can be swiped. enum SwipeDirection { @@ -32,7 +33,7 @@ enum SwipeDirection { startToEnd, /// The [Swipeable] cannot be swiped by dragging. - none + none, } /// A widget that can be swiped in a specified direction. @@ -95,9 +96,9 @@ class Swipeable extends StatefulWidget { this.dragStartBehavior = DragStartBehavior.start, this.behavior = HitTestBehavior.opaque, }) : assert( - swipeThreshold >= 0.0 && swipeThreshold <= 1.0, - 'swipeThreshold must be between 0.0 and 1.0', - ); + swipeThreshold >= 0.0 && swipeThreshold <= 1.0, + 'swipeThreshold must be between 0.0 and 1.0', + ); /// The widget below this widget in the tree. /// @@ -225,8 +226,7 @@ class _SwipeableClipper extends CustomClipper { } } -class _SwipeableState extends State - with SingleTickerProviderStateMixin, AutomaticKeepAliveClientMixin { +class _SwipeableState extends State with SingleTickerProviderStateMixin, AutomaticKeepAliveClientMixin { @override void initState() { super.initState(); @@ -261,13 +261,9 @@ class _SwipeableState extends State } switch (Directionality.of(context)) { case TextDirection.rtl: - return extent < 0 - ? SwipeDirection.startToEnd - : SwipeDirection.endToStart; + return extent < 0 ? SwipeDirection.startToEnd : SwipeDirection.endToStart; case TextDirection.ltr: - return extent > 0 - ? SwipeDirection.startToEnd - : SwipeDirection.endToStart; + return extent > 0 ? SwipeDirection.startToEnd : SwipeDirection.endToStart; } } @@ -280,8 +276,7 @@ class _SwipeableState extends State void _handleDragStart(DragStartDetails details) { _dragUnderway = true; if (_moveController!.isAnimating) { - _dragExtent = - _moveController!.value * _overallDragAxisExtent * _dragExtent.sign; + _dragExtent = _moveController!.value * _overallDragAxisExtent * _dragExtent.sign; _moveController!.stop(); } else { _dragExtent = 0.0; diff --git a/packages/stream_chat_flutter/lib/src/misc/thread_header.dart b/packages/stream_chat_flutter/lib/src/misc/thread_header.dart index 1142ac4507..d6c3995871 100644 --- a/packages/stream_chat_flutter/lib/src/misc/thread_header.dart +++ b/packages/stream_chat_flutter/lib/src/misc/thread_header.dart @@ -1,204 +1,105 @@ import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; import 'package:stream_chat_flutter/stream_chat_flutter.dart'; /// {@template streamThreadHeader} -/// ![screenshot](https://raw.githubusercontent.com/GetStream/stream-chat-flutter/master/packages/stream_chat_flutter/screenshots/thread_header.png) -/// ![screenshot](https://raw.githubusercontent.com/GetStream/stream-chat-flutter/master/packages/stream_chat_flutter/screenshots/thread_header_paint.png) -/// /// Shows information about the current message thread. /// -/// ```dart -/// class ThreadPage extends StatelessWidget { -/// final Message parent; -/// -/// ThreadPage({ -/// Key key, -/// this.parent, -/// }) : super(key: key); -/// -/// @override -/// Widget build(BuildContext context) { -/// return Scaffold( -/// appBar: ThreadHeader( -/// parent: parent, -/// ), -/// body: Column( -/// children: [ -/// Expanded( -/// child: MessageListView( -/// parentMessage: parent, -/// ), -/// ), -/// MessageInput( -/// parentMessage: parent, -/// ), -/// ], -/// ), -/// ); -/// } -/// } -/// ``` -/// -/// Usually you would use this widget as an [AppBar] inside a [Scaffold]. -/// However you can also use it as a normal widget. -/// -/// A [StreamChannel] ancestor is required in order to provide the -/// information about the channel. -/// -/// Every part of the widget uses a [StreamBuilder] to render the channel -/// information as soon as it updates. +/// Renders a [StreamAppBar] with the thread's reply label as the title and a +/// live typing indicator (or reply count) as the subtitle. Inherits the +/// [StreamAppBar] auto-implied back button — pass [leading] to override. /// -/// By default the widget shows a backButton that calls [Navigator.pop]. -/// You can disable this button using the [showBackButton] property. -/// Alternatively, you can override the behavior with [onBackPressed]. +/// The bar's chrome (background, padding, typography, divider) is themed via +/// [StreamChatThemeData.threadHeaderTheme]. Per-instance overrides go on +/// [style]. /// -/// The UI is rendered based on the first ancestor of type [StreamChatTheme] -/// and the [ChannelTheme.channelHeaderTheme] property. Modify it to change -/// the widget's appearance. +/// A [StreamChannel] ancestor is required so that the typing indicator can +/// observe the channel's typing events. /// {@endtemplate} -class StreamThreadHeader extends StatelessWidget - implements PreferredSizeWidget { +class StreamThreadHeader extends StatelessWidget implements PreferredSizeWidget { /// {@macro streamThreadHeader} const StreamThreadHeader({ super.key, required this.parent, - this.showBackButton = true, - this.onBackPressed, + this.leading, + this.automaticallyImplyLeading = true, this.title, this.subtitle, - this.centerTitle, - this.leading, - this.actions, - this.onTitleTap, - this.showTypingIndicator = true, - this.backgroundColor, - this.elevation = 1, - }) : preferredSize = const Size.fromHeight(kToolbarHeight); - - /// Whether to show the leading back button. - /// - /// Defaults to `true`. - final bool showBackButton; + this.trailing, + this.primary = true, + this.style, + }); - /// The action to perform when pressing the back button. - /// - /// By default it calls [Navigator.pop] - final VoidCallback? onBackPressed; + /// The message parent of this thread. + final Message parent; - /// The action to perform when the title is tapped. - final VoidCallback? onTitleTap; + /// {@macro StreamAppBar.leading} + final Widget? leading; - /// The message parent of this thread - final Message parent; + /// {@macro StreamAppBar.automaticallyImplyLeading} + final bool automaticallyImplyLeading; - /// Title widget + /// {@macro StreamAppBar.title} + /// + /// Defaults to the localized "Thread reply" label. final Widget? title; - /// Subtitle widget + /// {@macro StreamAppBar.subtitle} + /// + /// Defaults to a live [StreamTypingIndicator] that falls back to the + /// thread's reply count when nobody is typing. final Widget? subtitle; - /// Whether the title should be centered - final bool? centerTitle; - - /// Leading widget - final Widget? leading; + /// {@macro StreamAppBar.trailing} + final Widget? trailing; - /// {@macro flutter.material.appbar.actions} - final List? actions; + /// {@macro StreamAppBar.primary} + final bool primary; - /// Whether to show the typing indicator if users are currently typing. + /// {@macro StreamAppBar.style} /// - /// Defaults to `true`. - final bool showTypingIndicator; - - /// The background color of this [StreamThreadHeader]. - final Color? backgroundColor; + /// Per-instance override; merges over + /// [StreamChatThemeData.threadHeaderTheme]. + final StreamAppBarStyle? style; - /// The elevation for this [StreamThreadHeader]. - final double elevation; + @override + Size get preferredSize => const Size.fromHeight(kStreamToolbarHeight); @override Widget build(BuildContext context) { - final effectiveCenterTitle = getEffectiveCenterTitle( - Theme.of(context), - actions: actions, - centerTitle: centerTitle, + final channel = StreamChannel.maybeOf(context)?.channel; + final headerTheme = StreamChatTheme.of(context).threadHeaderTheme; + + var leading = this.leading; + if (leading == null && automaticallyImplyLeading) { + leading = StreamBackButton(channelId: channel?.cid, showUnreadCount: true); + } + + Widget? fallbackSubtitle; + if (parent.replyCount case final count? when count > 0) { + fallbackSubtitle = Text(context.translations.threadReplyCountText(count)); + } + + var title = this.title; + title ??= Text(context.translations.threadReplyLabel); + + var subtitle = this.subtitle; + subtitle ??= StreamTypingIndicator( + channel: channel, + parentId: parent.id, + alternativeWidget: fallbackSubtitle, ); - final channelHeaderTheme = StreamChannelHeaderTheme.of(context); - - final defaultSubtitle = subtitle ?? - Row( - mainAxisSize: MainAxisSize.min, - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Text( - '${context.translations.withText} ', - style: channelHeaderTheme.subtitleStyle, - ), - Flexible( - child: StreamChannelName( - channel: StreamChannel.of(context).channel, - textStyle: channelHeaderTheme.subtitleStyle, - ), - ), - ], - ); - - final theme = Theme.of(context); - return AppBar( - automaticallyImplyLeading: false, - toolbarTextStyle: theme.textTheme.bodyMedium, - titleTextStyle: theme.textTheme.titleLarge, - systemOverlayStyle: theme.brightness == Brightness.dark - ? SystemUiOverlayStyle.light - : SystemUiOverlayStyle.dark, - elevation: elevation, - leading: leading ?? - (showBackButton - ? StreamBackButton( - channelId: StreamChannel.of(context).channel.cid, - onPressed: onBackPressed, - showUnreadCount: true, - ) - : const SizedBox()), - backgroundColor: backgroundColor ?? channelHeaderTheme.color, - centerTitle: centerTitle, - actions: actions, - title: InkWell( - onTap: onTitleTap, - child: SizedBox( - height: preferredSize.height, - width: 250, - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - crossAxisAlignment: effectiveCenterTitle - ? CrossAxisAlignment.center - : CrossAxisAlignment.stretch, - children: [ - title ?? - Text( - context.translations.threadReplyLabel, - style: channelHeaderTheme.titleStyle, - ), - const SizedBox(height: 2), - if (showTypingIndicator) - StreamTypingIndicator( - channel: StreamChannel.of(context).channel, - style: channelHeaderTheme.subtitleStyle, - parentId: parent.id, - alternativeWidget: defaultSubtitle, - ) - else - defaultSubtitle, - ], - ), - ), + return StreamAppBarTheme( + data: headerTheme, + child: StreamAppBar( + leading: leading, + automaticallyImplyLeading: automaticallyImplyLeading, + title: title, + subtitle: subtitle, + trailing: trailing, + primary: primary, + style: style, ), ); } - - @override - final Size preferredSize; } diff --git a/packages/stream_chat_flutter/lib/src/misc/visible_footnote.dart b/packages/stream_chat_flutter/lib/src/misc/visible_footnote.dart index d2308f9d7d..50d52ae219 100644 --- a/packages/stream_chat_flutter/lib/src/misc/visible_footnote.dart +++ b/packages/stream_chat_flutter/lib/src/misc/visible_footnote.dart @@ -2,7 +2,7 @@ import 'package:flutter/material.dart'; import 'package:stream_chat_flutter/stream_chat_flutter.dart'; /// {@template streamVisibleFootnote} -/// Informs the user about a [StreamMessageWidget]'s visibility to the current +/// Informs the user about a [StreamMessageItem]'s visibility to the current /// user. /// /// Used in [StreamGiphyAttachment]. @@ -17,9 +17,9 @@ class StreamVisibleFootnote extends StatelessWidget { return Row( mainAxisSize: MainAxisSize.min, children: [ - StreamSvgIcon( + Icon( + context.streamIcons.eyeFill, size: 16, - icon: StreamSvgIcons.eye, color: chatThemeData.colorTheme.textLowEmphasis, ), const SizedBox(width: 8), diff --git a/packages/stream_chat_flutter/lib/src/poll/creator/poll_config_option.dart b/packages/stream_chat_flutter/lib/src/poll/creator/poll_config_option.dart new file mode 100644 index 0000000000..a808b0b632 --- /dev/null +++ b/packages/stream_chat_flutter/lib/src/poll/creator/poll_config_option.dart @@ -0,0 +1,180 @@ +import 'package:flutter/material.dart'; +import 'package:stream_chat_flutter/stream_chat_flutter.dart'; + +/// {@template pollConfigOption} +/// A card-style toggle for poll configuration options. +/// +/// Renders a rounded card with a title, description, and toggle switch. +/// When the switch is enabled and [child] is provided, it is revealed +/// below the header with an animated size transition. +/// +/// Can be nested — since the header is a simple [Row] rather than a tappable +/// list tile, padding from an outer [PollConfigOption] does not interfere +/// with an inner one. +/// {@endtemplate} +class PollConfigOption extends StatelessWidget { + /// {@macro pollConfigOption} + const PollConfigOption({ + super.key, + this.value = false, + required this.title, + this.description, + this.child, + this.backgroundColor, + this.contentPadding, + this.childSpacing, + this.onChanged, + }); + + /// Whether the toggle switch is on. + final bool value; + + /// The primary label of the card. + final String title; + + /// An optional short description displayed below [title]. + final String? description; + + /// Optional widget displayed below the header when [value] is true. + final Widget? child; + + /// The background color of the card. + /// + /// Defaults to [StreamColorScheme.backgroundSurfaceCard]. + final Color? backgroundColor; + + /// The padding inside the card around the content. + /// + /// Defaults to `EdgeInsets.all(spacing.md)`. Pass [EdgeInsets.zero] for + /// nested cards that sit inside a parent card's content padding. + final EdgeInsetsGeometry? contentPadding; + + /// The vertical spacing between the header and [child]. + /// + /// Defaults to `spacing.md`. + final double? childSpacing; + + /// Called when the user toggles the switch on or off. + /// + /// The card passes the new value to the callback but does not actually + /// change state until the parent widget rebuilds with the new [value]. + final ValueChanged? onChanged; + + @override + Widget build(BuildContext context) { + final theme = StreamPollCreatorTheme.of(context); + final defaults = _PollConfigOptionDefaults(context); + + final configOptionStyle = theme.configOptionStyle; + + final radius = context.streamRadius; + + final effectiveBackgroundColor = backgroundColor ?? configOptionStyle?.backgroundColor ?? defaults.backgroundColor; + final effectiveContentPadding = contentPadding ?? configOptionStyle?.contentPadding ?? defaults.contentPadding; + final effectiveChildSpacing = childSpacing ?? configOptionStyle?.childSpacing ?? defaults.childSpacing; + + return AnimatedSize( + duration: kThemeAnimationDuration, + alignment: Alignment.topCenter, + child: DecoratedBox( + decoration: BoxDecoration( + color: effectiveBackgroundColor, + borderRadius: BorderRadius.all(radius.xl), + ), + child: Padding( + padding: effectiveContentPadding, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + spacing: effectiveChildSpacing, + children: [ + _PollConfigOptionHeader( + title: title, + description: description, + value: value, + onChanged: onChanged, + ), + if (child case final child? when value) child, + ], + ), + ), + ), + ); + } +} + +// The header row: title/description on the left, toggle switch on the right. +class _PollConfigOptionHeader extends StatelessWidget { + const _PollConfigOptionHeader({ + required this.value, + required this.title, + this.description, + this.onChanged, + }); + + final String title; + final String? description; + final bool value; + final ValueChanged? onChanged; + + @override + Widget build(BuildContext context) { + final spacing = context.streamSpacing; + + final theme = StreamPollCreatorTheme.of(context); + final defaults = _PollConfigOptionDefaults(context); + final configOptionStyle = theme.configOptionStyle; + + final effectiveTitleStyle = configOptionStyle?.titleTextStyle ?? defaults.titleTextStyle; + final effectiveDescriptionStyle = configOptionStyle?.descriptionTextStyle ?? defaults.descriptionTextStyle; + final effectiveSwitchStyle = configOptionStyle?.switchStyle ?? defaults.switchStyle; + + return Row( + spacing: spacing.md, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Expanded( + child: Column( + spacing: spacing.xxs, + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(title, style: effectiveTitleStyle), + if (description case final description?) Text(description, style: effectiveDescriptionStyle), + ], + ), + ), + StreamSwitch( + value: value, + onChanged: onChanged, + style: effectiveSwitchStyle, + ), + ], + ); + } +} + +class _PollConfigOptionDefaults extends StreamPollConfigOptionStyle { + _PollConfigOptionDefaults(this._context); + + final BuildContext _context; + + late final _colorScheme = _context.streamColorScheme; + late final _textTheme = _context.streamTextTheme; + late final _spacing = _context.streamSpacing; + + @override + double get childSpacing => _spacing.md; + + @override + Color get backgroundColor => _colorScheme.backgroundSurfaceCard; + + @override + EdgeInsetsGeometry get contentPadding => EdgeInsets.all(_spacing.md); + + @override + TextStyle get titleTextStyle => _textTheme.headingSm.copyWith(color: _colorScheme.textPrimary); + + @override + TextStyle get descriptionTextStyle => _textTheme.captionDefault.copyWith(color: _colorScheme.textTertiary); +} diff --git a/packages/stream_chat_flutter/lib/src/poll/creator/poll_option_reorderable_list_view.dart b/packages/stream_chat_flutter/lib/src/poll/creator/poll_option_reorderable_list_view.dart index cff3c96253..4469fe44fa 100644 --- a/packages/stream_chat_flutter/lib/src/poll/creator/poll_option_reorderable_list_view.dart +++ b/packages/stream_chat_flutter/lib/src/poll/creator/poll_option_reorderable_list_view.dart @@ -1,10 +1,9 @@ -import 'dart:ui'; - import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; import 'package:stream_chat_flutter/src/misc/separated_reorderable_list_view.dart'; import 'package:stream_chat_flutter/src/poll/creator/stream_delete_option_dialog.dart'; import 'package:stream_chat_flutter/stream_chat_flutter.dart'; +import 'package:stream_core_flutter/stream_core_flutter.dart'; class _NullConst { const _NullConst(); @@ -48,6 +47,8 @@ class PollOptionItem { } } +enum _PollOptionVariant { option, addOption } + /// {@template pollOptionListItem} /// A widget that represents a poll option list item. /// {@endtemplate} @@ -55,17 +56,34 @@ class PollOptionListItem extends StatelessWidget { /// {@macro pollOptionListItem} const PollOptionListItem({ super.key, - required this.option, + required PollOptionItem this.option, this.hintText, this.focusNode, this.onRemove, this.onChanged, - }); + }) : onAddOptionPressed = null, + _variant = .option; + + /// Creates an "add an option" button styled to match the option list. + const PollOptionListItem.addOption({ + super.key, + this.hintText, + VoidCallback? onPressed, + }) : option = null, + focusNode = null, + onRemove = null, + onChanged = null, + onAddOptionPressed = onPressed, + _variant = .addOption; + + final _PollOptionVariant _variant; /// The poll option item. - final PollOptionItem option; + /// + /// Required for the default constructor, `null` for [addOption]. + final PollOptionItem? option; - /// Hint to be displayed in the poll option list item. + /// Hint to be displayed in the poll option list item or as the button label. final String? hintText; /// The focus node for the text field. @@ -77,66 +95,88 @@ class PollOptionListItem extends StatelessWidget { /// Callback called when the poll option item is changed. final ValueSetter? onChanged; + /// Callback called when the "add an option" button is pressed. + /// + /// If `null`, the button is disabled. + /// Only used by the [addOption] constructor. + final VoidCallback? onAddOptionPressed; + @override Widget build(BuildContext context) { - final theme = StreamPollCreatorTheme.of(context); - final fillColor = theme.optionsTextFieldFillColor; - final borderRadius = theme.optionsTextFieldBorderRadius; - - final colorTheme = StreamChatTheme.of(context).colorTheme; + return switch (_variant) { + _PollOptionVariant.option => _buildOption(context), + _PollOptionVariant.addOption => _buildAddOption(context), + }; + } - return DecoratedBox( - decoration: BoxDecoration( - color: fillColor, - borderRadius: borderRadius, + Widget _buildOption(BuildContext context) { + assert(option != null, 'option must not be null'); + + final icons = context.streamIcons; + + return StreamTextInput( + initialValue: option!.text, + hintText: hintText, + focusNode: focusNode, + textCapitalization: TextCapitalization.sentences, + helperText: option!.error, + helperState: option!.error != null ? StreamHelperState.error : null, + leading: MouseRegion( + cursor: SystemMouseCursors.grab, + child: Icon(icons.reorder), ), - child: Row( - children: [ - Padding( - padding: const EdgeInsetsDirectional.all(16), - child: MouseRegion( - cursor: SystemMouseCursors.grab, - child: Icon( - size: 24, - Icons.drag_handle_rounded, - color: colorTheme.textLowEmphasis, - ), - ), - ), - Expanded( - child: StreamPollTextField( - initialValue: option.text, - hintText: hintText, - style: theme.optionsTextFieldStyle, - fillColor: fillColor, - borderRadius: borderRadius, - errorText: option.error, - errorStyle: theme.optionsTextFieldErrorStyle, - focusNode: focusNode, - contentPadding: const EdgeInsets.symmetric(vertical: 18), - onChanged: switch (onChanged) { - final onChanged? => (text) { - final updated = option.copyWith(text: text); - return onChanged.call(updated); - }, - _ => null, - }, - ), - ), - IconButton( - iconSize: 24, - icon: const StreamSvgIcon(icon: StreamSvgIcons.delete), - style: IconButton.styleFrom( - foregroundColor: colorTheme.textLowEmphasis, - ), - // TODO: Enable once we have min SDK set to 3.29.0 - // onLongPress: () {/* Consume long press */}, - onPressed: switch (onRemove) { - final onRemove? => () => onRemove.call(option), - _ => null, - }, - ), - ], + trailing: StreamButton.icon( + type: .ghost, + style: .secondary, + icon: Icon(icons.minusCircle), + themeStyle: .from( + fixedSize: const .square(20), + tapTargetSize: .shrinkWrap, + ), + onPressed: switch (onRemove) { + final onRemove? => () => onRemove.call(option!), + _ => null, + }, + ), + onChanged: switch (onChanged) { + final onChanged? => (text) { + final updated = option!.copyWith(text: text); + return onChanged.call(updated); + }, + _ => null, + }, + ); + } + + Widget _buildAddOption(BuildContext context) { + final radius = context.streamRadius; + final spacing = context.streamSpacing; + final textTheme = context.streamTextTheme; + final colorScheme = context.streamColorScheme; + + final inputStyle = StreamPollCreatorTheme.of(context).optionInputStyle; + + final effectiveTextStyle = inputStyle?.textStyle ?? textTheme.bodyDefault; + final effectivePadding = inputStyle?.contentPadding ?? .symmetric(vertical: spacing.sm, horizontal: spacing.md); + final effectiveBorderColor = inputStyle?.border?.color ?? colorScheme.borderDefault; + final effectiveBorderRadius = inputStyle?.borderRadius ?? .all(radius.lg); + + return SizedBox( + width: .infinity, + child: StreamButton( + type: .outline, + style: .secondary, + onPressed: onAddOptionPressed, + themeStyle: .from( + fixedSize: .infinite, + textStyle: effectiveTextStyle, + padding: effectivePadding, + tapTargetSize: .shrinkWrap, + borderColor: effectiveBorderColor, + alignment: AlignmentDirectional.centerStart, + shape: RoundedRectangleBorder(borderRadius: effectiveBorderRadius), + ), + child: Text(hintText ?? context.translations.addAnOptionLabel), ), ); } @@ -181,12 +221,10 @@ class PollOptionReorderableListView extends StatefulWidget { final ValueSetter>? onOptionsChanged; @override - State createState() => - _PollOptionReorderableListViewState(); + State createState() => _PollOptionReorderableListViewState(); } -class _PollOptionReorderableListViewState - extends State { +class _PollOptionReorderableListViewState extends State { late Map _focusNodes; late Map _options; @@ -263,11 +301,12 @@ class _PollOptionReorderableListViewState return AnimatedBuilder( animation: animation, builder: (BuildContext context, Widget? child) { - final animValue = Curves.easeInOut.transform(animation.value); - final elevation = lerpDouble(0, 6, animValue)!; + final radius = context.streamRadius; + final colorScheme = context.streamColorScheme; return Material( - borderRadius: BorderRadius.circular(12), - elevation: elevation, + color: colorScheme.backgroundElevation3, + borderRadius: BorderRadius.all(radius.lg), + elevation: 3, child: child, ); }, @@ -275,25 +314,52 @@ class _PollOptionReorderableListViewState ); } - String? _validateOption(PollOptionItem option) { + // Revalidates all options for empty-text and duplicate errors. + // + // Empty options always receive an error. Duplicate checking is skipped + // when [PollOptionReorderableListView.allowDuplicate] is true. + // + // Uses a two-pass approach when [changedOptionId] is provided: + // + // 1. **Unchanged options first** — scanned in order to build the "seen" + // set. Among these, the first occurrence of a text is clean and every + // subsequent duplicate gets an error. + // 2. **Changed option last** — checked against the "seen" set built in + // pass 1. This ensures the just-edited option always yields to + // pre-existing ones, so the error appears on the option the user is + // actively typing in rather than jumping to an older field. + // + // Without [changedOptionId] (e.g. after reorder or remove), all options + // are validated in a single pass where the first occurrence wins. + void _revalidateOptions({String? changedOptionId}) { final translations = context.translations; + final checkDuplicates = !widget.allowDuplicate; + final seen = {}; - // Check if the option is empty. - if (option.text.isEmpty) return translations.pollOptionEmptyError; - - // Check for duplicate options if duplicates are not allowed. - if (widget.allowDuplicate case false) { - if (_options.values.any((it) { - // Skip if it's the same option - if (it.id == option.id) return false; - - return it.text == option.text; - })) { + String? _errorFor(String normalized) { + if (normalized.isEmpty) return translations.pollOptionEmptyError; + if (checkDuplicates) { + if (seen.add(normalized)) return null; return translations.pollOptionDuplicateError; } + + return null; } - return null; + // Pass 1 — validate every option except the one being edited. + _options.updateAll((key, option) { + if (key == changedOptionId) return option; + + final normalized = option.text.trim().toLowerCase(); + return option.copyWith(error: _errorFor(normalized)); + }); + + // Pass 2 — validate the edited option against the pre-existing texts. + if (changedOptionId case final id? when _options.containsKey(id)) { + final option = _options[id]!; + final normalized = option.text.trim().toLowerCase(); + _options[id] = option.copyWith(error: _errorFor(normalized)); + } } Future _onOptionRemoved(PollOptionItem option) async { @@ -303,6 +369,7 @@ class _PollOptionReorderableListViewState setState(() { _options.remove(option.id); _focusNodes.remove(option.id)?.dispose(); + _revalidateOptions(); }); // Ensure we have at least the minimum number of options @@ -316,18 +383,9 @@ class _PollOptionReorderableListViewState void _onOptionChanged(PollOptionItem option) { setState(() { - // Update the changed option. - _options[option.id] = option.copyWith( - error: _validateOption(option), - ); - - // Validate every other option to check for duplicates. - _options.updateAll((key, value) { - // Skip the changed option as it's already validated. - if (key == option.id) return value; - - return value.copyWith(error: _validateOption(value)); - }); + // Update the changed option and revalidate all for duplicates. + _options[option.id] = option; + _revalidateOptions(changedOptionId: option.id); }); // Notify the parent widget about the change @@ -348,6 +406,8 @@ class _PollOptionReorderableListViewState _options = { for (final option in options) option.id: option, }; + + _revalidateOptions(); }); // Notify the parent widget about the change @@ -395,60 +455,62 @@ class _PollOptionReorderableListViewState @override Widget build(BuildContext context) { final theme = StreamPollCreatorTheme.of(context); - final borderRadius = theme.optionsTextFieldBorderRadius; + final defaults = _PollOptionListViewDefaults(context); + + final spacing = context.streamSpacing; + + final effectiveTitleStyle = theme.headerTextStyle ?? defaults.headerTextStyle; + final effectiveInputStyle = theme.optionInputStyle ?? defaults.optionInputStyle; return Column( + spacing: spacing.xs, mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ - if (widget.title case final title?) ...[ - Text(title, style: theme.optionsHeaderStyle), - const SizedBox(height: 8), - ], + if (widget.title case final title?) Text(title, style: effectiveTitleStyle), Flexible( - child: SeparatedReorderableListView( - shrinkWrap: true, - itemCount: _options.length, - physics: const NeverScrollableScrollPhysics(), - proxyDecorator: _proxyDecorator, - separatorBuilder: (_, __) => const SizedBox(height: 8), - onReorderStart: (_) => FocusScope.of(context).unfocus(), - onReorder: _onOptionReorder, - itemBuilder: (context, index) { - final option = _options.values.elementAt(index); - return PollOptionListItem( - key: Key(option.id), - option: option, - hintText: widget.itemHintText, - focusNode: _focusNodes[option.id], - onRemove: _onOptionRemoved, - onChanged: _onOptionChanged, - ); - }, - ), - ), - const SizedBox(height: 8), - SizedBox( - width: double.infinity, - child: FilledButton.tonal( - onPressed: _canAddMoreOptions ? _onAddOptionPressed : null, - style: TextButton.styleFrom( - alignment: Alignment.centerLeft, - textStyle: theme.optionsTextFieldStyle, - shape: RoundedRectangleBorder( - borderRadius: borderRadius ?? BorderRadius.zero, - ), - padding: const EdgeInsets.symmetric( - vertical: 18, - horizontal: 16, - ), - backgroundColor: theme.optionsTextFieldFillColor, - foregroundColor: theme.optionsTextFieldStyle?.color, + child: StreamTextInputTheme( + data: .new(style: effectiveInputStyle), + child: SeparatedReorderableListView( + shrinkWrap: true, + itemCount: _options.length, + physics: const NeverScrollableScrollPhysics(), + proxyDecorator: _proxyDecorator, + separatorBuilder: (_, __) => SizedBox(height: spacing.xs), + onReorderStart: (_) => FocusScope.of(context).unfocus(), + onReorder: _onOptionReorder, + itemBuilder: (context, index) { + final option = _options.values.elementAt(index); + return PollOptionListItem( + key: Key(option.id), + option: option, + hintText: widget.itemHintText, + focusNode: _focusNodes[option.id], + onRemove: _onOptionRemoved, + onChanged: _onOptionChanged, + ); + }, ), - child: Text(context.translations.addAnOptionLabel), ), ), + if (_canAddMoreOptions) + PollOptionListItem.addOption( + hintText: widget.itemHintText, + onPressed: _onAddOptionPressed, + ), ], ); } } + +class _PollOptionListViewDefaults extends StreamPollCreatorThemeData { + _PollOptionListViewDefaults(this._context); + + final BuildContext _context; + + late final _colorScheme = _context.streamColorScheme; + late final _textTheme = _context.streamTextTheme; + + @override + TextStyle get headerTextStyle => _textTheme.headingSm.copyWith(color: _colorScheme.textPrimary); +} diff --git a/packages/stream_chat_flutter/lib/src/poll/creator/poll_question_text_field.dart b/packages/stream_chat_flutter/lib/src/poll/creator/poll_question_text_field.dart index a5e8ea25be..99f052860a 100644 --- a/packages/stream_chat_flutter/lib/src/poll/creator/poll_question_text_field.dart +++ b/packages/stream_chat_flutter/lib/src/poll/creator/poll_question_text_field.dart @@ -1,9 +1,6 @@ import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; -import 'package:stream_chat_flutter/src/poll/stream_poll_text_field.dart'; -import 'package:stream_chat_flutter/src/theme/poll_creator_theme.dart'; -import 'package:stream_chat_flutter/src/utils/extensions.dart'; -import 'package:stream_chat_flutter_core/stream_chat_flutter_core.dart'; +import 'package:stream_chat_flutter/stream_chat_flutter.dart'; class _NullConst { const _NullConst(); @@ -85,6 +82,16 @@ class PollQuestionTextField extends StatefulWidget { class _PollQuestionTextFieldState extends State { late var _question = widget.initialQuestion; + final _focusNode = FocusNode(); + + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) _focusNode.requestFocus(); + }); + } + @override void didUpdateWidget(covariant PollQuestionTextField oldWidget) { super.didUpdateWidget(oldWidget); @@ -102,12 +109,15 @@ class _PollQuestionTextFieldState extends State { } } + @override + void dispose() { + _focusNode.dispose(); + super.dispose(); + } + String? _validateQuestion(String question) { if (widget.questionRange case final range?) { - return context.translations.pollQuestionValidationError( - question.length, - range, - ); + return context.translations.pollQuestionValidationError(question.length, range); } return null; @@ -116,41 +126,49 @@ class _PollQuestionTextFieldState extends State { @override Widget build(BuildContext context) { final theme = StreamPollCreatorTheme.of(context); - final fillColor = theme.questionTextFieldFillColor; - final borderRadius = theme.questionTextFieldBorderRadius; + final defaults = _PollQuestionTextFieldDefaults(context); + + final spacing = context.streamSpacing; + + final effectiveHeaderStyle = theme.headerTextStyle ?? defaults.headerTextStyle; + final effectiveInputStyle = theme.questionInputStyle ?? defaults.questionInputStyle; return Column( + spacing: spacing.xs, mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ - if (widget.title case final title?) ...[ - Text(title, style: theme.questionHeaderStyle), - const SizedBox(height: 8), - ], - DecoratedBox( - decoration: BoxDecoration( - color: fillColor, - borderRadius: borderRadius, - ), - child: StreamPollTextField( - initialValue: _question.text, - hintText: widget.hintText, - fillColor: fillColor, - style: theme.questionTextFieldStyle, - borderRadius: borderRadius, - errorText: _question.error, - errorStyle: theme.questionTextFieldErrorStyle, - onChanged: (text) { - _question = _question.copyWith( - text: text, - error: _validateQuestion(text), - ); - - widget.onChanged?.call(_question); - }, - ), + if (widget.title case final title?) Text(title, style: effectiveHeaderStyle), + StreamTextInput( + focusNode: _focusNode, + initialValue: _question.text, + hintText: widget.hintText, + helperText: _question.error, + helperState: _question.error != null ? .error : null, + textCapitalization: TextCapitalization.sentences, + style: effectiveInputStyle, + onChanged: (text) { + _question = _question.copyWith( + text: text, + error: _validateQuestion(text), + ); + + widget.onChanged?.call(_question); + }, ), ], ); } } + +class _PollQuestionTextFieldDefaults extends StreamPollCreatorThemeData { + _PollQuestionTextFieldDefaults(this._context); + + final BuildContext _context; + + late final _colorScheme = _context.streamColorScheme; + late final _textTheme = _context.streamTextTheme; + + @override + TextStyle get headerTextStyle => _textTheme.headingSm.copyWith(color: _colorScheme.textPrimary); +} diff --git a/packages/stream_chat_flutter/lib/src/poll/creator/poll_switch_list_tile.dart b/packages/stream_chat_flutter/lib/src/poll/creator/poll_switch_list_tile.dart deleted file mode 100644 index b1088d3a90..0000000000 --- a/packages/stream_chat_flutter/lib/src/poll/creator/poll_switch_list_tile.dart +++ /dev/null @@ -1,240 +0,0 @@ -import 'package:collection/collection.dart'; -import 'package:flutter/material.dart'; -import 'package:stream_chat_flutter/stream_chat_flutter.dart'; - -class _NullConst { - const _NullConst(); -} - -const _nullConst = _NullConst(); - -/// {@template pollSwitchListTile} -/// A widget that represents a switch list tile for poll input. -/// -/// The switch list tile contains a title and a switch. -/// -/// Optionally, it can contain a list of children widgets that are displayed -/// below the switch when the switch is enabled. -/// -/// see also: -/// - [PollSwitchTextField], a widget that represents a toggleable text field -/// for poll input. -/// {@endtemplate} -class PollSwitchListTile extends StatelessWidget { - /// {@macro pollSwitchListTile} - const PollSwitchListTile({ - super.key, - this.value = false, - required this.title, - this.children = const [], - this.onChanged, - }); - - /// The current value of the switch. - final bool value; - - /// The title of the switch list tile. - final String title; - - /// Optional list of children widgets to be displayed when the switch is - /// enabled. - /// - /// If `null`, no children will be displayed. - final List children; - - /// Callback called when the switch value is changed. - final ValueSetter? onChanged; - - @override - Widget build(BuildContext context) { - final theme = StreamPollCreatorTheme.of(context); - final fillColor = theme.switchListTileFillColor; - final borderRadius = theme.switchListTileBorderRadius; - - final listTile = SwitchListTile( - value: value, - onChanged: onChanged, - tileColor: fillColor, - title: Text(title, style: theme.switchListTileTitleStyle), - contentPadding: const EdgeInsets.only(left: 16, right: 8), - shape: RoundedRectangleBorder( - borderRadius: borderRadius ?? BorderRadius.zero, - ), - ); - - return DecoratedBox( - decoration: BoxDecoration( - color: fillColor, - borderRadius: borderRadius, - ), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - listTile, - if (value) ...children, - ], - ), - ); - } -} - -/// {@template pollSwitchItem} -/// A data class that represents a poll boolean item. -/// {@endtemplate} -class PollSwitchItem { - /// {@macro pollSwitchItem} - PollSwitchItem({ - String? id, - this.value = false, - this.inputValue, - this.error, - }) : id = id ?? const Uuid().v4(); - - /// The unique id of the poll option item. - final String id; - - /// The boolean value of the poll switch item. - final bool value; - - /// Optional input value linked to the poll switch item. - final T? inputValue; - - /// Optional error message based on the validation of the poll switch item - /// and its input value. - /// - /// If the poll switch item is valid, this will be `null`. - final String? error; - - /// A copy of the current [PollSwitchItem] with the provided values. - PollSwitchItem copyWith({ - String? id, - bool? value, - Object? error = _nullConst, - Object? inputValue = _nullConst, - }) { - return PollSwitchItem( - id: id ?? this.id, - value: value ?? this.value, - error: error == _nullConst ? this.error : error as String?, - inputValue: inputValue == _nullConst ? this.inputValue : inputValue as T?, - ); - } -} - -/// {@template pollSwitchTextField} -/// A widget that represents a toggleable text field for poll input. -/// -/// Generally used as one of the children of [PollSwitchListTile]. -/// {@endtemplate} -class PollSwitchTextField extends StatefulWidget { - /// {@macro pollSwitchTextField} - const PollSwitchTextField({ - super.key, - required this.item, - this.hintText, - this.keyboardType, - this.onChanged, - this.validator, - }); - - /// The current value of the switch text field. - final PollSwitchItem item; - - /// The hint text to be displayed in the text field. - final String? hintText; - - /// The keyboard type of the text field. - final TextInputType? keyboardType; - - /// Callback called when the switch text field is changed. - final ValueChanged>? onChanged; - - /// The validator function to validate the input value. - final String? Function(PollSwitchItem)? validator; - - @override - State createState() => _PollSwitchTextFieldState(); -} - -class _PollSwitchTextFieldState extends State { - late var _item = widget.item.copyWith( - error: widget.validator?.call(widget.item), - ); - - @override - void didUpdateWidget(covariant PollSwitchTextField oldWidget) { - super.didUpdateWidget(oldWidget); - // Update the item if the updated item is different from the current item. - final currItem = _item; - final newItem = widget.item; - final itemEquality = EqualityBy, (bool, int?)>( - (it) => (it.value, it.inputValue), - ); - - if (itemEquality.equals(currItem, newItem) case false) { - _item = newItem; - } - } - - void _onSwitchToggled(bool value) { - setState(() { - // Update the switch value. - _item = _item.copyWith(value: value); - // Validate the switch value. - _item = _item.copyWith(error: widget.validator?.call(_item)); - - // Notify the parent widget about the change - widget.onChanged?.call(_item); - }); - } - - void _onFieldChanged(String text) { - setState(() { - // Update the input value. - _item = _item.copyWith(inputValue: int.tryParse(text)); - // Validate the input value. - _item = _item.copyWith(error: widget.validator?.call(_item)); - - // Notify the parent widget about the change - widget.onChanged?.call(_item); - }); - } - - @override - Widget build(BuildContext context) { - final theme = StreamPollCreatorTheme.of(context); - final fillColor = theme.switchListTileFillColor; - final borderRadius = theme.switchListTileBorderRadius; - - return DecoratedBox( - decoration: BoxDecoration( - color: fillColor, - borderRadius: borderRadius, - ), - child: Row( - children: [ - Expanded( - child: StreamPollTextField( - hintText: widget.hintText, - enabled: _item.value, - fillColor: fillColor, - style: theme.switchListTileTitleStyle, - keyboardType: widget.keyboardType, - borderRadius: borderRadius, - errorText: _item.value ? _item.error : null, - errorStyle: theme.switchListTileErrorStyle, - initialValue: _item.inputValue?.toString(), - onChanged: _onFieldChanged, - ), - ), - Switch( - value: _item.value, - onChanged: _onSwitchToggled, - ), - const SizedBox(width: 8), - ], - ), - ); - } -} diff --git a/packages/stream_chat_flutter/lib/src/poll/creator/stream_delete_option_dialog.dart b/packages/stream_chat_flutter/lib/src/poll/creator/stream_delete_option_dialog.dart index 11c901bf39..d879866366 100644 --- a/packages/stream_chat_flutter/lib/src/poll/creator/stream_delete_option_dialog.dart +++ b/packages/stream_chat_flutter/lib/src/poll/creator/stream_delete_option_dialog.dart @@ -1,10 +1,13 @@ import 'package:flutter/material.dart'; -import 'package:stream_chat_flutter/src/theme/poll_creator_theme.dart'; -import 'package:stream_chat_flutter/src/theme/stream_chat_theme.dart'; import 'package:stream_chat_flutter/src/utils/extensions.dart'; +import 'package:stream_core_flutter/stream_core_flutter.dart'; /// {@template showPollDeleteOptionDialog} /// Shows a dialog that allows the user to confirm deletion of a poll option. +/// +/// See also: +/// +/// * [PollDeleteOptionDialog], the dialog widget shown by this function. /// {@endtemplate} Future showPollDeleteOptionDialog({ required BuildContext context, @@ -17,6 +20,11 @@ Future showPollDeleteOptionDialog({ /// {@template pollDeleteOptionDialog} /// A dialog that allows the user to confirm deletion of a poll option. +/// +/// See also: +/// +/// * [showPollDeleteOptionDialog], the convenience function to show this +/// dialog. /// {@endtemplate} class PollDeleteOptionDialog extends StatelessWidget { /// {@macro pollDeleteOptionDialog} @@ -24,45 +32,30 @@ class PollDeleteOptionDialog extends StatelessWidget { @override Widget build(BuildContext context) { - final theme = StreamChatTheme.of(context); - final pollCreatorTheme = StreamPollCreatorTheme.of(context); + final colorScheme = context.streamColorScheme; final actions = [ - TextButton( + StreamButton( + type: .ghost, + style: .secondary, + size: .small, onPressed: () => Navigator.of(context).maybePop(false), - style: TextButton.styleFrom( - textStyle: theme.textTheme.headlineBold, - foregroundColor: theme.colorTheme.accentPrimary, - disabledForegroundColor: theme.colorTheme.disabled, - ), - child: Text(context.translations.cancelLabel.toUpperCase()), + child: Text(context.translations.cancelLabel), ), - TextButton( + StreamButton( + type: .solid, + style: .destructive, + size: .small, onPressed: () => Navigator.of(context).maybePop(true), - style: TextButton.styleFrom( - textStyle: theme.textTheme.headlineBold, - foregroundColor: theme.colorTheme.accentPrimary, - disabledForegroundColor: theme.colorTheme.disabled, - ), - child: Text(context.translations.deleteLabel.toUpperCase()), + child: Text(context.translations.deleteLabel), ), ]; return AlertDialog( - title: Text( - context.translations.deletePollOptionLabel, - style: pollCreatorTheme.actionDialogTitleStyle, - ), - content: Text( - context.translations.deletePollOptionQuestion, - style: pollCreatorTheme.actionDialogContentStyle, - ), actions: actions, - titlePadding: const EdgeInsetsDirectional.fromSTEB(16, 24, 16, 4), - contentPadding: const EdgeInsetsDirectional.fromSTEB(16, 4, 16, 24), - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), - actionsPadding: const EdgeInsets.all(8), - backgroundColor: theme.colorTheme.appBg, + title: Text(context.translations.deletePollOptionLabel), + content: Text(context.translations.deletePollOptionQuestion), + backgroundColor: colorScheme.backgroundElevation1, ); } } diff --git a/packages/stream_chat_flutter/lib/src/poll/creator/stream_poll_creator_dialog.dart b/packages/stream_chat_flutter/lib/src/poll/creator/stream_poll_creator_dialog.dart deleted file mode 100644 index f9d2a76777..0000000000 --- a/packages/stream_chat_flutter/lib/src/poll/creator/stream_poll_creator_dialog.dart +++ /dev/null @@ -1,270 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:stream_chat_flutter/stream_chat_flutter.dart'; - -/// {@template showStreamPollCreatorDialog} -/// Shows the poll creator dialog based on the screen size. -/// -/// The regular dialog is shown on larger screens such as tablets and desktops -/// and a full screen dialog is shown on smaller screens such as mobile phones. -/// -/// The [poll] and [config] parameters can be used to provide an initial poll -/// and a configuration to validate the poll. -/// {@endtemplate} -Future showStreamPollCreatorDialog({ - required BuildContext context, - Poll? poll, - PollConfig? config, - bool barrierDismissible = true, - Color? barrierColor, - String? barrierLabel, - bool useSafeArea = true, - bool useRootNavigator = false, - RouteSettings? routeSettings, - Offset? anchorPoint, - EdgeInsets padding = const EdgeInsets.all(16), - TraversalEdgeBehavior? traversalEdgeBehavior, -}) { - final size = MediaQuery.sizeOf(context); - final isTabletOrDesktop = size.width > 600; - - final colorTheme = StreamChatTheme.of(context).colorTheme; - - // Open it as a regular dialog on bigger screens such as tablets and desktops. - if (isTabletOrDesktop) { - return showDialog( - context: context, - barrierColor: barrierColor ?? colorTheme.overlay, - barrierDismissible: barrierDismissible, - barrierLabel: barrierLabel, - useSafeArea: useSafeArea, - useRootNavigator: useRootNavigator, - routeSettings: routeSettings, - builder: (context) => StreamPollCreatorDialog( - poll: poll, - config: config, - padding: padding, - ), - ); - } - - // Open it as a full screen dialog on smaller screens such as mobile phones. - final navigator = Navigator.of(context, rootNavigator: useRootNavigator); - return navigator.push( - MaterialPageRoute( - fullscreenDialog: true, - barrierDismissible: barrierDismissible, - builder: (context) => StreamPollCreatorFullScreenDialog( - poll: poll, - config: config, - padding: padding, - ), - ), - ); -} - -/// {@template streamPollCreatorDialog} -/// A dialog that allows users to create a poll. -/// -/// The dialog provides a form to create a poll with a question and multiple -/// options. -/// -/// This widget is intended to be used on larger screens such as tablets and -/// desktops. -/// -/// For smaller screens, consider using [StreamPollCreatorFullScreenDialog]. -/// {@endtemplate} -class StreamPollCreatorDialog extends StatefulWidget { - /// {@macro streamPollCreatorDialog} - const StreamPollCreatorDialog({ - super.key, - this.poll, - this.config, - this.padding = const EdgeInsets.all(16), - }); - - /// The initial poll to be used in the poll creator. - final Poll? poll; - - /// The configuration used to validate the poll. - final PollConfig? config; - - /// The padding around the poll creator. - final EdgeInsets padding; - - @override - State createState() => - _StreamPollCreatorDialogState(); -} - -class _StreamPollCreatorDialogState extends State { - late final _controller = StreamPollController( - poll: widget.poll, - config: widget.config, - ); - - @override - void dispose() { - _controller.dispose(); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - final theme = StreamChatTheme.of(context); - final pollCreatorTheme = StreamPollCreatorTheme.of(context); - - final actions = [ - TextButton( - onPressed: Navigator.of(context).pop, - style: TextButton.styleFrom( - textStyle: theme.textTheme.headlineBold, - foregroundColor: theme.colorTheme.accentPrimary, - disabledForegroundColor: theme.colorTheme.disabled, - ), - child: Text(context.translations.cancelLabel.toUpperCase()), - ), - ValueListenableBuilder( - valueListenable: _controller, - builder: (context, poll, child) { - final isValid = _controller.validate(); - return TextButton( - onPressed: isValid - ? () { - final errors = _controller.validateGranularly(); - if (errors.isNotEmpty) { - return; - } - - final sanitizedPoll = _controller.sanitizedPoll; - return Navigator.of(context).pop(sanitizedPoll); - } - : null, - style: TextButton.styleFrom( - textStyle: theme.textTheme.headlineBold, - foregroundColor: theme.colorTheme.accentPrimary, - disabledForegroundColor: theme.colorTheme.disabled, - ), - child: Text(context.translations.createLabel.toUpperCase()), - ); - }, - ), - ]; - - return AlertDialog( - title: Text( - context.translations.createPollLabel(), - style: pollCreatorTheme.appBarTitleStyle, - ), - titlePadding: const EdgeInsets.symmetric(vertical: 14, horizontal: 16), - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), - actions: actions, - contentPadding: EdgeInsets.zero, - actionsPadding: const EdgeInsets.all(8), - backgroundColor: pollCreatorTheme.backgroundColor, - content: SizedBox( - width: 640, // Similar to BottomSheet default width on M3 - child: StreamPollCreatorWidget( - shrinkWrap: true, - padding: widget.padding, - controller: _controller, - ), - ), - ); - } -} - -/// {@template streamPollCreatorFullScreenDialog} -/// A page that allows users to create a poll. -/// -/// The page provides a form to create a poll with a question and multiple -/// options. -/// -/// This widget is intended to be used on smaller screens such as mobile phones. -/// -/// For larger screens, consider using [StreamPollCreatorDialog]. -/// {@endtemplate} -class StreamPollCreatorFullScreenDialog extends StatefulWidget { - /// {@macro streamPollCreatorFullScreenDialog} - const StreamPollCreatorFullScreenDialog({ - super.key, - this.poll, - this.config, - this.padding = const EdgeInsets.all(16), - }); - - /// The initial poll to be used in the poll creator. - final Poll? poll; - - /// The configuration used to validate the poll. - final PollConfig? config; - - /// The padding around the poll creator. - final EdgeInsets padding; - - @override - State createState() => - _StreamPollCreatorFullScreenDialogState(); -} - -class _StreamPollCreatorFullScreenDialogState - extends State { - late final _controller = StreamPollController( - poll: widget.poll, - config: widget.config, - ); - - @override - void dispose() { - _controller.dispose(); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - final theme = StreamPollCreatorTheme.of(context); - - return Scaffold( - backgroundColor: theme.backgroundColor, - appBar: AppBar( - elevation: theme.appBarElevation, - backgroundColor: theme.appBarBackgroundColor, - foregroundColor: theme.appBarForegroundColor, - title: Text( - context.translations.createPollLabel(), - style: theme.appBarTitleStyle, - ), - actions: [ - ValueListenableBuilder( - valueListenable: _controller, - builder: (context, poll, child) { - final colorTheme = StreamChatTheme.of(context).colorTheme; - - final isValid = _controller.validate(); - - return IconButton( - color: colorTheme.accentPrimary, - disabledColor: colorTheme.disabled, - icon: const StreamSvgIcon(icon: StreamSvgIcons.send), - onPressed: isValid - ? () { - final errors = _controller.validateGranularly(); - if (errors.isNotEmpty) { - return; - } - - final sanitizedPoll = _controller.sanitizedPoll; - return Navigator.of(context).pop(sanitizedPoll); - } - : null, - ); - }, - ), - ], - ), - body: StreamPollCreatorWidget( - padding: widget.padding, - controller: _controller, - ), - ); - } -} diff --git a/packages/stream_chat_flutter/lib/src/poll/creator/stream_poll_creator_sheet.dart b/packages/stream_chat_flutter/lib/src/poll/creator/stream_poll_creator_sheet.dart new file mode 100644 index 0000000000..0211d2f231 --- /dev/null +++ b/packages/stream_chat_flutter/lib/src/poll/creator/stream_poll_creator_sheet.dart @@ -0,0 +1,127 @@ +import 'package:flutter/material.dart'; +import 'package:stream_chat_flutter/stream_chat_flutter.dart'; + +/// {@template showStreamPollCreatorSheet} +/// Displays an interactive bottom sheet that lets the user create a poll. +/// +/// The sheet renders a [StreamSheetHeader] with a close action on the leading +/// side and a confirm action on the trailing side, followed by a +/// [StreamPollCreatorWidget] form for the question, options, and poll +/// configuration. +/// +/// The future completes with the sanitized [Poll] when the user confirms the +/// creation, or `null` when the sheet is dismissed. +/// +/// The [poll] and [config] parameters can be used to provide an initial poll +/// and a configuration to validate the poll. +/// {@endtemplate} +Future showStreamPollCreatorSheet({ + required BuildContext context, + Poll? poll, + PollConfig? config, + EdgeInsets padding = const EdgeInsets.all(16), +}) { + return showStreamSheet( + context: context, + builder: (_, scrollController) => StreamPollCreatorSheet( + poll: poll, + config: config, + padding: padding, + scrollController: scrollController, + ), + ); +} + +/// {@template streamPollCreatorSheet} +/// A bottom sheet that allows users to create a poll. +/// +/// The sheet provides a form to create a poll with a question and multiple +/// options, alongside the standard poll configuration toggles (multiple +/// answers, anonymous voting, suggested options, comments). +/// +/// Pops the enclosing route with the sanitized [Poll] when the user taps the +/// trailing confirm action. The action is disabled until the form passes +/// validation. +/// {@endtemplate} +class StreamPollCreatorSheet extends StatefulWidget { + /// {@macro streamPollCreatorSheet} + const StreamPollCreatorSheet({ + super.key, + this.poll, + this.config, + this.padding = const EdgeInsets.all(16), + this.scrollController, + }); + + /// The initial poll to be used in the poll creator. + final Poll? poll; + + /// The configuration used to validate the poll. + final PollConfig? config; + + /// The padding around the poll creator form. + final EdgeInsets padding; + + /// Scroll controller attached to the bottom sheet's scrollable content. + /// + /// Typically provided by [DraggableScrollableSheet] so the sheet expands and + /// collapses in response to the user's scroll gesture. + final ScrollController? scrollController; + + @override + State createState() => _StreamPollCreatorSheetState(); +} + +class _StreamPollCreatorSheetState extends State { + late final _controller = StreamPollController( + poll: widget.poll, + config: widget.config, + ); + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final theme = StreamPollCreatorTheme.of(context); + + return Column( + mainAxisSize: .min, + children: [ + StreamSheetHeader( + style: theme.sheetHeaderStyle, + title: Text(context.translations.createPollLabel()), + trailing: ValueListenableBuilder( + valueListenable: _controller, + builder: (context, poll, child) { + final isValid = _controller.validate(); + + return StreamButton.icon( + style: .primary, + type: .solid, + icon: Icon(context.streamIcons.checkmark), + onPressed: switch (isValid) { + false => null, + true => () { + final sanitizedPoll = _controller.sanitizedPoll; + return Navigator.of(context).pop(sanitizedPoll); + }, + }, + ); + }, + ), + ), + Expanded( + child: StreamPollCreatorWidget( + controller: _controller, + padding: widget.padding, + scrollController: widget.scrollController, + ), + ), + ], + ); + } +} diff --git a/packages/stream_chat_flutter/lib/src/poll/creator/stream_poll_creator_widget.dart b/packages/stream_chat_flutter/lib/src/poll/creator/stream_poll_creator_widget.dart index dcb9edce7c..be8adb66a6 100644 --- a/packages/stream_chat_flutter/lib/src/poll/creator/stream_poll_creator_widget.dart +++ b/packages/stream_chat_flutter/lib/src/poll/creator/stream_poll_creator_widget.dart @@ -1,9 +1,11 @@ import 'package:flutter/material.dart'; +import 'package:stream_chat_flutter/src/poll/creator/poll_config_option.dart'; import 'package:stream_chat_flutter/src/poll/creator/poll_option_reorderable_list_view.dart'; import 'package:stream_chat_flutter/src/poll/creator/poll_question_text_field.dart'; -import 'package:stream_chat_flutter/src/poll/creator/poll_switch_list_tile.dart'; +import 'package:stream_chat_flutter/src/theme/poll_creator_theme.dart'; import 'package:stream_chat_flutter/src/utils/extensions.dart'; import 'package:stream_chat_flutter_core/stream_chat_flutter_core.dart'; +import 'package:stream_core_flutter/stream_core_flutter.dart'; /// {@template streamPollCreator} /// A widget that allows users to create a poll. @@ -20,6 +22,7 @@ class StreamPollCreatorWidget extends StatelessWidget { this.shrinkWrap = false, this.physics, this.padding = const EdgeInsets.all(16), + this.scrollController, }); /// The padding around the poll creator. @@ -34,11 +37,22 @@ class StreamPollCreatorWidget extends StatelessWidget { /// The controller used to manage the state of the poll. final StreamPollController controller; + /// Optional scroll controller for the underlying scroll view. + /// + /// When the creator is hosted inside a [DraggableScrollableSheet] (e.g. by + /// [showStreamPollCreatorSheet]), pass the controller provided by the sheet + /// so drag gestures expand and collapse the sheet correctly. + final ScrollController? scrollController; + @override Widget build(BuildContext context) { + final theme = StreamPollCreatorTheme.of(context); + return ValueListenableBuilder( valueListenable: controller, builder: (context, poll, child) { + final spacing = context.streamSpacing; + final config = controller.config; final translations = context.translations; @@ -49,88 +63,79 @@ class StreamPollCreatorWidget extends StatelessWidget { return SingleChildScrollView( padding: padding, physics: physics, + controller: scrollController, + keyboardDismissBehavior: .onDrag, child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ PollQuestionTextField( questionRange: config.nameRange, - title: translations.questionsLabel, + title: translations.questionLabel(), hintText: translations.askAQuestionLabel, initialQuestion: PollQuestion(id: poll.id, text: poll.name), onChanged: (question) => controller.question = question.text, ), - const SizedBox(height: 32), + SizedBox(height: spacing.xl), PollOptionReorderableListView( title: translations.optionLabel(isPlural: true), - itemHintText: translations.optionLabel(), + itemHintText: context.translations.addAnOptionLabel, allowDuplicate: config.allowDuplicateOptions, optionsRange: config.optionsRange, initialOptions: [ - for (final option in poll.options) - PollOptionItem(id: option.id, text: option.text), + for (final option in poll.options) PollOptionItem(id: option.id, text: option.text), ], onOptionsChanged: (options) => controller.options = [ - for (final option in options) - PollOption(id: option.id, text: option.text), + for (final option in options) PollOption(id: option.id, text: option.text), ], ), - const SizedBox(height: 32), - PollSwitchListTile( + SizedBox(height: spacing.xxl), + PollConfigOption( title: translations.multipleAnswersLabel, + description: translations.multipleAnswersDescription, value: poll.enforceUniqueVote == false, onChanged: (value) { controller.enforceUniqueVote = !value; // We also need to reset maxVotesAllowed if disabled. if (value case false) controller.maxVotesAllowed = null; }, - children: [ - PollSwitchTextField( - hintText: translations.maximumVotesPerPersonLabel, - item: PollSwitchItem( - value: poll.maxVotesAllowed != null, - inputValue: poll.maxVotesAllowed, - ), - keyboardType: TextInputType.number, - validator: (item) { - if (config.allowedVotesRange case final allowedRange?) { - final votes = item.inputValue; - if (votes == null) return null; - - return translations.maxVotesPerPersonValidationError( - votes, - allowedRange, - ); - } - - return null; - }, - onChanged: (option) { - final enabled = option.value; - final maxVotes = option.inputValue; - - controller.maxVotesAllowed = enabled ? maxVotes : null; - }, + child: PollConfigOption( + contentPadding: EdgeInsets.zero, + title: translations.maximumVotesPerPersonLabel, + description: translations.maximumVotesPerPersonDescription( + config.allowedVotesRange, ), - ], + value: poll.maxVotesAllowed != null, + onChanged: (enabled) { + controller.maxVotesAllowed = enabled ? config.allowedVotesRange?.min ?? 2 : null; + }, + child: StreamStepper( + min: config.allowedVotesRange?.min ?? 2, + max: config.allowedVotesRange?.max ?? 10, + value: poll.maxVotesAllowed ?? config.allowedVotesRange?.min ?? 2, + onChanged: (value) => controller.maxVotesAllowed = value, + style: theme.configOptionStyle?.stepperStyle, + ), + ), ), - const SizedBox(height: 8), - PollSwitchListTile( + SizedBox(height: spacing.md), + PollConfigOption( title: translations.anonymousPollLabel, - value: poll.votingVisibility == VotingVisibility.anonymous, - onChanged: (anon) => controller.votingVisibility = anon // - ? VotingVisibility.anonymous - : VotingVisibility.public, + description: translations.anonymousPollDescription, + value: poll.votingVisibility == .anonymous, + onChanged: (anon) => controller.votingVisibility = anon ? .anonymous : .public, ), - const SizedBox(height: 8), - PollSwitchListTile( + SizedBox(height: spacing.md), + PollConfigOption( title: translations.suggestAnOptionLabel, + description: translations.suggestAnOptionDescription, value: poll.allowUserSuggestedOptions, onChanged: (allow) => controller.allowSuggestions = allow, ), - const SizedBox(height: 8), - PollSwitchListTile( + SizedBox(height: spacing.md), + PollConfigOption( title: translations.addACommentLabel, + description: translations.addACommentDescription, value: poll.allowAnswers, onChanged: (allow) => controller.allowComments = allow, ), diff --git a/packages/stream_chat_flutter/lib/src/poll/interactor/poll_add_comment_dialog.dart b/packages/stream_chat_flutter/lib/src/poll/interactor/poll_add_comment_dialog.dart index 47c67a7b76..fea092c86f 100644 --- a/packages/stream_chat_flutter/lib/src/poll/interactor/poll_add_comment_dialog.dart +++ b/packages/stream_chat_flutter/lib/src/poll/interactor/poll_add_comment_dialog.dart @@ -1,30 +1,37 @@ import 'package:flutter/material.dart'; -import 'package:stream_chat_flutter/src/poll/stream_poll_text_field.dart'; -import 'package:stream_chat_flutter/src/theme/poll_interactor_theme.dart'; -import 'package:stream_chat_flutter/src/theme/stream_chat_theme.dart'; import 'package:stream_chat_flutter/src/utils/extensions.dart'; +import 'package:stream_core_flutter/stream_core_flutter.dart'; /// {@template showPollAddCommentDialog} /// Shows a dialog that allows the user to add a poll comment. /// /// Optionally, you can provide an [initialValue] to pre-fill the text field. +/// +/// See also: +/// +/// * [PollAddCommentDialog], the dialog widget shown by this function. +/// * [StreamPollInteractor], which invokes this via [StreamPollInteractor.onAddComment]. /// {@endtemplate} Future showPollAddCommentDialog({ required BuildContext context, String initialValue = '', -}) => - showDialog( - context: context, - barrierDismissible: false, - builder: (_) => PollAddCommentDialog( - initialValue: initialValue, - ), - ); +}) => showDialog( + context: context, + barrierDismissible: false, + builder: (_) => PollAddCommentDialog( + initialValue: initialValue, + ), +); /// {@template pollAddCommentDialog} /// A dialog that allows the user to add or update a poll comment. /// /// Optionally, you can provide an [initialValue] to pre-fill the text field. +/// +/// See also: +/// +/// * [showPollAddCommentDialog], the convenience function to show this dialog. +/// * [StreamPollInteractor], the parent widget that triggers this dialog. /// {@endtemplate} class PollAddCommentDialog extends StatefulWidget { /// {@macro pollAddCommentDialog} @@ -47,58 +54,42 @@ class _PollAddCommentDialogState extends State { @override Widget build(BuildContext context) { - final theme = StreamChatTheme.of(context); - final pollInteractorTheme = StreamPollInteractorTheme.of(context); + final colorScheme = context.streamColorScheme; final actions = [ - TextButton( + StreamButton( + type: .ghost, + style: .secondary, + size: .small, onPressed: Navigator.of(context).pop, - style: TextButton.styleFrom( - textStyle: theme.textTheme.headlineBold, - foregroundColor: theme.colorTheme.accentPrimary, - disabledForegroundColor: theme.colorTheme.disabled, - ), - child: Text(context.translations.cancelLabel.toUpperCase()), + child: Text(context.translations.cancelLabel), ), - TextButton( - onPressed: switch (_comment == widget.initialValue) { - true => null, - false => () => Navigator.of(context).pop(_comment), + StreamButton( + type: .solid, + style: .primary, + size: .small, + onPressed: switch (_comment.trim()) { + final comment when comment.isEmpty => null, + final comment when comment == widget.initialValue => null, + final comment => () => Navigator.of(context).pop(comment), }, - style: TextButton.styleFrom( - textStyle: theme.textTheme.headlineBold, - foregroundColor: theme.colorTheme.accentPrimary, - disabledForegroundColor: theme.colorTheme.disabled, - ), - child: Text(context.translations.sendLabel.toUpperCase()), + child: Text(context.translations.sendLabel), ), ]; return AlertDialog( + actions: actions, title: Text( switch (widget.initialValue.isEmpty) { true => context.translations.addACommentLabel, false => context.translations.updateYourCommentLabel, }, - style: pollInteractorTheme.pollActionDialogTitleStyle, ), - actions: actions, - titlePadding: const EdgeInsets.symmetric(vertical: 14, horizontal: 16), - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), - contentPadding: const EdgeInsets.all(16), - actionsPadding: const EdgeInsets.all(8), - backgroundColor: theme.colorTheme.appBg, - content: StreamPollTextField( - autoFocus: true, + backgroundColor: colorScheme.backgroundElevation1, + content: StreamTextInput( + autofocus: true, initialValue: _comment, hintText: context.translations.enterYourCommentLabel, - contentPadding: const EdgeInsets.symmetric( - vertical: 12, - horizontal: 16, - ), - style: pollInteractorTheme.pollActionDialogTextFieldStyle, - fillColor: pollInteractorTheme.pollActionDialogTextFieldFillColor, - borderRadius: pollInteractorTheme.pollActionDialogTextFieldBorderRadius, onChanged: (value) => setState(() => _comment = value), ), ); diff --git a/packages/stream_chat_flutter/lib/src/poll/interactor/poll_end_vote_dialog.dart b/packages/stream_chat_flutter/lib/src/poll/interactor/poll_end_vote_dialog.dart index fb7ec9f409..9294c7f27d 100644 --- a/packages/stream_chat_flutter/lib/src/poll/interactor/poll_end_vote_dialog.dart +++ b/packages/stream_chat_flutter/lib/src/poll/interactor/poll_end_vote_dialog.dart @@ -1,10 +1,13 @@ import 'package:flutter/material.dart'; -import 'package:stream_chat_flutter/src/theme/poll_interactor_theme.dart'; -import 'package:stream_chat_flutter/src/theme/stream_chat_theme.dart'; -import 'package:stream_chat_flutter/src/utils/extensions.dart'; +import 'package:stream_chat_flutter/stream_chat_flutter.dart'; -/// {@template showPollSuggestOptionDialog} +/// {@template showPollEndVoteDialog} /// Shows a dialog that allows the user to end vote for a poll. +/// +/// See also: +/// +/// * [PollEndVoteDialog], the dialog widget shown by this function. +/// * [StreamPollInteractor], which invokes this via [StreamPollInteractor.onEndVote]. /// {@endtemplate} Future showPollEndVoteDialog({ required BuildContext context, @@ -17,6 +20,11 @@ Future showPollEndVoteDialog({ /// {@template pollEndVoteDialog} /// A dialog that allows the user to end vote for a poll. +/// +/// See also: +/// +/// * [showPollEndVoteDialog], the convenience function to show this dialog. +/// * [StreamPollInteractor], the parent widget that triggers this dialog. /// {@endtemplate} class PollEndVoteDialog extends StatelessWidget { /// {@macro pollEndVoteDialog} @@ -24,41 +32,30 @@ class PollEndVoteDialog extends StatelessWidget { @override Widget build(BuildContext context) { - final theme = StreamChatTheme.of(context); - final pollInteractorTheme = StreamPollInteractorTheme.of(context); + final colorScheme = context.streamColorScheme; final actions = [ - TextButton( + StreamButton( + type: .ghost, + style: .secondary, + size: .small, onPressed: () => Navigator.of(context).maybePop(false), - style: TextButton.styleFrom( - textStyle: theme.textTheme.headlineBold, - foregroundColor: theme.colorTheme.accentPrimary, - disabledForegroundColor: theme.colorTheme.disabled, - ), - child: Text(context.translations.cancelLabel.toUpperCase()), + child: Text(context.translations.cancelLabel), ), - TextButton( + StreamButton( + type: .solid, + style: .destructive, + size: .small, onPressed: () => Navigator.of(context).maybePop(true), - style: TextButton.styleFrom( - textStyle: theme.textTheme.headlineBold, - foregroundColor: theme.colorTheme.accentPrimary, - disabledForegroundColor: theme.colorTheme.disabled, - ), - child: Text(context.translations.endLabel.toUpperCase()), + child: Text(context.translations.endLabel), ), ]; return AlertDialog( - title: Text( - context.translations.endVoteConfirmationText, - style: pollInteractorTheme.pollActionDialogTitleStyle, - ), actions: actions, - titlePadding: const EdgeInsets.symmetric(vertical: 14, horizontal: 16), - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), - contentPadding: const EdgeInsets.all(16), - actionsPadding: const EdgeInsets.all(8), - backgroundColor: theme.colorTheme.appBg, + title: Text(context.translations.endVoteConfirmationTitle), + content: Text(context.translations.endVoteConfirmationMessage), + backgroundColor: colorScheme.backgroundElevation1, ); } } diff --git a/packages/stream_chat_flutter/lib/src/poll/interactor/poll_footer.dart b/packages/stream_chat_flutter/lib/src/poll/interactor/poll_footer.dart index 807e5071e0..9be16f62c9 100644 --- a/packages/stream_chat_flutter/lib/src/poll/interactor/poll_footer.dart +++ b/packages/stream_chat_flutter/lib/src/poll/interactor/poll_footer.dart @@ -1,13 +1,20 @@ import 'package:flutter/material.dart'; -import 'package:stream_chat_flutter/src/theme/poll_interactor_theme.dart'; -import 'package:stream_chat_flutter/src/utils/utils.dart'; -import 'package:stream_chat_flutter_core/stream_chat_flutter_core.dart'; +import 'package:stream_chat_flutter/stream_chat_flutter.dart'; +import 'package:stream_core_flutter/stream_core_flutter.dart'; /// {@template pollFooter} /// A widget used as the footer of a poll. /// /// Used in [StreamPollInteractor] to display various actions the user can take /// on the poll. +/// +/// See also: +/// +/// * [StreamPollInteractorThemeData.primaryActionStyle], for customizing +/// primary action buttons (view results, end vote). +/// * [StreamPollInteractorThemeData.secondaryActionStyle], for customizing +/// secondary action buttons (suggest option, add comment, view comments). +/// * [StreamPollInteractor], the parent widget that uses this footer. /// {@endtemplate} class PollFooter extends StatelessWidget { /// {@macro pollFooter} @@ -21,7 +28,6 @@ class PollFooter extends StatelessWidget { this.onViewComments, this.onViewResults, this.onSuggestOption, - this.onSeeMoreOptions, }); /// The poll the footer is for. @@ -59,12 +65,6 @@ class PollFooter extends StatelessWidget { /// suggested options. final VoidCallback? onSuggestOption; - /// Callback invoked when the user wants to see more options. - /// - /// This is only available if the poll has more options than the - /// [visibleOptionCount]. - final VoidCallback? onSeeMoreOptions; - bool get _shouldShowEndPollButton { if (poll.isClosed) return false; @@ -72,6 +72,11 @@ class PollFooter extends StatelessWidget { return poll.createdBy?.id == currentUser.id; } + bool get _shouldShowViewResultsButton { + // If the poll has no votes, don't show the button. + return poll.voteCount > 0; + } + bool get _shouldShowAddCommentButton { if (poll.isClosed || !poll.allowAnswers) return false; @@ -93,86 +98,145 @@ class PollFooter extends StatelessWidget { return poll.allowUserSuggestedOptions; } - bool get _shouldEnableViewResultsButton { - // Disable the button if the poll haven't got any votes yet. - if (poll.voteCount < 1) return false; - - return true; - } - @override Widget build(BuildContext context) { final translations = context.translations; - return Column( - spacing: 2, - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - if (visibleOptionCount case final count? - when count < poll.options.length) - PollFooterButton( - title: translations.seeAllOptionsLabel(count: poll.options.length), - onPressed: onSeeMoreOptions, - ), - if (_shouldShowSuggestionsButton) - PollFooterButton( - title: translations.suggestAnOptionLabel, - onPressed: onSuggestOption, + final spacing = context.streamSpacing; + + final showEndPoll = _shouldShowEndPollButton; + final showViewResults = _shouldShowViewResultsButton; + final showSuggestions = _shouldShowSuggestionsButton; + final showAddComment = _shouldShowAddCommentButton; + final showViewComments = _shouldShowViewCommentsButton; + + if (!showEndPoll && !showViewResults && !showSuggestions && !showAddComment && !showViewComments) { + return SizedBox(height: spacing.lg); + } + + return Padding( + padding: .all(spacing.md), + child: Column( + mainAxisSize: .min, + spacing: spacing.xxxs, + crossAxisAlignment: .stretch, + children: [ + Column( + mainAxisSize: .min, + spacing: spacing.xs, + crossAxisAlignment: .stretch, + children: [ + if (showViewResults) + _PollFooterButton.primary( + label: translations.viewResultsLabel, + onPressed: onViewResults, + ), + if (showEndPoll) + _PollFooterButton.primary( + label: translations.endVoteLabel, + onPressed: onEndVote, + ), + ], ), - if (_shouldShowAddCommentButton) - PollFooterButton( - title: translations.addACommentLabel, - onPressed: onAddComment, - ), - if (_shouldShowViewCommentsButton) - PollFooterButton( - title: translations.viewCommentsLabel, - onPressed: onViewComments, - ), - PollFooterButton( - title: translations.viewResultsLabel, - onPressed: _shouldEnableViewResultsButton ? onViewResults : null, - ), - if (_shouldShowEndPollButton) - PollFooterButton( - title: translations.endVoteLabel, - onPressed: onEndVote, + Column( + mainAxisSize: .min, + spacing: spacing.xs, + crossAxisAlignment: .stretch, + children: [ + if (showSuggestions) + _PollFooterButton.secondary( + label: translations.suggestAnOptionLabel, + onPressed: onSuggestOption, + ), + if (showAddComment) + _PollFooterButton.secondary( + label: translations.addACommentLabel, + onPressed: onAddComment, + ), + if (showViewComments) + _PollFooterButton.secondary( + label: translations.viewCommentsLabel, + onPressed: onViewComments, + ), + ], ), - ], + ], + ), ); } } -/// {@template pollFooterButton} -/// A button used in [PollFooter]. -/// -/// Displays the title and invokes the [onPressed] callback when pressed. -/// {@endtemplate} -class PollFooterButton extends StatelessWidget { - /// {@macro pollFooterButton} - const PollFooterButton({ - super.key, - required this.title, +// A button used in [PollFooter]. +// +// Renders a [StreamButton] with the appropriate poll interactor theme style +// applied via [StreamButtonTheme]. +class _PollFooterButton extends StatelessWidget { + const _PollFooterButton({ + required this.label, + required this.type, this.onPressed, }); - /// The title of the button. - final String title; + // Creates a primary poll footer button (outline style). + const _PollFooterButton.primary({ + required String label, + VoidCallback? onPressed, + }) : this(label: label, type: .outline, onPressed: onPressed); + + // Creates a secondary poll footer button (ghost style). + const _PollFooterButton.secondary({ + required String label, + VoidCallback? onPressed, + }) : this(label: label, type: .ghost, onPressed: onPressed); - /// Callback invoked when the button is pressed. + final String label; + final StreamButtonType type; final VoidCallback? onPressed; @override Widget build(BuildContext context) { final theme = StreamPollInteractorTheme.of(context); + final defaults = _StreamPollFooterDefaults(context); - return TextButton( - onPressed: onPressed, - // Consume long press to avoid the parent long press. - onLongPress: onPressed != null ? () {} : null, - style: theme.pollActionButtonStyle, - child: Text(title), + final effectivePrimaryActionStyle = theme.primaryActionStyle ?? defaults.primaryActionStyle; + final effectiveSecondaryActionStyle = theme.secondaryActionStyle ?? defaults.secondaryActionStyle; + + return StreamButtonTheme( + data: StreamButtonThemeData( + secondary: StreamButtonTypeStyle( + outline: effectivePrimaryActionStyle, + ghost: effectiveSecondaryActionStyle, + ), + ), + child: StreamButton( + size: .small, + style: .secondary, + type: type, + onPressed: onPressed, + child: Text(label), + ), ); } } + +// Default values for [StreamPollInteractorThemeData] backed by stream design tokens. +class _StreamPollFooterDefaults extends StreamPollInteractorThemeData { + _StreamPollFooterDefaults(this._context); + + final BuildContext _context; + + late final _alignment = StreamMessageLayout.messageAlignmentOf(_context); + late final StreamColorScheme _colorScheme = _context.streamColorScheme; + + @override + StreamButtonThemeStyle get primaryActionStyle => .from( + tapTargetSize: .shrinkWrap, + borderColor: switch (_alignment) { + .start => _colorScheme.borderStrong, + .end => _colorScheme.brand.shade300, + }, + ); + + @override + StreamButtonThemeStyle get secondaryActionStyle => .from(tapTargetSize: .shrinkWrap); +} diff --git a/packages/stream_chat_flutter/lib/src/poll/interactor/poll_header.dart b/packages/stream_chat_flutter/lib/src/poll/interactor/poll_header.dart index 0e32a1e59f..655511b767 100644 --- a/packages/stream_chat_flutter/lib/src/poll/interactor/poll_header.dart +++ b/packages/stream_chat_flutter/lib/src/poll/interactor/poll_header.dart @@ -2,11 +2,20 @@ import 'package:flutter/material.dart'; import 'package:stream_chat_flutter/src/theme/poll_interactor_theme.dart'; import 'package:stream_chat_flutter/src/utils/extensions.dart'; import 'package:stream_chat_flutter_core/stream_chat_flutter_core.dart'; +import 'package:stream_core_flutter/stream_core_flutter.dart'; /// {@template pollHeader} /// A widget used as the header of a poll. /// /// Used in [StreamPollInteractor] to display the poll question and voting mode. +/// +/// See also: +/// +/// * [StreamPollInteractorThemeData.titleTextStyle], for customizing the +/// question text. +/// * [StreamPollInteractorThemeData.subtitleTextStyle], for customizing the +/// voting mode text. +/// * [StreamPollInteractor], the parent widget that uses this header. /// {@endtemplate} class PollHeader extends StatelessWidget { /// {@macro pollHeader} @@ -21,20 +30,46 @@ class PollHeader extends StatelessWidget { @override Widget build(BuildContext context) { final theme = StreamPollInteractorTheme.of(context); + final defaults = _StreamPollHeaderDefaults(context); + + final spacing = context.streamSpacing; + + final effectiveTitleTextStyle = theme.titleTextStyle ?? defaults.titleTextStyle; + final effectiveSubtitleTextStyle = theme.subtitleTextStyle ?? defaults.subtitleTextStyle; - return Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - poll.name, - style: theme.pollTitleStyle, - ), - Text( - context.translations.pollVotingModeLabel(poll.votingMode), - style: theme.pollSubtitleStyle, - ), - ], + return Padding( + padding: .all(spacing.md), + child: Column( + spacing: spacing.xxs, + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(poll.name, style: effectiveTitleTextStyle), + Text(context.translations.pollVotingModeLabel(poll.votingMode), style: effectiveSubtitleTextStyle), + ], + ), ); } } + +// Default values for [StreamPollInteractorThemeData] backed by stream design tokens. +class _StreamPollHeaderDefaults extends StreamPollInteractorThemeData { + _StreamPollHeaderDefaults(this._context); + + final BuildContext _context; + + late final _alignment = StreamMessageLayout.messageAlignmentOf(_context); + late final StreamColorScheme _colorScheme = _context.streamColorScheme; + late final StreamTextTheme _textTheme = _context.streamTextTheme; + + Color get _textColor => switch (_alignment) { + .start => _colorScheme.textPrimary, + .end => _colorScheme.brand.shade900, + }; + + @override + TextStyle get titleTextStyle => _textTheme.bodyEmphasis.copyWith(color: _textColor); + + @override + TextStyle get subtitleTextStyle => _textTheme.captionDefault.copyWith(color: _textColor); +} diff --git a/packages/stream_chat_flutter/lib/src/poll/interactor/poll_options_list_view.dart b/packages/stream_chat_flutter/lib/src/poll/interactor/poll_options_list_view.dart index 2aa627a176..dc60f5bfb1 100644 --- a/packages/stream_chat_flutter/lib/src/poll/interactor/poll_options_list_view.dart +++ b/packages/stream_chat_flutter/lib/src/poll/interactor/poll_options_list_view.dart @@ -1,22 +1,32 @@ import 'package:flutter/material.dart'; -import 'package:stream_chat_flutter/src/avatars/user_avatar.dart'; -import 'package:stream_chat_flutter/src/misc/empty_widget.dart'; +import 'package:stream_chat_flutter/src/components/avatar/stream_user_avatar_stack.dart'; import 'package:stream_chat_flutter/src/theme/poll_interactor_theme.dart'; -import 'package:stream_chat_flutter/src/theme/stream_chat_theme.dart'; +import 'package:stream_chat_flutter/src/utils/utils.dart'; import 'package:stream_chat_flutter_core/stream_chat_flutter_core.dart'; +import 'package:stream_core_flutter/stream_core_flutter.dart'; /// {@template pollOptionsListView} /// A widget that displays the list of poll options. /// /// Used in [StreamPollInteractor] to display the poll options to interact with. +/// +/// See also: +/// +/// * [PollOptionItem], the widget used to render each individual option. +/// * [StreamPollInteractorThemeData.optionStyle], for customizing option +/// appearance. +/// * [StreamPollInteractor], the parent widget that uses this list. /// {@endtemplate} class PollOptionsListView extends StatelessWidget { /// {@macro pollOptionsListView} const PollOptionsListView({ super.key, required this.poll, + this.padding, + this.spacing, + this.optionStyle, this.visibleOptionCount, - this.showProgressBar = false, + this.onSeeMoreOptions, this.onCastVote, this.onRemoveVote, }); @@ -24,15 +34,31 @@ class PollOptionsListView extends StatelessWidget { /// The poll to display the options for. final Poll poll; + /// Padding applied around the list of options. + /// + /// If null, defaults to `EdgeInsets.symmetric(horizontal: context.streamSpacing.xs)`. + final EdgeInsetsGeometry? padding; + + /// The vertical spacing between poll options. + /// + /// If null, defaults to `context.streamSpacing.xxxs`. + final double? spacing; + + /// The style used to render each [PollOptionItem]. + /// + /// If null, defaults to [StreamPollInteractorThemeData.optionStyle]. + final StreamPollOptionStyle? optionStyle; + /// The number of visible options in the poll. /// /// If null, all options will be visible. final int? visibleOptionCount; - /// Whether to show the voting progress bar. + /// Callback invoked when the user wants to see more options. /// - /// Note: This is only used when the poll is public. - final bool showProgressBar; + /// This is only available if the poll has more options than the + /// [visibleOptionCount]. + final VoidCallback? onSeeMoreOptions; /// Callback invoked when the user wants to cast a vote. /// @@ -66,36 +92,58 @@ class PollOptionsListView extends StatelessWidget { _ => poll.options, }; - return ListView.separated( - shrinkWrap: true, - itemCount: options.length, - physics: const NeverScrollableScrollPhysics(), - separatorBuilder: (_, __) => const SizedBox(height: 8), - itemBuilder: (context, index) { - final option = options.elementAt(index); - return PollOptionItem( - key: ValueKey(option.id), - poll: poll, - option: option, - showProgressBar: showProgressBar, - onChanged: (checked) { - if (checked == null) return; - - // Handle voting based on the voting mode. - poll.votingMode.when( - disabled: () {}, // Do nothing - all: () => _handleVoteAction(option, checked: checked), - // Note: We don't need to remove the other votes in the unique - // voting mode as the backend handles it. - unique: () => _handleVoteAction(option, checked: checked), - limited: (count) => _handleVoteAction( - option, - checked: checked && poll.ownVotes.length < count, - ), + final streamSpacing = context.streamSpacing; + final translations = context.translations; + + final effectiveSpacing = spacing ?? streamSpacing.xxxs; + final effectivePadding = padding ?? .symmetric(horizontal: streamSpacing.xs); + + return Column( + mainAxisSize: .min, + crossAxisAlignment: .stretch, + children: [ + ListView.separated( + shrinkWrap: true, + itemCount: options.length, + padding: effectivePadding, + physics: const NeverScrollableScrollPhysics(), + separatorBuilder: (_, __) => SizedBox(height: effectiveSpacing), + itemBuilder: (context, index) { + final option = options.elementAt(index); + return PollOptionItem( + key: ValueKey(option.id), + poll: poll, + option: option, + style: optionStyle, + onChanged: (checked) { + if (checked == null) return; + + // Handle voting based on the voting mode. + poll.votingMode.when( + disabled: () {}, // Do nothing + all: () => _handleVoteAction(option, checked: checked), + // Note: We don't need to remove the other votes in the unique + // voting mode as the backend handles it. + unique: () => _handleVoteAction(option, checked: checked), + limited: (count) => _handleVoteAction( + option, + checked: checked && poll.ownVotes.length < count, + ), + ); + }, ); }, - ); - }, + ), + if (visibleOptionCount case final count? when count < poll.options.length) + StreamButton( + size: .small, + style: .secondary, + type: .ghost, + onPressed: onSeeMoreOptions, + themeStyle: .from(tapTargetSize: .shrinkWrap), + child: Text(translations.seeAllOptionsLabel(count: poll.options.length)), + ), + ], ); } } @@ -107,6 +155,11 @@ class PollOptionsListView extends StatelessWidget { /// /// This widget is used to display the poll option and the number of votes it /// has received. Also shows the voters if the poll is public. +/// +/// See also: +/// +/// * [StreamPollOptionStyle], for customizing option appearance. +/// * [PollOptionsListView], which uses this widget for each option. /// {@endtemplate} class PollOptionItem extends StatelessWidget { /// {@macro pollOptionItem} @@ -114,7 +167,7 @@ class PollOptionItem extends StatelessWidget { super.key, required this.poll, required this.option, - this.showProgressBar = true, + this.style, this.onChanged, }); @@ -124,10 +177,10 @@ class PollOptionItem extends StatelessWidget { /// The poll option the user can interact with. final PollOption option; - /// Whether to show the progress bar. + /// The style used to render this item. /// - /// Note: This is only used when the poll is public. - final bool showProgressBar; + /// If null, defaults to [StreamPollInteractorThemeData.optionStyle]. + final StreamPollOptionStyle? style; /// Callback invoked when the user interacts with the option. /// @@ -136,80 +189,87 @@ class PollOptionItem extends StatelessWidget { @override Widget build(BuildContext context) { - final theme = StreamPollInteractorTheme.of(context); + final theme = style ?? StreamPollInteractorTheme.of(context).optionStyle; + final defaults = _StreamPollOptionDefaults(context); + + final radius = context.streamRadius; + final spacing = context.streamSpacing; + + final effectiveTextStyle = theme?.textStyle ?? defaults.textStyle; + final effectiveVotesTextStyle = theme?.votesTextStyle ?? defaults.votesTextStyle; + final effectiveCheckboxStyle = theme?.checkboxStyle ?? defaults.checkboxStyle; + final effectiveVotesAvatarSize = theme?.votesAvatarSize ?? defaults.votesAvatarSize; + final effectiveProgressBarStyle = theme?.progressBarStyle ?? defaults.progressBarStyle; final pollClosed = poll.isClosed; final isOptionSelected = poll.hasCurrentUserVotedFor(option); final control = ExcludeFocus( - child: Checkbox( - value: isOptionSelected, - onChanged: pollClosed ? null : onChanged, - checkColor: theme.pollOptionCheckboxCheckColor, - shape: theme.pollOptionCheckboxShape, - side: theme.pollOptionCheckboxBorderSide, - activeColor: theme.pollOptionCheckboxActiveColor, - materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, - visualDensity: const VisualDensity( - vertical: VisualDensity.minimumDensity, - horizontal: VisualDensity.minimumDensity, + child: StreamCheckboxTheme( + data: .new(style: effectiveCheckboxStyle), + child: StreamCheckbox.circular( + size: .md, + value: isOptionSelected, + onChanged: pollClosed ? null : onChanged, ), ), ); return InkWell( + borderRadius: .all(radius.md), onTap: pollClosed ? null : () => onChanged?.call(!isOptionSelected), child: Padding( - padding: const EdgeInsets.symmetric(vertical: 8), + padding: .all(spacing.xs), child: Row( - spacing: 4, + spacing: spacing.sm, children: [ if (pollClosed case false) control, Expanded( child: Column( - spacing: 4, + spacing: spacing.xxs, crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( - spacing: 4, + spacing: spacing.xs, children: [ Expanded( child: Text( option.text, maxLines: 2, overflow: TextOverflow.ellipsis, - style: theme.pollOptionTextStyle, + style: effectiveTextStyle, ), ), - // Show voters only if the poll is public. - if (poll.votingVisibility == VotingVisibility.public) - OptionVoters( - // We only show the latest 3 voters. - voters: [ - ...?poll.latestVotesByOption[option.id], - ].map((it) => it.user).whereType().take(3), - ), - Text( - poll.voteCountFor(option).toString(), - style: theme.pollOptionVoteCountTextStyle, + Row( + mainAxisSize: .min, + spacing: spacing.xxs, + children: [ + // Show voters only if the poll is public. + if (poll.votingVisibility == VotingVisibility.public) + StreamUserAvatarStack( + size: effectiveVotesAvatarSize, + // We only show the latest 3 voters. + users: [ + ...?poll.latestVotesByOption[option.id], + ].map((it) => it.user).whereType().take(3), + ), + Text( + poll.voteCountFor(option).toString(), + style: effectiveVotesTextStyle, + ), + ], ), ], ), - if (showProgressBar) - OptionVotesProgressBar( + StreamProgressBarTheme( + data: .new(style: effectiveProgressBarStyle), + child: _AnimatedPollOptionProgressBar( value: poll.voteRatioFor(option), - borderRadius: - theme.pollOptionVotesProgressBarBorderRadius ?? - BorderRadius.circular(4), - trackColor: theme.pollOptionVotesProgressBarTrackColor, - valueColor: switch (poll.isOptionWinner(option)) { - true => theme.pollOptionVotesProgressBarWinnerColor, - false => theme.pollOptionVotesProgressBarValueColor, - }, ), + ), ], ), - ) + ), ], ), ), @@ -217,147 +277,84 @@ class PollOptionItem extends StatelessWidget { } } -/// {@template optionVoters} -/// A widget that displays the voters of an option. -/// -/// Used in [PollOptionItem] to display the voters of a poll option. -/// {@endtemplate} -class OptionVoters extends StatelessWidget { - /// {@macro optionVoters} - const OptionVoters({ - super.key, - this.radius = 10, - this.overlap = 0.5, - required this.voters, - }) : assert( - overlap >= 0 && overlap <= 1, - 'Overlap must be between 0 and 1', - ); - - /// The radius of the avatars. - final double radius; - - /// The overlap between the avatars. - /// - /// The default value is 1/2 i.e. 50%. - final double overlap; +// A progress bar that animates only when [value] changes between widget +// updates, not every time the widget's State is recreated. +// +// This avoids the progress bar visually "refilling" from 0 each time a poll +// option is re-mounted (e.g. when the poll message scrolls back into view in +// the message list). +class _AnimatedPollOptionProgressBar extends StatefulWidget { + const _AnimatedPollOptionProgressBar({required this.value}); + + final double value; + + @override + State<_AnimatedPollOptionProgressBar> createState() => _AnimatedPollOptionProgressBarState(); +} - /// The list of voters to display. - final Iterable voters; +class _AnimatedPollOptionProgressBarState extends State<_AnimatedPollOptionProgressBar> { + // Tracks the value the bar is currently displaying so we can animate from + // it to a new target value when [widget.value] changes. + late double _previousValue = widget.value; + + @override + void didUpdateWidget(covariant _AnimatedPollOptionProgressBar oldWidget) { + super.didUpdateWidget(oldWidget); + _previousValue = oldWidget.value; + } @override Widget build(BuildContext context) { - if (voters.isEmpty) return const Empty(); - - final theme = StreamChatTheme.of(context); - - final diameter = radius * 2; - final width = diameter + (voters.length * diameter * overlap); - - var overlapPadding = 0.0; - - return SizedBox.fromSize( - size: Size(width, diameter), - child: Stack( - children: [ - ...voters.map( - (user) { - overlapPadding += diameter * overlap; - return Positioned( - right: overlapPadding - (diameter * overlap), - bottom: 0, - top: 0, - child: Container( - decoration: BoxDecoration( - shape: BoxShape.circle, - color: theme.colorTheme.barsBg, - ), - padding: const EdgeInsets.all(1), - child: StreamUserAvatar( - user: user, - constraints: BoxConstraints.tight(Size.fromRadius(radius)), - showOnlineStatus: false, - ), - ), - ); - }, - ), - ], - ), + return TweenAnimationBuilder( + curve: Curves.easeOutCubic, + duration: Durations.medium2, + tween: Tween(begin: _previousValue, end: widget.value), + builder: (_, value, _) => StreamProgressBar(value: value), ); } } -/// {@template optionVotesProgressBar} -/// A widget that displays the progress of the votes for an option. -/// -/// Used in [PollOptionItem] to display the progress of the votes for a -/// particular option. -/// {@endtemplate} -class OptionVotesProgressBar extends StatelessWidget { - /// {@macro optionVotesProgressBar} - const OptionVotesProgressBar({ - super.key, - required this.value, - this.minHeight = 4, - this.trackColor, - this.valueColor, - this.borderRadius = BorderRadius.zero, - }); +// Default values for [StreamPollOptionStyle] backed by stream design tokens. +class _StreamPollOptionDefaults extends StreamPollOptionStyle { + _StreamPollOptionDefaults(this._context); - /// The value of the progress bar. - final double value; + final BuildContext _context; - /// The minimum height of the progress bar. - final double minHeight; + late final _alignment = StreamMessageLayout.messageAlignmentOf(_context); + late final StreamColorScheme _colorScheme = _context.streamColorScheme; + late final StreamTextTheme _textTheme = _context.streamTextTheme; - /// The color of the track. - final Color? trackColor; + Color get _textColor => switch (_alignment) { + .start => _colorScheme.textPrimary, + .end => _colorScheme.brand.shade900, + }; - /// The color of the value. - final Color? valueColor; + @override + StreamAvatarStackSize get votesAvatarSize => StreamAvatarStackSize.xs; - /// The border radius of the progress bar. - /// - /// Defaults to [BorderRadius.zero]. - final BorderRadiusGeometry borderRadius; + @override + TextStyle get textStyle => _textTheme.captionDefault.copyWith(color: _textColor); @override - Widget build(BuildContext context) { - final shape = RoundedRectangleBorder(borderRadius: borderRadius); - return Container( - constraints: BoxConstraints( - minWidth: double.infinity, - minHeight: minHeight, - ), - decoration: ShapeDecoration( - shape: shape, - color: trackColor, - ), - child: LayoutBuilder( - builder: (context, constraints) { - final size = constraints.constrain(Size.zero); - - final textDirection = Directionality.of(context); - final alignment = switch (textDirection) { - TextDirection.ltr => Alignment.centerLeft, - TextDirection.rtl => Alignment.centerRight, - }; - - return Align( - alignment: alignment, - child: AnimatedContainer( - height: size.height, - width: size.width * value, - duration: Durations.medium2, - decoration: ShapeDecoration( - shape: shape, - color: valueColor, - ), - ), - ); - }, - ), - ); - } + TextStyle get votesTextStyle => _textTheme.metadataDefault.copyWith(color: _textColor); + + @override + StreamCheckboxStyle get checkboxStyle => StreamCheckboxStyle.from( + side: switch (_alignment) { + .start => BorderSide(color: _colorScheme.borderStrong), + .end => BorderSide(color: _colorScheme.brand.shade300), + }, + ); + + @override + StreamProgressBarStyle get progressBarStyle => StreamProgressBarStyle( + trackColor: switch (_alignment) { + .start => _colorScheme.backgroundSurfaceStrong, + .end => _colorScheme.brand.shade200, + }, + fillColor: switch (_alignment) { + .start => _colorScheme.accentNeutral, + .end => _colorScheme.accentPrimary, + }, + ); } diff --git a/packages/stream_chat_flutter/lib/src/poll/interactor/poll_suggest_option_dialog.dart b/packages/stream_chat_flutter/lib/src/poll/interactor/poll_suggest_option_dialog.dart index fd4c02f934..f71c32e09e 100644 --- a/packages/stream_chat_flutter/lib/src/poll/interactor/poll_suggest_option_dialog.dart +++ b/packages/stream_chat_flutter/lib/src/poll/interactor/poll_suggest_option_dialog.dart @@ -1,30 +1,39 @@ import 'package:flutter/material.dart'; -import 'package:stream_chat_flutter/src/poll/stream_poll_text_field.dart'; -import 'package:stream_chat_flutter/src/theme/poll_interactor_theme.dart'; -import 'package:stream_chat_flutter/src/theme/stream_chat_theme.dart'; import 'package:stream_chat_flutter/src/utils/extensions.dart'; +import 'package:stream_core_flutter/stream_core_flutter.dart'; /// {@template showPollSuggestOptionDialog} /// Shows a dialog that allows the user to suggest an option for a poll. /// /// Optionally, you can provide an [initialOption] to pre-fill the text field. +/// +/// See also: +/// +/// * [PollSuggestOptionDialog], the dialog widget shown by this function. +/// * [StreamPollInteractor], which invokes this via +/// [StreamPollInteractor.onSuggestOption]. /// {@endtemplate} Future showPollSuggestOptionDialog({ required BuildContext context, String initialOption = '', -}) => - showDialog( - context: context, - barrierDismissible: false, - builder: (_) => PollSuggestOptionDialog( - initialOption: initialOption, - ), - ); +}) => showDialog( + context: context, + barrierDismissible: false, + builder: (_) => PollSuggestOptionDialog( + initialOption: initialOption, + ), +); /// {@template pollSuggestOptionDialog} /// A dialog that allows the user to suggest an option for a poll. /// /// Optionally, you can provide an [initialOption] to pre-fill the text field. +/// +/// See also: +/// +/// * [showPollSuggestOptionDialog], the convenience function to show this +/// dialog. +/// * [StreamPollInteractor], the parent widget that triggers this dialog. /// {@endtemplate} class PollSuggestOptionDialog extends StatefulWidget { /// {@macro pollSuggestOptionDialog} @@ -39,8 +48,7 @@ class PollSuggestOptionDialog extends StatefulWidget { final String initialOption; @override - State createState() => - _PollSuggestOptionDialogState(); + State createState() => _PollSuggestOptionDialogState(); } class _PollSuggestOptionDialogState extends State { @@ -48,55 +56,37 @@ class _PollSuggestOptionDialogState extends State { @override Widget build(BuildContext context) { - final theme = StreamChatTheme.of(context); - final pollInteractorTheme = StreamPollInteractorTheme.of(context); + final colorScheme = context.streamColorScheme; final actions = [ - TextButton( + StreamButton( + type: .ghost, + style: .secondary, + size: .small, onPressed: Navigator.of(context).pop, - style: TextButton.styleFrom( - textStyle: theme.textTheme.headlineBold, - foregroundColor: theme.colorTheme.accentPrimary, - disabledForegroundColor: theme.colorTheme.disabled, - ), - child: Text(context.translations.cancelLabel.toUpperCase()), + child: Text(context.translations.cancelLabel), ), - TextButton( - onPressed: switch (_option == widget.initialOption) { - true => null, - false => () => Navigator.of(context).pop(_option), + StreamButton( + type: .solid, + style: .primary, + size: .small, + onPressed: switch (_option.trim()) { + final option when option.isEmpty => null, + final option when option == widget.initialOption => null, + final option => () => Navigator.of(context).pop(option), }, - style: TextButton.styleFrom( - textStyle: theme.textTheme.headlineBold, - foregroundColor: theme.colorTheme.accentPrimary, - disabledForegroundColor: theme.colorTheme.disabled, - ), - child: Text(context.translations.sendLabel.toUpperCase()), + child: Text(context.translations.sendLabel), ), ]; return AlertDialog( - title: Text( - context.translations.suggestAnOptionLabel, - style: pollInteractorTheme.pollActionDialogTitleStyle, - ), actions: actions, - titlePadding: const EdgeInsets.symmetric(vertical: 14, horizontal: 16), - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), - contentPadding: const EdgeInsets.all(16), - actionsPadding: const EdgeInsets.all(8), - backgroundColor: theme.colorTheme.appBg, - content: StreamPollTextField( - autoFocus: true, + title: Text(context.translations.suggestAnOptionLabel), + backgroundColor: colorScheme.backgroundElevation1, + content: StreamTextInput( + autofocus: true, initialValue: _option, hintText: context.translations.enterANewOptionLabel, - contentPadding: const EdgeInsets.symmetric( - vertical: 12, - horizontal: 16, - ), - style: pollInteractorTheme.pollActionDialogTextFieldStyle, - fillColor: pollInteractorTheme.pollActionDialogTextFieldFillColor, - borderRadius: pollInteractorTheme.pollActionDialogTextFieldBorderRadius, onChanged: (value) => setState(() => _option = value), ), ); diff --git a/packages/stream_chat_flutter/lib/src/poll/interactor/stream_poll_interactor.dart b/packages/stream_chat_flutter/lib/src/poll/interactor/stream_poll_interactor.dart index 035ea76e1e..95ea861951 100644 --- a/packages/stream_chat_flutter/lib/src/poll/interactor/stream_poll_interactor.dart +++ b/packages/stream_chat_flutter/lib/src/poll/interactor/stream_poll_interactor.dart @@ -19,6 +19,13 @@ import 'package:stream_chat_flutter_core/stream_chat_flutter_core.dart'; /// /// The widget also provides a [visibleOptionCount] parameter to control the /// number of visible options in the poll. If null, all options will be visible. +/// +/// See also: +/// +/// * [StreamPollInteractorTheme], for customizing poll interactor appearance. +/// * [PollHeader], the header sub-component showing the poll question. +/// * [PollOptionsListView], the list of votable options. +/// * [PollFooter], the footer sub-component with action buttons. /// {@endtemplate} class StreamPollInteractor extends StatelessWidget { /// {@macro streamPollInteractor} @@ -26,10 +33,6 @@ class StreamPollInteractor extends StatelessWidget { super.key, required this.poll, required this.currentUser, - this.padding = const EdgeInsets.symmetric( - vertical: 12, - horizontal: 10, - ), this.visibleOptionCount, this.onCastVote, this.onRemoveVote, @@ -47,9 +50,6 @@ class StreamPollInteractor extends StatelessWidget { /// The current user interacting with the poll. final User currentUser; - /// The padding to apply to the interactor. - final EdgeInsetsGeometry padding; - /// The number of visible options in the poll. /// /// If null, all options will be visible. @@ -96,41 +96,36 @@ class StreamPollInteractor extends StatelessWidget { @override Widget build(BuildContext context) { - return Padding( - padding: padding, - child: Column( - spacing: 8, - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - PollHeader(poll: poll), - MediaQuery.removePadding( - context: context, - // Workaround for the bottom padding issue. - // Link: https://github.com/flutter/flutter/issues/156149 - removeTop: true, - removeBottom: true, - child: PollOptionsListView( - poll: poll, - showProgressBar: true, - visibleOptionCount: visibleOptionCount, - onCastVote: onCastVote, - onRemoveVote: onRemoveVote, - ), - ), - PollFooter( + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + PollHeader(poll: poll), + MediaQuery.removePadding( + context: context, + // Workaround for the bottom padding issue. + // Link: https://github.com/flutter/flutter/issues/156149 + removeTop: true, + removeBottom: true, + child: PollOptionsListView( poll: poll, - currentUser: currentUser, visibleOptionCount: visibleOptionCount, - onEndVote: onEndVote, - onAddComment: onAddComment, - onViewComments: onViewComments, - onViewResults: onViewResults, - onSuggestOption: onSuggestOption, onSeeMoreOptions: onSeeMoreOptions, + onCastVote: onCastVote, + onRemoveVote: onRemoveVote, ), - ], - ), + ), + PollFooter( + poll: poll, + currentUser: currentUser, + visibleOptionCount: visibleOptionCount, + onEndVote: onEndVote, + onAddComment: onAddComment, + onViewComments: onViewComments, + onViewResults: onViewResults, + onSuggestOption: onSuggestOption, + ), + ], ); } } diff --git a/packages/stream_chat_flutter/lib/src/poll/stream_poll_comments_dialog.dart b/packages/stream_chat_flutter/lib/src/poll/stream_poll_comments_dialog.dart deleted file mode 100644 index eb6ba6db92..0000000000 --- a/packages/stream_chat_flutter/lib/src/poll/stream_poll_comments_dialog.dart +++ /dev/null @@ -1,163 +0,0 @@ -import 'package:flutter/foundation.dart'; -import 'package:flutter/material.dart'; -import 'package:stream_chat_flutter/src/misc/empty_widget.dart'; -import 'package:stream_chat_flutter/src/poll/interactor/poll_add_comment_dialog.dart'; -import 'package:stream_chat_flutter/src/scroll_view/poll_vote_scroll_view/stream_poll_vote_list_view.dart'; -import 'package:stream_chat_flutter/src/theme/poll_comments_dialog_theme.dart'; -import 'package:stream_chat_flutter/src/utils/extensions.dart'; -import 'package:stream_chat_flutter_core/stream_chat_flutter_core.dart'; - -/// {@template showStreamPollCommentsDialog} -/// Displays an interactive dialog to show all the comments for a poll. -/// -/// The comments are paginated and get's loaded as the user scrolls. -/// -/// The dialog also allows the user to update their comment. -/// {@endtemplate} -Future showStreamPollCommentsDialog({ - required BuildContext context, - required ValueListenable messageNotifier, -}) { - final navigator = Navigator.of(context); - return navigator.push( - MaterialPageRoute( - fullscreenDialog: true, - builder: (_) => StreamChannel( - channel: StreamChannel.of(context).channel, - child: ValueListenableBuilder( - valueListenable: messageNotifier, - builder: (context, message, child) { - final poll = message.poll; - if (poll == null) return const Empty(); - - final channel = StreamChannel.of(context).channel; - - Future onUpdateComment() async { - final commentText = await showPollAddCommentDialog( - context: context, - // We use the first answer as the initial value because the - // user can only add one comment per poll. - initialValue: poll.ownAnswers.firstOrNull?.answerText ?? '', - ); - - if (commentText == null) return; - channel.addPollAnswer(message, poll, answerText: commentText); - } - - return StreamPollCommentsDialog( - poll: poll, - onUpdateComment: onUpdateComment, - ); - }, - ), - ), - ), - ); -} - -/// {@template streamPollCommentsDialog} -/// A dialog that displays all the comments for a poll. -/// -/// The comments are paginated and get's loaded as the user scrolls. -/// -/// Provides a callback to update the user's comment. -/// {@endtemplate} -class StreamPollCommentsDialog extends StatefulWidget { - /// {@macro streamPollCommentsDialog} - const StreamPollCommentsDialog({ - super.key, - required this.poll, - this.onUpdateComment, - }); - - /// The poll to display the options for. - final Poll poll; - - /// Callback invoked when the user wants to cast a vote. - final VoidCallback? onUpdateComment; - - @override - State createState() => - _StreamPollCommentsDialogState(); -} - -class _StreamPollCommentsDialogState extends State { - late StreamPollVoteListController _controller; - - @override - void initState() { - super.initState(); - _initializeController(); - } - - @override - void didUpdateWidget(covariant StreamPollCommentsDialog oldWidget) { - super.didUpdateWidget(oldWidget); - if (oldWidget.poll.id != widget.poll.id) { - _controller.dispose(); // Dispose the old controller. - _initializeController(); // Initialize a new controller. - } - } - - void _initializeController() { - _controller = StreamPollVoteListController( - pollId: widget.poll.id, - channel: StreamChannel.of(context).channel, - filter: Filter.equal('is_answer', true), - ); - } - - @override - void dispose() { - _controller.dispose(); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - final theme = StreamPollCommentsDialogTheme.of(context); - - return Scaffold( - backgroundColor: theme.backgroundColor, - appBar: AppBar( - elevation: theme.appBarElevation, - backgroundColor: theme.appBarBackgroundColor, - foregroundColor: theme.appBarForegroundColor, - title: Text( - context.translations.pollCommentsLabel, - style: theme.appBarTitleTextStyle, - ), - ), - body: RefreshIndicator.adaptive( - onRefresh: _controller.refresh, - child: StreamPollVoteListView( - controller: _controller, - padding: const EdgeInsets.all(16), - itemBuilder: (context, _, __, defaultWidget) { - return defaultWidget.copyWith( - showAnswerText: true, - contentPadding: const EdgeInsets.all(16), - borderRadius: theme.pollCommentItemBorderRadius, - tileColor: theme.pollCommentItemBackgroundColor, - ); - }, - ), - ), - bottomNavigationBar: widget.poll.isClosed - ? null - : SafeArea( - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 16), - child: FilledButton.tonal( - onPressed: widget.onUpdateComment, - style: theme.updateYourCommentButtonStyle, - child: Text(switch (widget.poll.ownAnswers.isEmpty) { - true => context.translations.addACommentLabel, - false => context.translations.updateYourCommentLabel, - }), - ), - ), - ), - ); - } -} diff --git a/packages/stream_chat_flutter/lib/src/poll/stream_poll_comments_sheet.dart b/packages/stream_chat_flutter/lib/src/poll/stream_poll_comments_sheet.dart new file mode 100644 index 0000000000..99cfd1ff1e --- /dev/null +++ b/packages/stream_chat_flutter/lib/src/poll/stream_poll_comments_sheet.dart @@ -0,0 +1,314 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:stream_chat_flutter/src/components/avatar/stream_user_avatar.dart'; +import 'package:stream_chat_flutter/src/misc/empty_widget.dart'; +import 'package:stream_chat_flutter/src/misc/timestamp.dart'; +import 'package:stream_chat_flutter/src/poll/interactor/poll_add_comment_dialog.dart'; +import 'package:stream_chat_flutter/src/scroll_view/poll_vote_scroll_view/stream_poll_vote_list_view.dart'; +import 'package:stream_chat_flutter/src/theme/poll_card_style.dart'; +import 'package:stream_chat_flutter/src/theme/poll_comments_sheet_theme.dart'; +import 'package:stream_chat_flutter/src/theme/poll_option_votes_style.dart'; +import 'package:stream_chat_flutter/src/utils/date_formatter.dart'; +import 'package:stream_chat_flutter/src/utils/extensions.dart'; +import 'package:stream_chat_flutter_core/stream_chat_flutter_core.dart'; +import 'package:stream_core_flutter/stream_core_flutter.dart'; + +/// {@template showStreamPollCommentsSheet} +/// Displays an interactive bottom sheet to show all the comments for a poll. +/// +/// The comments are paginated and get's loaded as the user scrolls. +/// +/// The sheet also allows the user to update their comment. +/// {@endtemplate} +Future showStreamPollCommentsSheet({ + required BuildContext context, + required ValueListenable messageNotifier, +}) { + return showStreamSheet( + context: context, + builder: (_, scrollController) => StreamChannel( + channel: StreamChannel.of(context).channel, + child: ValueListenableBuilder( + valueListenable: messageNotifier, + builder: (context, message, _) { + final poll = message.poll; + if (poll == null) return const Empty(); + + final channel = StreamChannel.of(context).channel; + + Future onUpdateComment() async { + final commentText = await showPollAddCommentDialog( + context: context, + // We use the first answer as the initial value because the + // user can only add one comment per poll. + initialValue: poll.ownAnswers.firstOrNull?.answerText ?? '', + ); + + if (commentText == null) return; + channel.addPollAnswer(message, poll, answerText: commentText); + } + + return StreamPollCommentsSheet( + poll: poll, + scrollController: scrollController, + onUpdateComment: onUpdateComment, + ); + }, + ), + ), + ); +} + +/// {@template streamPollCommentsSheet} +/// A bottom sheet that displays all the comments for a poll. +/// +/// The comments are paginated and get's loaded as the user scrolls. +/// +/// Provides a callback to update the user's comment. +/// {@endtemplate} +class StreamPollCommentsSheet extends StatefulWidget { + /// {@macro streamPollCommentsSheet} + const StreamPollCommentsSheet({ + super.key, + required this.poll, + this.scrollController, + this.onUpdateComment, + }); + + /// The poll to display the comments for. + final Poll poll; + + /// Scroll controller attached to the bottom sheet's scrollable content. + /// + /// Typically provided by [DraggableScrollableSheet] so the sheet expands and + /// collapses in response to the user's scroll gesture. + final ScrollController? scrollController; + + /// Callback invoked when the user wants to add or update their comment. + final VoidCallback? onUpdateComment; + + @override + State createState() => _StreamPollCommentsSheetState(); +} + +class _StreamPollCommentsSheetState extends State { + late StreamPollVoteListController _controller; + + @override + void initState() { + super.initState(); + _initializeController(); + } + + @override + void didUpdateWidget(covariant StreamPollCommentsSheet oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.poll.id != widget.poll.id) { + _controller.dispose(); // Dispose the old controller. + _initializeController(); // Initialize a new controller. + } + } + + void _initializeController() { + _controller = StreamPollVoteListController( + pollId: widget.poll.id, + channel: StreamChannel.of(context).channel, + filter: Filter.equal('is_answer', true), + ); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + bool get _shouldShowAddCommentButton { + if (widget.poll.isClosed || !widget.poll.allowAnswers) return false; + + // If the user has already commented, don't show the button. + if (widget.poll.ownAnswers.isNotEmpty) return false; + + return true; + } + + @override + Widget build(BuildContext context) { + final theme = StreamPollCommentsSheetTheme.of(context); + final defaults = _StreamPollCommentsSheetDefaults(context); + + final effectiveTheme = defaults.merge(theme); + + final commentStyle = effectiveTheme.commentStyle; + final itemSpacing = effectiveTheme.itemSpacing ?? 0; + + return Column( + mainAxisSize: .min, + children: [ + StreamSheetHeader( + style: effectiveTheme.sheetHeaderStyle, + title: Text(context.translations.pollCommentsLabel), + trailing: switch (_shouldShowAddCommentButton) { + true => StreamButton.icon( + style: .primary, + type: .solid, + icon: Icon(context.streamIcons.edit), + onPressed: widget.onUpdateComment, + ), + false => null, + }, + ), + Expanded( + child: RefreshIndicator.adaptive( + onRefresh: _controller.refresh, + child: StreamPollVoteListView( + controller: _controller, + scrollController: widget.scrollController, + padding: effectiveTheme.contentPadding, + separatorBuilder: (_, __, ___) => SizedBox(height: itemSpacing), + itemBuilder: (context, comments, index, _) { + final comment = comments[index]; + + return _PollCommentCard( + poll: widget.poll, + comment: comment, + style: commentStyle, + onUpdateComment: widget.onUpdateComment, + ); + }, + ), + ), + ), + ], + ); + } +} + +// Renders a single comment as a card inside [StreamPollCommentsSheet]'s +// paginated list. +class _PollCommentCard extends StatelessWidget { + const _PollCommentCard({ + required this.poll, + required this.comment, + required this.style, + this.onUpdateComment, + }); + + final Poll poll; + final PollVote comment; + final StreamPollOptionVotesStyle? style; + final VoidCallback? onUpdateComment; + + @override + Widget build(BuildContext context) { + final spacing = context.streamSpacing; + final textTheme = context.streamTextTheme; + final colorScheme = context.streamColorScheme; + + final cardStyle = style?.cardStyle; + + final currentUser = StreamChatCore.maybeOf(context)?.currentUser; + final isCurrentUser = switch (comment.user) { + final user? => user.id == currentUser?.id, + _ => false, + }; + + return DecoratedBox( + decoration: BoxDecoration( + color: cardStyle?.backgroundColor, + borderRadius: cardStyle?.borderRadius, + ), + child: Column( + mainAxisSize: .min, + children: [ + Padding( + padding: cardStyle?.padding ?? .zero, + child: Column( + spacing: spacing.xs, + crossAxisAlignment: .start, + children: [ + if (comment.answerText case final answerText?) + Text( + answerText, + style: textTheme.bodyDefault.copyWith(color: colorScheme.textPrimary), + ), + Row( + spacing: spacing.xs, + children: [ + if (comment.user case final user?) ...[ + StreamUserAvatar( + size: .sm, + user: user, + showOnlineIndicator: false, + ), + Flexible( + child: Text( + user.name, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: textTheme.captionEmphasis.copyWith(color: colorScheme.textPrimary), + ), + ), + ], + StreamTimestamp( + date: comment.updatedAt.toLocal(), + formatter: formatRecentDateTime, + style: textTheme.captionDefault.copyWith(color: colorScheme.textTertiary), + ), + ], + ), + ], + ), + ), + if (isCurrentUser && !poll.isClosed) ...[ + Divider(height: 1, color: style?.footerDividerColor), + StreamButton( + size: .small, + type: .ghost, + style: .secondary, + onPressed: onUpdateComment, + themeStyle: style?.footerButtonStyle, + child: Text(context.translations.updateYourCommentLabel), + ), + ], + ], + ), + ); + } +} + +// Default values for [StreamPollCommentsSheetThemeData] backed by stream +// design tokens. +class _StreamPollCommentsSheetDefaults extends StreamPollCommentsSheetThemeData { + _StreamPollCommentsSheetDefaults(this._context); + + final BuildContext _context; + + late final _spacing = _context.streamSpacing; + late final _radius = _context.streamRadius; + late final _colorScheme = _context.streamColorScheme; + + @override + Color get backgroundColor => _colorScheme.backgroundApp; + + @override + EdgeInsetsGeometry get contentPadding => .directional( + start: _spacing.md, + end: _spacing.md, + top: _spacing.md, + bottom: _spacing.xxxl, + ); + + @override + double get itemSpacing => _spacing.md; + + @override + StreamPollOptionVotesStyle get commentStyle => StreamPollOptionVotesStyle( + cardStyle: StreamPollCardStyle( + backgroundColor: _colorScheme.backgroundSurfaceCard, + borderRadius: BorderRadius.all(_radius.lg), + padding: EdgeInsets.all(_spacing.md), + ), + footerDividerColor: _colorScheme.borderDefault, + ); +} diff --git a/packages/stream_chat_flutter/lib/src/poll/stream_poll_option_votes_dialog.dart b/packages/stream_chat_flutter/lib/src/poll/stream_poll_option_votes_dialog.dart deleted file mode 100644 index a22e994c57..0000000000 --- a/packages/stream_chat_flutter/lib/src/poll/stream_poll_option_votes_dialog.dart +++ /dev/null @@ -1,175 +0,0 @@ -import 'package:flutter/foundation.dart'; -import 'package:flutter/material.dart'; -import 'package:stream_chat_flutter/src/icons/stream_svg_icon.dart'; -import 'package:stream_chat_flutter/src/misc/empty_widget.dart'; -import 'package:stream_chat_flutter/src/scroll_view/poll_vote_scroll_view/stream_poll_vote_list_view.dart'; -import 'package:stream_chat_flutter/src/theme/poll_option_votes_dialog_theme.dart'; -import 'package:stream_chat_flutter/src/utils/extensions.dart'; -import 'package:stream_chat_flutter_core/stream_chat_flutter_core.dart'; - -/// {@template showStreamPollOptionVotesDialog} -/// Displays an interactive dialog to show all the votes for a poll option. -/// -/// The votes are paginated and get's loaded as the user scrolls. -/// {@endtemplate} -Future showStreamPollOptionVotesDialog({ - required BuildContext context, - required ValueListenable messageNotifier, - required PollOption option, -}) { - final navigator = Navigator.of(context); - return navigator.push( - MaterialPageRoute( - fullscreenDialog: true, - builder: (_) => StreamChannel( - channel: StreamChannel.of(context).channel, - child: ValueListenableBuilder( - valueListenable: messageNotifier, - builder: (context, message, child) { - final poll = message.poll; - if (poll == null) return const Empty(); - if (option.id == null) return const Empty(); - - return StreamPollOptionVotesDialog( - poll: poll, - option: option, - pollVotesCount: poll.voteCountsByOption[option.id], - ); - }, - ), - ), - ), - ); -} - -/// {@template streamPollOptionVotesDialog} -/// A dialog that displays all the votes for a poll option. -/// -/// The votes are paginated and get's loaded as the user scrolls. -/// {@endtemplate} -class StreamPollOptionVotesDialog extends StatefulWidget { - /// {@macro streamPollOptionVotesDialog} - const StreamPollOptionVotesDialog({ - super.key, - required this.poll, - required this.option, - required this.pollVotesCount, - }); - - /// The poll for which the votes are being displayed. - final Poll poll; - - /// The option for which the votes are being displayed. - final PollOption option; - - /// The total number of votes for the option. - final int? pollVotesCount; - - @override - State createState() => - _StreamPollOptionVotesDialogState(); -} - -class _StreamPollOptionVotesDialogState - extends State { - late StreamPollVoteListController _controller; - - @override - void initState() { - super.initState(); - _initializeController(); - } - - @override - void didUpdateWidget(covariant StreamPollOptionVotesDialog oldWidget) { - super.didUpdateWidget(oldWidget); - if (oldWidget.poll.id != widget.poll.id || - oldWidget.option.id != widget.option.id) { - _controller.dispose(); // Dispose the old controller. - _initializeController(); // Initialize a new controller. - } - } - - void _initializeController() { - _controller = StreamPollVoteListController( - pollId: widget.poll.id, - channel: StreamChannel.of(context).channel, - filter: Filter.equal('option_id', widget.option.id!), - ); - } - - @override - void dispose() { - _controller.dispose(); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - final theme = StreamPollOptionVotesDialogTheme.of(context); - - final isOptionWinner = widget.poll.isOptionWithMaximumVotes(widget.option); - - return Scaffold( - backgroundColor: theme.backgroundColor, - appBar: AppBar( - elevation: theme.appBarElevation, - backgroundColor: theme.appBarBackgroundColor, - foregroundColor: theme.appBarForegroundColor, - title: Text( - widget.option.text, - maxLines: 2, - style: theme.appBarTitleTextStyle, - ), - ), - body: Padding( - padding: const EdgeInsets.all(16), - child: Column( - spacing: 16, - children: [ - Row( - mainAxisAlignment: MainAxisAlignment.end, - children: [ - if (isOptionWinner) ...[ - StreamSvgIcon( - icon: StreamSvgIcons.award, - color: theme.pollOptionWinnerVoteCountTextStyle?.color, - ), - const SizedBox(width: 8), - ], - Text( - context.translations.voteCountLabel( - count: widget.pollVotesCount, - ), - style: isOptionWinner - ? theme.pollOptionWinnerVoteCountTextStyle - : theme.pollOptionVoteCountTextStyle, - ), - ], - ), - Expanded( - child: MediaQuery.removePadding( - context: context, - removeTop: true, - removeBottom: true, - child: RefreshIndicator.adaptive( - onRefresh: _controller.refresh, - child: StreamPollVoteListView( - controller: _controller, - itemBuilder: (context, _, __, defaultWidget) { - return defaultWidget.copyWith( - contentPadding: const EdgeInsets.all(16), - borderRadius: theme.pollOptionVoteItemBorderRadius, - tileColor: theme.pollOptionVoteItemBackgroundColor, - ); - }, - ), - ), - ), - ), - ], - ), - ), - ); - } -} diff --git a/packages/stream_chat_flutter/lib/src/poll/stream_poll_option_votes_sheet.dart b/packages/stream_chat_flutter/lib/src/poll/stream_poll_option_votes_sheet.dart new file mode 100644 index 0000000000..94ee20f892 --- /dev/null +++ b/packages/stream_chat_flutter/lib/src/poll/stream_poll_option_votes_sheet.dart @@ -0,0 +1,210 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:stream_chat_flutter/src/misc/empty_widget.dart'; +import 'package:stream_chat_flutter/src/poll/stream_poll_results_sheet.dart'; +import 'package:stream_chat_flutter/src/scroll_view/poll_vote_scroll_view/stream_poll_vote_list_view.dart'; +import 'package:stream_chat_flutter/src/theme/poll_card_style.dart'; +import 'package:stream_chat_flutter/src/theme/poll_option_votes_sheet_theme.dart'; +import 'package:stream_chat_flutter/src/theme/poll_option_votes_style.dart'; +import 'package:stream_chat_flutter/src/utils/extensions.dart'; +import 'package:stream_chat_flutter_core/stream_chat_flutter_core.dart'; +import 'package:stream_core_flutter/stream_core_flutter.dart'; + +/// {@template showStreamPollOptionVotesSheet} +/// Displays an interactive bottom sheet to show all the votes for a poll +/// option. +/// +/// The votes are paginated and get's loaded as the user scrolls. +/// {@endtemplate} +Future showStreamPollOptionVotesSheet({ + required BuildContext context, + required ValueListenable messageNotifier, + required PollOption option, +}) { + return showStreamSheet( + context: context, + builder: (_, scrollController) => StreamChannel( + channel: StreamChannel.of(context).channel, + child: ValueListenableBuilder( + valueListenable: messageNotifier, + builder: (context, message, _) { + final poll = message.poll; + if (poll == null) return const Empty(); + if (option.id == null) return const Empty(); + + return StreamPollOptionVotesSheet( + poll: poll, + option: option, + scrollController: scrollController, + ); + }, + ), + ), + ); +} + +/// {@template streamPollOptionVotesSheet} +/// A bottom sheet that displays all the votes for a poll option. +/// +/// The votes are paginated and get's loaded as the user scrolls. +/// {@endtemplate} +class StreamPollOptionVotesSheet extends StatefulWidget { + /// {@macro streamPollOptionVotesSheet} + const StreamPollOptionVotesSheet({ + super.key, + required this.poll, + required this.option, + this.scrollController, + }); + + /// The poll for which the votes are being displayed. + final Poll poll; + + /// The option for which the votes are being displayed. + final PollOption option; + + /// Scroll controller attached to the bottom sheet's scrollable content. + /// + /// Typically provided by [DraggableScrollableSheet] so the sheet expands and + /// collapses in response to the user's scroll gesture. + final ScrollController? scrollController; + + @override + State createState() => _StreamPollOptionVotesSheetState(); +} + +class _StreamPollOptionVotesSheetState extends State { + late StreamPollVoteListController _controller; + + @override + void initState() { + super.initState(); + _initializeController(); + } + + @override + void didUpdateWidget(covariant StreamPollOptionVotesSheet oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.poll.id != widget.poll.id || oldWidget.option.id != widget.option.id) { + _controller.dispose(); // Dispose the old controller. + _initializeController(); // Initialize a new controller. + } + } + + void _initializeController() { + _controller = StreamPollVoteListController( + pollId: widget.poll.id, + channel: StreamChannel.of(context).channel, + filter: Filter.equal('option_id', widget.option.id!), + ); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final theme = StreamPollOptionVotesSheetTheme.of(context); + final defaults = _StreamPollOptionVotesSheetDefaults(context); + + final effectiveTheme = defaults.merge(theme); + + final optionStyle = effectiveTheme.optionStyle; + final cardStyle = optionStyle?.cardStyle; + + final spacing = context.streamSpacing; + + final isOptionWinner = widget.poll.isOptionWithMaximumVotes(widget.option); + final pollVotesCount = widget.poll.voteCountFor(widget.option); + final optionIndex = widget.poll.options.indexWhere((it) => it.id == widget.option.id); + final optionNumber = optionIndex >= 0 ? optionIndex + 1 : 1; + + return Column( + children: [ + StreamSheetHeader( + style: effectiveTheme.sheetHeaderStyle, + title: Text(context.translations.pollVotesLabel), + ), + Flexible( + child: Padding( + padding: effectiveTheme.contentPadding ?? EdgeInsets.zero, + child: DecoratedBox( + decoration: BoxDecoration( + color: cardStyle?.backgroundColor, + borderRadius: cardStyle?.borderRadius, + ), + child: Padding( + padding: cardStyle?.padding ?? EdgeInsets.zero, + child: Column( + mainAxisSize: .min, + spacing: spacing.md, + crossAxisAlignment: .stretch, + children: [ + PollVotesByOptionHeader( + option: widget.option, + optionNumber: optionNumber, + pollVotesCount: pollVotesCount, + isOptionWinner: isOptionWinner, + optionStyle: optionStyle, + ), + Flexible( + child: MediaQuery.removePadding( + context: context, + removeTop: true, + removeBottom: true, + child: RefreshIndicator.adaptive( + onRefresh: _controller.refresh, + child: StreamPollVoteListView( + shrinkWrap: true, + controller: _controller, + scrollController: widget.scrollController, + ), + ), + ), + ), + ], + ), + ), + ), + ), + ), + ], + ); + } +} + +// Default values for [StreamPollOptionVotesSheetThemeData] backed by stream +// design tokens. +class _StreamPollOptionVotesSheetDefaults extends StreamPollOptionVotesSheetThemeData { + _StreamPollOptionVotesSheetDefaults(this._context); + + final BuildContext _context; + + late final _spacing = _context.streamSpacing; + late final _radius = _context.streamRadius; + late final _colorScheme = _context.streamColorScheme; + late final _textTheme = _context.streamTextTheme; + + @override + Color get backgroundColor => _colorScheme.backgroundApp; + + @override + EdgeInsetsGeometry get contentPadding => EdgeInsets.all(_spacing.md); + + @override + StreamPollOptionVotesStyle get optionStyle => StreamPollOptionVotesStyle( + cardStyle: StreamPollCardStyle( + backgroundColor: _colorScheme.backgroundSurfaceCard, + borderRadius: BorderRadius.all(_radius.lg), + padding: EdgeInsets.all(_spacing.md), + ), + numberTextStyle: _textTheme.headingXs.copyWith(color: _colorScheme.textTertiary), + textStyle: _textTheme.headingMd.copyWith(color: _colorScheme.textPrimary), + voteCountTextStyle: _textTheme.bodyEmphasis.copyWith(color: _colorScheme.textPrimary), + winnerIconColor: _colorScheme.textSecondary, + winnerIconSize: 20, + ); +} diff --git a/packages/stream_chat_flutter/lib/src/poll/stream_poll_options_dialog.dart b/packages/stream_chat_flutter/lib/src/poll/stream_poll_options_dialog.dart deleted file mode 100644 index 3d95c7ae5f..0000000000 --- a/packages/stream_chat_flutter/lib/src/poll/stream_poll_options_dialog.dart +++ /dev/null @@ -1,153 +0,0 @@ -import 'package:flutter/foundation.dart'; -import 'package:flutter/material.dart'; -import 'package:stream_chat_flutter/src/misc/empty_widget.dart'; -import 'package:stream_chat_flutter/src/poll/interactor/poll_options_list_view.dart'; -import 'package:stream_chat_flutter/src/theme/poll_options_dialog_theme.dart'; -import 'package:stream_chat_flutter/src/utils/extensions.dart'; -import 'package:stream_chat_flutter_core/stream_chat_flutter_core.dart'; - -/// {@template showStreamPollOptionsDialog} -/// Displays an interactive dialog to show all the available options for a poll. -/// -/// The dialog allows the user to cast a vote or remove a vote. -/// {@endtemplate} -Future showStreamPollOptionsDialog({ - required BuildContext context, - required ValueListenable messageNotifier, -}) { - final navigator = Navigator.of(context); - return navigator.push( - MaterialPageRoute( - fullscreenDialog: true, - builder: (_) => StreamChannel( - channel: StreamChannel.of(context).channel, - child: ValueListenableBuilder( - valueListenable: messageNotifier, - builder: (context, message, child) { - final poll = message.poll; - if (poll == null) return const Empty(); - - final channel = StreamChannel.of(context).channel; - - void onCastVote(PollOption option) { - channel.castPollVote(message, poll, option); - } - - void onRemoveVote(PollVote vote) { - channel.removePollVote(message, poll, vote); - } - - return StreamPollOptionsDialog( - poll: poll, - onCastVote: onCastVote, - onRemoveVote: onRemoveVote, - ); - }, - ), - ), - ), - ); -} - -/// {@template streamPollOptionsDialog} -/// A dialog that displays all the available options for a poll. -/// -/// Provides callbacks when a vote has been cast or removed from the poll. -/// {@endtemplate} -class StreamPollOptionsDialog extends StatelessWidget { - /// {@macro streamPollOptionsDialog} - const StreamPollOptionsDialog({ - super.key, - required this.poll, - this.onCastVote, - this.onRemoveVote, - }); - - /// The poll to display the options for. - final Poll poll; - - /// Callback invoked when the user wants to cast a vote. - /// - /// The [PollOption] parameter is the option the user wants to vote for. - final ValueChanged? onCastVote; - - /// Callback invoked when the user wants to remove a vote. - /// - /// The [PollVote] parameter is the vote the user wants to remove. - final ValueChanged? onRemoveVote; - - @override - Widget build(BuildContext context) { - final theme = StreamPollOptionsDialogTheme.of(context); - - return Scaffold( - backgroundColor: theme.backgroundColor, - appBar: AppBar( - elevation: theme.appBarElevation, - backgroundColor: theme.appBarBackgroundColor, - foregroundColor: theme.appBarForegroundColor, - title: Text( - context.translations.pollOptionsLabel, - style: theme.appBarTitleTextStyle, - ), - ), - body: ListView( - padding: const EdgeInsets.all(16), - children: [ - PollOptionsQuestion( - question: poll.name, - ), - Container( - padding: const EdgeInsets.symmetric( - vertical: 12, - horizontal: 16, - ), - decoration: theme.pollOptionsListViewDecoration, - child: MediaQuery.removePadding( - context: context, - removeTop: true, - removeBottom: true, - child: PollOptionsListView( - poll: poll, - onCastVote: onCastVote, - onRemoveVote: onRemoveVote, - ), - ), - ), - ].insertBetween(const SizedBox(height: 32)), - ), - ); - } -} - -/// {@template pollOptionsQuestion} -/// A widget that displays the question of a poll. -/// {@endtemplate} -class PollOptionsQuestion extends StatelessWidget { - /// {@macro pollOptionsQuestion} - const PollOptionsQuestion({ - super.key, - required this.question, - }); - - /// The question of the poll. - final String question; - - @override - Widget build(BuildContext context) { - final theme = StreamPollOptionsDialogTheme.of(context); - - return Container( - padding: const EdgeInsets.all(16), - decoration: theme.pollTitleDecoration, - constraints: const BoxConstraints( - minHeight: 56, - minWidth: double.infinity, - ), - child: Text( - question, - style: theme.pollTitleTextStyle, - ), - ); - } -} diff --git a/packages/stream_chat_flutter/lib/src/poll/stream_poll_options_sheet.dart b/packages/stream_chat_flutter/lib/src/poll/stream_poll_options_sheet.dart new file mode 100644 index 0000000000..bbd6ca3183 --- /dev/null +++ b/packages/stream_chat_flutter/lib/src/poll/stream_poll_options_sheet.dart @@ -0,0 +1,222 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:stream_chat_flutter/src/misc/empty_widget.dart'; +import 'package:stream_chat_flutter/src/poll/interactor/poll_options_list_view.dart'; +import 'package:stream_chat_flutter/stream_chat_flutter.dart'; + +/// {@template showStreamPollOptionsSheet} +/// Displays an interactive bottom sheet to show all the available options for +/// a poll. +/// +/// The bottom sheet allows the user to cast a vote or remove a vote. +/// {@endtemplate} +Future showStreamPollOptionsSheet({ + required BuildContext context, + required ValueListenable messageNotifier, +}) { + return showStreamSheet( + context: context, + builder: (_, scrollController) => StreamChannel( + channel: StreamChannel.of(context).channel, + child: ValueListenableBuilder( + valueListenable: messageNotifier, + builder: (context, message, _) { + final poll = message.poll; + if (poll == null) return const Empty(); + + final channel = StreamChannel.of(context).channel; + + void onCastVote(PollOption option) { + channel.castPollVote(message, poll, option); + } + + void onRemoveVote(PollVote vote) { + channel.removePollVote(message, poll, vote); + } + + return StreamPollOptionsSheet( + poll: poll, + scrollController: scrollController, + onCastVote: onCastVote, + onRemoveVote: onRemoveVote, + ); + }, + ), + ), + ); +} + +/// {@template streamPollOptionsSheet} +/// A bottom sheet that displays all the available options for a poll. +/// +/// Provides callbacks when a vote has been cast or removed from the poll. +/// {@endtemplate} +class StreamPollOptionsSheet extends StatelessWidget { + /// {@macro streamPollOptionsSheet} + const StreamPollOptionsSheet({ + super.key, + required this.poll, + this.scrollController, + this.onCastVote, + this.onRemoveVote, + }); + + /// The poll to display the options for. + final Poll poll; + + /// Scroll controller attached to the bottom sheet's scrollable content. + /// + /// Typically provided by [DraggableScrollableSheet] so the sheet expands and + /// collapses in response to the user's scroll gesture. + final ScrollController? scrollController; + + /// Callback invoked when the user wants to cast a vote. + /// + /// The [PollOption] parameter is the option the user wants to vote for. + final ValueChanged? onCastVote; + + /// Callback invoked when the user wants to remove a vote. + /// + /// The [PollVote] parameter is the vote the user wants to remove. + final ValueChanged? onRemoveVote; + + @override + Widget build(BuildContext context) { + final theme = StreamPollOptionsSheetTheme.of(context); + final defaults = _StreamPollOptionsSheetDefaults(context); + + final effectiveTheme = defaults.merge(theme); + + final optionsCardStyle = effectiveTheme.optionsCardStyle; + + return Column( + mainAxisSize: .min, + children: [ + StreamSheetHeader( + style: effectiveTheme.sheetHeaderStyle, + title: Text(context.translations.pollOptionsLabel), + ), + Expanded( + child: ListView( + controller: scrollController, + padding: effectiveTheme.contentPadding, + children: [ + PollOptionsQuestion(question: poll.name), + Material( + color: optionsCardStyle?.backgroundColor, + borderRadius: optionsCardStyle?.borderRadius, + child: Padding( + padding: optionsCardStyle?.padding ?? EdgeInsets.zero, + child: MediaQuery.removePadding( + context: context, + removeTop: true, + removeBottom: true, + child: PollOptionsListView( + poll: poll, + padding: .zero, + onCastVote: onCastVote, + onRemoveVote: onRemoveVote, + optionStyle: effectiveTheme.optionStyle, + spacing: effectiveTheme.optionsItemSpacing, + ), + ), + ), + ), + ].insertBetween(SizedBox(height: effectiveTheme.sectionSpacing)), + ), + ), + ], + ); + } +} + +/// {@template pollOptionsQuestion} +/// A widget that displays the question of a poll. +/// +/// Reads its styling from the ambient [StreamPollOptionsSheetTheme]. +/// {@endtemplate} +class PollOptionsQuestion extends StatelessWidget { + /// {@macro pollOptionsQuestion} + const PollOptionsQuestion({ + super.key, + required this.question, + }); + + /// The question of the poll. + final String question; + + @override + Widget build(BuildContext context) { + final theme = StreamPollOptionsSheetTheme.of(context); + final defaults = _StreamPollOptionsSheetDefaults(context); + + final effectiveTheme = defaults.merge(theme); + + final questionStyle = effectiveTheme.questionStyle; + final cardStyle = questionStyle?.cardStyle; + + final spacing = context.streamSpacing; + + return DecoratedBox( + decoration: BoxDecoration( + color: cardStyle?.backgroundColor, + borderRadius: cardStyle?.borderRadius, + ), + child: Padding( + padding: cardStyle?.padding ?? EdgeInsets.zero, + child: Column( + mainAxisSize: .min, + spacing: spacing.xxs, + crossAxisAlignment: .start, + children: [ + Text(context.translations.questionLabel(), style: questionStyle?.headerTextStyle), + Text(question, style: questionStyle?.textStyle), + ], + ), + ), + ); + } +} + +// Default values for [StreamPollOptionsSheetThemeData] backed by stream +// design tokens. +class _StreamPollOptionsSheetDefaults extends StreamPollOptionsSheetThemeData { + _StreamPollOptionsSheetDefaults(this._context); + + final BuildContext _context; + + late final _spacing = _context.streamSpacing; + late final _radius = _context.streamRadius; + late final _colorScheme = _context.streamColorScheme; + late final _textTheme = _context.streamTextTheme; + + @override + Color get backgroundColor => _colorScheme.backgroundApp; + + @override + EdgeInsetsGeometry get contentPadding => .all(_spacing.md); + + @override + double get sectionSpacing => _spacing.xxl; + + @override + StreamPollQuestionStyle get questionStyle => StreamPollQuestionStyle( + cardStyle: StreamPollCardStyle( + backgroundColor: _colorScheme.backgroundSurfaceCard, + borderRadius: BorderRadius.all(_radius.lg), + padding: EdgeInsets.all(_spacing.md), + ), + headerTextStyle: _textTheme.headingXs.copyWith(color: _colorScheme.textTertiary), + textStyle: _textTheme.headingMd.copyWith(color: _colorScheme.textPrimary), + ); + + @override + StreamPollCardStyle get optionsCardStyle => StreamPollCardStyle( + backgroundColor: _colorScheme.backgroundSurfaceCard, + borderRadius: BorderRadius.all(_radius.lg), + padding: EdgeInsets.symmetric(vertical: _spacing.md, horizontal: _spacing.xs), + ); + + @override + double get optionsItemSpacing => _spacing.xs; +} diff --git a/packages/stream_chat_flutter/lib/src/poll/stream_poll_results_dialog.dart b/packages/stream_chat_flutter/lib/src/poll/stream_poll_results_dialog.dart deleted file mode 100644 index e1b5ee1064..0000000000 --- a/packages/stream_chat_flutter/lib/src/poll/stream_poll_results_dialog.dart +++ /dev/null @@ -1,335 +0,0 @@ -import 'package:collection/collection.dart'; -import 'package:flutter/foundation.dart'; -import 'package:flutter/material.dart'; -import 'package:stream_chat_flutter/src/icons/stream_svg_icon.dart'; -import 'package:stream_chat_flutter/src/misc/empty_widget.dart'; -import 'package:stream_chat_flutter/src/poll/stream_poll_option_votes_dialog.dart'; -import 'package:stream_chat_flutter/src/scroll_view/poll_vote_scroll_view/stream_poll_vote_list_tile.dart'; -import 'package:stream_chat_flutter/src/theme/poll_results_dialog_theme.dart'; -import 'package:stream_chat_flutter/src/utils/extensions.dart'; -import 'package:stream_chat_flutter_core/stream_chat_flutter_core.dart'; - -/// {@template showStreamPollResultsDialog} -/// Displays an interactive dialog to show the results of a poll. -/// -/// The dialog allows the user to see the results of the poll. The results are -/// displayed in a list of options with the number of votes each option has -/// received and the users who have voted for that option. -/// -/// By default, only the first 5 votes are shown for each option. The user can -/// see all the votes for an option by pressing the "Show all votes" button. -/// -/// The dialog is updated in real-time as new votes are cast. -/// -/// {@endtemplate} -Future showStreamPollResultsDialog({ - required BuildContext context, - required ValueListenable messageNotifier, -}) { - final navigator = Navigator.of(context); - return navigator.push( - MaterialPageRoute( - fullscreenDialog: true, - builder: (_) => StreamChannel( - channel: StreamChannel.of(context).channel, - child: ValueListenableBuilder( - valueListenable: messageNotifier, - builder: (context, message, child) { - final poll = message.poll; - if (poll == null) return const Empty(); - - void onShowAllVotesPressed(PollOption option) { - showStreamPollOptionVotesDialog( - context: context, - messageNotifier: messageNotifier, - option: option, - ); - } - - return StreamPollResultsDialog( - poll: poll, - visibleVotesCount: 5, - onShowAllVotesPressed: onShowAllVotesPressed, - ); - }, - ), - ), - ), - ); -} - -/// {@template streamPollResultsDialog} -/// A dialog that displays the results of a poll. -/// -/// The results are displayed in a list of options with the number of votes each -/// option has received and the users who have voted for that option. -/// -/// By default, only the latest votes are shown for each option. The user can -/// see all the votes for an option by pressing the "Show all votes" button. -/// -/// The dialog is updated in real-time as new votes are cast. -/// {@endtemplate} -class StreamPollResultsDialog extends StatelessWidget { - /// {@macro streamPollResultsDialog} - const StreamPollResultsDialog({ - super.key, - required this.poll, - this.visibleVotesCount, - this.onShowAllVotesPressed, - }); - - /// The poll to display the results for. - final Poll poll; - - /// The number of votes to show for each option. - final int? visibleVotesCount; - - /// Callback invoked when the "Show all votes" button is pressed. - final ValueSetter? onShowAllVotesPressed; - - @override - Widget build(BuildContext context) { - final theme = StreamPollResultsDialogTheme.of(context); - - return Scaffold( - backgroundColor: theme.backgroundColor, - appBar: AppBar( - elevation: theme.appBarElevation, - backgroundColor: theme.appBarBackgroundColor, - foregroundColor: theme.appBarForegroundColor, - title: Text( - context.translations.pollResultsLabel, - style: theme.appBarTitleTextStyle, - ), - ), - body: ListView( - padding: const EdgeInsets.all(16), - children: [ - PollResultsQuestion(question: poll.name), - PollVotesByOptionListView( - poll: poll, - visibleVotesCount: visibleVotesCount, - onShowAllVotesPressed: onShowAllVotesPressed, - ), - ].insertBetween(const SizedBox(height: 32)), - ), - ); - } -} - -/// {@template pollResultsQuestion} -/// A widget that displays the question of a poll. -/// {@endtemplate} -class PollResultsQuestion extends StatelessWidget { - /// {@macro pollResultsQuestion} - const PollResultsQuestion({ - super.key, - required this.question, - }); - - /// The question of the poll. - final String question; - - @override - Widget build(BuildContext context) { - final theme = StreamPollResultsDialogTheme.of(context); - - return Container( - padding: const EdgeInsets.all(16), - decoration: theme.pollTitleDecoration, - constraints: const BoxConstraints( - minHeight: 56, - minWidth: double.infinity, - ), - child: Text( - question, - style: theme.pollTitleTextStyle, - ), - ); - } -} - -/// {@template pollVotesByOptionListView} -/// A list of poll options with the latest votes for each option. -/// -/// Displays a button with a callback [onShowAllVotesPressed] to show all votes -/// for an option if there are more votes than the [visibleVotesCount]. -/// -/// By default, The options are sorted by the number of votes they have -/// received in descending order. -/// {@endtemplate} -class PollVotesByOptionListView extends StatelessWidget { - /// {@macro pollVotesByOptionListView} - const PollVotesByOptionListView({ - super.key, - required this.poll, - this.visibleVotesCount, - this.onShowAllVotesPressed, - }); - - /// The poll the options are for. - final Poll poll; - - /// The number of votes to show for each option. - /// - /// If the number of votes for an option is greater than this value, a button - /// is displayed to show all votes for that option. - final int? visibleVotesCount; - - /// Callback invoked when the "Show all votes" button is pressed. - final ValueSetter? onShowAllVotesPressed; - - @override - Widget build(BuildContext context) { - final latestVotesByOption = poll.latestVotesByOption; - final voteCountsByOption = poll.voteCountsByOption; - final pollOptions = poll.options.sorted((a, b) { - final optionAVoteCounts = voteCountsByOption[a.id] ?? 0; - final optionBVoteCounts = voteCountsByOption[b.id] ?? 0; - return optionBVoteCounts.compareTo(optionAVoteCounts); - }); - - return ListView.separated( - shrinkWrap: true, - itemCount: pollOptions.length, - physics: const NeverScrollableScrollPhysics(), - separatorBuilder: (context, index) => const SizedBox(height: 8), - itemBuilder: (context, index) { - final option = pollOptions.elementAt(index); - final latestPollVotes = latestVotesByOption[option.id] ?? []; - final pollVotesCount = voteCountsByOption[option.id] ?? 0; - - return PollVotesByOptionItem( - option: option, - pollVotesCount: pollVotesCount, - latestPollVotes: latestPollVotes, - visibleVotesCount: visibleVotesCount, - isOptionWinner: poll.isOptionWithMaximumVotes(option), - onShowAllVotesPressed: switch (onShowAllVotesPressed) { - final onShowAllVotesPressed? => () => onShowAllVotesPressed(option), - _ => null, - }, - ); - }, - ); - } -} - -/// {@template pollVotesByOptionItem} -/// A widget that displays the votes for a poll option. -/// -/// The widget is used in [PollVotesByOptionListView] to display the votes for -/// each option in a poll. -/// -/// Displays a award icon next to the option if [isOptionWinner] is true. -/// {@endtemplate} -class PollVotesByOptionItem extends StatelessWidget { - /// {@macro pollVotesByOptionItem} - const PollVotesByOptionItem({ - super.key, - required this.option, - required this.latestPollVotes, - required this.pollVotesCount, - this.isOptionWinner = false, - this.visibleVotesCount, - this.onShowAllVotesPressed, - }); - - /// The option to display the votes for. - final PollOption option; - - /// The available latest votes for the option. - final List latestPollVotes; - - /// The total number of votes for the option. - final int pollVotesCount; - - /// Whether the option is the winner of the poll. - final bool isOptionWinner; - - /// The number of votes to show for the option. - /// - /// If this is less than the [pollVotesCount] a button is displayed to show - /// all votes for the option. - final int? visibleVotesCount; - - /// Callback invoked when the "Show all votes" button is pressed. - final VoidCallback? onShowAllVotesPressed; - - @override - Widget build(BuildContext context) { - final theme = StreamPollResultsDialogTheme.of(context); - - final votes = switch (visibleVotesCount) { - final visibleVotesCount? => latestPollVotes.take(visibleVotesCount), - _ => latestPollVotes, - }; - - return Container( - padding: const EdgeInsets.symmetric( - vertical: 12, - horizontal: 16, - ), - decoration: isOptionWinner - ? theme.pollOptionsWinnerDecoration - : theme.pollOptionsDecoration, - child: Column( - spacing: 16, - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Row( - children: [ - Expanded( - child: Text( - option.text, - style: isOptionWinner - ? theme.pollOptionsWinnerTextStyle - : theme.pollOptionsTextStyle, - ), - ), - const SizedBox(width: 8), - if (isOptionWinner) ...[ - StreamSvgIcon( - icon: StreamSvgIcons.award, - color: theme.pollOptionsWinnerVoteCountTextStyle?.color, - ), - const SizedBox(width: 8), - ], - Text( - context.translations.voteCountLabel(count: pollVotesCount), - style: isOptionWinner - ? theme.pollOptionsWinnerVoteCountTextStyle - : theme.pollOptionsVoteCountTextStyle, - ), - ], - ), - if (votes.isNotEmpty) - Flexible( - child: ListView.separated( - shrinkWrap: true, - itemCount: votes.length, - physics: const NeverScrollableScrollPhysics(), - separatorBuilder: (_, __) => const SizedBox(height: 16), - itemBuilder: (context, index) { - final pollVote = votes.elementAt(index); - return StreamPollVoteListTile( - pollVote: pollVote, - contentPadding: const EdgeInsets.symmetric(vertical: 6), - ); - }, - ), - ), - if (votes.length < latestPollVotes.length) - TextButton( - onPressed: onShowAllVotesPressed, - style: theme.pollOptionsShowAllVotesButtonStyle, - child: Text( - context.translations.showAllVotesLabel(count: pollVotesCount), - ), - ), - ], - ), - ); - } -} diff --git a/packages/stream_chat_flutter/lib/src/poll/stream_poll_results_sheet.dart b/packages/stream_chat_flutter/lib/src/poll/stream_poll_results_sheet.dart new file mode 100644 index 0000000000..c1e4b20cd3 --- /dev/null +++ b/packages/stream_chat_flutter/lib/src/poll/stream_poll_results_sheet.dart @@ -0,0 +1,544 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:stream_chat_flutter/src/misc/empty_widget.dart'; +import 'package:stream_chat_flutter/stream_chat_flutter.dart'; + +/// {@template showStreamPollResultsSheet} +/// Displays an interactive bottom sheet to show the results of a poll. +/// +/// The sheet allows the user to see the results of the poll. The results are +/// displayed in a list of options with the number of votes each option has +/// received and the users who have voted for that option. +/// +/// By default, only the first 5 votes are shown for each option. The user can +/// see all the votes for an option by pressing the "View all" footer button. +/// +/// The sheet is updated in real-time as new votes are cast. +/// +/// {@endtemplate} +Future showStreamPollResultsSheet({ + required BuildContext context, + required ValueListenable messageNotifier, +}) { + return showStreamSheet( + context: context, + builder: (_, scrollController) => StreamChannel( + channel: StreamChannel.of(context).channel, + child: ValueListenableBuilder( + valueListenable: messageNotifier, + builder: (context, message, _) { + final poll = message.poll; + if (poll == null) return const Empty(); + + void onShowAllVotesPressed(PollOption option) { + showStreamPollOptionVotesSheet( + context: context, + messageNotifier: messageNotifier, + option: option, + ); + } + + return StreamPollResultsSheet( + poll: poll, + visibleVotesCount: 5, + scrollController: scrollController, + onShowAllVotesPressed: onShowAllVotesPressed, + ); + }, + ), + ), + ); +} + +/// {@template streamPollResultsSheet} +/// A bottom sheet that displays the results of a poll. +/// +/// The results are displayed in a list of options with the number of votes each +/// option has received and the users who have voted for that option. +/// +/// By default, only the latest votes are shown for each option. The user can +/// see all the votes for an option by pressing the "View all" footer button. +/// +/// The sheet is updated in real-time as new votes are cast. +/// {@endtemplate} +class StreamPollResultsSheet extends StatelessWidget { + /// {@macro streamPollResultsSheet} + const StreamPollResultsSheet({ + super.key, + required this.poll, + this.visibleVotesCount, + this.scrollController, + this.onShowAllVotesPressed, + }); + + /// The poll to display the results for. + final Poll poll; + + /// The number of votes to show for each option. + final int? visibleVotesCount; + + /// Scroll controller attached to the bottom sheet's scrollable content. + /// + /// Typically provided by [DraggableScrollableSheet] so the sheet expands and + /// collapses in response to the user's scroll gesture. + final ScrollController? scrollController; + + /// Callback invoked when the "View all" footer button is pressed. + final ValueSetter? onShowAllVotesPressed; + + @override + Widget build(BuildContext context) { + final theme = StreamPollResultsSheetTheme.of(context); + final defaults = _StreamPollResultsSheetDefaults(context); + + final effectiveTheme = defaults.merge(theme); + + return Column( + mainAxisSize: .min, + children: [ + StreamSheetHeader( + style: effectiveTheme.sheetHeaderStyle, + title: Text(context.translations.pollResultsLabel), + ), + Expanded( + child: ListView( + controller: scrollController, + padding: effectiveTheme.contentPadding, + children: [ + PollResultsQuestion(question: poll.name), + MediaQuery.removePadding( + context: context, + removeTop: true, + removeBottom: true, + child: PollVotesByOptionListView( + poll: poll, + visibleVotesCount: visibleVotesCount, + onShowAllVotesPressed: onShowAllVotesPressed, + ), + ), + Center( + child: Text( + context.translations.totalVoteCountLabel(count: poll.voteCount), + style: effectiveTheme.totalVoteCountTextStyle, + ), + ), + ].insertBetween(SizedBox(height: effectiveTheme.sectionSpacing)), + ), + ), + ], + ); + } +} + +/// {@template pollResultsQuestion} +/// A widget that displays the question of a poll. +/// +/// Reads its styling from the ambient [StreamPollResultsSheetTheme]. +/// {@endtemplate} +class PollResultsQuestion extends StatelessWidget { + /// {@macro pollResultsQuestion} + const PollResultsQuestion({ + super.key, + required this.question, + }); + + /// The question of the poll. + final String question; + + @override + Widget build(BuildContext context) { + final theme = StreamPollResultsSheetTheme.of(context); + final defaults = _StreamPollResultsSheetDefaults(context); + + final effectiveTheme = defaults.merge(theme); + + final questionStyle = effectiveTheme.questionStyle; + final cardStyle = questionStyle?.cardStyle; + + final spacing = context.streamSpacing; + + return DecoratedBox( + decoration: BoxDecoration( + color: cardStyle?.backgroundColor, + borderRadius: cardStyle?.borderRadius, + ), + child: Padding( + padding: cardStyle?.padding ?? EdgeInsets.zero, + child: Column( + mainAxisSize: .min, + spacing: spacing.xxs, + crossAxisAlignment: .start, + children: [ + Text(context.translations.questionLabel(), style: questionStyle?.headerTextStyle), + Text(question, style: questionStyle?.textStyle), + ], + ), + ), + ); + } +} + +/// {@template pollVotesByOptionListView} +/// A list of poll options with the latest votes for each option. +/// +/// Displays a button with a callback [onShowAllVotesPressed] to show all votes +/// for an option if there are more votes than the [visibleVotesCount]. +/// +/// By default, the options are sorted by the number of votes they have +/// received in descending order. +/// {@endtemplate} +class PollVotesByOptionListView extends StatelessWidget { + /// {@macro pollVotesByOptionListView} + const PollVotesByOptionListView({ + super.key, + required this.poll, + this.visibleVotesCount, + this.onShowAllVotesPressed, + }); + + /// The poll the options are for. + final Poll poll; + + /// The number of votes to show for each option. + /// + /// If the number of votes for an option is greater than this value, a button + /// is displayed to show all votes for that option. + final int? visibleVotesCount; + + /// Callback invoked when the "View all" footer button is pressed. + final ValueSetter? onShowAllVotesPressed; + + @override + Widget build(BuildContext context) { + final theme = StreamPollResultsSheetTheme.of(context); + final defaults = _StreamPollResultsSheetDefaults(context); + + final effectiveTheme = defaults.merge(theme); + + final pollOptions = poll.options; + final latestVotesByOption = poll.latestVotesByOption; + final voteCountsByOption = poll.voteCountsByOption; + + return ListView.separated( + shrinkWrap: true, + itemCount: pollOptions.length, + physics: const NeverScrollableScrollPhysics(), + separatorBuilder: (context, index) => SizedBox(height: effectiveTheme.optionsItemSpacing), + itemBuilder: (context, index) { + final option = pollOptions.elementAt(index); + final latestPollVotes = latestVotesByOption[option.id] ?? []; + final pollVotesCount = voteCountsByOption[option.id] ?? 0; + + return PollVotesByOptionItem( + option: option, + optionNumber: index + 1, + pollVotesCount: pollVotesCount, + latestPollVotes: latestPollVotes, + visibleVotesCount: visibleVotesCount, + isOptionWinner: poll.isOptionWithMaximumVotes(option), + onShowAllVotesPressed: switch (onShowAllVotesPressed) { + final onShowAllVotesPressed? => () => onShowAllVotesPressed(option), + _ => null, + }, + ); + }, + ); + } +} + +/// {@template pollVotesByOptionItem} +/// A widget that displays the votes for a single poll option. +/// +/// Used by [PollVotesByOptionListView] to render one result card per option. +/// Composes a [PollVotesByOptionHeader] (option number, option text, trailing +/// vote count, and — when [isOptionWinner] is true — a trophy icon) on top of +/// up to [visibleVotesCount] [StreamPollVoteListTile]s. When the option has +/// more votes than are being shown, a divider + "View all" button footer is +/// rendered that invokes [onShowAllVotesPressed]. +/// +/// Reads its styling from the ambient [StreamPollResultsSheetTheme] via +/// [StreamPollResultsSheetThemeData.optionStyle]: +/// +/// * [StreamPollOptionVotesStyle.cardStyle] — card chrome (background, +/// corner radius, inner padding). +/// * The header text styles + winner icon color/size are consumed by the +/// nested [PollVotesByOptionHeader]. +/// * [StreamPollOptionVotesStyle.footerDividerColor] + +/// [StreamPollOptionVotesStyle.footerButtonStyle] — applied to the divider +/// and "View all" button shown when `visibleVotesCount < pollVotesCount`. +/// {@endtemplate} +class PollVotesByOptionItem extends StatelessWidget { + /// {@macro pollVotesByOptionItem} + const PollVotesByOptionItem({ + super.key, + required this.option, + required this.optionNumber, + required this.latestPollVotes, + required this.pollVotesCount, + this.isOptionWinner = false, + this.visibleVotesCount, + this.onShowAllVotesPressed, + }); + + /// The 1-based position of this option within the poll. + /// + /// Rendered as the section header (e.g. `Option 1`, `Option 2`). + final int optionNumber; + + /// The option to display the votes for. + final PollOption option; + + /// The available latest votes for the option. + final List latestPollVotes; + + /// The total number of votes for the option. + final int pollVotesCount; + + /// Whether the option is the winner of the poll. + final bool isOptionWinner; + + /// The number of votes to show for the option. + /// + /// If this is less than the [pollVotesCount] a button is displayed to show + /// all votes for the option. + final int? visibleVotesCount; + + /// Callback invoked when the "View all" footer button is pressed. + final VoidCallback? onShowAllVotesPressed; + + @override + Widget build(BuildContext context) { + final theme = StreamPollResultsSheetTheme.of(context); + final defaults = _StreamPollResultsSheetDefaults(context); + + final effectiveTheme = defaults.merge(theme); + + final optionStyle = effectiveTheme.optionStyle; + final cardStyle = optionStyle?.cardStyle; + + final spacing = context.streamSpacing; + + final votes = switch (visibleVotesCount) { + final visibleVotesCount? => latestPollVotes.take(visibleVotesCount), + _ => latestPollVotes, + }; + + return DecoratedBox( + decoration: BoxDecoration( + color: cardStyle?.backgroundColor, + borderRadius: cardStyle?.borderRadius, + ), + child: Column( + mainAxisSize: .min, + children: [ + Padding( + padding: cardStyle?.padding ?? .zero, + child: Column( + spacing: spacing.md, + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + PollVotesByOptionHeader( + option: option, + optionNumber: optionNumber, + pollVotesCount: pollVotesCount, + isOptionWinner: isOptionWinner, + optionStyle: optionStyle, + ), + if (votes.isNotEmpty) + Flexible( + child: ListView.separated( + shrinkWrap: true, + itemCount: votes.length, + physics: const NeverScrollableScrollPhysics(), + separatorBuilder: (_, __) => SizedBox(height: spacing.md), + itemBuilder: (context, index) { + final pollVote = votes.elementAt(index); + return StreamPollVoteListTile(pollVote: pollVote); + }, + ), + ), + ], + ), + ), + if (votes.length < latestPollVotes.length) ...[ + Divider(height: 1, color: optionStyle?.footerDividerColor), + StreamButton( + size: .small, + type: .ghost, + style: .secondary, + onPressed: onShowAllVotesPressed, + themeStyle: optionStyle?.footerButtonStyle, + child: Text(context.translations.viewAllLabel), + ), + ], + ], + ), + ); + } +} + +/// {@template pollVotesByOptionHeader} +/// Renders the header strip of a per-option result card inside +/// [PollVotesByOptionItem]. +/// +/// Composes the `"Option N"` label, the option body text, the trailing vote +/// count, and — when [isOptionWinner] is true — the trophy icon next to the +/// vote count. +/// +/// By default, reads its styling (text styles, winner icon color + size) +/// from the ambient [StreamPollResultsSheetTheme] via +/// [StreamPollResultsSheetThemeData.optionStyle]. Callers embedding this +/// header outside the results sheet (e.g. [StreamPollOptionVotesSheet]) +/// can pass an explicit [optionStyle] to inject their own resolved style. +/// {@endtemplate} +class PollVotesByOptionHeader extends StatelessWidget { + /// {@macro pollVotesByOptionHeader} + const PollVotesByOptionHeader({ + super.key, + required this.option, + required this.optionNumber, + required this.pollVotesCount, + this.isOptionWinner = false, + this.optionStyle, + }); + + /// The option this header describes. + final PollOption option; + + /// The 1-based position of this option within the poll, shown as the + /// `"Option N"` label. + final int optionNumber; + + /// The total number of votes for [option], rendered as the trailing vote + /// count (e.g. `"12 votes"`). + final int pollVotesCount; + + /// Whether [option] is the current winner of the poll. + /// + /// When true, a trophy icon is rendered next to the vote count. + final bool isOptionWinner; + + /// Explicit per-option style used to paint this header. + /// + /// When null, the style is resolved from the ambient + /// [StreamPollResultsSheetTheme]. Sheets that embed this header but + /// consume a different ambient theme (e.g. [StreamPollOptionVotesSheet]) + /// pass in their own resolved style so the header matches the owning + /// sheet's theme. + final StreamPollOptionVotesStyle? optionStyle; + + @override + Widget build(BuildContext context) { + final StreamPollOptionVotesStyle? style; + if (optionStyle != null) { + style = optionStyle; + } else { + final theme = StreamPollResultsSheetTheme.of(context); + final defaults = _StreamPollResultsSheetDefaults(context); + style = defaults.merge(theme).optionStyle; + } + + final spacing = context.streamSpacing; + + return Column( + mainAxisSize: .min, + spacing: spacing.xxs, + crossAxisAlignment: .start, + children: [ + Text( + '${context.translations.optionLabel()} $optionNumber', + style: style?.numberTextStyle, + ), + Row( + mainAxisSize: .min, + spacing: spacing.md, + children: [ + Expanded( + child: Text( + option.text, + style: style?.textStyle, + ), + ), + Row( + mainAxisSize: .min, + spacing: spacing.xs, + children: [ + if (isOptionWinner) ...[ + Icon( + context.streamIcons.trophy, + size: style?.winnerIconSize, + color: style?.winnerIconColor, + ), + ], + Text( + context.translations.voteCountLabel(count: pollVotesCount), + style: style?.voteCountTextStyle, + ), + ], + ), + ], + ), + ], + ); + } +} + +// Default values for [StreamPollResultsSheetThemeData] backed by stream +// design tokens. +class _StreamPollResultsSheetDefaults extends StreamPollResultsSheetThemeData { + _StreamPollResultsSheetDefaults(this._context); + + final BuildContext _context; + + late final _spacing = _context.streamSpacing; + late final _radius = _context.streamRadius; + late final _colorScheme = _context.streamColorScheme; + late final _textTheme = _context.streamTextTheme; + + @override + Color get backgroundColor => _colorScheme.backgroundApp; + + @override + EdgeInsetsGeometry get contentPadding => .directional( + start: _spacing.md, + end: _spacing.md, + top: _spacing.md, + bottom: _spacing.xxxl, + ); + + @override + double get sectionSpacing => _spacing.xxl; + + @override + double get optionsItemSpacing => _spacing.md; + + @override + StreamPollQuestionStyle get questionStyle => StreamPollQuestionStyle( + cardStyle: StreamPollCardStyle( + backgroundColor: _colorScheme.backgroundSurfaceCard, + borderRadius: BorderRadius.all(_radius.lg), + padding: EdgeInsets.all(_spacing.md), + ), + headerTextStyle: _textTheme.headingXs.copyWith(color: _colorScheme.textTertiary), + textStyle: _textTheme.headingMd.copyWith(color: _colorScheme.textPrimary), + ); + + @override + StreamPollOptionVotesStyle get optionStyle => StreamPollOptionVotesStyle( + cardStyle: StreamPollCardStyle( + backgroundColor: _colorScheme.backgroundSurfaceCard, + borderRadius: BorderRadius.all(_radius.lg), + padding: EdgeInsets.all(_spacing.md), + ), + numberTextStyle: _textTheme.headingXs.copyWith(color: _colorScheme.textTertiary), + textStyle: _textTheme.headingMd.copyWith(color: _colorScheme.textPrimary), + voteCountTextStyle: _textTheme.bodyEmphasis.copyWith(color: _colorScheme.textPrimary), + winnerIconColor: _colorScheme.textSecondary, + winnerIconSize: 20, + footerDividerColor: _colorScheme.borderDefault, + ); + + @override + TextStyle get totalVoteCountTextStyle => _textTheme.bodyDefault.copyWith(color: _colorScheme.textPrimary); +} diff --git a/packages/stream_chat_flutter/lib/src/poll/stream_poll_text_field.dart b/packages/stream_chat_flutter/lib/src/poll/stream_poll_text_field.dart deleted file mode 100644 index 3a4af9b9c4..0000000000 --- a/packages/stream_chat_flutter/lib/src/poll/stream_poll_text_field.dart +++ /dev/null @@ -1,270 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; -import 'package:stream_chat_flutter/src/misc/empty_widget.dart'; -import 'package:stream_chat_flutter/stream_chat_flutter.dart'; - -const _kTransitionDuration = Duration(milliseconds: 167); - -/// {@template streamPollTextField} -/// A widget that represents a text field for poll input. -/// {@endtemplate} -class StreamPollTextField extends StatefulWidget { - /// {@macro streamPollTextField} - const StreamPollTextField({ - super.key, - this.initialValue, - this.style, - this.enabled = true, - this.hintText, - this.fillColor, - this.errorText, - this.errorStyle, - this.contentPadding = const EdgeInsets.symmetric( - vertical: 18, - horizontal: 16, - ), - this.borderRadius, - this.focusNode, - this.keyboardType, - this.autoFocus = false, - this.onChanged, - }); - - /// The initial value of the text field. - /// - /// If `null`, the text field will be empty. - final String? initialValue; - - /// The style to use for the text field. - final TextStyle? style; - - /// Whether the text field is enabled. - final bool enabled; - - /// The hint text to be displayed in the text field. - final String? hintText; - - /// The fill color of the text field. - final Color? fillColor; - - /// The error text to be displayed below the text field. - /// - /// If `null`, no error text will be displayed. - final String? errorText; - - /// The style to use for the error text. - final TextStyle? errorStyle; - - /// The padding around the text field content. - final EdgeInsetsGeometry contentPadding; - - /// The border radius of the text field. - final BorderRadius? borderRadius; - - /// The keyboard type of the text field. - final TextInputType? keyboardType; - - /// Whether the text field should autofocus. - final bool autoFocus; - - /// The focus node of the text field. - final FocusNode? focusNode; - - /// Callback called when the text field value is changed. - final ValueChanged? onChanged; - - @override - State createState() => _StreamPollTextFieldState(); -} - -class _StreamPollTextFieldState extends State { - late final _controller = TextEditingController(text: widget.initialValue); - - @override - void didUpdateWidget(covariant StreamPollTextField oldWidget) { - super.didUpdateWidget(oldWidget); - // Update the controller value if the updated initial value is different - // from the current value. - final currValue = _controller.text; - final newValue = widget.initialValue; - if (currValue != newValue) { - _controller.value = switch (newValue) { - final value? => TextEditingValue( - text: value, - selection: TextSelection.collapsed(offset: value.length), - ), - _ => TextEditingValue.empty, - }; - } - } - - @override - void dispose() { - _controller.dispose(); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - final theme = StreamChatTheme.of(context); - - // Reduce vertical padding if there is an error text. - var contentPadding = widget.contentPadding; - final verticalPadding = contentPadding.vertical; - final horizontalPadding = contentPadding.horizontal; - if (widget.errorText != null) { - contentPadding = contentPadding.subtract( - EdgeInsets.symmetric(vertical: verticalPadding / 4), - ); - } - - return Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - PollTextFieldError( - padding: EdgeInsets.only( - top: verticalPadding / 4, - left: horizontalPadding / 2, - right: horizontalPadding / 2, - ), - errorText: widget.errorText, - errorStyle: widget.errorStyle ?? - theme.textTheme.footnote.copyWith( - color: theme.colorTheme.accentError, - ), - ), - TextField( - autocorrect: false, - controller: _controller, - focusNode: widget.focusNode, - onChanged: widget.onChanged, - style: widget.style ?? theme.textTheme.headline, - keyboardType: widget.keyboardType, - autofocus: widget.autoFocus, - inputFormatters: [FilteringTextInputFormatter.deny(RegExp(r'^\s'))], - decoration: InputDecoration( - filled: true, - isCollapsed: true, - enabled: widget.enabled, - fillColor: widget.fillColor, - hintText: widget.hintText, - hintStyle: (widget.style ?? theme.textTheme.headline).copyWith( - color: theme.colorTheme.textLowEmphasis, - ), - contentPadding: contentPadding, - border: OutlineInputBorder( - borderRadius: widget.borderRadius ?? BorderRadius.circular(12), - borderSide: BorderSide.none, - ), - ), - ), - ], - ); - } -} - -/// {@template pollTextFieldError} -/// A widget that displays an error text around a text field with a fade -/// transition. -/// -/// Usually used with [StreamPollTextField]. -/// {@endtemplate} -class PollTextFieldError extends StatefulWidget { - /// {@macro pollTextFieldError} - const PollTextFieldError({ - super.key, - this.errorText, - this.errorStyle, - this.errorMaxLines, - this.textAlign, - this.padding, - }); - - /// The error text to be displayed. - final String? errorText; - - /// The maximum number of lines for the error text. - final int? errorMaxLines; - - /// The alignment of the error text. - final TextAlign? textAlign; - - /// The style of the error text. - final TextStyle? errorStyle; - - /// The padding around the error text. - final EdgeInsetsGeometry? padding; - - @override - State createState() => _PollTextFieldErrorState(); -} - -class _PollTextFieldErrorState extends State - with SingleTickerProviderStateMixin { - late AnimationController _controller; - - @override - void initState() { - super.initState(); - _controller = AnimationController( - duration: _kTransitionDuration, - vsync: this, - )..addListener(() => setState(() {})); - - if (widget.errorText != null) { - _controller.value = 1.0; - } - } - - @override - void didUpdateWidget(covariant PollTextFieldError oldWidget) { - super.didUpdateWidget(oldWidget); - // Animate the error text if the error text state has changed. - final newError = widget.errorText; - final currError = oldWidget.errorText; - final errorTextStateChanged = (newError != null) != (currError != null); - if (errorTextStateChanged) { - if (newError != null) { - _controller.forward(); - } else { - _controller.reverse(); - } - } - } - - @override - void dispose() { - _controller.dispose(); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - final errorText = widget.errorText; - if (errorText == null) return const Empty(); - - return Container( - padding: widget.padding, - child: Semantics( - container: true, - child: FadeTransition( - opacity: _controller, - child: FractionalTranslation( - translation: Tween( - begin: const Offset(0, 0.25), - end: Offset.zero, - ).evaluate(_controller.view), - child: Text( - errorText, - style: widget.errorStyle, - textAlign: widget.textAlign, - overflow: TextOverflow.ellipsis, - maxLines: widget.errorMaxLines, - ), - ), - ), - ), - ); - } -} diff --git a/packages/stream_chat_flutter/lib/src/reactions/detail/reaction_detail_sheet.dart b/packages/stream_chat_flutter/lib/src/reactions/detail/reaction_detail_sheet.dart new file mode 100644 index 0000000000..2b3e855678 --- /dev/null +++ b/packages/stream_chat_flutter/lib/src/reactions/detail/reaction_detail_sheet.dart @@ -0,0 +1,226 @@ +import 'package:flutter/material.dart'; +import 'package:stream_chat_flutter/src/components/avatar/stream_user_avatar.dart'; +import 'package:stream_chat_flutter/src/message_action/message_action.dart'; +import 'package:stream_chat_flutter/src/scroll_view/reaction_scroll_view/stream_reaction_list_view.dart'; +import 'package:stream_chat_flutter/src/stream_chat.dart'; +import 'package:stream_chat_flutter/src/stream_chat_configuration.dart'; +import 'package:stream_chat_flutter/src/utils/extensions.dart'; +import 'package:stream_chat_flutter_core/stream_chat_flutter_core.dart'; +import 'package:stream_core_flutter/stream_core_flutter.dart'; + +/// A bottom sheet that displays detailed reaction information for a message. +/// +/// Shows the total reaction count, emoji filter chips for each reaction type, +/// and a paginated scrollable list of users who reacted. +/// +/// Reactions are fetched from the server using [StreamReactionListController], +/// supporting cursor-based pagination for large reaction lists. +/// +/// Use [ReactionDetailSheet.show] to display the sheet. +class ReactionDetailSheet extends StatefulWidget { + /// Creates a reaction detail sheet. + /// + /// This constructor is private. Use [ReactionDetailSheet.show] to display + /// the sheet as a modal bottom sheet. + const ReactionDetailSheet._({ + required this.scrollController, + required this.message, + this.initialReactionType, + }); + + /// The message whose reactions are displayed. + final Message message; + + /// Scroll controller provided by [DraggableScrollableSheet]. + final ScrollController scrollController; + + /// The reaction type to pre-select when the sheet opens. + /// + /// When non-null, the sheet opens with this reaction type already filtered + /// and the corresponding chip scrolled into view. + final String? initialReactionType; + + /// Shows the reaction detail sheet as a modal bottom sheet. + /// + /// Returns a [SelectReaction] if the user selects a reaction, or `null` + /// if the sheet is dismissed without any selection. + static Future show({ + required BuildContext context, + required Message message, + String? initialReactionType, + }) { + final radius = context.streamRadius; + final colorScheme = context.streamColorScheme; + + return showModalBottomSheet( + context: context, + useSafeArea: true, + isScrollControlled: true, + showDragHandle: true, + backgroundColor: colorScheme.backgroundElevation1, + shape: RoundedRectangleBorder( + borderRadius: BorderRadiusDirectional.only( + topStart: radius.xxxxl, + topEnd: radius.xxxxl, + ), + ), + builder: (context) => DraggableScrollableSheet( + snap: true, + expand: false, + minChildSize: 0.5, + snapSizes: const [0.5, 1], + builder: (_, scrollController) => ReactionDetailSheet._( + scrollController: scrollController, + message: message, + initialReactionType: initialReactionType, + ), + ), + ); + } + + @override + State createState() => _ReactionDetailSheetState(); +} + +class _ReactionDetailSheetState extends State { + late StreamReactionListController _controller; + late String? _currentReactionType = widget.initialReactionType; + + @override + void initState() { + super.initState(); + _initializeController(); + } + + @override + void didUpdateWidget(covariant ReactionDetailSheet oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.message.id != widget.message.id) { + _controller.dispose(); // Dispose the old controller. + _initializeController(); // Initialize a new controller. + } + } + + void _initializeController() { + _controller = .new( + client: StreamChat.of(context).client, + messageId: widget.message.id, + sort: const [.desc(ReactionSortKey.createdAt)], + filter: switch (_currentReactionType) { + final type? => .equal('type', type), + _ => null, + }, + ); + } + + void _onReactionTypeSelected(String? type) { + if (type == _currentReactionType) return; + setState(() => _currentReactionType = type); + + final updatedFilter = switch (type) { + final type? => Filter.equal('type', type), + _ => null, + }; + + _controller.filter = updatedFilter; + _controller.doInitialLoad(); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final spacing = context.streamSpacing; + final textTheme = context.streamTextTheme; + + final config = StreamChatConfiguration.of(context); + final resolver = config.reactionIconResolver; + + final ownReactions = [...?widget.message.ownReactions]; + final ownReactionsMap = {for (final it in ownReactions) it.type: it}; + final reactionGroups = widget.message.reactionGroups ?? {}; + + final currentUserId = StreamChatCore.of(context).currentUser?.id; + + final visibleCount = switch (_currentReactionType) { + final type? => reactionGroups[type]?.count ?? 0, + _ => reactionGroups.values.fold(0, (sum, g) => sum + g.count), + }; + + return Column( + mainAxisSize: .min, + crossAxisAlignment: .stretch, + children: [ + Padding( + padding: .symmetric(horizontal: spacing.sm), + child: Text( + context.translations.reactionsCountText(visibleCount), + textAlign: .center, + style: textTheme.headingSm, + ), + ), + SizedBox(height: spacing.sm), + StreamEmojiChipBar( + selected: _currentReactionType, + onSelected: _onReactionTypeSelected, + items: [ + for (final MapEntry(:key, :value) in reactionGroups.entries) + StreamEmojiChipItem( + value: key, + emoji: resolver.resolve(key), + count: value.count, + ), + ], + leading: StreamEmojiChip.addEmoji( + onPressed: () async { + final selectedReactions = ownReactionsMap.keys.toSet(); + final emoji = await StreamEmojiPickerSheet.show( + context: context, + selectedReactions: selectedReactions, + ); + + if (!context.mounted) return; + if (emoji == null) return Navigator.of(context).pop(); + + final reaction = Reaction(type: emoji.shortName, emojiCode: emoji.emoji); + return Navigator.of(context).pop(SelectReaction(message: widget.message, reaction: reaction)); + }, + ), + ), + SizedBox(height: spacing.md), + Expanded( + child: StreamReactionListView( + controller: _controller, + scrollController: widget.scrollController, + padding: .symmetric(horizontal: spacing.xxs), + itemBuilder: (context, reactions, index) { + final reaction = reactions[index]; + final user = reaction.user; + if (user == null) return const SizedBox.shrink(); + + final isOwnReaction = currentUserId != null && reaction.userId == currentUserId; + + return StreamListTile( + leading: StreamUserAvatar(size: .md, user: user, showOnlineIndicator: false), + title: Text(user.name), + subtitle: isOwnReaction ? Text(context.translations.tapToRemoveReactionLabel) : null, + trailing: StreamEmoji(size: .md, emoji: resolver.resolve(reaction.type)), + onTap: switch (isOwnReaction) { + true => () { + final action = SelectReaction(message: widget.message, reaction: reaction); + return Navigator.of(context).pop(action); + }, + _ => null, + }, + ); + }, + ), + ), + ], + ); + } +} diff --git a/packages/stream_chat_flutter/lib/src/reactions/picker/reaction_picker.dart b/packages/stream_chat_flutter/lib/src/reactions/picker/reaction_picker.dart new file mode 100644 index 0000000000..c9406db718 --- /dev/null +++ b/packages/stream_chat_flutter/lib/src/reactions/picker/reaction_picker.dart @@ -0,0 +1,89 @@ +import 'package:flutter/widgets.dart'; +import 'package:stream_chat_flutter/src/stream_chat_configuration.dart'; +import 'package:stream_chat_flutter_core/stream_chat_flutter_core.dart'; +import 'package:stream_core_flutter/stream_core_flutter.dart'; + +/// {@template onReactionPicked} +/// Callback called when a reaction is picked. +/// {@endtemplate} +typedef OnReactionPicked = ValueSetter; + +/// {@template streamMessageReactionPicker} +/// A chat-specific reaction picker that bridges [StreamReactionPicker] with +/// chat domain models. +/// +/// Resolves reaction icons via [ReactionIconResolver], tracks the current +/// user's own reactions on the [Message], and wires the "add reaction" button +/// to [StreamEmojiPickerSheet]. +/// +/// Visual customisation is controlled through [StreamReactionPickerTheme] in +/// the widget tree. +/// +/// See also: +/// +/// * [StreamReactionPicker], the domain-agnostic core picker. +/// * [ReactionIconResolver], which maps reaction types to emoji content models. +/// * [StreamReactionPickerTheme], for customising the picker appearance. +/// {@endtemplate} +class StreamMessageReactionPicker extends StatelessWidget { + /// {@macro streamMessageReactionPicker} + const StreamMessageReactionPicker({ + super.key, + required this.message, + this.onReactionPicked, + }); + + /// The message to attach the reaction to. + final Message message; + + /// {@macro onReactionPicked} + final OnReactionPicked? onReactionPicked; + + @override + Widget build(BuildContext context) { + final config = StreamChatConfiguration.of(context); + final resolver = config.reactionIconResolver; + final reactionTypes = resolver.defaultReactions; + + final ownReactions = [...?message.ownReactions]; + final ownReactionsMap = {for (final it in ownReactions) it.type: it}; + + final items = [ + ...reactionTypes.map( + (type) => StreamReactionPickerItem( + key: type, + emoji: resolver.resolve(type), + // If the reaction is present in ownReactions, it is selected. + isSelected: ownReactionsMap[type] != null, + ), + ), + ]; + + void onItemPicked(StreamReactionPickerItem item) { + final reactionEmojiCode = resolver.emojiCode(item.key); + final pickedReaction = switch (ownReactionsMap[item.key]) { + final reaction? => reaction, + _ => Reaction(type: item.key, emojiCode: reactionEmojiCode), + }; + + return onReactionPicked?.call(pickedReaction); + } + + return StreamReactionPicker( + items: items, + onReactionPicked: onItemPicked, + onAddReactionTap: () async { + final selectedReactions = ownReactionsMap.keys.toSet(); + final emoji = await StreamEmojiPickerSheet.show( + context: context, + selectedReactions: selectedReactions, + ); + + if (!context.mounted || emoji == null) return; + + final reaction = Reaction(type: emoji.shortName, emojiCode: emoji.emoji); + return onReactionPicked?.call(reaction); + }, + ); + } +} diff --git a/packages/stream_chat_flutter/lib/src/scroll_view/channel_scroll_view/stream_channel_grid_tile.dart b/packages/stream_chat_flutter/lib/src/scroll_view/channel_scroll_view/stream_channel_grid_tile.dart deleted file mode 100644 index dc685d09ae..0000000000 --- a/packages/stream_chat_flutter/lib/src/scroll_view/channel_scroll_view/stream_channel_grid_tile.dart +++ /dev/null @@ -1,91 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:stream_chat_flutter/stream_chat_flutter.dart'; - -/// A widget that displays a user. -/// -/// This widget is intended to be used as a Tile in -/// [StreamChannelGridView]. -/// -/// It shows the user's avatar and name. -/// -/// See also: -/// * [StreamChannelGridView] -/// * [StreamUserAvatar] -class StreamChannelGridTile extends StatelessWidget { - /// Creates a new instance of [StreamChannelGridTile] widget. - const StreamChannelGridTile({ - super.key, - required this.channel, - this.child, - this.footer, - this.onTap, - this.onLongPress, - }); - - /// The channel to display. - final Channel channel; - - /// The widget to display in the body of the tile. - final Widget? child; - - /// The widget to display in the footer of the tile. - final Widget? footer; - - /// Called when the user taps this grid tile. - final GestureTapCallback? onTap; - - /// Called when the user long-presses on this grid tile. - final GestureLongPressCallback? onLongPress; - - /// Creates a copy of this tile but with the given fields replaced with - /// the new values. - StreamChannelGridTile copyWith({ - Key? key, - Channel? channel, - Widget? child, - Widget? footer, - GestureTapCallback? onTap, - GestureLongPressCallback? onLongPress, - }) => - StreamChannelGridTile( - key: key ?? this.key, - channel: channel ?? this.channel, - footer: footer ?? this.footer, - onTap: onTap ?? this.onTap, - onLongPress: onLongPress ?? this.onLongPress, - child: child ?? this.child, - ); - - @override - Widget build(BuildContext context) { - final channelPreviewTheme = StreamChannelPreviewTheme.of(context); - - final child = this.child ?? - StreamChannelAvatar( - channel: channel, - borderRadius: BorderRadius.circular(32), - constraints: const BoxConstraints.tightFor( - height: 64, - width: 64, - ), - ); - - final footer = this.footer ?? - StreamChannelName( - channel: channel, - textStyle: channelPreviewTheme.titleStyle, - ); - - return InkWell( - onTap: onTap, - onLongPress: onLongPress, - child: Column( - mainAxisAlignment: MainAxisAlignment.spaceEvenly, - children: [ - child, - footer, - ], - ), - ); - } -} diff --git a/packages/stream_chat_flutter/lib/src/scroll_view/channel_scroll_view/stream_channel_grid_view.dart b/packages/stream_chat_flutter/lib/src/scroll_view/channel_scroll_view/stream_channel_grid_view.dart deleted file mode 100644 index e391451647..0000000000 --- a/packages/stream_chat_flutter/lib/src/scroll_view/channel_scroll_view/stream_channel_grid_view.dart +++ /dev/null @@ -1,396 +0,0 @@ -import 'package:flutter/gestures.dart'; -import 'package:flutter/material.dart'; -import 'package:stream_chat_flutter/src/scroll_view/stream_scroll_view_error_widget.dart'; -import 'package:stream_chat_flutter/src/scroll_view/stream_scroll_view_load_more_error.dart'; -import 'package:stream_chat_flutter/src/scroll_view/stream_scroll_view_load_more_indicator.dart'; -import 'package:stream_chat_flutter/src/scroll_view/stream_scroll_view_loading_widget.dart'; -import 'package:stream_chat_flutter/stream_chat_flutter.dart'; - -/// Default grid delegate for [StreamChannelGridView]. -const defaultChannelGridViewDelegate = - SliverGridDelegateWithFixedCrossAxisCount(crossAxisCount: 4); - -/// Signature for the item builder that creates the children of the -/// [StreamChannelGridView]. -typedef StreamChannelGridViewIndexedWidgetBuilder - = StreamScrollViewIndexedWidgetBuilder; - -/// A [GridView] that shows a grid of [User]s, -/// it uses [StreamChannelGridTile] as a default item. -/// -/// Example: -/// -/// ```dart -/// StreamChannelGridView( -/// controller: controller, -/// onChannelTap: (channel) { -/// // Handle channel tap event -/// }, -/// onChannelLongPress: (channel) { -/// // Handle channel long press event -/// }, -/// ) -/// ``` -/// -/// See also: -/// * [StreamChannelGridTile] -/// * [StreamChannelListController] -class StreamChannelGridView extends StatelessWidget { - /// Creates a new instance of [StreamChannelGridView]. - const StreamChannelGridView({ - super.key, - required this.controller, - this.gridDelegate = defaultChannelGridViewDelegate, - this.itemBuilder, - this.emptyBuilder, - this.loadMoreErrorBuilder, - this.loadMoreIndicatorBuilder, - this.loadingBuilder, - this.errorBuilder, - this.onChannelTap, - this.onChannelLongPress, - this.loadMoreTriggerIndex = 3, - this.scrollDirection = Axis.vertical, - this.reverse = false, - this.scrollController, - this.primary, - this.physics, - this.shrinkWrap = false, - this.padding, - this.addAutomaticKeepAlives = true, - this.addRepaintBoundaries = true, - this.addSemanticIndexes = true, - this.cacheExtent, - this.semanticChildCount, - this.dragStartBehavior = DragStartBehavior.start, - this.keyboardDismissBehavior = ScrollViewKeyboardDismissBehavior.manual, - this.restorationId, - this.clipBehavior = Clip.hardEdge, - }); - - /// The [StreamUserListController] used to control the grid of users. - final StreamChannelListController controller; - - /// A delegate that controls the layout of the children within - /// the [PagedValueGridView]. - final SliverGridDelegate gridDelegate; - - /// A builder that is called to build items in the [PagedValueGridView]. - /// - /// The `value` parameter is the [Channel] at this position in the grid. - final StreamChannelGridViewIndexedWidgetBuilder? itemBuilder; - - /// A builder that is called to build the empty state of the grid. - final WidgetBuilder? emptyBuilder; - - /// A builder that is called to build the load more error state of the grid. - final PagedValueScrollViewLoadMoreErrorBuilder? loadMoreErrorBuilder; - - /// A builder that is called to build the load more indicator of the grid. - final WidgetBuilder? loadMoreIndicatorBuilder; - - /// A builder that is called to build the loading state of the grid. - final WidgetBuilder? loadingBuilder; - - /// A builder that is called to build the error state of the grid. - final Widget Function(BuildContext, StreamChatError)? errorBuilder; - - /// Called when the user taps this grid tile. - final void Function(Channel)? onChannelTap; - - /// Called when the user long-presses on this grid tile. - final void Function(Channel)? onChannelLongPress; - - /// The index to take into account when triggering [controller.loadMore]. - final int loadMoreTriggerIndex; - - /// {@template flutter.widgets.scroll_view.scrollDirection} - /// The axis along which the scroll view scrolls. - /// - /// Defaults to [Axis.vertical]. - /// {@endtemplate} - final Axis scrollDirection; - - /// {@template flutter.widgets.scroll_view.reverse} - /// Whether the scroll view scrolls in the reading direction. - /// - /// For example, if the reading direction is left-to-right and - /// [scrollDirection] is [Axis.horizontal], then the scroll view scrolls from - /// left to right when [reverse] is false and from right to left when - /// [reverse] is true. - /// - /// Similarly, if [scrollDirection] is [Axis.vertical], then the scroll view - /// scrolls from top to bottom when [reverse] is false and from bottom to top - /// when [reverse] is true. - /// - /// Defaults to false. - /// {@endtemplate} - final bool reverse; - - /// {@template flutter.widgets.scroll_view.controller} - /// An object that can be used to control the position to which this scroll - /// view is scrolled. - /// - /// Must be null if [primary] is true. - /// - /// A [ScrollController] serves several purposes. It can be used to control - /// the initial scroll position (see [ScrollController.initialScrollOffset]). - /// It can be used to control whether the scroll view should automatically - /// save and restore its scroll position in the [PageStorage] (see - /// [ScrollController.keepScrollOffset]). It can be used to read the current - /// scroll position (see [ScrollController.offset]), or change it (see - /// [ScrollController.animateTo]). - /// {@endtemplate} - final ScrollController? scrollController; - - /// {@template flutter.widgets.scroll_view.primary} - /// Whether this is the primary scroll view associated with the parent - /// [PrimaryScrollController]. - /// - /// When this is true, the scroll view is scrollable even if it does not have - /// sufficient content to actually scroll. Otherwise, by default the user can - /// only scroll the view if it has sufficient content. See [physics]. - /// - /// Also when true, the scroll view is used for default [ScrollAction]s. If a - /// ScrollAction is not handled by - /// an otherwise focused part of the application, - /// the ScrollAction will be evaluated using this scroll view, for example, - /// when executing [Shortcuts] key events like page up and down. - /// - /// On iOS, this also identifies the scroll view that will scroll to top in - /// response to a tap in the status bar. - /// {@endtemplate} - /// - /// Defaults to true when [scrollDirection] is [Axis.vertical] and - /// [controller] is null. - final bool? primary; - - /// {@template flutter.widgets.scroll_view.physics} - /// How the scroll view should respond to user input. - /// - /// For example, determines how the scroll view continues to animate after the - /// user stops dragging the scroll view. - /// - /// Defaults to matching platform conventions. Furthermore, if [primary] is - /// false, then the user cannot scroll if there is insufficient content to - /// scroll, while if [primary] is true, they can always attempt to scroll. - /// - /// To force the scroll view to always be scrollable even if there is - /// insufficient content, as if [primary] was true but without necessarily - /// setting it to true, provide an [AlwaysScrollableScrollPhysics] physics - /// object, as in: - /// - /// ```dart - /// physics: const AlwaysScrollableScrollPhysics(), - /// ``` - /// - /// To force the scroll view to use the default platform conventions and not - /// be scrollable if there is insufficient content, regardless of the value of - /// [primary], provide an explicit [ScrollPhysics] object, as in: - /// - /// ```dart - /// physics: const ScrollPhysics(), - /// ``` - /// - /// The physics can be changed dynamically (by providing a new object in a - /// subsequent build), but new physics will only take effect if the _class_ of - /// the provided object changes. Merely constructing a new instance with a - /// different configuration is insufficient to cause the physics to be - /// reapplied. (This is because the final object used is generated - /// dynamically, which can be relatively expensive, and it would be - /// inefficient to speculatively create this object each frame to see if the - /// physics should be updated.) - /// {@endtemplate} - /// - /// If an explicit [ScrollBehavior] is provided to [scrollBehavior], the - /// [ScrollPhysics] provided by that behavior will take precedence after - /// [physics]. - final ScrollPhysics? physics; - - /// {@template flutter.widgets.scroll_view.shrinkWrap} - /// Whether the extent of the scroll view in the [scrollDirection] should be - /// determined by the contents being viewed. - /// - /// If the scroll view does not shrink wrap, then the scroll view will expand - /// to the maximum allowed size in the [scrollDirection]. If the scroll view - /// has unbounded constraints in the [scrollDirection], then [shrinkWrap] must - /// be true. - /// - /// Shrink wrapping the content of the scroll view is significantly more - /// expensive than expanding to the maximum allowed size because the content - /// can expand and contract during scrolling, which means the size of the - /// scroll view needs to be recomputed whenever the scroll position changes. - /// - /// Defaults to false. - /// {@endtemplate} - final bool shrinkWrap; - - /// The amount of space by which to inset the children. - final EdgeInsetsGeometry? padding; - - /// Whether to wrap each child in an [AutomaticKeepAlive]. - /// - /// Typically, children in lazy list are wrapped in [AutomaticKeepAlive] - /// widgets so that children can use [KeepAliveNotification]s to preserve - /// their state when they would otherwise be garbage collected off-screen. - /// - /// This feature (and [addRepaintBoundaries]) must be disabled if the children - /// are going to manually maintain their [KeepAlive] state. It may also be - /// more efficient to disable this feature if it is known ahead of time that - /// none of the children will ever try to keep themselves alive. - /// - /// Defaults to true. - final bool addAutomaticKeepAlives; - - /// Whether to wrap each child in a [RepaintBoundary]. - /// - /// Typically, children in a scrolling container are wrapped in repaint - /// boundaries so that they do not need to be repainted as the list scrolls. - /// If the children are easy to repaint (e.g., solid color blocks or a short - /// snippet of text), it might be more efficient to not add a repaint boundary - /// and simply repaint the children during scrolling. - /// - /// Defaults to true. - final bool addRepaintBoundaries; - - /// Whether to wrap each child in an [IndexedSemantics]. - /// - /// Typically, children in a scrolling container must be annotated with a - /// semantic index in order to generate the correct accessibility - /// announcements. This should only be set to false if the indexes have - /// already been provided by an [IndexedSemantics] widget. - /// - /// Defaults to true. - /// - /// See also: - /// - /// * [IndexedSemantics], for an explanation of how to manually - /// provide semantic indexes. - final bool addSemanticIndexes; - - /// {@macro flutter.rendering.RenderViewportBase.cacheExtent} - final double? cacheExtent; - - /// The number of children that will contribute semantic information. - /// - /// Some subtypes of [ScrollView] can infer this value automatically. For - /// example [ListView] will use the number of widgets in the child list, - /// while the [ListView.separated] constructor will use half that amount. - /// - /// For [CustomScrollView] and other types which do not receive a builder - /// or list of widgets, the child count must be explicitly provided. If the - /// number is unknown or unbounded this should be left unset or set to null. - /// - /// See also: - /// - /// * [SemanticsConfiguration.scrollChildCount], - /// the corresponding semantics property. - final int? semanticChildCount; - - /// {@macro flutter.widgets.scrollable.dragStartBehavior} - final DragStartBehavior dragStartBehavior; - - /// {@template flutter.widgets.scroll_view.keyboardDismissBehavior} - /// [ScrollViewKeyboardDismissBehavior] the defines how this [ScrollView] will - /// dismiss the keyboard automatically. - /// {@endtemplate} - final ScrollViewKeyboardDismissBehavior keyboardDismissBehavior; - - /// {@macro flutter.widgets.scrollable.restorationId} - final String? restorationId; - - /// {@macro flutter.material.Material.clipBehavior} - /// - /// Defaults to [Clip.hardEdge]. - final Clip clipBehavior; - - @override - Widget build(BuildContext context) { - return PagedValueGridView( - scrollDirection: scrollDirection, - reverse: reverse, - controller: controller, - primary: primary, - physics: physics, - shrinkWrap: shrinkWrap, - padding: padding, - scrollController: scrollController, - addAutomaticKeepAlives: addAutomaticKeepAlives, - addRepaintBoundaries: addRepaintBoundaries, - addSemanticIndexes: addSemanticIndexes, - cacheExtent: cacheExtent, - semanticChildCount: semanticChildCount, - dragStartBehavior: dragStartBehavior, - keyboardDismissBehavior: keyboardDismissBehavior, - restorationId: restorationId, - clipBehavior: clipBehavior, - gridDelegate: gridDelegate, - itemBuilder: (context, channels, index) { - final channel = channels[index]; - final onTap = onChannelTap; - final onLongPress = onChannelLongPress; - - final streamChannelGridTile = StreamChannelGridTile( - channel: channel, - onTap: onTap == null ? null : () => onTap(channel), - onLongPress: onLongPress == null ? null : () => onLongPress(channel), - ); - - return itemBuilder?.call( - context, - channels, - index, - streamChannelGridTile, - ) ?? - streamChannelGridTile; - }, - emptyBuilder: (context) { - final chatThemeData = StreamChatTheme.of(context); - return emptyBuilder?.call(context) ?? - Center( - child: Padding( - padding: const EdgeInsets.all(8), - child: StreamScrollViewEmptyWidget( - emptyIcon: StreamSvgIcon( - size: 148, - icon: StreamSvgIcons.message, - color: chatThemeData.colorTheme.disabled, - ), - emptyTitle: Text( - context.translations.letsStartChattingLabel, - style: chatThemeData.textTheme.headline, - ), - ), - ), - ); - }, - loadMoreErrorBuilder: (context, error) => - StreamScrollViewLoadMoreError.grid( - onTap: controller.retry, - error: Text( - context.translations.loadingChannelsError, - textAlign: TextAlign.center, - ), - ), - loadMoreIndicatorBuilder: (context) => const Center( - child: Padding( - padding: EdgeInsets.all(16), - child: StreamScrollViewLoadMoreIndicator(), - ), - ), - loadingBuilder: (context) => - loadingBuilder?.call(context) ?? - const Center( - child: StreamScrollViewLoadingWidget(), - ), - errorBuilder: (context, error) => - errorBuilder?.call(context, error) ?? - Center( - child: StreamScrollViewErrorWidget( - errorTitle: Text(context.translations.loadingChannelsError), - onRetryPressed: controller.refresh, - ), - ), - ); - } -} diff --git a/packages/stream_chat_flutter/lib/src/scroll_view/channel_scroll_view/stream_channel_list_item.dart b/packages/stream_chat_flutter/lib/src/scroll_view/channel_scroll_view/stream_channel_list_item.dart new file mode 100644 index 0000000000..4312c74ded --- /dev/null +++ b/packages/stream_chat_flutter/lib/src/scroll_view/channel_scroll_view/stream_channel_list_item.dart @@ -0,0 +1,771 @@ +import 'package:collection/collection.dart'; +import 'package:flutter/material.dart'; +import 'package:rxdart/rxdart.dart'; +import 'package:stream_chat_flutter/src/misc/empty_widget.dart'; +import 'package:stream_chat_flutter/stream_chat_flutter.dart'; +import 'package:stream_core_flutter/stream_core_flutter.dart'; + +/// A widget that displays a channel preview. +/// +/// This widget is intended to be used as a Tile in [StreamChannelListView]. +/// +/// It shows the last message of the channel, the last message time, the unread +/// message count, the typing indicator, the sending indicator and the channel +/// avatar. +/// +/// Internally uses [StreamListTileContainer] from the core design system for +/// consistent visual presentation. +/// +/// See also: +/// * [StreamChannelAvatar] +/// * [StreamChannelName] +class StreamChannelListItem extends StatelessWidget { + /// Creates a new instance of [StreamChannelListItem] widget. + StreamChannelListItem({ + super.key, + required Channel channel, + GestureTapCallback? onTap, + GestureLongPressCallback? onLongPress, + bool selected = false, + }) : assert( + channel.state != null, + 'Channel ${channel.id} is not initialized', + ), + props = .new( + channel: channel, + onTap: onTap, + onLongPress: onLongPress, + selected: selected, + ); + + /// The properties for the channel list item. + final StreamChannelListItemProps props; + + /// Creates a copy of this tile but with the given fields replaced with + /// the new values. + StreamChannelListItem copyWith({ + Key? key, + Channel? channel, + VoidCallback? onTap, + VoidCallback? onLongPress, + bool? selected, + }) { + return StreamChannelListItem( + key: key ?? this.key, + channel: channel ?? props.channel, + onTap: onTap ?? props.onTap, + onLongPress: onLongPress ?? props.onLongPress, + selected: selected ?? props.selected, + ); + } + + @override + Widget build(BuildContext context) { + final builder = context.chatComponentBuilder(); + return builder?.call(context, props) ?? _DefaultStreamChannelListItem(props: props); + } +} + +/// Properties for configuring a [StreamChannelListItem]. +/// +/// This class holds all the configuration options for a channel list item, +/// allowing them to be passed through the [StreamComponentFactory]. +/// +/// See also: +/// +/// * [StreamChannelListItem], which uses these properties. +/// * [DefaultStreamChannelListItem], the default implementation. +class StreamChannelListItemProps { + /// Creates properties for a channel list item. + const StreamChannelListItemProps({ + required this.channel, + this.leading, + this.title, + this.subtitle, + this.trailing, + this.onTap, + this.onLongPress, + this.sendingIndicatorBuilder, + this.selected = false, + }); + + /// The channel to display. + final Channel channel; + + /// A widget to display as the avatar. + /// + /// Defaults to [StreamChannelAvatar]. + final Widget? leading; + + /// The primary content of the list tile. + /// + /// Defaults to [StreamChannelName]. + final Widget? title; + + /// Additional content displayed below the title. + /// + /// Defaults to [ChannelListTileSubtitle] which shows typing indicators, + /// draft messages, or the last message preview. + final Widget? subtitle; + + /// A widget to display as the timestamp. + /// + /// Defaults to [ChannelLastMessageDate]. + final Widget? trailing; + + /// Called when the user taps this list tile. + final GestureTapCallback? onTap; + + /// Called when the user long-presses on this list tile. + final GestureLongPressCallback? onLongPress; + + /// The widget builder for the sending indicator. + /// + /// `Message` is the last message in the channel. Use it to determine the + /// status using [Message.state]. + final Widget Function(BuildContext, Message)? sendingIndicatorBuilder; + + /// True if the tile is in a selected state. + final bool selected; +} + +class _DefaultStreamChannelListItem extends StatelessWidget { + const _DefaultStreamChannelListItem({ + required this.props, + }); + + final StreamChannelListItemProps props; + + @override + Widget build(BuildContext context) { + final channelState = props.channel.state!; + + final avatar = props.leading ?? StreamChannelAvatar(channel: props.channel, size: .xl); + final titleWidget = props.title ?? StreamChannelName(channel: props.channel); + final subtitleWidget = + props.subtitle ?? + ChannelListTileSubtitle( + channel: props.channel, + sendingIndicatorBuilder: props.sendingIndicatorBuilder, + ); + final timestampWidget = props.trailing ?? ChannelLastMessageDate(channel: props.channel); + + return BetterStreamBuilder( + initialData: ( + isMuted: props.channel.isMuted, + isPinned: props.channel.isPinned, + unreadCount: channelState.unreadCount, + ), + stream: Rx.combineLatest3( + props.channel.isMutedStream, + props.channel.isPinnedStream, + channelState.unreadCountStream, + (isMuted, isPinned, unreadCount) => (isMuted: isMuted, isPinned: isPinned, unreadCount: unreadCount), + ), + builder: (context, state) => StreamChannelListTile( + avatar: avatar, + title: titleWidget, + subtitle: subtitleWidget, + timestamp: timestampWidget, + unreadCount: state.unreadCount, + isMuted: state.isMuted, + isPinned: state.isPinned, + onTap: props.onTap, + onLongPress: props.onLongPress, + selected: props.selected, + ), + ); + } +} + +/// A widget that displays a channel list tile. +/// It's the basic component for [StreamChannelListItem] without any of the logic. +/// It can be used to fully customize the list tile data being shown. +class StreamChannelListTile extends StatelessWidget { + /// Creates a new instance of [StreamChannelListTile] widget. + const StreamChannelListTile({ + super.key, + required this.avatar, + required this.title, + this.subtitle, + this.timestamp, + this.unreadCount = 0, + this.isMuted = false, + this.isPinned = false, + this.onTap, + this.onLongPress, + this.selected = false, + }); + + /// The avatar widget displayed at the leading edge. + /// + /// Typically a [StreamAvatar], [StreamAvatarGroup], or an avatar wrapped + /// in a [StreamOnlineIndicator]. + final Widget avatar; + + /// The channel title widget. + /// + /// Typically a [Text] widget with the channel name. The default text style + /// is provided by the theme's title style via [DefaultTextStyle]. + final Widget title; + + /// The message preview widget displayed below the title. + /// + /// Typically a [Text] widget with the last message, but can be any widget + /// for richer content (e.g., icons, read receipts, sender prefix). + final Widget? subtitle; + + /// The timestamp widget displayed in the trailing section of the title row. + /// + /// Typically a [Text] widget with a formatted date string. The default text + /// style is provided by the theme's timestamp style via [DefaultTextStyle]. + final Widget? timestamp; + + /// The number of unread messages. + /// + /// When greater than zero, a [StreamBadgeNotification] is displayed. + final int unreadCount; + + /// Whether the channel is muted. + /// + /// When true, a mute icon is displayed in the title or subtitle. + final bool isMuted; + + /// Whether the channel is pinned by the current user. + /// + /// When true, a pin icon is displayed alongside the mute icon. + final bool isPinned; + + /// Called when the list item is tapped. + final VoidCallback? onTap; + + /// Called when the list item is long-pressed. + final VoidCallback? onLongPress; + + /// Whether the list item is in a selected state. + final bool selected; + + @override + Widget build(BuildContext context) { + final icons = context.streamIcons; + final spacing = context.streamSpacing; + + final channelListItemTheme = StreamChannelListItemTheme.of(context); + final defaults = _StreamChannelListItemThemeDefaults(context); + + final effectiveTitleStyle = channelListItemTheme.titleStyle ?? defaults.titleStyle; + final effectiveSubtitleStyle = channelListItemTheme.subtitleStyle ?? defaults.subtitleStyle; + final effectiveTimestampStyle = channelListItemTheme.timestampStyle ?? defaults.timestampStyle; + final effectiveAttributePosition = channelListItemTheme.attributePosition ?? defaults.attributePosition; + + final channelAttributes = [ + if (isMuted) Icon(icons.mute), + if (isPinned) Icon(icons.pin), + ]; + + Widget? attributesRow; + if (channelAttributes.isNotEmpty) { + attributesRow = Row( + mainAxisSize: .min, + spacing: spacing.xxs, + children: channelAttributes, + ); + } + + final titleTrailing = effectiveAttributePosition == .inlineTitle ? attributesRow : null; + final subtitleTrailing = effectiveAttributePosition == .trailingBottom ? attributesRow : null; + + return Padding( + padding: EdgeInsets.all(spacing.xxs), + child: StreamListTileTheme( + data: StreamListTileThemeData( + contentPadding: EdgeInsets.all(spacing.sm), + backgroundColor: channelListItemTheme.backgroundColor, + ), + child: StreamListTileContainer( + onTap: onTap, + onLongPress: onLongPress, + selected: selected, + child: Row( + mainAxisSize: .min, + spacing: spacing.md, + children: [ + avatar, + Expanded( + child: Column( + mainAxisSize: .min, + spacing: spacing.xxs, + crossAxisAlignment: .start, + children: [ + _TitleRow( + title: title, + titleTrailing: titleTrailing, + timestamp: timestamp, + unreadCount: unreadCount, + titleStyle: effectiveTitleStyle, + timestampStyle: effectiveTimestampStyle, + ), + if (subtitle != null || subtitleTrailing != null) + _SubtitleRow( + subtitle: subtitle, + subtitleTrailing: subtitleTrailing, + subtitleStyle: effectiveSubtitleStyle, + ), + ], + ), + ), + ], + ), + ), + ), + ); + } +} + +class _TitleRow extends StatelessWidget { + const _TitleRow({ + required this.title, + this.titleTrailing, + this.timestamp, + required this.unreadCount, + required this.titleStyle, + required this.timestampStyle, + }); + + final Widget title; + final Widget? titleTrailing; + final Widget? timestamp; + final int unreadCount; + final TextStyle titleStyle; + final TextStyle timestampStyle; + + @override + Widget build(BuildContext context) { + final spacing = context.streamSpacing; + final colorScheme = context.streamColorScheme; + + return Row( + mainAxisSize: .min, + spacing: spacing.md, + children: [ + Expanded( + child: Row( + mainAxisSize: .min, + spacing: spacing.xs, + children: [ + Flexible( + child: DefaultTextStyle.merge( + style: titleStyle, + maxLines: 1, + overflow: .ellipsis, + child: title, + ), + ), + if (titleTrailing case final trailing?) + IconTheme.merge( + data: .new(size: 20, color: colorScheme.textTertiary), + child: trailing, + ), + ], + ), + ), + if (timestamp != null || unreadCount > 0) + Row( + mainAxisSize: .min, + spacing: spacing.xs, + children: [ + if (timestamp case final timestamp?) DefaultTextStyle.merge(style: timestampStyle, child: timestamp), + if (unreadCount > 0) StreamBadgeNotification(label: '$unreadCount'), + ], + ), + ], + ); + } +} + +class _SubtitleRow extends StatelessWidget { + const _SubtitleRow({ + required this.subtitle, + this.subtitleTrailing, + required this.subtitleStyle, + }); + + final Widget? subtitle; + final Widget? subtitleTrailing; + final TextStyle subtitleStyle; + + @override + Widget build(BuildContext context) { + final spacing = context.streamSpacing; + final colorScheme = context.streamColorScheme; + + return Row( + mainAxisSize: .min, + spacing: spacing.md, + children: [ + Flexible( + child: DefaultTextStyle.merge( + style: subtitleStyle, + maxLines: 1, + overflow: .ellipsis, + child: subtitle ?? const Empty(), + ), + ), + if (subtitleTrailing case final trailing?) + IconTheme.merge( + data: .new(size: 20, color: colorScheme.textTertiary), + child: trailing, + ), + ], + ); + } +} + +class _StreamChannelListItemThemeDefaults extends StreamChannelListItemThemeData { + _StreamChannelListItemThemeDefaults(this._context); + + final BuildContext _context; + + late final _colorScheme = _context.streamColorScheme; + late final _textTheme = _context.streamTextTheme; + + @override + AttributePosition get attributePosition => .inlineTitle; + + @override + TextStyle get titleStyle => _textTheme.headingSm.copyWith(color: _colorScheme.textPrimary); + + @override + TextStyle get subtitleStyle => _textTheme.captionDefault.copyWith(color: _colorScheme.textSecondary); + + @override + TextStyle get timestampStyle => _textTheme.captionDefault.copyWith(color: _colorScheme.textTertiary); + + @override + Color get borderColor => _colorScheme.borderSubtle; +} + +/// Shows the delivery status icon + "You:" prefix for outgoing messages in +/// the channel list. +/// +/// Unlike [StreamSendingIndicator], this widget does not show a read count +/// number. It only shows: +/// - Clock icon + "You:" (sending) +/// - Single check + "You:" (sent) +/// - Double check grey + "You:" (delivered) +/// - Double check blue + "You:" (read) +class _ChannelListDeliveryStatus extends StatelessWidget { + const _ChannelListDeliveryStatus({ + required this.channel, + required this.message, + }); + + final Channel channel; + final Message message; + + @override + Widget build(BuildContext context) { + return BetterStreamBuilder>( + stream: channel.state?.readStream, + initialData: channel.state?.read, + builder: (context, data) { + final isRead = data.readsOf(message: message).isNotEmpty; + final isDelivered = data.deliveriesOf(message: message).isNotEmpty; + + return StreamSendingIndicator( + size: 16, + message: message, + isMessageRead: isRead, + isMessageDelivered: isDelivered, + ); + }, + ); + } +} + +/// A widget that displays the channel last message date. +class ChannelLastMessageDate extends StatelessWidget { + /// Creates a new instance of the [ChannelLastMessageDate] widget. + ChannelLastMessageDate({ + super.key, + required this.channel, + this.textStyle, + this.formatter, + }) : assert( + channel.state != null, + 'Channel ${channel.id} is not initialized', + ); + + /// The channel to display the last message date for. + final Channel channel; + + /// The style of the text displayed + final TextStyle? textStyle; + + /// The formatter to format the date. + final DateFormatter? formatter; + + @override + Widget build(BuildContext context) { + return BetterStreamBuilder( + stream: channel.lastMessageAtStream, + initialData: channel.lastMessageAt, + builder: (context, lastMessageAt) => StreamTimestamp( + date: lastMessageAt.toLocal(), + style: textStyle, + formatter: formatter, + ), + ); + } +} + +/// A widget that displays the subtitle for [StreamChannelListItem]. +/// +/// Shows typing indicators, draft messages, or the last message preview. +/// The delivery status prefix (icon + "You:") is only shown when the subtitle +/// displays an actual sent message from the current user (not for drafts or +/// typing indicators). +class ChannelListTileSubtitle extends StatelessWidget { + /// Creates a new instance of [StreamChannelListTileSubtitle] widget. + ChannelListTileSubtitle({ + super.key, + required this.channel, + this.textStyle, + this.sendingIndicatorBuilder, + }) : assert( + channel.state != null, + 'Channel ${channel.id} is not initialized', + ); + + /// The channel to create the subtitle from. + final Channel channel; + + /// The style of the text displayed + final TextStyle? textStyle; + + /// The widget builder for the sending indicator. + /// + /// `Message` is the last message in the channel. Use it to determine the + /// status using [Message.state]. + final Widget Function(BuildContext, Message)? sendingIndicatorBuilder; + + @override + Widget build(BuildContext context) { + return StreamTypingIndicator( + channel: channel, + style: textStyle, + alternativeWidget: _ChannelLastMessageWithStatus( + channel: channel, + textStyle: textStyle, + sendingIndicatorBuilder: sendingIndicatorBuilder, + ), + ); + } +} + +/// Combines the delivery status prefix with the last message text. +/// +/// Shows the delivery status only when the displayed content is an actual +/// sent message from the current user (not a draft). +class _ChannelLastMessageWithStatus extends StatefulWidget { + const _ChannelLastMessageWithStatus({ + required this.channel, + this.textStyle, + this.sendingIndicatorBuilder, + }); + + final Channel channel; + final TextStyle? textStyle; + final Widget Function(BuildContext, Message)? sendingIndicatorBuilder; + + @override + State<_ChannelLastMessageWithStatus> createState() => _ChannelLastMessageWithStatusState(); +} + +class _ChannelLastMessageWithStatusState extends State<_ChannelLastMessageWithStatus> { + Message? _currentLastMessage; + + static bool _defaultLastMessagePredicate(Message message) { + if (message.isShadowed) return false; + if (message.isError) return false; + if (message.isEphemeral) return false; + + return true; + } + + @override + Widget build(BuildContext context) { + final channelState = widget.channel.state; + if (channelState == null) return const Empty(); + + final currentUser = widget.channel.client.state.currentUser; + + return BetterStreamBuilder<(Draft?, List)>( + stream: CombineLatestStream.combine2( + channelState.draftStream, + channelState.messagesStream, + (draft, messages) => (draft, messages), + ), + initialData: (channelState.draft, channelState.messages), + builder: (context, data) { + final spacing = context.streamSpacing; + + final (draft, messages) = data; + + final config = StreamChatConfiguration.maybeOf(context); + final draftMessagesEnabled = config?.draftMessagesEnabled == true; + + // If there's a draft, show only the draft preview (no delivery status). + if (draft?.message case final draftMessage? when draftMessagesEnabled) { + return StreamDraftMessagePreviewText( + draftMessage: draftMessage, + textStyle: widget.textStyle, + ); + } + + // Find the last valid message. + final message = messages.lastWhereOrNull(_defaultLastMessagePredicate); + final latestLastMessage = [message, _currentLastMessage].latest; + + if (latestLastMessage == null) { + return Text( + context.translations.emptyMessagesText, + style: widget.textStyle?.copyWith(color: context.streamColorScheme.textTertiary), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ); + } + + final isOwnMessage = currentUser != null && latestLastMessage.user?.id == currentUser.id; + + // Show delivery status prefix only for own messages. + Widget? deliveryPrefix; + if (isOwnMessage) { + if (widget.sendingIndicatorBuilder case final builder?) { + deliveryPrefix = builder(context, latestLastMessage); + } else { + deliveryPrefix = _ChannelListDeliveryStatus( + channel: widget.channel, + message: latestLastMessage, + ); + } + } + + return Row( + spacing: spacing.xxs, + children: [ + if (!latestLastMessage.isDeleted) ?deliveryPrefix, + Flexible( + child: StreamMessagePreviewText( + message: latestLastMessage, + textStyle: widget.textStyle, + channel: channelState.channelState.channel, + ), + ), + ], + ); + }, + ); + } +} + +/// A widget that displays the last message of a channel. +class ChannelLastMessageText extends StatefulWidget { + /// Creates a new instance of [ChannelLastMessageText] widget. + ChannelLastMessageText({ + super.key, + required this.channel, + this.textStyle, + this.lastMessagePredicate = _defaultLastMessagePredicate, + }) : assert( + channel.state != null, + 'Channel ${channel.id} is not initialized', + ); + + /// The channel to display the last message of. + final Channel channel; + + /// The style of the text displayed + final TextStyle? textStyle; + + /// The predicate to determine if the message should be considered for the + /// last message. + /// + /// This predicate is used to filter out messages that should not be + /// considered for the last message. + final bool Function(Message) lastMessagePredicate; + + // The default predicate to determine if the message should be + // considered for the last message. + static bool _defaultLastMessagePredicate(Message message) { + if (message.isShadowed) return false; + if (message.isError) return false; + if (message.isEphemeral) return false; + + return true; + } + + @override + State createState() => _ChannelLastMessageTextState(); +} + +class _ChannelLastMessageTextState extends State { + Message? _currentLastMessage; + + @override + Widget build(BuildContext context) { + final channelState = widget.channel.state; + if (channelState == null) return const Empty(); + + return BetterStreamBuilder<(Draft?, List)>( + stream: CombineLatestStream.combine2( + channelState.draftStream, + channelState.messagesStream, + (draft, messages) => (draft, messages), + ), + initialData: (channelState.draft, channelState.messages), + builder: (context, data) { + final (draft, messages) = data; + + // Prioritize the draft message if it exists. + if (draft?.message case final draftMessage?) { + return StreamDraftMessagePreviewText( + draftMessage: draftMessage, + textStyle: widget.textStyle, + ); + } + + // Otherwise, show the channel last message if it exists. + final message = messages.lastWhereOrNull(widget.lastMessagePredicate); + final latestLastMessage = [message, _currentLastMessage].latest; + + if (latestLastMessage == null) { + return Text( + maxLines: 1, + context.translations.emptyMessagesText, + style: widget.textStyle, + overflow: TextOverflow.ellipsis, + ); + } + + return StreamMessagePreviewText( + message: latestLastMessage, + textStyle: widget.textStyle, + channel: channelState.channelState.channel, + ); + }, + ); + } +} + +extension on Iterable { + Message? get latest { + return reduce((a, b) { + if (a == null) return b; + if (b == null) return a; + + if (a.createdAt.isAfter(b.createdAt)) return a; + return b; + }); + } +} diff --git a/packages/stream_chat_flutter/lib/src/scroll_view/channel_scroll_view/stream_channel_list_skeleton_loading.dart b/packages/stream_chat_flutter/lib/src/scroll_view/channel_scroll_view/stream_channel_list_skeleton_loading.dart new file mode 100644 index 0000000000..005f4d3fe3 --- /dev/null +++ b/packages/stream_chat_flutter/lib/src/scroll_view/channel_scroll_view/stream_channel_list_skeleton_loading.dart @@ -0,0 +1,89 @@ +import 'package:flutter/material.dart'; +import 'package:stream_core_flutter/stream_core_flutter.dart'; + +/// A shimmer loading placeholder for the channel list view. +/// +/// Displays a skeleton UI with shimmer animation using +/// [StreamSkeletonLoading] and [StreamSkeletonBox] from the core package. +class StreamChannelListSkeletonLoading extends StatelessWidget { + /// Creates a new instance of [StreamChannelListSkeletonLoading]. + const StreamChannelListSkeletonLoading({ + super.key, + this.itemCount = 7, + }); + + /// The number of skeleton items to display. + final int itemCount; + + @override + Widget build(BuildContext context) { + return StreamSkeletonLoading( + child: ListView.separated( + physics: const NeverScrollableScrollPhysics(), + itemCount: itemCount, + separatorBuilder: (context, index) => const SizedBox(height: 1), + itemBuilder: (context, index) => const _StreamChannelListItemSkeleton(), + ), + ); + } +} + +class _StreamChannelListItemSkeleton extends StatelessWidget { + const _StreamChannelListItemSkeleton(); + + @override + Widget build(BuildContext context) { + final spacing = context.streamSpacing; + + return Padding( + padding: EdgeInsets.all(spacing.md), + child: Row( + crossAxisAlignment: CrossAxisAlignment.center, + spacing: spacing.md, + children: [ + const StreamSkeletonBox.circular(radius: 24), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + spacing: spacing.xs, + children: [ + Row( + children: [ + Expanded( + child: StreamSkeletonBox( + width: double.infinity, + height: 16, + borderRadius: BorderRadius.all(context.streamRadius.max), + ), + ), + SizedBox(width: spacing.md), + StreamSkeletonBox( + width: 48, + height: 16, + borderRadius: BorderRadius.all(context.streamRadius.max), + ), + ], + ), + Row( + children: [ + Expanded( + flex: 3, + child: StreamSkeletonBox( + width: double.infinity, + height: 16, + borderRadius: BorderRadius.all(context.streamRadius.max), + ), + ), + const Spacer( + flex: 2, + ), + ], + ), + ], + ), + ), + ], + ), + ); + } +} diff --git a/packages/stream_chat_flutter/lib/src/scroll_view/channel_scroll_view/stream_channel_list_tile.dart b/packages/stream_chat_flutter/lib/src/scroll_view/channel_scroll_view/stream_channel_list_tile.dart deleted file mode 100644 index 1d950ce44f..0000000000 --- a/packages/stream_chat_flutter/lib/src/scroll_view/channel_scroll_view/stream_channel_list_tile.dart +++ /dev/null @@ -1,445 +0,0 @@ -import 'package:collection/collection.dart'; -import 'package:flutter/material.dart'; -import 'package:rxdart/rxdart.dart'; -import 'package:stream_chat_flutter/src/message_widget/sending_indicator_builder.dart'; -import 'package:stream_chat_flutter/src/misc/empty_widget.dart'; -import 'package:stream_chat_flutter/src/misc/timestamp.dart'; -import 'package:stream_chat_flutter/src/utils/date_formatter.dart'; -import 'package:stream_chat_flutter/stream_chat_flutter.dart'; - -/// A widget that displays a channel preview. -/// -/// This widget is intended to be used as a Tile in [StreamChannelListView] -/// -/// It shows the last message of the channel, the last message time, the unread -/// message count, the typing indicator, the sending indicator and the channel -/// avatar. -/// -/// See also: -/// * [StreamChannelAvatar] -/// * [StreamChannelName] -class StreamChannelListTile extends StatelessWidget { - /// Creates a new instance of [StreamChannelListTile] widget. - StreamChannelListTile({ - super.key, - required this.channel, - this.leading, - this.title, - this.subtitle, - this.trailing, - this.onTap, - this.onLongPress, - this.tileColor, - this.visualDensity = VisualDensity.compact, - this.contentPadding = const EdgeInsets.symmetric(horizontal: 8), - this.unreadIndicatorBuilder, - this.sendingIndicatorBuilder, - this.selected = false, - this.selectedTileColor, - }) : assert( - channel.state != null, - 'Channel ${channel.id} is not initialized', - ); - - /// The channel to display. - final Channel channel; - - /// A widget to display before the title. - final Widget? leading; - - /// The primary content of the list tile. - final Widget? title; - - /// Additional content displayed below the title. - final Widget? subtitle; - - /// A widget to display at the end of tile. - final Widget? trailing; - - /// Called when the user taps this list tile. - final GestureTapCallback? onTap; - - /// Called when the user long-presses on this list tile. - final GestureLongPressCallback? onLongPress; - - /// {@template flutter.material.ListTile.tileColor} - /// Defines the background color of `ListTile`. - /// - /// When the value is null, - /// the `tileColor` is set to [ListTileTheme.tileColor] - /// if it's not null and to [Colors.transparent] if it's null. - /// {@endtemplate} - final Color? tileColor; - - /// Defines how compact the list tile's layout will be. - /// - /// {@macro flutter.material.themedata.visualDensity} - /// - /// See also: - /// - /// * [ThemeData.visualDensity], which specifies the [visualDensity] for all - /// widgets within a [Theme]. - final VisualDensity visualDensity; - - /// The tile's internal padding. - /// - /// Insets a [ListTile]'s contents: its [leading], [title], [subtitle], - /// and [trailing] widgets. - /// - /// If null, `EdgeInsets.symmetric(horizontal: 16.0)` is used. - final EdgeInsetsGeometry contentPadding; - - /// The widget builder for the unread indicator. - final WidgetBuilder? unreadIndicatorBuilder; - - /// The widget builder for the sending indicator. - /// - /// `Message` is the last message in the channel, Use it to determine the - /// status using [Message.state]. - final Widget Function(BuildContext, Message)? sendingIndicatorBuilder; - - /// True if the tile is in a selected state. - final bool selected; - - /// The color of the tile in selected state. - final Color? selectedTileColor; - - /// Creates a copy of this tile but with the given fields replaced with - /// the new values. - StreamChannelListTile copyWith({ - Key? key, - Channel? channel, - Widget? leading, - Widget? title, - Widget? subtitle, - VoidCallback? onTap, - VoidCallback? onLongPress, - VisualDensity? visualDensity, - EdgeInsetsGeometry? contentPadding, - bool? selected, - Widget Function(BuildContext, Message)? sendingIndicatorBuilder, - Color? tileColor, - Color? selectedTileColor, - WidgetBuilder? unreadIndicatorBuilder, - Widget? trailing, - }) { - return StreamChannelListTile( - key: key ?? this.key, - channel: channel ?? this.channel, - leading: leading ?? this.leading, - title: title ?? this.title, - subtitle: subtitle ?? this.subtitle, - onTap: onTap ?? this.onTap, - onLongPress: onLongPress ?? this.onLongPress, - visualDensity: visualDensity ?? this.visualDensity, - contentPadding: contentPadding ?? this.contentPadding, - sendingIndicatorBuilder: - sendingIndicatorBuilder ?? this.sendingIndicatorBuilder, - tileColor: tileColor ?? this.tileColor, - trailing: trailing ?? this.trailing, - unreadIndicatorBuilder: - unreadIndicatorBuilder ?? this.unreadIndicatorBuilder, - selected: selected ?? this.selected, - selectedTileColor: selectedTileColor ?? this.selectedTileColor, - ); - } - - @override - Widget build(BuildContext context) { - final channelState = channel.state!; - final currentUser = channel.client.state.currentUser!; - - final channelPreviewTheme = StreamChannelPreviewTheme.of(context); - final streamChatTheme = StreamChatTheme.of(context); - final streamChat = StreamChat.of(context); - - final leading = this.leading ?? - StreamChannelAvatar( - channel: channel, - ); - - final title = this.title ?? - StreamChannelName( - channel: channel, - textStyle: channelPreviewTheme.titleStyle, - ); - - final subtitle = this.subtitle ?? - ChannelListTileSubtitle( - channel: channel, - textStyle: channelPreviewTheme.subtitleStyle, - ); - - final trailing = this.trailing ?? - ChannelLastMessageDate( - channel: channel, - textStyle: channelPreviewTheme.lastMessageAtStyle, - formatter: channelPreviewTheme.lastMessageAtFormatter, - ); - - return BetterStreamBuilder( - stream: channel.isMutedStream, - initialData: channel.isMuted, - builder: (context, isMuted) => AnimatedOpacity( - opacity: isMuted ? 0.5 : 1, - duration: const Duration(milliseconds: 300), - child: ListTile( - onTap: onTap, - onLongPress: onLongPress, - visualDensity: visualDensity, - contentPadding: contentPadding, - leading: leading, - tileColor: tileColor, - selected: selected, - selectedTileColor: selectedTileColor ?? - StreamChatTheme.of(context).colorTheme.borders, - title: Row( - children: [ - Expanded(child: title), - BetterStreamBuilder>( - stream: channelState.membersStream, - initialData: channelState.members, - comparator: const ListEquality().equals, - builder: (context, members) { - if (members.isEmpty) { - return const Empty(); - } - return unreadIndicatorBuilder?.call(context) ?? - StreamUnreadIndicator.channels(cid: channel.cid); - }, - ), - ], - ), - subtitle: Row( - children: [ - Expanded( - child: Align( - alignment: Alignment.centerLeft, - child: subtitle, - ), - ), - BetterStreamBuilder>( - stream: channelState.messagesStream, - initialData: channelState.messages, - comparator: const ListEquality().equals, - builder: (context, messages) { - final lastMessage = messages.lastWhereOrNull( - (m) => !m.shadowed && !m.isDeleted, - ); - - if (lastMessage == null || - (lastMessage.user?.id != currentUser.id)) { - return const Empty(); - } - - final hasNonUrlAttachments = lastMessage.attachments - .any((it) => it.type != AttachmentType.urlPreview); - - return Padding( - padding: const EdgeInsets.only(right: 4), - child: - sendingIndicatorBuilder?.call(context, lastMessage) ?? - SendingIndicatorBuilder( - messageTheme: streamChatTheme.ownMessageTheme, - message: lastMessage, - hasNonUrlAttachments: hasNonUrlAttachments, - streamChat: streamChat, - streamChatTheme: streamChatTheme, - channel: channel, - ), - ); - }, - ), - trailing, - ], - ), - ), - ), - ); - } -} - -/// A widget that displays the channel last message date. -class ChannelLastMessageDate extends StatelessWidget { - /// Creates a new instance of the [ChannelLastMessageDate] widget. - ChannelLastMessageDate({ - super.key, - required this.channel, - this.textStyle, - this.formatter, - }) : assert( - channel.state != null, - 'Channel ${channel.id} is not initialized', - ); - - /// The channel to display the last message date for. - final Channel channel; - - /// The style of the text displayed - final TextStyle? textStyle; - - /// The formatter to format the date. - final DateFormatter? formatter; - - @override - Widget build(BuildContext context) { - return BetterStreamBuilder( - stream: channel.lastMessageAtStream, - initialData: channel.lastMessageAt, - builder: (context, lastMessageAt) => StreamTimestamp( - date: lastMessageAt.toLocal(), - style: textStyle, - formatter: formatter, - ), - ); - } -} - -/// A widget that displays the subtitle for [StreamChannelListTile]. -class ChannelListTileSubtitle extends StatelessWidget { - /// Creates a new instance of [StreamChannelListTileSubtitle] widget. - ChannelListTileSubtitle({ - super.key, - required this.channel, - this.textStyle, - }) : assert( - channel.state != null, - 'Channel ${channel.id} is not initialized', - ); - - /// The channel to create the subtitle from. - final Channel channel; - - /// The style of the text displayed - final TextStyle? textStyle; - - @override - Widget build(BuildContext context) { - if (channel.isMuted) { - return Row( - crossAxisAlignment: CrossAxisAlignment.end, - children: [ - const StreamSvgIcon(size: 16, icon: StreamSvgIcons.mute), - Expanded( - child: Text( - ' ${context.translations.channelIsMutedText}', - style: textStyle, - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - ), - ], - ); - } - return StreamTypingIndicator( - channel: channel, - style: textStyle, - alternativeWidget: ChannelLastMessageText( - channel: channel, - textStyle: textStyle, - ), - ); - } -} - -/// A widget that displays the last message of a channel. -class ChannelLastMessageText extends StatefulWidget { - /// Creates a new instance of [ChannelLastMessageText] widget. - ChannelLastMessageText({ - super.key, - required this.channel, - this.textStyle, - this.lastMessagePredicate = _defaultLastMessagePredicate, - }) : assert( - channel.state != null, - 'Channel ${channel.id} is not initialized', - ); - - /// The channel to display the last message of. - final Channel channel; - - /// The style of the text displayed - final TextStyle? textStyle; - - /// The predicate to determine if the message should be considered for the - /// last message. - /// - /// This predicate is used to filter out messages that should not be - /// considered for the last message. - final bool Function(Message) lastMessagePredicate; - - // The default predicate to determine if the message should be - // considered for the last message. - static bool _defaultLastMessagePredicate(Message message) { - if (message.isShadowed) return false; - if (message.isDeleted) return false; - if (message.isError) return false; - if (message.isEphemeral) return false; - - return true; - } - - @override - State createState() => _ChannelLastMessageTextState(); -} - -class _ChannelLastMessageTextState extends State { - Message? _currentLastMessage; - - @override - Widget build(BuildContext context) { - final channelState = widget.channel.state; - if (channelState == null) return const Empty(); - - return BetterStreamBuilder<(Draft?, List)>( - stream: CombineLatestStream.combine2( - channelState.draftStream, - channelState.messagesStream, - (draft, messages) => (draft, messages), - ), - initialData: (channelState.draft, channelState.messages), - builder: (context, data) { - final (draft, messages) = data; - - // Prioritize the draft message if it exists. - if (draft?.message case final draftMessage?) { - return StreamDraftMessagePreviewText( - draftMessage: draftMessage, - textStyle: widget.textStyle, - ); - } - - // Otherwise, show the channel last message if it exists. - final message = messages.lastWhereOrNull(widget.lastMessagePredicate); - final latestLastMessage = [message, _currentLastMessage].latest; - - if (latestLastMessage == null) { - return Text( - maxLines: 1, - context.translations.emptyMessagesText, - style: widget.textStyle, - overflow: TextOverflow.ellipsis, - ); - } - - return StreamMessagePreviewText( - message: latestLastMessage, - textStyle: widget.textStyle, - channel: channelState.channelState.channel, - ); - }, - ); - } -} - -extension on Iterable { - Message? get latest { - return reduce((a, b) { - if (a == null) return b; - if (b == null) return a; - - if (a.createdAt.isAfter(b.createdAt)) return a; - return b; - }); - } -} diff --git a/packages/stream_chat_flutter/lib/src/scroll_view/channel_scroll_view/stream_channel_list_view.dart b/packages/stream_chat_flutter/lib/src/scroll_view/channel_scroll_view/stream_channel_list_view.dart index 3541963b3f..cd7a401fc2 100644 --- a/packages/stream_chat_flutter/lib/src/scroll_view/channel_scroll_view/stream_channel_list_view.dart +++ b/packages/stream_chat_flutter/lib/src/scroll_view/channel_scroll_view/stream_channel_list_view.dart @@ -1,9 +1,6 @@ import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; -import 'package:stream_chat_flutter/src/scroll_view/stream_scroll_view_error_widget.dart'; -import 'package:stream_chat_flutter/src/scroll_view/stream_scroll_view_load_more_error.dart'; -import 'package:stream_chat_flutter/src/scroll_view/stream_scroll_view_load_more_indicator.dart'; -import 'package:stream_chat_flutter/src/scroll_view/stream_scroll_view_loading_widget.dart'; +import 'package:stream_chat_flutter/src/scroll_view/channel_scroll_view/stream_channel_list_skeleton_loading.dart'; import 'package:stream_chat_flutter/stream_chat_flutter.dart'; /// Default separator builder for [StreamChannelListView]. @@ -11,16 +8,15 @@ Widget defaultChannelListViewSeparatorBuilder( BuildContext context, List items, int index, -) => - const StreamChannelListSeparator(); +) => const StreamChannelListSeparator(); /// Signature for the item builder that creates the children of the /// [StreamChannelListView]. -typedef StreamChannelListViewIndexedWidgetBuilder - = StreamScrollViewIndexedWidgetBuilder; +typedef StreamChannelListViewIndexedWidgetBuilder = + StreamScrollViewIndexedWidgetBuilder; /// A [ListView] that shows a list of [Channel]s, -/// it uses [StreamChannelListTile] as a default item. +/// it uses [StreamChannelListItem] as a default item. /// /// This is the new version of [StreamChannelListView] that uses /// [StreamChannelListController]. @@ -40,7 +36,7 @@ typedef StreamChannelListViewIndexedWidgetBuilder /// ``` /// /// See also: -/// * [StreamChannelListTile] +/// * [StreamChannelListItem] /// * [StreamChannelListController] class StreamChannelListView extends StatelessWidget { /// Creates a new instance of [StreamChannelListView]. @@ -304,7 +300,7 @@ class StreamChannelListView extends StatelessWidget { final onTap = onChannelTap; final onLongPress = onChannelLongPress; - final streamChannelListTile = StreamChannelListTile( + final streamChannelListTile = StreamChannelListItem( channel: channel, onTap: onTap == null ? null : () => onTap(channel), onLongPress: onLongPress == null ? null : () => onLongPress(channel), @@ -318,42 +314,25 @@ class StreamChannelListView extends StatelessWidget { ) ?? streamChannelListTile; }, - emptyBuilder: (context) { - final chatThemeData = StreamChatTheme.of(context); - return emptyBuilder?.call(context) ?? - Center( - child: Padding( - padding: const EdgeInsets.all(8), - child: StreamScrollViewEmptyWidget( - emptyIcon: StreamSvgIcon( - size: 148, - icon: StreamSvgIcons.message, - color: chatThemeData.colorTheme.disabled, - ), - emptyTitle: Text( - context.translations.letsStartChattingLabel, - style: chatThemeData.textTheme.headline, - ), - ), - ), - ); - }, - loadMoreErrorBuilder: (context, error) => - StreamScrollViewLoadMoreError.list( + emptyBuilder: (context) => + emptyBuilder?.call(context) ?? + Center( + child: StreamScrollViewEmptyWidget( + emptyIcon: Icon(context.streamIcons.messageBubblesLarge), + emptyTitle: Text(context.translations.noConversationsYetText), + ), + ), + loadMoreErrorBuilder: (context, error) => StreamScrollViewLoadMoreError.list( onTap: controller.retry, error: Text(context.translations.loadingChannelsError), ), - loadMoreIndicatorBuilder: (context) => const Center( + loadMoreIndicatorBuilder: (context) => Center( child: Padding( - padding: EdgeInsets.all(16), - child: StreamScrollViewLoadMoreIndicator(), + padding: const EdgeInsets.all(16), + child: StreamLoadingSpinner(), ), ), - loadingBuilder: (context) => - loadingBuilder?.call(context) ?? - const Center( - child: StreamScrollViewLoadingWidget(), - ), + loadingBuilder: (context) => loadingBuilder?.call(context) ?? const StreamChannelListSkeletonLoading(), errorBuilder: (context, error) => errorBuilder?.call(context, error) ?? Center( @@ -367,18 +346,14 @@ class StreamChannelListView extends StatelessWidget { } /// A widget that is used to display a separator between -/// [StreamChannelListTile] items. +/// [StreamChannelListItem] items. class StreamChannelListSeparator extends StatelessWidget { /// Creates a new instance of [StreamChannelListSeparator]. const StreamChannelListSeparator({super.key}); @override Widget build(BuildContext context) { - final effect = StreamChatTheme.of(context).colorTheme.borderBottom; - return Container( - height: 1, - // ignore: deprecated_member_use - color: effect.color!.withOpacity(effect.alpha ?? 1.0), - ); + final colorScheme = context.streamColorScheme; + return Divider(height: 1, color: colorScheme.borderSubtle); } } diff --git a/packages/stream_chat_flutter/lib/src/scroll_view/draft_scroll_view/stream_draft_list_view.dart b/packages/stream_chat_flutter/lib/src/scroll_view/draft_scroll_view/stream_draft_list_view.dart deleted file mode 100644 index 1c59029017..0000000000 --- a/packages/stream_chat_flutter/lib/src/scroll_view/draft_scroll_view/stream_draft_list_view.dart +++ /dev/null @@ -1,377 +0,0 @@ -import 'package:flutter/gestures.dart'; -import 'package:flutter/material.dart'; -import 'package:stream_chat_flutter/src/scroll_view/stream_scroll_view_error_widget.dart'; -import 'package:stream_chat_flutter/src/scroll_view/stream_scroll_view_load_more_error.dart'; -import 'package:stream_chat_flutter/src/scroll_view/stream_scroll_view_load_more_indicator.dart'; -import 'package:stream_chat_flutter/src/scroll_view/stream_scroll_view_loading_widget.dart'; -import 'package:stream_chat_flutter/stream_chat_flutter.dart'; - -/// Default separator builder for [StreamDraftListView]. -Widget defaultDraftListViewSeparatorBuilder( - BuildContext context, - List drafts, - int index, -) => - const StreamDraftListSeparator(); - -/// Signature for the item builder that creates the children of the -/// [StreamDraftListView]. -typedef StreamDraftListViewIndexedWidgetBuilder - = StreamScrollViewIndexedWidgetBuilder; - -/// {@template streamDraftListView} -/// A [ListView] that shows a list of [Draft]'s. It uses a -/// [StreamDraftListController] to load the drafts in paginated form. -/// -/// Example: -/// -/// ```dart -/// StreamDraftListView( -/// controller: controller, -/// onDraftTap: (draft) { -/// // Handle draft tap event -/// }, -/// onDraftLongPress: (draft) { -/// // Handle draft long press event -/// }, -/// ) -/// ``` -/// -/// See also: -/// * [StreamDraftListTile] -/// * [StreamDraftListController] -/// {@endtemplate} -class StreamDraftListView extends StatelessWidget { - /// {@macro streamDraftListView} - const StreamDraftListView({ - super.key, - required this.controller, - this.itemBuilder, - this.separatorBuilder = defaultDraftListViewSeparatorBuilder, - this.emptyBuilder, - this.loadingBuilder, - this.errorBuilder, - this.onDraftTap, - this.onDraftLongPress, - this.loadMoreTriggerIndex = 3, - this.scrollDirection = Axis.vertical, - this.reverse = false, - this.scrollController, - this.primary, - this.physics, - this.shrinkWrap = false, - this.padding, - this.addAutomaticKeepAlives = true, - this.addRepaintBoundaries = true, - this.addSemanticIndexes = true, - this.cacheExtent, - this.dragStartBehavior = DragStartBehavior.start, - this.keyboardDismissBehavior = ScrollViewKeyboardDismissBehavior.manual, - this.restorationId, - this.clipBehavior = Clip.hardEdge, - }); - - /// The [StreamDraftListController] used to control the drafts in the list. - final StreamDraftListController controller; - - /// A builder that is called to build items in the [ListView]. - final StreamDraftListViewIndexedWidgetBuilder? itemBuilder; - - /// A builder that is called to build the list separator. - final PagedValueScrollViewIndexedWidgetBuilder separatorBuilder; - - /// A builder that is called to build the empty state of the list. - final WidgetBuilder? emptyBuilder; - - /// A builder that is called to build the loading state of the list. - final WidgetBuilder? loadingBuilder; - - /// A builder that is called to build the error state of the list. - final Widget Function(BuildContext, StreamChatError)? errorBuilder; - - /// Called when the user taps this list tile. - final void Function(Draft)? onDraftTap; - - /// Called when the user long-presses on this list tile. - final void Function(Draft)? onDraftLongPress; - - /// The index to take into account when triggering [controller.loadMore]. - final int loadMoreTriggerIndex; - - /// {@template flutter.widgets.scroll_view.scrollDirection} - /// The axis along which the scroll view scrolls. - /// - /// Defaults to [Axis.vertical]. - /// {@endtemplate} - final Axis scrollDirection; - - /// The amount of space by which to inset the children. - final EdgeInsetsGeometry? padding; - - /// Whether to wrap each child in an [AutomaticKeepAlive]. - /// - /// Typically, children in lazy list are wrapped in [AutomaticKeepAlive] - /// widgets so that children can use [KeepAliveNotification]s to preserve - /// their state when they would otherwise be garbage collected off-screen. - /// - /// This feature (and [addRepaintBoundaries]) must be disabled if the children - /// are going to manually maintain their [KeepAlive] state. It may also be - /// more efficient to disable this feature if it is known ahead of time that - /// none of the children will ever try to keep themselves alive. - /// - /// Defaults to true. - final bool addAutomaticKeepAlives; - - /// Whether to wrap each child in a [RepaintBoundary]. - /// - /// Typically, children in a scrolling container are wrapped in repaint - /// boundaries so that they do not need to be repainted as the list scrolls. - /// If the children are easy to repaint (e.g., solid color blocks or a short - /// snippet of text), it might be more efficient to not add a repaint boundary - /// and simply repaint the children during scrolling. - /// - /// Defaults to true. - final bool addRepaintBoundaries; - - /// Whether to wrap each child in an [IndexedSemantics]. - /// - /// Typically, children in a scrolling container must be annotated with a - /// semantic index in order to generate the correct accessibility - /// announcements. This should only be set to false if the indexes have - /// already been provided by an [IndexedSemantics] widget. - /// - /// Defaults to true. - /// - /// See also: - /// - /// * [IndexedSemantics], for an explanation of how to manually - /// provide semantic indexes. - final bool addSemanticIndexes; - - /// {@template flutter.widgets.scroll_view.reverse} - /// Whether the scroll view scrolls in the reading direction. - /// - /// For example, if [scrollDirection] is [Axis.vertical], then the scroll view - /// scrolls from top to bottom when [reverse] is false and from bottom to top - /// when [reverse] is true. - /// - /// Defaults to false. - /// {@endtemplate} - final bool reverse; - - /// {@template flutter.widgets.scroll_view.controller} - /// An object that can be used to control the position to which this scroll - /// view is scrolled. - /// - /// Must be null if [primary] is true. - /// - /// A [ScrollController] serves several purposes. It can be used to control - /// the initial scroll position (see [ScrollController.initialScrollOffset]). - /// It can be used to control whether the scroll view should automatically - /// save and restore its scroll position in the [PageStorage] (see - /// [ScrollController.keepScrollOffset]). It can be used to read the current - /// scroll position (see [ScrollController.offset]), or change it (see - /// [ScrollController.animateTo]). - /// {@endtemplate} - final ScrollController? scrollController; - - /// {@template flutter.widgets.scroll_view.primary} - /// Whether this is the primary scroll view associated with the parent - /// [PrimaryScrollController]. - /// - /// When this is true, the scroll view is scrollable even if it does not have - /// sufficient content to actually scroll. Otherwise, by default the user can - /// only scroll the view if it has sufficient content. See [physics]. - /// - /// Also when true, the scroll view is used for default [ScrollAction]s. If a - /// ScrollAction is not handled by an otherwise focused part of the - /// application, the ScrollAction will be evaluated using this scroll view, - /// for example, when executing [Shortcuts] key events like page up and down. - /// - /// On iOS, this also identifies the scroll view that will scroll to top in - /// response to a tap in the status bar. - /// {@endtemplate} - /// - /// Defaults to true when [scrollController] is null. - final bool? primary; - - /// {@template flutter.widgets.scroll_view.shrinkWrap} - /// Whether the extent of the scroll view in the [scrollDirection] should be - /// determined by the contents being viewed. - /// - /// If the scroll view does not shrink wrap, then the scroll view will expand - /// to the maximum allowed size in the [scrollDirection]. If the scroll view - /// has unbounded constraints in the [scrollDirection], then [shrinkWrap] must - /// be true. - /// - /// Shrink wrapping the content of the scroll view is significantly more - /// expensive than expanding to the maximum allowed size because the content - /// can expand and contract during scrolling, which means the size of the - /// scroll view needs to be recomputed whenever the scroll position changes. - /// - /// Defaults to false. - /// {@endtemplate} - final bool shrinkWrap; - - /// {@template flutter.widgets.scroll_view.physics} - /// How the scroll view should respond to user input. - /// - /// For example, determines how the scroll view continues to animate after the - /// user stops dragging the scroll view. - /// - /// Defaults to matching platform conventions. Furthermore, if [primary] is - /// false, then the user cannot scroll if there is insufficient content to - /// scroll, while if [primary] is true, they can always attempt to scroll. - /// - /// To force the scroll view to always be scrollable even if there is - /// insufficient content, as if [primary] was true but without necessarily - /// setting it to true, provide an [AlwaysScrollableScrollPhysics] physics - /// object, as in: - /// - /// ```dart - /// physics: const AlwaysScrollableScrollPhysics(), - /// ``` - /// - /// To force the scroll view to use the default platform conventions and not - /// be scrollable if there is insufficient content, regardless of the value of - /// [primary], provide an explicit [ScrollPhysics] object, as in: - /// - /// ```dart - /// physics: const ScrollPhysics(), - /// ``` - /// - /// The physics can be changed dynamically (by providing a new object in a - /// subsequent build), but new physics will only take effect if the _class_ of - /// the provided object changes. Merely constructing a new instance with a - /// different configuration is insufficient to cause the physics to be - /// reapplied. (This is because the final object used is generated - /// dynamically, which can be relatively expensive, and it would be - /// inefficient to speculatively create this object each frame to see if the - /// physics should be updated.) - /// {@endtemplate} - /// - /// If an explicit [ScrollBehavior] is provided to [scrollBehavior], the - /// [ScrollPhysics] provided by that behavior will take precedence after - /// [physics]. - final ScrollPhysics? physics; - - /// {@macro flutter.rendering.RenderViewportBase.cacheExtent} - final double? cacheExtent; - - /// {@macro flutter.widgets.scrollable.dragStartBehavior} - final DragStartBehavior dragStartBehavior; - - /// {@template flutter.widgets.scroll_view.keyboardDismissBehavior} - /// [ScrollViewKeyboardDismissBehavior] the defines how this [ScrollView] will - /// dismiss the keyboard automatically. - /// {@endtemplate} - final ScrollViewKeyboardDismissBehavior keyboardDismissBehavior; - - /// {@macro flutter.widgets.scrollable.restorationId} - final String? restorationId; - - /// {@macro flutter.material.Material.clipBehavior} - /// - /// Defaults to [Clip.hardEdge]. - final Clip clipBehavior; - - @override - Widget build(BuildContext context) { - return PagedValueListView( - scrollDirection: scrollDirection, - padding: padding, - physics: physics, - reverse: reverse, - controller: controller, - scrollController: scrollController, - primary: primary, - shrinkWrap: shrinkWrap, - addAutomaticKeepAlives: addAutomaticKeepAlives, - addRepaintBoundaries: addRepaintBoundaries, - addSemanticIndexes: addSemanticIndexes, - keyboardDismissBehavior: keyboardDismissBehavior, - restorationId: restorationId, - dragStartBehavior: dragStartBehavior, - cacheExtent: cacheExtent, - clipBehavior: clipBehavior, - loadMoreTriggerIndex: loadMoreTriggerIndex, - separatorBuilder: separatorBuilder, - itemBuilder: (context, drafts, index) { - final draft = drafts[index]; - final currentUser = StreamChat.of(context).currentUser; - final onTap = onDraftTap; - final onLongPress = onDraftLongPress; - - final tile = StreamDraftListTile( - draft: draft, - currentUser: currentUser, - onTap: onTap == null ? null : () => onTap(draft), - onLongPress: onLongPress == null ? null : () => onLongPress(draft), - ); - - return itemBuilder?.call(context, drafts, index, tile) ?? tile; - }, - emptyBuilder: (context) { - final chatThemeData = StreamChatTheme.of(context); - return emptyBuilder?.call(context) ?? - Center( - child: Padding( - padding: const EdgeInsets.all(8), - child: StreamScrollViewEmptyWidget( - emptyIcon: StreamSvgIcon( - size: 148, - icon: StreamSvgIcons.edit, - color: chatThemeData.colorTheme.disabled, - ), - emptyTitle: Text( - context.translations.emptyMessagesText, - style: chatThemeData.textTheme.headline, - ), - ), - ), - ); - }, - loadMoreErrorBuilder: (context, error) => - StreamScrollViewLoadMoreError.list( - onTap: controller.retry, - error: Text(context.translations.loadingMessagesError), - ), - loadMoreIndicatorBuilder: (context) => const Center( - child: Padding( - padding: EdgeInsets.all(16), - child: StreamScrollViewLoadMoreIndicator(), - ), - ), - loadingBuilder: (context) => - loadingBuilder?.call(context) ?? - const Center( - child: StreamScrollViewLoadingWidget(), - ), - errorBuilder: (context, error) => - errorBuilder?.call(context, error) ?? - Center( - child: StreamScrollViewErrorWidget( - errorTitle: Text(context.translations.loadingMessagesError), - onRetryPressed: controller.refresh, - ), - ), - ); - } -} - -/// A widget that is used to display a separator between -/// [StreamDraftListTile] items. -class StreamDraftListSeparator extends StatelessWidget { - /// Creates a new instance of [StreamDraftListSeparator]. - const StreamDraftListSeparator({super.key}); - - @override - Widget build(BuildContext context) { - final effect = StreamChatTheme.of(context).colorTheme.borderBottom; - return Container( - height: 1, - // ignore: deprecated_member_use - color: effect.color!.withOpacity(effect.alpha ?? 1.0), - ); - } -} diff --git a/packages/stream_chat_flutter/lib/src/scroll_view/member_scroll_view/stream_member_grid_view.dart b/packages/stream_chat_flutter/lib/src/scroll_view/member_scroll_view/stream_member_grid_view.dart index adf97a7860..82fce3b0fe 100644 --- a/packages/stream_chat_flutter/lib/src/scroll_view/member_scroll_view/stream_member_grid_view.dart +++ b/packages/stream_chat_flutter/lib/src/scroll_view/member_scroll_view/stream_member_grid_view.dart @@ -1,19 +1,13 @@ import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; -import 'package:stream_chat_flutter/src/scroll_view/stream_scroll_view_error_widget.dart'; -import 'package:stream_chat_flutter/src/scroll_view/stream_scroll_view_load_more_error.dart'; -import 'package:stream_chat_flutter/src/scroll_view/stream_scroll_view_load_more_indicator.dart'; -import 'package:stream_chat_flutter/src/scroll_view/stream_scroll_view_loading_widget.dart'; import 'package:stream_chat_flutter/stream_chat_flutter.dart'; /// Default grid delegate for [StreamMemberGridView]. -const defaultMemberGridViewDelegate = - SliverGridDelegateWithFixedCrossAxisCount(crossAxisCount: 4); +const defaultMemberGridViewDelegate = SliverGridDelegateWithFixedCrossAxisCount(crossAxisCount: 4); /// Signature for the item builder that creates the children of the /// [StreamMemberGridView]. -typedef StreamMemberGridViewIndexedWidgetBuilder - = StreamScrollViewIndexedWidgetBuilder; +typedef StreamMemberGridViewIndexedWidgetBuilder = StreamScrollViewIndexedWidgetBuilder; /// Signature for the member grid tile, currently equal to [StreamUserGridTile]. typedef StreamMemberGridTile = StreamUserGridTile; @@ -345,38 +339,25 @@ class StreamMemberGridView extends StatelessWidget { ) ?? streamMemberGridTile; }, - emptyBuilder: (context) { - final chatThemeData = StreamChatTheme.of(context); - return emptyBuilder?.call(context) ?? - Center( - child: Padding( - padding: const EdgeInsets.all(8), - child: StreamScrollViewEmptyWidget( - emptyIcon: StreamSvgIcon( - size: 148, - icon: StreamSvgIcons.user, - color: chatThemeData.colorTheme.disabled, - ), - emptyTitle: Text( - context.translations.noUsersLabel, - style: chatThemeData.textTheme.headline, - ), - ), - ), - ); - }, - loadMoreErrorBuilder: (context, error) => - StreamScrollViewLoadMoreError.grid( + emptyBuilder: (context) => + emptyBuilder?.call(context) ?? + Center( + child: StreamScrollViewEmptyWidget( + emptyIcon: Icon(context.streamIcons.user), + emptyTitle: Text(context.translations.noUsersLabel), + ), + ), + loadMoreErrorBuilder: (context, error) => StreamScrollViewLoadMoreError.grid( onTap: controller.retry, error: Text( context.translations.loadingUsersError, textAlign: TextAlign.center, ), ), - loadMoreIndicatorBuilder: (context) => const Center( + loadMoreIndicatorBuilder: (context) => Center( child: Padding( - padding: EdgeInsets.all(16), - child: StreamScrollViewLoadMoreIndicator(), + padding: const EdgeInsets.all(16), + child: StreamLoadingSpinner(), ), ), loadingBuilder: (context) => diff --git a/packages/stream_chat_flutter/lib/src/scroll_view/member_scroll_view/stream_member_list_view.dart b/packages/stream_chat_flutter/lib/src/scroll_view/member_scroll_view/stream_member_list_view.dart index fcdad5379e..a51845dd61 100644 --- a/packages/stream_chat_flutter/lib/src/scroll_view/member_scroll_view/stream_member_list_view.dart +++ b/packages/stream_chat_flutter/lib/src/scroll_view/member_scroll_view/stream_member_list_view.dart @@ -1,9 +1,5 @@ import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; -import 'package:stream_chat_flutter/src/scroll_view/stream_scroll_view_error_widget.dart'; -import 'package:stream_chat_flutter/src/scroll_view/stream_scroll_view_load_more_error.dart'; -import 'package:stream_chat_flutter/src/scroll_view/stream_scroll_view_load_more_indicator.dart'; -import 'package:stream_chat_flutter/src/scroll_view/stream_scroll_view_loading_widget.dart'; import 'package:stream_chat_flutter/stream_chat_flutter.dart'; /// Default separator builder for [StreamMemberListView]. @@ -11,13 +7,11 @@ Widget defaultMemberListViewSeparatorBuilder( BuildContext context, List members, int index, -) => - const StreamUserListSeparator(); +) => const StreamUserListSeparator(); /// Signature for the item builder that creates the children of the /// [StreamMemberListView]. -typedef StreamMemberListViewIndexedWidgetBuilder - = StreamScrollViewIndexedWidgetBuilder; +typedef StreamMemberListViewIndexedWidgetBuilder = StreamScrollViewIndexedWidgetBuilder; /// Signature for the member grid tile, currently equal to [StreamUserListTile]. typedef StreamMemberListTile = StreamUserListTile; @@ -278,86 +272,73 @@ class StreamMemberListView extends StatelessWidget { @override Widget build(BuildContext context) => PagedValueListView( - scrollDirection: scrollDirection, - padding: padding, - physics: physics, - reverse: reverse, - controller: controller, - scrollController: scrollController, - primary: primary, - shrinkWrap: shrinkWrap, - addAutomaticKeepAlives: addAutomaticKeepAlives, - addRepaintBoundaries: addRepaintBoundaries, - addSemanticIndexes: addSemanticIndexes, - keyboardDismissBehavior: keyboardDismissBehavior, - restorationId: restorationId, - dragStartBehavior: dragStartBehavior, - cacheExtent: cacheExtent, - clipBehavior: clipBehavior, - loadMoreTriggerIndex: loadMoreTriggerIndex, - separatorBuilder: separatorBuilder, - itemBuilder: (context, members, index) { - final member = members[index]; - final onTap = onMemberTap; - final onLongPress = onMemberLongPress; + scrollDirection: scrollDirection, + padding: padding, + physics: physics, + reverse: reverse, + controller: controller, + scrollController: scrollController, + primary: primary, + shrinkWrap: shrinkWrap, + addAutomaticKeepAlives: addAutomaticKeepAlives, + addRepaintBoundaries: addRepaintBoundaries, + addSemanticIndexes: addSemanticIndexes, + keyboardDismissBehavior: keyboardDismissBehavior, + restorationId: restorationId, + dragStartBehavior: dragStartBehavior, + cacheExtent: cacheExtent, + clipBehavior: clipBehavior, + loadMoreTriggerIndex: loadMoreTriggerIndex, + separatorBuilder: separatorBuilder, + itemBuilder: (context, members, index) { + final member = members[index]; + final onTap = onMemberTap; + final onLongPress = onMemberLongPress; - final streamUserListTile = StreamMemberListTile( - user: member.user!, - onTap: onTap == null ? null : () => onTap(member), - onLongPress: onLongPress == null ? null : () => onLongPress(member), - ); + final streamUserListTile = StreamMemberListTile( + user: member.user!, + onTap: onTap == null ? null : () => onTap(member), + onLongPress: onLongPress == null ? null : () => onLongPress(member), + ); - return itemBuilder?.call( - context, - members, - index, - streamUserListTile, - ) ?? - streamUserListTile; - }, - emptyBuilder: (context) { - final chatThemeData = StreamChatTheme.of(context); - return emptyBuilder?.call(context) ?? - Center( - child: Padding( - padding: const EdgeInsets.all(8), - child: StreamScrollViewEmptyWidget( - emptyIcon: StreamSvgIcon( - size: 148, - icon: StreamSvgIcons.user, - color: chatThemeData.colorTheme.disabled, - ), - emptyTitle: Text( - context.translations.noUsersLabel, - style: chatThemeData.textTheme.headline, - ), - ), - ), - ); - }, - loadMoreErrorBuilder: (context, error) => - StreamScrollViewLoadMoreError.list( - onTap: controller.retry, - error: Text(context.translations.loadingUsersError), + return itemBuilder?.call( + context, + members, + index, + streamUserListTile, + ) ?? + streamUserListTile; + }, + emptyBuilder: (context) => + emptyBuilder?.call(context) ?? + Center( + child: StreamScrollViewEmptyWidget( + emptyIcon: Icon(context.streamIcons.user), + emptyTitle: Text(context.translations.noUsersLabel), + ), ), - loadMoreIndicatorBuilder: (context) => const Center( - child: Padding( - padding: EdgeInsets.all(16), - child: StreamScrollViewLoadMoreIndicator(), + loadMoreErrorBuilder: (context, error) => StreamScrollViewLoadMoreError.list( + onTap: controller.retry, + error: Text(context.translations.loadingUsersError), + ), + loadMoreIndicatorBuilder: (context) => Center( + child: Padding( + padding: const EdgeInsets.all(16), + child: StreamLoadingSpinner(), + ), + ), + loadingBuilder: (context) => + loadingBuilder?.call(context) ?? + const Center( + child: StreamScrollViewLoadingWidget(), + ), + errorBuilder: (context, error) => + errorBuilder?.call(context, error) ?? + Center( + child: StreamScrollViewErrorWidget( + errorTitle: Text(context.translations.loadingUsersError), + onRetryPressed: controller.refresh, ), ), - loadingBuilder: (context) => - loadingBuilder?.call(context) ?? - const Center( - child: StreamScrollViewLoadingWidget(), - ), - errorBuilder: (context, error) => - errorBuilder?.call(context, error) ?? - Center( - child: StreamScrollViewErrorWidget( - errorTitle: Text(context.translations.loadingUsersError), - onRetryPressed: controller.refresh, - ), - ), - ); + ); } diff --git a/packages/stream_chat_flutter/lib/src/scroll_view/message_search_scroll_view/stream_message_search_grid_view.dart b/packages/stream_chat_flutter/lib/src/scroll_view/message_search_scroll_view/stream_message_search_grid_view.dart deleted file mode 100644 index e7a939f942..0000000000 --- a/packages/stream_chat_flutter/lib/src/scroll_view/message_search_scroll_view/stream_message_search_grid_view.dart +++ /dev/null @@ -1,364 +0,0 @@ -import 'package:flutter/gestures.dart'; -import 'package:flutter/material.dart'; -import 'package:stream_chat_flutter/src/scroll_view/stream_scroll_view_error_widget.dart'; -import 'package:stream_chat_flutter/src/scroll_view/stream_scroll_view_load_more_error.dart'; -import 'package:stream_chat_flutter/src/scroll_view/stream_scroll_view_load_more_indicator.dart'; -import 'package:stream_chat_flutter/src/scroll_view/stream_scroll_view_loading_widget.dart'; -import 'package:stream_chat_flutter/stream_chat_flutter.dart'; - -/// Default grid delegate for [StreamMessageSearchGridView]. -const defaultMessageSearchGridViewDelegate = - SliverGridDelegateWithFixedCrossAxisCount(crossAxisCount: 4); - -/// Signature for the item builder that creates the children of the -/// [StreamMessageSearchGridView]. -typedef StreamMessageSearchGridViewIndexedWidgetBuilder - = PagedValueScrollViewIndexedWidgetBuilder; - -/// A [GridView] that shows a grid of [GetMessageResponse]s, -/// it uses [StreamMessageSearchGridTile] as a default item. -/// -/// Example: -/// -/// ```dart -/// StreamMessageSearchGridView( -/// controller: controller, -/// itemBuilder: (context, messageResponses, index) { -/// return GridTile(message: messageResponses[index]); -/// }, -/// ) -/// ``` -/// -/// See also: -/// * [StreamUserListTile] -/// * [StreamUserListController] -class StreamMessageSearchGridView extends StatelessWidget { - /// Creates a new instance of [StreamMessageSearchGridView]. - const StreamMessageSearchGridView({ - super.key, - required this.controller, - required this.itemBuilder, - this.gridDelegate = defaultMessageSearchGridViewDelegate, - this.emptyBuilder, - this.loadMoreErrorBuilder, - this.loadMoreIndicatorBuilder, - this.loadingBuilder, - this.errorBuilder, - this.loadMoreTriggerIndex = 3, - this.scrollDirection = Axis.vertical, - this.reverse = false, - this.scrollController, - this.primary, - this.physics, - this.shrinkWrap = false, - this.padding, - this.addAutomaticKeepAlives = true, - this.addRepaintBoundaries = true, - this.addSemanticIndexes = true, - this.cacheExtent, - this.semanticChildCount, - this.dragStartBehavior = DragStartBehavior.start, - this.keyboardDismissBehavior = ScrollViewKeyboardDismissBehavior.manual, - this.restorationId, - this.clipBehavior = Clip.hardEdge, - }); - - /// The [StreamUserListController] used to control the grid of users. - final StreamMessageSearchListController controller; - - /// A delegate that controls the layout of the children within - /// the [PagedValueGridView]. - final SliverGridDelegate gridDelegate; - - /// A builder that is called to build items in the [PagedValueGridView]. - /// - /// The `value` parameter is the [GetMessageBuilder] - /// at this position in the grid. - final StreamMessageSearchGridViewIndexedWidgetBuilder itemBuilder; - - /// A builder that is called to build the empty state of the grid. - final WidgetBuilder? emptyBuilder; - - /// A builder that is called to build the load more error state of the grid. - final PagedValueScrollViewLoadMoreErrorBuilder? loadMoreErrorBuilder; - - /// A builder that is called to build the load more indicator of the grid. - final WidgetBuilder? loadMoreIndicatorBuilder; - - /// A builder that is called to build the loading state of the grid. - final WidgetBuilder? loadingBuilder; - - /// A builder that is called to build the error state of the grid. - final Widget Function(BuildContext, StreamChatError)? errorBuilder; - - /// The index to take into account when triggering [controller.loadMore]. - final int loadMoreTriggerIndex; - - /// {@template flutter.widgets.scroll_view.scrollDirection} - /// The axis along which the scroll view scrolls. - /// - /// Defaults to [Axis.vertical]. - /// {@endtemplate} - final Axis scrollDirection; - - /// {@template flutter.widgets.scroll_view.reverse} - /// Whether the scroll view scrolls in the reading direction. - /// - /// For example, if the reading direction is left-to-right and - /// [scrollDirection] is [Axis.horizontal], then the scroll view scrolls from - /// left to right when [reverse] is false and from right to left when - /// [reverse] is true. - /// - /// Similarly, if [scrollDirection] is [Axis.vertical], then the scroll view - /// scrolls from top to bottom when [reverse] is false and from bottom to top - /// when [reverse] is true. - /// - /// Defaults to false. - /// {@endtemplate} - final bool reverse; - - /// {@template flutter.widgets.scroll_view.controller} - /// An object that can be used to control the position to which this scroll - /// view is scrolled. - /// - /// Must be null if [primary] is true. - /// - /// A [ScrollController] serves several purposes. It can be used to control - /// the initial scroll position (see [ScrollController.initialScrollOffset]). - /// It can be used to control whether the scroll view should automatically - /// save and restore its scroll position in the [PageStorage] (see - /// [ScrollController.keepScrollOffset]). It can be used to read the current - /// scroll position (see [ScrollController.offset]), or change it (see - /// [ScrollController.animateTo]). - /// {@endtemplate} - final ScrollController? scrollController; - - /// {@template flutter.widgets.scroll_view.primary} - /// Whether this is the primary scroll view associated with the parent - /// [PrimaryScrollController]. - /// - /// When this is true, the scroll view is scrollable even if it does not have - /// sufficient content to actually scroll. Otherwise, by default the user can - /// only scroll the view if it has sufficient content. See [physics]. - /// - /// Also when true, the scroll view is used for default [ScrollAction]s. If a - /// ScrollAction is not handled by - /// an otherwise focused part of the application, - /// the ScrollAction will be evaluated using this scroll view, for example, - /// when executing [Shortcuts] key events like page up and down. - /// - /// On iOS, this also identifies the scroll view that will scroll to top in - /// response to a tap in the status bar. - /// {@endtemplate} - /// - /// Defaults to true when [scrollDirection] is [Axis.vertical] and - /// [controller] is null. - final bool? primary; - - /// {@template flutter.widgets.scroll_view.physics} - /// How the scroll view should respond to user input. - /// - /// For example, determines how the scroll view continues to animate after the - /// user stops dragging the scroll view. - /// - /// Defaults to matching platform conventions. Furthermore, if [primary] is - /// false, then the user cannot scroll if there is insufficient content to - /// scroll, while if [primary] is true, they can always attempt to scroll. - /// - /// To force the scroll view to always be scrollable even if there is - /// insufficient content, as if [primary] was true but without necessarily - /// setting it to true, provide an [AlwaysScrollableScrollPhysics] physics - /// object, as in: - /// - /// ```dart - /// physics: const AlwaysScrollableScrollPhysics(), - /// ``` - /// - /// To force the scroll view to use the default platform conventions and not - /// be scrollable if there is insufficient content, regardless of the value of - /// [primary], provide an explicit [ScrollPhysics] object, as in: - /// - /// ```dart - /// physics: const ScrollPhysics(), - /// ``` - /// - /// The physics can be changed dynamically (by providing a new object in a - /// subsequent build), but new physics will only take effect if the _class_ of - /// the provided object changes. Merely constructing a new instance with a - /// different configuration is insufficient to cause the physics to be - /// reapplied. (This is because the final object used is generated - /// dynamically, which can be relatively expensive, and it would be - /// inefficient to speculatively create this object each frame to see if the - /// physics should be updated.) - /// {@endtemplate} - /// - /// If an explicit [ScrollBehavior] is provided to [scrollBehavior], the - /// [ScrollPhysics] provided by that behavior will take precedence after - /// [physics]. - final ScrollPhysics? physics; - - /// {@template flutter.widgets.scroll_view.shrinkWrap} - /// Whether the extent of the scroll view in the [scrollDirection] should be - /// determined by the contents being viewed. - /// - /// If the scroll view does not shrink wrap, then the scroll view will expand - /// to the maximum allowed size in the [scrollDirection]. If the scroll view - /// has unbounded constraints in the [scrollDirection], then [shrinkWrap] must - /// be true. - /// - /// Shrink wrapping the content of the scroll view is significantly more - /// expensive than expanding to the maximum allowed size because the content - /// can expand and contract during scrolling, which means the size of the - /// scroll view needs to be recomputed whenever the scroll position changes. - /// - /// Defaults to false. - /// {@endtemplate} - final bool shrinkWrap; - - /// The amount of space by which to inset the children. - final EdgeInsetsGeometry? padding; - - /// Whether to wrap each child in an [AutomaticKeepAlive]. - /// - /// Typically, children in lazy list are wrapped in [AutomaticKeepAlive] - /// widgets so that children can use [KeepAliveNotification]s to preserve - /// their state when they would otherwise be garbage collected off-screen. - /// - /// This feature (and [addRepaintBoundaries]) must be disabled if the children - /// are going to manually maintain their [KeepAlive] state. It may also be - /// more efficient to disable this feature if it is known ahead of time that - /// none of the children will ever try to keep themselves alive. - /// - /// Defaults to true. - final bool addAutomaticKeepAlives; - - /// Whether to wrap each child in a [RepaintBoundary]. - /// - /// Typically, children in a scrolling container are wrapped in repaint - /// boundaries so that they do not need to be repainted as the list scrolls. - /// If the children are easy to repaint (e.g., solid color blocks or a short - /// snippet of text), it might be more efficient to not add a repaint boundary - /// and simply repaint the children during scrolling. - /// - /// Defaults to true. - final bool addRepaintBoundaries; - - /// Whether to wrap each child in an [IndexedSemantics]. - /// - /// Typically, children in a scrolling container must be annotated with a - /// semantic index in order to generate the correct accessibility - /// announcements. This should only be set to false if the indexes have - /// already been provided by an [IndexedSemantics] widget. - /// - /// Defaults to true. - /// - /// See also: - /// - /// * [IndexedSemantics], for an explanation of how to manually - /// provide semantic indexes. - final bool addSemanticIndexes; - - /// {@macro flutter.rendering.RenderViewportBase.cacheExtent} - final double? cacheExtent; - - /// The number of children that will contribute semantic information. - /// - /// Some subtypes of [ScrollView] can infer this value automatically. For - /// example [ListView] will use the number of widgets in the child list, - /// while the [ListView.separated] constructor will use half that amount. - /// - /// For [CustomScrollView] and other types which do not receive a builder - /// or list of widgets, the child count must be explicitly provided. If the - /// number is unknown or unbounded this should be left unset or set to null. - /// - /// See also: - /// - /// * [SemanticsConfiguration.scrollChildCount], - /// the corresponding semantics property. - final int? semanticChildCount; - - /// {@macro flutter.widgets.scrollable.dragStartBehavior} - final DragStartBehavior dragStartBehavior; - - /// {@template flutter.widgets.scroll_view.keyboardDismissBehavior} - /// [ScrollViewKeyboardDismissBehavior] the defines how this [ScrollView] will - /// dismiss the keyboard automatically. - /// {@endtemplate} - final ScrollViewKeyboardDismissBehavior keyboardDismissBehavior; - - /// {@macro flutter.widgets.scrollable.restorationId} - final String? restorationId; - - /// {@macro flutter.material.Material.clipBehavior} - /// - /// Defaults to [Clip.hardEdge]. - final Clip clipBehavior; - - @override - Widget build(BuildContext context) { - return PagedValueGridView( - scrollDirection: scrollDirection, - reverse: reverse, - controller: controller, - primary: primary, - physics: physics, - shrinkWrap: shrinkWrap, - padding: padding, - scrollController: scrollController, - addAutomaticKeepAlives: addAutomaticKeepAlives, - addRepaintBoundaries: addRepaintBoundaries, - addSemanticIndexes: addSemanticIndexes, - cacheExtent: cacheExtent, - semanticChildCount: semanticChildCount, - dragStartBehavior: dragStartBehavior, - keyboardDismissBehavior: keyboardDismissBehavior, - restorationId: restorationId, - clipBehavior: clipBehavior, - gridDelegate: gridDelegate, - itemBuilder: itemBuilder, - emptyBuilder: (context) { - final chatThemeData = StreamChatTheme.of(context); - return emptyBuilder?.call(context) ?? - Center( - child: Padding( - padding: const EdgeInsets.all(8), - child: StreamScrollViewEmptyWidget( - emptyIcon: StreamSvgIcon( - size: 148, - icon: StreamSvgIcons.message, - color: chatThemeData.colorTheme.disabled, - ), - emptyTitle: Text( - context.translations.emptyMessagesText, - style: chatThemeData.textTheme.headline, - ), - ), - ), - ); - }, - loadMoreErrorBuilder: (context, error) => - StreamScrollViewLoadMoreError.grid( - onTap: controller.retry, - error: Text(context.translations.loadingMessagesError), - ), - loadMoreIndicatorBuilder: (context) => const Center( - child: Padding( - padding: EdgeInsets.all(16), - child: StreamScrollViewLoadMoreIndicator(), - ), - ), - loadingBuilder: (context) => - loadingBuilder?.call(context) ?? - const Center( - child: StreamScrollViewLoadingWidget(), - ), - errorBuilder: (context, error) => - errorBuilder?.call(context, error) ?? - Center( - child: StreamScrollViewErrorWidget( - onRetryPressed: controller.refresh, - ), - ), - ); - } -} diff --git a/packages/stream_chat_flutter/lib/src/scroll_view/message_search_scroll_view/stream_message_search_list_tile.dart b/packages/stream_chat_flutter/lib/src/scroll_view/message_search_scroll_view/stream_message_search_list_tile.dart index 23575f05bc..95019fc6fd 100644 --- a/packages/stream_chat_flutter/lib/src/scroll_view/message_search_scroll_view/stream_message_search_list_tile.dart +++ b/packages/stream_chat_flutter/lib/src/scroll_view/message_search_scroll_view/stream_message_search_list_tile.dart @@ -1,6 +1,4 @@ import 'package:flutter/material.dart'; -import 'package:stream_chat_flutter/src/misc/timestamp.dart'; -import 'package:stream_chat_flutter/src/utils/date_formatter.dart'; import 'package:stream_chat_flutter/stream_chat_flutter.dart'; /// A widget that displays a message search item. @@ -91,57 +89,52 @@ class StreamMessageSearchListTile extends StatelessWidget { Color? tileColor, VisualDensity? visualDensity, EdgeInsetsGeometry? contentPadding, - }) => - StreamMessageSearchListTile( - key: key ?? this.key, - messageResponse: messageResponse ?? this.messageResponse, - leading: leading ?? this.leading, - title: title ?? this.title, - subtitle: subtitle ?? this.subtitle, - trailing: trailing ?? this.trailing, - onTap: onTap ?? this.onTap, - onLongPress: onLongPress ?? this.onLongPress, - tileColor: tileColor ?? this.tileColor, - visualDensity: visualDensity ?? this.visualDensity, - contentPadding: contentPadding ?? this.contentPadding, - ); + }) => StreamMessageSearchListTile( + key: key ?? this.key, + messageResponse: messageResponse ?? this.messageResponse, + leading: leading ?? this.leading, + title: title ?? this.title, + subtitle: subtitle ?? this.subtitle, + trailing: trailing ?? this.trailing, + onTap: onTap ?? this.onTap, + onLongPress: onLongPress ?? this.onLongPress, + tileColor: tileColor ?? this.tileColor, + visualDensity: visualDensity ?? this.visualDensity, + contentPadding: contentPadding ?? this.contentPadding, + ); @override Widget build(BuildContext context) { final message = messageResponse.message; final user = message.user!; - final channelPreviewTheme = StreamChannelPreviewTheme.of(context); - - final leading = this.leading ?? - StreamUserAvatar( - user: user, - constraints: const BoxConstraints.tightFor( - height: 40, - width: 40, - ), - ); - final title = this.title ?? + final leading = this.leading ?? StreamUserAvatar(size: .lg, user: user); + + final title = + this.title ?? MessageSearchListTileTitle( messageResponse: messageResponse, - textStyle: channelPreviewTheme.titleStyle - ?.copyWith(overflow: TextOverflow.ellipsis), + textStyle: context.streamTextTheme.metadataEmphasis.copyWith(overflow: TextOverflow.ellipsis), ); - final subtitle = this.subtitle ?? + final subtitle = + this.subtitle ?? Row( children: [ Expanded( child: StreamMessagePreviewText( message: message, - textStyle: channelPreviewTheme.subtitleStyle, + textStyle: context.streamTextTheme.metadataDefault.copyWith( + color: context.streamColorScheme.textSecondary, + ), ), ), const SizedBox(width: 16), MessageSearchTileMessageDate( message: message, - textStyle: channelPreviewTheme.lastMessageAtStyle, - formatter: channelPreviewTheme.lastMessageAtFormatter, + textStyle: context.streamTextTheme.metadataDefault.copyWith( + color: context.streamColorScheme.textSecondary, + ), ), ], ); @@ -185,9 +178,7 @@ class MessageSearchListTileTitle extends StatelessWidget { TextSpan( children: [ TextSpan( - text: user.id == StreamChat.of(context).currentUser?.id - ? context.translations.youText - : user.name, + text: user.id == StreamChat.of(context).currentUser?.id ? context.translations.youText : user.name, ), if (channelName != null) ...[ TextSpan( diff --git a/packages/stream_chat_flutter/lib/src/scroll_view/message_search_scroll_view/stream_message_search_list_view.dart b/packages/stream_chat_flutter/lib/src/scroll_view/message_search_scroll_view/stream_message_search_list_view.dart index 2f4c75468f..5fb5c38f4c 100644 --- a/packages/stream_chat_flutter/lib/src/scroll_view/message_search_scroll_view/stream_message_search_list_view.dart +++ b/packages/stream_chat_flutter/lib/src/scroll_view/message_search_scroll_view/stream_message_search_list_view.dart @@ -1,9 +1,5 @@ import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; -import 'package:stream_chat_flutter/src/scroll_view/stream_scroll_view_error_widget.dart'; -import 'package:stream_chat_flutter/src/scroll_view/stream_scroll_view_load_more_error.dart'; -import 'package:stream_chat_flutter/src/scroll_view/stream_scroll_view_load_more_indicator.dart'; -import 'package:stream_chat_flutter/src/scroll_view/stream_scroll_view_loading_widget.dart'; import 'package:stream_chat_flutter/stream_chat_flutter.dart'; /// Default separator builder for [StreamMessageSearchListView]. @@ -11,14 +7,12 @@ Widget defaultMessageSearchListViewSeparatorBuilder( BuildContext context, List responses, int index, -) => - const StreamMessageSearchListSeparator(); +) => const StreamMessageSearchListSeparator(); /// Signature for the item builder that creates the children of the /// [StreamMessageSearchListView]. -typedef StreamMessageSearchListViewIndexedWidgetBuilder - = StreamScrollViewIndexedWidgetBuilder; +typedef StreamMessageSearchListViewIndexedWidgetBuilder = + StreamScrollViewIndexedWidgetBuilder; /// A [ListView] that shows a list of [GetMessageResponse]s, /// it uses [StreamMessageSearchListTile] as a default item. @@ -81,8 +75,7 @@ class StreamMessageSearchListView extends StatelessWidget { final StreamMessageSearchListViewIndexedWidgetBuilder? itemBuilder; /// A builder that is called to build the list separator. - final PagedValueScrollViewIndexedWidgetBuilder - separatorBuilder; + final PagedValueScrollViewIndexedWidgetBuilder separatorBuilder; /// A builder that is called to build the empty state of the list. final WidgetBuilder? emptyBuilder; @@ -308,8 +301,7 @@ class StreamMessageSearchListView extends StatelessWidget { final streamMessageSearchListTile = StreamMessageSearchListTile( messageResponse: messageResponse, onTap: onTap == null ? null : () => onTap(messageResponse), - onLongPress: - onLongPress == null ? null : () => onLongPress(messageResponse), + onLongPress: onLongPress == null ? null : () => onLongPress(messageResponse), ); return itemBuilder?.call( @@ -320,35 +312,22 @@ class StreamMessageSearchListView extends StatelessWidget { ) ?? streamMessageSearchListTile; }, - emptyBuilder: (context) { - final chatThemeData = StreamChatTheme.of(context); - return emptyBuilder?.call(context) ?? - Center( - child: Padding( - padding: const EdgeInsets.all(8), - child: StreamScrollViewEmptyWidget( - emptyIcon: StreamSvgIcon( - size: 148, - icon: StreamSvgIcons.message, - color: chatThemeData.colorTheme.disabled, - ), - emptyTitle: Text( - context.translations.emptyMessagesText, - style: chatThemeData.textTheme.headline, - ), - ), - ), - ); - }, - loadMoreErrorBuilder: (context, error) => - StreamScrollViewLoadMoreError.list( + emptyBuilder: (context) => + emptyBuilder?.call(context) ?? + Center( + child: StreamScrollViewEmptyWidget( + emptyIcon: Icon(context.streamIcons.messageBubbleLarge), + emptyTitle: Text(context.translations.emptyMessagesText), + ), + ), + loadMoreErrorBuilder: (context, error) => StreamScrollViewLoadMoreError.list( onTap: controller.retry, error: Text(context.translations.loadingMessagesError), ), - loadMoreIndicatorBuilder: (context) => const Center( + loadMoreIndicatorBuilder: (context) => Center( child: Padding( - padding: EdgeInsets.all(16), - child: StreamScrollViewLoadMoreIndicator(), + padding: const EdgeInsets.all(16), + child: StreamLoadingSpinner(), ), ), loadingBuilder: (context) => @@ -376,11 +355,7 @@ class StreamMessageSearchListSeparator extends StatelessWidget { @override Widget build(BuildContext context) { - final effect = StreamChatTheme.of(context).colorTheme.borderBottom; - return Container( - height: 1, - // ignore: deprecated_member_use - color: effect.color!.withOpacity(effect.alpha ?? 1.0), - ); + final colorScheme = context.streamColorScheme; + return Divider(height: 1, color: colorScheme.borderSubtle); } } diff --git a/packages/stream_chat_flutter/lib/src/scroll_view/photo_gallery/stream_photo_gallery.dart b/packages/stream_chat_flutter/lib/src/scroll_view/photo_gallery/stream_photo_gallery.dart index dcee2b76a4..1b224a1726 100644 --- a/packages/stream_chat_flutter/lib/src/scroll_view/photo_gallery/stream_photo_gallery.dart +++ b/packages/stream_chat_flutter/lib/src/scroll_view/photo_gallery/stream_photo_gallery.dart @@ -1,17 +1,11 @@ import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; -import 'package:photo_manager/photo_manager.dart' - show AssetEntity, ThumbnailFormat, ThumbnailSize; +import 'package:photo_manager/photo_manager.dart' show AssetEntity, ThumbnailFormat, ThumbnailSize; -import 'package:stream_chat_flutter/src/scroll_view/stream_scroll_view_error_widget.dart'; -import 'package:stream_chat_flutter/src/scroll_view/stream_scroll_view_load_more_error.dart'; -import 'package:stream_chat_flutter/src/scroll_view/stream_scroll_view_load_more_indicator.dart'; -import 'package:stream_chat_flutter/src/scroll_view/stream_scroll_view_loading_widget.dart'; import 'package:stream_chat_flutter/stream_chat_flutter.dart'; /// Default grid delegate for [StreamPhotoGallery]. -const defaultStreamPhotoGalleryDelegate = - SliverGridDelegateWithFixedCrossAxisCount( +const defaultStreamPhotoGalleryDelegate = SliverGridDelegateWithFixedCrossAxisCount( crossAxisCount: 3, mainAxisSpacing: 2, crossAxisSpacing: 2, @@ -19,8 +13,8 @@ const defaultStreamPhotoGalleryDelegate = /// Signature for the item builder that creates the children of the /// [StreamPhotoGallery]. -typedef StreamPhotoGalleryIndexedWidgetBuilder - = StreamScrollViewIndexedWidgetBuilder; +typedef StreamPhotoGalleryIndexedWidgetBuilder = + StreamScrollViewIndexedWidgetBuilder; /// Widget used to display a gallery of photos in the form of grid. class StreamPhotoGallery extends StatelessWidget { @@ -54,10 +48,11 @@ class StreamPhotoGallery extends StatelessWidget { this.keyboardDismissBehavior = ScrollViewKeyboardDismissBehavior.manual, this.restorationId, this.clipBehavior = Clip.hardEdge, - this.thumbnailSize = const ThumbnailSize(400, 400), + this.thumbnailSize, this.thumbnailFormat = ThumbnailFormat.jpeg, this.thumbnailQuality = 100, this.thumbnailScale = 1, + this.addMoreBuilder, }); /// The [StreamPhotoGalleryController] used to control the grid of users. @@ -294,8 +289,11 @@ class StreamPhotoGallery extends StatelessWidget { /// Defaults to [Clip.hardEdge]. final Clip clipBehavior; - /// The thumbnail size. - final ThumbnailSize thumbnailSize; + /// The thumbnail size in pixels to request from the platform. + /// + /// When null (the default), each tile auto-calculates its size from its + /// own layout constraints and the device pixel ratio. + final ThumbnailSize? thumbnailSize; /// {@macro photo_manager.ThumbnailFormat} final ThumbnailFormat thumbnailFormat; @@ -309,6 +307,11 @@ class StreamPhotoGallery extends StatelessWidget { /// Scale of the image. final double thumbnailScale; + /// An optional builder for a leading "Add more" tile shown as the first item + /// in the gallery grid. Useful when the user has limited photo library access + /// and needs a way to expand the selection. + final WidgetBuilder? addMoreBuilder; + @override Widget build(BuildContext context) { return PagedValueGridView( @@ -330,6 +333,7 @@ class StreamPhotoGallery extends StatelessWidget { restorationId: restorationId, clipBehavior: clipBehavior, loadMoreTriggerIndex: loadMoreTriggerIndex, + leadingItemBuilder: addMoreBuilder, gridDelegate: gridDelegate, itemBuilder: (context, mediaList, index) { final media = mediaList[index]; @@ -354,26 +358,14 @@ class StreamPhotoGallery extends StatelessWidget { ) ?? streamPhotoGalleryTile; }, - emptyBuilder: (context) { - final chatThemeData = StreamChatTheme.of(context); - return emptyBuilder?.call(context) ?? - Center( - child: Padding( - padding: const EdgeInsets.all(8), - child: StreamScrollViewEmptyWidget( - emptyIcon: StreamSvgIcon( - size: 148, - icon: StreamSvgIcons.pictures, - color: chatThemeData.colorTheme.disabled, - ), - emptyTitle: Text( - context.translations.noPhotoOrVideoLabel, - style: chatThemeData.textTheme.headline, - ), - ), - ), - ); - }, + emptyBuilder: (context) => + emptyBuilder?.call(context) ?? + Center( + child: StreamScrollViewEmptyWidget( + emptyIcon: Icon(context.streamIcons.imageLarge), + emptyTitle: Text(context.translations.noPhotoOrVideoLabel), + ), + ), loadMoreErrorBuilder: (context, error) { return StreamScrollViewLoadMoreError.grid( onTap: controller.retry, @@ -384,10 +376,10 @@ class StreamPhotoGallery extends StatelessWidget { ); }, loadMoreIndicatorBuilder: (context) { - return const Center( + return Center( child: Padding( - padding: EdgeInsets.all(16), - child: StreamScrollViewLoadMoreIndicator(), + padding: const EdgeInsets.all(16), + child: StreamLoadingSpinner(), ), ); }, diff --git a/packages/stream_chat_flutter/lib/src/scroll_view/photo_gallery/stream_photo_gallery_controller.dart b/packages/stream_chat_flutter/lib/src/scroll_view/photo_gallery/stream_photo_gallery_controller.dart index eb9b21bbf9..8d25cb561e 100644 --- a/packages/stream_chat_flutter/lib/src/scroll_view/photo_gallery/stream_photo_gallery_controller.dart +++ b/packages/stream_chat_flutter/lib/src/scroll_view/photo_gallery/stream_photo_gallery_controller.dart @@ -3,8 +3,7 @@ import 'package:photo_manager/photo_manager.dart'; import 'package:stream_chat_flutter_core/stream_chat_flutter_core.dart'; /// -class StreamPhotoGalleryController - extends PagedValueNotifier { +class StreamPhotoGalleryController extends PagedValueNotifier { /// StreamPhotoGalleryController({ this.limit = 50, diff --git a/packages/stream_chat_flutter/lib/src/scroll_view/photo_gallery/stream_photo_gallery_tile.dart b/packages/stream_chat_flutter/lib/src/scroll_view/photo_gallery/stream_photo_gallery_tile.dart index 6b7ef7ebdc..e81d6d5ccd 100644 --- a/packages/stream_chat_flutter/lib/src/scroll_view/photo_gallery/stream_photo_gallery_tile.dart +++ b/packages/stream_chat_flutter/lib/src/scroll_view/photo_gallery/stream_photo_gallery_tile.dart @@ -3,8 +3,8 @@ import 'dart:ui' as ui; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:photo_manager/photo_manager.dart'; -import 'package:stream_chat_flutter/src/icons/stream_svg_icon.dart'; -import 'package:stream_chat_flutter/src/theme/stream_chat_theme.dart'; +import 'package:stream_chat_flutter/src/attachment/thumbnail/thumbnail_size_calculator.dart'; +import 'package:stream_core_flutter/stream_core_flutter.dart'; /// Widget that displays a photo or video item from the gallery. class StreamPhotoGalleryTile extends StatelessWidget { @@ -15,8 +15,9 @@ class StreamPhotoGalleryTile extends StatelessWidget { this.selected = false, this.onTap, this.onLongPress, - this.thumbnailSize = const ThumbnailSize(400, 400), - this.thumbnailFormat = ThumbnailFormat.jpeg, + this.fit = .cover, + this.thumbnailSize, + this.thumbnailFormat = .jpeg, this.thumbnailQuality = 100, this.thumbnailScale = 1, }); @@ -33,8 +34,15 @@ class StreamPhotoGalleryTile extends StatelessWidget { /// Called when the user long-presses on this grid tile. final GestureLongPressCallback? onLongPress; - /// The thumbnail size. - final ThumbnailSize thumbnailSize; + /// Fit of the underlying thumbnail image. Defaults to [BoxFit.cover]. + final BoxFit fit; + + /// The thumbnail size in pixels to request from the platform. + /// + /// When null (the default), the size is auto-calculated from the tile's + /// layout constraints and the device pixel ratio so we don't decode more + /// pixels than the tile actually displays. + final ThumbnailSize? thumbnailSize; /// {@macro photo_manager.ThumbnailFormat} final ThumbnailFormat thumbnailFormat; @@ -56,38 +64,38 @@ class StreamPhotoGalleryTile extends StatelessWidget { bool? selected, GestureTapCallback? onTap, GestureLongPressCallback? onLongPress, + BoxFit? fit, ThumbnailSize? thumbnailSize, ThumbnailFormat? thumbnailFormat, int? thumbnailQuality, double? thumbnailScale, - }) => - StreamPhotoGalleryTile( - key: key ?? this.key, - media: media ?? this.media, - selected: selected ?? this.selected, - onTap: onTap ?? this.onTap, - onLongPress: onLongPress ?? this.onLongPress, - thumbnailSize: thumbnailSize ?? this.thumbnailSize, - thumbnailFormat: thumbnailFormat ?? this.thumbnailFormat, - thumbnailQuality: thumbnailQuality ?? this.thumbnailQuality, - thumbnailScale: thumbnailScale ?? this.thumbnailScale, - ); + }) => StreamPhotoGalleryTile( + key: key ?? this.key, + media: media ?? this.media, + selected: selected ?? this.selected, + onTap: onTap ?? this.onTap, + onLongPress: onLongPress ?? this.onLongPress, + fit: fit ?? this.fit, + thumbnailSize: thumbnailSize ?? this.thumbnailSize, + thumbnailFormat: thumbnailFormat ?? this.thumbnailFormat, + thumbnailQuality: thumbnailQuality ?? this.thumbnailQuality, + thumbnailScale: thumbnailScale ?? this.thumbnailScale, + ); @override Widget build(BuildContext context) { - final chatThemeData = StreamChatTheme.of(context); - return Stack( - children: [ - AspectRatio( - aspectRatio: 1, - child: FadeInImage( - fadeInDuration: const Duration(milliseconds: 300), - placeholder: const AssetImage( - 'lib/assets/images/placeholder.png', - package: 'stream_chat_flutter', - ), - fit: BoxFit.cover, - image: MediaThumbnailProvider( + final radius = context.streamRadius; + final colorScheme = context.streamColorScheme; + + return ClipRRect( + clipBehavior: .hardEdge, + borderRadius: .all(radius.xxs), + child: Stack( + children: [ + AspectRatio( + aspectRatio: 1, + child: _GalleryThumbnail( + fit: fit, media: media, size: thumbnailSize, format: thumbnailFormat, @@ -95,74 +103,134 @@ class StreamPhotoGalleryTile extends StatelessWidget { scale: thumbnailScale, ), ), - ), - Positioned.fill( - child: IgnorePointer( + Positioned.fill( child: AnimatedOpacity( - duration: const Duration(milliseconds: 300), opacity: selected ? 1.0 : 0.0, - child: Container( - color: - // ignore: deprecated_member_use - chatThemeData.colorTheme.textHighEmphasis.withOpacity(0.5), - alignment: Alignment.topRight, - padding: const EdgeInsets.only( - top: 8, - right: 8, - ), - child: CircleAvatar( - radius: 12, - backgroundColor: chatThemeData.colorTheme.barsBg, - child: StreamSvgIcon( - size: 24, - icon: StreamSvgIcons.check, - color: chatThemeData.colorTheme.textHighEmphasis, - ), - ), - ), + duration: const Duration(milliseconds: 300), + child: ColoredBox(color: colorScheme.backgroundSelected), ), ), - ), - if (media.type == AssetType.video) ...[ - const Positioned( - left: 8, - bottom: 10, - child: StreamSvgIcon(icon: StreamSvgIcons.videoCall), + PositionedDirectional( + top: 8, + end: 8, + child: _GallerySelectedIndicator(selected: selected), ), - Positioned( - right: 4, - bottom: 10, - child: Text( - media.videoDuration.format(), - style: TextStyle( - color: chatThemeData.colorTheme.barsBg, + if (media.type == AssetType.video) + PositionedDirectional( + start: 8, + bottom: 8, + child: StreamMediaBadge( + type: MediaBadgeType.video, + duration: media.videoDuration, + durationFormat: MediaBadgeDurationFormat.exact, + ), + ), + // https://stackoverflow.com/a/59317162/10036882 + Positioned.fill( + child: Material( + color: Colors.transparent, + child: InkWell( + onTap: onTap, + onLongPress: onLongPress, ), ), ), ], - // https://stackoverflow.com/a/59317162/10036882 - Positioned.fill( - child: Material( - color: Colors.transparent, - child: InkWell( - onTap: onTap, - onLongPress: onLongPress, - ), + ), + ); + } +} + +class _GalleryThumbnail extends StatelessWidget { + const _GalleryThumbnail({ + required this.media, + required this.fit, + required this.size, + required this.format, + required this.quality, + required this.scale, + }); + + final AssetEntity media; + final BoxFit fit; + final ThumbnailSize? size; + final ThumbnailFormat format; + final int quality; + final double scale; + + @override + Widget build(BuildContext context) { + return LayoutBuilder( + builder: (context, constraints) { + final effectiveSize = size ?? _autoSize(context, constraints); + + return Image( + fit: fit, + image: MediaThumbnailProvider( + media: media, + size: effectiveSize, + format: format, + quality: quality, + scale: scale, ), - ), - ], + frameBuilder: (context, child, frame, wasSynchronouslyLoaded) { + if (wasSynchronouslyLoaded || frame != null) return child; + return const StreamImageLoadingPlaceholder(); + }, + errorBuilder: (context, error, stackTrace) { + return const StreamImageErrorPlaceholder(); + }, + ); + }, ); } + + ThumbnailSize _autoSize(BuildContext context, BoxConstraints constraints) { + // orientatedSize accounts for EXIF rotation so portrait shots get the + // right aspect ratio. It's (0, 0) when EXIF parsing fails — the + // calculator returns null for that, and we fall back to the default + // tile size from Figma. fit drives cover vs contain sizing so the + // bitmap matches what the Image will actually paint, no upscale blur. + final size = ThumbnailSizeCalculator.calculate( + targetSize: constraints.biggest, + originalSize: media.orientatedSize, + pixelRatio: MediaQuery.devicePixelRatioOf(context), + fit: fit, + ); + + if (size == null) return const .square(132); + return .new(size.width.round(), size.height.round()); + } } -extension on Duration { - String format() { - final s = '$this'.split('.')[0].padLeft(8, '0'); - if (s.startsWith('00:')) { - return s.replaceFirst('00:', ''); - } +class _GallerySelectedIndicator extends StatelessWidget { + const _GallerySelectedIndicator({required this.selected}); + + final bool selected; - return s; + @override + Widget build(BuildContext context) { + final icons = context.streamIcons; + final colorScheme = context.streamColorScheme; + + return AnimatedContainer( + width: 24, + height: 24, + duration: const Duration(milliseconds: 200), + decoration: BoxDecoration( + shape: BoxShape.circle, + color: selected ? colorScheme.accentPrimary : Colors.transparent, + border: .all(color: colorScheme.borderOnAccent, width: 2, strokeAlign: BorderSide.strokeAlignOutside), + ), + child: selected + ? Icon( + size: 12, + icons.checkmark, + fontWeight: .w900, + color: colorScheme.textOnAccent, + ) + : null, + ); } } @@ -173,9 +241,8 @@ class MediaThumbnailProvider extends ImageProvider { /// {@macro mediaThumbnailProvider} const MediaThumbnailProvider({ required this.media, - // TODO: Are these sizes optimal? Consider web/desktop - this.size = const ThumbnailSize(400, 400), - this.format = ThumbnailFormat.jpeg, + this.size = const .square(132), + this.format = .jpeg, this.quality = 100, this.scale = 1, }); @@ -251,7 +318,8 @@ class MediaThumbnailProvider extends ImageProvider { int get hashCode => Object.hash(media, size, format, quality, scale); @override - String toString() => '$runtimeType(' + String toString() => + '$runtimeType(' 'media: $media, ' 'size: $size, ' 'format: $format, ' diff --git a/packages/stream_chat_flutter/lib/src/scroll_view/poll_vote_scroll_view/stream_poll_vote_list_tile.dart b/packages/stream_chat_flutter/lib/src/scroll_view/poll_vote_scroll_view/stream_poll_vote_list_tile.dart index 82ad280d74..c20f875106 100644 --- a/packages/stream_chat_flutter/lib/src/scroll_view/poll_vote_scroll_view/stream_poll_vote_list_tile.dart +++ b/packages/stream_chat_flutter/lib/src/scroll_view/poll_vote_scroll_view/stream_poll_vote_list_tile.dart @@ -1,6 +1,4 @@ import 'package:flutter/material.dart'; -import 'package:stream_chat_flutter/src/misc/timestamp.dart'; -import 'package:stream_chat_flutter/src/utils/date_formatter.dart'; import 'package:stream_chat_flutter/stream_chat_flutter.dart'; /// {@template streamPollVoteListTile} @@ -53,21 +51,23 @@ class StreamPollVoteListTile extends StatelessWidget { Color? tileColor, BorderRadiusGeometry? borderRadius, EdgeInsetsGeometry? contentPadding, - }) => - StreamPollVoteListTile( - key: key ?? this.key, - pollVote: pollVote ?? this.pollVote, - showAnswerText: showAnswerText ?? this.showAnswerText, - onTap: onTap ?? this.onTap, - onLongPress: onLongPress ?? this.onLongPress, - tileColor: tileColor ?? this.tileColor, - borderRadius: borderRadius ?? this.borderRadius, - contentPadding: contentPadding ?? this.contentPadding, - ); + }) => StreamPollVoteListTile( + key: key ?? this.key, + pollVote: pollVote ?? this.pollVote, + showAnswerText: showAnswerText ?? this.showAnswerText, + onTap: onTap ?? this.onTap, + onLongPress: onLongPress ?? this.onLongPress, + tileColor: tileColor ?? this.tileColor, + borderRadius: borderRadius ?? this.borderRadius, + contentPadding: contentPadding ?? this.contentPadding, + ); @override Widget build(BuildContext context) { - final theme = StreamChatTheme.of(context); + final spacing = context.streamSpacing; + + final textTheme = context.streamTextTheme; + final colorScheme = context.streamColorScheme; return InkWell( onTap: onTap, @@ -79,46 +79,39 @@ class StreamPollVoteListTile extends StatelessWidget { borderRadius: borderRadius, ), child: Column( + spacing: spacing.xs, crossAxisAlignment: CrossAxisAlignment.start, children: [ - if (pollVote.answerText case final answerText? - when showAnswerText) ...[ + if (pollVote.answerText case final answerText? when showAnswerText) ...[ Text( answerText, - style: theme.textTheme.headlineBold.copyWith( - color: theme.colorTheme.textHighEmphasis, - ), + style: textTheme.bodyDefault.copyWith(color: colorScheme.textPrimary), ), - const SizedBox(height: 16), ], Row( + spacing: spacing.sm, children: [ if (pollVote.user case final user?) ...[ StreamUserAvatar( + size: .md, user: user, - constraints: - BoxConstraints.tight(const Size.fromRadius(10)), - showOnlineStatus: false, + showOnlineIndicator: false, ), Expanded( - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 4), - child: Text( - user.name, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: theme.textTheme.body.copyWith( - color: theme.colorTheme.textHighEmphasis, - ), - ), + child: Text( + user.name, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: textTheme.bodyDefault.copyWith(color: colorScheme.textPrimary), ), ), ], PollVoteUpdatedAt( dateTime: pollVote.updatedAt.toLocal(), + textStyle: textTheme.bodyDefault.copyWith(color: colorScheme.textTertiary), ), ], - ) + ), ], ), ), @@ -134,40 +127,30 @@ class PollVoteUpdatedAt extends StatelessWidget { const PollVoteUpdatedAt({ super.key, required this.dateTime, + this.textStyle, }); /// The date and time when the poll vote was last updated. final DateTime dateTime; + /// The text style to use for the timestamp. + /// + /// If null, defaults to the theme's body text style with low emphasis color. + final TextStyle? textStyle; + @override Widget build(BuildContext context) { - final theme = StreamChatTheme.of(context); - - return Row( - children: [ - StreamTimestamp( - date: dateTime, - formatter: (context, date) { - if (date.isToday) return context.translations.todayLabel; - if (date.isYesterday) return context.translations.yesterdayLabel; - if (date.isWithinAWeek) return Jiffy.parseFromDateTime(date).EEEE; - if (date.isWithinAYear) return Jiffy.parseFromDateTime(date).MMMd; - - return Jiffy.parseFromDateTime(date).yMMMd; - }, - style: theme.textTheme.bodyBold.copyWith( - color: theme.colorTheme.textLowEmphasis, - ), - ), - const SizedBox(width: 8), - StreamTimestamp( - date: dateTime, - formatter: (context, date) => Jiffy.parseFromDateTime(date).jm, - style: theme.textTheme.body.copyWith( - color: theme.colorTheme.textLowEmphasis, - ), - ), - ], + return StreamTimestamp( + date: dateTime, + style: textStyle, + formatter: (context, date) { + if (date.isToday) return context.translations.todayLabel; + if (date.isYesterday) return context.translations.yesterdayLabel; + if (date.isWithinAWeek) return Jiffy.parseFromDateTime(date).EEEE; + if (date.isWithinAYear) return Jiffy.parseFromDateTime(date).MMMd; + + return Jiffy.parseFromDateTime(date).yMMMd; + }, ); } } diff --git a/packages/stream_chat_flutter/lib/src/scroll_view/poll_vote_scroll_view/stream_poll_vote_list_view.dart b/packages/stream_chat_flutter/lib/src/scroll_view/poll_vote_scroll_view/stream_poll_vote_list_view.dart index 13aecdb471..f912eb9d75 100644 --- a/packages/stream_chat_flutter/lib/src/scroll_view/poll_vote_scroll_view/stream_poll_vote_list_view.dart +++ b/packages/stream_chat_flutter/lib/src/scroll_view/poll_vote_scroll_view/stream_poll_vote_list_view.dart @@ -1,29 +1,26 @@ import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; -import 'package:stream_chat_flutter/src/icons/stream_svg_icon.dart'; import 'package:stream_chat_flutter/src/scroll_view/poll_vote_scroll_view/stream_poll_vote_list_tile.dart'; import 'package:stream_chat_flutter/src/scroll_view/stream_scroll_view_empty_widget.dart'; import 'package:stream_chat_flutter/src/scroll_view/stream_scroll_view_error_widget.dart'; import 'package:stream_chat_flutter/src/scroll_view/stream_scroll_view_indexed_widget_builder.dart'; import 'package:stream_chat_flutter/src/scroll_view/stream_scroll_view_load_more_error.dart'; -import 'package:stream_chat_flutter/src/scroll_view/stream_scroll_view_load_more_indicator.dart'; import 'package:stream_chat_flutter/src/scroll_view/stream_scroll_view_loading_widget.dart'; -import 'package:stream_chat_flutter/src/theme/stream_chat_theme.dart'; import 'package:stream_chat_flutter/src/utils/extensions.dart'; import 'package:stream_chat_flutter_core/stream_chat_flutter_core.dart'; +import 'package:stream_core_flutter/stream_core_flutter.dart'; /// Default separator builder for [StreamPollVoteListView]. Widget defaultPollVoteListViewSeparatorBuilder( BuildContext context, List pollVotes, int index, -) => - const SizedBox(height: 8); +) => const SizedBox(height: 8); /// Signature for the item builder that creates the children of the /// [StreamPollVoteListView]. -typedef StreamPollVoteListViewIndexedWidgetBuilder - = StreamScrollViewIndexedWidgetBuilder; +typedef StreamPollVoteListViewIndexedWidgetBuilder = + StreamScrollViewIndexedWidgetBuilder; /// {@template streamPollVoteListView} /// A [ListView] that shows a list of [PollVote] for a poll. It uses a @@ -283,87 +280,73 @@ class StreamPollVoteListView extends StatelessWidget { @override Widget build(BuildContext context) => PagedValueListView( - scrollDirection: scrollDirection, - padding: padding, - physics: physics, - reverse: reverse, - controller: controller, - scrollController: scrollController, - primary: primary, - shrinkWrap: shrinkWrap, - addAutomaticKeepAlives: addAutomaticKeepAlives, - addRepaintBoundaries: addRepaintBoundaries, - addSemanticIndexes: addSemanticIndexes, - keyboardDismissBehavior: keyboardDismissBehavior, - restorationId: restorationId, - dragStartBehavior: dragStartBehavior, - cacheExtent: cacheExtent, - clipBehavior: clipBehavior, - loadMoreTriggerIndex: loadMoreTriggerIndex, - separatorBuilder: separatorBuilder, - itemBuilder: (context, pollVotes, index) { - final pollVote = pollVotes[index]; - final onTap = onPollVoteTap; - final onLongPress = onPollVoteLongPress; + scrollDirection: scrollDirection, + padding: padding, + physics: physics, + reverse: reverse, + controller: controller, + scrollController: scrollController, + primary: primary, + shrinkWrap: shrinkWrap, + addAutomaticKeepAlives: addAutomaticKeepAlives, + addRepaintBoundaries: addRepaintBoundaries, + addSemanticIndexes: addSemanticIndexes, + keyboardDismissBehavior: keyboardDismissBehavior, + restorationId: restorationId, + dragStartBehavior: dragStartBehavior, + cacheExtent: cacheExtent, + clipBehavior: clipBehavior, + loadMoreTriggerIndex: loadMoreTriggerIndex, + separatorBuilder: separatorBuilder, + itemBuilder: (context, pollVotes, index) { + final pollVote = pollVotes[index]; + final onTap = onPollVoteTap; + final onLongPress = onPollVoteLongPress; - final streamPollVoteListTile = StreamPollVoteListTile( - pollVote: pollVote, - onTap: onTap == null ? null : () => onTap(pollVote), - onLongPress: - onLongPress == null ? null : () => onLongPress(pollVote), - ); + final streamPollVoteListTile = StreamPollVoteListTile( + pollVote: pollVote, + onTap: onTap == null ? null : () => onTap(pollVote), + onLongPress: onLongPress == null ? null : () => onLongPress(pollVote), + ); - return itemBuilder?.call( - context, - pollVotes, - index, - streamPollVoteListTile, - ) ?? - streamPollVoteListTile; - }, - emptyBuilder: (context) { - final chatThemeData = StreamChatTheme.of(context); - return emptyBuilder?.call(context) ?? - Center( - child: Padding( - padding: const EdgeInsets.all(8), - child: StreamScrollViewEmptyWidget( - emptyIcon: StreamSvgIcon( - size: 148, - icon: StreamSvgIcons.polls, - color: chatThemeData.colorTheme.disabled, - ), - emptyTitle: Text( - context.translations.noPollVotesLabel, - style: chatThemeData.textTheme.headline, - ), - ), - ), - ); - }, - loadMoreErrorBuilder: (context, error) => - StreamScrollViewLoadMoreError.list( - onTap: controller.retry, - error: Text(context.translations.loadingPollVotesError), + return itemBuilder?.call( + context, + pollVotes, + index, + streamPollVoteListTile, + ) ?? + streamPollVoteListTile; + }, + emptyBuilder: (context) => + emptyBuilder?.call(context) ?? + Center( + child: StreamScrollViewEmptyWidget( + emptyIcon: Icon(context.streamIcons.pollLarge), + emptyTitle: Text(context.translations.noPollVotesLabel), + ), ), - loadMoreIndicatorBuilder: (context) => const Center( - child: Padding( - padding: EdgeInsets.all(16), - child: StreamScrollViewLoadMoreIndicator(), + loadMoreErrorBuilder: (context, error) => StreamScrollViewLoadMoreError.list( + onTap: controller.retry, + error: Text(context.translations.loadingPollVotesError), + ), + loadMoreIndicatorBuilder: (context) => Center( + child: Padding( + padding: const EdgeInsets.all(16), + child: StreamLoadingSpinner(), + ), + ), + loadingBuilder: (context) => + loadingBuilder?.call(context) ?? + const Center( + child: StreamScrollViewLoadingWidget(), + ), + errorBuilder: (context, error) => + errorBuilder?.call(context, error) ?? + Center( + child: StreamScrollViewErrorWidget( + errorTitle: Text(context.translations.loadingPollVotesError), + onRetryPressed: controller.refresh, ), ), - loadingBuilder: (context) => - loadingBuilder?.call(context) ?? - const Center( - child: StreamScrollViewLoadingWidget(), - ), - errorBuilder: (context, error) => - errorBuilder?.call(context, error) ?? - Center( - child: StreamScrollViewErrorWidget( - errorTitle: Text(context.translations.loadingPollVotesError), - onRetryPressed: controller.refresh, - ), - ), - ); + ); } diff --git a/packages/stream_chat_flutter/lib/src/scroll_view/reaction_scroll_view/stream_reaction_list_view.dart b/packages/stream_chat_flutter/lib/src/scroll_view/reaction_scroll_view/stream_reaction_list_view.dart new file mode 100644 index 0000000000..636874ee53 --- /dev/null +++ b/packages/stream_chat_flutter/lib/src/scroll_view/reaction_scroll_view/stream_reaction_list_view.dart @@ -0,0 +1,210 @@ +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:stream_chat_flutter/stream_chat_flutter.dart'; + +/// Default separator builder for [StreamReactionListView]. +Widget defaultReactionListViewSeparatorBuilder( + BuildContext context, + List reactions, + int index, +) => const SizedBox.shrink(); + +/// Signature for the item builder that creates the children of the +/// [StreamReactionListView]. +typedef StreamReactionListViewIndexedWidgetBuilder = PagedValueScrollViewIndexedWidgetBuilder; + +/// {@template streamReactionListView} +/// A [ListView] that shows a list of [Reaction]s. It uses a +/// [StreamReactionListController] to load the reactions in paginated form. +/// +/// Example: +/// +/// ```dart +/// StreamReactionListView( +/// controller: controller, +/// itemBuilder: (context, reactions, index) { +/// final reaction = reactions[index]; +/// return ListTile(title: Text(reaction.type)); +/// }, +/// ) +/// ``` +/// +/// See also: +/// * [StreamReactionListController] +/// {@endtemplate} +class StreamReactionListView extends StatelessWidget { + /// Creates a new instance of [StreamReactionListView]. + const StreamReactionListView({ + super.key, + required this.controller, + required this.itemBuilder, + this.separatorBuilder = defaultReactionListViewSeparatorBuilder, + this.emptyBuilder, + this.loadingBuilder, + this.errorBuilder, + this.loadMoreTriggerIndex = 3, + this.scrollDirection = Axis.vertical, + this.reverse = false, + this.scrollController, + this.primary, + this.physics, + this.shrinkWrap = false, + this.padding, + this.addAutomaticKeepAlives = true, + this.addRepaintBoundaries = true, + this.addSemanticIndexes = true, + this.cacheExtent, + this.dragStartBehavior = DragStartBehavior.start, + this.keyboardDismissBehavior = ScrollViewKeyboardDismissBehavior.manual, + this.restorationId, + this.clipBehavior = Clip.hardEdge, + }); + + /// The [StreamReactionListController] used to control the list of reactions. + final StreamReactionListController controller; + + /// A builder that is called to build items in the [ListView]. + final StreamReactionListViewIndexedWidgetBuilder itemBuilder; + + /// A builder that is called to build the list separator. + final PagedValueScrollViewIndexedWidgetBuilder separatorBuilder; + + /// A builder that is called to build the empty state of the list. + final WidgetBuilder? emptyBuilder; + + /// A builder that is called to build the loading state of the list. + final WidgetBuilder? loadingBuilder; + + /// A builder that is called to build the error state of the list. + final Widget Function(BuildContext, StreamChatError)? errorBuilder; + + /// The index to take into account when triggering [controller.loadMore]. + final int loadMoreTriggerIndex; + + /// {@template flutter.widgets.scroll_view.scrollDirection} + /// The axis along which the scroll view scrolls. + /// + /// Defaults to [Axis.vertical]. + /// {@endtemplate} + final Axis scrollDirection; + + /// The amount of space by which to inset the children. + final EdgeInsetsGeometry? padding; + + /// Whether to wrap each child in an [AutomaticKeepAlive]. + /// + /// Defaults to true. + final bool addAutomaticKeepAlives; + + /// Whether to wrap each child in a [RepaintBoundary]. + /// + /// Defaults to true. + final bool addRepaintBoundaries; + + /// Whether to wrap each child in an [IndexedSemantics]. + /// + /// Defaults to true. + final bool addSemanticIndexes; + + /// {@template flutter.widgets.scroll_view.reverse} + /// Whether the scroll view scrolls in the reading direction. + /// + /// Defaults to false. + /// {@endtemplate} + final bool reverse; + + /// {@template flutter.widgets.scroll_view.controller} + /// An object that can be used to control the position to which this scroll + /// view is scrolled. + /// {@endtemplate} + final ScrollController? scrollController; + + /// {@template flutter.widgets.scroll_view.primary} + /// Whether this is the primary scroll view associated with the parent + /// [PrimaryScrollController]. + /// {@endtemplate} + final bool? primary; + + /// {@template flutter.widgets.scroll_view.shrinkWrap} + /// Whether the extent of the scroll view in the [scrollDirection] should be + /// determined by the contents being viewed. + /// + /// Defaults to false. + /// {@endtemplate} + final bool shrinkWrap; + + /// {@template flutter.widgets.scroll_view.physics} + /// How the scroll view should respond to user input. + /// {@endtemplate} + final ScrollPhysics? physics; + + /// {@macro flutter.rendering.RenderViewportBase.cacheExtent} + final double? cacheExtent; + + /// {@macro flutter.widgets.scrollable.dragStartBehavior} + final DragStartBehavior dragStartBehavior; + + /// {@template flutter.widgets.scroll_view.keyboardDismissBehavior} + /// [ScrollViewKeyboardDismissBehavior] the defines how this [ScrollView] will + /// dismiss the keyboard automatically. + /// {@endtemplate} + final ScrollViewKeyboardDismissBehavior keyboardDismissBehavior; + + /// {@macro flutter.widgets.scrollable.restorationId} + final String? restorationId; + + /// {@macro flutter.material.Material.clipBehavior} + /// + /// Defaults to [Clip.hardEdge]. + final Clip clipBehavior; + + @override + Widget build(BuildContext context) => PagedValueListView( + scrollDirection: scrollDirection, + padding: padding, + physics: physics, + reverse: reverse, + controller: controller, + scrollController: scrollController, + primary: primary, + shrinkWrap: shrinkWrap, + addAutomaticKeepAlives: addAutomaticKeepAlives, + addRepaintBoundaries: addRepaintBoundaries, + addSemanticIndexes: addSemanticIndexes, + keyboardDismissBehavior: keyboardDismissBehavior, + restorationId: restorationId, + dragStartBehavior: dragStartBehavior, + cacheExtent: cacheExtent, + clipBehavior: clipBehavior, + loadMoreTriggerIndex: loadMoreTriggerIndex, + separatorBuilder: separatorBuilder, + itemBuilder: itemBuilder, + emptyBuilder: (context) => + emptyBuilder?.call(context) ?? + Center( + child: StreamScrollViewEmptyWidget( + emptyIcon: Icon(context.streamIcons.emoji), + emptyTitle: Text(context.translations.emptyReactionsText), + ), + ), + loadMoreErrorBuilder: (context, error) => StreamScrollViewLoadMoreError.list( + onTap: controller.retry, + error: Text(context.translations.loadingReactionsError), + ), + loadMoreIndicatorBuilder: (context) => Center( + child: Padding( + padding: const EdgeInsets.all(16), + child: StreamLoadingSpinner(), + ), + ), + loadingBuilder: (context) => loadingBuilder?.call(context) ?? const Center(child: StreamScrollViewLoadingWidget()), + errorBuilder: (context, error) => + errorBuilder?.call(context, error) ?? + Center( + child: StreamScrollViewErrorWidget( + errorTitle: Text(context.translations.loadingReactionsError), + onRetryPressed: controller.refresh, + ), + ), + ); +} diff --git a/packages/stream_chat_flutter/lib/src/scroll_view/stream_scroll_view_empty_widget.dart b/packages/stream_chat_flutter/lib/src/scroll_view/stream_scroll_view_empty_widget.dart index 6fe8b8524a..3333bbd8ea 100644 --- a/packages/stream_chat_flutter/lib/src/scroll_view/stream_scroll_view_empty_widget.dart +++ b/packages/stream_chat_flutter/lib/src/scroll_view/stream_scroll_view_empty_widget.dart @@ -35,27 +35,39 @@ class StreamScrollViewEmptyWidget extends StatelessWidget { @override Widget build(BuildContext context) { - final chatThemeData = StreamChatTheme.of(context); + final spacing = context.streamSpacing; - final emptyIcon = AnimatedSwitcher( - duration: kThemeChangeDuration, + final textTheme = context.streamTextTheme; + final colorScheme = context.streamColorScheme; + + final effectiveTitleStyle = emptyTitleStyle ?? textTheme.captionDefault.copyWith(color: colorScheme.textSecondary); + + final emptyIcon = IconTheme.merge( + data: IconThemeData( + size: 32, + color: colorScheme.textTertiary, + ), child: this.emptyIcon, ); final emptyTitleText = AnimatedDefaultTextStyle( - style: emptyTitleStyle ?? chatThemeData.textTheme.headline, + style: effectiveTitleStyle, duration: kThemeChangeDuration, child: emptyTitle, ); - return Column( - mainAxisSize: mainAxisSize, - mainAxisAlignment: mainAxisAlignment, - crossAxisAlignment: crossAxisAlignment, - children: [ - emptyIcon, - emptyTitleText, - ], + return Padding( + padding: .symmetric( + horizontal: spacing.md, + vertical: spacing.xxxl, + ), + child: Column( + spacing: spacing.sm, + mainAxisSize: mainAxisSize, + mainAxisAlignment: mainAxisAlignment, + crossAxisAlignment: crossAxisAlignment, + children: [emptyIcon, emptyTitleText], + ), ); } } diff --git a/packages/stream_chat_flutter/lib/src/scroll_view/stream_scroll_view_error_widget.dart b/packages/stream_chat_flutter/lib/src/scroll_view/stream_scroll_view_error_widget.dart index 3dafd08931..60a83c192b 100644 --- a/packages/stream_chat_flutter/lib/src/scroll_view/stream_scroll_view_error_widget.dart +++ b/packages/stream_chat_flutter/lib/src/scroll_view/stream_scroll_view_error_widget.dart @@ -1,5 +1,4 @@ import 'package:flutter/material.dart'; -import 'package:stream_chat_flutter/src/misc/empty_widget.dart'; import 'package:stream_chat_flutter/stream_chat_flutter.dart'; /// A widget that is displayed when a [StreamScrollView] encounters an error @@ -48,45 +47,49 @@ class StreamScrollViewErrorWidget extends StatelessWidget { @override Widget build(BuildContext context) { - final chatThemeData = StreamChatTheme.of(context); + final icons = context.streamIcons; + final spacing = context.streamSpacing; - final errorIcon = AnimatedSwitcher( - duration: kThemeChangeDuration, - child: this.errorIcon ?? - Icon( - Icons.error_outline_rounded, - size: 148, - color: chatThemeData.colorTheme.disabled, - ), + final textTheme = context.streamTextTheme; + final colorScheme = context.streamColorScheme; + + final errorIcon = IconTheme.merge( + data: IconThemeData(size: 32, color: colorScheme.textTertiary), + child: this.errorIcon ?? Icon(icons.exclamationCircle), ); - final titleText = AnimatedDefaultTextStyle( - style: errorTitleStyle ?? chatThemeData.textTheme.headline, + final effectiveStyle = errorTitleStyle ?? textTheme.captionDefault.copyWith(color: colorScheme.textSecondary); + final emptyTitleText = AnimatedDefaultTextStyle( + style: effectiveStyle, duration: kThemeChangeDuration, - child: errorTitle ?? const Empty(), + child: errorTitle ?? Text(context.translations.genericErrorText), ); - final retryButtonText = AnimatedDefaultTextStyle( - style: errorTitleStyle ?? - chatThemeData.textTheme.headline.copyWith( - color: Colors.white, - ), - duration: kThemeChangeDuration, - child: this.retryButtonText ?? Text(context.translations.retryLabel), + final retryButton = StreamButton( + size: .medium, + type: .outline, + style: .secondary, + onPressed: onRetryPressed, + child: Text(context.translations.retryLabel), ); - return Column( - mainAxisSize: mainAxisSize, - mainAxisAlignment: mainAxisAlignment, - crossAxisAlignment: crossAxisAlignment, - children: [ - errorIcon, - titleText, - ElevatedButton( - onPressed: onRetryPressed, - child: retryButtonText, - ), - ], + return Padding( + padding: .symmetric( + horizontal: spacing.md, + vertical: spacing.xxxl, + ), + child: Column( + mainAxisSize: mainAxisSize, + mainAxisAlignment: mainAxisAlignment, + crossAxisAlignment: crossAxisAlignment, + children: [ + errorIcon, + SizedBox(height: spacing.xs), + emptyTitleText, + SizedBox(height: spacing.md), + retryButton, + ], + ), ); } } diff --git a/packages/stream_chat_flutter/lib/src/scroll_view/stream_scroll_view_indexed_widget_builder.dart b/packages/stream_chat_flutter/lib/src/scroll_view/stream_scroll_view_indexed_widget_builder.dart index 0305cd1f20..9d669f04d0 100644 --- a/packages/stream_chat_flutter/lib/src/scroll_view/stream_scroll_view_indexed_widget_builder.dart +++ b/packages/stream_chat_flutter/lib/src/scroll_view/stream_scroll_view_indexed_widget_builder.dart @@ -5,11 +5,10 @@ import 'package:flutter/material.dart'; /// /// Used by [StreamChannelListView], [StreamMessageSearchListView] /// and [StreamUserListView]. -typedef StreamScrollViewIndexedWidgetBuilder - = Widget Function( - BuildContext context, - List items, - int index, - WidgetType defaultWidget, -); +typedef StreamScrollViewIndexedWidgetBuilder = + Widget Function( + BuildContext context, + List items, + int index, + WidgetType defaultWidget, + ); diff --git a/packages/stream_chat_flutter/lib/src/scroll_view/stream_scroll_view_load_more_error.dart b/packages/stream_chat_flutter/lib/src/scroll_view/stream_scroll_view_load_more_error.dart index 0b32058559..f5168d1917 100644 --- a/packages/stream_chat_flutter/lib/src/scroll_view/stream_scroll_view_load_more_error.dart +++ b/packages/stream_chat_flutter/lib/src/scroll_view/stream_scroll_view_load_more_error.dart @@ -74,14 +74,16 @@ class StreamScrollViewLoadMoreError extends StatelessWidget { final errorIcon = AnimatedSwitcher( duration: kThemeChangeDuration, - child: this.errorIcon ?? - const StreamSvgIcon( + child: + this.errorIcon ?? + Icon( + context.streamIcons.retry, color: Colors.white, - icon: StreamSvgIcons.retry, ), ); - final backgroundColor = this.backgroundColor ?? + final backgroundColor = + this.backgroundColor ?? // ignore: deprecated_member_use theme.colorTheme.textLowEmphasis.withOpacity(0.9); diff --git a/packages/stream_chat_flutter/lib/src/scroll_view/stream_scroll_view_load_more_indicator.dart b/packages/stream_chat_flutter/lib/src/scroll_view/stream_scroll_view_load_more_indicator.dart index 7258522590..ab32318d69 100644 --- a/packages/stream_chat_flutter/lib/src/scroll_view/stream_scroll_view_load_more_indicator.dart +++ b/packages/stream_chat_flutter/lib/src/scroll_view/stream_scroll_view_load_more_indicator.dart @@ -18,8 +18,8 @@ class StreamScrollViewLoadMoreIndicator extends StatelessWidget { @override Widget build(BuildContext context) => SizedBox( - height: height, - width: width, - child: const CircularProgressIndicator.adaptive(), - ); + height: height, + width: width, + child: const CircularProgressIndicator.adaptive(), + ); } diff --git a/packages/stream_chat_flutter/lib/src/scroll_view/stream_scroll_view_loading_widget.dart b/packages/stream_chat_flutter/lib/src/scroll_view/stream_scroll_view_loading_widget.dart index 958eb80dc4..b18cab4d55 100644 --- a/packages/stream_chat_flutter/lib/src/scroll_view/stream_scroll_view_loading_widget.dart +++ b/packages/stream_chat_flutter/lib/src/scroll_view/stream_scroll_view_loading_widget.dart @@ -17,8 +17,8 @@ class StreamScrollViewLoadingWidget extends StatelessWidget { @override Widget build(BuildContext context) => SizedBox( - height: height, - width: width, - child: const CircularProgressIndicator.adaptive(), - ); + height: height, + width: width, + child: const CircularProgressIndicator.adaptive(), + ); } diff --git a/packages/stream_chat_flutter/lib/src/scroll_view/thread_scroll_view/stream_thread_list_skeleton_loading.dart b/packages/stream_chat_flutter/lib/src/scroll_view/thread_scroll_view/stream_thread_list_skeleton_loading.dart new file mode 100644 index 0000000000..6a92bc91ee --- /dev/null +++ b/packages/stream_chat_flutter/lib/src/scroll_view/thread_scroll_view/stream_thread_list_skeleton_loading.dart @@ -0,0 +1,107 @@ +import 'package:flutter/material.dart'; +import 'package:stream_core_flutter/stream_core_flutter.dart'; + +/// A shimmer loading placeholder for the thread list view. +/// +/// Displays a skeleton UI with shimmer animation using +/// [StreamSkeletonLoading] and [StreamSkeletonBox] from the core package. +class StreamThreadListSkeletonLoading extends StatelessWidget { + /// Creates a new instance of [StreamThreadListSkeletonLoading]. + const StreamThreadListSkeletonLoading({ + super.key, + this.itemCount = 6, + }); + + /// The number of skeleton items to display. + final int itemCount; + + @override + Widget build(BuildContext context) { + return StreamSkeletonLoading( + child: ListView.separated( + physics: const NeverScrollableScrollPhysics(), + itemCount: itemCount, + separatorBuilder: (context, index) => const SizedBox(height: 1), + itemBuilder: (context, index) => const _StreamThreadListItemSkeleton(), + ), + ); + } +} + +class _StreamThreadListItemSkeleton extends StatelessWidget { + const _StreamThreadListItemSkeleton(); + + @override + Widget build(BuildContext context) { + return Padding( + padding: EdgeInsets.all(context.streamSpacing.sm), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const StreamSkeletonBox.circular(radius: 24), + SizedBox(width: context.streamSpacing.sm), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + flex: 2, + child: StreamSkeletonBox( + width: double.infinity, + height: 12, + borderRadius: BorderRadius.all(context.streamRadius.max), + ), + ), + SizedBox(width: context.streamSpacing.sm), + const Spacer( + flex: 2, + ), + StreamSkeletonBox( + width: 48, + height: 16, + borderRadius: BorderRadius.all(context.streamRadius.max), + ), + ], + ), + SizedBox(height: context.streamSpacing.xs), + Row( + children: [ + Expanded( + child: StreamSkeletonBox( + width: double.infinity, + height: 20, + borderRadius: BorderRadius.all(context.streamRadius.max), + ), + ), + SizedBox(width: context.streamSpacing.sm), + const SizedBox(width: 48), + ], + ), + SizedBox(height: context.streamSpacing.xs), + Row( + children: [ + const StreamSkeletonBox.circular(radius: 12), + SizedBox(width: context.streamSpacing.xs), + StreamSkeletonBox( + width: 64, + height: 12, + borderRadius: BorderRadius.all(context.streamRadius.max), + ), + SizedBox(width: context.streamSpacing.xs), + StreamSkeletonBox( + width: 64, + height: 12, + borderRadius: BorderRadius.all(context.streamRadius.max), + ), + ], + ), + ], + ), + ), + ], + ), + ); + } +} diff --git a/packages/stream_chat_flutter/lib/src/scroll_view/thread_scroll_view/stream_thread_list_tile.dart b/packages/stream_chat_flutter/lib/src/scroll_view/thread_scroll_view/stream_thread_list_tile.dart index 0617a81305..89dbf8140a 100644 --- a/packages/stream_chat_flutter/lib/src/scroll_view/thread_scroll_view/stream_thread_list_tile.dart +++ b/packages/stream_chat_flutter/lib/src/scroll_view/thread_scroll_view/stream_thread_list_tile.dart @@ -1,6 +1,5 @@ import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; -import 'package:stream_chat_flutter/src/misc/timestamp.dart'; import 'package:stream_chat_flutter/stream_chat_flutter.dart'; /// {@template streamThreadListTile} @@ -13,71 +12,161 @@ import 'package:stream_chat_flutter/stream_chat_flutter.dart'; /// {@endtemplate} class StreamThreadListTile extends StatelessWidget { /// {@macro streamThreadListTile} - const StreamThreadListTile({ + StreamThreadListTile({ super.key, + required Thread thread, + User? currentUser, + GestureTapCallback? onTap, + GestureLongPressCallback? onLongPress, + }) : props = StreamThreadListTileProps( + thread: thread, + currentUser: currentUser, + onTap: onTap, + onLongPress: onLongPress, + ); + + /// Creates a thread list tile from pre-built [props]. + const StreamThreadListTile.fromProps({ + super.key, + required this.props, + }); + + /// The properties configuring this thread list tile. + final StreamThreadListTileProps props; + + @override + Widget build(BuildContext context) { + final builder = context.chatComponentBuilder(); + return builder?.call(context, props) ?? _DefaultStreamThreadListTile(props: props); + } +} + +/// Properties for configuring a [StreamThreadListTile]. +class StreamThreadListTileProps { + /// Creates properties for a thread list tile. + const StreamThreadListTileProps({ required this.thread, this.currentUser, this.onTap, this.onLongPress, }); - /// The thread to display. + /// The thread displayed by the tile. final Thread thread; /// The current user. final User? currentUser; - /// Called when the user taps this list tile. + /// Called when the tile is tapped. final GestureTapCallback? onTap; - /// Called when the user long-presses on this list tile. + /// Called when the tile is long pressed. final GestureLongPressCallback? onLongPress; +} + +class _DefaultStreamThreadListTile extends StatelessWidget { + const _DefaultStreamThreadListTile({ + required this.props, + }); + + final StreamThreadListTileProps props; @override Widget build(BuildContext context) { + final spacing = context.streamSpacing; + final theme = StreamThreadListTileTheme.of(context); + final defaults = _StreamThreadListTileThemeDefaults(context); + + final effectiveBackgroundColor = theme.backgroundColor ?? defaults.backgroundColor; + final effectivePadding = theme.padding ?? defaults.padding; + final effectiveChannelNameStyle = theme.threadChannelNameStyle ?? defaults.threadChannelNameStyle; + final effectiveReplyToMessageStyle = theme.threadReplyToMessageStyle ?? defaults.threadReplyToMessageStyle; + final effectiveLatestReplyMessageStyle = + theme.threadLatestReplyMessageStyle ?? defaults.threadLatestReplyMessageStyle; + final effectiveReplyCountStyle = theme.threadReplyCountStyle ?? defaults.threadReplyCountStyle; + final effectiveTimestampStyle = theme.threadLatestReplyTimestampStyle ?? defaults.threadLatestReplyTimestampStyle; + final effectiveTimestampFormatter = + theme.threadLatestReplyTimestampFormatter ?? defaults.threadLatestReplyTimestampFormatter; + final effectiveUnreadCountStyle = theme.threadUnreadMessageCountStyle ?? defaults.threadUnreadMessageCountStyle; + final effectiveUnreadCountBackgroundColor = + theme.threadUnreadMessageCountBackgroundColor ?? defaults.threadUnreadMessageCountBackgroundColor; + final thread = props.thread; + final currentUser = props.currentUser; + final parentMessage = thread.parentMessage; + final latestReply = thread.latestReplies.lastOrNull; + final channel = thread.channel; final language = currentUser?.language; - final unreadMessageCount = thread.read - ?.firstWhereOrNull((read) => read.user.id == currentUser?.id) - ?.unreadMessages; - - return Material( - color: theme.backgroundColor, - child: InkWell( - onTap: onTap, - onLongPress: onLongPress, - child: Container( - padding: theme.padding, - child: Column( - spacing: 6, - mainAxisSize: MainAxisSize.min, + final unreadMessageCount = thread.read?.firstWhereOrNull((read) => read.user.id == currentUser?.id)?.unreadMessages; + final latestActivityAt = thread.lastMessageAt ?? latestReply?.createdAt ?? thread.updatedAt; + final avatarUser = parentMessage?.user ?? latestReply?.user ?? thread.createdBy; + final channelName = + channel?.formatName(currentUser: currentUser) ?? avatarUser?.name ?? context.translations.noTitleText; + final participantUsers = thread.threadParticipants.map((it) => it.user).nonNulls.toList(growable: false); + + return Padding( + padding: EdgeInsets.all(spacing.xxs), + child: StreamListTileTheme( + data: StreamListTileThemeData( + contentPadding: effectivePadding, + backgroundColor: .all(effectiveBackgroundColor), + ), + child: StreamListTileContainer( + onTap: props.onTap, + onLongPress: props.onLongPress, + child: Row( + spacing: spacing.sm, crossAxisAlignment: CrossAxisAlignment.start, - children: [ - if (thread.channel case final channel?) - ThreadTitle( - channelName: channel.formatName(currentUser: currentUser), + children: [ + if (avatarUser case final user?) + StreamUserAvatar( + user: user, + size: StreamAvatarSize.xl, ), - Row( - children: [ - if (thread.parentMessage case final parentMessage?) - Expanded( - child: ThreadReplyToContent( - language: language, - prefix: context.translations.repliedToLabel, - parentMessage: parentMessage, - ), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Row( + spacing: spacing.sm, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: ThreadTitle( + channelName: channelName, + style: effectiveChannelNameStyle, + ), + ), + if (unreadMessageCount case final count? when count > 0) + ThreadUnreadCount( + unreadCount: count, + style: effectiveUnreadCountStyle, + backgroundColor: effectiveUnreadCountBackgroundColor, + ), + ], ), - if (unreadMessageCount case final count? when count > 0) - ThreadUnreadCount(unreadCount: count), - ], - ), - if (thread.latestReplies.lastOrNull case final latestReply?) - ThreadLatestReply( - language: language, - latestReply: latestReply, - draftMessage: thread.draft?.message, + SizedBox(height: spacing.xxs), + ThreadRootMessagePreview( + parentMessage: parentMessage, + channel: channel, + language: language, + style: effectiveReplyToMessageStyle, + emptyStyle: effectiveLatestReplyMessageStyle, + ), + SizedBox(height: spacing.xs), + ThreadFooter( + participantUsers: participantUsers, + replyCount: thread.replyCount, + latestActivityAt: latestActivityAt, + replyCountStyle: effectiveReplyCountStyle, + timestampStyle: effectiveTimestampStyle, + timestampFormatter: effectiveTimestampFormatter, + ), + ], ), + ), ], ), ), @@ -94,80 +183,75 @@ class ThreadTitle extends StatelessWidget { const ThreadTitle({ super.key, this.channelName, + this.style, }); /// The channel name to display. final String? channelName; + /// The text style to use. + /// + /// When null, uses the effective style resolved from the theme and defaults. + final TextStyle? style; + @override Widget build(BuildContext context) { - final theme = StreamThreadListTileTheme.of(context); - - return Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon( - Icons.message_outlined, - size: 16, - color: theme.threadChannelNameStyle?.color, - ), - const SizedBox(width: 4), - Flexible( - child: Text( - channelName ?? context.translations.noTitleText, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: theme.threadChannelNameStyle, - ), - ), - ], + return Text( + channelName ?? context.translations.noTitleText, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: style, ); } } -/// {@template threadReplyToContent} -/// A widget that displays the message the thread is replying to. -/// {@endtemplate} -class ThreadReplyToContent extends StatelessWidget { - /// {@macro threadReplyToContent} - const ThreadReplyToContent({ +/// A widget that displays the original thread message as a single-line preview. +class ThreadRootMessagePreview extends StatelessWidget { + /// Creates a new instance of [ThreadRootMessagePreview]. + const ThreadRootMessagePreview({ super.key, - this.language, - this.prefix = 'replied to:', required this.parentMessage, + this.channel, + this.language, + this.style, + this.emptyStyle, }); - /// The prefix to display before the message. - /// - /// Defaults to `replied to:`. - final String prefix; + /// The root message of the thread. + final Message? parentMessage; - /// The language of the message. + /// The channel the thread belongs to. + final ChannelModel? channel; + + /// The language used for translations. final String? language; - /// The message the thread is replying to. - final Message parentMessage; + /// The text style used for the message preview. + /// + /// When null, uses the effective style resolved from the theme and defaults. + final TextStyle? style; + + /// The text style used when no parent message is available. + /// + /// When null, uses the effective style resolved from the theme and defaults. + final TextStyle? emptyStyle; @override Widget build(BuildContext context) { - final theme = StreamThreadListTileTheme.of(context); + if (parentMessage case final message?) { + return StreamMessagePreviewText( + message: message, + channel: channel, + language: language, + textStyle: style, + ); + } - return Row( - mainAxisSize: MainAxisSize.min, - children: [ - Text( - prefix, - style: theme.threadReplyToMessageStyle, - ), - const SizedBox(width: 4), - Flexible( - child: StreamMessagePreviewText( - language: language, - message: parentMessage, - textStyle: theme.threadReplyToMessageStyle, - ), - ), - ], + return Text( + context.translations.emptyMessagesText, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: emptyStyle, ); } } @@ -180,93 +264,135 @@ class ThreadUnreadCount extends StatelessWidget { const ThreadUnreadCount({ super.key, required this.unreadCount, + this.style, + this.backgroundColor, }) : assert(unreadCount > 0, 'unreadCount must be greater than 0'); /// The number of unread messages. final int unreadCount; + /// The text style for the badge label. + /// + /// When null, uses the effective style resolved from the theme and defaults. + final TextStyle? style; + + /// The background color for the badge. + /// + /// When null, uses the effective color resolved from the theme and defaults. + final Color? backgroundColor; + @override Widget build(BuildContext context) { - final theme = StreamThreadListTileTheme.of(context); - return Badge( - textStyle: theme.threadUnreadMessageCountStyle, - textColor: theme.threadUnreadMessageCountStyle?.color, - backgroundColor: theme.threadUnreadMessageCountBackgroundColor, + textStyle: style, + textColor: style?.color, + backgroundColor: backgroundColor, + largeSize: 20, label: Text('$unreadCount'), ); } } -/// {@template threadLatestReply} -/// A widget that displays the latest reply in the thread. -/// {@endtemplate} -class ThreadLatestReply extends StatelessWidget { - /// {@macro threadLatestReply} - const ThreadLatestReply({ +/// A widget that displays reply metadata for a thread. +class ThreadFooter extends StatelessWidget { + /// Creates a new instance of [ThreadFooter]. + const ThreadFooter({ super.key, - this.language, - this.draftMessage, - required this.latestReply, + required this.participantUsers, + required this.replyCount, + required this.latestActivityAt, + this.replyCountStyle, + this.timestampStyle, + this.timestampFormatter, }); - /// The language of the message. - final String? language; + /// Users participating in the thread. + final List participantUsers; + + /// The number of replies in the thread. + final int replyCount; - /// The draft message in the thread. - final DraftMessage? draftMessage; + /// The latest activity time in the thread. + final DateTime latestActivityAt; - /// The latest reply in the thread. - final Message latestReply; + /// The text style for the reply count label. + /// + /// When null, uses the effective style resolved from the theme and defaults. + final TextStyle? replyCountStyle; + + /// The text style for the timestamp. + /// + /// When null, uses the effective style resolved from the theme and defaults. + final TextStyle? timestampStyle; + + /// The formatter to use for the timestamp. + /// + /// When null, uses [formatRecentDateTime]. + final DateFormatter? timestampFormatter; @override Widget build(BuildContext context) { - final theme = StreamThreadListTileTheme.of(context); + final spacing = context.streamSpacing; return Row( - spacing: 8, - children: [ - if (latestReply.user case final user?) StreamUserAvatar(user: user), - Expanded( - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - latestReply.user!.name, - style: theme.threadLatestReplyUsernameStyle, - ), - Row( - children: [ - Expanded( - child: Builder( - builder: (context) { - if (draftMessage case final draftMessage?) { - return StreamDraftMessagePreviewText( - draftMessage: draftMessage, - textStyle: theme.threadLatestReplyMessageStyle, - ); - } - - return StreamMessagePreviewText( - language: language, - message: latestReply, - textStyle: theme.threadLatestReplyMessageStyle, - ); - }, - ), - ), - StreamTimestamp( - date: latestReply.createdAt.toLocal(), - style: theme.threadLatestReplyTimestampStyle, - formatter: theme.threadLatestReplyTimestampFormatter, - ), - ], - ), - ], + spacing: spacing.xs, + children: [ + if (participantUsers.isNotEmpty) + StreamUserAvatarStack( + users: participantUsers, + size: StreamAvatarStackSize.sm, + max: 3, ), + Text( + context.translations.threadReplyCountText(replyCount), + style: replyCountStyle, + ), + StreamTimestamp( + date: latestActivityAt.toLocal(), + style: timestampStyle, + formatter: timestampFormatter ?? formatRecentDateTime, ), ], ); } } + +class _StreamThreadListTileThemeDefaults extends StreamThreadListTileThemeData { + _StreamThreadListTileThemeDefaults(this._context); + + final BuildContext _context; + + late final _spacing = _context.streamSpacing; + late final _colorScheme = _context.streamColorScheme; + late final _textTheme = _context.streamTextTheme; + + @override + EdgeInsetsGeometry get padding => EdgeInsets.all(_spacing.sm); + + @override + Color get backgroundColor => _colorScheme.backgroundApp; + + @override + TextStyle get threadChannelNameStyle => _textTheme.captionEmphasis.copyWith(color: _colorScheme.textTertiary); + + @override + TextStyle get threadReplyToMessageStyle => _textTheme.bodyDefault; + + @override + TextStyle get threadLatestReplyMessageStyle => _textTheme.bodyDefault; + + @override + TextStyle get threadReplyCountStyle => _textTheme.captionEmphasis.copyWith(color: _colorScheme.textLink); + + @override + TextStyle get threadLatestReplyTimestampStyle => _textTheme.captionDefault.copyWith(color: _colorScheme.textTertiary); + + @override + DateFormatter get threadLatestReplyTimestampFormatter => formatRecentDateTime; + + @override + TextStyle get threadUnreadMessageCountStyle => _textTheme.numericXl.copyWith(color: _colorScheme.textOnAccent); + + @override + Color get threadUnreadMessageCountBackgroundColor => _colorScheme.accentPrimary; +} diff --git a/packages/stream_chat_flutter/lib/src/scroll_view/thread_scroll_view/stream_thread_list_view.dart b/packages/stream_chat_flutter/lib/src/scroll_view/thread_scroll_view/stream_thread_list_view.dart index b8b2d293e3..3601033c05 100644 --- a/packages/stream_chat_flutter/lib/src/scroll_view/thread_scroll_view/stream_thread_list_view.dart +++ b/packages/stream_chat_flutter/lib/src/scroll_view/thread_scroll_view/stream_thread_list_view.dart @@ -1,9 +1,6 @@ import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; -import 'package:stream_chat_flutter/src/scroll_view/stream_scroll_view_error_widget.dart'; -import 'package:stream_chat_flutter/src/scroll_view/stream_scroll_view_load_more_error.dart'; -import 'package:stream_chat_flutter/src/scroll_view/stream_scroll_view_load_more_indicator.dart'; -import 'package:stream_chat_flutter/src/scroll_view/stream_scroll_view_loading_widget.dart'; +import 'package:stream_chat_flutter/src/scroll_view/thread_scroll_view/stream_thread_list_skeleton_loading.dart'; import 'package:stream_chat_flutter/stream_chat_flutter.dart'; /// Default separator builder for [StreamThreadListView]. @@ -11,17 +8,24 @@ Widget defaultThreadListViewSeparatorBuilder( BuildContext context, List threads, int index, -) => - const StreamThreadListSeparator(); +) => const StreamThreadListSeparator(); /// Signature for the item builder that creates the children of the /// [StreamThreadListView]. -typedef StreamThreadListViewIndexedWidgetBuilder - = StreamScrollViewIndexedWidgetBuilder; +/// +typedef StreamThreadListViewIndexedWidgetBuilder = StreamScrollViewIndexedWidgetBuilder; /// {@template streamThreadListView} -/// A [ListView] that shows a list of [Thread]'s. It uses a -/// [StreamThreadListController] to load the threads in paginated form. +/// A [ListView] that shows a list of [Thread]'s the current user participated +/// in. +/// +/// Uses a [StreamThreadListController] to load threads in paginated form. +/// +/// Wrap with [StreamUnreadThreadsBanner] to show a banner above the list when +/// new unseen threads are available. +/// +/// Each row is rendered using [StreamThreadListTile], which can be customized +/// app-wide through [StreamComponentFactory]. /// /// Example: /// @@ -29,16 +33,14 @@ typedef StreamThreadListViewIndexedWidgetBuilder /// StreamThreadListView( /// controller: controller, /// onThreadTap: (thread) { -/// // Handle thread tap event -/// }, -/// onThreadLongPress: (thread) { -/// // Handle thread long press event +/// // Navigate to thread conversation /// }, /// ) /// ``` /// /// See also: -/// * [StreamThreadListTile] +/// * [StreamUnreadThreadsBanner], which wraps this view to show new threads. +/// * [StreamMessageItem], which renders each thread's parent message. /// * [StreamThreadListController] /// {@endtemplate} class StreamThreadListView extends StatelessWidget { @@ -75,6 +77,7 @@ class StreamThreadListView extends StatelessWidget { final StreamThreadListController controller; /// A builder that is called to build items in the [ListView]. + /// final StreamThreadListViewIndexedWidgetBuilder? itemBuilder; /// A builder that is called to build the list separator. @@ -89,10 +92,10 @@ class StreamThreadListView extends StatelessWidget { /// A builder that is called to build the error state of the list. final Widget Function(BuildContext, StreamChatError)? errorBuilder; - /// Called when the user taps this list tile. + /// Called when the user taps a thread. final void Function(Thread)? onThreadTap; - /// Called when the user long-presses on this list tile. + /// Called when the user long-presses on a thread. final void Function(Thread)? onThreadLongPress; /// The index to take into account when triggering [controller.loadMore]. @@ -301,7 +304,6 @@ class StreamThreadListView extends StatelessWidget { final currentUser = StreamChat.of(context).currentUser; final onTap = onThreadTap; final onLongPress = onThreadLongPress; - final tile = StreamThreadListTile( thread: thread, currentUser: currentUser, @@ -311,41 +313,28 @@ class StreamThreadListView extends StatelessWidget { return itemBuilder?.call(context, threads, index, tile) ?? tile; }, - emptyBuilder: (context) { - final chatThemeData = StreamChatTheme.of(context); - return emptyBuilder?.call(context) ?? - Center( - child: Padding( - padding: const EdgeInsets.all(8), - child: StreamScrollViewEmptyWidget( - emptyIcon: StreamSvgIcon( - size: 148, - icon: StreamSvgIcons.threadReply, - color: chatThemeData.colorTheme.disabled, - ), - emptyTitle: Text( - context.translations.emptyMessagesText, - style: chatThemeData.textTheme.headline, - ), - ), - ), - ); - }, - loadMoreErrorBuilder: (context, error) => - StreamScrollViewLoadMoreError.list( + emptyBuilder: (context) => + emptyBuilder?.call(context) ?? + Center( + child: StreamScrollViewEmptyWidget( + emptyIcon: Icon(context.streamIcons.messageBubblesLarge), + emptyTitle: Text(context.translations.replyToStartThreadText), + ), + ), + loadMoreErrorBuilder: (context, error) => StreamScrollViewLoadMoreError.list( onTap: controller.retry, error: Text(context.translations.loadingMessagesError), ), - loadMoreIndicatorBuilder: (context) => const Center( + loadMoreIndicatorBuilder: (context) => Center( child: Padding( - padding: EdgeInsets.all(16), - child: StreamScrollViewLoadMoreIndicator(), + padding: const EdgeInsets.all(16), + child: StreamLoadingSpinner(), ), ), loadingBuilder: (context) => loadingBuilder?.call(context) ?? const Center( - child: StreamScrollViewLoadingWidget(), + child: StreamThreadListSkeletonLoading(), ), errorBuilder: (context, error) => errorBuilder?.call(context, error) ?? @@ -367,11 +356,7 @@ class StreamThreadListSeparator extends StatelessWidget { @override Widget build(BuildContext context) { - final effect = StreamChatTheme.of(context).colorTheme.borderBottom; - return Container( - height: 1, - // ignore: deprecated_member_use - color: effect.color!.withOpacity(effect.alpha ?? 1.0), - ); + final colorScheme = context.streamColorScheme; + return Divider(height: 1, color: colorScheme.borderSubtle); } } diff --git a/packages/stream_chat_flutter/lib/src/scroll_view/thread_scroll_view/stream_unread_threads_banner.dart b/packages/stream_chat_flutter/lib/src/scroll_view/thread_scroll_view/stream_unread_threads_banner.dart index 088203d4fd..1890b22404 100644 --- a/packages/stream_chat_flutter/lib/src/scroll_view/thread_scroll_view/stream_unread_threads_banner.dart +++ b/packages/stream_chat_flutter/lib/src/scroll_view/thread_scroll_view/stream_unread_threads_banner.dart @@ -1,86 +1,159 @@ import 'package:flutter/material.dart'; -import 'package:stream_chat_flutter/src/icons/stream_svg_icon.dart'; import 'package:stream_chat_flutter/src/misc/empty_widget.dart'; -import 'package:stream_chat_flutter/src/theme/stream_chat_theme.dart'; import 'package:stream_chat_flutter/src/utils/extensions.dart'; +import 'package:stream_core_flutter/stream_core_flutter.dart'; /// {@template unreadThreadsBanner} -/// A widget that shows a banner with the number of unread threads. +/// A widget that displays an unread-threads banner. /// -/// This widget can be used to show a banner with the number of unread threads -/// on the top of the [ThreadListView]. +/// When a [child] is provided, the banner appears above it — similar to how +/// [RefreshIndicator] wraps a scrollable. When [child] is omitted, the widget +/// renders only the banner itself. +/// +/// When [enabled] is `false` (the default), the banner is hidden and only the +/// [child] (if any) is rendered. Set [enabled] to `true` and provide +/// [unreadThreads] to show the banner. +/// +/// Example: +/// +/// ```dart +/// StreamUnreadThreadsBanner( +/// enabled: true, +/// unreadThreads: unseenThreadIds, +/// onRefresh: () async { +/// await controller.refresh(resetValue: false); +/// controller.clearUnseenThreadIds(); +/// }, +/// child: StreamThreadListView(controller: controller), +/// ) +/// ``` /// {@endtemplate} -class StreamUnreadThreadsBanner extends StatelessWidget { +class StreamUnreadThreadsBanner extends StatefulWidget { /// {@macro unreadThreadsBanner} const StreamUnreadThreadsBanner({ super.key, - required this.unreadThreads, - this.onTap, - this.minHeight = 52, - this.margin = const EdgeInsets.symmetric(horizontal: 8, vertical: 6), - this.padding = const EdgeInsets.symmetric(horizontal: 16), + this.child, + this.enabled = false, + this.unreadThreads = const {}, + this.onRefresh, + this.margin = EdgeInsets.zero, + this.padding, }); - /// The set of all the unread threads. - final Set unreadThreads; + /// The widget below the banner in the tree. + /// + /// When `null`, only the banner is rendered without any wrapped content. + final Widget? child; + + /// Whether the banner is enabled. + /// + /// When `false`, the banner is hidden and only [child] is rendered. + /// + /// Defaults to `false`. + final bool enabled; - /// Optional callback to handle tap events. - final VoidCallback? onTap; + /// The set of all the unread thread IDs. + final Set unreadThreads; - /// The minimum height of the banner. + /// Called when the user taps the banner. /// - /// Defaults to 52. - final double minHeight; + /// While the returned [Future] is pending, the banner shows a loading + /// spinner instead of the refresh icon and label. + final Future Function()? onRefresh; /// The margin applied to the banner. /// - /// Defaults to `EdgeInsets.symmetric(horizontal: 8, vertical: 6)`. + /// Defaults to [EdgeInsets.zero]. final EdgeInsetsGeometry? margin; /// The padding applied to the banner. /// - /// Defaults to `EdgeInsets.symmetric(horizontal: 16)`. - final EdgeInsetsGeometry padding; + /// Defaults to `EdgeInsets.all(spacing.sm)`. + final EdgeInsetsGeometry? padding; @override - Widget build(BuildContext context) { - if (unreadThreads.isEmpty) { - return const Empty(); + State createState() => _StreamUnreadThreadsBannerState(); +} + +class _StreamUnreadThreadsBannerState extends State { + bool _isRefreshing = false; + + Future _handleTap() async { + if (_isRefreshing) return; + + setState(() => _isRefreshing = true); + try { + await widget.onRefresh?.call(); + } finally { + if (mounted) setState(() => _isRefreshing = false); } + } + + @override + Widget build(BuildContext context) { + final banner = widget.enabled ? _buildBanner(context) : null; + final child = widget.child; + + if (child == null) return banner ?? const Empty(); + + return Column( + children: [ + if (banner != null) banner, + Expanded(child: child), + ], + ); + } - final theme = StreamChatTheme.of(context); + Widget _buildBanner(BuildContext context) { + final isVisible = _isRefreshing || widget.unreadThreads.isNotEmpty; + if (!isVisible) return const Empty(); return GestureDetector( - onTap: onTap, + onTap: _isRefreshing ? null : _handleTap, child: Container( - margin: margin, - padding: padding, - constraints: BoxConstraints(minHeight: minHeight), - decoration: BoxDecoration( - color: theme.colorTheme.textHighEmphasis, - borderRadius: BorderRadius.circular(16), + margin: widget.margin, + padding: widget.padding ?? EdgeInsets.all(context.streamSpacing.sm), + color: context.streamColorScheme.backgroundSurface, + child: _isRefreshing ? _buildLoading(context) : _buildContent(context), + ), + ); + } + + Widget _buildLoading(BuildContext context) { + return Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + StreamLoadingSpinner( + color: context.streamColorScheme.textSecondary, ), - child: Row( - children: [ - Expanded( - child: Text( - context.translations.newThreadsLabel( - count: unreadThreads.length, - ), - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: theme.textTheme.headline.copyWith( - color: theme.colorTheme.barsBg, - ), - ), - ), - StreamSvgIcon( - icon: StreamSvgIcons.reload, - color: theme.colorTheme.barsBg, - ), - ], + SizedBox(width: context.streamSpacing.xs), + Text( + context.translations.loadingLabel, + style: context.streamTextTheme.metadataEmphasis, ), - ), + ], + ); + } + + Widget _buildContent(BuildContext context) { + return Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + context.streamIcons.refresh, + size: 20, + color: context.streamColorScheme.textSecondary, + ), + SizedBox(width: context.streamSpacing.xs), + Text( + context.translations.newThreadsLabel( + count: widget.unreadThreads.length, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: context.streamTextTheme.metadataEmphasis, + ), + ], ); } } diff --git a/packages/stream_chat_flutter/lib/src/scroll_view/user_scroll_view/stream_user_grid_tile.dart b/packages/stream_chat_flutter/lib/src/scroll_view/user_scroll_view/stream_user_grid_tile.dart index 4d512f05ea..0a3a331365 100644 --- a/packages/stream_chat_flutter/lib/src/scroll_view/user_scroll_view/stream_user_grid_tile.dart +++ b/packages/stream_chat_flutter/lib/src/scroll_view/user_scroll_view/stream_user_grid_tile.dart @@ -45,33 +45,21 @@ class StreamUserGridTile extends StatelessWidget { Widget? footer, GestureTapCallback? onTap, GestureLongPressCallback? onLongPress, - }) => - StreamUserGridTile( - key: key ?? this.key, - user: user ?? this.user, - footer: footer ?? this.footer, - onTap: onTap ?? this.onTap, - onLongPress: onLongPress ?? this.onLongPress, - child: child ?? this.child, - ); + }) => StreamUserGridTile( + key: key ?? this.key, + user: user ?? this.user, + footer: footer ?? this.footer, + onTap: onTap ?? this.onTap, + onLongPress: onLongPress ?? this.onLongPress, + child: child ?? this.child, + ); @override Widget build(BuildContext context) { - final child = this.child ?? - StreamUserAvatar( - user: user, - borderRadius: BorderRadius.circular(32), - constraints: const BoxConstraints.tightFor( - height: 64, - width: 64, - ), - onlineIndicatorConstraints: const BoxConstraints.tightFor( - height: 12, - width: 12, - ), - ); + final child = this.child ?? StreamUserAvatar(size: .xl, user: user); - final footer = this.footer ?? + final footer = + this.footer ?? Padding( padding: const EdgeInsets.symmetric(horizontal: 8), child: Text( diff --git a/packages/stream_chat_flutter/lib/src/scroll_view/user_scroll_view/stream_user_grid_view.dart b/packages/stream_chat_flutter/lib/src/scroll_view/user_scroll_view/stream_user_grid_view.dart index 5aac833955..4108d9b6f7 100644 --- a/packages/stream_chat_flutter/lib/src/scroll_view/user_scroll_view/stream_user_grid_view.dart +++ b/packages/stream_chat_flutter/lib/src/scroll_view/user_scroll_view/stream_user_grid_view.dart @@ -1,19 +1,13 @@ import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; -import 'package:stream_chat_flutter/src/scroll_view/stream_scroll_view_error_widget.dart'; -import 'package:stream_chat_flutter/src/scroll_view/stream_scroll_view_load_more_error.dart'; -import 'package:stream_chat_flutter/src/scroll_view/stream_scroll_view_load_more_indicator.dart'; -import 'package:stream_chat_flutter/src/scroll_view/stream_scroll_view_loading_widget.dart'; import 'package:stream_chat_flutter/stream_chat_flutter.dart'; /// Default grid delegate for [StreamUserGridView]. -const defaultUserGridViewDelegate = - SliverGridDelegateWithFixedCrossAxisCount(crossAxisCount: 4); +const defaultUserGridViewDelegate = SliverGridDelegateWithFixedCrossAxisCount(crossAxisCount: 4); /// Signature for the item builder that creates the children of the /// [StreamUserGridView]. -typedef StreamUserGridViewIndexedWidgetBuilder - = StreamScrollViewIndexedWidgetBuilder; +typedef StreamUserGridViewIndexedWidgetBuilder = StreamScrollViewIndexedWidgetBuilder; /// A [GridView] that shows a grid of [User]s, /// it uses [StreamUserGridTile] as a default item. @@ -342,38 +336,25 @@ class StreamUserGridView extends StatelessWidget { ) ?? streamUserGridTile; }, - emptyBuilder: (context) { - final chatThemeData = StreamChatTheme.of(context); - return emptyBuilder?.call(context) ?? - Center( - child: Padding( - padding: const EdgeInsets.all(8), - child: StreamScrollViewEmptyWidget( - emptyIcon: StreamSvgIcon( - size: 148, - icon: StreamSvgIcons.user, - color: chatThemeData.colorTheme.disabled, - ), - emptyTitle: Text( - context.translations.noUsersLabel, - style: chatThemeData.textTheme.headline, - ), - ), - ), - ); - }, - loadMoreErrorBuilder: (context, error) => - StreamScrollViewLoadMoreError.grid( + emptyBuilder: (context) => + emptyBuilder?.call(context) ?? + Center( + child: StreamScrollViewEmptyWidget( + emptyIcon: Icon(context.streamIcons.user), + emptyTitle: Text(context.translations.noUsersLabel), + ), + ), + loadMoreErrorBuilder: (context, error) => StreamScrollViewLoadMoreError.grid( onTap: controller.retry, error: Text( context.translations.loadingUsersError, textAlign: TextAlign.center, ), ), - loadMoreIndicatorBuilder: (context) => const Center( + loadMoreIndicatorBuilder: (context) => Center( child: Padding( - padding: EdgeInsets.all(16), - child: StreamScrollViewLoadMoreIndicator(), + padding: const EdgeInsets.all(16), + child: StreamLoadingSpinner(), ), ), loadingBuilder: (context) => diff --git a/packages/stream_chat_flutter/lib/src/scroll_view/user_scroll_view/stream_user_list_tile.dart b/packages/stream_chat_flutter/lib/src/scroll_view/user_scroll_view/stream_user_list_tile.dart index 32129c0110..0e2c7135d8 100644 --- a/packages/stream_chat_flutter/lib/src/scroll_view/user_scroll_view/stream_user_list_tile.dart +++ b/packages/stream_chat_flutter/lib/src/scroll_view/user_scroll_view/stream_user_list_tile.dart @@ -105,49 +105,44 @@ class StreamUserListTile extends StatelessWidget { Color? tileColor, VisualDensity? visualDensity, EdgeInsetsGeometry? contentPadding, - }) => - StreamUserListTile( - key: key ?? this.key, - user: user ?? this.user, - leading: leading ?? this.leading, - title: title ?? this.title, - subtitle: subtitle ?? this.subtitle, - selectedWidget: selectedWidget ?? this.selectedWidget, - selected: selected ?? this.selected, - onTap: onTap ?? this.onTap, - onLongPress: onLongPress ?? this.onLongPress, - tileColor: tileColor ?? this.tileColor, - visualDensity: visualDensity ?? this.visualDensity, - contentPadding: contentPadding ?? this.contentPadding, - ); + }) => StreamUserListTile( + key: key ?? this.key, + user: user ?? this.user, + leading: leading ?? this.leading, + title: title ?? this.title, + subtitle: subtitle ?? this.subtitle, + selectedWidget: selectedWidget ?? this.selectedWidget, + selected: selected ?? this.selected, + onTap: onTap ?? this.onTap, + onLongPress: onLongPress ?? this.onLongPress, + tileColor: tileColor ?? this.tileColor, + visualDensity: visualDensity ?? this.visualDensity, + contentPadding: contentPadding ?? this.contentPadding, + ); @override Widget build(BuildContext context) { final chatThemeData = StreamChatTheme.of(context); - final leading = this.leading ?? - StreamUserAvatar( - user: user, - constraints: const BoxConstraints.tightFor( - height: 40, - width: 40, - ), - ); + final leading = this.leading ?? StreamUserAvatar(size: .lg, user: user); - final title = this.title ?? + final title = + this.title ?? Text( user.name, style: chatThemeData.textTheme.bodyBold, ); - final subtitle = this.subtitle ?? + final subtitle = + this.subtitle ?? UserLastActive( user: user, ); - final selectedWidget = this.selectedWidget ?? - StreamSvgIcon( - icon: StreamSvgIcons.checkSend, + final selectedWidget = + this.selectedWidget ?? + Icon( + context.streamIcons.checkmark, color: chatThemeData.colorTheme.accentPrimary, ); @@ -184,7 +179,7 @@ class UserLastActive extends StatelessWidget { user.online ? context.translations.userOnlineText : '${context.translations.userLastOnlineText} ' - '${Jiffy.parseFromDateTime(lastActive).fromNow()}', + '${Jiffy.parseFromDateTime(lastActive).fromNow()}', style: chatTheme.textTheme.footnote.copyWith( // ignore: deprecated_member_use color: chatTheme.colorTheme.textHighEmphasis.withOpacity(0.5), diff --git a/packages/stream_chat_flutter/lib/src/scroll_view/user_scroll_view/stream_user_list_view.dart b/packages/stream_chat_flutter/lib/src/scroll_view/user_scroll_view/stream_user_list_view.dart index df2530aad0..3c5bef0193 100644 --- a/packages/stream_chat_flutter/lib/src/scroll_view/user_scroll_view/stream_user_list_view.dart +++ b/packages/stream_chat_flutter/lib/src/scroll_view/user_scroll_view/stream_user_list_view.dart @@ -1,9 +1,5 @@ import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; -import 'package:stream_chat_flutter/src/scroll_view/stream_scroll_view_error_widget.dart'; -import 'package:stream_chat_flutter/src/scroll_view/stream_scroll_view_load_more_error.dart'; -import 'package:stream_chat_flutter/src/scroll_view/stream_scroll_view_load_more_indicator.dart'; -import 'package:stream_chat_flutter/src/scroll_view/stream_scroll_view_loading_widget.dart'; import 'package:stream_chat_flutter/stream_chat_flutter.dart'; /// Default separator builder for [StreamUserListView]. @@ -11,13 +7,11 @@ Widget defaultUserListViewSeparatorBuilder( BuildContext context, List users, int index, -) => - const StreamUserListSeparator(); +) => const StreamUserListSeparator(); /// Signature for the item builder that creates the children of the /// [StreamUserListView]. -typedef StreamUserListViewIndexedWidgetBuilder - = StreamScrollViewIndexedWidgetBuilder; +typedef StreamUserListViewIndexedWidgetBuilder = StreamScrollViewIndexedWidgetBuilder; /// A [ListView] that shows a list of [User]s, /// it uses [StreamUserListTile] as a default item. @@ -278,88 +272,75 @@ class StreamUserListView extends StatelessWidget { @override Widget build(BuildContext context) => PagedValueListView( - scrollDirection: scrollDirection, - padding: padding, - physics: physics, - reverse: reverse, - controller: controller, - scrollController: scrollController, - primary: primary, - shrinkWrap: shrinkWrap, - addAutomaticKeepAlives: addAutomaticKeepAlives, - addRepaintBoundaries: addRepaintBoundaries, - addSemanticIndexes: addSemanticIndexes, - keyboardDismissBehavior: keyboardDismissBehavior, - restorationId: restorationId, - dragStartBehavior: dragStartBehavior, - cacheExtent: cacheExtent, - clipBehavior: clipBehavior, - loadMoreTriggerIndex: loadMoreTriggerIndex, - separatorBuilder: separatorBuilder, - itemBuilder: (context, users, index) { - final user = users[index]; - final onTap = onUserTap; - final onLongPress = onUserLongPress; - - final streamUserListTile = StreamUserListTile( - user: user, - onTap: onTap == null ? null : () => onTap(user), - onLongPress: onLongPress == null ? null : () => onLongPress(user), - ); - - return itemBuilder?.call( - context, - users, - index, - streamUserListTile, - ) ?? - streamUserListTile; - }, - emptyBuilder: (context) { - final chatThemeData = StreamChatTheme.of(context); - return emptyBuilder?.call(context) ?? - Center( - child: Padding( - padding: const EdgeInsets.all(8), - child: StreamScrollViewEmptyWidget( - emptyIcon: StreamSvgIcon( - size: 148, - icon: StreamSvgIcons.user, - color: chatThemeData.colorTheme.disabled, - ), - emptyTitle: Text( - context.translations.noUsersLabel, - style: chatThemeData.textTheme.headline, - ), - ), - ), - ); - }, - loadMoreErrorBuilder: (context, error) => - StreamScrollViewLoadMoreError.list( - onTap: controller.retry, - error: Text(context.translations.loadingUsersError), + scrollDirection: scrollDirection, + padding: padding, + physics: physics, + reverse: reverse, + controller: controller, + scrollController: scrollController, + primary: primary, + shrinkWrap: shrinkWrap, + addAutomaticKeepAlives: addAutomaticKeepAlives, + addRepaintBoundaries: addRepaintBoundaries, + addSemanticIndexes: addSemanticIndexes, + keyboardDismissBehavior: keyboardDismissBehavior, + restorationId: restorationId, + dragStartBehavior: dragStartBehavior, + cacheExtent: cacheExtent, + clipBehavior: clipBehavior, + loadMoreTriggerIndex: loadMoreTriggerIndex, + separatorBuilder: separatorBuilder, + itemBuilder: (context, users, index) { + final user = users[index]; + final onTap = onUserTap; + final onLongPress = onUserLongPress; + + final streamUserListTile = StreamUserListTile( + user: user, + onTap: onTap == null ? null : () => onTap(user), + onLongPress: onLongPress == null ? null : () => onLongPress(user), + ); + + return itemBuilder?.call( + context, + users, + index, + streamUserListTile, + ) ?? + streamUserListTile; + }, + emptyBuilder: (context) => + emptyBuilder?.call(context) ?? + Center( + child: StreamScrollViewEmptyWidget( + emptyIcon: Icon(context.streamIcons.user), + emptyTitle: Text(context.translations.noUsersLabel), + ), ), - loadMoreIndicatorBuilder: (context) => const Center( - child: Padding( - padding: EdgeInsets.all(16), - child: StreamScrollViewLoadMoreIndicator(), + loadMoreErrorBuilder: (context, error) => StreamScrollViewLoadMoreError.list( + onTap: controller.retry, + error: Text(context.translations.loadingUsersError), + ), + loadMoreIndicatorBuilder: (context) => Center( + child: Padding( + padding: const EdgeInsets.all(16), + child: StreamLoadingSpinner(), + ), + ), + loadingBuilder: (context) => + loadingBuilder?.call(context) ?? + const Center( + child: StreamScrollViewLoadingWidget(), + ), + errorBuilder: (context, error) => + errorBuilder?.call(context, error) ?? + Center( + child: StreamScrollViewErrorWidget( + errorTitle: Text(context.translations.loadingUsersError), + onRetryPressed: controller.refresh, ), ), - loadingBuilder: (context) => - loadingBuilder?.call(context) ?? - const Center( - child: StreamScrollViewLoadingWidget(), - ), - errorBuilder: (context, error) => - errorBuilder?.call(context, error) ?? - Center( - child: StreamScrollViewErrorWidget( - errorTitle: Text(context.translations.loadingUsersError), - onRetryPressed: controller.refresh, - ), - ), - ); + ); } /// A widget that is used to display a separator between @@ -370,11 +351,7 @@ class StreamUserListSeparator extends StatelessWidget { @override Widget build(BuildContext context) { - final effect = StreamChatTheme.of(context).colorTheme.borderBottom; - return Container( - height: 1, - // ignore: deprecated_member_use - color: effect.color!.withOpacity(effect.alpha ?? 1.0), - ); + final colorScheme = context.streamColorScheme; + return Divider(height: 1, color: colorScheme.borderSubtle); } } diff --git a/packages/stream_chat_flutter/lib/src/stream_chat.dart b/packages/stream_chat_flutter/lib/src/stream_chat.dart index 33aef8d964..34b7263a57 100644 --- a/packages/stream_chat_flutter/lib/src/stream_chat.dart +++ b/packages/stream_chat_flutter/lib/src/stream_chat.dart @@ -37,8 +37,9 @@ class StreamChat extends StatefulWidget { required this.child, this.streamChatThemeData, this.streamChatConfigData, + this.componentBuilders, this.onBackgroundEventReceived, - this.backgroundKeepAlive = const Duration(minutes: 1), + this.backgroundKeepAlive = const Duration(seconds: 15), this.connectivityStream, }); @@ -54,6 +55,36 @@ class StreamChat extends StatefulWidget { /// Non-theme related UI configuration options. final StreamChatConfigurationData? streamChatConfigData; + /// Custom component builders for overriding default UI components. + /// + /// When provided, a [StreamComponentFactory] is inserted into the widget + /// tree below the theme and above [StreamChatCore], allowing all descendant + /// widgets to resolve custom builders. + /// + /// {@tool snippet} + /// + /// Override the default message item with a custom builder: + /// + /// ```dart + /// StreamChat( + /// client: client, + /// componentBuilders: StreamComponentBuilders( + /// extensions: streamChatComponentBuilders( + /// messageItem: (context, props) { + /// return DefaultStreamMessageItem( + /// props: props.copyWith( + /// actionsBuilder: myActionsBuilder, + /// ), + /// ); + /// }, + /// ), + /// ), + /// child: MyApp(), + /// ) + /// ``` + /// {@end-tool} + final StreamComponentBuilders? componentBuilders; + /// The amount of time that will pass before disconnecting the client /// in the background final Duration backgroundKeepAlive; @@ -141,8 +172,7 @@ class StreamChatState extends State { StreamChatClient get client => widget.client; /// Gets configuration options from widget - StreamChatConfigurationData get streamChatConfigData => - widget.streamChatConfigData ?? StreamChatConfigurationData(); + StreamChatConfigurationData get streamChatConfigData => widget.streamChatConfigData ?? StreamChatConfigurationData(); @override void initState() { @@ -156,39 +186,39 @@ class StreamChatState extends State { @override Widget build(BuildContext context) { final theme = _getTheme(context, widget.streamChatThemeData); - return Portal( - child: StreamChatConfiguration( - data: streamChatConfigData, - child: StreamChatTheme( - data: theme, - child: Builder( - builder: (context) { - final materialTheme = Theme.of(context); - final streamTheme = StreamChatTheme.of(context); - return Theme( - data: materialTheme.copyWith( - primaryIconTheme: streamTheme.primaryIconTheme, - colorScheme: materialTheme.colorScheme.copyWith( - secondary: streamTheme.colorTheme.accentPrimary, - ), - ), - child: StreamChatCore( - client: client, - onBackgroundEventReceived: widget.onBackgroundEventReceived, - backgroundKeepAlive: widget.backgroundKeepAlive, - connectivityStream: widget.connectivityStream, - child: Builder( - builder: (context) { - return widget.child ?? const Empty(); - }, - ), - ), - ); - }, - ), - ), + + Widget child = StreamChatTheme( + data: theme, + child: Builder( + builder: (context) { + final materialTheme = Theme.of(context); + final streamTheme = StreamChatTheme.of(context); + return Theme( + data: materialTheme.copyWith( + primaryIconTheme: streamTheme.primaryIconTheme, + colorScheme: materialTheme.colorScheme.copyWith( + secondary: streamTheme.colorTheme.accentPrimary, + ), + ), + child: StreamChatCore( + client: client, + onBackgroundEventReceived: widget.onBackgroundEventReceived, + backgroundKeepAlive: widget.backgroundKeepAlive, + connectivityStream: widget.connectivityStream, + child: widget.child ?? const Empty(), + ), + ); + }, ), ); + + if (widget.componentBuilders case final builders?) { + child = StreamComponentFactory(builders: builders, child: child); + } + + return Portal( + child: StreamChatConfiguration(data: streamChatConfigData, child: child), + ); } StreamChatThemeData _getTheme( @@ -208,8 +238,7 @@ class StreamChatState extends State { @override void didChangeDependencies() { - final currentLocale = - Localizations.localeOf(context).toString().toLowerCase(); + final currentLocale = Localizations.localeOf(context).toString().toLowerCase(); final availableLocales = Jiffy.getSupportedLocales(); if (availableLocales.contains(currentLocale)) { Jiffy.setLocale(currentLocale); diff --git a/packages/stream_chat_flutter/lib/src/stream_chat_configuration.dart b/packages/stream_chat_flutter/lib/src/stream_chat_configuration.dart index 54fec9887b..89cb62e1ea 100644 --- a/packages/stream_chat_flutter/lib/src/stream_chat_configuration.dart +++ b/packages/stream_chat_flutter/lib/src/stream_chat_configuration.dart @@ -18,21 +18,70 @@ class StreamChatConfiguration extends InheritedWidget { final StreamChatConfigurationData data; @override - bool updateShouldNotify(StreamChatConfiguration oldWidget) => - data != oldWidget.data; + bool updateShouldNotify(StreamChatConfiguration oldWidget) => data != oldWidget.data; - /// Use this method to get the current [StreamChatThemeData] instance + /// Finds the [StreamChatConfigurationData] from the closest + /// [StreamChatConfiguration] ancestor that encloses the given context. + /// + /// This will throw a [FlutterError] if no [StreamChatConfiguration] is found + /// in the widget tree above the given context. + /// + /// Typical usage: + /// + /// ```dart + /// final config = StreamChatConfiguration.of(context); + /// ``` + /// + /// If you're calling this in the same `build()` method that creates the + /// `StreamChatConfiguration`, consider using a `Builder` or refactoring into + /// a separate widget to obtain a context below the [StreamChatConfiguration]. + /// + /// If you want to return null instead of throwing, use [maybeOf]. static StreamChatConfigurationData of(BuildContext context) { - final streamChatConfiguration = - context.dependOnInheritedWidgetOfExactType(); + final result = maybeOf(context); + if (result != null) return result; - assert( - streamChatConfiguration != null, - ''' -You must have a StreamChatConfigurationProvider widget at the top of your widget tree''', - ); + throw FlutterError.fromParts([ + ErrorSummary( + 'StreamChatConfiguration.of() called with a context that does not ' + 'contain a StreamChatConfiguration.', + ), + ErrorDescription( + 'No StreamChatConfiguration ancestor could be found starting from the ' + 'context that was passed to StreamChatConfiguration.of(). This usually ' + 'happens when the context used comes from the widget that creates the ' + 'StreamChatConfiguration itself.', + ), + ErrorHint( + 'To fix this, ensure that you are using a context that is a descendant ' + 'of the StreamChatConfiguration. You can use a Builder to get a new ' + 'context that is under the StreamChatConfiguration:\n\n' + ' Builder(\n' + ' builder: (context) {\n' + ' final config = StreamChatConfiguration.of(context);\n' + ' ...\n' + ' },\n' + ' )', + ), + ErrorHint( + 'Alternatively, split your build method into smaller widgets so that ' + 'you get a new BuildContext that is below the StreamChatConfiguration ' + 'in the widget tree.', + ), + context.describeElement('The context used was'), + ]); + } - return streamChatConfiguration!.data; + /// Finds the [StreamChatConfigurationData] from the closest + /// [StreamChatConfiguration] ancestor that encloses the given context. + /// + /// Returns null if no such ancestor exists. + /// + /// See also: + /// * [of], which throws if no [StreamChatConfiguration] is found. + static StreamChatConfigurationData? maybeOf(BuildContext context) { + final streamChatConfiguration = context.dependOnInheritedWidgetOfExactType(); + return streamChatConfiguration?.data; } } @@ -112,31 +161,45 @@ class StreamChatConfigurationData { Widget loadingIndicator = const StreamLoadingIndicator(), Widget Function(BuildContext, User)? defaultUserImage, Widget Function(BuildContext, User)? placeholderUserImage, - List? reactionIcons, + ReactionIconResolver? reactionIconResolver, bool? enforceUniqueReactions, bool draftMessagesEnabled = false, MessagePreviewFormatter? messagePreviewFormatter, + StreamImageCDN imageCDN = const StreamImageCDN(), + List? attachmentBuilders, + StreamReactionsType? reactionType, + StreamReactionsPosition? reactionPosition, + bool? reactionOverlap, }) { return StreamChatConfigurationData._( loadingIndicator: loadingIndicator, defaultUserImage: defaultUserImage ?? _defaultUserImage, placeholderUserImage: placeholderUserImage, - reactionIcons: reactionIcons ?? _defaultReactionIcons, + reactionIconResolver: reactionIconResolver ?? const DefaultReactionIconResolver(), enforceUniqueReactions: enforceUniqueReactions ?? true, draftMessagesEnabled: draftMessagesEnabled, - messagePreviewFormatter: - messagePreviewFormatter ?? MessagePreviewFormatter(), + messagePreviewFormatter: messagePreviewFormatter ?? MessagePreviewFormatter(), + imageCDN: imageCDN, + attachmentBuilders: attachmentBuilders, + reactionType: reactionType, + reactionPosition: reactionPosition, + reactionOverlap: reactionOverlap, ); } - StreamChatConfigurationData._({ + const StreamChatConfigurationData._({ required this.loadingIndicator, required this.defaultUserImage, required this.placeholderUserImage, - required this.reactionIcons, + required this.reactionIconResolver, required this.enforceUniqueReactions, required this.draftMessagesEnabled, required this.messagePreviewFormatter, + required this.imageCDN, + required this.attachmentBuilders, + this.reactionType, + this.reactionPosition, + this.reactionOverlap, }); /// Copies the configuration options from one [StreamChatConfigurationData] to @@ -145,21 +208,29 @@ class StreamChatConfigurationData { Widget? loadingIndicator, Widget Function(BuildContext, User)? defaultUserImage, Widget Function(BuildContext, User)? placeholderUserImage, - List? reactionIcons, + ReactionIconResolver? reactionIconResolver, bool? enforceUniqueReactions, bool? draftMessagesEnabled, MessagePreviewFormatter? messagePreviewFormatter, + StreamImageCDN? imageCDN, + List? attachmentBuilders, + StreamReactionsType? reactionType, + StreamReactionsPosition? reactionPosition, + bool? reactionOverlap, }) { return StreamChatConfigurationData( - reactionIcons: reactionIcons ?? this.reactionIcons, + reactionIconResolver: reactionIconResolver ?? this.reactionIconResolver, defaultUserImage: defaultUserImage ?? this.defaultUserImage, placeholderUserImage: placeholderUserImage ?? this.placeholderUserImage, loadingIndicator: loadingIndicator ?? this.loadingIndicator, - enforceUniqueReactions: - enforceUniqueReactions ?? this.enforceUniqueReactions, + enforceUniqueReactions: enforceUniqueReactions ?? this.enforceUniqueReactions, draftMessagesEnabled: draftMessagesEnabled ?? this.draftMessagesEnabled, - messagePreviewFormatter: - messagePreviewFormatter ?? this.messagePreviewFormatter, + messagePreviewFormatter: messagePreviewFormatter ?? this.messagePreviewFormatter, + imageCDN: imageCDN ?? this.imageCDN, + attachmentBuilders: attachmentBuilders ?? this.attachmentBuilders, + reactionType: reactionType ?? this.reactionType, + reactionPosition: reactionPosition ?? this.reactionPosition, + reactionOverlap: reactionOverlap ?? this.reactionOverlap, ); } @@ -177,8 +248,11 @@ class StreamChatConfigurationData { /// The widget that will be built when the user image is loading. final Widget Function(BuildContext, User)? placeholderUserImage; - /// Assets used for rendering reactions. - final List reactionIcons; + /// The resolver used to convert reaction types into [StreamEmojiContent] + /// models and to provide the list of supported/default reaction types. + /// + /// Defaults to [DefaultReactionIconResolver]. + final ReactionIconResolver reactionIconResolver; /// Whether a new reaction should replace the existing one. final bool enforceUniqueReactions; @@ -188,78 +262,49 @@ class StreamChatConfigurationData { /// Defaults to [MessagePreviewFormatter]. final MessagePreviewFormatter messagePreviewFormatter; - static final _defaultReactionIcons = [ - StreamReactionIcon( - type: 'love', - builder: (context, highlighted, size) { - final theme = StreamChatTheme.of(context); - return StreamSvgIcon( - icon: StreamSvgIcons.loveReaction, - color: highlighted - ? theme.colorTheme.accentPrimary - : theme.primaryIconTheme.color, - size: size, - ); - }, - ), - StreamReactionIcon( - type: 'like', - builder: (context, highlighted, size) { - final theme = StreamChatTheme.of(context); - return StreamSvgIcon( - icon: StreamSvgIcons.thumbsUpReaction, - color: highlighted - ? theme.colorTheme.accentPrimary - : theme.primaryIconTheme.color, - size: size, - ); - }, - ), - StreamReactionIcon( - type: 'sad', - builder: (context, highlighted, size) { - final theme = StreamChatTheme.of(context); - return StreamSvgIcon( - icon: StreamSvgIcons.thumbsDownReaction, - color: highlighted - ? theme.colorTheme.accentPrimary - : theme.primaryIconTheme.color, - size: size, - ); - }, - ), - StreamReactionIcon( - type: 'haha', - builder: (context, highlighted, size) { - final theme = StreamChatTheme.of(context); - return StreamSvgIcon( - icon: StreamSvgIcons.lolReaction, - color: highlighted - ? theme.colorTheme.accentPrimary - : theme.primaryIconTheme.color, - size: size, - ); - }, - ), - StreamReactionIcon( - type: 'wow', - builder: (context, highlighted, size) { - final theme = StreamChatTheme.of(context); - return StreamSvgIcon( - icon: StreamSvgIcons.wutReaction, - color: highlighted - ? theme.colorTheme.accentPrimary - : theme.primaryIconTheme.color, - size: size, - ); - }, - ), - ]; + /// The image CDN used for generating resized image URLs and stable + /// cache keys. + /// + /// Defaults to [StreamImageCDN], which supports Stream's own CDN. + /// Extend [StreamImageCDN] to customize behavior for a custom CDN. + final StreamImageCDN imageCDN; - static Widget _defaultUserImage(BuildContext context, User user) => Center( - child: StreamGradientAvatar( - name: user.name, - userId: user.id, - ), - ); + /// Custom attachment builders for rendering attachment widgets in messages. + /// + /// When non-null, these builders are prepended to the default builders + /// based on the [Attachment.type], allowing custom attachment types to be + /// rendered globally across all message widgets. + final List? attachmentBuilders; + + /// The visual type of the reactions display used across all message widgets. + /// + /// When null, the widget resolves its own default + /// ([StreamReactionsType.segmented]). + final StreamReactionsType? reactionType; + + /// Where reactions appear relative to the message bubble across all + /// message widgets. + /// + /// When null, the widget resolves its own default + /// ([StreamReactionsPosition.header]). + final StreamReactionsPosition? reactionPosition; + + /// Whether reactions overlap the message bubble edge across all message + /// widgets. + /// + /// When null, the widget resolves its own default (overlap on mobile, + /// no overlap on desktop and web). + final bool? reactionOverlap; + + static Widget _defaultUserImage( + BuildContext context, + User user, + ) { + return Center( + child: StreamGradientAvatar( + name: user.name, + userId: user.id, + ), + ); + } } diff --git a/packages/stream_chat_flutter/lib/src/theme/audio_waveform_slider_theme.dart b/packages/stream_chat_flutter/lib/src/theme/audio_waveform_slider_theme.dart deleted file mode 100644 index 0b2b9b5424..0000000000 --- a/packages/stream_chat_flutter/lib/src/theme/audio_waveform_slider_theme.dart +++ /dev/null @@ -1,139 +0,0 @@ -import 'package:flutter/foundation.dart'; -import 'package:flutter/widgets.dart'; -import 'package:stream_chat_flutter/src/theme/audio_waveform_theme.dart'; -import 'package:stream_chat_flutter/src/theme/stream_chat_theme.dart'; - -/// {@template streamAudioWaveformSliderTheme} -/// Overrides the default style of [StreamAudioWaveformSlider] descendants. -/// -/// See also: -/// -/// * [StreamVoiceRecordingAttachmentThemeData], which is used to configure -/// this theme. -/// {@endtemplate} -class StreamAudioWaveformSliderTheme extends InheritedTheme { - /// Creates a [StreamAudioWaveformSliderTheme]. - /// - /// The [data] parameter must not be null. - const StreamAudioWaveformSliderTheme({ - super.key, - required this.data, - required super.child, - }); - - /// The configuration of this theme. - final StreamAudioWaveformSliderThemeData data; - - /// The closest instance of this class that encloses the given context. - /// - /// If there is no enclosing [StreamAudioWaveformSliderTheme] widget, - /// then [StreamAudioWaveformSliderTheme.audioWaveformSliderTheme] is used. - /// - /// Typical usage is as follows: - /// - /// ```dart - /// StreamAudioWaveformSliderTheme theme = - /// StreamAudioWaveformSliderTheme.of(context); - /// ``` - static StreamAudioWaveformSliderThemeData of(BuildContext context) { - final audioWaveformSliderTheme = context - .dependOnInheritedWidgetOfExactType(); - return audioWaveformSliderTheme?.data ?? - StreamChatTheme.of(context).audioWaveformSliderTheme; - } - - @override - Widget wrap(BuildContext context, Widget child) => - StreamAudioWaveformSliderTheme(data: data, child: child); - - @override - bool updateShouldNotify(StreamAudioWaveformSliderTheme oldWidget) => - data != oldWidget.data; -} - -/// {@template streamAudioWaveformSliderThemeData} -/// A style that overrides the default appearance of -/// [StreamAudioWaveformSlider] widgets when used with -/// [StreamAudioWaveformSliderTheme] or with the overall -/// [StreamChatTheme]'s [StreamChatThemeData.audioWaveformSliderTheme]. -/// {@endtemplate} -class StreamAudioWaveformSliderThemeData with Diagnosticable { - /// {@macro streamVoiceRecordingAttachmentThemeData} - const StreamAudioWaveformSliderThemeData({ - this.audioWaveformTheme, - this.thumbColor, - this.thumbBorderColor, - }); - - /// The theme of the audio waveform. - final StreamAudioWaveformThemeData? audioWaveformTheme; - - /// The color of the thumb. - final Color? thumbColor; - - /// The color of the thumb border. - final Color? thumbBorderColor; - - /// A copy of [StreamAudioWaveformSliderThemeData] with specified attributes - /// overridden. - StreamAudioWaveformSliderThemeData copyWith({ - StreamAudioWaveformThemeData? audioWaveformTheme, - Color? thumbColor, - Color? thumbBorderColor, - }) { - return StreamAudioWaveformSliderThemeData( - audioWaveformTheme: audioWaveformTheme ?? this.audioWaveformTheme, - thumbColor: thumbColor ?? this.thumbColor, - thumbBorderColor: thumbBorderColor ?? this.thumbBorderColor, - ); - } - - /// Merges this [StreamPollOptionsDialogThemeData] with the [other]. - StreamAudioWaveformSliderThemeData merge( - StreamAudioWaveformSliderThemeData? other, - ) { - if (other == null) return this; - return copyWith( - audioWaveformTheme: other.audioWaveformTheme, - thumbColor: other.thumbColor, - thumbBorderColor: other.thumbBorderColor, - ); - } - - /// Linearly interpolate between two [StreamPollOptionsDialogThemeData]. - static StreamAudioWaveformSliderThemeData lerp( - StreamAudioWaveformSliderThemeData a, - StreamAudioWaveformSliderThemeData b, - double t, - ) => - StreamAudioWaveformSliderThemeData( - audioWaveformTheme: StreamAudioWaveformThemeData.lerp( - a.audioWaveformTheme!, b.audioWaveformTheme!, t), - thumbColor: Color.lerp(a.thumbColor, b.thumbColor, t), - thumbBorderColor: Color.lerp(a.thumbBorderColor, b.thumbBorderColor, t), - ); - - @override - bool operator ==(Object other) => - identical(this, other) || - other is StreamAudioWaveformSliderThemeData && - other.audioWaveformTheme == audioWaveformTheme && - other.thumbColor == thumbColor && - other.thumbBorderColor == thumbBorderColor; - - @override - int get hashCode => - audioWaveformTheme.hashCode ^ - thumbColor.hashCode ^ - thumbBorderColor.hashCode; - - @override - void debugFillProperties(DiagnosticPropertiesBuilder properties) { - super.debugFillProperties(properties); - properties - ..add(DiagnosticsProperty( - 'audioWaveformTheme', audioWaveformTheme)) - ..add(ColorProperty('thumbColor', thumbColor)) - ..add(ColorProperty('thumbBorderColor', thumbBorderColor)); - } -} diff --git a/packages/stream_chat_flutter/lib/src/theme/audio_waveform_theme.dart b/packages/stream_chat_flutter/lib/src/theme/audio_waveform_theme.dart deleted file mode 100644 index dde8fc78df..0000000000 --- a/packages/stream_chat_flutter/lib/src/theme/audio_waveform_theme.dart +++ /dev/null @@ -1,159 +0,0 @@ -import 'dart:ui'; - -import 'package:flutter/foundation.dart'; -import 'package:flutter/widgets.dart'; -import 'package:stream_chat_flutter/src/theme/stream_chat_theme.dart'; - -/// {@template streamAudioWaveformTheme} -/// Overrides the default style of [StreamAudioWaveform] descendants. -/// -/// See also: -/// -/// * [StreamVoiceRecordingAttachmentThemeData], which is used to configure -/// this theme. -/// {@endtemplate} -class StreamAudioWaveformTheme extends InheritedTheme { - /// Creates a [StreamAudioWaveformTheme]. - /// - /// The [data] parameter must not be null. - const StreamAudioWaveformTheme({ - super.key, - required this.data, - required super.child, - }); - - /// The configuration of this theme. - final StreamAudioWaveformThemeData data; - - /// The closest instance of this class that encloses the given context. - /// - /// If there is no enclosing [StreamAudioWaveformTheme] widget, - /// then [StreamAudioWaveformTheme.audioWaveformSliderTheme] is used. - /// - /// Typical usage is as follows: - /// - /// ```dart - /// StreamAudioWaveformTheme theme = StreamAudioWaveformTheme.of(context); - /// ``` - static StreamAudioWaveformThemeData of(BuildContext context) { - final audioWaveformTheme = - context.dependOnInheritedWidgetOfExactType(); - return audioWaveformTheme?.data ?? - StreamChatTheme.of(context).audioWaveformTheme; - } - - @override - Widget wrap(BuildContext context, Widget child) => - StreamAudioWaveformTheme(data: data, child: child); - - @override - bool updateShouldNotify(StreamAudioWaveformTheme oldWidget) => - data != oldWidget.data; -} - -/// {@template streamVoiceRecordingAttachmentThemeData} -/// A style that overrides the default appearance of -/// [StreamAudioWaveformSlider] widgets when used with -/// [StreamAudioWaveformTheme] or with the overall -/// [StreamChatTheme]'s [StreamChatThemeData.audioWaveformSliderTheme]. -/// {@endtemplate} -class StreamAudioWaveformThemeData with Diagnosticable { - /// {@macro streamAudioWaveformThemeData} - const StreamAudioWaveformThemeData({ - this.color, - this.progressColor, - this.minBarHeight, - this.spacingRatio, - this.heightScale, - }); - - /// The color of the wave bars. - final Color? color; - - /// The color of the progressed wave bars. - final Color? progressColor; - - /// The minimum height of the bars. - final double? minBarHeight; - - /// The ratio of the spacing between the bars. - final double? spacingRatio; - - /// The scale of the height of the bars. - final double? heightScale; - - /// A copy of [StreamAudioWaveformThemeData] with specified attributes - /// overridden. - StreamAudioWaveformThemeData copyWith({ - Color? color, - Color? progressColor, - double? minBarHeight, - double? spacingRatio, - double? heightScale, - }) { - return StreamAudioWaveformThemeData( - color: color ?? this.color, - progressColor: progressColor ?? this.progressColor, - minBarHeight: minBarHeight ?? this.minBarHeight, - spacingRatio: spacingRatio ?? this.spacingRatio, - heightScale: heightScale ?? this.heightScale, - ); - } - - /// Merges this [StreamPollOptionsDialogThemeData] with the [other]. - StreamAudioWaveformThemeData merge( - StreamAudioWaveformThemeData? other, - ) { - if (other == null) return this; - return copyWith( - color: other.color, - progressColor: other.progressColor, - minBarHeight: other.minBarHeight, - spacingRatio: other.spacingRatio, - heightScale: other.heightScale, - ); - } - - /// Linearly interpolate between two [StreamPollOptionsDialogThemeData]. - static StreamAudioWaveformThemeData lerp( - StreamAudioWaveformThemeData a, - StreamAudioWaveformThemeData b, - double t, - ) => - StreamAudioWaveformThemeData( - color: Color.lerp(a.color, b.color, t), - progressColor: Color.lerp(a.progressColor, b.progressColor, t), - minBarHeight: lerpDouble(a.minBarHeight, b.minBarHeight, t), - spacingRatio: lerpDouble(a.spacingRatio, b.spacingRatio, t), - heightScale: lerpDouble(a.heightScale, b.heightScale, t), - ); - - @override - bool operator ==(Object other) => - identical(this, other) || - other is StreamAudioWaveformThemeData && - other.color == color && - other.progressColor == progressColor && - other.minBarHeight == minBarHeight && - other.spacingRatio == spacingRatio && - other.heightScale == heightScale; - - @override - int get hashCode => - color.hashCode ^ - progressColor.hashCode ^ - minBarHeight.hashCode ^ - spacingRatio.hashCode ^ - heightScale.hashCode; - - @override - void debugFillProperties(DiagnosticPropertiesBuilder properties) { - super.debugFillProperties(properties); - properties - ..add(ColorProperty('color', color)) - ..add(ColorProperty('progressColor', progressColor)) - ..add(DoubleProperty('minBarHeight', minBarHeight)) - ..add(DoubleProperty('spacingRatio', spacingRatio)) - ..add(DoubleProperty('heightScale', heightScale)); - } -} diff --git a/packages/stream_chat_flutter/lib/src/theme/avatar_theme.dart b/packages/stream_chat_flutter/lib/src/theme/avatar_theme.dart deleted file mode 100644 index dbc36da931..0000000000 --- a/packages/stream_chat_flutter/lib/src/theme/avatar_theme.dart +++ /dev/null @@ -1,82 +0,0 @@ -import 'package:flutter/foundation.dart'; -import 'package:flutter/material.dart'; - -/// {@template avatarThemeData} -/// A style that overrides the default appearance of various avatar widgets. -/// {@endtemplate} -// ignore: prefer-match-file-name -class StreamAvatarThemeData with Diagnosticable { - /// {@macro avatarThemeData} - const StreamAvatarThemeData({ - BoxConstraints? constraints, - BorderRadius? borderRadius, - }) : _constraints = constraints, - _borderRadius = borderRadius; - - final BoxConstraints? _constraints; - final BorderRadius? _borderRadius; - - /// Get constraints for avatar - BoxConstraints get constraints => - _constraints ?? - const BoxConstraints.tightFor( - height: 32, - width: 32, - ); - - /// Get border radius - BorderRadius get borderRadius => _borderRadius ?? BorderRadius.circular(20); - - /// Copy this [StreamAvatarThemeData] to another. - StreamAvatarThemeData copyWith({ - BoxConstraints? constraints, - BorderRadius? borderRadius, - }) { - return StreamAvatarThemeData( - constraints: constraints ?? _constraints, - borderRadius: borderRadius ?? _borderRadius, - ); - } - - /// Linearly interpolate between two [UserAvatar] themes. - /// - /// All the properties must be non-null. - StreamAvatarThemeData lerp( - StreamAvatarThemeData a, - StreamAvatarThemeData b, - double t, - ) { - return StreamAvatarThemeData( - borderRadius: BorderRadius.lerp(a.borderRadius, b.borderRadius, t), - constraints: BoxConstraints.lerp(a.constraints, b.constraints, t), - ); - } - - @override - bool operator ==(Object other) => - identical(this, other) || - other is StreamAvatarThemeData && - runtimeType == other.runtimeType && - _constraints == other._constraints && - _borderRadius == other._borderRadius; - - @override - int get hashCode => _constraints.hashCode ^ _borderRadius.hashCode; - - /// Merges one [StreamAvatarThemeData] with the another - StreamAvatarThemeData merge(StreamAvatarThemeData? other) { - if (other == null) return this; - return copyWith( - constraints: other._constraints, - borderRadius: other._borderRadius, - ); - } - - @override - void debugFillProperties(DiagnosticPropertiesBuilder properties) { - super.debugFillProperties(properties); - properties - ..add(DiagnosticsProperty('borderRadius', borderRadius)) - ..add(DiagnosticsProperty('constraints', constraints)); - } -} diff --git a/packages/stream_chat_flutter/lib/src/theme/channel_header_theme.dart b/packages/stream_chat_flutter/lib/src/theme/channel_header_theme.dart deleted file mode 100644 index 2df24f685e..0000000000 --- a/packages/stream_chat_flutter/lib/src/theme/channel_header_theme.dart +++ /dev/null @@ -1,155 +0,0 @@ -import 'package:flutter/foundation.dart'; -import 'package:flutter/material.dart'; -import 'package:stream_chat_flutter/src/theme/avatar_theme.dart'; -import 'package:stream_chat_flutter/src/theme/stream_chat_theme.dart'; -import 'package:stream_chat_flutter/src/theme/themes.dart'; - -/// {@template channel_header_theme} -/// Overrides the default style of [ChannelHeader] descendants. -/// -/// See also: -/// -/// * [StreamChannelHeaderThemeData], which is used to configure this theme. -/// {@endtemplate} -class StreamChannelHeaderTheme extends InheritedTheme { - /// Creates a [StreamChannelHeaderTheme]. - /// - /// The [data] parameter must not be null. - const StreamChannelHeaderTheme({ - super.key, - required this.data, - required super.child, - }); - - /// The configuration of this theme. - final StreamChannelHeaderThemeData data; - - /// The closest instance of this class that encloses the given context. - /// - /// If there is no enclosing [StreamChannelHeaderTheme] widget, then - /// [StreamChatThemeData.channelTheme.channelHeaderTheme] is used. - /// - /// Typical usage is as follows: - /// - /// ```dart - /// final theme = ChannelHeaderTheme.of(context); - /// ``` - static StreamChannelHeaderThemeData of(BuildContext context) { - final channelHeaderTheme = - context.dependOnInheritedWidgetOfExactType(); - return channelHeaderTheme?.data ?? - StreamChatTheme.of(context).channelHeaderTheme; - } - - @override - Widget wrap(BuildContext context, Widget child) => - StreamChannelHeaderTheme(data: data, child: child); - - @override - bool updateShouldNotify(StreamChannelHeaderTheme oldWidget) => - data != oldWidget.data; -} - -/// {@template channel_header_theme_data} -/// A style that overrides the default appearance of [ChannelHeader]s when used -/// with [StreamChannelHeaderTheme] or with the overall [StreamChatTheme]'s -/// [StreamChatThemeData.channelHeaderTheme]. -/// -/// See also: -/// -/// * [StreamChannelHeaderTheme], the theme which is configured with this class. -/// * [StreamChatThemeData.channelHeaderTheme], which can be used to override -/// the default style for [ChannelHeader]s below the overall [StreamChatTheme]. -/// {@endtemplate} -class StreamChannelHeaderThemeData with Diagnosticable { - /// Creates a [StreamChannelHeaderThemeData] - const StreamChannelHeaderThemeData({ - this.titleStyle, - this.subtitleStyle, - this.avatarTheme, - this.color, - }); - - /// Theme for title - final TextStyle? titleStyle; - - /// Theme for subtitle - final TextStyle? subtitleStyle; - - /// Theme for avatar - final StreamAvatarThemeData? avatarTheme; - - /// Color for [StreamChannelHeaderThemeData] - final Color? color; - - /// Copy with theme - StreamChannelHeaderThemeData copyWith({ - TextStyle? titleStyle, - TextStyle? subtitleStyle, - StreamAvatarThemeData? avatarTheme, - Color? color, - }) { - return StreamChannelHeaderThemeData( - titleStyle: titleStyle ?? this.titleStyle, - subtitleStyle: subtitleStyle ?? this.subtitleStyle, - avatarTheme: avatarTheme ?? this.avatarTheme, - color: color ?? this.color, - ); - } - - /// Linearly interpolate between two [StreamChannelHeaderThemeData]. - /// - /// All the properties must be non-null. - StreamChannelHeaderThemeData lerp( - StreamChannelHeaderThemeData a, - StreamChannelHeaderThemeData b, - double t, - ) { - return StreamChannelHeaderThemeData( - titleStyle: TextStyle.lerp(a.titleStyle, b.titleStyle, t), - subtitleStyle: TextStyle.lerp(a.subtitleStyle, b.subtitleStyle, t), - avatarTheme: - const StreamAvatarThemeData().lerp(a.avatarTheme!, b.avatarTheme!, t), - color: Color.lerp(a.color, b.color, t), - ); - } - - /// Merge with other [StreamChannelHeaderThemeData] - StreamChannelHeaderThemeData merge(StreamChannelHeaderThemeData? other) { - if (other == null) return this; - return copyWith( - titleStyle: titleStyle?.merge(other.titleStyle) ?? other.titleStyle, - subtitleStyle: - subtitleStyle?.merge(other.subtitleStyle) ?? other.subtitleStyle, - avatarTheme: avatarTheme?.merge(other.avatarTheme) ?? other.avatarTheme, - color: other.color, - ); - } - - @override - bool operator ==(Object other) => - identical(this, other) || - other is StreamChannelHeaderThemeData && - runtimeType == other.runtimeType && - titleStyle == other.titleStyle && - subtitleStyle == other.subtitleStyle && - avatarTheme == other.avatarTheme && - color == other.color; - - @override - int get hashCode => - titleStyle.hashCode ^ - subtitleStyle.hashCode ^ - avatarTheme.hashCode ^ - color.hashCode; - - @override - void debugFillProperties(DiagnosticPropertiesBuilder properties) { - super.debugFillProperties(properties); - properties - ..add(DiagnosticsProperty('title', titleStyle)) - ..add(DiagnosticsProperty('subtitle', subtitleStyle)) - ..add(DiagnosticsProperty('avatarTheme', avatarTheme)) - ..add(ColorProperty('color', color)); - } -} diff --git a/packages/stream_chat_flutter/lib/src/theme/channel_list_header_theme.dart b/packages/stream_chat_flutter/lib/src/theme/channel_list_header_theme.dart deleted file mode 100644 index 7452065df7..0000000000 --- a/packages/stream_chat_flutter/lib/src/theme/channel_list_header_theme.dart +++ /dev/null @@ -1,135 +0,0 @@ -import 'package:flutter/foundation.dart'; -import 'package:flutter/material.dart'; -import 'package:stream_chat_flutter/src/theme/avatar_theme.dart'; -import 'package:stream_chat_flutter/src/theme/stream_chat_theme.dart'; - -/// {@template channelListHeaderTheme} -/// Overrides the default style of [ChannelListHeader] descendants. -/// -/// See also: -/// -/// * [StreamChannelListHeaderThemeData], which is used -/// to configure this theme. -/// {@endtemplate} -class StreamChannelListHeaderTheme extends InheritedTheme { - /// Creates a [StreamChannelListHeaderTheme]. - /// - /// The [data] parameter must not be null. - const StreamChannelListHeaderTheme({ - super.key, - required this.data, - required super.child, - }); - - /// The configuration of this theme. - final StreamChannelListHeaderThemeData data; - - /// The closest instance of this class that encloses the given context. - /// - /// If there is no enclosing [StreamChannelListHeaderTheme] widget, then - /// [StreamChatThemeData.channelListHeaderTheme] is used. - /// - /// Typical usage is as follows: - /// - /// ```dart - /// final theme = ChannelListHeaderTheme.of(context); - /// ``` - static StreamChannelListHeaderThemeData of(BuildContext context) { - final channelListHeaderTheme = context - .dependOnInheritedWidgetOfExactType(); - return channelListHeaderTheme?.data ?? - StreamChatTheme.of(context).channelListHeaderTheme; - } - - @override - Widget wrap(BuildContext context, Widget child) => - StreamChannelListHeaderTheme(data: data, child: child); - - @override - bool updateShouldNotify(StreamChannelListHeaderTheme oldWidget) => - data != oldWidget.data; -} - -/// {@template channel_list_header_theme_data} -/// Theme dedicated to the [ChannelListHeader] -/// {@endtemplate} -class StreamChannelListHeaderThemeData with Diagnosticable { - /// Returns a new [StreamChannelListHeaderThemeData] - const StreamChannelListHeaderThemeData({ - this.titleStyle, - this.avatarTheme, - this.color, - }); - - /// Style of the title text - final TextStyle? titleStyle; - - /// Theme dedicated to the userAvatar - final StreamAvatarThemeData? avatarTheme; - - /// Background color of the appbar - final Color? color; - - /// Returns a new [StreamChannelListHeaderThemeData] replacing some of its - /// properties - StreamChannelListHeaderThemeData copyWith({ - TextStyle? titleStyle, - StreamAvatarThemeData? avatarTheme, - Color? color, - }) { - return StreamChannelListHeaderThemeData( - titleStyle: titleStyle ?? this.titleStyle, - avatarTheme: avatarTheme ?? this.avatarTheme, - color: color ?? this.color, - ); - } - - /// Linearly interpolate from one [StreamChannelListHeaderThemeData] - /// to another. - StreamChannelListHeaderThemeData lerp( - StreamChannelListHeaderThemeData a, - StreamChannelListHeaderThemeData b, - double t, - ) { - return StreamChannelListHeaderThemeData( - avatarTheme: - const StreamAvatarThemeData().lerp(a.avatarTheme!, b.avatarTheme!, t), - color: Color.lerp(a.color, b.color, t), - titleStyle: TextStyle.lerp(a.titleStyle, b.titleStyle, t), - ); - } - - /// Merges [this] [StreamChannelListHeaderThemeData] with the [other] - StreamChannelListHeaderThemeData merge( - StreamChannelListHeaderThemeData? other, - ) { - if (other == null) return this; - return copyWith( - titleStyle: titleStyle?.merge(other.titleStyle) ?? other.titleStyle, - avatarTheme: avatarTheme?.merge(other.avatarTheme) ?? other.avatarTheme, - color: other.color, - ); - } - - @override - bool operator ==(Object other) => - identical(this, other) || - other is StreamChannelListHeaderThemeData && - runtimeType == other.runtimeType && - titleStyle == other.titleStyle && - avatarTheme == other.avatarTheme && - color == other.color; - - @override - int get hashCode => - titleStyle.hashCode ^ avatarTheme.hashCode ^ color.hashCode; - - @override - void debugFillProperties(DiagnosticPropertiesBuilder properties) { - super.debugFillProperties(properties); - properties - ..add(DiagnosticsProperty('titleStyle', titleStyle)) - ..add(DiagnosticsProperty('avatarTheme', avatarTheme)) - ..add(ColorProperty('color', color)); - } -} diff --git a/packages/stream_chat_flutter/lib/src/theme/channel_preview_theme.dart b/packages/stream_chat_flutter/lib/src/theme/channel_preview_theme.dart deleted file mode 100644 index 3b9a39f658..0000000000 --- a/packages/stream_chat_flutter/lib/src/theme/channel_preview_theme.dart +++ /dev/null @@ -1,204 +0,0 @@ -import 'package:flutter/foundation.dart'; -import 'package:flutter/material.dart'; -import 'package:stream_chat_flutter/src/theme/avatar_theme.dart'; -import 'package:stream_chat_flutter/src/theme/stream_chat_theme.dart'; -import 'package:stream_chat_flutter/src/utils/date_formatter.dart'; - -/// {@template channelPreviewTheme} -/// Overrides the default style of [ChannelPreview] descendants. -/// -/// See also: -/// -/// * [StreamChannelPreviewThemeData], which is used to configure this theme. -/// {@endtemplate} -class StreamChannelPreviewTheme extends InheritedTheme { - /// Creates a [StreamChannelPreviewTheme]. - /// - /// The [data] parameter must not be null. - const StreamChannelPreviewTheme({ - super.key, - required this.data, - required super.child, - }); - - /// The configuration of this theme. - final StreamChannelPreviewThemeData data; - - /// The closest instance of this class that encloses the given context. - /// - /// If there is no enclosing [StreamChannelPreviewTheme] widget, then - /// [StreamChatThemeData.channelPreviewTheme] is used. - /// - /// Typical usage is as follows: - /// - /// ```dart - /// final theme = ChannelPreviewTheme.of(context); - /// ``` - static StreamChannelPreviewThemeData of(BuildContext context) { - final channelPreviewTheme = - context.dependOnInheritedWidgetOfExactType(); - return channelPreviewTheme?.data ?? - StreamChatTheme.of(context).channelPreviewTheme; - } - - @override - Widget wrap(BuildContext context, Widget child) => - StreamChannelPreviewTheme(data: data, child: child); - - @override - bool updateShouldNotify(StreamChannelPreviewTheme oldWidget) => - data != oldWidget.data; -} - -/// {@template channelPreviewThemeData} -/// A style that overrides the default appearance of [ChannelPreview]s when used -/// with [StreamChannelPreviewTheme] or with the overall [StreamChatTheme]'s -/// [StreamChatThemeData.channelPreviewTheme]. -/// -/// See also: -/// -/// * [StreamChannelPreviewTheme], the theme -/// which is configured with this class. -/// * [StreamChatThemeData.channelPreviewTheme], which can be used to override -/// the default style for [ChannelHeader]s below the overall [StreamChatTheme]. -/// {@endtemplate} -class StreamChannelPreviewThemeData with Diagnosticable { - /// Creates a [StreamChannelPreviewThemeData]. - const StreamChannelPreviewThemeData({ - this.titleStyle, - this.subtitleStyle, - this.lastMessageAtStyle, - this.avatarTheme, - this.unreadCounterColor, - this.indicatorIconSize, - this.lastMessageAtFormatter, - }); - - /// Theme for title - final TextStyle? titleStyle; - - /// Theme for subtitle - final TextStyle? subtitleStyle; - - /// Theme of last message at - final TextStyle? lastMessageAtStyle; - - /// Avatar theme - final StreamAvatarThemeData? avatarTheme; - - /// Unread counter color - final Color? unreadCounterColor; - - /// Indicator icon size - final double? indicatorIconSize; - - /// Formatter for the last message timestamp. - /// - /// If null, uses the default date formatting. - /// - /// Example: - /// ```dart - /// StreamChannelPreviewThemeData( - /// lastMessageAtStyle: TextStyle(...), - /// lastMessageAtFormatter: (context, date) { - /// return Jiffy.parseFromDateTime(date).format('d MMMM'); // "23 May" - /// }, - /// ) - /// ``` - final DateFormatter? lastMessageAtFormatter; - - /// Copy with theme - StreamChannelPreviewThemeData copyWith({ - TextStyle? titleStyle, - TextStyle? subtitleStyle, - TextStyle? lastMessageAtStyle, - StreamAvatarThemeData? avatarTheme, - Color? unreadCounterColor, - double? indicatorIconSize, - DateFormatter? lastMessageAtFormatter, - }) { - return StreamChannelPreviewThemeData( - titleStyle: titleStyle ?? this.titleStyle, - subtitleStyle: subtitleStyle ?? this.subtitleStyle, - lastMessageAtStyle: lastMessageAtStyle ?? this.lastMessageAtStyle, - avatarTheme: avatarTheme ?? this.avatarTheme, - unreadCounterColor: unreadCounterColor ?? this.unreadCounterColor, - indicatorIconSize: indicatorIconSize ?? this.indicatorIconSize, - lastMessageAtFormatter: - lastMessageAtFormatter ?? this.lastMessageAtFormatter, - ); - } - - /// Linearly interpolate one [StreamChannelPreviewThemeData] to another. - StreamChannelPreviewThemeData lerp( - StreamChannelPreviewThemeData a, - StreamChannelPreviewThemeData b, - double t, - ) { - return StreamChannelPreviewThemeData( - avatarTheme: - const StreamAvatarThemeData().lerp(a.avatarTheme!, b.avatarTheme!, t), - indicatorIconSize: a.indicatorIconSize, - lastMessageAtStyle: - TextStyle.lerp(a.lastMessageAtStyle, b.lastMessageAtStyle, t), - subtitleStyle: TextStyle.lerp(a.subtitleStyle, b.subtitleStyle, t), - titleStyle: TextStyle.lerp(a.titleStyle, b.titleStyle, t), - unreadCounterColor: - Color.lerp(a.unreadCounterColor, b.unreadCounterColor, t), - lastMessageAtFormatter: - t < 0.5 ? a.lastMessageAtFormatter : b.lastMessageAtFormatter, - ); - } - - /// Merge with theme - StreamChannelPreviewThemeData merge(StreamChannelPreviewThemeData? other) { - if (other == null) return this; - return copyWith( - titleStyle: titleStyle?.merge(other.titleStyle) ?? other.titleStyle, - subtitleStyle: - subtitleStyle?.merge(other.subtitleStyle) ?? other.subtitleStyle, - lastMessageAtStyle: lastMessageAtStyle?.merge(other.lastMessageAtStyle) ?? - other.lastMessageAtStyle, - avatarTheme: avatarTheme?.merge(other.avatarTheme) ?? other.avatarTheme, - unreadCounterColor: other.unreadCounterColor, - lastMessageAtFormatter: - other.lastMessageAtFormatter ?? lastMessageAtFormatter, - ); - } - - @override - bool operator ==(Object other) => - identical(this, other) || - other is StreamChannelPreviewThemeData && - runtimeType == other.runtimeType && - titleStyle == other.titleStyle && - subtitleStyle == other.subtitleStyle && - lastMessageAtStyle == other.lastMessageAtStyle && - avatarTheme == other.avatarTheme && - unreadCounterColor == other.unreadCounterColor && - indicatorIconSize == other.indicatorIconSize && - lastMessageAtFormatter == other.lastMessageAtFormatter; - - @override - int get hashCode => - titleStyle.hashCode ^ - subtitleStyle.hashCode ^ - lastMessageAtStyle.hashCode ^ - avatarTheme.hashCode ^ - unreadCounterColor.hashCode ^ - indicatorIconSize.hashCode ^ - lastMessageAtFormatter.hashCode; - - @override - void debugFillProperties(DiagnosticPropertiesBuilder properties) { - super.debugFillProperties(properties); - properties - ..add(DiagnosticsProperty('titleStyle', titleStyle)) - ..add(DiagnosticsProperty('subtitleStyle', subtitleStyle)) - ..add(DiagnosticsProperty('lastMessageAtStyle', lastMessageAtStyle)) - ..add(DiagnosticsProperty('avatarTheme', avatarTheme)) - ..add(ColorProperty('unreadCounterColor', unreadCounterColor)) - ..add(DiagnosticsProperty( - 'lastMessageAtFormatter', lastMessageAtFormatter)); - } -} diff --git a/packages/stream_chat_flutter/lib/src/theme/color_theme.dart b/packages/stream_chat_flutter/lib/src/theme/color_theme.dart index 167c68ee61..785af3c404 100644 --- a/packages/stream_chat_flutter/lib/src/theme/color_theme.dart +++ b/packages/stream_chat_flutter/lib/src/theme/color_theme.dart @@ -1,84 +1,110 @@ import 'package:flutter/material.dart'; -/// {@template color_theme} -/// Theme that holds colors -/// {@endtemplate} +/// Defines a color theme for the Stream Chat UI, +/// including core surfaces, text colors, accents, and visual effects. +/// +/// This theme provides two variants: +/// - `StreamColorTheme.light`: for light mode +/// - `StreamColorTheme.dark`: for dark mode class StreamColorTheme { - /// Initialise with light theme - StreamColorTheme.light({ + /// Creates a [StreamColorTheme] instance based on the provided [brightness]. + /// + /// Returns a light theme when [brightness] is [Brightness.light] and + /// a dark theme when [brightness] is [Brightness.dark]. + factory StreamColorTheme({ + Brightness brightness = Brightness.light, + }) { + return switch (brightness) { + Brightness.light => const StreamColorTheme.light(), + Brightness.dark => const StreamColorTheme.dark(), + }; + } + + /// Creates a light mode [StreamColorTheme] using design system values. + const StreamColorTheme.light({ this.textHighEmphasis = const Color(0xff000000), - this.textLowEmphasis = const Color(0xff7a7a7a), - this.disabled = const Color(0xffdbdbdb), - this.borders = const Color(0xffecebeb), + this.textLowEmphasis = const Color(0xff72767e), + this.disabled = const Color(0xffb4b7bb), + this.borders = const Color(0xffdbdde1), this.inputBg = const Color(0xffe9eaed), - this.appBg = const Color(0xfff7f7f8), + this.appBg = const Color(0xffffffff), this.barsBg = const Color(0xffffffff), this.linkBg = const Color(0xffe9f2ff), - this.accentPrimary = const Color(0xff005FFF), - this.accentError = const Color(0xffFF3842), - this.accentInfo = const Color(0xff20E070), + this.accentPrimary = const Color(0xff005fff), + this.accentError = const Color(0xffff3742), + this.accentInfo = const Color(0xff20e070), this.highlight = const Color(0xfffbf4dd), this.overlay = const Color.fromRGBO(0, 0, 0, 0.2), this.overlayDark = const Color.fromRGBO(0, 0, 0, 0.6), this.bgGradient = const LinearGradient( begin: Alignment.topCenter, end: Alignment.bottomCenter, - colors: [Color(0xfff7f7f7), Color(0xfffcfcfc)], + colors: [Color(0xfff7f7f8), Color(0xffe9eaed)], stops: [0, 1], ), this.borderTop = const Effect( sigmaX: 0, sigmaY: -1, - color: Color(0xff000000), + color: Color(0xffdbdde1), blur: 0, - alpha: 0.08, + alpha: 1, ), this.borderBottom = const Effect( sigmaX: 0, sigmaY: 1, - color: Color(0xff000000), + color: Color(0xffdbdde1), blur: 0, - alpha: 0.08, + alpha: 1, ), this.shadowIconButton = const Effect( sigmaX: 0, sigmaY: 2, color: Color(0xff000000), - alpha: 0.5, blur: 4, + alpha: 0.25, ), this.modalShadow = const Effect( sigmaX: 0, sigmaY: 0, color: Color(0xff000000), - alpha: 1, - blur: 8, + blur: 4, + alpha: 0.6, ), }) : brightness = Brightness.light; - /// Initialise with dark theme - StreamColorTheme.dark({ + /// Creates a dark mode [StreamColorTheme] using design system values. + const StreamColorTheme.dark({ this.textHighEmphasis = const Color(0xffffffff), - this.textLowEmphasis = const Color(0xff7a7a7a), - this.disabled = const Color(0xff2d2f2f), - this.borders = const Color(0xff1c1e22), - this.inputBg = const Color(0xff13151b), + this.textLowEmphasis = const Color(0xff72767e), + this.disabled = const Color(0xff4c525c), + this.borders = const Color(0xff272a30), + this.inputBg = const Color(0xff1c1e22), this.appBg = const Color(0xff000000), this.barsBg = const Color(0xff121416), - this.linkBg = const Color(0xff00193D), + this.linkBg = const Color(0xff00193d), this.accentPrimary = const Color(0xff337eff), - this.accentError = const Color(0xffFF3742), - this.accentInfo = const Color(0xff20E070), + this.accentError = const Color(0xffff3742), + this.accentInfo = const Color(0xff20e070), + this.highlight = const Color(0xff302d22), + this.overlay = const Color.fromRGBO(0, 0, 0, 0.4), + this.overlayDark = const Color.fromRGBO(255, 255, 255, 0.6), + this.bgGradient = const LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [Color(0xff101214), Color(0xff070a0d)], + stops: [0, 1], + ), this.borderTop = const Effect( sigmaX: 0, sigmaY: -1, - color: Color(0xff141924), + color: Color(0xff272a30), blur: 0, + alpha: 1, ), this.borderBottom = const Effect( sigmaX: 0, sigmaY: 1, - color: Color(0xff141924), + color: Color(0xff272a30), blur: 0, alpha: 1, ), @@ -86,93 +112,81 @@ class StreamColorTheme { sigmaX: 0, sigmaY: 2, color: Color(0xff000000), - alpha: 0.5, blur: 4, + alpha: 0.5, ), this.modalShadow = const Effect( sigmaX: 0, sigmaY: 0, color: Color(0xff000000), - alpha: 1, blur: 8, - ), - this.highlight = const Color(0xff302d22), - this.overlay = const Color.fromRGBO(0, 0, 0, 0.4), - this.overlayDark = const Color.fromRGBO(255, 255, 255, 0.6), - this.bgGradient = const LinearGradient( - begin: Alignment.topCenter, - end: Alignment.bottomCenter, - colors: [ - Color(0xff101214), - Color(0xff070a0d), - ], - stops: [0, 1], + alpha: 1, ), }) : brightness = Brightness.dark; - /// + /// Main body text or primary icons. final Color textHighEmphasis; - /// + /// Secondary or less prominent text/icons. final Color textLowEmphasis; - /// + /// Disabled UI elements (icons, inputs). final Color disabled; - /// + /// Standard UI borders and dividers. final Color borders; - /// + /// Background for input fields. final Color inputBg; - /// + /// Main app background. final Color appBg; - /// + /// Bars: headers, footers, and toolbars. final Color barsBg; - /// + /// Background for links and link cards. final Color linkBg; - /// + /// Primary action color (buttons, active states). final Color accentPrimary; - /// + /// Error color (alerts, badges). final Color accentError; - /// + /// Informational highlights (e.g., status). final Color accentInfo; - /// - final Effect borderTop; - - /// - final Effect borderBottom; - - /// - final Effect shadowIconButton; - - /// - final Effect modalShadow; - - /// + /// Highlighted rows, pinned messages. final Color highlight; - /// + /// General translucent overlay for modals, sheets. final Color overlay; - /// + /// Overlay for dark mode interactions or highlight effects. final Color overlayDark; - /// + /// Background gradient for section headers. final Gradient bgGradient; - /// + /// Theme brightness indicator. final Brightness brightness; - /// Copy with theme + /// Top border effect (for elevation). + final Effect borderTop; + + /// Bottom border effect. + final Effect borderBottom; + + /// Icon button drop shadow effect. + final Effect shadowIconButton; + + /// Modal shadow effect. + final Effect modalShadow; + + /// Returns a new [StreamColorTheme] by overriding selected fields. StreamColorTheme copyWith({ - Brightness brightness = Brightness.light, + Brightness? brightness, Color? textHighEmphasis, Color? textLowEmphasis, Color? disabled, @@ -184,16 +198,16 @@ class StreamColorTheme { Color? accentPrimary, Color? accentError, Color? accentInfo, - Effect? borderTop, - Effect? borderBottom, - Effect? shadowIconButton, - Effect? modalShadow, Color? highlight, Color? overlay, Color? overlayDark, Gradient? bgGradient, + Effect? borderTop, + Effect? borderBottom, + Effect? shadowIconButton, + Effect? modalShadow, }) { - return brightness == Brightness.light + return (brightness ?? this.brightness) == Brightness.light ? StreamColorTheme.light( textHighEmphasis: textHighEmphasis ?? this.textHighEmphasis, textLowEmphasis: textLowEmphasis ?? this.textLowEmphasis, @@ -206,14 +220,14 @@ class StreamColorTheme { accentPrimary: accentPrimary ?? this.accentPrimary, accentError: accentError ?? this.accentError, accentInfo: accentInfo ?? this.accentInfo, - borderTop: borderTop ?? this.borderTop, - borderBottom: borderBottom ?? this.borderBottom, - shadowIconButton: shadowIconButton ?? this.shadowIconButton, - modalShadow: modalShadow ?? this.modalShadow, highlight: highlight ?? this.highlight, overlay: overlay ?? this.overlay, overlayDark: overlayDark ?? this.overlayDark, bgGradient: bgGradient ?? this.bgGradient, + borderTop: borderTop ?? this.borderTop, + borderBottom: borderBottom ?? this.borderBottom, + shadowIconButton: shadowIconButton ?? this.shadowIconButton, + modalShadow: modalShadow ?? this.modalShadow, ) : StreamColorTheme.dark( textHighEmphasis: textHighEmphasis ?? this.textHighEmphasis, @@ -227,18 +241,18 @@ class StreamColorTheme { accentPrimary: accentPrimary ?? this.accentPrimary, accentError: accentError ?? this.accentError, accentInfo: accentInfo ?? this.accentInfo, - borderTop: borderTop ?? this.borderTop, - borderBottom: borderBottom ?? this.borderBottom, - shadowIconButton: shadowIconButton ?? this.shadowIconButton, - modalShadow: modalShadow ?? this.modalShadow, highlight: highlight ?? this.highlight, overlay: overlay ?? this.overlay, overlayDark: overlayDark ?? this.overlayDark, bgGradient: bgGradient ?? this.bgGradient, + borderTop: borderTop ?? this.borderTop, + borderBottom: borderBottom ?? this.borderBottom, + shadowIconButton: shadowIconButton ?? this.shadowIconButton, + modalShadow: modalShadow ?? this.modalShadow, ); } - /// Merge color theme + /// Merges this theme with [other], replacing any fields that [other] defines. StreamColorTheme merge(StreamColorTheme? other) { if (other == null) return this; return copyWith( @@ -265,9 +279,9 @@ class StreamColorTheme { } } -/// Effect store +/// Visual effect such as blur or shadow used by the theme. class Effect { - /// Constructor for creating [Effect] + /// Creates an [Effect] instance. const Effect({ this.sigmaX, this.sigmaY, @@ -276,22 +290,22 @@ class Effect { this.blur, }); - /// + /// Horizontal shadow offset. final double? sigmaX; - /// + /// Vertical shadow offset. final double? sigmaY; - /// + /// Color of the shadow or border. final Color? color; - /// + /// Opacity (0–1) of the effect. final double? alpha; - /// + /// Blur radius. final double? blur; - /// Copy with new effect + /// Returns a copy with updated fields. Effect copyWith({ double? sigmaX, double? sigmaY, @@ -303,7 +317,7 @@ class Effect { sigmaX: sigmaX ?? this.sigmaX, sigmaY: sigmaY ?? this.sigmaY, color: color ?? this.color, - alpha: color as double? ?? this.alpha, + alpha: alpha ?? this.alpha, blur: blur ?? this.blur, ); } diff --git a/packages/stream_chat_flutter/lib/src/theme/draft_list_tile_theme.dart b/packages/stream_chat_flutter/lib/src/theme/draft_list_tile_theme.dart deleted file mode 100644 index 20b8973657..0000000000 --- a/packages/stream_chat_flutter/lib/src/theme/draft_list_tile_theme.dart +++ /dev/null @@ -1,178 +0,0 @@ -import 'package:flutter/foundation.dart'; -import 'package:flutter/widgets.dart'; -import 'package:stream_chat_flutter/src/theme/stream_chat_theme.dart'; -import 'package:stream_chat_flutter/src/utils/date_formatter.dart'; - -/// {@template streamDraftListTileTheme} -/// Overrides the default style of [StreamDraftListTile] descendants. -/// -/// See also: -/// -/// * [StreamDraftListTileThemeData], which is used to configure this -/// theme. -/// {@endtemplate} -class StreamDraftListTileTheme extends InheritedTheme { - /// Creates a [StreamDraftListTileTheme]. - /// - /// The [data] parameter must not be null. - const StreamDraftListTileTheme({ - super.key, - required this.data, - required super.child, - }); - - /// The configuration of this theme. - final StreamDraftListTileThemeData data; - - /// The closest instance of this class that encloses the given context. - /// - /// If there is no enclosing [StreamDraftListTileTheme] widget, then - /// [StreamChatThemeData.draftListTileTheme] is used. - static StreamDraftListTileThemeData of(BuildContext context) { - final draftListTileTheme = - context.dependOnInheritedWidgetOfExactType(); - return draftListTileTheme?.data ?? - StreamChatTheme.of(context).draftListTileTheme; - } - - @override - Widget wrap(BuildContext context, Widget child) => - StreamDraftListTileTheme(data: data, child: child); - - @override - bool updateShouldNotify(StreamDraftListTileTheme oldWidget) => - data != oldWidget.data; -} - -/// {@template streamDraftListTileThemeData} -/// A style that overrides the default appearance of -/// [StreamDraftListTile] widgets when used with -/// [StreamDraftListTileTheme] or with the overall [StreamChatTheme]'s -/// [StreamChatThemeData.draftListTileTheme]. -/// {@endtemplate} -class StreamDraftListTileThemeData with Diagnosticable { - /// {@macro streamDraftListTileThemeData} - const StreamDraftListTileThemeData({ - this.padding, - this.backgroundColor, - this.draftChannelNameStyle, - this.draftMessageStyle, - this.draftTimestampStyle, - this.draftTimestampFormatter, - }); - - /// The padding around the [StreamDraftListTile] widget. - final EdgeInsetsGeometry? padding; - - /// The background color of the [StreamDraftListTile] widget. - final Color? backgroundColor; - - /// The style of the channel name in the [StreamDraftListTile] widget. - final TextStyle? draftChannelNameStyle; - - /// The style of the draft message in the [StreamDraftListTile] widget. - final TextStyle? draftMessageStyle; - - /// The style of the draft timestamp in the [StreamDraftListTile] widget. - final TextStyle? draftTimestampStyle; - - /// Formatter for the draft timestamp. - /// - /// If null, uses the default date formatting. - /// - /// Example: - /// ```dart - /// StreamDraftListTileThemeData( - /// draftTimestampStyle: TextStyle(...), - /// draftTimestampFormatter: (context, date) { - /// return Jiffy.parseFromDateTime(date).fromNow(); // "2 hours ago" - /// }, - /// ) - /// ``` - final DateFormatter? draftTimestampFormatter; - - /// A copy of [StreamDraftListTileThemeData] with specified attributes - /// overridden. - StreamDraftListTileThemeData copyWith({ - EdgeInsetsGeometry? padding, - Color? backgroundColor, - TextStyle? draftChannelNameStyle, - TextStyle? draftMessageStyle, - TextStyle? draftTimestampStyle, - DateFormatter? draftTimestampFormatter, - Color? draftIconColor, - }) => - StreamDraftListTileThemeData( - padding: padding ?? this.padding, - backgroundColor: backgroundColor ?? this.backgroundColor, - draftChannelNameStyle: - draftChannelNameStyle ?? this.draftChannelNameStyle, - draftMessageStyle: draftMessageStyle ?? this.draftMessageStyle, - draftTimestampStyle: draftTimestampStyle ?? this.draftTimestampStyle, - draftTimestampFormatter: - draftTimestampFormatter ?? this.draftTimestampFormatter, - ); - - /// Merges this [StreamDraftListTileThemeData] with the [other]. - StreamDraftListTileThemeData merge( - StreamDraftListTileThemeData? other, - ) { - if (other == null) return this; - return copyWith( - padding: other.padding, - backgroundColor: other.backgroundColor, - draftChannelNameStyle: other.draftChannelNameStyle, - draftMessageStyle: other.draftMessageStyle, - draftTimestampStyle: other.draftTimestampStyle, - draftTimestampFormatter: other.draftTimestampFormatter, - ); - } - - /// Linearly interpolate between two [StreamDraftListTileThemeData]. - StreamDraftListTileThemeData lerp( - StreamDraftListTileThemeData? a, - StreamDraftListTileThemeData? b, - double t, - ) => - StreamDraftListTileThemeData( - padding: EdgeInsetsGeometry.lerp(a?.padding, b?.padding, t), - backgroundColor: Color.lerp(a?.backgroundColor, b?.backgroundColor, t), - draftChannelNameStyle: TextStyle.lerp( - a?.draftChannelNameStyle, - b?.draftChannelNameStyle, - t, - ), - draftMessageStyle: TextStyle.lerp( - a?.draftMessageStyle, - b?.draftMessageStyle, - t, - ), - draftTimestampStyle: TextStyle.lerp( - a?.draftTimestampStyle, - b?.draftTimestampStyle, - t, - ), - draftTimestampFormatter: - t < 0.5 ? a?.draftTimestampFormatter : b?.draftTimestampFormatter, - ); - - @override - bool operator ==(Object other) => - identical(this, other) || - other is StreamDraftListTileThemeData && - other.padding == padding && - other.backgroundColor == backgroundColor && - other.draftChannelNameStyle == draftChannelNameStyle && - other.draftMessageStyle == draftMessageStyle && - other.draftTimestampStyle == draftTimestampStyle && - other.draftTimestampFormatter == draftTimestampFormatter; - - @override - int get hashCode => - padding.hashCode ^ - backgroundColor.hashCode ^ - draftChannelNameStyle.hashCode ^ - draftMessageStyle.hashCode ^ - draftTimestampStyle.hashCode ^ - draftTimestampFormatter.hashCode; -} diff --git a/packages/stream_chat_flutter/lib/src/theme/gallery_footer_theme.dart b/packages/stream_chat_flutter/lib/src/theme/gallery_footer_theme.dart deleted file mode 100644 index 84e1688a60..0000000000 --- a/packages/stream_chat_flutter/lib/src/theme/gallery_footer_theme.dart +++ /dev/null @@ -1,238 +0,0 @@ -import 'package:flutter/foundation.dart'; -import 'package:flutter/widgets.dart'; -import 'package:stream_chat_flutter/src/theme/stream_chat_theme.dart'; - -/// {@template galleryFooterTheme} -/// Overrides the default style of [GalleryFooter] descendants. -/// -/// See also: -/// -/// * [StreamGalleryFooterThemeData], which is used to configure this theme. -/// {@endtemplate} -class StreamGalleryFooterTheme extends InheritedTheme { - /// Creates an [StreamGalleryFooterTheme]. - /// - /// The [data] parameter must not be null. - const StreamGalleryFooterTheme({ - super.key, - required this.data, - required super.child, - }); - - /// The configuration of this theme. - final StreamGalleryFooterThemeData data; - - /// The closest instance of this class that encloses the given context. - /// - /// If there is no enclosing [StreamGalleryFooterTheme] widget, then - /// [StreamChatThemeData.galleryFooterTheme] is used. - /// - /// Typical usage is as follows: - /// - /// ```dart - /// ImageFooterTheme theme = ImageFooterTheme.of(context); - /// ``` - static StreamGalleryFooterThemeData of(BuildContext context) { - final imageFooterTheme = - context.dependOnInheritedWidgetOfExactType(); - return imageFooterTheme?.data ?? - StreamChatTheme.of(context).galleryFooterTheme; - } - - @override - Widget wrap(BuildContext context, Widget child) => - StreamGalleryFooterTheme(data: data, child: child); - - @override - bool updateShouldNotify(StreamGalleryFooterTheme oldWidget) => - data != oldWidget.data; -} - -/// {@template galleryFooterThemeData} -/// A style that overrides the default appearance of [GalleryFooter]s when used -/// with [StreamGalleryFooterTheme] or with the overall [StreamChatTheme]'s -/// [StreamChatThemeData.galleryFooterTheme]. -/// -/// See also: -/// -/// * [StreamGalleryFooterTheme], the theme which is configured with this class. -/// * [StreamChatThemeData.galleryFooterTheme], which can be used to override -/// the default style for [GalleryFooter]s below the overall [StreamChatTheme]. -/// {@endtemplate} -class StreamGalleryFooterThemeData with Diagnosticable { - /// Creates an [StreamGalleryFooterThemeData]. - const StreamGalleryFooterThemeData({ - this.backgroundColor, - this.shareIconColor, - this.titleTextStyle, - this.gridIconButtonColor, - this.bottomSheetBarrierColor, - this.bottomSheetBackgroundColor, - this.bottomSheetPhotosTextStyle, - this.bottomSheetCloseIconColor, - }); - - /// The background color for the [GalleryFooter] widget. - /// - /// Defaults to [ColorTheme.barsBg]. - final Color? backgroundColor; - - /// The color for the "share" icon. - /// - /// Defaults to [ColorTheme.textHighEmphasis]. - final Color? shareIconColor; - - /// The [TextStyle] to use for the [GalleryFooter] title text. - /// - /// Defaults to [TextTheme.headlineBold]. - final TextStyle? titleTextStyle; - - /// The color to use for the "grid" icon. - /// - /// Defaults to [ColorTheme.textHighEmphasis]. - final Color? gridIconButtonColor; - - /// The color to use behind the bottom sheet. - /// - /// Defaults to [ColorTheme.overlay]. - final Color? bottomSheetBarrierColor; - - /// The background color to use for the bottom sheet. - /// - /// Defaults to [ColorTheme.barsBg]. - final Color? bottomSheetBackgroundColor; - - /// The [TextStyle] to use for the "photos" text in the bottom sheet. - /// - /// Defaults to [TextTheme.headlineBold]. - final TextStyle? bottomSheetPhotosTextStyle; - - /// The color to use for the "close" icon. - /// - /// Defaults to [ColorTheme.textHighEmphasis]. - final Color? bottomSheetCloseIconColor; - - /// Copies this [StreamGalleryFooterThemeData] to another. - StreamGalleryFooterThemeData copyWith({ - Color? backgroundColor, - Color? shareIconColor, - TextStyle? titleTextStyle, - Color? gridIconButtonColor, - Color? bottomSheetBarrierColor, - Color? bottomSheetBackgroundColor, - TextStyle? bottomSheetPhotosTextStyle, - Color? bottomSheetCloseIconColor, - }) { - return StreamGalleryFooterThemeData( - backgroundColor: backgroundColor ?? this.backgroundColor, - shareIconColor: shareIconColor ?? this.shareIconColor, - titleTextStyle: titleTextStyle ?? this.titleTextStyle, - gridIconButtonColor: gridIconButtonColor ?? this.gridIconButtonColor, - bottomSheetBarrierColor: - bottomSheetBarrierColor ?? this.bottomSheetBarrierColor, - bottomSheetBackgroundColor: - bottomSheetBackgroundColor ?? this.bottomSheetBackgroundColor, - bottomSheetPhotosTextStyle: - bottomSheetPhotosTextStyle ?? this.bottomSheetPhotosTextStyle, - bottomSheetCloseIconColor: - bottomSheetCloseIconColor ?? this.bottomSheetCloseIconColor, - ); - } - - /// Linearly interpolate between two [GalleryFooter] themes. - /// - /// All the properties must be non-null. - StreamGalleryFooterThemeData lerp( - StreamGalleryFooterThemeData a, - StreamGalleryFooterThemeData b, - double t, - ) { - return StreamGalleryFooterThemeData( - backgroundColor: Color.lerp(a.backgroundColor, b.backgroundColor, t), - shareIconColor: Color.lerp(a.shareIconColor, b.shareIconColor, t), - titleTextStyle: TextStyle.lerp(a.titleTextStyle, b.titleTextStyle, t), - gridIconButtonColor: - Color.lerp(a.gridIconButtonColor, b.gridIconButtonColor, t), - bottomSheetBarrierColor: - Color.lerp(a.bottomSheetBarrierColor, b.bottomSheetBarrierColor, t), - bottomSheetBackgroundColor: Color.lerp( - a.bottomSheetBackgroundColor, - b.bottomSheetBackgroundColor, - t, - ), - bottomSheetPhotosTextStyle: TextStyle.lerp( - a.bottomSheetPhotosTextStyle, - b.bottomSheetPhotosTextStyle, - t, - ), - bottomSheetCloseIconColor: Color.lerp( - a.bottomSheetCloseIconColor, - b.bottomSheetCloseIconColor, - t, - ), - ); - } - - /// Merges one [StreamGalleryFooterThemeData] with another. - StreamGalleryFooterThemeData merge(StreamGalleryFooterThemeData? other) { - if (other == null) return this; - return copyWith( - backgroundColor: other.backgroundColor, - bottomSheetBarrierColor: other.bottomSheetBarrierColor, - bottomSheetBackgroundColor: other.bottomSheetBackgroundColor, - bottomSheetCloseIconColor: other.bottomSheetCloseIconColor, - bottomSheetPhotosTextStyle: other.bottomSheetPhotosTextStyle, - gridIconButtonColor: other.gridIconButtonColor, - titleTextStyle: other.titleTextStyle, - shareIconColor: other.shareIconColor, - ); - } - - @override - bool operator ==(Object other) => - identical(this, other) || - other is StreamGalleryFooterThemeData && - runtimeType == other.runtimeType && - backgroundColor == other.backgroundColor && - shareIconColor == other.shareIconColor && - titleTextStyle == other.titleTextStyle && - gridIconButtonColor == other.gridIconButtonColor && - bottomSheetBarrierColor == other.bottomSheetBarrierColor && - bottomSheetBackgroundColor == other.bottomSheetBackgroundColor && - bottomSheetPhotosTextStyle == other.bottomSheetPhotosTextStyle && - bottomSheetCloseIconColor == other.bottomSheetCloseIconColor; - - @override - int get hashCode => - backgroundColor.hashCode ^ - shareIconColor.hashCode ^ - titleTextStyle.hashCode ^ - gridIconButtonColor.hashCode ^ - bottomSheetBarrierColor.hashCode ^ - bottomSheetBackgroundColor.hashCode ^ - bottomSheetPhotosTextStyle.hashCode ^ - bottomSheetCloseIconColor.hashCode; - - @override - void debugFillProperties(DiagnosticPropertiesBuilder properties) { - super.debugFillProperties(properties); - properties - ..add(ColorProperty('backgroundColor', backgroundColor)) - ..add(ColorProperty('shareIconColor', shareIconColor)) - ..add(DiagnosticsProperty('titleTextStyle', titleTextStyle)) - ..add(ColorProperty('gridIconButtonColor', gridIconButtonColor)) - ..add(ColorProperty('bottomSheetBarrierColor', bottomSheetBarrierColor)) - ..add(ColorProperty( - 'bottomSheetBackgroundColor', - bottomSheetBackgroundColor, - )) - ..add(DiagnosticsProperty( - 'bottomSheetPhotosTextStyle', - bottomSheetPhotosTextStyle, - )) - ..add(ColorProperty( - 'bottomSheetCloseIconColor', - bottomSheetCloseIconColor, - )); - } -} diff --git a/packages/stream_chat_flutter/lib/src/theme/gallery_header_theme.dart b/packages/stream_chat_flutter/lib/src/theme/gallery_header_theme.dart deleted file mode 100644 index 90977d4c9f..0000000000 --- a/packages/stream_chat_flutter/lib/src/theme/gallery_header_theme.dart +++ /dev/null @@ -1,185 +0,0 @@ -import 'package:flutter/foundation.dart'; -import 'package:flutter/widgets.dart'; -import 'package:stream_chat_flutter/src/theme/stream_chat_theme.dart'; - -/// {@template galleryHeaderTheme} -/// Overrides the default style of [GalleryHeader] descendants. -/// -/// See also: -/// -/// * [StreamGalleryHeaderThemeData], which is used to configure this theme. -/// {@endtemplate} -class StreamGalleryHeaderTheme extends InheritedTheme { - /// Creates a [StreamGalleryHeaderTheme]. - /// - /// The [data] parameter must not be null. - const StreamGalleryHeaderTheme({ - super.key, - required this.data, - required super.child, - }); - - /// The configuration of this theme. - final StreamGalleryHeaderThemeData data; - - /// The closest instance of this class that encloses the given context. - /// - /// If there is no enclosing [StreamGalleryHeaderTheme] widget, then - /// [StreamChatThemeData.galleryHeaderTheme] is used. - /// - /// Typical usage is as follows: - /// - /// ```dart - /// ImageHeaderTheme theme = ImageHeaderTheme.of(context); - /// ``` - static StreamGalleryHeaderThemeData of(BuildContext context) { - final galleryHeaderTheme = - context.dependOnInheritedWidgetOfExactType(); - return galleryHeaderTheme?.data ?? - StreamChatTheme.of(context).galleryHeaderTheme; - } - - @override - Widget wrap(BuildContext context, Widget child) => - StreamGalleryHeaderTheme(data: data, child: child); - - @override - bool updateShouldNotify(StreamGalleryHeaderTheme oldWidget) => - data != oldWidget.data; -} - -/// {@template galleryHeaderThemeData} -/// A style that overrides the default appearance of [GalleryHeader]s when used -/// with [StreamGalleryHeaderTheme] or with the overall [StreamChatTheme]'s -/// [StreamChatThemeData.galleryHeaderTheme]. -/// -/// See also: -/// -/// * [StreamGalleryHeaderTheme], the theme which is configured with this class. -/// * [StreamChatThemeData.galleryHeaderTheme], which can be used to override -/// the default style for [GalleryHeader]s below the overall [StreamChatTheme]. -/// {@endtemplate} -class StreamGalleryHeaderThemeData with Diagnosticable { - /// Creates an [StreamGalleryHeaderThemeData]. - const StreamGalleryHeaderThemeData({ - this.closeButtonColor, - this.backgroundColor, - this.iconMenuPointColor, - this.titleTextStyle, - this.subtitleTextStyle, - this.bottomSheetBarrierColor, - }); - - /// The color of the "close" button. - /// - /// Defaults to [ColorTheme.textHighEmphasis]. - final Color? closeButtonColor; - - /// The background color of the [GalleryHeader] widget. - /// - /// Defaults to [ChannelHeaderTheme.color]. - final Color? backgroundColor; - - /// Defaults to [ColorTheme.textHighEmphasis]. - final Color? iconMenuPointColor; - - /// The [TextStyle] to use for the [GalleryHeader] title text. - /// - /// Defaults to [TextTheme.headlineBold]. - final TextStyle? titleTextStyle; - - /// The [TextStyle] to use for the [GalleryHeader] subtitle text. - /// - /// Defaults to [ChannelPreviewTheme.subtitleStyle]. - final TextStyle? subtitleTextStyle; - - /// - final Color? bottomSheetBarrierColor; - - /// Copies this [StreamGalleryHeaderThemeData] to another. - StreamGalleryHeaderThemeData copyWith({ - Color? closeButtonColor, - Color? backgroundColor, - Color? iconMenuPointColor, - TextStyle? titleTextStyle, - TextStyle? subtitleTextStyle, - Color? bottomSheetBarrierColor, - }) { - return StreamGalleryHeaderThemeData( - closeButtonColor: closeButtonColor ?? this.closeButtonColor, - backgroundColor: backgroundColor ?? this.backgroundColor, - iconMenuPointColor: iconMenuPointColor ?? this.iconMenuPointColor, - titleTextStyle: titleTextStyle ?? this.titleTextStyle, - subtitleTextStyle: subtitleTextStyle ?? this.subtitleTextStyle, - bottomSheetBarrierColor: - bottomSheetBarrierColor ?? this.bottomSheetBarrierColor, - ); - } - - /// Linearly interpolate between two [GalleryHeader] themes. - /// - /// All the properties must be non-null. - StreamGalleryHeaderThemeData lerp( - StreamGalleryHeaderThemeData a, - StreamGalleryHeaderThemeData b, - double t, - ) { - return StreamGalleryHeaderThemeData( - closeButtonColor: Color.lerp(a.closeButtonColor, b.closeButtonColor, t), - backgroundColor: Color.lerp(a.backgroundColor, b.backgroundColor, t), - iconMenuPointColor: - Color.lerp(a.iconMenuPointColor, b.iconMenuPointColor, t), - titleTextStyle: TextStyle.lerp(a.titleTextStyle, b.titleTextStyle, t), - subtitleTextStyle: - TextStyle.lerp(a.subtitleTextStyle, b.subtitleTextStyle, t), - bottomSheetBarrierColor: - Color.lerp(a.bottomSheetBarrierColor, b.bottomSheetBarrierColor, t), - ); - } - - /// Merges one [StreamGalleryHeaderThemeData] with the another - StreamGalleryHeaderThemeData merge(StreamGalleryHeaderThemeData? other) { - if (other == null) return this; - return copyWith( - closeButtonColor: other.closeButtonColor, - backgroundColor: other.backgroundColor, - iconMenuPointColor: other.iconMenuPointColor, - titleTextStyle: other.titleTextStyle, - subtitleTextStyle: other.subtitleTextStyle, - bottomSheetBarrierColor: other.bottomSheetBarrierColor, - ); - } - - @override - bool operator ==(Object other) => - identical(this, other) || - other is StreamGalleryHeaderThemeData && - runtimeType == other.runtimeType && - closeButtonColor == other.closeButtonColor && - backgroundColor == other.backgroundColor && - iconMenuPointColor == other.iconMenuPointColor && - titleTextStyle == other.titleTextStyle && - subtitleTextStyle == other.subtitleTextStyle && - bottomSheetBarrierColor == other.bottomSheetBarrierColor; - - @override - int get hashCode => - closeButtonColor.hashCode ^ - backgroundColor.hashCode ^ - iconMenuPointColor.hashCode ^ - titleTextStyle.hashCode ^ - subtitleTextStyle.hashCode ^ - bottomSheetBarrierColor.hashCode; - - @override - void debugFillProperties(DiagnosticPropertiesBuilder properties) { - super.debugFillProperties(properties); - properties - ..add(ColorProperty('closeButtonColor', closeButtonColor)) - ..add(ColorProperty('backgroundColor', backgroundColor)) - ..add(ColorProperty('iconMenuPointColor', iconMenuPointColor)) - ..add(DiagnosticsProperty('titleTextStyle', titleTextStyle)) - ..add(DiagnosticsProperty('subtitleTextStyle', subtitleTextStyle)) - ..add(ColorProperty('bottomSheetBarrierColor', bottomSheetBarrierColor)); - } -} diff --git a/packages/stream_chat_flutter/lib/src/theme/message_input_theme.dart b/packages/stream_chat_flutter/lib/src/theme/message_input_theme.dart deleted file mode 100644 index a97df00b1e..0000000000 --- a/packages/stream_chat_flutter/lib/src/theme/message_input_theme.dart +++ /dev/null @@ -1,313 +0,0 @@ -import 'dart:ui'; - -import 'package:flutter/foundation.dart'; -import 'package:flutter/material.dart'; -import 'package:stream_chat_flutter/stream_chat_flutter.dart'; - -/// {@template messageInputTheme} -/// Overrides the default style of [MessageInput] descendants. -/// -/// See also: -/// -/// * [StreamMessageInputThemeData], which is used to configure this theme. -/// {@endtemplate} -class StreamMessageInputTheme extends InheritedTheme { - /// Creates a [StreamMessageInputTheme]. - /// - /// The [data] parameter must not be null. - const StreamMessageInputTheme({ - super.key, - required this.data, - required super.child, - }); - - /// The configuration of this theme. - final StreamMessageInputThemeData data; - - /// The closest instance of this class that encloses the given context. - /// - /// If there is no enclosing [StreamMessageInputTheme] widget, then - /// [StreamChatThemeData.messageInputTheme] is used. - /// - /// Typical usage is as follows: - /// - /// ```dart - /// final theme = MessageInputTheme.of(context); - /// ``` - static StreamMessageInputThemeData of(BuildContext context) { - final messageInputTheme = - context.dependOnInheritedWidgetOfExactType(); - return messageInputTheme?.data ?? - StreamChatTheme.of(context).messageInputTheme; - } - - @override - Widget wrap(BuildContext context, Widget child) => - StreamMessageInputTheme(data: data, child: child); - - @override - bool updateShouldNotify(StreamMessageInputTheme oldWidget) => - data != oldWidget.data; -} - -/// {@template messageInputThemeData} -/// A style that overrides the default appearance of [MessageInput] widgets -/// when used with [StreamMessageInputTheme] -/// or with the overall [StreamChatTheme]'s -/// [StreamChatThemeData.messageInputTheme]. -/// {@endtemplate} -class StreamMessageInputThemeData with Diagnosticable { - /// Creates a [StreamMessageInputThemeData]. - const StreamMessageInputThemeData({ - this.sendAnimationDuration, - this.actionButtonColor, - this.sendButtonColor, - this.actionButtonIdleColor, - this.sendButtonIdleColor, - this.inputBackgroundColor, - this.inputTextStyle, - this.inputDecoration, - this.activeBorderGradient, - this.idleBorderGradient, - this.borderRadius, - this.expandButtonColor, - this.linkHighlightColor, - this.enableSafeArea, - this.elevation, - this.shadow, - this.useSystemAttachmentPicker, - }); - - /// Duration of the [MessageInput] send button animation - final Duration? sendAnimationDuration; - - /// Background color of [MessageInput] send button - final Color? sendButtonColor; - - /// Color of a link - final Color? linkHighlightColor; - - /// Background color of [MessageInput] action buttons - final Color? actionButtonColor; - - /// Background color of [MessageInput] send button - final Color? sendButtonIdleColor; - - /// Background color of [MessageInput] action buttons - final Color? actionButtonIdleColor; - - /// Background color of [MessageInput] expand button - final Color? expandButtonColor; - - /// Background color of [MessageInput] - final Color? inputBackgroundColor; - - /// TextStyle of [MessageInput] - final TextStyle? inputTextStyle; - - /// InputDecoration of [MessageInput] - final InputDecoration? inputDecoration; - - /// Border gradient when the [MessageInput] is not focused - final Gradient? idleBorderGradient; - - /// Border gradient when the [MessageInput] is focused - final Gradient? activeBorderGradient; - - /// Border radius of [MessageInput] - final BorderRadius? borderRadius; - - /// Wrap [MessageInput] with a [SafeArea widget] - final bool? enableSafeArea; - - /// Elevation of the [MessageInput] - final double? elevation; - - /// Shadow for the [MessageInput] widget - final BoxShadow? shadow; - - /// If True, allows you to use the system’s default media picker instead of - /// the custom media picker provided by the library. This can be beneficial - /// for several reasons: - /// - /// 1. Consistency: Provides a consistent user experience by using the - /// familiar system media picker. - /// 2. Permissions: Reduces the need for additional permissions, as the system - /// media picker handles permissions internally. - /// 3. Simplicity: Simplifies the implementation by leveraging the built-in - /// functionality of the system media picker. - final bool? useSystemAttachmentPicker; - - /// Returns a new [StreamMessageInputThemeData] - /// replacing some of its properties - StreamMessageInputThemeData copyWith({ - Duration? sendAnimationDuration, - Color? inputBackgroundColor, - Color? actionButtonColor, - Color? sendButtonColor, - Color? actionButtonIdleColor, - Color? linkHighlightColor, - Color? sendButtonIdleColor, - Color? expandButtonColor, - TextStyle? inputTextStyle, - InputDecoration? inputDecoration, - Gradient? activeBorderGradient, - Gradient? idleBorderGradient, - BorderRadius? borderRadius, - bool? enableSafeArea, - double? elevation, - BoxShadow? shadow, - bool? useSystemAttachmentPicker, - }) { - return StreamMessageInputThemeData( - sendAnimationDuration: - sendAnimationDuration ?? this.sendAnimationDuration, - inputBackgroundColor: inputBackgroundColor ?? this.inputBackgroundColor, - actionButtonColor: actionButtonColor ?? this.actionButtonColor, - sendButtonColor: sendButtonColor ?? this.sendButtonColor, - actionButtonIdleColor: - actionButtonIdleColor ?? this.actionButtonIdleColor, - linkHighlightColor: linkHighlightColor ?? this.linkHighlightColor, - expandButtonColor: expandButtonColor ?? this.expandButtonColor, - inputTextStyle: inputTextStyle ?? this.inputTextStyle, - sendButtonIdleColor: sendButtonIdleColor ?? this.sendButtonIdleColor, - inputDecoration: inputDecoration ?? this.inputDecoration, - activeBorderGradient: activeBorderGradient ?? this.activeBorderGradient, - idleBorderGradient: idleBorderGradient ?? this.idleBorderGradient, - borderRadius: borderRadius ?? this.borderRadius, - enableSafeArea: enableSafeArea ?? this.enableSafeArea, - elevation: elevation ?? this.elevation, - shadow: shadow ?? this.shadow, - useSystemAttachmentPicker: - useSystemAttachmentPicker ?? this.useSystemAttachmentPicker, - ); - } - - /// Linearly interpolate from one [StreamMessageInputThemeData] to another. - StreamMessageInputThemeData lerp( - StreamMessageInputThemeData a, - StreamMessageInputThemeData b, - double t, - ) { - return StreamMessageInputThemeData( - actionButtonColor: - Color.lerp(a.actionButtonColor, b.actionButtonColor, t), - actionButtonIdleColor: - Color.lerp(a.actionButtonIdleColor, b.actionButtonIdleColor, t), - activeBorderGradient: - Gradient.lerp(a.activeBorderGradient, b.activeBorderGradient, t), - borderRadius: BorderRadius.lerp(a.borderRadius, b.borderRadius, t), - expandButtonColor: - Color.lerp(a.expandButtonColor, b.expandButtonColor, t), - linkHighlightColor: - Color.lerp(a.linkHighlightColor, b.linkHighlightColor, t), - idleBorderGradient: - Gradient.lerp(a.idleBorderGradient, b.idleBorderGradient, t), - inputBackgroundColor: - Color.lerp(a.inputBackgroundColor, b.inputBackgroundColor, t), - inputTextStyle: TextStyle.lerp(a.inputTextStyle, b.inputTextStyle, t), - sendButtonColor: Color.lerp(a.sendButtonColor, b.sendButtonColor, t), - sendButtonIdleColor: - Color.lerp(a.sendButtonIdleColor, b.sendButtonIdleColor, t), - sendAnimationDuration: a.sendAnimationDuration, - inputDecoration: a.inputDecoration, - enableSafeArea: a.enableSafeArea, - elevation: lerpDouble(a.elevation, b.elevation, t), - shadow: BoxShadow.lerp(a.shadow, b.shadow, t), - useSystemAttachmentPicker: b.useSystemAttachmentPicker, - ); - } - - /// Merges [this] [StreamMessageInputThemeData] with the [other] - StreamMessageInputThemeData merge(StreamMessageInputThemeData? other) { - if (other == null) return this; - return copyWith( - sendAnimationDuration: other.sendAnimationDuration, - inputBackgroundColor: other.inputBackgroundColor, - actionButtonColor: other.actionButtonColor, - actionButtonIdleColor: other.actionButtonIdleColor, - sendButtonColor: other.sendButtonColor, - sendButtonIdleColor: other.sendButtonIdleColor, - inputTextStyle: - inputTextStyle?.merge(other.inputTextStyle) ?? other.inputTextStyle, - inputDecoration: inputDecoration?.merge(other.inputDecoration) ?? - other.inputDecoration, - activeBorderGradient: other.activeBorderGradient, - idleBorderGradient: other.idleBorderGradient, - borderRadius: other.borderRadius, - expandButtonColor: other.expandButtonColor, - linkHighlightColor: other.linkHighlightColor, - enableSafeArea: other.enableSafeArea, - elevation: other.elevation, - shadow: other.shadow, - useSystemAttachmentPicker: other.useSystemAttachmentPicker, - ); - } - - @override - bool operator ==(Object other) => - identical(this, other) || - other is StreamMessageInputThemeData && - runtimeType == other.runtimeType && - sendAnimationDuration == other.sendAnimationDuration && - sendButtonColor == other.sendButtonColor && - actionButtonColor == other.actionButtonColor && - sendButtonIdleColor == other.sendButtonIdleColor && - actionButtonIdleColor == other.actionButtonIdleColor && - expandButtonColor == other.expandButtonColor && - inputBackgroundColor == other.inputBackgroundColor && - inputTextStyle == other.inputTextStyle && - inputDecoration == other.inputDecoration && - idleBorderGradient == other.idleBorderGradient && - activeBorderGradient == other.activeBorderGradient && - borderRadius == other.borderRadius && - linkHighlightColor == other.linkHighlightColor && - enableSafeArea == other.enableSafeArea && - elevation == other.elevation && - shadow == other.shadow && - useSystemAttachmentPicker == other.useSystemAttachmentPicker; - - @override - int get hashCode => - sendAnimationDuration.hashCode ^ - sendButtonColor.hashCode ^ - actionButtonColor.hashCode ^ - sendButtonIdleColor.hashCode ^ - actionButtonIdleColor.hashCode ^ - expandButtonColor.hashCode ^ - inputBackgroundColor.hashCode ^ - inputTextStyle.hashCode ^ - inputDecoration.hashCode ^ - idleBorderGradient.hashCode ^ - activeBorderGradient.hashCode ^ - borderRadius.hashCode ^ - linkHighlightColor.hashCode ^ - elevation.hashCode ^ - shadow.hashCode ^ - enableSafeArea.hashCode ^ - useSystemAttachmentPicker.hashCode; - - @override - void debugFillProperties(DiagnosticPropertiesBuilder properties) { - super.debugFillProperties(properties); - properties - ..add(DiagnosticsProperty('sendAnimationDuration', sendAnimationDuration)) - ..add(ColorProperty('inputBackgroundColor', inputBackgroundColor)) - ..add(ColorProperty('actionButtonColor', actionButtonColor)) - ..add(ColorProperty('actionButtonIdleColor', actionButtonIdleColor)) - ..add(ColorProperty('sendButtonColor', sendButtonColor)) - ..add(ColorProperty('sendButtonIdleColor', sendButtonIdleColor)) - ..add(DiagnosticsProperty('inputTextStyle', inputTextStyle)) - ..add(DiagnosticsProperty('inputDecoration', inputDecoration)) - ..add(DiagnosticsProperty('activeBorderGradient', activeBorderGradient)) - ..add(DiagnosticsProperty('idleBorderGradient', idleBorderGradient)) - ..add(DiagnosticsProperty('borderRadius', borderRadius)) - ..add(ColorProperty('expandButtonColor', expandButtonColor)) - ..add(ColorProperty('linkHighlightColor', linkHighlightColor)) - ..add(DiagnosticsProperty('elevation', elevation)) - ..add(DiagnosticsProperty('shadow', shadow)) - ..add(DiagnosticsProperty('enableSafeArea', enableSafeArea)) - ..add(DiagnosticsProperty( - 'useSystemAttachmentPicker', useSystemAttachmentPicker)); - } -} diff --git a/packages/stream_chat_flutter/lib/src/theme/message_list_view_theme.dart b/packages/stream_chat_flutter/lib/src/theme/message_list_view_theme.dart index d21b3725e9..66eb1b9644 100644 --- a/packages/stream_chat_flutter/lib/src/theme/message_list_view_theme.dart +++ b/packages/stream_chat_flutter/lib/src/theme/message_list_view_theme.dart @@ -33,19 +33,15 @@ class StreamMessageListViewTheme extends InheritedTheme { /// MessageListViewTheme theme = MessageListViewTheme.of(context); /// ``` static StreamMessageListViewThemeData of(BuildContext context) { - final messageListViewTheme = context - .dependOnInheritedWidgetOfExactType(); - return messageListViewTheme?.data ?? - StreamChatTheme.of(context).messageListViewTheme; + final messageListViewTheme = context.dependOnInheritedWidgetOfExactType(); + return messageListViewTheme?.data ?? StreamChatTheme.of(context).messageListViewTheme; } @override - Widget wrap(BuildContext context, Widget child) => - StreamMessageListViewTheme(data: data, child: child); + Widget wrap(BuildContext context, Widget child) => StreamMessageListViewTheme(data: data, child: child); @override - bool updateShouldNotify(StreamMessageListViewTheme oldWidget) => - data != oldWidget.data; + bool updateShouldNotify(StreamMessageListViewTheme oldWidget) => data != oldWidget.data; } /// {@template messageListViewThemeData} diff --git a/packages/stream_chat_flutter/lib/src/theme/message_theme.dart b/packages/stream_chat_flutter/lib/src/theme/message_theme.dart deleted file mode 100644 index 62b38d4edd..0000000000 --- a/packages/stream_chat_flutter/lib/src/theme/message_theme.dart +++ /dev/null @@ -1,361 +0,0 @@ -import 'dart:ui'; - -import 'package:flutter/foundation.dart'; -import 'package:flutter/material.dart'; -import 'package:stream_chat_flutter/src/theme/avatar_theme.dart'; -import 'package:stream_chat_flutter/src/utils/date_formatter.dart'; - -/// {@template message_theme_data} -/// Class for getting message theme -/// {@endtemplate} -// ignore: prefer-match-file-name -class StreamMessageThemeData with Diagnosticable { - /// Creates a [StreamMessageThemeData]. - const StreamMessageThemeData({ - this.repliesStyle, - this.messageTextStyle, - this.messageAuthorStyle, - this.messageLinksStyle, - this.messageDeletedStyle, - this.messageBackgroundColor, - this.messageBackgroundGradient, - this.messageBorderColor, - this.reactionsBackgroundColor, - this.reactionsBorderColor, - this.reactionsMaskColor, - this.avatarTheme, - this.createdAtStyle, - this.createdAtFormatter, - this.urlAttachmentBackgroundColor, - this.urlAttachmentHostStyle, - this.urlAttachmentTitleStyle, - this.urlAttachmentTextStyle, - this.urlAttachmentTitleMaxLine, - this.urlAttachmentTextMaxLine, - }); - - /// Text style for message text - final TextStyle? messageTextStyle; - - /// Text style for message author - final TextStyle? messageAuthorStyle; - - /// Text style for message links - final TextStyle? messageLinksStyle; - - /// Text style for created at text - final TextStyle? createdAtStyle; - - /// Formatter for the created at timestamp. - /// - /// If null, uses the default date formatting. - /// - /// Example: - /// ```dart - /// StreamMessageThemeData( - /// createdAtStyle: TextStyle(...), - /// createdAtFormatter: (context, date) { - /// return Jiffy.parseFromDateTime(date).jm; // "2:30 PM" - /// }, - /// ) - /// ``` - final DateFormatter? createdAtFormatter; - - /// Text style for the text on a deleted message - /// If not set [messageTextStyle] is used with [FontStyle.italic] and - /// [createdAtStyle.color]. - final TextStyle? messageDeletedStyle; - - /// Text style for replies - final TextStyle? repliesStyle; - - /// Color for messageBackgroundColor - final Color? messageBackgroundColor; - - /// Gradient for message background. - /// - /// Note: If this is set, it will override [messageBackgroundColor]. - final Gradient? messageBackgroundGradient; - - /// Color for message border color - final Color? messageBorderColor; - - /// Color for reactions - final Color? reactionsBackgroundColor; - - /// Colors reaction border - final Color? reactionsBorderColor; - - /// Color for reaction mask - final Color? reactionsMaskColor; - - /// Theme of the avatar - final StreamAvatarThemeData? avatarTheme; - - /// Background color for messages with url attachments. - final Color? urlAttachmentBackgroundColor; - - /// Color for url attachment host. - final TextStyle? urlAttachmentHostStyle; - - /// Color for url attachment title. - final TextStyle? urlAttachmentTitleStyle; - - /// Color for url attachment text. - final TextStyle? urlAttachmentTextStyle; - - /// Max number of lines in Url link title. - final int? urlAttachmentTitleMaxLine; - - /// Max number of lines in Url link text. - final int? urlAttachmentTextMaxLine; - - /// Copy with a theme - StreamMessageThemeData copyWith({ - TextStyle? messageTextStyle, - TextStyle? messageAuthorStyle, - TextStyle? messageLinksStyle, - TextStyle? messageDeletedStyle, - TextStyle? createdAtStyle, - DateFormatter? createdAtFormatter, - TextStyle? repliesStyle, - Color? messageBackgroundColor, - Gradient? messageBackgroundGradient, - Color? messageBorderColor, - StreamAvatarThemeData? avatarTheme, - Color? reactionsBackgroundColor, - Color? reactionsBorderColor, - Color? reactionsMaskColor, - Color? urlAttachmentBackgroundColor, - TextStyle? urlAttachmentHostStyle, - TextStyle? urlAttachmentTitleStyle, - TextStyle? urlAttachmentTextStyle, - int? urlAttachmentTitleMaxLine, - int? urlAttachmentTextMaxLine, - }) { - return StreamMessageThemeData( - messageTextStyle: messageTextStyle ?? this.messageTextStyle, - messageAuthorStyle: messageAuthorStyle ?? this.messageAuthorStyle, - messageLinksStyle: messageLinksStyle ?? this.messageLinksStyle, - createdAtStyle: createdAtStyle ?? this.createdAtStyle, - createdAtFormatter: createdAtFormatter ?? this.createdAtFormatter, - messageDeletedStyle: messageDeletedStyle ?? this.messageDeletedStyle, - messageBackgroundColor: - messageBackgroundColor ?? this.messageBackgroundColor, - messageBackgroundGradient: - messageBackgroundGradient ?? this.messageBackgroundGradient, - messageBorderColor: messageBorderColor ?? this.messageBorderColor, - avatarTheme: avatarTheme ?? this.avatarTheme, - repliesStyle: repliesStyle ?? this.repliesStyle, - reactionsBackgroundColor: - reactionsBackgroundColor ?? this.reactionsBackgroundColor, - reactionsBorderColor: reactionsBorderColor ?? this.reactionsBorderColor, - reactionsMaskColor: reactionsMaskColor ?? this.reactionsMaskColor, - urlAttachmentBackgroundColor: - urlAttachmentBackgroundColor ?? this.urlAttachmentBackgroundColor, - urlAttachmentHostStyle: - urlAttachmentHostStyle ?? this.urlAttachmentHostStyle, - urlAttachmentTitleStyle: - urlAttachmentTitleStyle ?? this.urlAttachmentTitleStyle, - urlAttachmentTextStyle: - urlAttachmentTextStyle ?? this.urlAttachmentTextStyle, - urlAttachmentTitleMaxLine: - urlAttachmentTitleMaxLine ?? this.urlAttachmentTitleMaxLine, - urlAttachmentTextMaxLine: - urlAttachmentTextMaxLine ?? this.urlAttachmentTextMaxLine, - ); - } - - /// Linearly interpolate from one [StreamMessageThemeData] to another. - StreamMessageThemeData lerp( - StreamMessageThemeData a, - StreamMessageThemeData b, - double t, - ) { - return StreamMessageThemeData( - avatarTheme: - const StreamAvatarThemeData().lerp(a.avatarTheme!, b.avatarTheme!, t), - messageAuthorStyle: - TextStyle.lerp(a.messageAuthorStyle, b.messageAuthorStyle, t), - createdAtStyle: TextStyle.lerp(a.createdAtStyle, b.createdAtStyle, t), - createdAtFormatter: t < 0.5 ? a.createdAtFormatter : b.createdAtFormatter, - messageDeletedStyle: - TextStyle.lerp(a.messageDeletedStyle, b.messageDeletedStyle, t), - messageBackgroundColor: - Color.lerp(a.messageBackgroundColor, b.messageBackgroundColor, t), - messageBackgroundGradient: - t < 0.5 ? a.messageBackgroundGradient : b.messageBackgroundGradient, - messageBorderColor: - Color.lerp(a.messageBorderColor, b.messageBorderColor, t), - messageLinksStyle: - TextStyle.lerp(a.messageLinksStyle, b.messageLinksStyle, t), - messageTextStyle: - TextStyle.lerp(a.messageTextStyle, b.messageTextStyle, t), - reactionsBackgroundColor: Color.lerp( - a.reactionsBackgroundColor, - b.reactionsBackgroundColor, - t, - ), - reactionsBorderColor: - Color.lerp(a.messageBorderColor, b.reactionsBorderColor, t), - reactionsMaskColor: - Color.lerp(a.reactionsMaskColor, b.reactionsMaskColor, t), - repliesStyle: TextStyle.lerp(a.repliesStyle, b.repliesStyle, t), - urlAttachmentBackgroundColor: Color.lerp( - a.urlAttachmentBackgroundColor, - b.urlAttachmentBackgroundColor, - t, - ), - urlAttachmentHostStyle: - TextStyle.lerp(a.urlAttachmentHostStyle, b.urlAttachmentHostStyle, t), - urlAttachmentTextStyle: TextStyle.lerp( - a.urlAttachmentTextStyle, - b.urlAttachmentTextStyle, - t, - ), - urlAttachmentTitleStyle: TextStyle.lerp( - a.urlAttachmentTitleStyle, - b.urlAttachmentTitleStyle, - t, - ), - urlAttachmentTitleMaxLine: lerpDouble( - a.urlAttachmentTitleMaxLine, - b.urlAttachmentTitleMaxLine, - t, - )?.round(), - urlAttachmentTextMaxLine: lerpDouble( - a.urlAttachmentTextMaxLine, - b.urlAttachmentTextMaxLine, - t, - )?.round(), - ); - } - - /// Merge with a theme - StreamMessageThemeData merge(StreamMessageThemeData? other) { - if (other == null) return this; - return copyWith( - messageTextStyle: messageTextStyle?.merge(other.messageTextStyle) ?? - other.messageTextStyle, - messageAuthorStyle: messageAuthorStyle?.merge(other.messageAuthorStyle) ?? - other.messageAuthorStyle, - messageLinksStyle: messageLinksStyle?.merge(other.messageLinksStyle) ?? - other.messageLinksStyle, - createdAtStyle: - createdAtStyle?.merge(other.createdAtStyle) ?? other.createdAtStyle, - createdAtFormatter: other.createdAtFormatter ?? createdAtFormatter, - messageDeletedStyle: - messageDeletedStyle?.merge(other.messageDeletedStyle) ?? - other.messageDeletedStyle, - repliesStyle: - repliesStyle?.merge(other.repliesStyle) ?? other.repliesStyle, - messageBackgroundColor: other.messageBackgroundColor, - messageBackgroundGradient: other.messageBackgroundGradient, - messageBorderColor: other.messageBorderColor, - avatarTheme: avatarTheme?.merge(other.avatarTheme) ?? other.avatarTheme, - reactionsBackgroundColor: other.reactionsBackgroundColor, - reactionsBorderColor: other.reactionsBorderColor, - reactionsMaskColor: other.reactionsMaskColor, - urlAttachmentBackgroundColor: other.urlAttachmentBackgroundColor, - urlAttachmentHostStyle: other.urlAttachmentHostStyle, - urlAttachmentTitleStyle: other.urlAttachmentTitleStyle, - urlAttachmentTextStyle: other.urlAttachmentTextStyle, - urlAttachmentTitleMaxLine: other.urlAttachmentTitleMaxLine, - urlAttachmentTextMaxLine: other.urlAttachmentTextMaxLine, - ); - } - - @override - bool operator ==(Object other) => - identical(this, other) || - other is StreamMessageThemeData && - runtimeType == other.runtimeType && - messageTextStyle == other.messageTextStyle && - messageAuthorStyle == other.messageAuthorStyle && - messageLinksStyle == other.messageLinksStyle && - createdAtStyle == other.createdAtStyle && - createdAtFormatter == other.createdAtFormatter && - messageDeletedStyle == other.messageDeletedStyle && - repliesStyle == other.repliesStyle && - messageBackgroundColor == other.messageBackgroundColor && - messageBackgroundGradient == other.messageBackgroundGradient && - messageBorderColor == other.messageBorderColor && - reactionsBackgroundColor == other.reactionsBackgroundColor && - reactionsBorderColor == other.reactionsBorderColor && - reactionsMaskColor == other.reactionsMaskColor && - avatarTheme == other.avatarTheme && - urlAttachmentBackgroundColor == other.urlAttachmentBackgroundColor && - urlAttachmentHostStyle == other.urlAttachmentHostStyle && - urlAttachmentTitleStyle == other.urlAttachmentTitleStyle && - urlAttachmentTextStyle == other.urlAttachmentTextStyle && - urlAttachmentTitleMaxLine == other.urlAttachmentTitleMaxLine && - urlAttachmentTextMaxLine == other.urlAttachmentTextMaxLine; - - @override - int get hashCode => - messageTextStyle.hashCode ^ - messageAuthorStyle.hashCode ^ - messageLinksStyle.hashCode ^ - createdAtStyle.hashCode ^ - createdAtFormatter.hashCode ^ - messageDeletedStyle.hashCode ^ - repliesStyle.hashCode ^ - messageBackgroundColor.hashCode ^ - messageBackgroundGradient.hashCode ^ - messageBorderColor.hashCode ^ - reactionsBackgroundColor.hashCode ^ - reactionsBorderColor.hashCode ^ - reactionsMaskColor.hashCode ^ - avatarTheme.hashCode ^ - urlAttachmentBackgroundColor.hashCode ^ - urlAttachmentHostStyle.hashCode ^ - urlAttachmentTitleStyle.hashCode ^ - urlAttachmentTextStyle.hashCode ^ - urlAttachmentTitleMaxLine.hashCode ^ - urlAttachmentTextMaxLine.hashCode; - - @override - void debugFillProperties(DiagnosticPropertiesBuilder properties) { - super.debugFillProperties(properties); - properties - ..add(DiagnosticsProperty('messageTextStyle', messageTextStyle)) - ..add(DiagnosticsProperty('messageAuthorStyle', messageAuthorStyle)) - ..add(DiagnosticsProperty('messageLinksStyle', messageLinksStyle)) - ..add(DiagnosticsProperty('createdAtStyle', createdAtStyle)) - ..add(DiagnosticsProperty('createdAtFormatter', createdAtFormatter)) - ..add(DiagnosticsProperty('messageDeletedStyle', messageDeletedStyle)) - ..add(DiagnosticsProperty('repliesStyle', repliesStyle)) - ..add(ColorProperty('messageBackgroundColor', messageBackgroundColor)) - ..add(DiagnosticsProperty( - 'messageBackgroundGradient', messageBackgroundGradient)) - ..add(ColorProperty('messageBorderColor', messageBorderColor)) - ..add(DiagnosticsProperty('avatarTheme', avatarTheme)) - ..add(ColorProperty('reactionsBackgroundColor', reactionsBackgroundColor)) - ..add(ColorProperty('reactionsBorderColor', reactionsBorderColor)) - ..add(ColorProperty('reactionsMaskColor', reactionsMaskColor)) - ..add(ColorProperty( - 'urlAttachmentBackgroundColor', - urlAttachmentBackgroundColor, - )) - ..add(DiagnosticsProperty( - 'urlAttachmentHostStyle', - urlAttachmentHostStyle, - )) - ..add(DiagnosticsProperty( - 'urlAttachmentTitleStyle', - urlAttachmentTitleStyle, - )) - ..add(DiagnosticsProperty( - 'urlAttachmentTextStyle', - urlAttachmentTextStyle, - )) - ..add(DiagnosticsProperty( - 'urlAttachmentTitleMaxLine', - urlAttachmentTitleMaxLine, - )) - ..add(DiagnosticsProperty( - 'urlAttachmentTextMaxLine', - urlAttachmentTextMaxLine, - )); - } -} diff --git a/packages/stream_chat_flutter/lib/src/theme/poll_card_style.dart b/packages/stream_chat_flutter/lib/src/theme/poll_card_style.dart new file mode 100644 index 0000000000..3b0a34e253 --- /dev/null +++ b/packages/stream_chat_flutter/lib/src/theme/poll_card_style.dart @@ -0,0 +1,49 @@ +import 'package:flutter/widgets.dart'; +import 'package:theme_extensions_builder_annotation/theme_extensions_builder_annotation.dart'; + +part 'poll_card_style.g.theme.dart'; + +/// Visual styling for card-like surfaces used by poll widgets. +/// +/// Describes the generic chrome (background color, corner radius, inner +/// padding) of a card container. Reused by +/// [StreamPollOptionsSheetThemeData] for both the question card and the +/// options card, and available for other poll surfaces that need the same +/// shape of styling. +/// +/// Per-surface concerns (text styles, spacing between inner items, sub-widget +/// styles) intentionally live on the containing theme data classes rather +/// than here, to keep this style class single-purpose. +@themeGen +@immutable +class StreamPollCardStyle with _$StreamPollCardStyle { + /// Creates poll card style properties. + const StreamPollCardStyle({ + this.backgroundColor, + this.borderRadius, + this.padding, + }); + + /// The background color of the card surface. + /// + /// If null, defaults to [StreamColorScheme.backgroundSurfaceCard]. + final Color? backgroundColor; + + /// The corner radius of the card surface. + /// + /// If null, defaults to `BorderRadius.all(StreamRadius.lg)`. + final BorderRadiusGeometry? borderRadius; + + /// The padding inside the card around its content. + /// + /// If null, defaults are resolved by the consuming widget + /// (typically based on [StreamSpacing]). + final EdgeInsetsGeometry? padding; + + /// Linearly interpolate between two [StreamPollCardStyle] objects. + static StreamPollCardStyle? lerp( + StreamPollCardStyle? a, + StreamPollCardStyle? b, + double t, + ) => _$StreamPollCardStyle.lerp(a, b, t); +} diff --git a/packages/stream_chat_flutter/lib/src/theme/poll_card_style.g.theme.dart b/packages/stream_chat_flutter/lib/src/theme/poll_card_style.g.theme.dart new file mode 100644 index 0000000000..f764b9f47b --- /dev/null +++ b/packages/stream_chat_flutter/lib/src/theme/poll_card_style.g.theme.dart @@ -0,0 +1,104 @@ +// dart format width=80 +// coverage:ignore-file +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint, unused_element + +part of 'poll_card_style.dart'; + +// ************************************************************************** +// ThemeGenGenerator +// ************************************************************************** + +mixin _$StreamPollCardStyle { + bool get canMerge => true; + + static StreamPollCardStyle? lerp( + StreamPollCardStyle? a, + StreamPollCardStyle? b, + double t, + ) { + if (identical(a, b)) { + return a; + } + + if (a == null) { + return t == 1.0 ? b : null; + } + + if (b == null) { + return t == 0.0 ? a : null; + } + + return StreamPollCardStyle( + backgroundColor: Color.lerp(a.backgroundColor, b.backgroundColor, t), + borderRadius: BorderRadiusGeometry.lerp( + a.borderRadius, + b.borderRadius, + t, + ), + padding: EdgeInsetsGeometry.lerp(a.padding, b.padding, t), + ); + } + + StreamPollCardStyle copyWith({ + Color? backgroundColor, + BorderRadiusGeometry? borderRadius, + EdgeInsetsGeometry? padding, + }) { + final _this = (this as StreamPollCardStyle); + + return StreamPollCardStyle( + backgroundColor: backgroundColor ?? _this.backgroundColor, + borderRadius: borderRadius ?? _this.borderRadius, + padding: padding ?? _this.padding, + ); + } + + StreamPollCardStyle merge(StreamPollCardStyle? other) { + final _this = (this as StreamPollCardStyle); + + if (other == null || identical(_this, other)) { + return _this; + } + + if (!other.canMerge) { + return other; + } + + return copyWith( + backgroundColor: other.backgroundColor, + borderRadius: other.borderRadius, + padding: other.padding, + ); + } + + @override + bool operator ==(Object other) { + if (identical(this, other)) { + return true; + } + + if (other.runtimeType != runtimeType) { + return false; + } + + final _this = (this as StreamPollCardStyle); + final _other = (other as StreamPollCardStyle); + + return _other.backgroundColor == _this.backgroundColor && + _other.borderRadius == _this.borderRadius && + _other.padding == _this.padding; + } + + @override + int get hashCode { + final _this = (this as StreamPollCardStyle); + + return Object.hash( + runtimeType, + _this.backgroundColor, + _this.borderRadius, + _this.padding, + ); + } +} diff --git a/packages/stream_chat_flutter/lib/src/theme/poll_comments_dialog_theme.dart b/packages/stream_chat_flutter/lib/src/theme/poll_comments_dialog_theme.dart deleted file mode 100644 index 9ad433546e..0000000000 --- a/packages/stream_chat_flutter/lib/src/theme/poll_comments_dialog_theme.dart +++ /dev/null @@ -1,191 +0,0 @@ -import 'dart:ui'; - -import 'package:flutter/foundation.dart'; -import 'package:flutter/material.dart'; -import 'package:stream_chat_flutter/src/theme/stream_chat_theme.dart'; - -/// {@template streamPollCommentsDialogTheme} -/// Overrides the default style of [StreamPollCommentsDialog] descendants. -/// -/// See also: -/// -/// * [StreamPollCommentsDialogThemeData], which is used to configure this -/// theme. -/// {@endtemplate} -class StreamPollCommentsDialogTheme extends InheritedTheme { - /// Creates a [StreamPollCommentsDialogTheme]. - /// - /// The [data] parameter must not be null. - const StreamPollCommentsDialogTheme({ - super.key, - required this.data, - required super.child, - }); - - /// The configuration of this theme. - final StreamPollCommentsDialogThemeData data; - - /// The closest instance of this class that encloses the given context. - /// - /// If there is no enclosing [StreamPollCommentsDialogTheme] widget, then - /// [StreamChatThemeData.pollCommentsDialogTheme] is used. - static StreamPollCommentsDialogThemeData of(BuildContext context) { - final pollCommentsDialogTheme = context - .dependOnInheritedWidgetOfExactType(); - return pollCommentsDialogTheme?.data ?? - StreamChatTheme.of(context).pollCommentsDialogTheme; - } - - @override - Widget wrap(BuildContext context, Widget child) => - StreamPollCommentsDialogTheme(data: data, child: child); - - @override - bool updateShouldNotify(StreamPollCommentsDialogTheme oldWidget) => - data != oldWidget.data; -} - -/// {@template streamPollCommentsDialogThemeData} -/// A style that overrides the default appearance of [StreamPollCommentsDialog] -/// widgets when used with [StreamPollCommentsDialogTheme] or with the overall -/// [StreamChatTheme]'s [StreamChatThemeData.pollCommentsDialogTheme]. -/// {@endtemplate} -class StreamPollCommentsDialogThemeData with Diagnosticable { - /// {@macro streamPollCommentsDialogThemeData} - const StreamPollCommentsDialogThemeData({ - this.backgroundColor, - this.appBarElevation, - this.appBarBackgroundColor, - this.appBarForegroundColor, - this.appBarTitleTextStyle, - this.pollCommentItemBackgroundColor, - this.pollCommentItemBorderRadius, - this.updateYourCommentButtonStyle, - }); - - /// The background color of the dialog. - final Color? backgroundColor; - - /// The elevation of the app bar. - final double? appBarElevation; - - /// The background color of the app bar. - final Color? appBarBackgroundColor; - - /// The foreground color of the app bar (icon and text color). - final Color? appBarForegroundColor; - - /// The text style of the app bar title. - final TextStyle? appBarTitleTextStyle; - - /// The background color of the poll comment item. - final Color? pollCommentItemBackgroundColor; - - /// The border radius of the poll comment item. - final BorderRadius? pollCommentItemBorderRadius; - - /// The button style for the update your comment button. - final ButtonStyle? updateYourCommentButtonStyle; - - /// Copies this [StreamPollCommentsDialogThemeData] with some new values. - StreamPollCommentsDialogThemeData copyWith({ - Color? backgroundColor, - double? appBarElevation, - Color? appBarBackgroundColor, - Color? appBarForegroundColor, - TextStyle? appBarTitleTextStyle, - Color? pollCommentItemBackgroundColor, - BorderRadius? pollCommentItemBorderRadius, - ButtonStyle? updateYourCommentButtonStyle, - }) => - StreamPollCommentsDialogThemeData( - backgroundColor: backgroundColor ?? this.backgroundColor, - appBarElevation: appBarElevation ?? this.appBarElevation, - appBarBackgroundColor: - appBarBackgroundColor ?? this.appBarBackgroundColor, - appBarForegroundColor: - appBarForegroundColor ?? this.appBarForegroundColor, - appBarTitleTextStyle: appBarTitleTextStyle ?? this.appBarTitleTextStyle, - pollCommentItemBackgroundColor: pollCommentItemBackgroundColor ?? - this.pollCommentItemBackgroundColor, - pollCommentItemBorderRadius: - pollCommentItemBorderRadius ?? this.pollCommentItemBorderRadius, - updateYourCommentButtonStyle: - updateYourCommentButtonStyle ?? this.updateYourCommentButtonStyle, - ); - - /// Merges this [StreamPollCommentsDialogThemeData] with the [other]. - StreamPollCommentsDialogThemeData merge( - StreamPollCommentsDialogThemeData? other, - ) { - if (other == null) return this; - return copyWith( - backgroundColor: other.backgroundColor, - appBarElevation: other.appBarElevation, - appBarBackgroundColor: other.appBarBackgroundColor, - appBarForegroundColor: other.appBarForegroundColor, - appBarTitleTextStyle: other.appBarTitleTextStyle, - pollCommentItemBackgroundColor: other.pollCommentItemBackgroundColor, - pollCommentItemBorderRadius: other.pollCommentItemBorderRadius, - updateYourCommentButtonStyle: other.updateYourCommentButtonStyle, - ); - } - - /// Linearly interpolate between two [StreamPollCommentsDialogThemeData]. - StreamPollCommentsDialogThemeData lerp( - StreamPollCommentsDialogThemeData? a, - StreamPollCommentsDialogThemeData? b, - double t, - ) { - return StreamPollCommentsDialogThemeData( - backgroundColor: Color.lerp(a?.backgroundColor, b?.backgroundColor, t), - appBarElevation: lerpDouble(a?.appBarElevation, b?.appBarElevation, t), - appBarBackgroundColor: - Color.lerp(a?.appBarBackgroundColor, b?.appBarBackgroundColor, t), - appBarForegroundColor: - Color.lerp(a?.appBarForegroundColor, b?.appBarForegroundColor, t), - appBarTitleTextStyle: - TextStyle.lerp(a?.appBarTitleTextStyle, b?.appBarTitleTextStyle, t), - pollCommentItemBackgroundColor: Color.lerp( - a?.pollCommentItemBackgroundColor, - b?.pollCommentItemBackgroundColor, - t, - ), - pollCommentItemBorderRadius: BorderRadius.lerp( - a?.pollCommentItemBorderRadius, - b?.pollCommentItemBorderRadius, - t, - ), - updateYourCommentButtonStyle: ButtonStyle.lerp( - a?.updateYourCommentButtonStyle, - b?.updateYourCommentButtonStyle, - t, - ), - ); - } - - @override - bool operator ==(Object other) => - identical(this, other) || - other is StreamPollCommentsDialogThemeData && - other.backgroundColor == backgroundColor && - other.appBarElevation == appBarElevation && - other.appBarBackgroundColor == appBarBackgroundColor && - other.appBarForegroundColor == appBarForegroundColor && - other.appBarTitleTextStyle == appBarTitleTextStyle && - other.pollCommentItemBackgroundColor == - pollCommentItemBackgroundColor && - other.pollCommentItemBorderRadius == pollCommentItemBorderRadius && - other.updateYourCommentButtonStyle == updateYourCommentButtonStyle; - - @override - int get hashCode => - backgroundColor.hashCode ^ - appBarElevation.hashCode ^ - appBarBackgroundColor.hashCode ^ - appBarForegroundColor.hashCode ^ - appBarTitleTextStyle.hashCode ^ - pollCommentItemBackgroundColor.hashCode ^ - pollCommentItemBorderRadius.hashCode ^ - updateYourCommentButtonStyle.hashCode; -} diff --git a/packages/stream_chat_flutter/lib/src/theme/poll_comments_sheet_theme.dart b/packages/stream_chat_flutter/lib/src/theme/poll_comments_sheet_theme.dart new file mode 100644 index 0000000000..2addbef126 --- /dev/null +++ b/packages/stream_chat_flutter/lib/src/theme/poll_comments_sheet_theme.dart @@ -0,0 +1,163 @@ +import 'package:flutter/widgets.dart'; +import 'package:stream_chat_flutter/src/theme/poll_option_votes_style.dart'; +import 'package:stream_chat_flutter/src/theme/stream_chat_theme.dart'; +import 'package:stream_core_flutter/stream_core_flutter.dart'; +import 'package:theme_extensions_builder_annotation/theme_extensions_builder_annotation.dart'; + +part 'poll_comments_sheet_theme.g.theme.dart'; + +/// Applies a poll comments sheet theme to descendant +/// [StreamPollCommentsSheet] widgets. +/// +/// Wrap a subtree with [StreamPollCommentsSheetTheme] to override the +/// comments sheet styling. Access the merged theme using +/// [StreamPollCommentsSheetTheme.of]. +/// +/// {@tool snippet} +/// +/// Override comments sheet styling for a specific route: +/// +/// ```dart +/// StreamPollCommentsSheetTheme( +/// data: StreamPollCommentsSheetThemeData( +/// commentStyle: StreamPollOptionVotesStyle( +/// cardStyle: StreamPollCardStyle(backgroundColor: Colors.white), +/// ), +/// ), +/// child: StreamPollCommentsSheet(poll: poll), +/// ) +/// ``` +/// {@end-tool} +/// +/// See also: +/// +/// * [StreamPollCommentsSheetThemeData], which describes the comments +/// sheet theme. +/// * [StreamPollCommentsSheet], the widget affected by this theme. +class StreamPollCommentsSheetTheme extends InheritedTheme { + /// Creates a poll comments sheet theme that controls descendant widgets. + const StreamPollCommentsSheetTheme({ + super.key, + required this.data, + required super.child, + }); + + /// The poll comments sheet theme data for descendant widgets. + final StreamPollCommentsSheetThemeData data; + + /// Returns the [StreamPollCommentsSheetThemeData] merged from local and + /// global themes. + /// + /// Local values from the nearest [StreamPollCommentsSheetTheme] ancestor + /// take precedence over global values from [StreamChatTheme.of]. + /// + /// This allows partial overrides — for example, overriding only + /// [StreamPollCommentsSheetThemeData.backgroundColor] while inheriting + /// other properties from the global theme. + static StreamPollCommentsSheetThemeData of(BuildContext context) { + final localTheme = context.dependOnInheritedWidgetOfExactType(); + return StreamChatTheme.of(context).pollCommentsSheetTheme.merge(localTheme?.data); + } + + @override + Widget wrap(BuildContext context, Widget child) => StreamPollCommentsSheetTheme(data: data, child: child); + + @override + bool updateShouldNotify(StreamPollCommentsSheetTheme oldWidget) => data != oldWidget.data; +} + +/// Theme data for customizing [StreamPollCommentsSheet] widgets. +/// +/// Covers the sheet surface and the per-comment card rendered for each +/// item in the paginated comment feed. +/// +/// The per-comment card reuses [StreamPollOptionVotesStyle] because +/// comments (a.k.a. answers) are a flavour of poll vote on the backend. +/// Only [StreamPollOptionVotesStyle.cardStyle], +/// [StreamPollOptionVotesStyle.footerDividerColor], and +/// [StreamPollOptionVotesStyle.footerButtonStyle] are consumed by the +/// comments sheet. The other fields on that style (number / body / vote +/// count text styles, winner trophy icon) are intentionally unused here +/// and have no effect on the rendered comment card. +/// +/// {@tool snippet} +/// +/// Customize the comments sheet appearance globally: +/// +/// ```dart +/// StreamChatThemeData( +/// pollCommentsSheetTheme: StreamPollCommentsSheetThemeData( +/// commentStyle: StreamPollOptionVotesStyle( +/// cardStyle: StreamPollCardStyle(backgroundColor: Colors.white), +/// ), +/// ), +/// ) +/// ``` +/// {@end-tool} +/// +/// See also: +/// +/// * [StreamPollCommentsSheet], the widget that uses this theme data. +/// * [StreamPollCommentsSheetTheme], for overriding theme in a widget +/// subtree. +/// * [StreamPollOptionVotesStyle], the nested style reused for the +/// per-comment card. +@themeGen +@immutable +class StreamPollCommentsSheetThemeData with _$StreamPollCommentsSheetThemeData { + /// Creates poll comments sheet theme data with optional style overrides. + const StreamPollCommentsSheetThemeData({ + this.backgroundColor, + this.contentPadding, + this.itemSpacing, + this.sheetHeaderStyle, + this.commentStyle, + }); + + /// The background color of the sheet surface. + /// + /// If null, defaults to [StreamColorScheme.backgroundApp]. + final Color? backgroundColor; + + /// The visual styling for the [StreamSheetHeader] rendered at the top of + /// the sheet. + /// + /// Scoped to the sheet's header via an inner [StreamSheetHeaderTheme] so + /// overrides here do not leak into other sheet headers on the screen. + /// When null, the header inherits the ambient [StreamSheetHeaderTheme]. + final StreamSheetHeaderStyle? sheetHeaderStyle; + + /// The padding around the sheet's scrollable comment list. + /// + /// If null, defaults to `EdgeInsets.all(StreamSpacing.md)`. + final EdgeInsetsGeometry? contentPadding; + + /// The vertical spacing between consecutive comment cards. + /// + /// If null, defaults to `StreamSpacing.md`. + final double? itemSpacing; + + /// The visual styling for the per-comment card rendered as each item in + /// the list. + /// + /// Controls the card chrome (background, corner radius, inner padding), + /// the thin divider rendered above the optional "Update your comment" + /// footer action, and the [StreamButtonThemeStyle] applied to that + /// footer button. + /// + /// Other fields on [StreamPollOptionVotesStyle] (the number / body / + /// vote count text styles and the winner trophy icon color / size) are + /// intentionally not consumed by this sheet and have no effect on the + /// comment card. + /// + /// When null, the sheet falls back to its token-backed defaults. + final StreamPollOptionVotesStyle? commentStyle; + + /// Linearly interpolate between two [StreamPollCommentsSheetThemeData] + /// objects. + static StreamPollCommentsSheetThemeData? lerp( + StreamPollCommentsSheetThemeData? a, + StreamPollCommentsSheetThemeData? b, + double t, + ) => _$StreamPollCommentsSheetThemeData.lerp(a, b, t); +} diff --git a/packages/stream_chat_flutter/lib/src/theme/poll_comments_sheet_theme.g.theme.dart b/packages/stream_chat_flutter/lib/src/theme/poll_comments_sheet_theme.g.theme.dart new file mode 100644 index 0000000000..6cabdaccf2 --- /dev/null +++ b/packages/stream_chat_flutter/lib/src/theme/poll_comments_sheet_theme.g.theme.dart @@ -0,0 +1,129 @@ +// dart format width=80 +// coverage:ignore-file +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint, unused_element + +part of 'poll_comments_sheet_theme.dart'; + +// ************************************************************************** +// ThemeGenGenerator +// ************************************************************************** + +mixin _$StreamPollCommentsSheetThemeData { + bool get canMerge => true; + + static StreamPollCommentsSheetThemeData? lerp( + StreamPollCommentsSheetThemeData? a, + StreamPollCommentsSheetThemeData? b, + double t, + ) { + if (identical(a, b)) { + return a; + } + + if (a == null) { + return t == 1.0 ? b : null; + } + + if (b == null) { + return t == 0.0 ? a : null; + } + + return StreamPollCommentsSheetThemeData( + backgroundColor: Color.lerp(a.backgroundColor, b.backgroundColor, t), + sheetHeaderStyle: StreamSheetHeaderStyle.lerp( + a.sheetHeaderStyle, + b.sheetHeaderStyle, + t, + ), + contentPadding: EdgeInsetsGeometry.lerp( + a.contentPadding, + b.contentPadding, + t, + ), + itemSpacing: lerpDouble$(a.itemSpacing, b.itemSpacing, t), + commentStyle: StreamPollOptionVotesStyle.lerp( + a.commentStyle, + b.commentStyle, + t, + ), + ); + } + + StreamPollCommentsSheetThemeData copyWith({ + Color? backgroundColor, + StreamSheetHeaderStyle? sheetHeaderStyle, + EdgeInsetsGeometry? contentPadding, + double? itemSpacing, + StreamPollOptionVotesStyle? commentStyle, + }) { + final _this = (this as StreamPollCommentsSheetThemeData); + + return StreamPollCommentsSheetThemeData( + backgroundColor: backgroundColor ?? _this.backgroundColor, + sheetHeaderStyle: sheetHeaderStyle ?? _this.sheetHeaderStyle, + contentPadding: contentPadding ?? _this.contentPadding, + itemSpacing: itemSpacing ?? _this.itemSpacing, + commentStyle: commentStyle ?? _this.commentStyle, + ); + } + + StreamPollCommentsSheetThemeData merge( + StreamPollCommentsSheetThemeData? other, + ) { + final _this = (this as StreamPollCommentsSheetThemeData); + + if (other == null || identical(_this, other)) { + return _this; + } + + if (!other.canMerge) { + return other; + } + + return copyWith( + backgroundColor: other.backgroundColor, + sheetHeaderStyle: + _this.sheetHeaderStyle?.merge(other.sheetHeaderStyle) ?? + other.sheetHeaderStyle, + contentPadding: other.contentPadding, + itemSpacing: other.itemSpacing, + commentStyle: + _this.commentStyle?.merge(other.commentStyle) ?? other.commentStyle, + ); + } + + @override + bool operator ==(Object other) { + if (identical(this, other)) { + return true; + } + + if (other.runtimeType != runtimeType) { + return false; + } + + final _this = (this as StreamPollCommentsSheetThemeData); + final _other = (other as StreamPollCommentsSheetThemeData); + + return _other.backgroundColor == _this.backgroundColor && + _other.sheetHeaderStyle == _this.sheetHeaderStyle && + _other.contentPadding == _this.contentPadding && + _other.itemSpacing == _this.itemSpacing && + _other.commentStyle == _this.commentStyle; + } + + @override + int get hashCode { + final _this = (this as StreamPollCommentsSheetThemeData); + + return Object.hash( + runtimeType, + _this.backgroundColor, + _this.sheetHeaderStyle, + _this.contentPadding, + _this.itemSpacing, + _this.commentStyle, + ); + } +} diff --git a/packages/stream_chat_flutter/lib/src/theme/poll_creator_theme.dart b/packages/stream_chat_flutter/lib/src/theme/poll_creator_theme.dart index 5287547603..934ce6c940 100644 --- a/packages/stream_chat_flutter/lib/src/theme/poll_creator_theme.dart +++ b/packages/stream_chat_flutter/lib/src/theme/poll_creator_theme.dart @@ -1,357 +1,202 @@ -import 'dart:ui'; - -import 'package:flutter/foundation.dart'; import 'package:flutter/widgets.dart'; import 'package:stream_chat_flutter/src/theme/stream_chat_theme.dart'; +import 'package:stream_core_flutter/stream_core_flutter.dart'; +import 'package:theme_extensions_builder_annotation/theme_extensions_builder_annotation.dart'; + +part 'poll_creator_theme.g.theme.dart'; -/// {@template streamPollCreatorTheme} -/// Overrides the default style of [StreamPollCreatorWidget] descendants. +/// Applies a poll creator theme to descendant [StreamPollCreatorWidget] +/// widgets. +/// +/// Wrap a subtree with [StreamPollCreatorTheme] to override poll creator +/// styling. Access the merged theme using [StreamPollCreatorTheme.of]. +/// +/// {@tool snippet} +/// +/// Override poll creator styling for a specific section: +/// +/// ```dart +/// StreamPollCreatorTheme( +/// data: StreamPollCreatorThemeData( +/// headerTextStyle: TextStyle(fontWeight: FontWeight.w700), +/// configOptionStyle: StreamPollConfigOptionStyle( +/// switchStyle: StreamSwitchStyle.from( +/// selectedTrackColor: Colors.green, +/// ), +/// ), +/// ), +/// child: StreamPollCreatorWidget(controller: controller), +/// ) +/// ``` +/// {@end-tool} /// /// See also: /// -/// * [StreamPollCreatorThemeData], which is used to configure this theme. -/// {@endtemplate} +/// * [StreamPollCreatorThemeData], which describes the poll creator theme. +/// * [StreamPollCreatorWidget], the widget affected by this theme. class StreamPollCreatorTheme extends InheritedTheme { - /// Creates a [StreamPollCreatorTheme]. - /// - /// The [data] parameter must not be null. + /// Creates a poll creator theme that controls descendant widgets. const StreamPollCreatorTheme({ super.key, required this.data, required super.child, }); - /// The configuration of this theme. + /// The poll creator theme data for descendant widgets. final StreamPollCreatorThemeData data; - /// The closest instance of this class that encloses the given context. + /// Returns the [StreamPollCreatorThemeData] merged from local and global + /// themes. /// - /// If there is no enclosing [StreamPollCreatorTheme] widget, then - /// [StreamChatThemeData.pollCreatorTheme] is used. + /// Local values from the nearest [StreamPollCreatorTheme] ancestor take + /// precedence over global values from [StreamChatTheme.of]. /// - /// Typical usage is as follows: - /// - /// ```dart - /// StreamPollCreatorTheme theme = StreamPollCreatorTheme.of(context); - /// ``` + /// This allows partial overrides — for example, overriding only + /// [StreamPollCreatorThemeData.headerTextStyle] while inheriting other + /// properties from the global theme. static StreamPollCreatorThemeData of(BuildContext context) { - final pollCreatorTheme = - context.dependOnInheritedWidgetOfExactType(); - return pollCreatorTheme?.data ?? - StreamChatTheme.of(context).pollCreatorTheme; + final localTheme = context.dependOnInheritedWidgetOfExactType(); + return StreamChatTheme.of(context).pollCreatorTheme.merge(localTheme?.data); } @override - Widget wrap(BuildContext context, Widget child) => - StreamPollCreatorTheme(data: data, child: child); + Widget wrap(BuildContext context, Widget child) => StreamPollCreatorTheme(data: data, child: child); @override - bool updateShouldNotify(StreamPollCreatorTheme oldWidget) => - data != oldWidget.data; + bool updateShouldNotify(StreamPollCreatorTheme oldWidget) => data != oldWidget.data; } -/// {@template streamPollCreatorThemeData} -/// A style that overrides the default appearance of [StreamPollCreator] widget -/// when used with [StreamPollCreatorTheme] or with the overall -/// [StreamChatTheme]'s [StreamChatThemeData.pollCreatorTheme]. -/// {@endtemplate} -class StreamPollCreatorThemeData with Diagnosticable { - /// {@macro streamPollCreatorThemeData} +/// Theme data for customizing [StreamPollCreatorWidget] widgets. +/// +/// {@tool snippet} +/// +/// Customize poll creator appearance globally: +/// +/// ```dart +/// StreamChatThemeData( +/// pollCreatorTheme: StreamPollCreatorThemeData( +/// headerTextStyle: TextStyle(fontWeight: FontWeight.w700), +/// ), +/// ) +/// ``` +/// {@end-tool} +/// +/// See also: +/// +/// * [StreamPollCreatorWidget], the widget that uses this theme data. +/// * [StreamPollCreatorTheme], for overriding theme in a widget subtree. +@themeGen +@immutable +class StreamPollCreatorThemeData with _$StreamPollCreatorThemeData { + /// Creates poll creator theme data with optional style overrides. const StreamPollCreatorThemeData({ - this.backgroundColor, - this.appBarTitleStyle, - this.appBarElevation, - this.appBarBackgroundColor, - this.appBarForegroundColor, - this.questionTextFieldFillColor, - this.questionHeaderStyle, - this.questionTextFieldStyle, - this.questionTextFieldErrorStyle, - this.questionTextFieldBorderRadius, - this.optionsHeaderStyle, - this.optionsTextFieldStyle, - this.optionsTextFieldFillColor, - this.optionsTextFieldErrorStyle, - this.optionsTextFieldBorderRadius, - this.switchListTileFillColor, - this.switchListTileTitleStyle, - this.switchListTileErrorStyle, - this.switchListTileBorderRadius, - this.actionDialogTitleStyle, - this.actionDialogContentStyle, + this.sheetHeaderStyle, + this.headerTextStyle, + this.questionInputStyle, + this.optionInputStyle, + this.configOptionStyle, }); - /// The background color of the poll creator. - final Color? backgroundColor; - - /// The text style of the appBar title. - final TextStyle? appBarTitleStyle; - - /// The elevation of the appBar. - final double? appBarElevation; - - /// The background color of the appBar. - final Color? appBarBackgroundColor; - - /// The foreground color of the appBar (icon and text color). - final Color? appBarForegroundColor; - - /// The fill color of the question text field. - final Color? questionTextFieldFillColor; - - /// The style of the question header text. - final TextStyle? questionHeaderStyle; - - /// The text style of the question text field. - final TextStyle? questionTextFieldStyle; - - /// The text style of the question error text when the question is invalid. - final TextStyle? questionTextFieldErrorStyle; - - /// The border radius of the question text field. - final BorderRadius? questionTextFieldBorderRadius; - - /// The fill color of the options text field. - final Color? optionsTextFieldFillColor; + /// The visual styling for the [StreamSheetHeader] rendered at the top of + /// the [StreamPollCreatorSheet]. + /// + /// Scoped to the sheet's header via an inner [StreamSheetHeaderTheme] so + /// overrides here do not leak into other sheet headers on the screen. + /// When null, the header inherits the ambient [StreamSheetHeaderTheme]. + final StreamSheetHeaderStyle? sheetHeaderStyle; - /// The style of the options header text. - final TextStyle? optionsHeaderStyle; + /// The text style for section header labels (e.g. "Questions", "Options"). + /// + /// If null, defaults to [StreamTextTheme.headingSm] with primary color. + final TextStyle? headerTextStyle; - /// The text style of the options text field. - final TextStyle? optionsTextFieldStyle; + /// The visual styling for the question text input. + /// + /// If null, the text input uses its own inherited theme defaults. + final StreamTextInputStyle? questionInputStyle; - /// The text style of the options error text when the options are invalid. - final TextStyle? optionsTextFieldErrorStyle; + /// The visual styling for the option text inputs. + /// + /// If null, the text input uses its own inherited theme defaults. + final StreamTextInputStyle? optionInputStyle; - /// The border radius of the options text field. - final BorderRadius? optionsTextFieldBorderRadius; + /// The visual styling for option toggle cards (e.g. "Multiple answers", + /// "Anonymous poll"). + final StreamPollConfigOptionStyle? configOptionStyle; - /// The fill color of the switch list tile. - final Color? switchListTileFillColor; + /// Linearly interpolate between two [StreamPollCreatorThemeData] objects. + static StreamPollCreatorThemeData? lerp( + StreamPollCreatorThemeData? a, + StreamPollCreatorThemeData? b, + double t, + ) => _$StreamPollCreatorThemeData.lerp(a, b, t); +} - /// The text style of the switch list tile title. - final TextStyle? switchListTileTitleStyle; +/// Visual styling properties for poll creator option toggle cards. +/// +/// Defines the appearance of the toggle-switch cards used for poll +/// configuration options like "Multiple answers" and "Anonymous poll", +/// including sub-component styles for the toggle switch and stepper. +/// +/// See also: +/// +/// * [StreamPollCreatorThemeData], which wraps this style for theming. +/// * [StreamPollCreatorWidget], which uses this styling. +@themeGen +@immutable +class StreamPollConfigOptionStyle with _$StreamPollConfigOptionStyle { + /// Creates poll creator option card style properties. + const StreamPollConfigOptionStyle({ + this.backgroundColor, + this.contentPadding, + this.childSpacing, + this.titleTextStyle, + this.descriptionTextStyle, + this.switchStyle, + this.stepperStyle, + }); - /// The text style of the switch list tile error text when the switch list - /// tile is invalid. - final TextStyle? switchListTileErrorStyle; + /// The background color of the option card. + /// + /// If null, defaults to [StreamColorScheme.backgroundSurfaceCard]. + final Color? backgroundColor; - /// The border radius of the switch list tile. - final BorderRadius? switchListTileBorderRadius; + /// The padding inside the card around the content. + /// + /// If null, defaults to `EdgeInsets.all(spacing.md)`. + final EdgeInsetsGeometry? contentPadding; - /// The text style of the action dialog title. - final TextStyle? actionDialogTitleStyle; + /// The vertical spacing between the header and the child widget. + /// + /// If null, defaults to `spacing.md`. + final double? childSpacing; - /// The text style of the action dialog content. - final TextStyle? actionDialogContentStyle; + /// The text style for the option card title. + /// + /// If null, defaults to [StreamTextTheme.headingSm] with primary color. + final TextStyle? titleTextStyle; - /// Copies this [StreamPollCreatorThemeData] with some new values. - StreamPollCreatorThemeData copyWith({ - Color? backgroundColor, - TextStyle? appBarTitleStyle, - double? appBarElevation, - Color? appBarBackgroundColor, - Color? appBarForegroundColor, - Color? questionTextFieldFillColor, - TextStyle? questionHeaderStyle, - TextStyle? questionTextFieldStyle, - TextStyle? questionTextFieldErrorStyle, - BorderRadius? questionTextFieldBorderRadius, - Color? optionsTextFieldFillColor, - TextStyle? optionsHeaderStyle, - TextStyle? optionsTextFieldStyle, - TextStyle? optionsTextFieldErrorStyle, - BorderRadius? optionsTextFieldBorderRadius, - Color? switchListTileFillColor, - TextStyle? switchListTileTitleStyle, - TextStyle? switchListTileErrorStyle, - BorderRadius? switchListTileBorderRadius, - TextStyle? actionDialogTitleStyle, - TextStyle? actionDialogContentStyle, - }) { - return StreamPollCreatorThemeData( - backgroundColor: backgroundColor ?? this.backgroundColor, - appBarTitleStyle: appBarTitleStyle ?? this.appBarTitleStyle, - appBarElevation: appBarElevation ?? this.appBarElevation, - appBarBackgroundColor: - appBarBackgroundColor ?? this.appBarBackgroundColor, - appBarForegroundColor: - appBarForegroundColor ?? this.appBarForegroundColor, - questionTextFieldFillColor: - questionTextFieldFillColor ?? this.questionTextFieldFillColor, - questionHeaderStyle: questionHeaderStyle ?? this.questionHeaderStyle, - questionTextFieldStyle: - questionTextFieldStyle ?? this.questionTextFieldStyle, - questionTextFieldErrorStyle: - questionTextFieldErrorStyle ?? this.questionTextFieldErrorStyle, - questionTextFieldBorderRadius: - questionTextFieldBorderRadius ?? this.questionTextFieldBorderRadius, - optionsTextFieldFillColor: - optionsTextFieldFillColor ?? this.optionsTextFieldFillColor, - optionsHeaderStyle: optionsHeaderStyle ?? this.optionsHeaderStyle, - optionsTextFieldStyle: - optionsTextFieldStyle ?? this.optionsTextFieldStyle, - optionsTextFieldErrorStyle: - optionsTextFieldErrorStyle ?? this.optionsTextFieldErrorStyle, - optionsTextFieldBorderRadius: - optionsTextFieldBorderRadius ?? this.optionsTextFieldBorderRadius, - switchListTileFillColor: - switchListTileFillColor ?? this.switchListTileFillColor, - switchListTileTitleStyle: - switchListTileTitleStyle ?? this.switchListTileTitleStyle, - switchListTileErrorStyle: - switchListTileErrorStyle ?? this.switchListTileErrorStyle, - switchListTileBorderRadius: - switchListTileBorderRadius ?? this.switchListTileBorderRadius, - actionDialogTitleStyle: - actionDialogTitleStyle ?? this.actionDialogTitleStyle, - actionDialogContentStyle: - actionDialogContentStyle ?? this.actionDialogContentStyle, - ); - } + /// The text style for the option card description. + /// + /// If null, defaults to [StreamTextTheme.captionDefault] with tertiary + /// color. + final TextStyle? descriptionTextStyle; - /// Merges [this] [StreamPollCreatorThemeData] with the [other] - StreamPollCreatorThemeData merge(StreamPollCreatorThemeData? other) { - if (other == null) return this; - return copyWith( - backgroundColor: other.backgroundColor ?? backgroundColor, - appBarTitleStyle: other.appBarTitleStyle ?? appBarTitleStyle, - appBarElevation: other.appBarElevation ?? appBarElevation, - appBarBackgroundColor: - other.appBarBackgroundColor ?? appBarBackgroundColor, - appBarForegroundColor: - other.appBarForegroundColor ?? appBarForegroundColor, - questionTextFieldFillColor: - other.questionTextFieldFillColor ?? questionTextFieldFillColor, - questionHeaderStyle: other.questionHeaderStyle ?? questionHeaderStyle, - questionTextFieldStyle: - other.questionTextFieldStyle ?? questionTextFieldStyle, - questionTextFieldErrorStyle: - other.questionTextFieldErrorStyle ?? questionTextFieldErrorStyle, - questionTextFieldBorderRadius: - other.questionTextFieldBorderRadius ?? questionTextFieldBorderRadius, - optionsTextFieldFillColor: - other.optionsTextFieldFillColor ?? optionsTextFieldFillColor, - optionsHeaderStyle: other.optionsHeaderStyle ?? optionsHeaderStyle, - optionsTextFieldStyle: - other.optionsTextFieldStyle ?? optionsTextFieldStyle, - optionsTextFieldErrorStyle: - other.optionsTextFieldErrorStyle ?? optionsTextFieldErrorStyle, - optionsTextFieldBorderRadius: - other.optionsTextFieldBorderRadius ?? optionsTextFieldBorderRadius, - switchListTileFillColor: - other.switchListTileFillColor ?? switchListTileFillColor, - switchListTileTitleStyle: - other.switchListTileTitleStyle ?? switchListTileTitleStyle, - switchListTileErrorStyle: - other.switchListTileErrorStyle ?? switchListTileErrorStyle, - switchListTileBorderRadius: - other.switchListTileBorderRadius ?? switchListTileBorderRadius, - actionDialogTitleStyle: - other.actionDialogTitleStyle ?? actionDialogTitleStyle, - actionDialogContentStyle: - other.actionDialogContentStyle ?? actionDialogContentStyle, - ); - } + /// The visual styling for the toggle switch in the card. + /// + /// If null, the toggle switch uses its own inherited theme defaults. + final StreamSwitchStyle? switchStyle; - /// Linearly interpolate between two [StreamPollCreatorThemeData]. - StreamPollCreatorThemeData lerp( - StreamPollCreatorThemeData a, - StreamPollCreatorThemeData b, + /// The visual styling for the stepper control in the card. + /// + /// If null, the stepper uses its own inherited theme defaults. + final StreamStepperStyle? stepperStyle; + + /// Linearly interpolate between two [StreamPollConfigOptionStyle] + /// objects. + static StreamPollConfigOptionStyle? lerp( + StreamPollConfigOptionStyle? a, + StreamPollConfigOptionStyle? b, double t, - ) { - return StreamPollCreatorThemeData( - backgroundColor: Color.lerp(a.backgroundColor, b.backgroundColor, t), - appBarTitleStyle: - TextStyle.lerp(a.appBarTitleStyle, b.appBarTitleStyle, t), - appBarElevation: lerpDouble(a.appBarElevation, b.appBarElevation, t), - appBarBackgroundColor: - Color.lerp(a.appBarBackgroundColor, b.appBarBackgroundColor, t), - appBarForegroundColor: - Color.lerp(a.appBarForegroundColor, b.appBarForegroundColor, t), - questionTextFieldFillColor: Color.lerp( - a.questionTextFieldFillColor, b.questionTextFieldFillColor, t), - questionHeaderStyle: - TextStyle.lerp(a.questionHeaderStyle, b.questionHeaderStyle, t), - questionTextFieldStyle: - TextStyle.lerp(a.questionTextFieldStyle, b.questionTextFieldStyle, t), - questionTextFieldErrorStyle: TextStyle.lerp( - a.questionTextFieldErrorStyle, b.questionTextFieldErrorStyle, t), - questionTextFieldBorderRadius: BorderRadius.lerp( - a.questionTextFieldBorderRadius, b.questionTextFieldBorderRadius, t), - optionsTextFieldFillColor: Color.lerp( - a.optionsTextFieldFillColor, b.optionsTextFieldFillColor, t), - optionsHeaderStyle: - TextStyle.lerp(a.optionsHeaderStyle, b.optionsHeaderStyle, t), - optionsTextFieldStyle: - TextStyle.lerp(a.optionsTextFieldStyle, b.optionsTextFieldStyle, t), - optionsTextFieldErrorStyle: TextStyle.lerp( - a.optionsTextFieldErrorStyle, b.optionsTextFieldErrorStyle, t), - optionsTextFieldBorderRadius: BorderRadius.lerp( - a.optionsTextFieldBorderRadius, b.optionsTextFieldBorderRadius, t), - switchListTileFillColor: - Color.lerp(a.switchListTileFillColor, b.switchListTileFillColor, t), - switchListTileTitleStyle: TextStyle.lerp( - a.switchListTileTitleStyle, b.switchListTileTitleStyle, t), - switchListTileErrorStyle: TextStyle.lerp( - a.switchListTileErrorStyle, b.switchListTileErrorStyle, t), - switchListTileBorderRadius: BorderRadius.lerp( - a.switchListTileBorderRadius, b.switchListTileBorderRadius, t), - actionDialogTitleStyle: - TextStyle.lerp(a.actionDialogTitleStyle, b.actionDialogTitleStyle, t), - actionDialogContentStyle: TextStyle.lerp( - a.actionDialogContentStyle, b.actionDialogContentStyle, t), - ); - } - - @override - bool operator ==(Object other) => - identical(this, other) || - other is StreamPollCreatorThemeData && - other.backgroundColor == backgroundColor && - other.appBarTitleStyle == appBarTitleStyle && - other.appBarElevation == appBarElevation && - other.appBarBackgroundColor == appBarBackgroundColor && - other.appBarForegroundColor == appBarForegroundColor && - other.questionTextFieldFillColor == questionTextFieldFillColor && - other.questionHeaderStyle == questionHeaderStyle && - other.questionTextFieldStyle == questionTextFieldStyle && - other.questionTextFieldErrorStyle == questionTextFieldErrorStyle && - other.questionTextFieldBorderRadius == - questionTextFieldBorderRadius && - other.optionsTextFieldFillColor == optionsTextFieldFillColor && - other.optionsHeaderStyle == optionsHeaderStyle && - other.optionsTextFieldStyle == optionsTextFieldStyle && - other.optionsTextFieldErrorStyle == optionsTextFieldErrorStyle && - other.optionsTextFieldBorderRadius == optionsTextFieldBorderRadius && - other.switchListTileFillColor == switchListTileFillColor && - other.switchListTileTitleStyle == switchListTileTitleStyle && - other.switchListTileErrorStyle == switchListTileErrorStyle && - other.switchListTileBorderRadius == switchListTileBorderRadius && - other.actionDialogTitleStyle == actionDialogTitleStyle && - other.actionDialogContentStyle == actionDialogContentStyle; - - @override - int get hashCode => - backgroundColor.hashCode ^ - appBarTitleStyle.hashCode ^ - appBarElevation.hashCode ^ - appBarBackgroundColor.hashCode ^ - appBarForegroundColor.hashCode ^ - questionTextFieldFillColor.hashCode ^ - questionHeaderStyle.hashCode ^ - questionTextFieldStyle.hashCode ^ - questionTextFieldErrorStyle.hashCode ^ - questionTextFieldBorderRadius.hashCode ^ - optionsTextFieldFillColor.hashCode ^ - optionsHeaderStyle.hashCode ^ - optionsTextFieldStyle.hashCode ^ - optionsTextFieldErrorStyle.hashCode ^ - optionsTextFieldBorderRadius.hashCode ^ - switchListTileFillColor.hashCode ^ - switchListTileTitleStyle.hashCode ^ - switchListTileErrorStyle.hashCode ^ - switchListTileBorderRadius.hashCode ^ - actionDialogTitleStyle.hashCode ^ - actionDialogContentStyle.hashCode; + ) => _$StreamPollConfigOptionStyle.lerp(a, b, t); } diff --git a/packages/stream_chat_flutter/lib/src/theme/poll_creator_theme.g.theme.dart b/packages/stream_chat_flutter/lib/src/theme/poll_creator_theme.g.theme.dart new file mode 100644 index 0000000000..e16ebde0fa --- /dev/null +++ b/packages/stream_chat_flutter/lib/src/theme/poll_creator_theme.g.theme.dart @@ -0,0 +1,266 @@ +// dart format width=80 +// coverage:ignore-file +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint, unused_element + +part of 'poll_creator_theme.dart'; + +// ************************************************************************** +// ThemeGenGenerator +// ************************************************************************** + +mixin _$StreamPollCreatorThemeData { + bool get canMerge => true; + + static StreamPollCreatorThemeData? lerp( + StreamPollCreatorThemeData? a, + StreamPollCreatorThemeData? b, + double t, + ) { + if (identical(a, b)) { + return a; + } + + if (a == null) { + return t == 1.0 ? b : null; + } + + if (b == null) { + return t == 0.0 ? a : null; + } + + return StreamPollCreatorThemeData( + sheetHeaderStyle: StreamSheetHeaderStyle.lerp( + a.sheetHeaderStyle, + b.sheetHeaderStyle, + t, + ), + headerTextStyle: TextStyle.lerp(a.headerTextStyle, b.headerTextStyle, t), + questionInputStyle: StreamTextInputStyle.lerp( + a.questionInputStyle, + b.questionInputStyle, + t, + ), + optionInputStyle: StreamTextInputStyle.lerp( + a.optionInputStyle, + b.optionInputStyle, + t, + ), + configOptionStyle: StreamPollConfigOptionStyle.lerp( + a.configOptionStyle, + b.configOptionStyle, + t, + ), + ); + } + + StreamPollCreatorThemeData copyWith({ + StreamSheetHeaderStyle? sheetHeaderStyle, + TextStyle? headerTextStyle, + StreamTextInputStyle? questionInputStyle, + StreamTextInputStyle? optionInputStyle, + StreamPollConfigOptionStyle? configOptionStyle, + }) { + final _this = (this as StreamPollCreatorThemeData); + + return StreamPollCreatorThemeData( + sheetHeaderStyle: sheetHeaderStyle ?? _this.sheetHeaderStyle, + headerTextStyle: headerTextStyle ?? _this.headerTextStyle, + questionInputStyle: questionInputStyle ?? _this.questionInputStyle, + optionInputStyle: optionInputStyle ?? _this.optionInputStyle, + configOptionStyle: configOptionStyle ?? _this.configOptionStyle, + ); + } + + StreamPollCreatorThemeData merge(StreamPollCreatorThemeData? other) { + final _this = (this as StreamPollCreatorThemeData); + + if (other == null || identical(_this, other)) { + return _this; + } + + if (!other.canMerge) { + return other; + } + + return copyWith( + sheetHeaderStyle: + _this.sheetHeaderStyle?.merge(other.sheetHeaderStyle) ?? + other.sheetHeaderStyle, + headerTextStyle: + _this.headerTextStyle?.merge(other.headerTextStyle) ?? + other.headerTextStyle, + questionInputStyle: + _this.questionInputStyle?.merge(other.questionInputStyle) ?? + other.questionInputStyle, + optionInputStyle: + _this.optionInputStyle?.merge(other.optionInputStyle) ?? + other.optionInputStyle, + configOptionStyle: + _this.configOptionStyle?.merge(other.configOptionStyle) ?? + other.configOptionStyle, + ); + } + + @override + bool operator ==(Object other) { + if (identical(this, other)) { + return true; + } + + if (other.runtimeType != runtimeType) { + return false; + } + + final _this = (this as StreamPollCreatorThemeData); + final _other = (other as StreamPollCreatorThemeData); + + return _other.sheetHeaderStyle == _this.sheetHeaderStyle && + _other.headerTextStyle == _this.headerTextStyle && + _other.questionInputStyle == _this.questionInputStyle && + _other.optionInputStyle == _this.optionInputStyle && + _other.configOptionStyle == _this.configOptionStyle; + } + + @override + int get hashCode { + final _this = (this as StreamPollCreatorThemeData); + + return Object.hash( + runtimeType, + _this.sheetHeaderStyle, + _this.headerTextStyle, + _this.questionInputStyle, + _this.optionInputStyle, + _this.configOptionStyle, + ); + } +} + +mixin _$StreamPollConfigOptionStyle { + bool get canMerge => true; + + static StreamPollConfigOptionStyle? lerp( + StreamPollConfigOptionStyle? a, + StreamPollConfigOptionStyle? b, + double t, + ) { + if (identical(a, b)) { + return a; + } + + if (a == null) { + return t == 1.0 ? b : null; + } + + if (b == null) { + return t == 0.0 ? a : null; + } + + return StreamPollConfigOptionStyle( + backgroundColor: Color.lerp(a.backgroundColor, b.backgroundColor, t), + contentPadding: EdgeInsetsGeometry.lerp( + a.contentPadding, + b.contentPadding, + t, + ), + childSpacing: lerpDouble$(a.childSpacing, b.childSpacing, t), + titleTextStyle: TextStyle.lerp(a.titleTextStyle, b.titleTextStyle, t), + descriptionTextStyle: TextStyle.lerp( + a.descriptionTextStyle, + b.descriptionTextStyle, + t, + ), + switchStyle: StreamSwitchStyle.lerp(a.switchStyle, b.switchStyle, t), + stepperStyle: StreamStepperStyle.lerp(a.stepperStyle, b.stepperStyle, t), + ); + } + + StreamPollConfigOptionStyle copyWith({ + Color? backgroundColor, + EdgeInsetsGeometry? contentPadding, + double? childSpacing, + TextStyle? titleTextStyle, + TextStyle? descriptionTextStyle, + StreamSwitchStyle? switchStyle, + StreamStepperStyle? stepperStyle, + }) { + final _this = (this as StreamPollConfigOptionStyle); + + return StreamPollConfigOptionStyle( + backgroundColor: backgroundColor ?? _this.backgroundColor, + contentPadding: contentPadding ?? _this.contentPadding, + childSpacing: childSpacing ?? _this.childSpacing, + titleTextStyle: titleTextStyle ?? _this.titleTextStyle, + descriptionTextStyle: descriptionTextStyle ?? _this.descriptionTextStyle, + switchStyle: switchStyle ?? _this.switchStyle, + stepperStyle: stepperStyle ?? _this.stepperStyle, + ); + } + + StreamPollConfigOptionStyle merge(StreamPollConfigOptionStyle? other) { + final _this = (this as StreamPollConfigOptionStyle); + + if (other == null || identical(_this, other)) { + return _this; + } + + if (!other.canMerge) { + return other; + } + + return copyWith( + backgroundColor: other.backgroundColor, + contentPadding: other.contentPadding, + childSpacing: other.childSpacing, + titleTextStyle: + _this.titleTextStyle?.merge(other.titleTextStyle) ?? + other.titleTextStyle, + descriptionTextStyle: + _this.descriptionTextStyle?.merge(other.descriptionTextStyle) ?? + other.descriptionTextStyle, + switchStyle: + _this.switchStyle?.merge(other.switchStyle) ?? other.switchStyle, + stepperStyle: + _this.stepperStyle?.merge(other.stepperStyle) ?? other.stepperStyle, + ); + } + + @override + bool operator ==(Object other) { + if (identical(this, other)) { + return true; + } + + if (other.runtimeType != runtimeType) { + return false; + } + + final _this = (this as StreamPollConfigOptionStyle); + final _other = (other as StreamPollConfigOptionStyle); + + return _other.backgroundColor == _this.backgroundColor && + _other.contentPadding == _this.contentPadding && + _other.childSpacing == _this.childSpacing && + _other.titleTextStyle == _this.titleTextStyle && + _other.descriptionTextStyle == _this.descriptionTextStyle && + _other.switchStyle == _this.switchStyle && + _other.stepperStyle == _this.stepperStyle; + } + + @override + int get hashCode { + final _this = (this as StreamPollConfigOptionStyle); + + return Object.hash( + runtimeType, + _this.backgroundColor, + _this.contentPadding, + _this.childSpacing, + _this.titleTextStyle, + _this.descriptionTextStyle, + _this.switchStyle, + _this.stepperStyle, + ); + } +} diff --git a/packages/stream_chat_flutter/lib/src/theme/poll_interactor_theme.dart b/packages/stream_chat_flutter/lib/src/theme/poll_interactor_theme.dart index 1880ad5ce7..27f479482b 100644 --- a/packages/stream_chat_flutter/lib/src/theme/poll_interactor_theme.dart +++ b/packages/stream_chat_flutter/lib/src/theme/poll_interactor_theme.dart @@ -1,377 +1,125 @@ -// ignore_for_file: parameter_assignments +import 'package:flutter/widgets.dart'; +import 'package:stream_chat_flutter/src/theme/poll_option_style.dart'; +import 'package:stream_chat_flutter/src/theme/stream_chat_theme.dart'; +import 'package:stream_core_flutter/stream_core_flutter.dart'; +import 'package:theme_extensions_builder_annotation/theme_extensions_builder_annotation.dart'; -import 'dart:ui'; +export 'package:stream_chat_flutter/src/theme/poll_option_style.dart'; -import 'package:flutter/foundation.dart'; -import 'package:flutter/material.dart'; -import 'package:stream_chat_flutter/src/theme/stream_chat_theme.dart'; +part 'poll_interactor_theme.g.theme.dart'; -/// {@template streamPollInteractorTheme} -/// Overrides the default style of [StreamPollInteractorWidget] descendants. +/// Applies a poll interactor theme to descendant [StreamPollInteractor] +/// widgets. +/// +/// Wrap a subtree with [StreamPollInteractorTheme] to override poll interactor +/// styling. Access the merged theme using [StreamPollInteractorTheme.of]. +/// +/// {@tool snippet} +/// +/// Override poll interactor styling for a specific section: +/// +/// ```dart +/// StreamPollInteractorTheme( +/// data: StreamPollInteractorThemeData( +/// titleTextStyle: TextStyle(fontWeight: FontWeight.w700), +/// optionStyle: StreamPollOptionStyle( +/// progressBarStyle: StreamProgressBarStyle( +/// fillColor: Colors.green, +/// ), +/// ), +/// ), +/// child: StreamPollInteractor(poll: poll), +/// ) +/// ``` +/// {@end-tool} /// /// See also: /// -/// * [StreamPollInteractorThemeData], which is used to configure this theme. -/// {@endtemplate} +/// * [StreamPollInteractorThemeData], which describes the poll interactor +/// theme. +/// * [StreamPollInteractor], the widget affected by this theme. class StreamPollInteractorTheme extends InheritedTheme { - /// Creates a [StreamPollInteractorTheme]. - /// - /// The [data] parameter must not be null. + /// Creates a poll interactor theme that controls descendant widgets. const StreamPollInteractorTheme({ super.key, required this.data, required super.child, }); - /// The configuration of this theme. + /// The poll interactor theme data for descendant widgets. final StreamPollInteractorThemeData data; - /// The closest instance of this class that encloses the given context. - /// - /// If there is no enclosing [StreamPollInteractorTheme] widget, then - /// [StreamChatThemeData.pollInteractorTheme] is used. + /// Returns the [StreamPollInteractorThemeData] merged from local and global + /// themes. /// - /// Typical usage is as follows: + /// Local values from the nearest [StreamPollInteractorTheme] ancestor take + /// precedence over global values from [StreamChatTheme.of]. /// - /// ```dart - /// StreamPollInteractorTheme theme = StreamPollInteractorTheme.of(context); - /// ``` + /// This allows partial overrides - for example, overriding only + /// [StreamPollInteractorThemeData.titleTextStyle] while inheriting other + /// properties from the global theme. static StreamPollInteractorThemeData of(BuildContext context) { - final pollInteractorTheme = - context.dependOnInheritedWidgetOfExactType(); - return pollInteractorTheme?.data ?? - StreamChatTheme.of(context).pollInteractorTheme; + final localTheme = context.dependOnInheritedWidgetOfExactType(); + return StreamChatTheme.of(context).pollInteractorTheme.merge(localTheme?.data); } @override - Widget wrap(BuildContext context, Widget child) => - StreamPollInteractorTheme(data: data, child: child); + Widget wrap(BuildContext context, Widget child) => StreamPollInteractorTheme(data: data, child: child); @override - bool updateShouldNotify(StreamPollInteractorTheme oldWidget) => - data != oldWidget.data; + bool updateShouldNotify(StreamPollInteractorTheme oldWidget) => data != oldWidget.data; } -/// {@template streamPollInteractorThemeData} -/// A style that overrides the default appearance of [StreamPollInteractor] -/// widget when used with [StreamPollCreatorTheme] or with the overall -/// [StreamChatTheme]'s [StreamChatThemeData.pollInteractorTheme]. -/// {@endtemplate} -class StreamPollInteractorThemeData with Diagnosticable { - /// {@macro streamPollInteractorThemeData} +/// Theme data for customizing [StreamPollInteractor] widgets. +/// +/// {@tool snippet} +/// +/// Customize poll interactor appearance globally: +/// +/// ```dart +/// StreamChatThemeData( +/// pollInteractorTheme: StreamPollInteractorThemeData( +/// titleTextStyle: TextStyle(fontWeight: FontWeight.w700), +/// ), +/// ) +/// ``` +/// {@end-tool} +/// +/// See also: +/// +/// * [StreamPollInteractor], the widget that uses this theme data. +/// * [StreamPollInteractorTheme], for overriding theme in a widget subtree. +@themeGen +@immutable +class StreamPollInteractorThemeData with _$StreamPollInteractorThemeData { + /// Creates poll interactor theme data with optional style overrides. const StreamPollInteractorThemeData({ - this.pollTitleStyle, - this.pollSubtitleStyle, - this.pollOptionTextStyle, - this.pollOptionVoteCountTextStyle, - this.pollOptionCheckboxShape, - this.pollOptionCheckboxCheckColor, - this.pollOptionCheckboxActiveColor, - this.pollOptionCheckboxBorderSide, - this.pollOptionVotesProgressBarMinHeight, - this.pollOptionVotesProgressBarTrackColor, - this.pollOptionVotesProgressBarValueColor, - this.pollOptionVotesProgressBarWinnerColor, - this.pollOptionVotesProgressBarBorderRadius, - this.pollActionButtonStyle, - this.pollActionDialogTitleStyle, - this.pollActionDialogTextFieldStyle, - this.pollActionDialogTextFieldFillColor, - this.pollActionDialogTextFieldBorderRadius, + this.titleTextStyle, + this.subtitleTextStyle, + this.primaryActionStyle, + this.secondaryActionStyle, + this.optionStyle, }); - /// The text style of the poll title. - final TextStyle? pollTitleStyle; - - /// The text style of the poll subtitle. - final TextStyle? pollSubtitleStyle; - - /// The text style of the poll option. - final TextStyle? pollOptionTextStyle; - - /// The text style of the poll option vote count. - final TextStyle? pollOptionVoteCountTextStyle; - - /// The shape of the poll option checkbox. - final OutlinedBorder? pollOptionCheckboxShape; - - /// The color used for the poll option checkbox check. - final Color? pollOptionCheckboxCheckColor; - - /// The color used for the checkbox when it's active. - final Color? pollOptionCheckboxActiveColor; + /// The text style for the poll question title. + final TextStyle? titleTextStyle; - /// The border configuration of the poll option checkbox. - final BorderSide? pollOptionCheckboxBorderSide; + /// The text style for the poll description or status subtitle. + final TextStyle? subtitleTextStyle; - /// The minimum height of the poll option votes progress bar. - final double? pollOptionVotesProgressBarMinHeight; + /// The visual styling for the primary action button. + final StreamButtonThemeStyle? primaryActionStyle; - /// The track color of the poll option votes progress bar. - final Color? pollOptionVotesProgressBarTrackColor; + /// The visual styling for secondary action buttons. + final StreamButtonThemeStyle? secondaryActionStyle; - /// The color of the poll option votes progress bar value. - final Color? pollOptionVotesProgressBarValueColor; + /// The visual styling for poll option rows. + final StreamPollOptionStyle? optionStyle; - /// The color of the poll option votes progress bar value when it's the - /// winner. - final Color? pollOptionVotesProgressBarWinnerColor; - - /// The border radius of the poll option votes progress bar. - final BorderRadius? pollOptionVotesProgressBarBorderRadius; - - /// The button style of the poll action buttons. - final ButtonStyle? pollActionButtonStyle; - - /// The text style of the poll action dialog title. - final TextStyle? pollActionDialogTitleStyle; - - /// The text style of the poll action dialog text field. - final TextStyle? pollActionDialogTextFieldStyle; - - /// The fill color of the poll action dialog text field. - final Color? pollActionDialogTextFieldFillColor; - - /// The border radius of the poll action dialog text field. - final BorderRadius? pollActionDialogTextFieldBorderRadius; - - /// Copies this [StreamPollInteractorThemeData] with some new values. - StreamPollInteractorThemeData copyWith({ - TextStyle? pollTitleStyle, - TextStyle? pollSubtitleStyle, - TextStyle? pollOptionTextStyle, - TextStyle? pollOptionVoteCountTextStyle, - OutlinedBorder? pollOptionCheckboxShape, - Color? pollOptionCheckboxCheckColor, - Color? pollOptionCheckboxActiveColor, - BorderSide? pollOptionCheckboxBorderSide, - double? pollOptionVotesProgressBarMinHeight, - Color? pollOptionVotesProgressBarTrackColor, - Color? pollOptionVotesProgressBarValueColor, - Color? pollOptionVotesProgressBarWinnerColor, - BorderRadius? pollOptionVotesProgressBarBorderRadius, - ButtonStyle? pollActionButtonStyle, - TextStyle? pollActionDialogTitleStyle, - TextStyle? pollActionDialogTextFieldStyle, - Color? pollActionDialogTextFieldFillColor, - BorderRadius? pollActionDialogTextFieldBorderRadius, - }) { - return StreamPollInteractorThemeData( - pollTitleStyle: pollTitleStyle ?? this.pollTitleStyle, - pollSubtitleStyle: pollSubtitleStyle ?? this.pollSubtitleStyle, - pollOptionTextStyle: pollOptionTextStyle ?? this.pollOptionTextStyle, - pollOptionVoteCountTextStyle: - pollOptionVoteCountTextStyle ?? this.pollOptionVoteCountTextStyle, - pollOptionCheckboxShape: - pollOptionCheckboxShape ?? this.pollOptionCheckboxShape, - pollOptionCheckboxCheckColor: - pollOptionCheckboxCheckColor ?? this.pollOptionCheckboxCheckColor, - pollOptionCheckboxActiveColor: - pollOptionCheckboxActiveColor ?? this.pollOptionCheckboxActiveColor, - pollOptionCheckboxBorderSide: - pollOptionCheckboxBorderSide ?? this.pollOptionCheckboxBorderSide, - pollOptionVotesProgressBarMinHeight: - pollOptionVotesProgressBarMinHeight ?? - this.pollOptionVotesProgressBarMinHeight, - pollOptionVotesProgressBarTrackColor: - pollOptionVotesProgressBarTrackColor ?? - this.pollOptionVotesProgressBarTrackColor, - pollOptionVotesProgressBarValueColor: - pollOptionVotesProgressBarValueColor ?? - this.pollOptionVotesProgressBarValueColor, - pollOptionVotesProgressBarWinnerColor: - pollOptionVotesProgressBarWinnerColor ?? - this.pollOptionVotesProgressBarWinnerColor, - pollOptionVotesProgressBarBorderRadius: - pollOptionVotesProgressBarBorderRadius ?? - this.pollOptionVotesProgressBarBorderRadius, - pollActionButtonStyle: - pollActionButtonStyle ?? this.pollActionButtonStyle, - pollActionDialogTitleStyle: - pollActionDialogTitleStyle ?? this.pollActionDialogTitleStyle, - pollActionDialogTextFieldStyle: - pollActionDialogTextFieldStyle ?? this.pollActionDialogTextFieldStyle, - pollActionDialogTextFieldFillColor: pollActionDialogTextFieldFillColor ?? - this.pollActionDialogTextFieldFillColor, - pollActionDialogTextFieldBorderRadius: - pollActionDialogTextFieldBorderRadius ?? - this.pollActionDialogTextFieldBorderRadius, - ); - } - - /// Merges [this] [StreamPollInteractorThemeData] with the [other] - StreamPollInteractorThemeData merge(StreamPollInteractorThemeData? other) { - if (other == null) return this; - return copyWith( - pollTitleStyle: other.pollTitleStyle ?? pollTitleStyle, - pollSubtitleStyle: other.pollSubtitleStyle ?? pollSubtitleStyle, - pollOptionTextStyle: other.pollOptionTextStyle ?? pollOptionTextStyle, - pollOptionVoteCountTextStyle: - other.pollOptionVoteCountTextStyle ?? pollOptionVoteCountTextStyle, - pollOptionCheckboxShape: - other.pollOptionCheckboxShape ?? pollOptionCheckboxShape, - pollOptionCheckboxCheckColor: - other.pollOptionCheckboxCheckColor ?? pollOptionCheckboxCheckColor, - pollOptionCheckboxActiveColor: - other.pollOptionCheckboxActiveColor ?? pollOptionCheckboxActiveColor, - pollOptionCheckboxBorderSide: - other.pollOptionCheckboxBorderSide ?? pollOptionCheckboxBorderSide, - pollOptionVotesProgressBarMinHeight: - other.pollOptionVotesProgressBarMinHeight ?? - pollOptionVotesProgressBarMinHeight, - pollOptionVotesProgressBarTrackColor: - other.pollOptionVotesProgressBarTrackColor ?? - pollOptionVotesProgressBarTrackColor, - pollOptionVotesProgressBarValueColor: - other.pollOptionVotesProgressBarValueColor ?? - pollOptionVotesProgressBarValueColor, - pollOptionVotesProgressBarWinnerColor: - other.pollOptionVotesProgressBarWinnerColor ?? - pollOptionVotesProgressBarWinnerColor, - pollOptionVotesProgressBarBorderRadius: - other.pollOptionVotesProgressBarBorderRadius ?? - pollOptionVotesProgressBarBorderRadius, - pollActionButtonStyle: - other.pollActionButtonStyle ?? pollActionButtonStyle, - pollActionDialogTitleStyle: - other.pollActionDialogTitleStyle ?? pollActionDialogTitleStyle, - pollActionDialogTextFieldStyle: other.pollActionDialogTextFieldStyle ?? - pollActionDialogTextFieldStyle, - pollActionDialogTextFieldFillColor: - other.pollActionDialogTextFieldFillColor ?? - pollActionDialogTextFieldFillColor, - pollActionDialogTextFieldBorderRadius: - other.pollActionDialogTextFieldBorderRadius ?? - pollActionDialogTextFieldBorderRadius, - ); - } - - /// Linearly interpolate between two [StreamPollInteractorThemeData]. - StreamPollInteractorThemeData lerp( - StreamPollInteractorThemeData a, - StreamPollInteractorThemeData b, + /// Linearly interpolate between two [StreamPollInteractorThemeData] objects. + static StreamPollInteractorThemeData? lerp( + StreamPollInteractorThemeData? a, + StreamPollInteractorThemeData? b, double t, - ) { - return StreamPollInteractorThemeData( - pollTitleStyle: TextStyle.lerp(a.pollTitleStyle, b.pollTitleStyle, t), - pollSubtitleStyle: - TextStyle.lerp(a.pollSubtitleStyle, b.pollSubtitleStyle, t), - pollOptionTextStyle: - TextStyle.lerp(a.pollOptionTextStyle, b.pollOptionTextStyle, t), - pollOptionVoteCountTextStyle: TextStyle.lerp( - a.pollOptionVoteCountTextStyle, b.pollOptionVoteCountTextStyle, t), - pollOptionCheckboxShape: OutlinedBorder.lerp( - a.pollOptionCheckboxShape, b.pollOptionCheckboxShape, t), - pollOptionCheckboxCheckColor: Color.lerp( - a.pollOptionCheckboxCheckColor, b.pollOptionCheckboxCheckColor, t), - pollOptionCheckboxActiveColor: Color.lerp( - a.pollOptionCheckboxActiveColor, b.pollOptionCheckboxActiveColor, t), - pollOptionCheckboxBorderSide: _lerpSides( - a.pollOptionCheckboxBorderSide, b.pollOptionCheckboxBorderSide, t), - pollOptionVotesProgressBarMinHeight: lerpDouble( - a.pollOptionVotesProgressBarMinHeight, - b.pollOptionVotesProgressBarMinHeight, - t), - pollOptionVotesProgressBarTrackColor: Color.lerp( - a.pollOptionVotesProgressBarTrackColor, - b.pollOptionVotesProgressBarTrackColor, - t), - pollOptionVotesProgressBarValueColor: Color.lerp( - a.pollOptionVotesProgressBarValueColor, - b.pollOptionVotesProgressBarValueColor, - t), - pollOptionVotesProgressBarWinnerColor: Color.lerp( - a.pollOptionVotesProgressBarWinnerColor, - b.pollOptionVotesProgressBarWinnerColor, - t), - pollOptionVotesProgressBarBorderRadius: BorderRadius.lerp( - a.pollOptionVotesProgressBarBorderRadius, - b.pollOptionVotesProgressBarBorderRadius, - t), - pollActionButtonStyle: - ButtonStyle.lerp(a.pollActionButtonStyle, b.pollActionButtonStyle, t), - pollActionDialogTitleStyle: TextStyle.lerp( - a.pollActionDialogTitleStyle, b.pollActionDialogTitleStyle, t), - pollActionDialogTextFieldStyle: TextStyle.lerp( - a.pollActionDialogTextFieldStyle, - b.pollActionDialogTextFieldStyle, - t), - pollActionDialogTextFieldFillColor: Color.lerp( - a.pollActionDialogTextFieldFillColor, - b.pollActionDialogTextFieldFillColor, - t), - pollActionDialogTextFieldBorderRadius: BorderRadius.lerp( - a.pollActionDialogTextFieldBorderRadius, - b.pollActionDialogTextFieldBorderRadius, - t), - ); - } - - // Special case because BorderSide.lerp() doesn't support null arguments - static BorderSide? _lerpSides(BorderSide? a, BorderSide? b, double t) { - if (a == null || b == null) return null; - if (identical(a, b)) return a; - - if (a is WidgetStateBorderSide) { - a = a.resolve({}); - } - if (b is WidgetStateBorderSide) { - b = b.resolve({}); - } - - return BorderSide.lerp(a!, b!, t); - } - - @override - bool operator ==(Object other) => - identical(this, other) || - other is StreamPollInteractorThemeData && - other.pollTitleStyle == pollTitleStyle && - other.pollSubtitleStyle == pollSubtitleStyle && - other.pollOptionTextStyle == pollOptionTextStyle && - other.pollOptionVoteCountTextStyle == pollOptionVoteCountTextStyle && - other.pollOptionCheckboxShape == pollOptionCheckboxShape && - other.pollOptionCheckboxCheckColor == pollOptionCheckboxCheckColor && - other.pollOptionCheckboxActiveColor == - pollOptionCheckboxActiveColor && - other.pollOptionCheckboxBorderSide == pollOptionCheckboxBorderSide && - other.pollOptionVotesProgressBarMinHeight == - pollOptionVotesProgressBarMinHeight && - other.pollOptionVotesProgressBarTrackColor == - pollOptionVotesProgressBarTrackColor && - other.pollOptionVotesProgressBarValueColor == - pollOptionVotesProgressBarValueColor && - other.pollOptionVotesProgressBarWinnerColor == - pollOptionVotesProgressBarWinnerColor && - other.pollOptionVotesProgressBarBorderRadius == - pollOptionVotesProgressBarBorderRadius && - other.pollActionButtonStyle == pollActionButtonStyle && - other.pollActionDialogTitleStyle == pollActionDialogTitleStyle && - other.pollActionDialogTextFieldStyle == - pollActionDialogTextFieldStyle && - other.pollActionDialogTextFieldFillColor == - pollActionDialogTextFieldFillColor && - other.pollActionDialogTextFieldBorderRadius == - pollActionDialogTextFieldBorderRadius; - - @override - int get hashCode => - pollTitleStyle.hashCode ^ - pollSubtitleStyle.hashCode ^ - pollOptionTextStyle.hashCode ^ - pollOptionVoteCountTextStyle.hashCode ^ - pollOptionCheckboxShape.hashCode ^ - pollOptionCheckboxCheckColor.hashCode ^ - pollOptionCheckboxActiveColor.hashCode ^ - pollOptionCheckboxBorderSide.hashCode ^ - pollOptionVotesProgressBarMinHeight.hashCode ^ - pollOptionVotesProgressBarTrackColor.hashCode ^ - pollOptionVotesProgressBarValueColor.hashCode ^ - pollOptionVotesProgressBarWinnerColor.hashCode ^ - pollOptionVotesProgressBarBorderRadius.hashCode ^ - pollActionButtonStyle.hashCode ^ - pollActionDialogTitleStyle.hashCode ^ - pollActionDialogTextFieldStyle.hashCode ^ - pollActionDialogTextFieldFillColor.hashCode ^ - pollActionDialogTextFieldBorderRadius.hashCode; + ) => _$StreamPollInteractorThemeData.lerp(a, b, t); } diff --git a/packages/stream_chat_flutter/lib/src/theme/poll_interactor_theme.g.theme.dart b/packages/stream_chat_flutter/lib/src/theme/poll_interactor_theme.g.theme.dart new file mode 100644 index 0000000000..f5b093d5a5 --- /dev/null +++ b/packages/stream_chat_flutter/lib/src/theme/poll_interactor_theme.g.theme.dart @@ -0,0 +1,133 @@ +// dart format width=80 +// coverage:ignore-file +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint, unused_element + +part of 'poll_interactor_theme.dart'; + +// ************************************************************************** +// ThemeGenGenerator +// ************************************************************************** + +mixin _$StreamPollInteractorThemeData { + bool get canMerge => true; + + static StreamPollInteractorThemeData? lerp( + StreamPollInteractorThemeData? a, + StreamPollInteractorThemeData? b, + double t, + ) { + if (identical(a, b)) { + return a; + } + + if (a == null) { + return t == 1.0 ? b : null; + } + + if (b == null) { + return t == 0.0 ? a : null; + } + + return StreamPollInteractorThemeData( + titleTextStyle: TextStyle.lerp(a.titleTextStyle, b.titleTextStyle, t), + subtitleTextStyle: TextStyle.lerp( + a.subtitleTextStyle, + b.subtitleTextStyle, + t, + ), + primaryActionStyle: StreamButtonThemeStyle.lerp( + a.primaryActionStyle, + b.primaryActionStyle, + t, + ), + secondaryActionStyle: StreamButtonThemeStyle.lerp( + a.secondaryActionStyle, + b.secondaryActionStyle, + t, + ), + optionStyle: StreamPollOptionStyle.lerp(a.optionStyle, b.optionStyle, t), + ); + } + + StreamPollInteractorThemeData copyWith({ + TextStyle? titleTextStyle, + TextStyle? subtitleTextStyle, + StreamButtonThemeStyle? primaryActionStyle, + StreamButtonThemeStyle? secondaryActionStyle, + StreamPollOptionStyle? optionStyle, + }) { + final _this = (this as StreamPollInteractorThemeData); + + return StreamPollInteractorThemeData( + titleTextStyle: titleTextStyle ?? _this.titleTextStyle, + subtitleTextStyle: subtitleTextStyle ?? _this.subtitleTextStyle, + primaryActionStyle: primaryActionStyle ?? _this.primaryActionStyle, + secondaryActionStyle: secondaryActionStyle ?? _this.secondaryActionStyle, + optionStyle: optionStyle ?? _this.optionStyle, + ); + } + + StreamPollInteractorThemeData merge(StreamPollInteractorThemeData? other) { + final _this = (this as StreamPollInteractorThemeData); + + if (other == null || identical(_this, other)) { + return _this; + } + + if (!other.canMerge) { + return other; + } + + return copyWith( + titleTextStyle: + _this.titleTextStyle?.merge(other.titleTextStyle) ?? + other.titleTextStyle, + subtitleTextStyle: + _this.subtitleTextStyle?.merge(other.subtitleTextStyle) ?? + other.subtitleTextStyle, + primaryActionStyle: + _this.primaryActionStyle?.merge(other.primaryActionStyle) ?? + other.primaryActionStyle, + secondaryActionStyle: + _this.secondaryActionStyle?.merge(other.secondaryActionStyle) ?? + other.secondaryActionStyle, + optionStyle: + _this.optionStyle?.merge(other.optionStyle) ?? other.optionStyle, + ); + } + + @override + bool operator ==(Object other) { + if (identical(this, other)) { + return true; + } + + if (other.runtimeType != runtimeType) { + return false; + } + + final _this = (this as StreamPollInteractorThemeData); + final _other = (other as StreamPollInteractorThemeData); + + return _other.titleTextStyle == _this.titleTextStyle && + _other.subtitleTextStyle == _this.subtitleTextStyle && + _other.primaryActionStyle == _this.primaryActionStyle && + _other.secondaryActionStyle == _this.secondaryActionStyle && + _other.optionStyle == _this.optionStyle; + } + + @override + int get hashCode { + final _this = (this as StreamPollInteractorThemeData); + + return Object.hash( + runtimeType, + _this.titleTextStyle, + _this.subtitleTextStyle, + _this.primaryActionStyle, + _this.secondaryActionStyle, + _this.optionStyle, + ); + } +} diff --git a/packages/stream_chat_flutter/lib/src/theme/poll_option_style.dart b/packages/stream_chat_flutter/lib/src/theme/poll_option_style.dart new file mode 100644 index 0000000000..a00160d6ac --- /dev/null +++ b/packages/stream_chat_flutter/lib/src/theme/poll_option_style.dart @@ -0,0 +1,66 @@ +import 'package:flutter/widgets.dart'; +import 'package:stream_core_flutter/stream_core_flutter.dart'; +import 'package:theme_extensions_builder_annotation/theme_extensions_builder_annotation.dart'; + +part 'poll_option_style.g.theme.dart'; + +/// Visual styling properties for poll option rows. +/// +/// Defines the appearance of individual poll options including text styles, +/// checkbox, progress bar, and voter avatar stack. +/// +/// Shared by [StreamPollInteractorThemeData] and +/// [StreamPollOptionsSheetThemeData] so that option rows can be styled +/// consistently (or overridden independently) wherever they are rendered. +/// +/// See also: +/// +/// * [StreamPollInteractorThemeData], which wraps this style for the inline +/// poll interactor. +/// * [StreamPollOptionsSheetThemeData], which wraps this style for the +/// full-screen options dialog. +@themeGen +@immutable +class StreamPollOptionStyle with _$StreamPollOptionStyle { + /// Creates poll option style properties. + const StreamPollOptionStyle({ + this.textStyle, + this.votesTextStyle, + this.votesAvatarSize, + this.checkboxStyle, + this.progressBarStyle, + }); + + /// The text style for the option label. + /// + /// If null, defaults to [StreamTextTheme.captionDefault]. + final TextStyle? textStyle; + + /// The text style for the vote count displayed alongside each option. + /// + /// If null, defaults to [StreamTextTheme.metadataDefault]. + final TextStyle? votesTextStyle; + + /// The size of the voter avatar stack shown alongside each option. + /// + /// Only visible when the poll has public voting visibility. + /// If null, defaults to [StreamAvatarStackSize.xs]. + final StreamAvatarStackSize? votesAvatarSize; + + /// The visual styling for the option selection checkbox. + /// + /// If null, defaults to a circular checkbox with [StreamCheckboxSize.md]. + final StreamCheckboxStyle? checkboxStyle; + + /// The visual styling for the vote distribution progress bar. + /// + /// If null, defaults to a progress bar with accent neutral fill. + final StreamProgressBarStyle? progressBarStyle; + + /// Linearly interpolate between two [StreamPollOptionStyle] objects. + static StreamPollOptionStyle? lerp( + StreamPollOptionStyle? a, + StreamPollOptionStyle? b, + double t, + ) => _$StreamPollOptionStyle.lerp(a, b, t); +} diff --git a/packages/stream_chat_flutter/lib/src/theme/poll_option_style.g.theme.dart b/packages/stream_chat_flutter/lib/src/theme/poll_option_style.g.theme.dart new file mode 100644 index 0000000000..03e1fd6a06 --- /dev/null +++ b/packages/stream_chat_flutter/lib/src/theme/poll_option_style.g.theme.dart @@ -0,0 +1,126 @@ +// dart format width=80 +// coverage:ignore-file +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint, unused_element + +part of 'poll_option_style.dart'; + +// ************************************************************************** +// ThemeGenGenerator +// ************************************************************************** + +mixin _$StreamPollOptionStyle { + bool get canMerge => true; + + static StreamPollOptionStyle? lerp( + StreamPollOptionStyle? a, + StreamPollOptionStyle? b, + double t, + ) { + if (identical(a, b)) { + return a; + } + + if (a == null) { + return t == 1.0 ? b : null; + } + + if (b == null) { + return t == 0.0 ? a : null; + } + + return StreamPollOptionStyle( + textStyle: TextStyle.lerp(a.textStyle, b.textStyle, t), + votesTextStyle: TextStyle.lerp(a.votesTextStyle, b.votesTextStyle, t), + votesAvatarSize: t < 0.5 ? a.votesAvatarSize : b.votesAvatarSize, + checkboxStyle: StreamCheckboxStyle.lerp( + a.checkboxStyle, + b.checkboxStyle, + t, + ), + progressBarStyle: StreamProgressBarStyle.lerp( + a.progressBarStyle, + b.progressBarStyle, + t, + ), + ); + } + + StreamPollOptionStyle copyWith({ + TextStyle? textStyle, + TextStyle? votesTextStyle, + StreamAvatarStackSize? votesAvatarSize, + StreamCheckboxStyle? checkboxStyle, + StreamProgressBarStyle? progressBarStyle, + }) { + final _this = (this as StreamPollOptionStyle); + + return StreamPollOptionStyle( + textStyle: textStyle ?? _this.textStyle, + votesTextStyle: votesTextStyle ?? _this.votesTextStyle, + votesAvatarSize: votesAvatarSize ?? _this.votesAvatarSize, + checkboxStyle: checkboxStyle ?? _this.checkboxStyle, + progressBarStyle: progressBarStyle ?? _this.progressBarStyle, + ); + } + + StreamPollOptionStyle merge(StreamPollOptionStyle? other) { + final _this = (this as StreamPollOptionStyle); + + if (other == null || identical(_this, other)) { + return _this; + } + + if (!other.canMerge) { + return other; + } + + return copyWith( + textStyle: _this.textStyle?.merge(other.textStyle) ?? other.textStyle, + votesTextStyle: + _this.votesTextStyle?.merge(other.votesTextStyle) ?? + other.votesTextStyle, + votesAvatarSize: other.votesAvatarSize, + checkboxStyle: + _this.checkboxStyle?.merge(other.checkboxStyle) ?? + other.checkboxStyle, + progressBarStyle: + _this.progressBarStyle?.merge(other.progressBarStyle) ?? + other.progressBarStyle, + ); + } + + @override + bool operator ==(Object other) { + if (identical(this, other)) { + return true; + } + + if (other.runtimeType != runtimeType) { + return false; + } + + final _this = (this as StreamPollOptionStyle); + final _other = (other as StreamPollOptionStyle); + + return _other.textStyle == _this.textStyle && + _other.votesTextStyle == _this.votesTextStyle && + _other.votesAvatarSize == _this.votesAvatarSize && + _other.checkboxStyle == _this.checkboxStyle && + _other.progressBarStyle == _this.progressBarStyle; + } + + @override + int get hashCode { + final _this = (this as StreamPollOptionStyle); + + return Object.hash( + runtimeType, + _this.textStyle, + _this.votesTextStyle, + _this.votesAvatarSize, + _this.checkboxStyle, + _this.progressBarStyle, + ); + } +} diff --git a/packages/stream_chat_flutter/lib/src/theme/poll_option_votes_dialog_theme.dart b/packages/stream_chat_flutter/lib/src/theme/poll_option_votes_dialog_theme.dart deleted file mode 100644 index 72ee25dc4d..0000000000 --- a/packages/stream_chat_flutter/lib/src/theme/poll_option_votes_dialog_theme.dart +++ /dev/null @@ -1,212 +0,0 @@ -import 'dart:ui'; - -import 'package:flutter/foundation.dart'; -import 'package:flutter/material.dart'; -import 'package:stream_chat_flutter/src/theme/stream_chat_theme.dart'; - -/// {@template streamPollOptionVotesDialogTheme} -/// Overrides the default style of [StreamPollOptionVotesDialog] descendants. -/// -/// See also: -/// -/// * [StreamPollOptionVotesDialogThemeData], which is used to configure this -/// theme. -/// {@endtemplate} -class StreamPollOptionVotesDialogTheme extends InheritedTheme { - /// Creates a [StreamPollOptionVotesDialogTheme]. - /// - /// The [data] parameter must not be null. - const StreamPollOptionVotesDialogTheme({ - super.key, - required this.data, - required super.child, - }); - - /// The configuration of this theme. - final StreamPollOptionVotesDialogThemeData data; - - /// The closest instance of this class that encloses the given context. - /// - /// If there is no enclosing [StreamPollOptionVotesDialogTheme] widget, then - /// [StreamChatThemeData.pollOptionVotesDialogTheme] is used. - static StreamPollOptionVotesDialogThemeData of(BuildContext context) { - final pollCommentsDialogTheme = context - .dependOnInheritedWidgetOfExactType(); - return pollCommentsDialogTheme?.data ?? - StreamChatTheme.of(context).pollOptionVotesDialogTheme; - } - - @override - Widget wrap(BuildContext context, Widget child) => - StreamPollOptionVotesDialogTheme(data: data, child: child); - - @override - bool updateShouldNotify(StreamPollOptionVotesDialogTheme oldWidget) => - data != oldWidget.data; -} - -/// {@template streamPollOptionVotesDialogThemeData} -/// A style that overrides the default appearance of -/// [StreamPollOptionVotesDialog] widgets when used with -/// [StreamPollCommentsDialogTheme] or with the overall [StreamChatTheme]'s -/// [StreamChatThemeData.pollOptionVotesDialogTheme]. -/// {@endtemplate} -class StreamPollOptionVotesDialogThemeData with Diagnosticable { - /// {@macro streamPollOptionVotesDialogThemeData} - const StreamPollOptionVotesDialogThemeData({ - this.backgroundColor, - this.appBarElevation, - this.appBarBackgroundColor, - this.appBarForegroundColor, - this.appBarTitleTextStyle, - this.pollOptionVoteCountTextStyle, - this.pollOptionWinnerVoteCountTextStyle, - this.pollOptionVoteItemBackgroundColor, - this.pollOptionVoteItemBorderRadius, - }); - - /// The background color of the dialog. - final Color? backgroundColor; - - /// The elevation of the app bar. - final double? appBarElevation; - - /// The background color of the app bar. - final Color? appBarBackgroundColor; - - /// The foreground color of the app bar (icon and text color). - final Color? appBarForegroundColor; - - /// The text style of the app bar title. - final TextStyle? appBarTitleTextStyle; - - /// The text style of the poll option vote count. - final TextStyle? pollOptionVoteCountTextStyle; - - /// The text style of the winner poll option vote count. - final TextStyle? pollOptionWinnerVoteCountTextStyle; - - /// The background color of the poll option vote item. - final Color? pollOptionVoteItemBackgroundColor; - - /// The border radius of the poll option vote item. - final BorderRadius? pollOptionVoteItemBorderRadius; - - /// Copies this [StreamPollOptionVotesDialogThemeData] with some new values. - StreamPollOptionVotesDialogThemeData copyWith({ - Color? backgroundColor, - double? appBarElevation, - Color? appBarBackgroundColor, - Color? appBarForegroundColor, - TextStyle? appBarTitleTextStyle, - TextStyle? pollOptionVoteCountTextStyle, - TextStyle? pollOptionWinnerVoteCountTextStyle, - Color? pollOptionVoteItemBackgroundColor, - BorderRadius? pollOptionVoteItemBorderRadius, - }) => - StreamPollOptionVotesDialogThemeData( - backgroundColor: backgroundColor ?? this.backgroundColor, - appBarElevation: appBarElevation ?? this.appBarElevation, - appBarBackgroundColor: - appBarBackgroundColor ?? this.appBarBackgroundColor, - appBarForegroundColor: - appBarForegroundColor ?? this.appBarForegroundColor, - appBarTitleTextStyle: appBarTitleTextStyle ?? this.appBarTitleTextStyle, - pollOptionVoteCountTextStyle: - pollOptionVoteCountTextStyle ?? this.pollOptionVoteCountTextStyle, - pollOptionWinnerVoteCountTextStyle: - pollOptionWinnerVoteCountTextStyle ?? - this.pollOptionWinnerVoteCountTextStyle, - pollOptionVoteItemBackgroundColor: pollOptionVoteItemBackgroundColor ?? - this.pollOptionVoteItemBackgroundColor, - pollOptionVoteItemBorderRadius: pollOptionVoteItemBorderRadius ?? - this.pollOptionVoteItemBorderRadius, - ); - - /// Merges this [StreamPollOptionVotesDialogThemeData] with the [other]. - StreamPollOptionVotesDialogThemeData merge( - StreamPollOptionVotesDialogThemeData? other, - ) { - if (other == null) return this; - return copyWith( - backgroundColor: other.backgroundColor, - appBarElevation: other.appBarElevation, - appBarBackgroundColor: other.appBarBackgroundColor, - appBarForegroundColor: other.appBarForegroundColor, - appBarTitleTextStyle: other.appBarTitleTextStyle, - pollOptionVoteCountTextStyle: other.pollOptionVoteCountTextStyle, - pollOptionWinnerVoteCountTextStyle: - other.pollOptionWinnerVoteCountTextStyle, - pollOptionVoteItemBackgroundColor: - other.pollOptionVoteItemBackgroundColor, - pollOptionVoteItemBorderRadius: other.pollOptionVoteItemBorderRadius, - ); - } - - /// Linearly interpolate between two poll option votes dialog themes. - StreamPollOptionVotesDialogThemeData lerp( - StreamPollOptionVotesDialogThemeData? a, - StreamPollOptionVotesDialogThemeData? b, - double t, - ) { - return StreamPollOptionVotesDialogThemeData( - backgroundColor: Color.lerp(a?.backgroundColor, b?.backgroundColor, t), - appBarElevation: lerpDouble(a?.appBarElevation, b?.appBarElevation, t), - appBarBackgroundColor: - Color.lerp(a?.appBarBackgroundColor, b?.appBarBackgroundColor, t), - appBarForegroundColor: - Color.lerp(a?.appBarForegroundColor, b?.appBarForegroundColor, t), - appBarTitleTextStyle: - TextStyle.lerp(a?.appBarTitleTextStyle, b?.appBarTitleTextStyle, t), - pollOptionVoteCountTextStyle: TextStyle.lerp( - a?.pollOptionVoteCountTextStyle, - b?.pollOptionVoteCountTextStyle, - t, - ), - pollOptionWinnerVoteCountTextStyle: TextStyle.lerp( - a?.pollOptionWinnerVoteCountTextStyle, - b?.pollOptionWinnerVoteCountTextStyle, - t, - ), - pollOptionVoteItemBackgroundColor: Color.lerp( - a?.pollOptionVoteItemBackgroundColor, - b?.pollOptionVoteItemBackgroundColor, - t, - ), - pollOptionVoteItemBorderRadius: BorderRadiusGeometry.lerp( - a?.pollOptionVoteItemBorderRadius, - b?.pollOptionVoteItemBorderRadius, - t, - ) as BorderRadius?, - ); - } - - @override - bool operator ==(Object other) => - identical(this, other) || - other is StreamPollOptionVotesDialogThemeData && - other.backgroundColor == backgroundColor && - other.appBarElevation == appBarElevation && - other.appBarBackgroundColor == appBarBackgroundColor && - other.appBarForegroundColor == appBarForegroundColor && - other.appBarTitleTextStyle == appBarTitleTextStyle && - other.pollOptionVoteCountTextStyle == pollOptionVoteCountTextStyle && - other.pollOptionWinnerVoteCountTextStyle == - pollOptionWinnerVoteCountTextStyle && - other.pollOptionVoteItemBackgroundColor == - pollOptionVoteItemBackgroundColor && - other.pollOptionVoteItemBorderRadius == - pollOptionVoteItemBorderRadius; - - @override - int get hashCode => - backgroundColor.hashCode ^ - appBarElevation.hashCode ^ - appBarBackgroundColor.hashCode ^ - appBarForegroundColor.hashCode ^ - appBarTitleTextStyle.hashCode ^ - pollOptionVoteCountTextStyle.hashCode ^ - pollOptionWinnerVoteCountTextStyle.hashCode ^ - pollOptionVoteItemBackgroundColor.hashCode ^ - pollOptionVoteItemBorderRadius.hashCode; -} diff --git a/packages/stream_chat_flutter/lib/src/theme/poll_option_votes_sheet_theme.dart b/packages/stream_chat_flutter/lib/src/theme/poll_option_votes_sheet_theme.dart new file mode 100644 index 0000000000..43b0feb277 --- /dev/null +++ b/packages/stream_chat_flutter/lib/src/theme/poll_option_votes_sheet_theme.dart @@ -0,0 +1,152 @@ +import 'package:flutter/widgets.dart'; +import 'package:stream_chat_flutter/src/theme/poll_option_votes_style.dart'; +import 'package:stream_chat_flutter/src/theme/stream_chat_theme.dart'; +import 'package:stream_core_flutter/stream_core_flutter.dart'; +import 'package:theme_extensions_builder_annotation/theme_extensions_builder_annotation.dart'; + +part 'poll_option_votes_sheet_theme.g.theme.dart'; + +/// Applies a poll option votes sheet theme to descendant +/// [StreamPollOptionVotesSheet] widgets. +/// +/// Wrap a subtree with [StreamPollOptionVotesSheetTheme] to override the +/// votes sheet styling. Access the merged theme using +/// [StreamPollOptionVotesSheetTheme.of]. +/// +/// {@tool snippet} +/// +/// Override votes sheet styling for a specific route: +/// +/// ```dart +/// StreamPollOptionVotesSheetTheme( +/// data: StreamPollOptionVotesSheetThemeData( +/// optionStyle: StreamPollOptionVotesStyle( +/// cardStyle: StreamPollCardStyle(backgroundColor: Colors.white), +/// ), +/// ), +/// child: StreamPollOptionVotesSheet( +/// poll: poll, +/// option: option, +/// pollVotesCount: poll.voteCountsByOption[option.id], +/// ), +/// ) +/// ``` +/// {@end-tool} +/// +/// See also: +/// +/// * [StreamPollOptionVotesSheetThemeData], which describes the votes +/// sheet theme. +/// * [StreamPollOptionVotesSheet], the widget affected by this theme. +class StreamPollOptionVotesSheetTheme extends InheritedTheme { + /// Creates a poll option votes sheet theme that controls descendant + /// widgets. + const StreamPollOptionVotesSheetTheme({ + super.key, + required this.data, + required super.child, + }); + + /// The poll option votes sheet theme data for descendant widgets. + final StreamPollOptionVotesSheetThemeData data; + + /// Returns the [StreamPollOptionVotesSheetThemeData] merged from local + /// and global themes. + /// + /// Local values from the nearest [StreamPollOptionVotesSheetTheme] + /// ancestor take precedence over global values from [StreamChatTheme.of]. + /// + /// This allows partial overrides — for example, overriding only + /// [StreamPollOptionVotesSheetThemeData.backgroundColor] while inheriting + /// other properties from the global theme. + static StreamPollOptionVotesSheetThemeData of(BuildContext context) { + final localTheme = context.dependOnInheritedWidgetOfExactType(); + return StreamChatTheme.of(context).pollOptionVotesSheetTheme.merge(localTheme?.data); + } + + @override + Widget wrap(BuildContext context, Widget child) => StreamPollOptionVotesSheetTheme(data: data, child: child); + + @override + bool updateShouldNotify(StreamPollOptionVotesSheetTheme oldWidget) => data != oldWidget.data; +} + +/// Theme data for customizing [StreamPollOptionVotesSheet] widgets. +/// +/// Covers the sheet surface and the per-option card that lists every vote +/// cast for the selected option. +/// +/// The per-option card reuses [StreamPollOptionVotesStyle] so that the +/// votes sheet visually matches the option cards rendered inside +/// [StreamPollResultsSheet]. +/// +/// {@tool snippet} +/// +/// Customize the votes sheet appearance globally: +/// +/// ```dart +/// StreamChatThemeData( +/// pollOptionVotesSheetTheme: StreamPollOptionVotesSheetThemeData( +/// optionStyle: StreamPollOptionVotesStyle( +/// cardStyle: StreamPollCardStyle(backgroundColor: Colors.white), +/// ), +/// ), +/// ) +/// ``` +/// {@end-tool} +/// +/// See also: +/// +/// * [StreamPollOptionVotesSheet], the widget that uses this theme data. +/// * [StreamPollOptionVotesSheetTheme], for overriding theme in a widget +/// subtree. +/// * [StreamPollOptionVotesStyle], the nested style describing the +/// per-option card, reused from the results sheet. +@themeGen +@immutable +class StreamPollOptionVotesSheetThemeData with _$StreamPollOptionVotesSheetThemeData { + /// Creates poll option votes sheet theme data with optional style + /// overrides. + const StreamPollOptionVotesSheetThemeData({ + this.backgroundColor, + this.contentPadding, + this.sheetHeaderStyle, + this.optionStyle, + }); + + /// The background color of the sheet surface. + /// + /// If null, defaults to [StreamColorScheme.backgroundApp]. + final Color? backgroundColor; + + /// The visual styling for the [StreamSheetHeader] rendered at the top of + /// the sheet. + /// + /// Scoped to the sheet's header via an inner [StreamSheetHeaderTheme] so + /// overrides here do not leak into other sheet headers on the screen. + /// When null, the header inherits the ambient [StreamSheetHeaderTheme]. + final StreamSheetHeaderStyle? sheetHeaderStyle; + + /// The padding around the sheet's content. + /// + /// If null, defaults to `EdgeInsets.all(StreamSpacing.md)`. + final EdgeInsetsGeometry? contentPadding; + + /// The visual styling for the per-option card rendered as the sheet's + /// body. + /// + /// Controls the card chrome (background, corner radius, inner padding), + /// the "Option N" header label, the option body text, the vote count + /// label, and — when the option is the winner — the trophy icon. + /// + /// When null, the sheet falls back to its token-backed defaults. + final StreamPollOptionVotesStyle? optionStyle; + + /// Linearly interpolate between two + /// [StreamPollOptionVotesSheetThemeData] objects. + static StreamPollOptionVotesSheetThemeData? lerp( + StreamPollOptionVotesSheetThemeData? a, + StreamPollOptionVotesSheetThemeData? b, + double t, + ) => _$StreamPollOptionVotesSheetThemeData.lerp(a, b, t); +} diff --git a/packages/stream_chat_flutter/lib/src/theme/poll_option_votes_sheet_theme.g.theme.dart b/packages/stream_chat_flutter/lib/src/theme/poll_option_votes_sheet_theme.g.theme.dart new file mode 100644 index 0000000000..685ceb7e58 --- /dev/null +++ b/packages/stream_chat_flutter/lib/src/theme/poll_option_votes_sheet_theme.g.theme.dart @@ -0,0 +1,123 @@ +// dart format width=80 +// coverage:ignore-file +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint, unused_element + +part of 'poll_option_votes_sheet_theme.dart'; + +// ************************************************************************** +// ThemeGenGenerator +// ************************************************************************** + +mixin _$StreamPollOptionVotesSheetThemeData { + bool get canMerge => true; + + static StreamPollOptionVotesSheetThemeData? lerp( + StreamPollOptionVotesSheetThemeData? a, + StreamPollOptionVotesSheetThemeData? b, + double t, + ) { + if (identical(a, b)) { + return a; + } + + if (a == null) { + return t == 1.0 ? b : null; + } + + if (b == null) { + return t == 0.0 ? a : null; + } + + return StreamPollOptionVotesSheetThemeData( + backgroundColor: Color.lerp(a.backgroundColor, b.backgroundColor, t), + sheetHeaderStyle: StreamSheetHeaderStyle.lerp( + a.sheetHeaderStyle, + b.sheetHeaderStyle, + t, + ), + contentPadding: EdgeInsetsGeometry.lerp( + a.contentPadding, + b.contentPadding, + t, + ), + optionStyle: StreamPollOptionVotesStyle.lerp( + a.optionStyle, + b.optionStyle, + t, + ), + ); + } + + StreamPollOptionVotesSheetThemeData copyWith({ + Color? backgroundColor, + StreamSheetHeaderStyle? sheetHeaderStyle, + EdgeInsetsGeometry? contentPadding, + StreamPollOptionVotesStyle? optionStyle, + }) { + final _this = (this as StreamPollOptionVotesSheetThemeData); + + return StreamPollOptionVotesSheetThemeData( + backgroundColor: backgroundColor ?? _this.backgroundColor, + sheetHeaderStyle: sheetHeaderStyle ?? _this.sheetHeaderStyle, + contentPadding: contentPadding ?? _this.contentPadding, + optionStyle: optionStyle ?? _this.optionStyle, + ); + } + + StreamPollOptionVotesSheetThemeData merge( + StreamPollOptionVotesSheetThemeData? other, + ) { + final _this = (this as StreamPollOptionVotesSheetThemeData); + + if (other == null || identical(_this, other)) { + return _this; + } + + if (!other.canMerge) { + return other; + } + + return copyWith( + backgroundColor: other.backgroundColor, + sheetHeaderStyle: + _this.sheetHeaderStyle?.merge(other.sheetHeaderStyle) ?? + other.sheetHeaderStyle, + contentPadding: other.contentPadding, + optionStyle: + _this.optionStyle?.merge(other.optionStyle) ?? other.optionStyle, + ); + } + + @override + bool operator ==(Object other) { + if (identical(this, other)) { + return true; + } + + if (other.runtimeType != runtimeType) { + return false; + } + + final _this = (this as StreamPollOptionVotesSheetThemeData); + final _other = (other as StreamPollOptionVotesSheetThemeData); + + return _other.backgroundColor == _this.backgroundColor && + _other.sheetHeaderStyle == _this.sheetHeaderStyle && + _other.contentPadding == _this.contentPadding && + _other.optionStyle == _this.optionStyle; + } + + @override + int get hashCode { + final _this = (this as StreamPollOptionVotesSheetThemeData); + + return Object.hash( + runtimeType, + _this.backgroundColor, + _this.sheetHeaderStyle, + _this.contentPadding, + _this.optionStyle, + ); + } +} diff --git a/packages/stream_chat_flutter/lib/src/theme/poll_option_votes_style.dart b/packages/stream_chat_flutter/lib/src/theme/poll_option_votes_style.dart new file mode 100644 index 0000000000..52de4247d1 --- /dev/null +++ b/packages/stream_chat_flutter/lib/src/theme/poll_option_votes_style.dart @@ -0,0 +1,103 @@ +import 'package:flutter/widgets.dart'; +import 'package:stream_chat_flutter/src/theme/poll_card_style.dart'; +import 'package:stream_core_flutter/stream_core_flutter.dart'; +import 'package:theme_extensions_builder_annotation/theme_extensions_builder_annotation.dart'; + +part 'poll_option_votes_style.g.theme.dart'; + +/// Visual styling for a per-option votes card. +/// +/// Backs the per-option cards rendered by the `PollVotesByOption*` widget +/// family — used inside [StreamPollResultsSheet] (one card per option, with +/// a preview list of voters and an optional "View all" footer) and inside +/// [StreamPollOptionVotesSheet] (a single card listing every vote cast for +/// the selected option). +/// +/// Bundles the card chrome (via [StreamPollCardStyle]) with the text styles +/// for the "Option N" header label, the option body text, and the vote count +/// label, the winner trophy icon, the footer divider color, and the +/// [StreamButtonThemeStyle] applied to the footer action button (e.g. +/// "View all") shown when the number of votes exceeds the configured visible +/// count. +/// +/// See also: +/// +/// * [StreamPollResultsSheetThemeData], which embeds this style as its +/// per-option card styling. +/// * [StreamPollOptionVotesSheetThemeData], which embeds this style so the +/// votes dialog card matches the results dialog cards visually. +/// * [StreamPollCardStyle], the chrome primitive reused inside [cardStyle]. +@themeGen +@immutable +class StreamPollOptionVotesStyle with _$StreamPollOptionVotesStyle { + /// Creates poll option votes style properties. + const StreamPollOptionVotesStyle({ + this.cardStyle, + this.numberTextStyle, + this.textStyle, + this.voteCountTextStyle, + this.winnerIconColor, + this.winnerIconSize, + this.footerDividerColor, + this.footerButtonStyle, + }); + + /// Chrome (background color, corner radius, padding) of the option card. + /// + /// If null, defaults are resolved by the consuming dialog theme. + final StreamPollCardStyle? cardStyle; + + /// The text style for the small "Option N" header label rendered above + /// the option body text. + /// + /// If null, defaults to [StreamTextTheme.headingXs] with + /// [StreamColorScheme.textTertiary]. + final TextStyle? numberTextStyle; + + /// The text style for the poll option body text. + /// + /// If null, defaults to [StreamTextTheme.headingMd] with + /// [StreamColorScheme.textPrimary]. + final TextStyle? textStyle; + + /// The text style for the per-option vote count label + /// (e.g. `"12 votes"`) rendered on the trailing edge of the option row. + /// + /// If null, defaults to [StreamTextTheme.bodyEmphasis] with + /// [StreamColorScheme.textPrimary]. + final TextStyle? voteCountTextStyle; + + /// The color applied to the trophy icon shown next to the winning + /// option's vote count. + /// + /// If null, defaults to [StreamColorScheme.textSecondary]. + final Color? winnerIconColor; + + /// The size (in logical pixels) of the trophy icon shown next to the + /// winning option's vote count. + /// + /// If null, defaults to `20`. + final double? winnerIconSize; + + /// The color of the thin divider rendered between the option row and the + /// "View all" footer action when the number of votes exceeds the visible + /// count. + /// + /// If null, defaults to [StreamColorScheme.borderDefault]. + final Color? footerDividerColor; + + /// The button styling applied to the footer action (e.g. "View all") + /// rendered beneath an option when the number of votes exceeds the visible + /// count. + /// + /// Forwarded to [StreamButton.themeStyle]. When null, the button uses its + /// secondary / ghost / small defaults from [StreamButton]. + final StreamButtonThemeStyle? footerButtonStyle; + + /// Linearly interpolate between two [StreamPollOptionVotesStyle] objects. + static StreamPollOptionVotesStyle? lerp( + StreamPollOptionVotesStyle? a, + StreamPollOptionVotesStyle? b, + double t, + ) => _$StreamPollOptionVotesStyle.lerp(a, b, t); +} diff --git a/packages/stream_chat_flutter/lib/src/theme/poll_option_votes_style.g.theme.dart b/packages/stream_chat_flutter/lib/src/theme/poll_option_votes_style.g.theme.dart new file mode 100644 index 0000000000..7920614acc --- /dev/null +++ b/packages/stream_chat_flutter/lib/src/theme/poll_option_votes_style.g.theme.dart @@ -0,0 +1,148 @@ +// dart format width=80 +// coverage:ignore-file +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint, unused_element + +part of 'poll_option_votes_style.dart'; + +// ************************************************************************** +// ThemeGenGenerator +// ************************************************************************** + +mixin _$StreamPollOptionVotesStyle { + bool get canMerge => true; + + static StreamPollOptionVotesStyle? lerp( + StreamPollOptionVotesStyle? a, + StreamPollOptionVotesStyle? b, + double t, + ) { + if (identical(a, b)) { + return a; + } + + if (a == null) { + return t == 1.0 ? b : null; + } + + if (b == null) { + return t == 0.0 ? a : null; + } + + return StreamPollOptionVotesStyle( + cardStyle: StreamPollCardStyle.lerp(a.cardStyle, b.cardStyle, t), + numberTextStyle: TextStyle.lerp(a.numberTextStyle, b.numberTextStyle, t), + textStyle: TextStyle.lerp(a.textStyle, b.textStyle, t), + voteCountTextStyle: TextStyle.lerp( + a.voteCountTextStyle, + b.voteCountTextStyle, + t, + ), + winnerIconColor: Color.lerp(a.winnerIconColor, b.winnerIconColor, t), + winnerIconSize: lerpDouble$(a.winnerIconSize, b.winnerIconSize, t), + footerDividerColor: Color.lerp( + a.footerDividerColor, + b.footerDividerColor, + t, + ), + footerButtonStyle: StreamButtonThemeStyle.lerp( + a.footerButtonStyle, + b.footerButtonStyle, + t, + ), + ); + } + + StreamPollOptionVotesStyle copyWith({ + StreamPollCardStyle? cardStyle, + TextStyle? numberTextStyle, + TextStyle? textStyle, + TextStyle? voteCountTextStyle, + Color? winnerIconColor, + double? winnerIconSize, + Color? footerDividerColor, + StreamButtonThemeStyle? footerButtonStyle, + }) { + final _this = (this as StreamPollOptionVotesStyle); + + return StreamPollOptionVotesStyle( + cardStyle: cardStyle ?? _this.cardStyle, + numberTextStyle: numberTextStyle ?? _this.numberTextStyle, + textStyle: textStyle ?? _this.textStyle, + voteCountTextStyle: voteCountTextStyle ?? _this.voteCountTextStyle, + winnerIconColor: winnerIconColor ?? _this.winnerIconColor, + winnerIconSize: winnerIconSize ?? _this.winnerIconSize, + footerDividerColor: footerDividerColor ?? _this.footerDividerColor, + footerButtonStyle: footerButtonStyle ?? _this.footerButtonStyle, + ); + } + + StreamPollOptionVotesStyle merge(StreamPollOptionVotesStyle? other) { + final _this = (this as StreamPollOptionVotesStyle); + + if (other == null || identical(_this, other)) { + return _this; + } + + if (!other.canMerge) { + return other; + } + + return copyWith( + cardStyle: _this.cardStyle?.merge(other.cardStyle) ?? other.cardStyle, + numberTextStyle: + _this.numberTextStyle?.merge(other.numberTextStyle) ?? + other.numberTextStyle, + textStyle: _this.textStyle?.merge(other.textStyle) ?? other.textStyle, + voteCountTextStyle: + _this.voteCountTextStyle?.merge(other.voteCountTextStyle) ?? + other.voteCountTextStyle, + winnerIconColor: other.winnerIconColor, + winnerIconSize: other.winnerIconSize, + footerDividerColor: other.footerDividerColor, + footerButtonStyle: + _this.footerButtonStyle?.merge(other.footerButtonStyle) ?? + other.footerButtonStyle, + ); + } + + @override + bool operator ==(Object other) { + if (identical(this, other)) { + return true; + } + + if (other.runtimeType != runtimeType) { + return false; + } + + final _this = (this as StreamPollOptionVotesStyle); + final _other = (other as StreamPollOptionVotesStyle); + + return _other.cardStyle == _this.cardStyle && + _other.numberTextStyle == _this.numberTextStyle && + _other.textStyle == _this.textStyle && + _other.voteCountTextStyle == _this.voteCountTextStyle && + _other.winnerIconColor == _this.winnerIconColor && + _other.winnerIconSize == _this.winnerIconSize && + _other.footerDividerColor == _this.footerDividerColor && + _other.footerButtonStyle == _this.footerButtonStyle; + } + + @override + int get hashCode { + final _this = (this as StreamPollOptionVotesStyle); + + return Object.hash( + runtimeType, + _this.cardStyle, + _this.numberTextStyle, + _this.textStyle, + _this.voteCountTextStyle, + _this.winnerIconColor, + _this.winnerIconSize, + _this.footerDividerColor, + _this.footerButtonStyle, + ); + } +} diff --git a/packages/stream_chat_flutter/lib/src/theme/poll_options_dialog_theme.dart b/packages/stream_chat_flutter/lib/src/theme/poll_options_dialog_theme.dart deleted file mode 100644 index 8d2e07c8a8..0000000000 --- a/packages/stream_chat_flutter/lib/src/theme/poll_options_dialog_theme.dart +++ /dev/null @@ -1,182 +0,0 @@ -import 'dart:ui'; - -import 'package:flutter/foundation.dart'; -import 'package:flutter/material.dart'; -import 'package:stream_chat_flutter/src/theme/stream_chat_theme.dart'; - -/// {@template streamPollOptionsDialogTheme} -/// Overrides the default style of [StreamPollOptionsDialog] descendants. -/// -/// See also: -/// -/// * [StreamPollResultsDialogThemeData], which is used to configure this -/// theme. -/// {@endtemplate} -class StreamPollOptionsDialogTheme extends InheritedTheme { - /// Creates a [StreamPollOptionsDialogTheme]. - /// - /// The [data] parameter must not be null. - const StreamPollOptionsDialogTheme({ - super.key, - required this.data, - required super.child, - }); - - /// The configuration of this theme. - final StreamPollOptionsDialogThemeData data; - - /// The closest instance of this class that encloses the given context. - /// - /// If there is no enclosing [StreamPollOptionsDialogTheme] widget, then - /// [StreamChatThemeData.pollOptionsDialogTheme] is used. - static StreamPollOptionsDialogThemeData of(BuildContext context) { - final pollOptionsDialogTheme = context - .dependOnInheritedWidgetOfExactType(); - return pollOptionsDialogTheme?.data ?? - StreamChatTheme.of(context).pollOptionsDialogTheme; - } - - @override - Widget wrap(BuildContext context, Widget child) => - StreamPollOptionsDialogTheme(data: data, child: child); - - @override - bool updateShouldNotify(StreamPollOptionsDialogTheme oldWidget) => - data != oldWidget.data; -} - -/// {@template streamPollOptionsDialogThemeData} -/// A style that overrides the default appearance of [StreamPollOptionsDialog] -/// widgets when used with [StreamPollOptionsDialogTheme] or with the overall -/// [StreamChatTheme]'s [StreamChatThemeData.pollOptionsDialogTheme]. -/// {@endtemplate} -class StreamPollOptionsDialogThemeData with Diagnosticable { - /// {@macro streamPollOptionsDialogThemeData} - const StreamPollOptionsDialogThemeData({ - this.backgroundColor, - this.appBarElevation, - this.appBarBackgroundColor, - this.appBarForegroundColor, - this.appBarTitleTextStyle, - this.pollTitleTextStyle, - this.pollTitleDecoration, - this.pollOptionsListViewDecoration, - }); - - /// The background color of the dialog. - final Color? backgroundColor; - - /// The elevation of the app bar. - final double? appBarElevation; - - /// The background color of the app bar. - final Color? appBarBackgroundColor; - - /// The foreground color of the app bar (icon and text color). - final Color? appBarForegroundColor; - - /// The text style of the app bar title. - final TextStyle? appBarTitleTextStyle; - - /// The text style of the poll title. - final TextStyle? pollTitleTextStyle; - - /// The decoration of the poll title. - final Decoration? pollTitleDecoration; - - /// The decoration of the poll options list view. - final Decoration? pollOptionsListViewDecoration; - - /// A copy of [StreamPollOptionsDialogThemeData] with specified attributes - /// overridden. - StreamPollOptionsDialogThemeData copyWith({ - Color? backgroundColor, - double? appBarElevation, - Color? appBarBackgroundColor, - Color? appBarForegroundColor, - TextStyle? appBarTitleTextStyle, - TextStyle? pollTitleTextStyle, - Decoration? pollTitleDecoration, - Decoration? pollOptionsListViewDecoration, - }) => - StreamPollOptionsDialogThemeData( - backgroundColor: backgroundColor ?? this.backgroundColor, - appBarElevation: appBarElevation ?? this.appBarElevation, - appBarBackgroundColor: - appBarBackgroundColor ?? this.appBarBackgroundColor, - appBarForegroundColor: - appBarForegroundColor ?? this.appBarForegroundColor, - appBarTitleTextStyle: appBarTitleTextStyle ?? this.appBarTitleTextStyle, - pollTitleTextStyle: pollTitleTextStyle ?? this.pollTitleTextStyle, - pollTitleDecoration: pollTitleDecoration ?? this.pollTitleDecoration, - pollOptionsListViewDecoration: - pollOptionsListViewDecoration ?? this.pollOptionsListViewDecoration, - ); - - /// Merges this [StreamPollOptionsDialogThemeData] with the [other]. - StreamPollOptionsDialogThemeData merge( - StreamPollOptionsDialogThemeData? other, - ) { - if (other == null) return this; - return copyWith( - backgroundColor: other.backgroundColor, - appBarElevation: other.appBarElevation, - appBarBackgroundColor: other.appBarBackgroundColor, - appBarForegroundColor: other.appBarForegroundColor, - appBarTitleTextStyle: other.appBarTitleTextStyle, - pollTitleTextStyle: other.pollTitleTextStyle, - pollTitleDecoration: other.pollTitleDecoration, - pollOptionsListViewDecoration: other.pollOptionsListViewDecoration, - ); - } - - /// Linearly interpolate between two [StreamPollOptionsDialogThemeData]. - StreamPollOptionsDialogThemeData lerp( - StreamPollOptionsDialogThemeData a, - StreamPollOptionsDialogThemeData b, - double t, - ) => - StreamPollOptionsDialogThemeData( - backgroundColor: Color.lerp(a.backgroundColor, b.backgroundColor, t), - appBarElevation: lerpDouble(a.appBarElevation, b.appBarElevation, t), - appBarBackgroundColor: - Color.lerp(a.appBarBackgroundColor, b.appBarBackgroundColor, t), - appBarForegroundColor: - Color.lerp(a.appBarForegroundColor, b.appBarForegroundColor, t), - appBarTitleTextStyle: - TextStyle.lerp(a.appBarTitleTextStyle, b.appBarTitleTextStyle, t), - pollTitleTextStyle: - TextStyle.lerp(a.pollTitleTextStyle, b.pollTitleTextStyle, t), - pollTitleDecoration: - Decoration.lerp(a.pollTitleDecoration, b.pollTitleDecoration, t), - pollOptionsListViewDecoration: Decoration.lerp( - a.pollOptionsListViewDecoration, - b.pollOptionsListViewDecoration, - t, - ), - ); - - @override - bool operator ==(Object other) => - identical(this, other) || - other is StreamPollOptionsDialogThemeData && - other.backgroundColor == backgroundColor && - other.appBarElevation == appBarElevation && - other.appBarBackgroundColor == appBarBackgroundColor && - other.appBarForegroundColor == appBarForegroundColor && - other.appBarTitleTextStyle == appBarTitleTextStyle && - other.pollTitleTextStyle == pollTitleTextStyle && - other.pollTitleDecoration == pollTitleDecoration && - other.pollOptionsListViewDecoration == pollOptionsListViewDecoration; - - @override - int get hashCode => - backgroundColor.hashCode ^ - appBarElevation.hashCode ^ - appBarBackgroundColor.hashCode ^ - appBarForegroundColor.hashCode ^ - appBarTitleTextStyle.hashCode ^ - pollTitleTextStyle.hashCode ^ - pollTitleDecoration.hashCode ^ - pollOptionsListViewDecoration.hashCode; -} diff --git a/packages/stream_chat_flutter/lib/src/theme/poll_options_sheet_theme.dart b/packages/stream_chat_flutter/lib/src/theme/poll_options_sheet_theme.dart new file mode 100644 index 0000000000..224d413541 --- /dev/null +++ b/packages/stream_chat_flutter/lib/src/theme/poll_options_sheet_theme.dart @@ -0,0 +1,178 @@ +import 'package:flutter/widgets.dart'; +import 'package:stream_chat_flutter/src/theme/poll_card_style.dart'; +import 'package:stream_chat_flutter/src/theme/poll_option_style.dart'; +import 'package:stream_chat_flutter/src/theme/poll_question_style.dart'; +import 'package:stream_chat_flutter/src/theme/stream_chat_theme.dart'; +import 'package:stream_core_flutter/stream_core_flutter.dart'; +import 'package:theme_extensions_builder_annotation/theme_extensions_builder_annotation.dart'; + +part 'poll_options_sheet_theme.g.theme.dart'; + +/// Applies a poll options sheet theme to descendant [StreamPollOptionsSheet] +/// widgets. +/// +/// Wrap a subtree with [StreamPollOptionsSheetTheme] to override the +/// options sheet styling. Access the merged theme using +/// [StreamPollOptionsSheetTheme.of]. +/// +/// {@tool snippet} +/// +/// Override options sheet styling for a specific route: +/// +/// ```dart +/// StreamPollOptionsSheetTheme( +/// data: StreamPollOptionsSheetThemeData( +/// questionStyle: StreamPollQuestionStyle( +/// textStyle: TextStyle(fontWeight: FontWeight.w700), +/// ), +/// optionsCardStyle: StreamPollCardStyle( +/// backgroundColor: Colors.white, +/// ), +/// ), +/// child: StreamPollOptionsSheet(poll: poll), +/// ) +/// ``` +/// {@end-tool} +/// +/// See also: +/// +/// * [StreamPollOptionsSheetThemeData], which describes the options sheet +/// theme. +/// * [StreamPollOptionsSheet], the widget affected by this theme. +class StreamPollOptionsSheetTheme extends InheritedTheme { + /// Creates a poll options sheet theme that controls descendant widgets. + const StreamPollOptionsSheetTheme({ + super.key, + required this.data, + required super.child, + }); + + /// The poll options sheet theme data for descendant widgets. + final StreamPollOptionsSheetThemeData data; + + /// Returns the [StreamPollOptionsSheetThemeData] merged from local and + /// global themes. + /// + /// Local values from the nearest [StreamPollOptionsSheetTheme] ancestor + /// take precedence over global values from [StreamChatTheme.of]. + /// + /// This allows partial overrides — for example, overriding only + /// [StreamPollOptionsSheetThemeData.backgroundColor] while inheriting + /// other properties from the global theme. + static StreamPollOptionsSheetThemeData of(BuildContext context) { + final localTheme = context.dependOnInheritedWidgetOfExactType(); + return StreamChatTheme.of(context).pollOptionsSheetTheme.merge(localTheme?.data); + } + + @override + Widget wrap(BuildContext context, Widget child) => StreamPollOptionsSheetTheme(data: data, child: child); + + @override + bool updateShouldNotify(StreamPollOptionsSheetTheme oldWidget) => data != oldWidget.data; +} + +/// Theme data for customizing [StreamPollOptionsSheet] widgets. +/// +/// Covers the sheet surface, the "Question" card, and the options card. +/// +/// {@tool snippet} +/// +/// Customize the options sheet appearance globally: +/// +/// ```dart +/// StreamChatThemeData( +/// pollOptionsSheetTheme: StreamPollOptionsSheetThemeData( +/// questionStyle: StreamPollQuestionStyle( +/// headerTextStyle: TextStyle(fontWeight: FontWeight.w700), +/// ), +/// ), +/// ) +/// ``` +/// {@end-tool} +/// +/// See also: +/// +/// * [StreamPollOptionsSheet], the widget that uses this theme data. +/// * [StreamPollOptionsSheetTheme], for overriding theme in a widget +/// subtree. +/// * [StreamPollQuestionStyle], the nested style describing the question +/// card surface (chrome + header + body text). +/// * [StreamPollCardStyle], the nested style describing the options card +/// chrome. +/// * [StreamPollOptionStyle], the nested style describing each option row. +@themeGen +@immutable +class StreamPollOptionsSheetThemeData with _$StreamPollOptionsSheetThemeData { + /// Creates poll options sheet theme data with optional style overrides. + const StreamPollOptionsSheetThemeData({ + this.backgroundColor, + this.contentPadding, + this.sectionSpacing, + this.sheetHeaderStyle, + this.questionStyle, + this.optionsCardStyle, + this.optionsItemSpacing, + this.optionStyle, + }); + + /// The background color of the sheet surface. + /// + /// If null, defaults to [StreamColorScheme.backgroundApp]. + final Color? backgroundColor; + + /// The visual styling for the [StreamSheetHeader] rendered at the top of + /// the sheet. + /// + /// Scoped to the sheet's header via an inner [StreamSheetHeaderTheme] so + /// overrides here do not leak into other sheet headers on the screen. + /// When null, the header inherits the ambient [StreamSheetHeaderTheme]. + final StreamSheetHeaderStyle? sheetHeaderStyle; + + /// The padding around the sheet's scrollable content. + /// + /// If null, defaults to `EdgeInsets.all(StreamSpacing.md)`. + final EdgeInsetsGeometry? contentPadding; + + /// The vertical gap between the question card and the options card. + /// + /// If null, defaults to `StreamSpacing.xxl`. + final double? sectionSpacing; + + /// The visual styling for the question card surface (chrome + header + /// label + question body text). + /// + /// When null, the sheet falls back to its token-backed defaults. + final StreamPollQuestionStyle? questionStyle; + + /// The visual styling for the options card surface that wraps the poll + /// options list. + /// + /// Controls the card background color, corner radius, and inner padding. + final StreamPollCardStyle? optionsCardStyle; + + /// The vertical spacing between individual poll option rows inside the + /// options card. + /// + /// Forwarded to [PollOptionsListView.spacing]. If null, the list view + /// falls back to its own token-backed default. + final double? optionsItemSpacing; + + /// The visual styling applied to each poll option row rendered inside the + /// sheet. + /// + /// When non-null, the sheet scopes this value onto the descendant + /// [StreamPollInteractorTheme] so that [PollOptionItem] picks it up + /// without the options list needing to know about the sheet theme. + /// + /// When null, option rows inherit styling from the surrounding + /// [StreamPollInteractorTheme] (or its token-backed defaults). + final StreamPollOptionStyle? optionStyle; + + /// Linearly interpolate between two [StreamPollOptionsSheetThemeData] + /// objects. + static StreamPollOptionsSheetThemeData? lerp( + StreamPollOptionsSheetThemeData? a, + StreamPollOptionsSheetThemeData? b, + double t, + ) => _$StreamPollOptionsSheetThemeData.lerp(a, b, t); +} diff --git a/packages/stream_chat_flutter/lib/src/theme/poll_options_sheet_theme.g.theme.dart b/packages/stream_chat_flutter/lib/src/theme/poll_options_sheet_theme.g.theme.dart new file mode 100644 index 0000000000..b40bb9d738 --- /dev/null +++ b/packages/stream_chat_flutter/lib/src/theme/poll_options_sheet_theme.g.theme.dart @@ -0,0 +1,159 @@ +// dart format width=80 +// coverage:ignore-file +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint, unused_element + +part of 'poll_options_sheet_theme.dart'; + +// ************************************************************************** +// ThemeGenGenerator +// ************************************************************************** + +mixin _$StreamPollOptionsSheetThemeData { + bool get canMerge => true; + + static StreamPollOptionsSheetThemeData? lerp( + StreamPollOptionsSheetThemeData? a, + StreamPollOptionsSheetThemeData? b, + double t, + ) { + if (identical(a, b)) { + return a; + } + + if (a == null) { + return t == 1.0 ? b : null; + } + + if (b == null) { + return t == 0.0 ? a : null; + } + + return StreamPollOptionsSheetThemeData( + backgroundColor: Color.lerp(a.backgroundColor, b.backgroundColor, t), + sheetHeaderStyle: StreamSheetHeaderStyle.lerp( + a.sheetHeaderStyle, + b.sheetHeaderStyle, + t, + ), + contentPadding: EdgeInsetsGeometry.lerp( + a.contentPadding, + b.contentPadding, + t, + ), + sectionSpacing: lerpDouble$(a.sectionSpacing, b.sectionSpacing, t), + questionStyle: StreamPollQuestionStyle.lerp( + a.questionStyle, + b.questionStyle, + t, + ), + optionsCardStyle: StreamPollCardStyle.lerp( + a.optionsCardStyle, + b.optionsCardStyle, + t, + ), + optionsItemSpacing: lerpDouble$( + a.optionsItemSpacing, + b.optionsItemSpacing, + t, + ), + optionStyle: StreamPollOptionStyle.lerp(a.optionStyle, b.optionStyle, t), + ); + } + + StreamPollOptionsSheetThemeData copyWith({ + Color? backgroundColor, + StreamSheetHeaderStyle? sheetHeaderStyle, + EdgeInsetsGeometry? contentPadding, + double? sectionSpacing, + StreamPollQuestionStyle? questionStyle, + StreamPollCardStyle? optionsCardStyle, + double? optionsItemSpacing, + StreamPollOptionStyle? optionStyle, + }) { + final _this = (this as StreamPollOptionsSheetThemeData); + + return StreamPollOptionsSheetThemeData( + backgroundColor: backgroundColor ?? _this.backgroundColor, + sheetHeaderStyle: sheetHeaderStyle ?? _this.sheetHeaderStyle, + contentPadding: contentPadding ?? _this.contentPadding, + sectionSpacing: sectionSpacing ?? _this.sectionSpacing, + questionStyle: questionStyle ?? _this.questionStyle, + optionsCardStyle: optionsCardStyle ?? _this.optionsCardStyle, + optionsItemSpacing: optionsItemSpacing ?? _this.optionsItemSpacing, + optionStyle: optionStyle ?? _this.optionStyle, + ); + } + + StreamPollOptionsSheetThemeData merge( + StreamPollOptionsSheetThemeData? other, + ) { + final _this = (this as StreamPollOptionsSheetThemeData); + + if (other == null || identical(_this, other)) { + return _this; + } + + if (!other.canMerge) { + return other; + } + + return copyWith( + backgroundColor: other.backgroundColor, + sheetHeaderStyle: + _this.sheetHeaderStyle?.merge(other.sheetHeaderStyle) ?? + other.sheetHeaderStyle, + contentPadding: other.contentPadding, + sectionSpacing: other.sectionSpacing, + questionStyle: + _this.questionStyle?.merge(other.questionStyle) ?? + other.questionStyle, + optionsCardStyle: + _this.optionsCardStyle?.merge(other.optionsCardStyle) ?? + other.optionsCardStyle, + optionsItemSpacing: other.optionsItemSpacing, + optionStyle: + _this.optionStyle?.merge(other.optionStyle) ?? other.optionStyle, + ); + } + + @override + bool operator ==(Object other) { + if (identical(this, other)) { + return true; + } + + if (other.runtimeType != runtimeType) { + return false; + } + + final _this = (this as StreamPollOptionsSheetThemeData); + final _other = (other as StreamPollOptionsSheetThemeData); + + return _other.backgroundColor == _this.backgroundColor && + _other.sheetHeaderStyle == _this.sheetHeaderStyle && + _other.contentPadding == _this.contentPadding && + _other.sectionSpacing == _this.sectionSpacing && + _other.questionStyle == _this.questionStyle && + _other.optionsCardStyle == _this.optionsCardStyle && + _other.optionsItemSpacing == _this.optionsItemSpacing && + _other.optionStyle == _this.optionStyle; + } + + @override + int get hashCode { + final _this = (this as StreamPollOptionsSheetThemeData); + + return Object.hash( + runtimeType, + _this.backgroundColor, + _this.sheetHeaderStyle, + _this.contentPadding, + _this.sectionSpacing, + _this.questionStyle, + _this.optionsCardStyle, + _this.optionsItemSpacing, + _this.optionStyle, + ); + } +} diff --git a/packages/stream_chat_flutter/lib/src/theme/poll_question_style.dart b/packages/stream_chat_flutter/lib/src/theme/poll_question_style.dart new file mode 100644 index 0000000000..9ca974d464 --- /dev/null +++ b/packages/stream_chat_flutter/lib/src/theme/poll_question_style.dart @@ -0,0 +1,57 @@ +import 'package:flutter/widgets.dart'; +import 'package:stream_chat_flutter/src/theme/poll_card_style.dart'; +import 'package:theme_extensions_builder_annotation/theme_extensions_builder_annotation.dart'; + +part 'poll_question_style.g.theme.dart'; + +/// Visual styling for the "Question" card surface used by poll dialogs. +/// +/// Bundles the card chrome (via [StreamPollCardStyle]) with the text styles +/// applied to the small "Question" header label and the question body text. +/// +/// Shared by [StreamPollOptionsSheetThemeData] and +/// [StreamPollResultsSheetThemeData], which both render a visually identical +/// question card at the top of their scaffold. +/// +/// See also: +/// +/// * [StreamPollOptionsSheetThemeData], which embeds this style. +/// * [StreamPollResultsSheetThemeData], which embeds this style. +/// * [StreamPollCardStyle], the chrome primitive reused inside [cardStyle]. +@themeGen +@immutable +class StreamPollQuestionStyle with _$StreamPollQuestionStyle { + /// Creates poll question style properties. + const StreamPollQuestionStyle({ + this.cardStyle, + this.headerTextStyle, + this.textStyle, + }); + + /// Chrome (background color, corner radius, padding) of the question card. + /// + /// If null, defaults are resolved by the consuming dialog theme (typically + /// a token-backed [StreamPollCardStyle] wrapped around the question + /// header + body text). + final StreamPollCardStyle? cardStyle; + + /// The text style for the small "Question" header label rendered above + /// the question body text. + /// + /// If null, defaults to [StreamTextTheme.headingXs] with + /// [StreamColorScheme.textTertiary]. + final TextStyle? headerTextStyle; + + /// The text style for the poll question body text. + /// + /// If null, defaults to [StreamTextTheme.headingMd] with + /// [StreamColorScheme.textPrimary]. + final TextStyle? textStyle; + + /// Linearly interpolate between two [StreamPollQuestionStyle] objects. + static StreamPollQuestionStyle? lerp( + StreamPollQuestionStyle? a, + StreamPollQuestionStyle? b, + double t, + ) => _$StreamPollQuestionStyle.lerp(a, b, t); +} diff --git a/packages/stream_chat_flutter/lib/src/theme/poll_question_style.g.theme.dart b/packages/stream_chat_flutter/lib/src/theme/poll_question_style.g.theme.dart new file mode 100644 index 0000000000..f0c6a08cf3 --- /dev/null +++ b/packages/stream_chat_flutter/lib/src/theme/poll_question_style.g.theme.dart @@ -0,0 +1,102 @@ +// dart format width=80 +// coverage:ignore-file +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint, unused_element + +part of 'poll_question_style.dart'; + +// ************************************************************************** +// ThemeGenGenerator +// ************************************************************************** + +mixin _$StreamPollQuestionStyle { + bool get canMerge => true; + + static StreamPollQuestionStyle? lerp( + StreamPollQuestionStyle? a, + StreamPollQuestionStyle? b, + double t, + ) { + if (identical(a, b)) { + return a; + } + + if (a == null) { + return t == 1.0 ? b : null; + } + + if (b == null) { + return t == 0.0 ? a : null; + } + + return StreamPollQuestionStyle( + cardStyle: StreamPollCardStyle.lerp(a.cardStyle, b.cardStyle, t), + headerTextStyle: TextStyle.lerp(a.headerTextStyle, b.headerTextStyle, t), + textStyle: TextStyle.lerp(a.textStyle, b.textStyle, t), + ); + } + + StreamPollQuestionStyle copyWith({ + StreamPollCardStyle? cardStyle, + TextStyle? headerTextStyle, + TextStyle? textStyle, + }) { + final _this = (this as StreamPollQuestionStyle); + + return StreamPollQuestionStyle( + cardStyle: cardStyle ?? _this.cardStyle, + headerTextStyle: headerTextStyle ?? _this.headerTextStyle, + textStyle: textStyle ?? _this.textStyle, + ); + } + + StreamPollQuestionStyle merge(StreamPollQuestionStyle? other) { + final _this = (this as StreamPollQuestionStyle); + + if (other == null || identical(_this, other)) { + return _this; + } + + if (!other.canMerge) { + return other; + } + + return copyWith( + cardStyle: _this.cardStyle?.merge(other.cardStyle) ?? other.cardStyle, + headerTextStyle: + _this.headerTextStyle?.merge(other.headerTextStyle) ?? + other.headerTextStyle, + textStyle: _this.textStyle?.merge(other.textStyle) ?? other.textStyle, + ); + } + + @override + bool operator ==(Object other) { + if (identical(this, other)) { + return true; + } + + if (other.runtimeType != runtimeType) { + return false; + } + + final _this = (this as StreamPollQuestionStyle); + final _other = (other as StreamPollQuestionStyle); + + return _other.cardStyle == _this.cardStyle && + _other.headerTextStyle == _this.headerTextStyle && + _other.textStyle == _this.textStyle; + } + + @override + int get hashCode { + final _this = (this as StreamPollQuestionStyle); + + return Object.hash( + runtimeType, + _this.cardStyle, + _this.headerTextStyle, + _this.textStyle, + ); + } +} diff --git a/packages/stream_chat_flutter/lib/src/theme/poll_results_dialog_theme.dart b/packages/stream_chat_flutter/lib/src/theme/poll_results_dialog_theme.dart deleted file mode 100644 index bb7d1550a5..0000000000 --- a/packages/stream_chat_flutter/lib/src/theme/poll_results_dialog_theme.dart +++ /dev/null @@ -1,279 +0,0 @@ -import 'dart:ui'; - -import 'package:flutter/foundation.dart'; -import 'package:flutter/material.dart'; -import 'package:stream_chat_flutter/src/theme/stream_chat_theme.dart'; - -/// {@template streamPollResultsDialogTheme} -/// Overrides the default style of [StreamPollResultsDialog] descendants. -/// -/// See also: -/// -/// * [StreamPollResultsDialogThemeData], which is used to configure this -/// theme. -/// {@endtemplate} -class StreamPollResultsDialogTheme extends InheritedTheme { - /// Creates a [StreamPollResultsDialogTheme]. - /// - /// The [data] parameter must not be null. - const StreamPollResultsDialogTheme({ - super.key, - required this.data, - required super.child, - }); - - /// The configuration of this theme. - final StreamPollResultsDialogThemeData data; - - /// The closest instance of this class that encloses the given context. - /// - /// If there is no enclosing [StreamPollInteractorTheme] widget, then - /// [StreamChatThemeData.pollInteractorTheme] is used. - /// - /// Typical usage is as follows: - /// - /// ```dart - /// StreamPollCreatorTheme theme = StreamPollCreatorTheme.of(context); - /// ``` - static StreamPollResultsDialogThemeData of(BuildContext context) { - final pollResultsDialogTheme = context - .dependOnInheritedWidgetOfExactType(); - return pollResultsDialogTheme?.data ?? - StreamChatTheme.of(context).pollResultsDialogTheme; - } - - @override - Widget wrap(BuildContext context, Widget child) => - StreamPollResultsDialogTheme(data: data, child: child); - - @override - bool updateShouldNotify(StreamPollResultsDialogTheme oldWidget) => - data != oldWidget.data; -} - -/// {@template streamPollCreatorThemeData} -/// A style that overrides the default appearance of [StreamPollResultsDialog] -/// widgets when used with [StreamPollResultsDialogTheme] or with the overall -/// [StreamChatTheme]'s [StreamChatThemeData.pollResultsDialogTheme]. -/// {@endtemplate} -class StreamPollResultsDialogThemeData with Diagnosticable { - /// {@macro streamPollResultsDialogThemeData} - const StreamPollResultsDialogThemeData({ - this.backgroundColor, - this.appBarElevation, - this.appBarBackgroundColor, - this.appBarForegroundColor, - this.appBarTitleTextStyle, - this.pollTitleTextStyle, - this.pollTitleDecoration, - this.pollOptionsDecoration, - this.pollOptionsWinnerDecoration, - this.pollOptionsTextStyle, - this.pollOptionsWinnerTextStyle, - this.pollOptionsVoteCountTextStyle, - this.pollOptionsWinnerVoteCountTextStyle, - this.pollOptionsShowAllVotesButtonStyle, - }); - - /// The background color of the dialog. - final Color? backgroundColor; - - /// The elevation of the app bar. - final double? appBarElevation; - - /// The background color of the app bar. - final Color? appBarBackgroundColor; - - /// The foreground color of the app bar (icon and text color). - final Color? appBarForegroundColor; - - /// The text style of the app bar title. - final TextStyle? appBarTitleTextStyle; - - /// The text style of the poll title. - final TextStyle? pollTitleTextStyle; - - /// The decoration of the poll title. - final Decoration? pollTitleDecoration; - - /// The decoration of the poll options. - final Decoration? pollOptionsDecoration; - - /// The decoration of the winner poll option. - final Decoration? pollOptionsWinnerDecoration; - - /// The text style of the poll options. - final TextStyle? pollOptionsTextStyle; - - /// The text style of the winner poll options. - final TextStyle? pollOptionsWinnerTextStyle; - - /// The text style of the poll options vote count. - final TextStyle? pollOptionsVoteCountTextStyle; - - /// The text style of the winner poll options vote count. - final TextStyle? pollOptionsWinnerVoteCountTextStyle; - - /// The style of the poll options show all votes button. - final ButtonStyle? pollOptionsShowAllVotesButtonStyle; - - /// A copy of [StreamPollResultsDialogThemeData] with the given fields - /// replaced with the new values. - StreamPollResultsDialogThemeData copyWith({ - Color? backgroundColor, - double? appBarElevation, - Color? appBarBackgroundColor, - Color? appBarForegroundColor, - TextStyle? appBarTitleTextStyle, - TextStyle? pollTitleTextStyle, - Decoration? pollTitleDecoration, - Decoration? pollOptionsDecoration, - Decoration? pollOptionsWinnerDecoration, - TextStyle? pollOptionsTextStyle, - TextStyle? pollOptionsWinnerTextStyle, - TextStyle? pollOptionsVoteCountTextStyle, - TextStyle? pollOptionsWinnerVoteCountTextStyle, - ButtonStyle? pollOptionsShowAllVotesButtonStyle, - }) { - return StreamPollResultsDialogThemeData( - backgroundColor: backgroundColor ?? this.backgroundColor, - appBarElevation: appBarElevation ?? this.appBarElevation, - appBarBackgroundColor: - appBarBackgroundColor ?? this.appBarBackgroundColor, - appBarForegroundColor: - appBarForegroundColor ?? this.appBarForegroundColor, - appBarTitleTextStyle: appBarTitleTextStyle ?? this.appBarTitleTextStyle, - pollTitleTextStyle: pollTitleTextStyle ?? this.pollTitleTextStyle, - pollTitleDecoration: pollTitleDecoration ?? this.pollTitleDecoration, - pollOptionsDecoration: - pollOptionsDecoration ?? this.pollOptionsDecoration, - pollOptionsWinnerDecoration: - pollOptionsWinnerDecoration ?? this.pollOptionsWinnerDecoration, - pollOptionsTextStyle: pollOptionsTextStyle ?? this.pollOptionsTextStyle, - pollOptionsWinnerTextStyle: - pollOptionsWinnerTextStyle ?? this.pollOptionsWinnerTextStyle, - pollOptionsVoteCountTextStyle: - pollOptionsVoteCountTextStyle ?? this.pollOptionsVoteCountTextStyle, - pollOptionsWinnerVoteCountTextStyle: - pollOptionsWinnerVoteCountTextStyle ?? - this.pollOptionsWinnerVoteCountTextStyle, - pollOptionsShowAllVotesButtonStyle: pollOptionsShowAllVotesButtonStyle ?? - this.pollOptionsShowAllVotesButtonStyle, - ); - } - - /// Merges [this] [StreamPollResultsDialogThemeData] with the [other] - StreamPollResultsDialogThemeData merge( - StreamPollResultsDialogThemeData? other, - ) { - if (other == null) return this; - return copyWith( - backgroundColor: other.backgroundColor, - appBarElevation: other.appBarElevation, - appBarBackgroundColor: other.appBarBackgroundColor, - appBarForegroundColor: other.appBarForegroundColor, - appBarTitleTextStyle: other.appBarTitleTextStyle, - pollTitleTextStyle: other.pollTitleTextStyle, - pollTitleDecoration: other.pollTitleDecoration, - pollOptionsDecoration: other.pollOptionsDecoration, - pollOptionsWinnerDecoration: other.pollOptionsWinnerDecoration, - pollOptionsTextStyle: other.pollOptionsTextStyle, - pollOptionsWinnerTextStyle: other.pollOptionsWinnerTextStyle, - pollOptionsVoteCountTextStyle: other.pollOptionsVoteCountTextStyle, - pollOptionsWinnerVoteCountTextStyle: - other.pollOptionsWinnerVoteCountTextStyle, - pollOptionsShowAllVotesButtonStyle: - other.pollOptionsShowAllVotesButtonStyle, - ); - } - - /// Linearly interpolate between two [StreamPollResultsDialogThemeData]. - StreamPollResultsDialogThemeData lerp( - StreamPollResultsDialogThemeData a, - StreamPollResultsDialogThemeData b, - double t, - ) { - return StreamPollResultsDialogThemeData( - backgroundColor: Color.lerp(a.backgroundColor, b.backgroundColor, t), - appBarElevation: lerpDouble(a.appBarElevation, b.appBarElevation, t), - appBarBackgroundColor: - Color.lerp(a.appBarBackgroundColor, b.appBarBackgroundColor, t), - appBarForegroundColor: - Color.lerp(a.appBarForegroundColor, b.appBarForegroundColor, t), - appBarTitleTextStyle: - TextStyle.lerp(a.appBarTitleTextStyle, b.appBarTitleTextStyle, t), - pollTitleTextStyle: - TextStyle.lerp(a.pollTitleTextStyle, b.pollTitleTextStyle, t), - pollTitleDecoration: - Decoration.lerp(a.pollTitleDecoration, b.pollTitleDecoration, t), - pollOptionsDecoration: - Decoration.lerp(a.pollOptionsDecoration, b.pollOptionsDecoration, t), - pollOptionsWinnerDecoration: Decoration.lerp( - a.pollOptionsWinnerDecoration, - b.pollOptionsWinnerDecoration, - t, - ), - pollOptionsTextStyle: - TextStyle.lerp(a.pollOptionsTextStyle, b.pollOptionsTextStyle, t), - pollOptionsWinnerTextStyle: TextStyle.lerp( - a.pollOptionsWinnerTextStyle, - b.pollOptionsWinnerTextStyle, - t, - ), - pollOptionsVoteCountTextStyle: TextStyle.lerp( - a.pollOptionsVoteCountTextStyle, - b.pollOptionsVoteCountTextStyle, - t, - ), - pollOptionsWinnerVoteCountTextStyle: TextStyle.lerp( - a.pollOptionsWinnerVoteCountTextStyle, - b.pollOptionsWinnerVoteCountTextStyle, - t, - ), - pollOptionsShowAllVotesButtonStyle: ButtonStyle.lerp( - a.pollOptionsShowAllVotesButtonStyle, - b.pollOptionsShowAllVotesButtonStyle, - t, - ), - ); - } - - @override - bool operator ==(Object other) => - identical(this, other) || - other is StreamPollResultsDialogThemeData && - other.backgroundColor == backgroundColor && - other.appBarElevation == appBarElevation && - other.appBarBackgroundColor == appBarBackgroundColor && - other.appBarForegroundColor == appBarForegroundColor && - other.appBarTitleTextStyle == appBarTitleTextStyle && - other.pollTitleTextStyle == pollTitleTextStyle && - other.pollTitleDecoration == pollTitleDecoration && - other.pollOptionsDecoration == pollOptionsDecoration && - other.pollOptionsWinnerDecoration == pollOptionsWinnerDecoration && - other.pollOptionsTextStyle == pollOptionsTextStyle && - other.pollOptionsWinnerTextStyle == pollOptionsWinnerTextStyle && - other.pollOptionsVoteCountTextStyle == - pollOptionsVoteCountTextStyle && - other.pollOptionsWinnerVoteCountTextStyle == - pollOptionsWinnerVoteCountTextStyle && - other.pollOptionsShowAllVotesButtonStyle == - pollOptionsShowAllVotesButtonStyle; - - @override - int get hashCode => - backgroundColor.hashCode ^ - appBarElevation.hashCode ^ - appBarBackgroundColor.hashCode ^ - appBarForegroundColor.hashCode ^ - appBarTitleTextStyle.hashCode ^ - pollTitleTextStyle.hashCode ^ - pollTitleDecoration.hashCode ^ - pollOptionsDecoration.hashCode ^ - pollOptionsWinnerDecoration.hashCode ^ - pollOptionsTextStyle.hashCode ^ - pollOptionsWinnerTextStyle.hashCode ^ - pollOptionsVoteCountTextStyle.hashCode ^ - pollOptionsWinnerVoteCountTextStyle.hashCode ^ - pollOptionsShowAllVotesButtonStyle.hashCode; -} diff --git a/packages/stream_chat_flutter/lib/src/theme/poll_results_sheet_theme.dart b/packages/stream_chat_flutter/lib/src/theme/poll_results_sheet_theme.dart new file mode 100644 index 0000000000..dbb12023d6 --- /dev/null +++ b/packages/stream_chat_flutter/lib/src/theme/poll_results_sheet_theme.dart @@ -0,0 +1,175 @@ +import 'package:flutter/widgets.dart'; +import 'package:stream_chat_flutter/src/theme/poll_option_votes_style.dart'; +import 'package:stream_chat_flutter/src/theme/poll_question_style.dart'; +import 'package:stream_chat_flutter/src/theme/stream_chat_theme.dart'; +import 'package:stream_core_flutter/stream_core_flutter.dart'; +import 'package:theme_extensions_builder_annotation/theme_extensions_builder_annotation.dart'; + +part 'poll_results_sheet_theme.g.theme.dart'; + +/// Applies a poll results sheet theme to descendant [StreamPollResultsSheet] +/// widgets. +/// +/// Wrap a subtree with [StreamPollResultsSheetTheme] to override the +/// results sheet styling. Access the merged theme using +/// [StreamPollResultsSheetTheme.of]. +/// +/// {@tool snippet} +/// +/// Override results sheet styling for a specific route: +/// +/// ```dart +/// StreamPollResultsSheetTheme( +/// data: StreamPollResultsSheetThemeData( +/// questionStyle: StreamPollQuestionStyle( +/// textStyle: TextStyle(fontWeight: FontWeight.w700), +/// ), +/// optionStyle: StreamPollOptionVotesStyle( +/// cardStyle: StreamPollCardStyle( +/// backgroundColor: Colors.white, +/// ), +/// ), +/// ), +/// child: StreamPollResultsSheet(poll: poll), +/// ) +/// ``` +/// {@end-tool} +/// +/// See also: +/// +/// * [StreamPollResultsSheetThemeData], which describes the results sheet +/// theme. +/// * [StreamPollResultsSheet], the widget affected by this theme. +class StreamPollResultsSheetTheme extends InheritedTheme { + /// Creates a poll results sheet theme that controls descendant widgets. + const StreamPollResultsSheetTheme({ + super.key, + required this.data, + required super.child, + }); + + /// The poll results sheet theme data for descendant widgets. + final StreamPollResultsSheetThemeData data; + + /// Returns the [StreamPollResultsSheetThemeData] merged from local and + /// global themes. + /// + /// Local values from the nearest [StreamPollResultsSheetTheme] ancestor + /// take precedence over global values from [StreamChatTheme.of]. + /// + /// This allows partial overrides — for example, overriding only + /// [StreamPollResultsSheetThemeData.backgroundColor] while inheriting + /// other properties from the global theme. + static StreamPollResultsSheetThemeData of(BuildContext context) { + final localTheme = context.dependOnInheritedWidgetOfExactType(); + return StreamChatTheme.of(context).pollResultsSheetTheme.merge(localTheme?.data); + } + + @override + Widget wrap(BuildContext context, Widget child) => StreamPollResultsSheetTheme(data: data, child: child); + + @override + bool updateShouldNotify(StreamPollResultsSheetTheme oldWidget) => data != oldWidget.data; +} + +/// Theme data for customizing [StreamPollResultsSheet] widgets. +/// +/// Covers the sheet surface, the "Question" card at the top, and each +/// per-option card that lists the latest votes. +/// +/// {@tool snippet} +/// +/// Customize the results sheet appearance globally: +/// +/// ```dart +/// StreamChatThemeData( +/// pollResultsSheetTheme: StreamPollResultsSheetThemeData( +/// questionStyle: StreamPollQuestionStyle( +/// headerTextStyle: TextStyle(fontWeight: FontWeight.w700), +/// ), +/// ), +/// ) +/// ``` +/// {@end-tool} +/// +/// See also: +/// +/// * [StreamPollResultsSheet], the widget that uses this theme data. +/// * [StreamPollResultsSheetTheme], for overriding theme in a widget +/// subtree. +/// * [StreamPollQuestionStyle], the nested style describing the question +/// card surface (chrome + header + body text). +/// * [StreamPollOptionVotesStyle], the nested style describing each +/// per-option result card. +@themeGen +@immutable +class StreamPollResultsSheetThemeData with _$StreamPollResultsSheetThemeData { + /// Creates poll results sheet theme data with optional style overrides. + const StreamPollResultsSheetThemeData({ + this.backgroundColor, + this.contentPadding, + this.sectionSpacing, + this.sheetHeaderStyle, + this.questionStyle, + this.optionsItemSpacing, + this.optionStyle, + this.totalVoteCountTextStyle, + }); + + /// The background color of the sheet surface. + /// + /// If null, defaults to [StreamColorScheme.backgroundApp]. + final Color? backgroundColor; + + /// The visual styling for the [StreamSheetHeader] rendered at the top of + /// the sheet. + /// + /// Scoped to the sheet's header via an inner [StreamSheetHeaderTheme] so + /// overrides here do not leak into other sheet headers on the screen. + /// When null, the header inherits the ambient [StreamSheetHeaderTheme]. + final StreamSheetHeaderStyle? sheetHeaderStyle; + + /// The padding around the sheet's scrollable content. + /// + /// If null, defaults to `EdgeInsets.all(StreamSpacing.md)`. + final EdgeInsetsGeometry? contentPadding; + + /// The vertical gap between the question card and the per-option results + /// cards. + /// + /// If null, defaults to `StreamSpacing.xxl`. + final double? sectionSpacing; + + /// The visual styling for the question card surface (chrome + header + /// label + question body text). + /// + /// When null, the sheet falls back to its token-backed defaults. + final StreamPollQuestionStyle? questionStyle; + + /// The vertical spacing between consecutive per-option results cards. + /// + /// If null, defaults to `StreamSpacing.md`. + final double? optionsItemSpacing; + + /// The visual styling for each per-option results card. + /// + /// Controls the card chrome, the "Option N" header label, the option body + /// text, the vote count label, the winner trophy icon, the footer divider, + /// and the footer action button (e.g. "View all"). + final StreamPollOptionVotesStyle? optionStyle; + + /// The text style for the total vote count footer shown beneath the + /// per-option results (e.g. `"14 votes total"`). + /// + /// If null, defaults to [StreamTextTheme.bodyDefault] with + /// [StreamColorScheme.textPrimary]. + final TextStyle? totalVoteCountTextStyle; + + /// Linearly interpolate between two [StreamPollResultsSheetThemeData] + /// objects. + static StreamPollResultsSheetThemeData? lerp( + StreamPollResultsSheetThemeData? a, + StreamPollResultsSheetThemeData? b, + double t, + ) => _$StreamPollResultsSheetThemeData.lerp(a, b, t); +} diff --git a/packages/stream_chat_flutter/lib/src/theme/poll_results_sheet_theme.g.theme.dart b/packages/stream_chat_flutter/lib/src/theme/poll_results_sheet_theme.g.theme.dart new file mode 100644 index 0000000000..62628915cc --- /dev/null +++ b/packages/stream_chat_flutter/lib/src/theme/poll_results_sheet_theme.g.theme.dart @@ -0,0 +1,164 @@ +// dart format width=80 +// coverage:ignore-file +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint, unused_element + +part of 'poll_results_sheet_theme.dart'; + +// ************************************************************************** +// ThemeGenGenerator +// ************************************************************************** + +mixin _$StreamPollResultsSheetThemeData { + bool get canMerge => true; + + static StreamPollResultsSheetThemeData? lerp( + StreamPollResultsSheetThemeData? a, + StreamPollResultsSheetThemeData? b, + double t, + ) { + if (identical(a, b)) { + return a; + } + + if (a == null) { + return t == 1.0 ? b : null; + } + + if (b == null) { + return t == 0.0 ? a : null; + } + + return StreamPollResultsSheetThemeData( + backgroundColor: Color.lerp(a.backgroundColor, b.backgroundColor, t), + sheetHeaderStyle: StreamSheetHeaderStyle.lerp( + a.sheetHeaderStyle, + b.sheetHeaderStyle, + t, + ), + contentPadding: EdgeInsetsGeometry.lerp( + a.contentPadding, + b.contentPadding, + t, + ), + sectionSpacing: lerpDouble$(a.sectionSpacing, b.sectionSpacing, t), + questionStyle: StreamPollQuestionStyle.lerp( + a.questionStyle, + b.questionStyle, + t, + ), + optionsItemSpacing: lerpDouble$( + a.optionsItemSpacing, + b.optionsItemSpacing, + t, + ), + optionStyle: StreamPollOptionVotesStyle.lerp( + a.optionStyle, + b.optionStyle, + t, + ), + totalVoteCountTextStyle: TextStyle.lerp( + a.totalVoteCountTextStyle, + b.totalVoteCountTextStyle, + t, + ), + ); + } + + StreamPollResultsSheetThemeData copyWith({ + Color? backgroundColor, + StreamSheetHeaderStyle? sheetHeaderStyle, + EdgeInsetsGeometry? contentPadding, + double? sectionSpacing, + StreamPollQuestionStyle? questionStyle, + double? optionsItemSpacing, + StreamPollOptionVotesStyle? optionStyle, + TextStyle? totalVoteCountTextStyle, + }) { + final _this = (this as StreamPollResultsSheetThemeData); + + return StreamPollResultsSheetThemeData( + backgroundColor: backgroundColor ?? _this.backgroundColor, + sheetHeaderStyle: sheetHeaderStyle ?? _this.sheetHeaderStyle, + contentPadding: contentPadding ?? _this.contentPadding, + sectionSpacing: sectionSpacing ?? _this.sectionSpacing, + questionStyle: questionStyle ?? _this.questionStyle, + optionsItemSpacing: optionsItemSpacing ?? _this.optionsItemSpacing, + optionStyle: optionStyle ?? _this.optionStyle, + totalVoteCountTextStyle: + totalVoteCountTextStyle ?? _this.totalVoteCountTextStyle, + ); + } + + StreamPollResultsSheetThemeData merge( + StreamPollResultsSheetThemeData? other, + ) { + final _this = (this as StreamPollResultsSheetThemeData); + + if (other == null || identical(_this, other)) { + return _this; + } + + if (!other.canMerge) { + return other; + } + + return copyWith( + backgroundColor: other.backgroundColor, + sheetHeaderStyle: + _this.sheetHeaderStyle?.merge(other.sheetHeaderStyle) ?? + other.sheetHeaderStyle, + contentPadding: other.contentPadding, + sectionSpacing: other.sectionSpacing, + questionStyle: + _this.questionStyle?.merge(other.questionStyle) ?? + other.questionStyle, + optionsItemSpacing: other.optionsItemSpacing, + optionStyle: + _this.optionStyle?.merge(other.optionStyle) ?? other.optionStyle, + totalVoteCountTextStyle: + _this.totalVoteCountTextStyle?.merge(other.totalVoteCountTextStyle) ?? + other.totalVoteCountTextStyle, + ); + } + + @override + bool operator ==(Object other) { + if (identical(this, other)) { + return true; + } + + if (other.runtimeType != runtimeType) { + return false; + } + + final _this = (this as StreamPollResultsSheetThemeData); + final _other = (other as StreamPollResultsSheetThemeData); + + return _other.backgroundColor == _this.backgroundColor && + _other.sheetHeaderStyle == _this.sheetHeaderStyle && + _other.contentPadding == _this.contentPadding && + _other.sectionSpacing == _this.sectionSpacing && + _other.questionStyle == _this.questionStyle && + _other.optionsItemSpacing == _this.optionsItemSpacing && + _other.optionStyle == _this.optionStyle && + _other.totalVoteCountTextStyle == _this.totalVoteCountTextStyle; + } + + @override + int get hashCode { + final _this = (this as StreamPollResultsSheetThemeData); + + return Object.hash( + runtimeType, + _this.backgroundColor, + _this.sheetHeaderStyle, + _this.contentPadding, + _this.sectionSpacing, + _this.questionStyle, + _this.optionsItemSpacing, + _this.optionStyle, + _this.totalVoteCountTextStyle, + ); + } +} diff --git a/packages/stream_chat_flutter/lib/src/theme/quoted_message_theme.dart b/packages/stream_chat_flutter/lib/src/theme/quoted_message_theme.dart new file mode 100644 index 0000000000..38c37b9aa0 --- /dev/null +++ b/packages/stream_chat_flutter/lib/src/theme/quoted_message_theme.dart @@ -0,0 +1,181 @@ +import 'package:flutter/widgets.dart'; +import 'package:stream_chat_flutter/src/theme/stream_chat_theme.dart'; +import 'package:stream_core_flutter/stream_core_flutter.dart'; +import 'package:theme_extensions_builder_annotation/theme_extensions_builder_annotation.dart'; + +part 'quoted_message_theme.g.theme.dart'; + +/// Applies a quoted-message theme to descendant [StreamQuotedMessage] +/// widgets. +/// +/// Wrap a subtree with [StreamQuotedMessageTheme] to override the styling of +/// the quoted-message preview rendered inside replies. Access the merged +/// theme using [StreamQuotedMessageTheme.of]. +/// +/// {@tool snippet} +/// +/// Override quoted-message styling for a specific section: +/// +/// ```dart +/// StreamQuotedMessageTheme( +/// data: StreamQuotedMessageThemeData( +/// titleTextStyle: TextStyle(fontWeight: FontWeight.w700), +/// indicatorColor: Colors.green, +/// ), +/// child: StreamMessageListView(), +/// ) +/// ``` +/// {@end-tool} +/// +/// See also: +/// +/// * [StreamQuotedMessageThemeData], which describes the theme data. +/// * [StreamQuotedMessage], the widget affected by this theme. +class StreamQuotedMessageTheme extends InheritedTheme { + /// Creates a quoted-message theme that controls descendant widgets. + const StreamQuotedMessageTheme({ + super.key, + required this.data, + required super.child, + }); + + /// The quoted-message theme data for descendant widgets. + final StreamQuotedMessageThemeData data; + + /// Returns the [StreamQuotedMessageThemeData] merged from local and global + /// themes. + /// + /// Local values from the nearest [StreamQuotedMessageTheme] ancestor take + /// precedence over global values from [StreamChatTheme.of]. + /// + /// This allows partial overrides — for example, overriding only + /// [StreamQuotedMessageThemeData.titleTextStyle] while inheriting other + /// properties from the global theme. + static StreamQuotedMessageThemeData of(BuildContext context) { + final localTheme = context.dependOnInheritedWidgetOfExactType(); + return StreamChatTheme.of(context).quotedMessageTheme.merge(localTheme?.data); + } + + @override + Widget wrap(BuildContext context, Widget child) => StreamQuotedMessageTheme(data: data, child: child); + + @override + bool updateShouldNotify(StreamQuotedMessageTheme oldWidget) => data != oldWidget.data; +} + +/// Theme data for customizing [StreamQuotedMessage] widgets. +/// +/// All fields are nullable. When a field is null, the consuming widget falls +/// back to a default derived from the alignment-aware [StreamColorScheme] +/// and [StreamTextTheme] tokens. +/// +/// {@tool snippet} +/// +/// Customize quoted-message appearance globally: +/// +/// ```dart +/// StreamChatThemeData( +/// quotedMessageTheme: StreamQuotedMessageThemeData( +/// titleTextStyle: TextStyle(fontWeight: FontWeight.w700), +/// padding: EdgeInsetsDirectional.all(12), +/// ), +/// ) +/// ``` +/// {@end-tool} +/// +/// See also: +/// +/// * [StreamQuotedMessage], the widget that uses this theme data. +/// * [StreamQuotedMessageTheme], for overriding theme in a widget subtree. +@themeGen +@immutable +class StreamQuotedMessageThemeData with _$StreamQuotedMessageThemeData { + /// Creates quoted-message theme data with optional style overrides. + const StreamQuotedMessageThemeData({ + this.titleTextStyle, + this.subtitleTextStyle, + this.indicatorColor, + this.backgroundColor, + this.shape, + this.side, + this.margin, + this.padding, + this.thumbnailShape, + this.thumbnailSide, + this.thumbnailSize, + }); + + /// The text style for the quoted user's name. + /// + /// If null, defaults to [StreamTextTheme.metadataEmphasis] tinted with the + /// alignment-aware text color. + final TextStyle? titleTextStyle; + + /// The text style for the quoted message preview. + /// + /// If null, defaults to [StreamTextTheme.metadataDefault] tinted with the + /// alignment-aware text color. + final TextStyle? subtitleTextStyle; + + /// Color of the leading indicator bar. + /// + /// If null, the consuming widget falls back to a direction-aware default: + /// `colorScheme.chrome.shade400` for incoming, `colorScheme.brand.shade400` + /// for outgoing. + final Color? indicatorColor; + + /// Background fill color of the quoted-message card. + /// + /// If null, the consuming widget falls back to a direction-aware default: + /// `colorScheme.backgroundSurfaceStrong` for incoming, + /// `colorScheme.brand.shade150` for outgoing. + final Color? backgroundColor; + + /// Shape of the quoted-message card. + /// + /// Composed with [side] to draw the card's border. If null, defaults to a + /// [RoundedSuperellipseBorder] with radius [StreamRadius.lg]. + final OutlinedBorder? shape; + + /// Border side drawn around the quoted-message card. + /// + /// Composed onto [shape] via [OutlinedBorder.copyWith]. If null, defaults to + /// [BorderSide.none]. + final BorderSide? side; + + /// Outer margin applied around the quoted-message card. + /// + /// If null, defaults to `EdgeInsets.symmetric(horizontal: spacing.xs)`. + final EdgeInsetsGeometry? margin; + + /// Inner padding around the indicator, text content, and optional trailing + /// thumbnail. This is the spacing between the card edge and its contents. + /// + /// If null, defaults to `EdgeInsetsDirectional.only(start: spacing.sm, + /// end: spacing.xs, top: spacing.xs, bottom: spacing.xs)`. + final EdgeInsetsGeometry? padding; + + /// Outer shape of the trailing thumbnail. + /// + /// Composed with [thumbnailSide] to draw the thumbnail's border. If null, + /// defaults to a [RoundedSuperellipseBorder] with radius [StreamRadius.md]. + final OutlinedBorder? thumbnailShape; + + /// Border side drawn around the trailing thumbnail. + /// + /// Composed onto [thumbnailShape] via [OutlinedBorder.copyWith]. If null, + /// defaults to [BorderSide.none]. + final BorderSide? thumbnailSide; + + /// Dimensions of the trailing thumbnail. + /// + /// If null, defaults to `Size.square(40)`. + final Size? thumbnailSize; + + /// Linearly interpolate between two [StreamQuotedMessageThemeData] objects. + static StreamQuotedMessageThemeData? lerp( + StreamQuotedMessageThemeData? a, + StreamQuotedMessageThemeData? b, + double t, + ) => _$StreamQuotedMessageThemeData.lerp(a, b, t); +} diff --git a/packages/stream_chat_flutter/lib/src/theme/quoted_message_theme.g.theme.dart b/packages/stream_chat_flutter/lib/src/theme/quoted_message_theme.g.theme.dart new file mode 100644 index 0000000000..d7c5e96ecd --- /dev/null +++ b/packages/stream_chat_flutter/lib/src/theme/quoted_message_theme.g.theme.dart @@ -0,0 +1,172 @@ +// dart format width=80 +// coverage:ignore-file +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint, unused_element + +part of 'quoted_message_theme.dart'; + +// ************************************************************************** +// ThemeGenGenerator +// ************************************************************************** + +mixin _$StreamQuotedMessageThemeData { + bool get canMerge => true; + + static StreamQuotedMessageThemeData? lerp( + StreamQuotedMessageThemeData? a, + StreamQuotedMessageThemeData? b, + double t, + ) { + if (identical(a, b)) { + return a; + } + + if (a == null) { + return t == 1.0 ? b : null; + } + + if (b == null) { + return t == 0.0 ? a : null; + } + + return StreamQuotedMessageThemeData( + titleTextStyle: TextStyle.lerp(a.titleTextStyle, b.titleTextStyle, t), + subtitleTextStyle: TextStyle.lerp( + a.subtitleTextStyle, + b.subtitleTextStyle, + t, + ), + indicatorColor: Color.lerp(a.indicatorColor, b.indicatorColor, t), + backgroundColor: Color.lerp(a.backgroundColor, b.backgroundColor, t), + shape: OutlinedBorder.lerp(a.shape, b.shape, t), + side: a.side == null + ? b.side + : b.side == null + ? a.side + : BorderSide.lerp(a.side!, b.side!, t), + margin: EdgeInsetsGeometry.lerp(a.margin, b.margin, t), + padding: EdgeInsetsGeometry.lerp(a.padding, b.padding, t), + thumbnailShape: OutlinedBorder.lerp( + a.thumbnailShape, + b.thumbnailShape, + t, + ), + thumbnailSide: a.thumbnailSide == null + ? b.thumbnailSide + : b.thumbnailSide == null + ? a.thumbnailSide + : BorderSide.lerp(a.thumbnailSide!, b.thumbnailSide!, t), + thumbnailSize: Size.lerp(a.thumbnailSize, b.thumbnailSize, t), + ); + } + + StreamQuotedMessageThemeData copyWith({ + TextStyle? titleTextStyle, + TextStyle? subtitleTextStyle, + Color? indicatorColor, + Color? backgroundColor, + OutlinedBorder? shape, + BorderSide? side, + EdgeInsetsGeometry? margin, + EdgeInsetsGeometry? padding, + OutlinedBorder? thumbnailShape, + BorderSide? thumbnailSide, + Size? thumbnailSize, + }) { + final _this = (this as StreamQuotedMessageThemeData); + + return StreamQuotedMessageThemeData( + titleTextStyle: titleTextStyle ?? _this.titleTextStyle, + subtitleTextStyle: subtitleTextStyle ?? _this.subtitleTextStyle, + indicatorColor: indicatorColor ?? _this.indicatorColor, + backgroundColor: backgroundColor ?? _this.backgroundColor, + shape: shape ?? _this.shape, + side: side ?? _this.side, + margin: margin ?? _this.margin, + padding: padding ?? _this.padding, + thumbnailShape: thumbnailShape ?? _this.thumbnailShape, + thumbnailSide: thumbnailSide ?? _this.thumbnailSide, + thumbnailSize: thumbnailSize ?? _this.thumbnailSize, + ); + } + + StreamQuotedMessageThemeData merge(StreamQuotedMessageThemeData? other) { + final _this = (this as StreamQuotedMessageThemeData); + + if (other == null || identical(_this, other)) { + return _this; + } + + if (!other.canMerge) { + return other; + } + + return copyWith( + titleTextStyle: + _this.titleTextStyle?.merge(other.titleTextStyle) ?? + other.titleTextStyle, + subtitleTextStyle: + _this.subtitleTextStyle?.merge(other.subtitleTextStyle) ?? + other.subtitleTextStyle, + indicatorColor: other.indicatorColor, + backgroundColor: other.backgroundColor, + shape: other.shape, + side: _this.side != null && other.side != null + ? BorderSide.merge(_this.side!, other.side!) + : other.side, + margin: other.margin, + padding: other.padding, + thumbnailShape: other.thumbnailShape, + thumbnailSide: _this.thumbnailSide != null && other.thumbnailSide != null + ? BorderSide.merge(_this.thumbnailSide!, other.thumbnailSide!) + : other.thumbnailSide, + thumbnailSize: other.thumbnailSize, + ); + } + + @override + bool operator ==(Object other) { + if (identical(this, other)) { + return true; + } + + if (other.runtimeType != runtimeType) { + return false; + } + + final _this = (this as StreamQuotedMessageThemeData); + final _other = (other as StreamQuotedMessageThemeData); + + return _other.titleTextStyle == _this.titleTextStyle && + _other.subtitleTextStyle == _this.subtitleTextStyle && + _other.indicatorColor == _this.indicatorColor && + _other.backgroundColor == _this.backgroundColor && + _other.shape == _this.shape && + _other.side == _this.side && + _other.margin == _this.margin && + _other.padding == _this.padding && + _other.thumbnailShape == _this.thumbnailShape && + _other.thumbnailSide == _this.thumbnailSide && + _other.thumbnailSize == _this.thumbnailSize; + } + + @override + int get hashCode { + final _this = (this as StreamQuotedMessageThemeData); + + return Object.hash( + runtimeType, + _this.titleTextStyle, + _this.subtitleTextStyle, + _this.indicatorColor, + _this.backgroundColor, + _this.shape, + _this.side, + _this.margin, + _this.padding, + _this.thumbnailShape, + _this.thumbnailSide, + _this.thumbnailSize, + ); + } +} diff --git a/packages/stream_chat_flutter/lib/src/theme/stream_channel_list_item_theme.dart b/packages/stream_chat_flutter/lib/src/theme/stream_channel_list_item_theme.dart new file mode 100644 index 0000000000..8bd98fcf6c --- /dev/null +++ b/packages/stream_chat_flutter/lib/src/theme/stream_channel_list_item_theme.dart @@ -0,0 +1,151 @@ +import 'package:flutter/widgets.dart'; +import 'package:stream_chat_flutter/stream_chat_flutter.dart'; +import 'package:theme_extensions_builder_annotation/theme_extensions_builder_annotation.dart'; + +part 'stream_channel_list_item_theme.g.theme.dart'; + +/// Predefined attribute positions for [StreamChannelListTile]. +/// +/// Each position controls where the channel attribute icons are rendered +/// within the tile. +/// +/// See also: +/// +/// * [StreamChannelListTile], which uses these position variants. +/// * [StreamChannelListItemThemeData.attributePosition], for setting a +/// global default position. +enum AttributePosition { + /// Inline with the channel name in the title row. + inlineTitle, + + /// At the trailing end of the subtitle row. + trailingBottom, +} + +/// Applies a channel list item theme to descendant +/// [StreamChannelListItem] widgets. +/// +/// Wrap a subtree with [StreamChannelListItemTheme] to override styling. +/// Access the merged theme using [BuildContext.streamChannelListItemTheme]. +/// +/// {@tool snippet} +/// +/// Override channel list item colors for a specific section: +/// +/// ```dart +/// StreamChannelListItemTheme( +/// data: StreamChannelListItemThemeData( +/// backgroundColor: Colors.grey.shade50, +/// ), +/// child: StreamChannelListItem( +/// avatar: StreamAvatar(...), +/// title: 'General', +/// ), +/// ) +/// ``` +/// {@end-tool} +/// +/// See also: +/// +/// * [StreamChannelListItemThemeData], which describes the theme. +/// * [StreamChannelListItem], the widget affected by this theme. +class StreamChannelListItemTheme extends InheritedTheme { + /// Creates a channel list item theme that controls descendant widgets. + const StreamChannelListItemTheme({ + super.key, + required this.data, + required super.child, + }); + + /// The channel list item theme data for descendant widgets. + final StreamChannelListItemThemeData data; + + /// Returns the [StreamChannelListItemThemeData] merged from local and + /// global themes. + /// + /// Local values from the nearest [StreamChannelListItemTheme] ancestor + /// take precedence over global values from [StreamTheme.of]. + static StreamChannelListItemThemeData of(BuildContext context) { + final localTheme = context.dependOnInheritedWidgetOfExactType(); + return StreamChatTheme.of(context).channelListItemTheme.merge(localTheme?.data); + } + + @override + Widget wrap(BuildContext context, Widget child) { + return StreamChannelListItemTheme(data: data, child: child); + } + + @override + bool updateShouldNotify(StreamChannelListItemTheme oldWidget) => data != oldWidget.data; +} + +/// Theme data for customizing [StreamChannelListItem] widgets. +/// +/// {@tool snippet} +/// +/// Customize channel list item appearance globally: +/// +/// ```dart +/// StreamTheme( +/// channelListItemTheme: StreamChannelListItemThemeData( +/// backgroundColor: Colors.white, +/// borderColor: Colors.grey.shade200, +/// ), +/// ) +/// ``` +/// {@end-tool} +/// +/// See also: +/// +/// * [StreamChannelListItem], the widget that uses this theme data. +/// * [StreamChannelListItemTheme], for overriding theme in a widget subtree. +@themeGen +@immutable +class StreamChannelListItemThemeData with _$StreamChannelListItemThemeData { + /// Creates a channel list item theme with optional style overrides. + const StreamChannelListItemThemeData({ + this.titleStyle, + this.subtitleStyle, + this.timestampStyle, + this.backgroundColor, + this.borderColor, + this.attributePosition, + }); + + /// The text style for the channel title. + /// + /// Falls back to [StreamTextTheme.headingSm] with [StreamColorScheme.textPrimary]. + final TextStyle? titleStyle; + + /// The text style for the message preview subtitle. + /// + /// Falls back to [StreamTextTheme.captionDefault] with [StreamColorScheme.textSecondary]. + final TextStyle? subtitleStyle; + + /// The text style for the timestamp. + /// + /// Falls back to [StreamTextTheme.captionDefault] with [StreamColorScheme.textTertiary]. + final TextStyle? timestampStyle; + + /// Defines the default background color of the tile. + /// + /// This color is resolved from [WidgetState]s. + final WidgetStateProperty? backgroundColor; + + /// The bottom border color of the list item. + /// + /// Falls back to [StreamColorScheme.borderSubtle]. + final Color? borderColor; + + /// Position of channel attribute icons. + /// + /// Defaults to [AttributePosition.inlineTitle]. + final AttributePosition? attributePosition; + + /// Linearly interpolate between two [StreamChannelListItemThemeData] objects. + static StreamChannelListItemThemeData? lerp( + StreamChannelListItemThemeData? a, + StreamChannelListItemThemeData? b, + double t, + ) => _$StreamChannelListItemThemeData.lerp(a, b, t); +} diff --git a/packages/stream_chat_flutter/lib/src/theme/stream_channel_list_item_theme.g.theme.dart b/packages/stream_chat_flutter/lib/src/theme/stream_channel_list_item_theme.g.theme.dart new file mode 100644 index 0000000000..a2543cbe8a --- /dev/null +++ b/packages/stream_chat_flutter/lib/src/theme/stream_channel_list_item_theme.g.theme.dart @@ -0,0 +1,127 @@ +// dart format width=80 +// coverage:ignore-file +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint, unused_element + +part of 'stream_channel_list_item_theme.dart'; + +// ************************************************************************** +// ThemeGenGenerator +// ************************************************************************** + +mixin _$StreamChannelListItemThemeData { + bool get canMerge => true; + + static StreamChannelListItemThemeData? lerp( + StreamChannelListItemThemeData? a, + StreamChannelListItemThemeData? b, + double t, + ) { + if (identical(a, b)) { + return a; + } + + if (a == null) { + return t == 1.0 ? b : null; + } + + if (b == null) { + return t == 0.0 ? a : null; + } + + return StreamChannelListItemThemeData( + titleStyle: TextStyle.lerp(a.titleStyle, b.titleStyle, t), + subtitleStyle: TextStyle.lerp(a.subtitleStyle, b.subtitleStyle, t), + timestampStyle: TextStyle.lerp(a.timestampStyle, b.timestampStyle, t), + backgroundColor: WidgetStateProperty.lerp( + a.backgroundColor, + b.backgroundColor, + t, + Color.lerp, + ), + borderColor: Color.lerp(a.borderColor, b.borderColor, t), + attributePosition: t < 0.5 ? a.attributePosition : b.attributePosition, + ); + } + + StreamChannelListItemThemeData copyWith({ + TextStyle? titleStyle, + TextStyle? subtitleStyle, + TextStyle? timestampStyle, + WidgetStateProperty? backgroundColor, + Color? borderColor, + AttributePosition? attributePosition, + }) { + final _this = (this as StreamChannelListItemThemeData); + + return StreamChannelListItemThemeData( + titleStyle: titleStyle ?? _this.titleStyle, + subtitleStyle: subtitleStyle ?? _this.subtitleStyle, + timestampStyle: timestampStyle ?? _this.timestampStyle, + backgroundColor: backgroundColor ?? _this.backgroundColor, + borderColor: borderColor ?? _this.borderColor, + attributePosition: attributePosition ?? _this.attributePosition, + ); + } + + StreamChannelListItemThemeData merge(StreamChannelListItemThemeData? other) { + final _this = (this as StreamChannelListItemThemeData); + + if (other == null || identical(_this, other)) { + return _this; + } + + if (!other.canMerge) { + return other; + } + + return copyWith( + titleStyle: _this.titleStyle?.merge(other.titleStyle) ?? other.titleStyle, + subtitleStyle: + _this.subtitleStyle?.merge(other.subtitleStyle) ?? + other.subtitleStyle, + timestampStyle: + _this.timestampStyle?.merge(other.timestampStyle) ?? + other.timestampStyle, + backgroundColor: other.backgroundColor, + borderColor: other.borderColor, + attributePosition: other.attributePosition, + ); + } + + @override + bool operator ==(Object other) { + if (identical(this, other)) { + return true; + } + + if (other.runtimeType != runtimeType) { + return false; + } + + final _this = (this as StreamChannelListItemThemeData); + final _other = (other as StreamChannelListItemThemeData); + + return _other.titleStyle == _this.titleStyle && + _other.subtitleStyle == _this.subtitleStyle && + _other.timestampStyle == _this.timestampStyle && + _other.backgroundColor == _this.backgroundColor && + _other.borderColor == _this.borderColor && + _other.attributePosition == _this.attributePosition; + } + + @override + int get hashCode { + final _this = (this as StreamChannelListItemThemeData); + + return Object.hash( + runtimeType, + _this.titleStyle, + _this.subtitleStyle, + _this.timestampStyle, + _this.backgroundColor, + _this.borderColor, + _this.attributePosition, + ); + } +} diff --git a/packages/stream_chat_flutter/lib/src/theme/stream_chat_theme.dart b/packages/stream_chat_flutter/lib/src/theme/stream_chat_theme.dart index 3f9e42bf80..a3f3ff554d 100644 --- a/packages/stream_chat_flutter/lib/src/theme/stream_chat_theme.dart +++ b/packages/stream_chat_flutter/lib/src/theme/stream_chat_theme.dart @@ -1,7 +1,6 @@ // ignore_for_file: deprecated_member_use_from_same_package import 'package:flutter/material.dart' hide TextTheme; -import 'package:stream_chat_flutter/src/misc/audio_waveform.dart'; import 'package:stream_chat_flutter/stream_chat_flutter.dart'; /// {@template streamChatTheme} @@ -23,8 +22,7 @@ class StreamChatTheme extends InheritedWidget { /// Use this method to get the current [StreamChatThemeData] instance static StreamChatThemeData of(BuildContext context) { - final streamChatTheme = - context.dependOnInheritedWidgetOfExactType(); + final streamChatTheme = context.dependOnInheritedWidgetOfExactType(); assert( streamChatTheme != null, @@ -44,39 +42,27 @@ class StreamChatThemeData { Brightness? brightness, StreamTextTheme? textTheme, StreamColorTheme? colorTheme, - StreamChannelListHeaderThemeData? channelListHeaderTheme, - StreamChannelPreviewThemeData? channelPreviewTheme, - StreamChannelHeaderThemeData? channelHeaderTheme, - StreamMessageThemeData? otherMessageTheme, - StreamMessageThemeData? ownMessageTheme, - StreamMessageInputThemeData? messageInputTheme, + StreamAppBarThemeData? channelHeaderTheme, + StreamAppBarThemeData? channelListHeaderTheme, + StreamAppBarThemeData? threadHeaderTheme, Widget Function(BuildContext, User)? defaultUserImage, PlaceholderUserImage? placeholderUserImage, IconThemeData? primaryIconTheme, - @Deprecated('Use StreamChatConfigurationData.reactionIcons instead') - List? reactionIcons, - StreamGalleryHeaderThemeData? imageHeaderTheme, - StreamGalleryFooterThemeData? imageFooterTheme, StreamMessageListViewThemeData? messageListViewTheme, - @Deprecated( - "Use 'StreamChatThemeData.voiceRecordingAttachmentTheme' instead") - StreamVoiceRecordingThemeData? voiceRecordingTheme, StreamPollCreatorThemeData? pollCreatorTheme, StreamPollInteractorThemeData? pollInteractorTheme, - StreamPollOptionsDialogThemeData? pollOptionsDialogTheme, - StreamPollResultsDialogThemeData? pollResultsDialogTheme, - StreamPollCommentsDialogThemeData? pollCommentsDialogTheme, - StreamPollOptionVotesDialogThemeData? pollOptionVotesDialogTheme, + StreamPollOptionsSheetThemeData? pollOptionsSheetTheme, + StreamPollResultsSheetThemeData? pollResultsSheetTheme, + StreamPollCommentsSheetThemeData? pollCommentsSheetTheme, + StreamPollOptionVotesSheetThemeData? pollOptionVotesSheetTheme, StreamThreadListTileThemeData? threadListTileTheme, - StreamDraftListTileThemeData? draftListTileTheme, - StreamAudioWaveformThemeData? audioWaveformTheme, - StreamAudioWaveformSliderThemeData? audioWaveformSliderTheme, StreamVoiceRecordingAttachmentThemeData? voiceRecordingAttachmentTheme, + StreamQuotedMessageThemeData? quotedMessageTheme, + StreamChannelListItemThemeData? channelListItemTheme, }) { brightness ??= colorTheme?.brightness ?? Brightness.light; - final isDark = brightness == Brightness.dark; - textTheme ??= isDark ? StreamTextTheme.dark() : StreamTextTheme.light(); - colorTheme ??= isDark ? StreamColorTheme.dark() : StreamColorTheme.light(); + textTheme ??= StreamTextTheme(brightness: brightness); + colorTheme ??= StreamColorTheme(brightness: brightness); final defaultData = StreamChatThemeData.fromColorAndTextTheme( colorTheme, @@ -84,70 +70,53 @@ class StreamChatThemeData { ); final customizedData = defaultData.copyWith( - channelListHeaderTheme: channelListHeaderTheme, - channelPreviewTheme: channelPreviewTheme, channelHeaderTheme: channelHeaderTheme, - otherMessageTheme: otherMessageTheme, - ownMessageTheme: ownMessageTheme, - messageInputTheme: messageInputTheme, + channelListHeaderTheme: channelListHeaderTheme, + threadHeaderTheme: threadHeaderTheme, defaultUserImage: defaultUserImage, placeholderUserImage: placeholderUserImage, primaryIconTheme: primaryIconTheme, - reactionIcons: reactionIcons, - galleryHeaderTheme: imageHeaderTheme, - galleryFooterTheme: imageFooterTheme, messageListViewTheme: messageListViewTheme, - voiceRecordingTheme: voiceRecordingTheme, pollCreatorTheme: pollCreatorTheme, pollInteractorTheme: pollInteractorTheme, - pollOptionsDialogTheme: pollOptionsDialogTheme, - pollResultsDialogTheme: pollResultsDialogTheme, - pollCommentsDialogTheme: pollCommentsDialogTheme, - pollOptionVotesDialogTheme: pollOptionVotesDialogTheme, + pollOptionsSheetTheme: pollOptionsSheetTheme, + pollResultsSheetTheme: pollResultsSheetTheme, + pollCommentsSheetTheme: pollCommentsSheetTheme, + pollOptionVotesSheetTheme: pollOptionVotesSheetTheme, threadListTileTheme: threadListTileTheme, - draftListTileTheme: draftListTileTheme, - audioWaveformTheme: audioWaveformTheme, - audioWaveformSliderTheme: audioWaveformSliderTheme, voiceRecordingAttachmentTheme: voiceRecordingAttachmentTheme, + quotedMessageTheme: quotedMessageTheme, + channelListItemTheme: channelListItemTheme, ); return defaultData.merge(customizedData); } /// Theme initialized with light - factory StreamChatThemeData.light() => - StreamChatThemeData(brightness: Brightness.light); + factory StreamChatThemeData.light() => StreamChatThemeData(brightness: Brightness.light); /// Theme initialized with dark - factory StreamChatThemeData.dark() => - StreamChatThemeData(brightness: Brightness.dark); + factory StreamChatThemeData.dark() => StreamChatThemeData(brightness: Brightness.dark); /// Raw theme initialization const StreamChatThemeData.raw({ required this.textTheme, required this.colorTheme, - required this.channelListHeaderTheme, - required this.channelPreviewTheme, required this.channelHeaderTheme, - required this.otherMessageTheme, - required this.ownMessageTheme, - required this.messageInputTheme, + required this.channelListHeaderTheme, + required this.threadHeaderTheme, required this.primaryIconTheme, - required this.galleryHeaderTheme, - required this.galleryFooterTheme, required this.messageListViewTheme, - required this.voiceRecordingTheme, required this.pollCreatorTheme, required this.pollInteractorTheme, - required this.pollResultsDialogTheme, - required this.pollOptionsDialogTheme, - required this.pollCommentsDialogTheme, - required this.pollOptionVotesDialogTheme, + required this.pollResultsSheetTheme, + required this.pollOptionsSheetTheme, + required this.pollCommentsSheetTheme, + required this.pollOptionVotesSheetTheme, required this.threadListTileTheme, - required this.draftListTileTheme, - required this.audioWaveformTheme, - required this.audioWaveformSliderTheme, required this.voiceRecordingAttachmentTheme, + required this.quotedMessageTheme, + required this.channelListItemTheme, }); /// Creates a theme from a Material [Theme] @@ -167,445 +136,26 @@ class StreamChatThemeData { StreamColorTheme colorTheme, StreamTextTheme textTheme, ) { - final accentColor = colorTheme.accentPrimary; final iconTheme = IconThemeData(color: colorTheme.textLowEmphasis); - final channelHeaderTheme = StreamChannelHeaderThemeData( - avatarTheme: StreamAvatarThemeData( - borderRadius: BorderRadius.circular(20), - constraints: const BoxConstraints.tightFor( - height: 40, - width: 40, - ), - ), - color: colorTheme.barsBg, - titleStyle: textTheme.headlineBold, - subtitleStyle: textTheme.footnote.copyWith( - color: const Color(0xff7A7A7A), - ), - ); - final channelPreviewTheme = StreamChannelPreviewThemeData( - unreadCounterColor: colorTheme.accentError, - avatarTheme: StreamAvatarThemeData( - borderRadius: BorderRadius.circular(20), - constraints: const BoxConstraints.tightFor( - height: 40, - width: 40, - ), - ), - titleStyle: textTheme.bodyBold, - subtitleStyle: textTheme.footnote.copyWith( - color: const Color(0xff7A7A7A), - ), - lastMessageAtStyle: textTheme.footnote.copyWith( - // ignore: deprecated_member_use - color: colorTheme.textHighEmphasis.withOpacity(0.5), - ), - indicatorIconSize: 16, - ); - - final audioWaveformTheme = StreamAudioWaveformThemeData( - color: colorTheme.textLowEmphasis, - progressColor: colorTheme.accentPrimary, - minBarHeight: 2, - spacingRatio: 0.3, - heightScale: 1, - ); - - final audioWaveformSliderTheme = StreamAudioWaveformSliderThemeData( - audioWaveformTheme: audioWaveformTheme, - thumbColor: Colors.white, - thumbBorderColor: colorTheme.borders, - ); return StreamChatThemeData.raw( textTheme: textTheme, colorTheme: colorTheme, primaryIconTheme: iconTheme, - channelPreviewTheme: channelPreviewTheme, - channelListHeaderTheme: StreamChannelListHeaderThemeData( - avatarTheme: StreamAvatarThemeData( - borderRadius: BorderRadius.circular(20), - constraints: const BoxConstraints.tightFor( - height: 40, - width: 40, - ), - ), - color: colorTheme.barsBg, - titleStyle: textTheme.headlineBold, - ), - channelHeaderTheme: channelHeaderTheme, - ownMessageTheme: StreamMessageThemeData( - messageAuthorStyle: - textTheme.footnote.copyWith(color: colorTheme.textLowEmphasis), - messageTextStyle: textTheme.body, - messageDeletedStyle: textTheme.body.copyWith( - color: colorTheme.textLowEmphasis, - fontStyle: FontStyle.italic, - ), - createdAtStyle: - textTheme.footnote.copyWith(color: colorTheme.textLowEmphasis), - repliesStyle: textTheme.footnoteBold.copyWith(color: accentColor), - messageBackgroundColor: colorTheme.borders, - messageBorderColor: colorTheme.borders, - reactionsBackgroundColor: colorTheme.barsBg, - reactionsBorderColor: colorTheme.borders, - reactionsMaskColor: colorTheme.appBg, - avatarTheme: StreamAvatarThemeData( - borderRadius: BorderRadius.circular(20), - constraints: const BoxConstraints.tightFor( - height: 32, - width: 32, - ), - ), - messageLinksStyle: TextStyle(color: accentColor), - urlAttachmentBackgroundColor: colorTheme.linkBg, - urlAttachmentHostStyle: textTheme.bodyBold.copyWith(color: accentColor), - urlAttachmentTitleStyle: textTheme.footnoteBold, - urlAttachmentTextStyle: textTheme.footnote, - urlAttachmentTitleMaxLine: 1, - urlAttachmentTextMaxLine: 3, - ), - otherMessageTheme: StreamMessageThemeData( - reactionsBackgroundColor: colorTheme.borders, - reactionsBorderColor: colorTheme.borders, - reactionsMaskColor: colorTheme.appBg, - messageTextStyle: textTheme.body, - messageDeletedStyle: textTheme.body.copyWith( - color: colorTheme.textLowEmphasis, - fontStyle: FontStyle.italic, - ), - createdAtStyle: - textTheme.footnote.copyWith(color: colorTheme.textLowEmphasis), - messageAuthorStyle: - textTheme.footnote.copyWith(color: colorTheme.textLowEmphasis), - repliesStyle: textTheme.footnoteBold.copyWith(color: accentColor), - messageLinksStyle: TextStyle(color: accentColor), - messageBackgroundColor: colorTheme.barsBg, - messageBorderColor: colorTheme.borders, - avatarTheme: StreamAvatarThemeData( - borderRadius: BorderRadius.circular(20), - constraints: const BoxConstraints.tightFor( - height: 32, - width: 32, - ), - ), - urlAttachmentBackgroundColor: colorTheme.linkBg, - urlAttachmentHostStyle: textTheme.bodyBold.copyWith(color: accentColor), - urlAttachmentTitleStyle: textTheme.footnoteBold, - urlAttachmentTextStyle: textTheme.footnote, - urlAttachmentTitleMaxLine: 1, - urlAttachmentTextMaxLine: 3, - ), - messageInputTheme: StreamMessageInputThemeData( - borderRadius: BorderRadius.circular(20), - sendAnimationDuration: const Duration(milliseconds: 300), - actionButtonColor: colorTheme.accentPrimary, - actionButtonIdleColor: colorTheme.textLowEmphasis, - expandButtonColor: colorTheme.accentPrimary, - sendButtonColor: colorTheme.accentPrimary, - sendButtonIdleColor: colorTheme.disabled, - inputBackgroundColor: colorTheme.barsBg, - inputTextStyle: textTheme.body, - linkHighlightColor: colorTheme.accentPrimary, - idleBorderGradient: LinearGradient( - colors: [ - colorTheme.disabled, - colorTheme.disabled, - ], - ), - activeBorderGradient: LinearGradient( - colors: [ - colorTheme.disabled, - colorTheme.disabled, - ], - ), - useSystemAttachmentPicker: false, - ), - galleryHeaderTheme: StreamGalleryHeaderThemeData( - closeButtonColor: colorTheme.textHighEmphasis, - backgroundColor: channelHeaderTheme.color, - iconMenuPointColor: colorTheme.textHighEmphasis, - titleTextStyle: textTheme.headlineBold, - subtitleTextStyle: channelPreviewTheme.subtitleStyle, - bottomSheetBarrierColor: colorTheme.overlay, - ), - galleryFooterTheme: StreamGalleryFooterThemeData( - backgroundColor: colorTheme.barsBg, - shareIconColor: colorTheme.textHighEmphasis, - titleTextStyle: textTheme.headlineBold, - gridIconButtonColor: colorTheme.textHighEmphasis, - bottomSheetBarrierColor: colorTheme.overlay, - bottomSheetBackgroundColor: colorTheme.barsBg, - bottomSheetPhotosTextStyle: textTheme.headlineBold, - bottomSheetCloseIconColor: colorTheme.textHighEmphasis, - ), - messageListViewTheme: StreamMessageListViewThemeData( - backgroundColor: colorTheme.barsBg, - ), - pollCreatorTheme: StreamPollCreatorThemeData( - backgroundColor: colorTheme.appBg, - appBarBackgroundColor: colorTheme.barsBg, - appBarForegroundColor: colorTheme.textHighEmphasis, - appBarElevation: 1, - appBarTitleStyle: textTheme.headlineBold.copyWith( - color: colorTheme.textHighEmphasis, - ), - questionTextFieldFillColor: colorTheme.inputBg, - questionHeaderStyle: textTheme.headline.copyWith( - color: colorTheme.textHighEmphasis, - ), - questionTextFieldStyle: textTheme.headline.copyWith( - color: colorTheme.textHighEmphasis, - ), - questionTextFieldErrorStyle: textTheme.footnote.copyWith( - color: colorTheme.accentError, - ), - questionTextFieldBorderRadius: BorderRadius.circular(12), - optionsTextFieldFillColor: colorTheme.inputBg, - optionsHeaderStyle: textTheme.headline.copyWith( - color: colorTheme.textHighEmphasis, - ), - optionsTextFieldStyle: textTheme.headline.copyWith( - color: colorTheme.textHighEmphasis, - ), - optionsTextFieldErrorStyle: textTheme.footnote.copyWith( - color: colorTheme.accentError, - ), - optionsTextFieldBorderRadius: BorderRadius.circular(12), - switchListTileFillColor: colorTheme.inputBg, - switchListTileTitleStyle: textTheme.headline.copyWith( - color: colorTheme.textHighEmphasis, - ), - switchListTileErrorStyle: textTheme.footnote.copyWith( - color: colorTheme.accentError, - ), - switchListTileBorderRadius: BorderRadius.circular(12), - actionDialogTitleStyle: textTheme.headlineBold.copyWith( - color: colorTheme.textHighEmphasis, - ), - actionDialogContentStyle: textTheme.body.copyWith( - color: colorTheme.textHighEmphasis, - ), - ), - pollInteractorTheme: StreamPollInteractorThemeData( - pollTitleStyle: textTheme.headlineBold.copyWith( - color: colorTheme.textHighEmphasis, - ), - pollSubtitleStyle: textTheme.footnote.copyWith( - color: colorTheme.textLowEmphasis, - ), - pollOptionTextStyle: textTheme.headline.copyWith( - color: colorTheme.textHighEmphasis, - ), - pollOptionVoteCountTextStyle: textTheme.footnote.copyWith( - color: colorTheme.textLowEmphasis, - ), - pollOptionCheckboxShape: const CircleBorder(), - pollOptionCheckboxCheckColor: Colors.white, - pollOptionCheckboxActiveColor: colorTheme.accentPrimary, - pollOptionCheckboxBorderSide: BorderSide( - width: 2, - color: colorTheme.disabled, - ), - pollOptionVotesProgressBarMinHeight: 4, - pollOptionVotesProgressBarTrackColor: colorTheme.disabled, - pollOptionVotesProgressBarValueColor: colorTheme.accentPrimary, - pollOptionVotesProgressBarWinnerColor: colorTheme.accentInfo, - pollOptionVotesProgressBarBorderRadius: BorderRadius.circular(4), - pollActionButtonStyle: TextButton.styleFrom( - textStyle: textTheme.headline, - foregroundColor: colorTheme.accentPrimary, - ), - pollActionDialogTitleStyle: textTheme.headlineBold.copyWith( - color: colorTheme.textHighEmphasis, - ), - pollActionDialogTextFieldStyle: textTheme.headline.copyWith( - color: colorTheme.textHighEmphasis, - ), - pollActionDialogTextFieldBorderRadius: BorderRadius.circular(12), - pollActionDialogTextFieldFillColor: colorTheme.inputBg, - ), - pollResultsDialogTheme: StreamPollResultsDialogThemeData( - backgroundColor: colorTheme.appBg, - appBarElevation: 1, - appBarBackgroundColor: colorTheme.barsBg, - appBarForegroundColor: colorTheme.textHighEmphasis, - appBarTitleTextStyle: textTheme.headlineBold.copyWith( - color: colorTheme.textHighEmphasis, - ), - pollTitleTextStyle: textTheme.headlineBold.copyWith( - color: colorTheme.textHighEmphasis, - ), - pollTitleDecoration: BoxDecoration( - color: colorTheme.inputBg, - borderRadius: BorderRadius.circular(12), - ), - pollOptionsDecoration: BoxDecoration( - color: colorTheme.inputBg, - borderRadius: BorderRadius.circular(12), - ), - pollOptionsWinnerDecoration: BoxDecoration( - color: colorTheme.inputBg, - borderRadius: BorderRadius.circular(12), - ), - pollOptionsTextStyle: textTheme.headlineBold.copyWith( - color: colorTheme.textHighEmphasis, - ), - pollOptionsWinnerTextStyle: textTheme.headlineBold.copyWith( - color: colorTheme.textHighEmphasis, - ), - pollOptionsVoteCountTextStyle: textTheme.headline.copyWith( - color: colorTheme.textHighEmphasis, - ), - pollOptionsWinnerVoteCountTextStyle: textTheme.headline.copyWith( - color: colorTheme.textHighEmphasis, - ), - pollOptionsShowAllVotesButtonStyle: TextButton.styleFrom( - textStyle: textTheme.headline, - foregroundColor: colorTheme.accentPrimary, - ), - ), - pollOptionsDialogTheme: StreamPollOptionsDialogThemeData( - backgroundColor: colorTheme.appBg, - appBarElevation: 1, - appBarBackgroundColor: colorTheme.barsBg, - appBarForegroundColor: colorTheme.textHighEmphasis, - appBarTitleTextStyle: textTheme.headlineBold.copyWith( - color: colorTheme.textHighEmphasis, - ), - pollTitleTextStyle: textTheme.headlineBold.copyWith( - color: colorTheme.textHighEmphasis, - ), - pollTitleDecoration: BoxDecoration( - color: colorTheme.inputBg, - borderRadius: BorderRadius.circular(12), - ), - pollOptionsListViewDecoration: BoxDecoration( - color: colorTheme.inputBg, - borderRadius: BorderRadius.circular(12), - ), - ), - pollCommentsDialogTheme: StreamPollCommentsDialogThemeData( - backgroundColor: colorTheme.appBg, - appBarElevation: 1, - appBarBackgroundColor: colorTheme.barsBg, - appBarForegroundColor: colorTheme.textHighEmphasis, - appBarTitleTextStyle: textTheme.headlineBold.copyWith( - color: colorTheme.textHighEmphasis, - ), - pollCommentItemBackgroundColor: colorTheme.inputBg, - pollCommentItemBorderRadius: BorderRadius.circular(12), - updateYourCommentButtonStyle: TextButton.styleFrom( - textStyle: textTheme.headlineBold, - foregroundColor: colorTheme.accentPrimary, - backgroundColor: colorTheme.inputBg, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), - ), - padding: const EdgeInsets.symmetric( - vertical: 18, - horizontal: 16, - ), - ), - ), - pollOptionVotesDialogTheme: StreamPollOptionVotesDialogThemeData( - backgroundColor: colorTheme.appBg, - appBarElevation: 1, - appBarBackgroundColor: colorTheme.barsBg, - appBarForegroundColor: colorTheme.textHighEmphasis, - appBarTitleTextStyle: textTheme.headlineBold.copyWith( - color: colorTheme.textHighEmphasis, - ), - pollOptionVoteCountTextStyle: textTheme.headline.copyWith( - color: colorTheme.textHighEmphasis, - ), - pollOptionWinnerVoteCountTextStyle: textTheme.headline.copyWith( - color: colorTheme.textHighEmphasis, - ), - pollOptionVoteItemBackgroundColor: colorTheme.inputBg, - pollOptionVoteItemBorderRadius: BorderRadius.circular(12), - ), - threadListTileTheme: StreamThreadListTileThemeData( - backgroundColor: colorTheme.barsBg, - padding: const EdgeInsets.symmetric(vertical: 14, horizontal: 8), - threadUnreadMessageCountStyle: textTheme.footnoteBold.copyWith( - color: Colors.white, - ), - threadUnreadMessageCountBackgroundColor: - channelPreviewTheme.unreadCounterColor, - threadChannelNameStyle: textTheme.bodyBold.copyWith( - color: colorTheme.textHighEmphasis, - ), - threadReplyToMessageStyle: textTheme.footnote.copyWith( - color: colorTheme.textLowEmphasis, - ), - threadLatestReplyTimestampStyle: textTheme.footnote.copyWith( - color: colorTheme.textLowEmphasis, - ), - threadLatestReplyUsernameStyle: textTheme.bodyBold.copyWith( - color: colorTheme.textHighEmphasis, - ), - threadLatestReplyMessageStyle: textTheme.body.copyWith( - color: colorTheme.textLowEmphasis, - ), - ), - draftListTileTheme: StreamDraftListTileThemeData( - backgroundColor: colorTheme.barsBg, - padding: const EdgeInsets.symmetric(vertical: 14, horizontal: 8), - draftChannelNameStyle: textTheme.bodyBold.copyWith( - color: colorTheme.textHighEmphasis, - ), - draftMessageStyle: textTheme.footnote.copyWith( - color: colorTheme.textLowEmphasis, - ), - draftTimestampStyle: textTheme.footnote.copyWith( - color: colorTheme.textLowEmphasis, - ), - ), - audioWaveformTheme: audioWaveformTheme, - audioWaveformSliderTheme: audioWaveformSliderTheme, - voiceRecordingAttachmentTheme: StreamVoiceRecordingAttachmentThemeData( - backgroundColor: colorTheme.barsBg, - playIcon: const StreamSvgIcon(icon: StreamSvgIcons.play), - pauseIcon: const StreamSvgIcon(icon: StreamSvgIcons.pause), - loadingIndicator: SizedBox.fromSize( - size: const Size.square(24 - /* Padding */ 2), - child: Center( - child: CircularProgressIndicator.adaptive( - valueColor: AlwaysStoppedAnimation(colorTheme.accentPrimary), - ), - ), - ), - audioControlButtonStyle: ElevatedButton.styleFrom( - elevation: 2, - iconColor: Colors.black, - padding: const EdgeInsets.symmetric(horizontal: 6), - backgroundColor: Colors.white, - shape: const CircleBorder(), - tapTargetSize: MaterialTapTargetSize.shrinkWrap, - minimumSize: const Size(36, 36), - ), - titleTextStyle: textTheme.bodyBold.copyWith( - color: colorTheme.textHighEmphasis, - ), - durationTextStyle: textTheme.footnote.copyWith( - color: colorTheme.textLowEmphasis, - ), - speedControlButtonStyle: ElevatedButton.styleFrom( - elevation: 2, - textStyle: textTheme.footnote, - foregroundColor: Colors.black, - padding: const EdgeInsets.symmetric(horizontal: 8), - backgroundColor: Colors.white, - shape: const StadiumBorder(), - tapTargetSize: MaterialTapTargetSize.shrinkWrap, - minimumSize: const Size(40, 28), - ), - audioWaveformSliderTheme: audioWaveformSliderTheme, - ), - voiceRecordingTheme: colorTheme.brightness == Brightness.dark - ? StreamVoiceRecordingThemeData.dark() - : StreamVoiceRecordingThemeData.light(), + channelHeaderTheme: const StreamAppBarThemeData(), + channelListHeaderTheme: const StreamAppBarThemeData(), + threadHeaderTheme: const StreamAppBarThemeData(), + messageListViewTheme: StreamMessageListViewThemeData(backgroundColor: colorTheme.appBg), + pollCreatorTheme: const StreamPollCreatorThemeData(), + pollInteractorTheme: const StreamPollInteractorThemeData(), + pollResultsSheetTheme: const StreamPollResultsSheetThemeData(), + pollOptionsSheetTheme: const StreamPollOptionsSheetThemeData(), + pollCommentsSheetTheme: const StreamPollCommentsSheetThemeData(), + pollOptionVotesSheetTheme: const StreamPollOptionVotesSheetThemeData(), + threadListTileTheme: const StreamThreadListTileThemeData(), + voiceRecordingAttachmentTheme: const StreamVoiceRecordingAttachmentThemeData(), + quotedMessageTheme: const StreamQuotedMessageThemeData(), + channelListItemTheme: const StreamChannelListItemThemeData(), ); } @@ -615,31 +165,14 @@ class StreamChatThemeData { /// The color themes used in the widgets final StreamColorTheme colorTheme; - /// Theme of the [StreamChannelPreview] - final StreamChannelPreviewThemeData channelPreviewTheme; - - /// Theme of the [StreamChannelListHeader] - final StreamChannelListHeaderThemeData channelListHeaderTheme; + /// The default [StreamAppBar] style applied to [StreamChannelHeader]. + final StreamAppBarThemeData channelHeaderTheme; - /// Theme of the chat widgets dedicated to a channel header - final StreamChannelHeaderThemeData channelHeaderTheme; + /// The default [StreamAppBar] style applied to [StreamChannelListHeader]. + final StreamAppBarThemeData channelListHeaderTheme; - /// The default style for [StreamGalleryHeader]s below the overall - /// [StreamChatTheme]. - final StreamGalleryHeaderThemeData galleryHeaderTheme; - - /// The default style for [StreamGalleryFooter]s below the overall - /// [StreamChatTheme]. - final StreamGalleryFooterThemeData galleryFooterTheme; - - /// Theme of the current user messages - final StreamMessageThemeData ownMessageTheme; - - /// Theme of other users messages - final StreamMessageThemeData otherMessageTheme; - - /// Theme dedicated to the [StreamMessageInput] widget - final StreamMessageInputThemeData messageInputTheme; + /// The default [StreamAppBar] style applied to [StreamThreadHeader]. + final StreamAppBarThemeData threadHeaderTheme; /// Primary icon theme final IconThemeData primaryIconTheme; @@ -647,147 +180,99 @@ class StreamChatThemeData { /// Theme configuration for the [StreamMessageListView] widget. final StreamMessageListViewThemeData messageListViewTheme; - /// Theme configuration for the [StreamVoiceRecordingListPLayer] widget. - @Deprecated("Use 'StreamChatThemeData.voiceRecordingAttachmentTheme' instead") - final StreamVoiceRecordingThemeData voiceRecordingTheme; - /// Theme configuration for the [StreamPollCreatorWidget] widget. final StreamPollCreatorThemeData pollCreatorTheme; /// Theme configuration for the [StreamPollInteractor] widget. final StreamPollInteractorThemeData pollInteractorTheme; - /// Theme configuration for the [StreamPollResultsDialog] widget. - final StreamPollResultsDialogThemeData pollResultsDialogTheme; + /// Theme configuration for the [StreamPollResultsSheet] widget. + final StreamPollResultsSheetThemeData pollResultsSheetTheme; - /// Theme configuration for the [StreamPollOptionsDialog] widget. - final StreamPollOptionsDialogThemeData pollOptionsDialogTheme; + /// Theme configuration for the [StreamPollOptionsSheet] widget. + final StreamPollOptionsSheetThemeData pollOptionsSheetTheme; - /// Theme configuration for the [StreamPollCommentsDialog] widget. - final StreamPollCommentsDialogThemeData pollCommentsDialogTheme; + /// Theme configuration for the [StreamPollCommentsSheet] widget. + final StreamPollCommentsSheetThemeData pollCommentsSheetTheme; - /// Theme configuration for the [StreamPollOptionVotesDialog] widget. - final StreamPollOptionVotesDialogThemeData pollOptionVotesDialogTheme; + /// Theme configuration for the [StreamPollOptionVotesSheet] widget. + final StreamPollOptionVotesSheetThemeData pollOptionVotesSheetTheme; /// Theme configuration for the [StreamThreadListTile] widget. final StreamThreadListTileThemeData threadListTileTheme; - /// Theme configuration for the [StreamAudioWaveform] widget. - final StreamAudioWaveformThemeData audioWaveformTheme; - - /// Theme configuration for the [StreamAudioWaveformSlider] widget. - final StreamAudioWaveformSliderThemeData audioWaveformSliderTheme; - /// Theme configuration for the [StreamVoiceRecordingAttachment] widget. final StreamVoiceRecordingAttachmentThemeData voiceRecordingAttachmentTheme; - /// Theme configuration for the [StreamDraftListTile] widget. - final StreamDraftListTileThemeData draftListTileTheme; + /// Theme configuration for the [StreamQuotedMessage] widget. + final StreamQuotedMessageThemeData quotedMessageTheme; + + /// Theme configuration for the [StreamChannelListItem] widget. + final StreamChannelListItemThemeData channelListItemTheme; /// Creates a copy of [StreamChatThemeData] with specified attributes /// overridden. StreamChatThemeData copyWith({ StreamTextTheme? textTheme, StreamColorTheme? colorTheme, - StreamChannelPreviewThemeData? channelPreviewTheme, - StreamChannelHeaderThemeData? channelHeaderTheme, - StreamMessageThemeData? ownMessageTheme, - StreamMessageThemeData? otherMessageTheme, - StreamMessageInputThemeData? messageInputTheme, + StreamAppBarThemeData? channelHeaderTheme, + StreamAppBarThemeData? channelListHeaderTheme, + StreamAppBarThemeData? threadHeaderTheme, Widget Function(BuildContext, User)? defaultUserImage, PlaceholderUserImage? placeholderUserImage, IconThemeData? primaryIconTheme, - StreamChannelListHeaderThemeData? channelListHeaderTheme, - @Deprecated('Use StreamChatConfigurationData.reactionIcons instead') - List? reactionIcons, - StreamGalleryHeaderThemeData? galleryHeaderTheme, - StreamGalleryFooterThemeData? galleryFooterTheme, StreamMessageListViewThemeData? messageListViewTheme, - @Deprecated("Use 'voiceRecordingAttachmentTheme' instead") - StreamVoiceRecordingThemeData? voiceRecordingTheme, StreamPollCreatorThemeData? pollCreatorTheme, StreamPollInteractorThemeData? pollInteractorTheme, - StreamPollResultsDialogThemeData? pollResultsDialogTheme, - StreamPollOptionsDialogThemeData? pollOptionsDialogTheme, - StreamPollCommentsDialogThemeData? pollCommentsDialogTheme, - StreamPollOptionVotesDialogThemeData? pollOptionVotesDialogTheme, + StreamPollResultsSheetThemeData? pollResultsSheetTheme, + StreamPollOptionsSheetThemeData? pollOptionsSheetTheme, + StreamPollCommentsSheetThemeData? pollCommentsSheetTheme, + StreamPollOptionVotesSheetThemeData? pollOptionVotesSheetTheme, StreamThreadListTileThemeData? threadListTileTheme, - StreamDraftListTileThemeData? draftListTileTheme, - StreamAudioWaveformThemeData? audioWaveformTheme, - StreamAudioWaveformSliderThemeData? audioWaveformSliderTheme, StreamVoiceRecordingAttachmentThemeData? voiceRecordingAttachmentTheme, - }) => - StreamChatThemeData.raw( - channelListHeaderTheme: - this.channelListHeaderTheme.merge(channelListHeaderTheme), - textTheme: this.textTheme.merge(textTheme), - colorTheme: this.colorTheme.merge(colorTheme), - primaryIconTheme: this.primaryIconTheme.merge(primaryIconTheme), - channelPreviewTheme: - this.channelPreviewTheme.merge(channelPreviewTheme), - channelHeaderTheme: this.channelHeaderTheme.merge(channelHeaderTheme), - ownMessageTheme: this.ownMessageTheme.merge(ownMessageTheme), - otherMessageTheme: this.otherMessageTheme.merge(otherMessageTheme), - messageInputTheme: this.messageInputTheme.merge(messageInputTheme), - galleryHeaderTheme: galleryHeaderTheme ?? this.galleryHeaderTheme, - galleryFooterTheme: galleryFooterTheme ?? this.galleryFooterTheme, - messageListViewTheme: messageListViewTheme ?? this.messageListViewTheme, - voiceRecordingTheme: voiceRecordingTheme ?? this.voiceRecordingTheme, - pollCreatorTheme: pollCreatorTheme ?? this.pollCreatorTheme, - pollInteractorTheme: pollInteractorTheme ?? this.pollInteractorTheme, - pollResultsDialogTheme: - pollResultsDialogTheme ?? this.pollResultsDialogTheme, - pollOptionsDialogTheme: - pollOptionsDialogTheme ?? this.pollOptionsDialogTheme, - pollCommentsDialogTheme: - pollCommentsDialogTheme ?? this.pollCommentsDialogTheme, - pollOptionVotesDialogTheme: - pollOptionVotesDialogTheme ?? this.pollOptionVotesDialogTheme, - threadListTileTheme: threadListTileTheme ?? this.threadListTileTheme, - draftListTileTheme: draftListTileTheme ?? this.draftListTileTheme, - audioWaveformTheme: audioWaveformTheme ?? this.audioWaveformTheme, - audioWaveformSliderTheme: - audioWaveformSliderTheme ?? this.audioWaveformSliderTheme, - voiceRecordingAttachmentTheme: - voiceRecordingAttachmentTheme ?? this.voiceRecordingAttachmentTheme, - ); + StreamQuotedMessageThemeData? quotedMessageTheme, + StreamChannelListItemThemeData? channelListItemTheme, + }) => StreamChatThemeData.raw( + textTheme: this.textTheme.merge(textTheme), + colorTheme: this.colorTheme.merge(colorTheme), + channelHeaderTheme: this.channelHeaderTheme.merge(channelHeaderTheme), + channelListHeaderTheme: this.channelListHeaderTheme.merge(channelListHeaderTheme), + threadHeaderTheme: this.threadHeaderTheme.merge(threadHeaderTheme), + primaryIconTheme: this.primaryIconTheme.merge(primaryIconTheme), + messageListViewTheme: messageListViewTheme ?? this.messageListViewTheme, + pollCreatorTheme: pollCreatorTheme ?? this.pollCreatorTheme, + pollInteractorTheme: pollInteractorTheme ?? this.pollInteractorTheme, + pollResultsSheetTheme: pollResultsSheetTheme ?? this.pollResultsSheetTheme, + pollOptionsSheetTheme: pollOptionsSheetTheme ?? this.pollOptionsSheetTheme, + pollCommentsSheetTheme: pollCommentsSheetTheme ?? this.pollCommentsSheetTheme, + pollOptionVotesSheetTheme: pollOptionVotesSheetTheme ?? this.pollOptionVotesSheetTheme, + threadListTileTheme: threadListTileTheme ?? this.threadListTileTheme, + voiceRecordingAttachmentTheme: voiceRecordingAttachmentTheme ?? this.voiceRecordingAttachmentTheme, + quotedMessageTheme: quotedMessageTheme ?? this.quotedMessageTheme, + channelListItemTheme: channelListItemTheme ?? this.channelListItemTheme, + ); /// Merge themes StreamChatThemeData merge(StreamChatThemeData? other) { if (other == null) return this; return copyWith( - channelListHeaderTheme: - channelListHeaderTheme.merge(other.channelListHeaderTheme), textTheme: textTheme.merge(other.textTheme), colorTheme: colorTheme.merge(other.colorTheme), - primaryIconTheme: other.primaryIconTheme, - channelPreviewTheme: channelPreviewTheme.merge(other.channelPreviewTheme), channelHeaderTheme: channelHeaderTheme.merge(other.channelHeaderTheme), - ownMessageTheme: ownMessageTheme.merge(other.ownMessageTheme), - otherMessageTheme: otherMessageTheme.merge(other.otherMessageTheme), - messageInputTheme: messageInputTheme.merge(other.messageInputTheme), - galleryHeaderTheme: galleryHeaderTheme.merge(other.galleryHeaderTheme), - galleryFooterTheme: galleryFooterTheme.merge(other.galleryFooterTheme), - messageListViewTheme: - messageListViewTheme.merge(other.messageListViewTheme), - voiceRecordingTheme: voiceRecordingTheme.merge(other.voiceRecordingTheme), + channelListHeaderTheme: channelListHeaderTheme.merge(other.channelListHeaderTheme), + threadHeaderTheme: threadHeaderTheme.merge(other.threadHeaderTheme), + primaryIconTheme: other.primaryIconTheme, + messageListViewTheme: messageListViewTheme.merge(other.messageListViewTheme), pollCreatorTheme: pollCreatorTheme.merge(other.pollCreatorTheme), pollInteractorTheme: pollInteractorTheme.merge(other.pollInteractorTheme), - pollResultsDialogTheme: - pollResultsDialogTheme.merge(other.pollResultsDialogTheme), - pollOptionsDialogTheme: - pollOptionsDialogTheme.merge(other.pollOptionsDialogTheme), - pollCommentsDialogTheme: - pollCommentsDialogTheme.merge(other.pollCommentsDialogTheme), - pollOptionVotesDialogTheme: - pollOptionVotesDialogTheme.merge(other.pollOptionVotesDialogTheme), + pollResultsSheetTheme: pollResultsSheetTheme.merge(other.pollResultsSheetTheme), + pollOptionsSheetTheme: pollOptionsSheetTheme.merge(other.pollOptionsSheetTheme), + pollCommentsSheetTheme: pollCommentsSheetTheme.merge(other.pollCommentsSheetTheme), + pollOptionVotesSheetTheme: pollOptionVotesSheetTheme.merge(other.pollOptionVotesSheetTheme), threadListTileTheme: threadListTileTheme.merge(other.threadListTileTheme), - draftListTileTheme: draftListTileTheme.merge(other.draftListTileTheme), - audioWaveformTheme: audioWaveformTheme.merge(other.audioWaveformTheme), - audioWaveformSliderTheme: - audioWaveformSliderTheme.merge(other.audioWaveformSliderTheme), - voiceRecordingAttachmentTheme: voiceRecordingAttachmentTheme - .merge(other.voiceRecordingAttachmentTheme), + voiceRecordingAttachmentTheme: voiceRecordingAttachmentTheme.merge(other.voiceRecordingAttachmentTheme), + quotedMessageTheme: quotedMessageTheme.merge(other.quotedMessageTheme), + channelListItemTheme: channelListItemTheme.merge(other.channelListItemTheme), ); } } diff --git a/packages/stream_chat_flutter/lib/src/theme/text_theme.dart b/packages/stream_chat_flutter/lib/src/theme/text_theme.dart index 9662b4bcd9..113e67f8f6 100644 --- a/packages/stream_chat_flutter/lib/src/theme/text_theme.dart +++ b/packages/stream_chat_flutter/lib/src/theme/text_theme.dart @@ -4,8 +4,21 @@ import 'package:flutter/material.dart'; /// Class for holding text theme /// {@endtemplate} class StreamTextTheme { + /// Creates a [StreamTextTheme] instance based on the provided [brightness]. + /// + /// Returns a light theme when [brightness] is [Brightness.light] and + /// a dark theme when [brightness] is [Brightness.dark]. + factory StreamTextTheme({ + Brightness brightness = Brightness.light, + }) { + return switch (brightness) { + Brightness.light => const StreamTextTheme.light(), + Brightness.dark => const StreamTextTheme.dark(), + }; + } + /// Initialise light text theme - StreamTextTheme.light({ + const StreamTextTheme.light({ this.title = const TextStyle( fontSize: 22, fontWeight: FontWeight.w500, @@ -49,7 +62,7 @@ class StreamTextTheme { }); /// Initialise with dark theme - StreamTextTheme.dark({ + const StreamTextTheme.dark({ this.title = const TextStyle( fontSize: 22, fontWeight: FontWeight.w500, @@ -127,28 +140,27 @@ class StreamTextTheme { TextStyle? footnoteBold, TextStyle? footnote, TextStyle? captionBold, - }) => - brightness == Brightness.light - ? StreamTextTheme.light( - body: body ?? this.body, - title: title ?? this.title, - headlineBold: headlineBold ?? this.headlineBold, - headline: headline ?? this.headline, - bodyBold: bodyBold ?? this.bodyBold, - footnoteBold: footnoteBold ?? this.footnoteBold, - footnote: footnote ?? this.footnote, - captionBold: captionBold ?? this.captionBold, - ) - : StreamTextTheme.dark( - body: body ?? this.body, - title: title ?? this.title, - headlineBold: headlineBold ?? this.headlineBold, - headline: headline ?? this.headline, - bodyBold: bodyBold ?? this.bodyBold, - footnoteBold: footnoteBold ?? this.footnoteBold, - footnote: footnote ?? this.footnote, - captionBold: captionBold ?? this.captionBold, - ); + }) => brightness == Brightness.light + ? StreamTextTheme.light( + body: body ?? this.body, + title: title ?? this.title, + headlineBold: headlineBold ?? this.headlineBold, + headline: headline ?? this.headline, + bodyBold: bodyBold ?? this.bodyBold, + footnoteBold: footnoteBold ?? this.footnoteBold, + footnote: footnote ?? this.footnote, + captionBold: captionBold ?? this.captionBold, + ) + : StreamTextTheme.dark( + body: body ?? this.body, + title: title ?? this.title, + headlineBold: headlineBold ?? this.headlineBold, + headline: headline ?? this.headline, + bodyBold: bodyBold ?? this.bodyBold, + footnoteBold: footnoteBold ?? this.footnoteBold, + footnote: footnote ?? this.footnote, + captionBold: captionBold ?? this.captionBold, + ); /// Merge text theme StreamTextTheme merge(StreamTextTheme? other) { diff --git a/packages/stream_chat_flutter/lib/src/theme/themes.dart b/packages/stream_chat_flutter/lib/src/theme/themes.dart index 053ba9b039..4894ad2310 100644 --- a/packages/stream_chat_flutter/lib/src/theme/themes.dart +++ b/packages/stream_chat_flutter/lib/src/theme/themes.dart @@ -1,23 +1,17 @@ -export 'audio_waveform_slider_theme.dart'; -export 'audio_waveform_theme.dart'; -export 'avatar_theme.dart'; -export 'channel_header_theme.dart'; -export 'channel_list_header_theme.dart'; -export 'channel_preview_theme.dart'; export 'color_theme.dart'; -export 'draft_list_tile_theme.dart'; -export 'gallery_footer_theme.dart'; -export 'gallery_header_theme.dart'; -export 'message_input_theme.dart'; export 'message_list_view_theme.dart'; -export 'message_theme.dart'; -export 'poll_comments_dialog_theme.dart'; +export 'poll_card_style.dart'; +export 'poll_comments_sheet_theme.dart'; export 'poll_creator_theme.dart'; export 'poll_interactor_theme.dart'; -export 'poll_option_votes_dialog_theme.dart'; -export 'poll_options_dialog_theme.dart'; -export 'poll_results_dialog_theme.dart'; +export 'poll_option_style.dart'; +export 'poll_option_votes_sheet_theme.dart'; +export 'poll_option_votes_style.dart'; +export 'poll_options_sheet_theme.dart'; +export 'poll_question_style.dart'; +export 'poll_results_sheet_theme.dart'; +export 'quoted_message_theme.dart'; +export 'stream_channel_list_item_theme.dart'; export 'text_theme.dart'; export 'thread_list_tile_theme.dart'; -export 'voice_attachment_theme.dart'; export 'voice_recording_attachment_theme.dart'; diff --git a/packages/stream_chat_flutter/lib/src/theme/thread_list_tile_theme.dart b/packages/stream_chat_flutter/lib/src/theme/thread_list_tile_theme.dart index 6becfc2a27..539cb0814c 100644 --- a/packages/stream_chat_flutter/lib/src/theme/thread_list_tile_theme.dart +++ b/packages/stream_chat_flutter/lib/src/theme/thread_list_tile_theme.dart @@ -8,7 +8,7 @@ import 'package:stream_chat_flutter/src/utils/date_formatter.dart'; /// /// See also: /// -/// * [StreamPollOptionVotesDialogThemeData], which is used to configure this +/// * [StreamPollOptionVotesSheetThemeData], which is used to configure this /// theme. /// {@endtemplate} class StreamThreadListTileTheme extends InheritedTheme { @@ -27,28 +27,30 @@ class StreamThreadListTileTheme extends InheritedTheme { /// The closest instance of this class that encloses the given context. /// /// If there is no enclosing [StreamThreadListTileTheme] widget, then - /// [StreamChatThemeData.pollOptionVotesDialogTheme] is used. + /// [StreamChatThemeData.pollOptionVotesSheetTheme] is used. static StreamThreadListTileThemeData of(BuildContext context) { - final threadListTileTheme = - context.dependOnInheritedWidgetOfExactType(); - return threadListTileTheme?.data ?? - StreamChatTheme.of(context).threadListTileTheme; + final threadListTileTheme = context.dependOnInheritedWidgetOfExactType(); + return threadListTileTheme?.data ?? StreamChatTheme.of(context).threadListTileTheme; } @override - Widget wrap(BuildContext context, Widget child) => - StreamThreadListTileTheme(data: data, child: child); + Widget wrap(BuildContext context, Widget child) => StreamThreadListTileTheme(data: data, child: child); @override - bool updateShouldNotify(StreamThreadListTileTheme oldWidget) => - data != oldWidget.data; + bool updateShouldNotify(StreamThreadListTileTheme oldWidget) => data != oldWidget.data; } /// {@template streamThreadListTileThemeData} -/// A style that overrides the default appearance of -/// [StreamPollOptionVotesDialog] widgets when used with -/// [StreamPollCommentsDialogTheme] or with the overall [StreamChatTheme]'s -/// [StreamChatThemeData.pollOptionVotesDialogTheme]. +/// Theme data for customizing [StreamThreadListTile] widgets. +/// +/// When a property is null the widget falls back to computed defaults derived +/// from the ambient [StreamTextTheme] and [StreamColorScheme]. See +/// [StreamThreadListTile] for the built-in default values. +/// +/// See also: +/// +/// * [StreamThreadListTileTheme], the inherited theme widget. +/// * [StreamChatThemeData.threadListTileTheme], global theme entry-point. /// {@endtemplate} class StreamThreadListTileThemeData with Diagnosticable { /// {@macro streamThreadListTileThemeData} @@ -61,6 +63,7 @@ class StreamThreadListTileThemeData with Diagnosticable { this.threadLatestReplyMessageStyle, this.threadLatestReplyTimestampStyle, this.threadLatestReplyTimestampFormatter, + this.threadReplyCountStyle, this.threadUnreadMessageCountStyle, this.threadUnreadMessageCountBackgroundColor, }); @@ -72,10 +75,15 @@ class StreamThreadListTileThemeData with Diagnosticable { final Color? backgroundColor; /// The style of the channel name in the [StreamThreadListTile] widget. + /// + /// Falls back to [StreamTextTheme.captionEmphasis] with + /// [StreamColorScheme.textTertiary]. final TextStyle? threadChannelNameStyle; - /// The style of the message the thread is replying to in the - /// [StreamThreadListTile] widget. + /// The style of the root message preview in the [StreamThreadListTile] + /// widget. + /// + /// Falls back to [StreamTextTheme.bodyDefault]. final TextStyle? threadReplyToMessageStyle; /// The style of the latest reply author username in the @@ -83,15 +91,17 @@ class StreamThreadListTileThemeData with Diagnosticable { final TextStyle? threadLatestReplyUsernameStyle; /// The style of the latest reply message in the [StreamThreadListTile]. - /// widget. final TextStyle? threadLatestReplyMessageStyle; /// The style of the latest reply timestamp in the [StreamThreadListTile]. + /// + /// Falls back to [StreamTextTheme.captionDefault] with + /// [StreamColorScheme.textTertiary]. final TextStyle? threadLatestReplyTimestampStyle; /// Formatter for the latest reply timestamp. /// - /// If null, uses the default date formatting. + /// If null, uses [formatRecentDateTime]. /// /// Example: /// ```dart @@ -104,6 +114,12 @@ class StreamThreadListTileThemeData with Diagnosticable { /// ``` final DateFormatter? threadLatestReplyTimestampFormatter; + /// The style of the reply count label in the thread footer. + /// + /// Falls back to [StreamTextTheme.captionEmphasis] with + /// [StreamColorScheme.textLink]. + final TextStyle? threadReplyCountStyle; + /// The style of the unread message count in the [StreamThreadListTile]. final TextStyle? threadUnreadMessageCountStyle; @@ -122,31 +138,24 @@ class StreamThreadListTileThemeData with Diagnosticable { TextStyle? threadLatestReplyMessageStyle, TextStyle? threadLatestReplyTimestampStyle, DateFormatter? threadLatestReplyTimestampFormatter, + TextStyle? threadReplyCountStyle, TextStyle? threadUnreadMessageCountStyle, Color? threadUnreadMessageCountBackgroundColor, - }) => - StreamThreadListTileThemeData( - padding: padding ?? this.padding, - backgroundColor: backgroundColor ?? this.backgroundColor, - threadChannelNameStyle: - threadChannelNameStyle ?? this.threadChannelNameStyle, - threadReplyToMessageStyle: - threadReplyToMessageStyle ?? this.threadReplyToMessageStyle, - threadLatestReplyUsernameStyle: threadLatestReplyUsernameStyle ?? - this.threadLatestReplyUsernameStyle, - threadLatestReplyMessageStyle: - threadLatestReplyMessageStyle ?? this.threadLatestReplyMessageStyle, - threadLatestReplyTimestampStyle: threadLatestReplyTimestampStyle ?? - this.threadLatestReplyTimestampStyle, - threadLatestReplyTimestampFormatter: - threadLatestReplyTimestampFormatter ?? - this.threadLatestReplyTimestampFormatter, - threadUnreadMessageCountStyle: - threadUnreadMessageCountStyle ?? this.threadUnreadMessageCountStyle, - threadUnreadMessageCountBackgroundColor: - threadUnreadMessageCountBackgroundColor ?? - this.threadUnreadMessageCountBackgroundColor, - ); + }) => StreamThreadListTileThemeData( + padding: padding ?? this.padding, + backgroundColor: backgroundColor ?? this.backgroundColor, + threadChannelNameStyle: threadChannelNameStyle ?? this.threadChannelNameStyle, + threadReplyToMessageStyle: threadReplyToMessageStyle ?? this.threadReplyToMessageStyle, + threadLatestReplyUsernameStyle: threadLatestReplyUsernameStyle ?? this.threadLatestReplyUsernameStyle, + threadLatestReplyMessageStyle: threadLatestReplyMessageStyle ?? this.threadLatestReplyMessageStyle, + threadLatestReplyTimestampStyle: threadLatestReplyTimestampStyle ?? this.threadLatestReplyTimestampStyle, + threadLatestReplyTimestampFormatter: + threadLatestReplyTimestampFormatter ?? this.threadLatestReplyTimestampFormatter, + threadReplyCountStyle: threadReplyCountStyle ?? this.threadReplyCountStyle, + threadUnreadMessageCountStyle: threadUnreadMessageCountStyle ?? this.threadUnreadMessageCountStyle, + threadUnreadMessageCountBackgroundColor: + threadUnreadMessageCountBackgroundColor ?? this.threadUnreadMessageCountBackgroundColor, + ); /// Merges this [StreamThreadListTileThemeData] with the [other]. StreamThreadListTileThemeData merge( @@ -161,11 +170,10 @@ class StreamThreadListTileThemeData with Diagnosticable { threadLatestReplyUsernameStyle: other.threadLatestReplyUsernameStyle, threadLatestReplyMessageStyle: other.threadLatestReplyMessageStyle, threadLatestReplyTimestampStyle: other.threadLatestReplyTimestampStyle, - threadLatestReplyTimestampFormatter: - other.threadLatestReplyTimestampFormatter, + threadLatestReplyTimestampFormatter: other.threadLatestReplyTimestampFormatter, + threadReplyCountStyle: other.threadReplyCountStyle, threadUnreadMessageCountStyle: other.threadUnreadMessageCountStyle, - threadUnreadMessageCountBackgroundColor: - other.threadUnreadMessageCountBackgroundColor, + threadUnreadMessageCountBackgroundColor: other.threadUnreadMessageCountBackgroundColor, ); } @@ -174,49 +182,53 @@ class StreamThreadListTileThemeData with Diagnosticable { StreamThreadListTileThemeData? a, StreamThreadListTileThemeData? b, double t, - ) => - StreamThreadListTileThemeData( - padding: EdgeInsetsGeometry.lerp(a?.padding, b?.padding, t), - backgroundColor: Color.lerp(a?.backgroundColor, b?.backgroundColor, t), - threadChannelNameStyle: TextStyle.lerp( - a?.threadChannelNameStyle, - b?.threadChannelNameStyle, - t, - ), - threadReplyToMessageStyle: TextStyle.lerp( - a?.threadReplyToMessageStyle, - b?.threadReplyToMessageStyle, - t, - ), - threadLatestReplyUsernameStyle: TextStyle.lerp( - a?.threadLatestReplyUsernameStyle, - b?.threadLatestReplyUsernameStyle, - t, - ), - threadLatestReplyMessageStyle: TextStyle.lerp( - a?.threadLatestReplyMessageStyle, - b?.threadLatestReplyMessageStyle, - t, - ), - threadLatestReplyTimestampStyle: TextStyle.lerp( - a?.threadLatestReplyTimestampStyle, - b?.threadLatestReplyTimestampStyle, - t, - ), - threadLatestReplyTimestampFormatter: t < 0.5 - ? a?.threadLatestReplyTimestampFormatter - : b?.threadLatestReplyTimestampFormatter, - threadUnreadMessageCountStyle: TextStyle.lerp( - a?.threadUnreadMessageCountStyle, - b?.threadUnreadMessageCountStyle, - t, - ), - threadUnreadMessageCountBackgroundColor: Color.lerp( - a?.threadUnreadMessageCountBackgroundColor, - b?.threadUnreadMessageCountBackgroundColor, - t, - ), - ); + ) => StreamThreadListTileThemeData( + padding: EdgeInsetsGeometry.lerp(a?.padding, b?.padding, t), + backgroundColor: Color.lerp(a?.backgroundColor, b?.backgroundColor, t), + threadChannelNameStyle: TextStyle.lerp( + a?.threadChannelNameStyle, + b?.threadChannelNameStyle, + t, + ), + threadReplyToMessageStyle: TextStyle.lerp( + a?.threadReplyToMessageStyle, + b?.threadReplyToMessageStyle, + t, + ), + threadLatestReplyUsernameStyle: TextStyle.lerp( + a?.threadLatestReplyUsernameStyle, + b?.threadLatestReplyUsernameStyle, + t, + ), + threadLatestReplyMessageStyle: TextStyle.lerp( + a?.threadLatestReplyMessageStyle, + b?.threadLatestReplyMessageStyle, + t, + ), + threadLatestReplyTimestampStyle: TextStyle.lerp( + a?.threadLatestReplyTimestampStyle, + b?.threadLatestReplyTimestampStyle, + t, + ), + threadLatestReplyTimestampFormatter: t < 0.5 + ? a?.threadLatestReplyTimestampFormatter + : b?.threadLatestReplyTimestampFormatter, + threadReplyCountStyle: TextStyle.lerp( + a?.threadReplyCountStyle, + b?.threadReplyCountStyle, + t, + ), + threadUnreadMessageCountStyle: TextStyle.lerp( + a?.threadUnreadMessageCountStyle, + b?.threadUnreadMessageCountStyle, + t, + ), + threadUnreadMessageCountBackgroundColor: Color.lerp( + a?.threadUnreadMessageCountBackgroundColor, + b?.threadUnreadMessageCountBackgroundColor, + t, + ), + ); @override bool operator ==(Object other) => @@ -226,18 +238,13 @@ class StreamThreadListTileThemeData with Diagnosticable { other.backgroundColor == backgroundColor && other.threadChannelNameStyle == threadChannelNameStyle && other.threadReplyToMessageStyle == threadReplyToMessageStyle && - other.threadLatestReplyUsernameStyle == - threadLatestReplyUsernameStyle && - other.threadLatestReplyMessageStyle == - threadLatestReplyMessageStyle && - other.threadLatestReplyTimestampStyle == - threadLatestReplyTimestampStyle && - other.threadLatestReplyTimestampFormatter == - threadLatestReplyTimestampFormatter && - other.threadUnreadMessageCountStyle == - threadUnreadMessageCountStyle && - other.threadUnreadMessageCountBackgroundColor == - threadUnreadMessageCountBackgroundColor; + other.threadLatestReplyUsernameStyle == threadLatestReplyUsernameStyle && + other.threadLatestReplyMessageStyle == threadLatestReplyMessageStyle && + other.threadLatestReplyTimestampStyle == threadLatestReplyTimestampStyle && + other.threadLatestReplyTimestampFormatter == threadLatestReplyTimestampFormatter && + other.threadReplyCountStyle == threadReplyCountStyle && + other.threadUnreadMessageCountStyle == threadUnreadMessageCountStyle && + other.threadUnreadMessageCountBackgroundColor == threadUnreadMessageCountBackgroundColor; @override int get hashCode => @@ -249,6 +256,7 @@ class StreamThreadListTileThemeData with Diagnosticable { threadLatestReplyMessageStyle.hashCode ^ threadLatestReplyTimestampStyle.hashCode ^ threadLatestReplyTimestampFormatter.hashCode ^ + threadReplyCountStyle.hashCode ^ threadUnreadMessageCountStyle.hashCode ^ threadUnreadMessageCountBackgroundColor.hashCode; } diff --git a/packages/stream_chat_flutter/lib/src/theme/voice_attachment_theme.dart b/packages/stream_chat_flutter/lib/src/theme/voice_attachment_theme.dart deleted file mode 100644 index db1ed7db65..0000000000 --- a/packages/stream_chat_flutter/lib/src/theme/voice_attachment_theme.dart +++ /dev/null @@ -1,466 +0,0 @@ -import 'package:flutter/foundation.dart'; -import 'package:flutter/material.dart'; -import 'package:stream_chat_flutter/stream_chat_flutter.dart'; - -/// {@template StreamVoiceRecordingThemeData} -/// The theme data for the voice recording attachment builder. -/// {@endtemplate} -@Deprecated("Use 'StreamVoiceRecordingAttachmentThemeData' instead.") -class StreamVoiceRecordingThemeData with Diagnosticable { - /// {@macro StreamVoiceRecordingThemeData} - const StreamVoiceRecordingThemeData({ - required this.loadingTheme, - required this.sliderTheme, - required this.listPlayerTheme, - required this.playerTheme, - }); - - /// {@template ThemeDataLight} - /// Creates a theme data with light values. - /// {@endtemplate} - factory StreamVoiceRecordingThemeData.light() { - return StreamVoiceRecordingThemeData( - loadingTheme: StreamVoiceRecordingLoadingThemeData.light(), - sliderTheme: StreamVoiceRecordingSliderTheme.light(), - listPlayerTheme: StreamVoiceRecordingListPlayerThemeData.light(), - playerTheme: StreamVoiceRecordingPlayerThemeData.light(), - ); - } - - /// {@template ThemeDataDark} - /// Creates a theme data with dark values. - /// {@endtemplate} - factory StreamVoiceRecordingThemeData.dark() { - return StreamVoiceRecordingThemeData( - loadingTheme: StreamVoiceRecordingLoadingThemeData.dark(), - sliderTheme: StreamVoiceRecordingSliderTheme.dark(), - listPlayerTheme: StreamVoiceRecordingListPlayerThemeData.dark(), - playerTheme: StreamVoiceRecordingPlayerThemeData.dark(), - ); - } - - /// The theme for the loading widget. - final StreamVoiceRecordingLoadingThemeData loadingTheme; - - /// The theme for the slider widget. - final StreamVoiceRecordingSliderTheme sliderTheme; - - /// The theme for the list player widget. - final StreamVoiceRecordingListPlayerThemeData listPlayerTheme; - - /// The theme for the player widget. - final StreamVoiceRecordingPlayerThemeData playerTheme; - - /// {@template ThemeDataMerge} - /// Used to merge the values of another theme data object into this. - /// {@endtemplate} - StreamVoiceRecordingThemeData merge(StreamVoiceRecordingThemeData? other) { - if (other == null) return this; - return StreamVoiceRecordingThemeData( - loadingTheme: loadingTheme.merge(other.loadingTheme), - sliderTheme: sliderTheme.merge(other.sliderTheme), - listPlayerTheme: listPlayerTheme.merge(other.listPlayerTheme), - playerTheme: playerTheme.merge(other.playerTheme), - ); - } - - @override - void debugFillProperties(DiagnosticPropertiesBuilder properties) { - super.debugFillProperties(properties); - properties - ..add(DiagnosticsProperty('loadingTheme', loadingTheme)) - ..add(DiagnosticsProperty('sliderTheme', sliderTheme)) - ..add(DiagnosticsProperty('listPlayerTheme', listPlayerTheme)) - ..add(DiagnosticsProperty('playerTheme', playerTheme)); - } -} - -/// {@template StreamAudioPlayerLoadingTheme} -/// The theme data for the voice recording attachment builder -/// loading widget [StreamVoiceRecordingLoading]. -/// {@endtemplate} -@Deprecated("Use 'StreamVoiceRecordingAttachmentThemeData' instead.") -class StreamVoiceRecordingLoadingThemeData with Diagnosticable { - /// {@macro StreamAudioPlayerLoadingTheme} - const StreamVoiceRecordingLoadingThemeData({ - this.size, - this.strokeWidth, - this.color, - this.padding, - }); - - /// {@macro ThemeDataLight} - factory StreamVoiceRecordingLoadingThemeData.light() { - return const StreamVoiceRecordingLoadingThemeData( - size: Size(20, 20), - strokeWidth: 2, - color: Color(0xFF005FFF), - padding: EdgeInsets.all(8), - ); - } - - /// {@macro ThemeDataDark} - factory StreamVoiceRecordingLoadingThemeData.dark() { - return const StreamVoiceRecordingLoadingThemeData( - size: Size(20, 20), - strokeWidth: 2, - color: Color(0xFF005FFF), - padding: EdgeInsets.all(8), - ); - } - - /// The size of the loading indicator. - final Size? size; - - /// The stroke width of the loading indicator. - final double? strokeWidth; - - /// The color of the loading indicator. - final Color? color; - - /// The padding around the loading indicator. - final EdgeInsets? padding; - - /// {@macro ThemeDataMerge} - StreamVoiceRecordingLoadingThemeData merge( - StreamVoiceRecordingLoadingThemeData? other) { - if (other == null) return this; - return StreamVoiceRecordingLoadingThemeData( - size: other.size, - strokeWidth: other.strokeWidth, - color: other.color, - padding: other.padding, - ); - } - - @override - void debugFillProperties(DiagnosticPropertiesBuilder properties) { - super.debugFillProperties(properties); - properties - ..add(DiagnosticsProperty('size', size)) - ..add(DiagnosticsProperty('strokeWidth', strokeWidth)) - ..add(ColorProperty('color', color)) - ..add(DiagnosticsProperty('padding', padding)); - } -} - -/// {@template StreamAudioPlayerSliderTheme} -/// The theme data for the voice recording attachment builder audio player -/// slider [StreamVoiceRecordingSlider]. -/// {@endtemplate} -@Deprecated("Use 'StreamVoiceRecordingAttachmentThemeData' instead.") -class StreamVoiceRecordingSliderTheme with Diagnosticable { - /// {@macro StreamAudioPlayerSliderTheme} - const StreamVoiceRecordingSliderTheme({ - this.horizontalPadding = 10, - this.spacingRatio = 0.007, - this.waveHeightRatio = 1, - this.buttonBorderRadius = const BorderRadius.all(Radius.circular(8)), - this.buttonColor, - this.buttonBorderColor, - this.buttonBorderWidth = 1, - this.waveColorPlayed, - this.waveColorUnplayed, - this.buttonShadow = const BoxShadow( - color: Color(0x33000000), - blurRadius: 4, - offset: Offset(0, 2), - ), - }); - - /// {@macro ThemeDataLight} - factory StreamVoiceRecordingSliderTheme.light() { - return const StreamVoiceRecordingSliderTheme( - buttonColor: Color(0xFFFFFFFF), - buttonBorderColor: Color(0x3308070733), - waveColorPlayed: Color(0xFF005DFF), - waveColorUnplayed: Color(0xFF7E828B), - ); - } - - /// {@macro ThemeDataDark} - factory StreamVoiceRecordingSliderTheme.dark() { - return const StreamVoiceRecordingSliderTheme( - buttonColor: Color(0xFF005FFF), - buttonBorderColor: Color(0x3308070766), - waveColorPlayed: Color(0xFF337EFF), - waveColorUnplayed: Color(0xFF7E828B), - ); - } - - /// The color of the slider button. - final Color? buttonColor; - - /// The color of the border of the slider button. - final Color? buttonBorderColor; - - /// The width of the border of the slider button. - final double? buttonBorderWidth; - - /// The shadow of the slider button. - final BoxShadow? buttonShadow; - - /// The border radius of the slider button. - final BorderRadius buttonBorderRadius; - - /// The horizontal padding of the slider. - final double horizontalPadding; - - /// Spacing ratios. This is the percentage that the space takes from the whole - /// available space. Typically this value should be between 0.003 to 0.02. - /// Default = 0.01 - final double spacingRatio; - - /// The percentage maximum value of waves. This can be used to reduce the - /// height of bars. Default = 1; - final double waveHeightRatio; - - /// Color of the waves to the left side of the slider button. - final Color? waveColorPlayed; - - /// Color of the waves to the right side of the slider button. - final Color? waveColorUnplayed; - - /// {@macro ThemeDataMerge} - StreamVoiceRecordingSliderTheme merge( - StreamVoiceRecordingSliderTheme? other) { - if (other == null) return this; - return StreamVoiceRecordingSliderTheme( - buttonColor: other.buttonColor, - buttonBorderColor: other.buttonBorderColor, - buttonBorderRadius: other.buttonBorderRadius, - horizontalPadding: other.horizontalPadding, - spacingRatio: other.spacingRatio, - waveHeightRatio: other.waveHeightRatio, - waveColorPlayed: other.waveColorPlayed, - waveColorUnplayed: other.waveColorUnplayed, - ); - } - - @override - void debugFillProperties(DiagnosticPropertiesBuilder properties) { - super.debugFillProperties(properties); - properties - ..add(ColorProperty('buttonColor', buttonColor)) - ..add(ColorProperty('buttonBorderColor', buttonBorderColor)) - ..add(DiagnosticsProperty('buttonBorderRadius', buttonBorderRadius)) - ..add(DoubleProperty('horizontalPadding', horizontalPadding)) - ..add(DoubleProperty('spacingRatio', spacingRatio)) - ..add(DoubleProperty('waveHeightRatio', waveHeightRatio)) - ..add(ColorProperty('waveColorPlayed', waveColorPlayed)) - ..add(ColorProperty('waveColorUnplayed', waveColorUnplayed)); - } -} - -/// {@template StreamAudioListPlayerTheme} -/// The theme data for the voice recording attachment builder audio player -/// [StreamVoiceRecordingListPlayer]. -/// {@endtemplate} -@Deprecated("Use 'StreamVoiceRecordingAttachmentThemeData' instead.") -class StreamVoiceRecordingListPlayerThemeData with Diagnosticable { - /// {@macro StreamAudioListPlayerTheme} - const StreamVoiceRecordingListPlayerThemeData({ - this.backgroundColor, - this.borderColor, - this.borderRadius, - this.margin, - }); - - /// {@macro ThemeDataLight} - factory StreamVoiceRecordingListPlayerThemeData.light() { - return StreamVoiceRecordingListPlayerThemeData( - backgroundColor: const Color(0xFFFFFFFF), - borderColor: const Color(0xFFDBDDE1), - borderRadius: BorderRadius.circular(14), - margin: const EdgeInsets.all(4), - ); - } - - /// {@macro ThemeDataDark} - factory StreamVoiceRecordingListPlayerThemeData.dark() { - return StreamVoiceRecordingListPlayerThemeData( - backgroundColor: const Color(0xFF17191C), - borderColor: const Color(0xFF272A30), - borderRadius: BorderRadius.circular(14), - margin: const EdgeInsets.all(4), - ); - } - - /// The background color of the list. - final Color? backgroundColor; - - /// The border color of the list. - final Color? borderColor; - - /// The border radius of the list. - final BorderRadius? borderRadius; - - /// The margin of the list. - final EdgeInsets? margin; - - /// {@macro ThemeDataMerge} - StreamVoiceRecordingListPlayerThemeData merge( - StreamVoiceRecordingListPlayerThemeData? other) { - if (other == null) return this; - return StreamVoiceRecordingListPlayerThemeData( - backgroundColor: other.backgroundColor, - borderColor: other.borderColor, - borderRadius: other.borderRadius, - margin: other.margin, - ); - } - - @override - void debugFillProperties(DiagnosticPropertiesBuilder properties) { - super.debugFillProperties(properties); - properties - ..add(ColorProperty('backgroundColor', backgroundColor)) - ..add(ColorProperty('borderColor', borderColor)) - ..add(DiagnosticsProperty('borderRadius', borderRadius)) - ..add(DiagnosticsProperty('margin', margin)); - } -} - -/// {@template StreamVoiceRecordingPlayerTheme} -/// The theme data for the voice recording attachment builder audio player -/// {@endtemplate} -@Deprecated("Use 'StreamVoiceRecordingAttachmentThemeData' instead.") -class StreamVoiceRecordingPlayerThemeData with Diagnosticable { - /// {@macro StreamVoiceRecordingPlayerTheme} - const StreamVoiceRecordingPlayerThemeData({ - this.playIcon = Icons.play_arrow, - this.pauseIcon = Icons.pause, - this.iconColor, - this.buttonBackgroundColor, - this.buttonPadding = const EdgeInsets.symmetric(horizontal: 6), - this.buttonShape = const CircleBorder(), - this.buttonElevation = 2, - this.speedButtonSize = const Size(44, 36), - this.speedButtonElevation = 2, - this.speedButtonPadding = const EdgeInsets.symmetric(horizontal: 8), - this.speedButtonBackgroundColor = const Color(0xFFFFFFFF), - this.speedButtonShape = const RoundedRectangleBorder( - borderRadius: BorderRadius.all(Radius.circular(50)), - ), - this.speedButtonTextStyle = const TextStyle( - fontSize: 12, - color: Color(0xFF080707), - ), - this.fileTypeIcon = const StreamSvgIcon( - icon: StreamSvgIcons.filetypeAudioAac, - ), - this.fileSizeTextStyle = const TextStyle(fontSize: 10), - this.timerTextStyle, - }); - - /// {@macro ThemeDataLight} - factory StreamVoiceRecordingPlayerThemeData.light() { - return const StreamVoiceRecordingPlayerThemeData( - iconColor: Color(0xFF080707), - buttonBackgroundColor: Color(0xFFFFFFFF), - ); - } - - /// {@macro ThemeDataDark} - factory StreamVoiceRecordingPlayerThemeData.dark() { - return const StreamVoiceRecordingPlayerThemeData( - iconColor: Color(0xFF080707), - buttonBackgroundColor: Color(0xFFFFFFFF), - ); - } - - /// The icon to display when the player is paused/stopped. - final IconData playIcon; - - /// The icon to display when the player is playing. - final IconData pauseIcon; - - /// The color of the icons. - final Color? iconColor; - - /// The background color of the buttons. - final Color? buttonBackgroundColor; - - /// The padding of the buttons. - final EdgeInsets? buttonPadding; - - /// The shape of the buttons. - final OutlinedBorder? buttonShape; - - /// The elevation of the buttons. - final double? buttonElevation; - - /// The size of the speed button. - final Size? speedButtonSize; - - /// The elevation of the speed button. - final double? speedButtonElevation; - - /// The padding of the speed button. - final EdgeInsets? speedButtonPadding; - - /// The background color of the speed button. - final Color? speedButtonBackgroundColor; - - /// The shape of the speed button. - final OutlinedBorder? speedButtonShape; - - /// The text style of the speed button. - final TextStyle? speedButtonTextStyle; - - /// The icon to display for the file type. - final Widget? fileTypeIcon; - - /// The text style of the file size. - final TextStyle? fileSizeTextStyle; - - /// The text style of the timer. - final TextStyle? timerTextStyle; - - /// {@macro ThemeDataMerge} - StreamVoiceRecordingPlayerThemeData merge( - StreamVoiceRecordingPlayerThemeData? other) { - if (other == null) return this; - return StreamVoiceRecordingPlayerThemeData( - playIcon: other.playIcon, - pauseIcon: other.pauseIcon, - iconColor: other.iconColor, - buttonBackgroundColor: other.buttonBackgroundColor, - buttonPadding: other.buttonPadding, - buttonShape: other.buttonShape, - buttonElevation: other.buttonElevation, - speedButtonSize: other.speedButtonSize, - speedButtonElevation: other.speedButtonElevation, - speedButtonPadding: other.speedButtonPadding, - speedButtonBackgroundColor: other.speedButtonBackgroundColor, - speedButtonShape: other.speedButtonShape, - speedButtonTextStyle: other.speedButtonTextStyle, - fileTypeIcon: other.fileTypeIcon, - fileSizeTextStyle: other.fileSizeTextStyle, - timerTextStyle: other.timerTextStyle, - ); - } - - @override - void debugFillProperties(DiagnosticPropertiesBuilder properties) { - super.debugFillProperties(properties); - properties - ..add(DiagnosticsProperty('playIcon', playIcon)) - ..add(DiagnosticsProperty('pauseIcon', pauseIcon)) - ..add(ColorProperty('iconColor', iconColor)) - ..add(ColorProperty('buttonBackgroundColor', buttonBackgroundColor)) - ..add(DiagnosticsProperty('buttonPadding', buttonPadding)) - ..add(DiagnosticsProperty('buttonShape', buttonShape)) - ..add(DoubleProperty('buttonElevation', buttonElevation)) - ..add(DiagnosticsProperty('speedButtonSize', speedButtonSize)) - ..add(DoubleProperty('speedButtonElevation', speedButtonElevation)) - ..add(DiagnosticsProperty('speedButtonPadding', speedButtonPadding)) - ..add(ColorProperty( - 'speedButtonBackgroundColor', speedButtonBackgroundColor)) - ..add(DiagnosticsProperty('speedButtonShape', speedButtonShape)) - ..add(DiagnosticsProperty('speedButtonTextStyle', speedButtonTextStyle)) - ..add(DiagnosticsProperty('fileTypeIcon', fileTypeIcon)) - ..add(DiagnosticsProperty('fileSizeTextStyle', fileSizeTextStyle)) - ..add(DiagnosticsProperty('timerTextStyle', timerTextStyle)); - } -} diff --git a/packages/stream_chat_flutter/lib/src/theme/voice_recording_attachment_theme.dart b/packages/stream_chat_flutter/lib/src/theme/voice_recording_attachment_theme.dart index 6103156c04..e5aff52079 100644 --- a/packages/stream_chat_flutter/lib/src/theme/voice_recording_attachment_theme.dart +++ b/packages/stream_chat_flutter/lib/src/theme/voice_recording_attachment_theme.dart @@ -1,198 +1,150 @@ -import 'package:flutter/foundation.dart'; -import 'package:flutter/material.dart'; -import 'package:stream_chat_flutter/src/theme/audio_waveform_slider_theme.dart'; +import 'package:flutter/widgets.dart'; import 'package:stream_chat_flutter/src/theme/stream_chat_theme.dart'; +import 'package:stream_core_flutter/stream_core_flutter.dart'; +import 'package:theme_extensions_builder_annotation/theme_extensions_builder_annotation.dart'; -/// {@template streamVoiceRecordingAttachmentTheme} -/// Overrides the default style of [StreamVoiceRecordingAttachment] descendants. +part 'voice_recording_attachment_theme.g.theme.dart'; + +/// Applies a voice recording attachment theme to descendant +/// [StreamVoiceRecordingAttachment] widgets. +/// +/// Wrap a subtree with [StreamVoiceRecordingAttachmentTheme] to override +/// voice recording styling. Access the merged theme using +/// [StreamVoiceRecordingAttachmentTheme.of]. +/// +/// {@tool snippet} +/// +/// Override voice recording styling for a specific section: +/// +/// ```dart +/// StreamVoiceRecordingAttachmentTheme( +/// data: StreamVoiceRecordingAttachmentThemeData( +/// durationTextStyle: TextStyle(fontWeight: FontWeight.w700), +/// activeDurationTextStyle: TextStyle(color: Colors.blue), +/// ), +/// child: StreamVoiceRecordingAttachment( +/// track: track, +/// speed: speed, +/// ), +/// ) +/// ``` +/// {@end-tool} /// /// See also: /// -/// * [StreamVoiceRecordingAttachmentThemeData], which is used to configure -/// this theme. -/// {@endtemplate} +/// * [StreamVoiceRecordingAttachmentThemeData], which describes the voice +/// recording attachment theme. +/// * [StreamVoiceRecordingAttachment], the widget affected by this theme. class StreamVoiceRecordingAttachmentTheme extends InheritedTheme { - /// Creates a [StreamVoiceRecordingAttachmentTheme]. - /// - /// The [data] parameter must not be null. + /// Creates a voice recording attachment theme that controls descendant + /// widgets. const StreamVoiceRecordingAttachmentTheme({ super.key, required this.data, required super.child, }); - /// The configuration of this theme. + /// The voice recording attachment theme data for descendant widgets. final StreamVoiceRecordingAttachmentThemeData data; - /// The closest instance of this class that encloses the given context. + /// Returns the [StreamVoiceRecordingAttachmentThemeData] merged from local + /// and global themes. /// - /// If there is no enclosing [StreamVoiceRecordingAttachmentTheme] widget, - /// then [StreamVoiceRecordingAttachmentTheme.voiceRecordingTheme] is used. + /// Local values from the nearest [StreamVoiceRecordingAttachmentTheme] + /// ancestor take precedence over global values from [StreamChatTheme.of]. /// - /// Typical usage is as follows: - /// - /// ```dart - /// StreamVoiceRecordingAttachmentTheme theme = - /// StreamVoiceRecordingAttachmentTheme.of(context); - /// ``` + /// This allows partial overrides - for example, overriding only + /// [StreamVoiceRecordingAttachmentThemeData.durationTextStyle] while + /// inheriting other properties from the global theme. static StreamVoiceRecordingAttachmentThemeData of(BuildContext context) { - final voiceRecordingTheme = context.dependOnInheritedWidgetOfExactType< - StreamVoiceRecordingAttachmentTheme>(); - return voiceRecordingTheme?.data ?? - StreamChatTheme.of(context).voiceRecordingAttachmentTheme; + final localTheme = context.dependOnInheritedWidgetOfExactType(); + return StreamChatTheme.of(context).voiceRecordingAttachmentTheme.merge(localTheme?.data); } @override - Widget wrap(BuildContext context, Widget child) => - StreamVoiceRecordingAttachmentTheme(data: data, child: child); + Widget wrap(BuildContext context, Widget child) => StreamVoiceRecordingAttachmentTheme(data: data, child: child); @override - bool updateShouldNotify(StreamVoiceRecordingAttachmentTheme oldWidget) => - data != oldWidget.data; + bool updateShouldNotify(StreamVoiceRecordingAttachmentTheme oldWidget) => data != oldWidget.data; } -/// {@template streamVoiceRecordingAttachmentThemeData} -/// A style that overrides the default appearance of -/// [StreamVoiceRecordingAttachment] widgets when used with -/// [StreamVoiceRecordingAttachmentTheme] or with the overall -/// [StreamChatTheme]'s [StreamChatThemeData.voiceRecordingAttachmentTheme]. -/// {@endtemplate} -class StreamVoiceRecordingAttachmentThemeData with Diagnosticable { - /// {@macro streamVoiceRecordingAttachmentThemeData} +/// Theme data for customizing [StreamVoiceRecordingAttachment] widgets. +/// +/// {@tool snippet} +/// +/// Customize voice recording attachment appearance globally: +/// +/// ```dart +/// StreamChatThemeData( +/// voiceRecordingAttachmentTheme: StreamVoiceRecordingAttachmentThemeData( +/// durationTextStyle: TextStyle(fontWeight: FontWeight.w700), +/// speedToggleStyle: StreamPlaybackSpeedToggleStyle.from( +/// borderColor: Colors.grey, +/// ), +/// ), +/// ) +/// ``` +/// {@end-tool} +/// +/// See also: +/// +/// * [StreamVoiceRecordingAttachment], the widget that uses this theme data. +/// * [StreamVoiceRecordingAttachmentTheme], for overriding theme in a widget +/// subtree. +@themeGen +@immutable +class StreamVoiceRecordingAttachmentThemeData with _$StreamVoiceRecordingAttachmentThemeData { + /// Creates voice recording attachment theme data with optional style + /// overrides. const StreamVoiceRecordingAttachmentThemeData({ - this.backgroundColor, - this.playIcon, - this.pauseIcon, - this.loadingIndicator, - this.audioControlButtonStyle, this.titleTextStyle, this.durationTextStyle, - this.speedControlButtonStyle, - this.audioWaveformSliderTheme, + this.activeDurationTextStyle, + this.controlButtonStyle, + this.speedToggleStyle, + this.waveformStyle, }); - /// The background color of the attachment. - final Color? backgroundColor; - - /// The icon widget to show when the recording is playing. - final Widget? playIcon; - - /// The icon widget to show when the recording is paused. - final Widget? pauseIcon; - - /// The widget to show when the recording is loading. - final Widget? loadingIndicator; - - /// The style for the audio control button. - final ButtonStyle? audioControlButtonStyle; - - /// The text style for the title. + /// The text style for the audio file title. + /// + /// If null, defaults to [StreamTextTheme.metadataEmphasis]. final TextStyle? titleTextStyle; - /// The text style for the duration. + /// The text style for the duration label in default/idle state. + /// + /// If null, defaults to [StreamTextTheme.metadataEmphasis] with + /// [StreamColorScheme.textPrimary] color. final TextStyle? durationTextStyle; - /// The style for the speed control button. - final ButtonStyle? speedControlButtonStyle; - - /// The theme for the audio waveform slider. - final StreamAudioWaveformSliderThemeData? audioWaveformSliderTheme; + /// The text style for the duration label when actively playing. + /// + /// If null, defaults to [StreamTextTheme.metadataEmphasis] with + /// [StreamColorScheme.accentPrimary] color. + final TextStyle? activeDurationTextStyle; - /// A copy of [StreamVoiceRecordingAttachmentThemeData] with specified - /// attributes overridden. - StreamVoiceRecordingAttachmentThemeData copyWith({ - Color? backgroundColor, - Widget? playIcon, - Widget? pauseIcon, - Widget? loadingIndicator, - ButtonStyle? audioControlButtonStyle, - TextStyle? titleTextStyle, - TextStyle? durationTextStyle, - ButtonStyle? speedControlButtonStyle, - StreamAudioWaveformSliderThemeData? audioWaveformSliderTheme, - }) => - StreamVoiceRecordingAttachmentThemeData( - backgroundColor: backgroundColor ?? this.backgroundColor, - playIcon: playIcon ?? this.playIcon, - pauseIcon: pauseIcon ?? this.pauseIcon, - loadingIndicator: loadingIndicator ?? this.loadingIndicator, - audioControlButtonStyle: - audioControlButtonStyle ?? this.audioControlButtonStyle, - titleTextStyle: titleTextStyle ?? this.titleTextStyle, - durationTextStyle: durationTextStyle ?? this.durationTextStyle, - speedControlButtonStyle: - speedControlButtonStyle ?? this.speedControlButtonStyle, - audioWaveformSliderTheme: - audioWaveformSliderTheme ?? this.audioWaveformSliderTheme, - ); + /// The visual styling for the play/pause/replay control button. + /// + /// If null, defaults to secondary outline [StreamButton] defaults with + /// chat-specific border color. + final StreamButtonThemeStyle? controlButtonStyle; - /// Merges this [StreamVoiceRecordingAttachmentThemeData] with the [other]. - StreamVoiceRecordingAttachmentThemeData merge( - StreamVoiceRecordingAttachmentThemeData? other, - ) { - if (other == null) return this; - return copyWith( - backgroundColor: other.backgroundColor, - playIcon: other.playIcon, - pauseIcon: other.pauseIcon, - loadingIndicator: other.loadingIndicator, - audioControlButtonStyle: other.audioControlButtonStyle, - titleTextStyle: other.titleTextStyle, - durationTextStyle: other.durationTextStyle, - speedControlButtonStyle: other.speedControlButtonStyle, - audioWaveformSliderTheme: audioWaveformSliderTheme?.merge( - other.audioWaveformSliderTheme, - ), - ); - } + /// The visual styling for the [StreamPlaybackSpeedToggle] (x1, x2, x0.5). + /// + /// If null, defaults to [StreamPlaybackSpeedToggle] defaults with + /// chat-specific border color and disabled state styling. + final StreamPlaybackSpeedToggleStyle? speedToggleStyle; - /// Linearly interpolate between two [StreamVoiceRecordingAttachmentThemeData] - /// objects. - static StreamVoiceRecordingAttachmentThemeData lerp( - StreamVoiceRecordingAttachmentThemeData a, - StreamVoiceRecordingAttachmentThemeData b, + /// The theme overrides for the waveform visualization. + /// + /// Chat-specific waveform colors for idle bars, playing bars, and thumb. + /// If null, defaults to [StreamAudioWaveformTheme] defaults. + final StreamAudioWaveformThemeData? waveformStyle; + + /// Linearly interpolate between two + /// [StreamVoiceRecordingAttachmentThemeData] objects. + static StreamVoiceRecordingAttachmentThemeData? lerp( + StreamVoiceRecordingAttachmentThemeData? a, + StreamVoiceRecordingAttachmentThemeData? b, double t, - ) { - return StreamVoiceRecordingAttachmentThemeData( - backgroundColor: Color.lerp(a.backgroundColor, b.backgroundColor, t), - playIcon: t < 0.5 ? a.playIcon : b.playIcon, - pauseIcon: t < 0.5 ? a.pauseIcon : b.pauseIcon, - loadingIndicator: t < 0.5 ? a.loadingIndicator : b.loadingIndicator, - audioControlButtonStyle: ButtonStyle.lerp( - a.audioControlButtonStyle, b.audioControlButtonStyle, t), - titleTextStyle: TextStyle.lerp(a.titleTextStyle, b.titleTextStyle, t), - durationTextStyle: - TextStyle.lerp(a.durationTextStyle, b.durationTextStyle, t), - speedControlButtonStyle: ButtonStyle.lerp( - a.speedControlButtonStyle, b.speedControlButtonStyle, t), - audioWaveformSliderTheme: StreamAudioWaveformSliderThemeData.lerp( - a.audioWaveformSliderTheme!, b.audioWaveformSliderTheme!, t), - ); - } - - @override - bool operator ==(Object other) => - identical(this, other) || - other is StreamVoiceRecordingAttachmentThemeData && - other.backgroundColor == backgroundColor && - other.playIcon == playIcon && - other.pauseIcon == pauseIcon && - other.loadingIndicator == loadingIndicator && - other.audioControlButtonStyle == audioControlButtonStyle && - other.titleTextStyle == titleTextStyle && - other.durationTextStyle == durationTextStyle && - other.speedControlButtonStyle == speedControlButtonStyle && - other.audioWaveformSliderTheme == audioWaveformSliderTheme; - - @override - int get hashCode => - backgroundColor.hashCode ^ - playIcon.hashCode ^ - pauseIcon.hashCode ^ - loadingIndicator.hashCode ^ - audioControlButtonStyle.hashCode ^ - titleTextStyle.hashCode ^ - durationTextStyle.hashCode ^ - speedControlButtonStyle.hashCode ^ - audioWaveformSliderTheme.hashCode; + ) => _$StreamVoiceRecordingAttachmentThemeData.lerp(a, b, t); } diff --git a/packages/stream_chat_flutter/lib/src/theme/voice_recording_attachment_theme.g.theme.dart b/packages/stream_chat_flutter/lib/src/theme/voice_recording_attachment_theme.g.theme.dart new file mode 100644 index 0000000000..881b9d49f1 --- /dev/null +++ b/packages/stream_chat_flutter/lib/src/theme/voice_recording_attachment_theme.g.theme.dart @@ -0,0 +1,153 @@ +// dart format width=80 +// coverage:ignore-file +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint, unused_element + +part of 'voice_recording_attachment_theme.dart'; + +// ************************************************************************** +// ThemeGenGenerator +// ************************************************************************** + +mixin _$StreamVoiceRecordingAttachmentThemeData { + bool get canMerge => true; + + static StreamVoiceRecordingAttachmentThemeData? lerp( + StreamVoiceRecordingAttachmentThemeData? a, + StreamVoiceRecordingAttachmentThemeData? b, + double t, + ) { + if (identical(a, b)) { + return a; + } + + if (a == null) { + return t == 1.0 ? b : null; + } + + if (b == null) { + return t == 0.0 ? a : null; + } + + return StreamVoiceRecordingAttachmentThemeData( + titleTextStyle: TextStyle.lerp(a.titleTextStyle, b.titleTextStyle, t), + durationTextStyle: TextStyle.lerp( + a.durationTextStyle, + b.durationTextStyle, + t, + ), + activeDurationTextStyle: TextStyle.lerp( + a.activeDurationTextStyle, + b.activeDurationTextStyle, + t, + ), + controlButtonStyle: StreamButtonThemeStyle.lerp( + a.controlButtonStyle, + b.controlButtonStyle, + t, + ), + speedToggleStyle: StreamPlaybackSpeedToggleStyle.lerp( + a.speedToggleStyle, + b.speedToggleStyle, + t, + ), + waveformStyle: StreamAudioWaveformThemeData.lerp( + a.waveformStyle, + b.waveformStyle, + t, + ), + ); + } + + StreamVoiceRecordingAttachmentThemeData copyWith({ + TextStyle? titleTextStyle, + TextStyle? durationTextStyle, + TextStyle? activeDurationTextStyle, + StreamButtonThemeStyle? controlButtonStyle, + StreamPlaybackSpeedToggleStyle? speedToggleStyle, + StreamAudioWaveformThemeData? waveformStyle, + }) { + final _this = (this as StreamVoiceRecordingAttachmentThemeData); + + return StreamVoiceRecordingAttachmentThemeData( + titleTextStyle: titleTextStyle ?? _this.titleTextStyle, + durationTextStyle: durationTextStyle ?? _this.durationTextStyle, + activeDurationTextStyle: + activeDurationTextStyle ?? _this.activeDurationTextStyle, + controlButtonStyle: controlButtonStyle ?? _this.controlButtonStyle, + speedToggleStyle: speedToggleStyle ?? _this.speedToggleStyle, + waveformStyle: waveformStyle ?? _this.waveformStyle, + ); + } + + StreamVoiceRecordingAttachmentThemeData merge( + StreamVoiceRecordingAttachmentThemeData? other, + ) { + final _this = (this as StreamVoiceRecordingAttachmentThemeData); + + if (other == null || identical(_this, other)) { + return _this; + } + + if (!other.canMerge) { + return other; + } + + return copyWith( + titleTextStyle: + _this.titleTextStyle?.merge(other.titleTextStyle) ?? + other.titleTextStyle, + durationTextStyle: + _this.durationTextStyle?.merge(other.durationTextStyle) ?? + other.durationTextStyle, + activeDurationTextStyle: + _this.activeDurationTextStyle?.merge(other.activeDurationTextStyle) ?? + other.activeDurationTextStyle, + controlButtonStyle: + _this.controlButtonStyle?.merge(other.controlButtonStyle) ?? + other.controlButtonStyle, + speedToggleStyle: + _this.speedToggleStyle?.merge(other.speedToggleStyle) ?? + other.speedToggleStyle, + waveformStyle: + _this.waveformStyle?.merge(other.waveformStyle) ?? + other.waveformStyle, + ); + } + + @override + bool operator ==(Object other) { + if (identical(this, other)) { + return true; + } + + if (other.runtimeType != runtimeType) { + return false; + } + + final _this = (this as StreamVoiceRecordingAttachmentThemeData); + final _other = (other as StreamVoiceRecordingAttachmentThemeData); + + return _other.titleTextStyle == _this.titleTextStyle && + _other.durationTextStyle == _this.durationTextStyle && + _other.activeDurationTextStyle == _this.activeDurationTextStyle && + _other.controlButtonStyle == _this.controlButtonStyle && + _other.speedToggleStyle == _this.speedToggleStyle && + _other.waveformStyle == _this.waveformStyle; + } + + @override + int get hashCode { + final _this = (this as StreamVoiceRecordingAttachmentThemeData); + + return Object.hash( + runtimeType, + _this.titleTextStyle, + _this.durationTextStyle, + _this.activeDurationTextStyle, + _this.controlButtonStyle, + _this.speedToggleStyle, + _this.waveformStyle, + ); + } +} diff --git a/packages/stream_chat_flutter/lib/src/user/user_mention_tile.dart b/packages/stream_chat_flutter/lib/src/user/user_mention_tile.dart index f7f9ffa42d..03994220db 100644 --- a/packages/stream_chat_flutter/lib/src/user/user_mention_tile.dart +++ b/packages/stream_chat_flutter/lib/src/user/user_mention_tile.dart @@ -43,11 +43,7 @@ class StreamUserMentionTile extends StatelessWidget { const SizedBox( width: 16, ), - leading ?? - StreamUserAvatar( - user: user, - constraints: BoxConstraints.tight(const Size(40, 40)), - ), + leading ?? StreamUserAvatar(size: .lg, user: user), const SizedBox(width: 8), Expanded( child: Align( @@ -83,8 +79,8 @@ class StreamUserMentionTile extends StatelessWidget { right: 18, left: 8, ), - child: StreamSvgIcon( - icon: StreamSvgIcons.mentions, + child: Icon( + context.streamIcons.mention, color: chatThemeData.colorTheme.accentPrimary, ), ), diff --git a/packages/stream_chat_flutter/lib/src/utils/date_formatter.dart b/packages/stream_chat_flutter/lib/src/utils/date_formatter.dart index 1b41cf6d3f..e06a3825b2 100644 --- a/packages/stream_chat_flutter/lib/src/utils/date_formatter.dart +++ b/packages/stream_chat_flutter/lib/src/utils/date_formatter.dart @@ -3,10 +3,7 @@ import 'package:jiffy/jiffy.dart'; import 'package:stream_chat_flutter/src/utils/extensions.dart'; /// Represents a function type that formats a date. -typedef DateFormatter = String Function( - BuildContext context, - DateTime date, -); +typedef DateFormatter = String Function(BuildContext context, DateTime date); /// Formats the given [date] as a String. String formatDate(BuildContext context, DateTime date) { @@ -17,6 +14,56 @@ String formatDate(BuildContext context, DateTime date) { return Jiffy.parseFromDateTime(date).yMd; } +/// Output examples: +/// - `Just now` +/// - `Today at 9:41` +/// - `Yesterday at 9:41` +/// - `Saturday at 9:41` +/// - `Jan 1st at 9:41` +String formatRecentDateTime( + BuildContext context, + DateTime date, { + DateTime? referenceDate, +}) { + final localDate = date.toLocal(); + final now = (referenceDate ?? DateTime.now()).toLocal(); + final difference = now.difference(localDate).abs(); + + if (difference < const Duration(minutes: 1)) { + return context.translations.justNowLabel; + } + + final jiffyDate = Jiffy.parseFromDateTime(localDate); + final time = jiffyDate.format(pattern: 'H:mm'); + + if (_isSameDay(localDate, now)) { + return '${context.translations.todayLabel} at $time'; + } + + final yesterday = now.subtract(const Duration(days: 1)); + if (_isSameDay(localDate, yesterday)) { + return '${context.translations.yesterdayLabel} at $time'; + } + + if (_isWithinPreviousWeek(localDate, now)) { + return '${jiffyDate.EEEE} at $time'; + } + + return '${jiffyDate.format(pattern: 'MMM do')} at $time'; +} + +bool _isSameDay(DateTime a, DateTime b) { + final jiffyA = Jiffy.parseFromDateTime(a); + final jiffyB = Jiffy.parseFromDateTime(b); + return jiffyA.isSame(jiffyB, unit: Unit.day); +} + +bool _isWithinPreviousWeek(DateTime date, DateTime referenceDate) { + final jiffyDate = Jiffy.parseFromDateTime(date); + final jiffyReference = Jiffy.parseFromDateTime(referenceDate); + return jiffyDate.isAfter(jiffyReference.subtract(days: 7), unit: Unit.day); +} + /// Extension on [DateTime] to provide common date comparison utilities. extension DateTimeComparisonUtils on DateTime { /// Returns true if the date is today. diff --git a/packages/stream_chat_flutter/lib/src/utils/device_segmentation.dart b/packages/stream_chat_flutter/lib/src/utils/device_segmentation.dart index bd65820c5e..334e4cfe30 100644 --- a/packages/stream_chat_flutter/lib/src/utils/device_segmentation.dart +++ b/packages/stream_chat_flutter/lib/src/utils/device_segmentation.dart @@ -7,16 +7,12 @@ bool get isWeb => CurrentPlatform.isWeb; bool get isMobileDevice => CurrentPlatform.isIos || CurrentPlatform.isAndroid; /// Returns true if the app is running in a desktop device. -bool get isDesktopDevice => - CurrentPlatform.isMacOS || - CurrentPlatform.isWindows || - CurrentPlatform.isLinux; +bool get isDesktopDevice => CurrentPlatform.isMacOS || CurrentPlatform.isWindows || CurrentPlatform.isLinux; /// Returns true if the app is running on windows or linux platform. bool get isDesktopVideoPlayerSupported => // Dart VLC is not supported on MacOS. - !CurrentPlatform.isMacOS && - (CurrentPlatform.isWindows || CurrentPlatform.isLinux); + !CurrentPlatform.isMacOS && (CurrentPlatform.isWindows || CurrentPlatform.isLinux); /// Returns true if the app is running in a mobile or web. bool get isMobileDeviceOrWeb => isWeb || isMobileDevice; diff --git a/packages/stream_chat_flutter/lib/src/utils/extensions.dart b/packages/stream_chat_flutter/lib/src/utils/extensions.dart index 164a0ec053..ef4f0b3a70 100644 --- a/packages/stream_chat_flutter/lib/src/utils/extensions.dart +++ b/packages/stream_chat_flutter/lib/src/utils/extensions.dart @@ -23,8 +23,7 @@ extension IntExtension on int { if (this <= 0) return '0 B'; const suffixes = ['B', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB']; final i = (log(this) / log(_byteUnitConversionFactor)).floor(); - final numberValue = - (this / pow(_byteUnitConversionFactor, i)).toStringAsFixed(2); + final numberValue = (this / pow(_byteUnitConversionFactor, i)).toStringAsFixed(2); final suffix = suffixes[i]; return '$numberValue $suffix'; } @@ -44,14 +43,25 @@ extension DurationExtension on Duration { /// String extension extension StringExtension on String { /// Returns the capitalized string - String capitalize() => - isNotEmpty ? '${this[0].toUpperCase()}${substring(1).toLowerCase()}' : ''; + @Deprecated('Use sentenceCase instead') + String capitalize() => sentenceCase; + + /// Returns the string in sentence case. + /// + /// Example: 'hello WORLD' -> 'Hello world' + String get sentenceCase { + if (isEmpty) return this; + + final firstChar = this[0].toUpperCase(); + final restOfString = substring(1).toLowerCase(); + + return '$firstChar$restOfString'; + } /// Returns the biggest line of a text. String biggestLine() { if (contains('\n')) { - return split('\n') - .reduce((curr, next) => curr.length > next.length ? curr : next); + return split('\n').reduce((curr, next) => curr.length > next.length ? curr : next); } else { return this; } @@ -91,55 +101,15 @@ extension StringExtension on String { /// Levenshtein distance between this and [t]. int levenshteinDistance(String t) => levenshtein(this, t); - - /// Returns a resized imageUrl with the given [width], [height], [resize] - /// and [crop] if it is from Stream CDN or Dashboard. - /// - /// Read more at https://getstream.io/chat/docs/flutter-dart/file_uploads/?language=dart#image-resizing - String getResizedImageUrl({ - // TODO: Are these sizes optimal? Consider web/desktop - double width = 400, - double height = 400, - String /*clip|crop|scale|fill*/ resize = 'clip', - String /*center|top|bottom|left|right*/ crop = 'center', - }) { - final uri = Uri.parse(this); - final host = uri.host; - - final fromStreamCDN = host.endsWith('stream-io-cdn.com'); - final fromStreamDashboard = host.endsWith('stream-cloud-uploads.imgix.net'); - - if (!fromStreamCDN && !fromStreamDashboard) return this; - - final queryParameters = {...uri.queryParameters}; - - if (fromStreamCDN) { - if (queryParameters['h'].isNullOrMatches('*') && - queryParameters['w'].isNullOrMatches('*') && - queryParameters['crop'].isNullOrMatches('*') && - queryParameters['resize'].isNullOrMatches('*')) { - queryParameters['h'] = height.floor().toString(); - queryParameters['w'] = width.floor().toString(); - queryParameters['crop'] = crop; - queryParameters['resize'] = resize; - } - } else if (fromStreamDashboard) { - queryParameters['height'] = height.floor().toString(); - queryParameters['width'] = width.floor().toString(); - queryParameters['fit'] = crop; - } - - return uri.replace(queryParameters: queryParameters).toString(); - } } /// List extension extension IterableExtension on Iterable { /// Insert any item inBetween the list items List insertBetween(T item) => expand((e) sync* { - yield item; - yield e; - }).skip(1).toList(growable: false); + yield item; + yield e; + }).skip(1).toList(growable: false); } /// Useful extension for [PlatformFile] @@ -251,8 +221,7 @@ extension InputDecorationX on InputDecoration { suffixIconConstraints: other.suffixIconConstraints, counter: other.counter, counterText: other.counterText, - counterStyle: - counterStyle?.merge(other.counterStyle) ?? other.counterStyle, + counterStyle: counterStyle?.merge(other.counterStyle) ?? other.counterStyle, filled: other.filled, fillColor: other.fillColor, focusColor: other.focusColor, @@ -279,8 +248,7 @@ extension BuildContextX on BuildContext { /// Retrieves current translations according to locale /// Defaults to [DefaultTranslations] - Translations get translations => - StreamChatLocalizations.of(this) ?? DefaultTranslations.instance; + Translations get translations => StreamChatLocalizations.of(this) ?? DefaultTranslations.instance; } /// Extension on [BorderRadius] @@ -372,8 +340,7 @@ extension UserListX on List { final entries = matchingUsers.entries.toList(growable: false) ..sort((prev, curr) { bool containsQuery(User user) => - normalize(user.id).contains(normalizedQuery) || - normalize(user.name).contains(normalizedQuery); + normalize(user.id).contains(normalizedQuery) || normalize(user.name).contains(normalizedQuery); final containsInPrev = containsQuery(prev.key); final containsInCurr = containsQuery(curr.key); @@ -401,7 +368,7 @@ extension MessageX on Message { messageTextToRender = messageTextToRender?.replaceAll( RegExp('@(${RegExp.escape(userId)}|${RegExp.escape(userName)})'), - linkify ? '[@$userName]($userId)' : '@$userName', + linkify ? '[@$userName](mention:$userId)' : '@$userName', ); } @@ -413,8 +380,7 @@ extension MessageX on Message { var messageTextLength = min(text?.biggestLine().length ?? 0, 65); if (quotedMessage != null) { - var quotedMessageLength = - (min(quotedMessage!.text?.biggestLine().length ?? 0, 65)) + 8; + var quotedMessageLength = (min(quotedMessage!.text?.biggestLine().length ?? 0, 65)) + 8; if (quotedMessage!.attachments.isNotEmpty) { quotedMessageLength += 8; @@ -436,8 +402,7 @@ extension MessageX on Message { } /// It returns the message with the translated text if available locally - Message translate(String language) => - copyWith(text: i18n?['${language}_text'] ?? text); + Message translate(String language) => copyWith(text: i18n?['${language}_text'] ?? text); /// It returns the message replacing the mentioned user names with /// the respective user ids @@ -479,18 +444,12 @@ extension TypeX on T? { extension FileTypeX on FileType { /// Converts the [FileType] to a [String]. String toAttachmentType() { - switch (this) { - case FileType.image: - return AttachmentType.image; - case FileType.video: - return AttachmentType.video; - case FileType.audio: - return AttachmentType.audio; - case FileType.any: - case FileType.media: - case FileType.custom: - return AttachmentType.file; - } + return switch (this) { + FileType.image => AttachmentType.image, + FileType.video => AttachmentType.video, + FileType.audio => AttachmentType.audio, + FileType.any || FileType.media || FileType.custom => AttachmentType.file, + }; } } @@ -498,18 +457,16 @@ extension FileTypeX on FileType { extension AttachmentPickerTypeX on AttachmentPickerType { /// Converts the [AttachmentPickerType] to a [FileType]. FileType get fileType { - switch (this) { - case AttachmentPickerType.images: - return FileType.image; - case AttachmentPickerType.videos: - return FileType.video; - case AttachmentPickerType.files: - return FileType.any; - case AttachmentPickerType.audios: - return FileType.audio; - case AttachmentPickerType.poll: - throw Exception('Polls do not have a file type'); - } + return switch (this) { + ImagesPickerType() => FileType.image, + VideosPickerType() => FileType.video, + AudiosPickerType() => FileType.audio, + FilesPickerType() => FileType.any, + _ => throw Exception( + 'Unsupported AttachmentPickerType: $this. ' + 'Only Images, Videos, Audios and Files are supported.', + ), + }; } } @@ -576,24 +533,6 @@ extension MessageListX on Iterable { /// /// The [userRead] is the last read message by the user. /// - /// The last unread message is the last message in the list that is not - /// sent by the current user and is sent after the last read message. - @Deprecated("Use 'StreamChannel.getFirstUnreadMessage' instead.") - Message? lastUnreadMessage(Read? userRead) { - if (isEmpty || userRead == null) return null; - - if (first.createdAt.isAfter(userRead.lastRead) && - last.createdAt.isBefore(userRead.lastRead)) { - return lastWhereOrNull( - (it) => - it.user?.id != userRead.user.id && - it.id != userRead.lastReadMessageId && - it.createdAt.compareTo(userRead.lastRead) > 0, - ); - } - - return null; - } } /// Useful extensions on [ChannelModel]. @@ -619,16 +558,11 @@ extension ChannelModelX on ChannelModel { // Otherwise, we return the names of the first `maxMembers` members sorted // alphabetically, followed by the number of remaining members if there are // more than `maxMembers` members. - final memberNames = otherMembers - .map((it) => it.user?.name) - .whereType() - .take(maxMembers) - .sorted(); + final memberNames = otherMembers.map((it) => it.user?.name).whereType().take(maxMembers).sorted(); return switch (otherMembers.length <= maxMembers) { true => memberNames.join(', '), - false => - '${memberNames.join(', ')} + ${otherMembers.length - maxMembers}', + false => '${memberNames.join(', ')} + ${otherMembers.length - maxMembers}', }; } } @@ -644,7 +578,7 @@ extension VoiceRecordingAttachmentExtension on Attachment { final duration = extraData['duration'] as num?; if (duration == null) return Duration.zero; - return Duration(milliseconds: duration.round() * 1000); + return Duration(milliseconds: (duration * 1000).round()); } /// Returns the waveform data of the voice recording attachment if available @@ -657,6 +591,15 @@ extension VoiceRecordingAttachmentExtension on Attachment { } } +/// {@template singleAttachmentPlaylistExtension} +/// Extension on [Attachment] to provide the playlist specific +/// properties. +/// {@endtemplate} +extension SingleAttachmentPlaylistExtension on Attachment { + /// Converts the attachment to a list of [PlaylistTrack]. + List toPlaylist() => [this].toPlaylist(); +} + /// {@template attachmentPlaylistExtension} /// Extension on [Iterable] to provide the playlist specific /// properties. @@ -668,25 +611,25 @@ extension AttachmentPlaylistExtension on Iterable { ...map((it) { final uri = switch (it.uploadState) { Preparing() || InProgress() || Failed() => () { - if (CurrentPlatform.isWeb) { - final bytes = it.file?.bytes; - final mimeType = it.file?.mediaType?.mimeType; - if (bytes == null || mimeType == null) return null; + if (CurrentPlatform.isWeb) { + final bytes = it.file?.bytes; + final mimeType = it.file?.mediaType?.mimeType; + if (bytes == null || mimeType == null) return null; - return Uri.dataFromBytes(bytes, mimeType: mimeType); - } + return Uri.dataFromBytes(bytes, mimeType: mimeType); + } - final path = it.file?.path; - if (path == null) return null; + final path = it.file?.path; + if (path == null) return null; - return Uri.file(path, windows: CurrentPlatform.isWindows); - }(), + return Uri.file(path, windows: CurrentPlatform.isWindows); + }(), Success() => () { - final url = it.assetUrl; - if (url == null) return null; + final url = it.assetUrl; + if (url == null) return null; - return Uri.tryParse(url); - }(), + return Uri.tryParse(url); + }(), }; if (uri == null) return null; @@ -696,8 +639,42 @@ extension AttachmentPlaylistExtension on Iterable { title: it.title, waveform: it.waveform, duration: it.duration, + key: it, ); }).nonNulls, ]; } } + +/// Adapts an [Offset] for the current [TextDirection]. +extension OffsetDirectionalX on Offset { + /// Flips [dx] for RTL so a positive offset always means "toward trailing." + Offset directional([TextDirection? textDirection]) { + if (textDirection == null || textDirection == TextDirection.ltr) return this; + return Offset(-dx, dy); + } +} + +/// Extension to convert [AlignmentGeometry] to the corresponding +/// [CrossAxisAlignment]. +extension ColumnAlignmentExtension on AlignmentGeometry { + /// Converts an [AlignmentGeometry] to the most appropriate + /// [CrossAxisAlignment] value. + CrossAxisAlignment toColumnCrossAxisAlignment() { + final x = switch (this) { + Alignment(x: final x) => x, + AlignmentDirectional(start: final start) => start, + _ => null, + }; + + // If the alignment is unknown, fallback to the center alignment. + if (x == null) return CrossAxisAlignment.center; + + return switch (x) { + 0.0 => CrossAxisAlignment.center, + < 0 => CrossAxisAlignment.start, + > 0 => CrossAxisAlignment.end, + _ => CrossAxisAlignment.center, // fallback (in case of NaN etc) + }; + } +} diff --git a/packages/stream_chat_flutter/lib/src/utils/helpers.dart b/packages/stream_chat_flutter/lib/src/utils/helpers.dart index 1e732bc40d..86ece86773 100644 --- a/packages/stream_chat_flutter/lib/src/utils/helpers.dart +++ b/packages/stream_chat_flutter/lib/src/utils/helpers.dart @@ -113,10 +113,9 @@ Future showConfirmationBottomSheet( onPressed: () => Navigator.of(context).pop(false), style: TextButton.styleFrom( textStyle: chatThemeData.textTheme.bodyBold, - foregroundColor: - chatThemeData.colorTheme.textHighEmphasis - // ignore: deprecated_member_use - .withOpacity(0.5), + foregroundColor: chatThemeData.colorTheme.textHighEmphasis + // ignore: deprecated_member_use + .withOpacity(0.5), ), child: Text(cancelText), ), @@ -155,8 +154,7 @@ Future showInfoBottomSheet( }) { final chatThemeData = StreamChatTheme.of(context); return showModalBottomSheet( - backgroundColor: - theme?.colorTheme.barsBg ?? chatThemeData.colorTheme.barsBg, + backgroundColor: theme?.colorTheme.barsBg ?? chatThemeData.colorTheme.barsBg, context: context, shape: const RoundedRectangleBorder( borderRadius: BorderRadius.only( @@ -177,8 +175,7 @@ Future showInfoBottomSheet( ), Text( title, - style: theme?.textTheme.headlineBold ?? - chatThemeData.textTheme.headlineBold, + style: theme?.textTheme.headlineBold ?? chatThemeData.textTheme.headlineBold, ), const SizedBox( height: 7, @@ -188,10 +185,9 @@ Future showInfoBottomSheet( height: 36, ), Container( - // ignore: deprecated_member_use - color: theme?.colorTheme.textHighEmphasis.withOpacity(0.08) ?? - // ignore: deprecated_member_use - chatThemeData.colorTheme.textHighEmphasis.withOpacity(0.08), + color: + theme?.colorTheme.textHighEmphasis.withValues(alpha: 0.08) ?? + chatThemeData.colorTheme.textHighEmphasis.withValues(alpha: 0.08), height: 1, ), Center( @@ -203,8 +199,7 @@ Future showInfoBottomSheet( okText, style: TextStyle( // ignore: deprecated_member_use - color: theme?.colorTheme.textHighEmphasis.withOpacity(0.5) ?? - chatThemeData.colorTheme.accentPrimary, + color: theme?.colorTheme.textHighEmphasis.withOpacity(0.5) ?? chatThemeData.colorTheme.accentPrimary, fontWeight: FontWeight.w400, ), ), @@ -217,8 +212,7 @@ Future showInfoBottomSheet( } /// Get random png with initials -String getRandomPicUrl(User user) => - 'https://getstream.io/random_png/?id=${user.id}&name=${user.name}'; +String getRandomPicUrl(User user) => 'https://getstream.io/random_png/?id=${user.id}&name=${user.name}'; /// Get websiteName from [hostName] String? getWebsiteName(String hostName) { @@ -308,8 +302,7 @@ String fileSize(dynamic size, [int round = 2]) { return '${(_size / divider / divider / divider).toStringAsFixed(round)} GB'; } - if (_size < divider * divider * divider * divider * divider && - _size % divider == 0) { + if (_size < divider * divider * divider * divider * divider && _size % divider == 0) { final num r = _size / divider / divider / divider / divider; return '${r.toStringAsFixed(0)} TB'; } @@ -319,8 +312,7 @@ String fileSize(dynamic size, [int round = 2]) { return '${r.toStringAsFixed(round)} TB'; } - if (_size < divider * divider * divider * divider * divider * divider && - _size % divider == 0) { + if (_size < divider * divider * divider * divider * divider * divider && _size % divider == 0) { final num r = _size / divider / divider / divider / divider / divider; return '${r.toStringAsFixed(0)} PB'; } else { @@ -329,64 +321,6 @@ String fileSize(dynamic size, [int round = 2]) { } } -// TODO: Use file extension instead of mime type to get the file type icon. -/// Returns a [StreamSvgIcon] based on the [mimeType] of the file. -StreamSvgIcon getFileTypeImage([String? mimeType]) { - return StreamSvgIcon( - size: 40, - icon: switch (mimeType) { - 'audio/mpeg' => StreamSvgIcons.filetypeAudioMp3, - 'audio/aac' => StreamSvgIcons.filetypeAudioAac, - 'audio/wav' || 'audio/x-wav' => StreamSvgIcons.filetypeAudioWav, - 'audio/flac' => StreamSvgIcons.filetypeAudioFlac, - 'audio/mp4' => StreamSvgIcons.filetypeAudioM4a, - 'audio/ogg' => StreamSvgIcons.filetypeAudioOgg, - 'audio/aiff' => StreamSvgIcons.filetypeAudioAiff, - 'audio/alac' => StreamSvgIcons.filetypeAudioAlac, - 'application/zip' => StreamSvgIcons.filetypeCompressionZip, - 'application/x-7z-compressed' => StreamSvgIcons.filetypeCompression7z, - 'application/x-arj' => StreamSvgIcons.filetypeCompressionArj, - 'application/vnd.debian.binary-package' => - StreamSvgIcons.filetypeCompressionDeb, - 'application/x-apple-diskimage' => StreamSvgIcons.filetypeCompressionPkg, - 'application/x-rar-compressed' => StreamSvgIcons.filetypeCompressionRar, - 'application/x-rpm' => StreamSvgIcons.filetypeCompressionRpm, - 'application/x-tar' => StreamSvgIcons.filetypeCodeTar, - 'application/x-compress' => StreamSvgIcons.filetypeCompressionZ, - 'application/vnd.ms-powerpoint' => StreamSvgIcons.filetypePresentationPpt, - 'application/vnd.openxmlformats-officedocument.presentationml.presentation' => - StreamSvgIcons.filetypePresentationPptx, - 'application/vnd.apple.keynote' => StreamSvgIcons.filetypePresentationKey, - 'application/vnd.oasis.opendocument.presentation' => - StreamSvgIcons.filetypePresentationOdp, - 'application/vnd.ms-excel' => StreamSvgIcons.filetypeSpreadsheetXls, - 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' => - StreamSvgIcons.filetypeSpreadsheetXlsx, - 'application/vnd.ms-excel.sheet.macroEnabled.12' => - StreamSvgIcons.filetypeSpreadsheetXlsm, - 'application/vnd.oasis.opendocument.spreadsheet' => - StreamSvgIcons.filetypeSpreadsheetOds, - 'application/msword' => StreamSvgIcons.filetypeTextDoc, - 'application/vnd.openxmlformats-officedocument.wordprocessingml.document' => - StreamSvgIcons.filetypeTextDocx, - 'application/vnd.oasis.opendocument.text' => - StreamSvgIcons.filetypeTextOdt, - 'text/plain' => StreamSvgIcons.filetypeTextTxt, - 'application/rtf' => StreamSvgIcons.filetypeTextRtf, - 'application/x-tex' => StreamSvgIcons.filetypeTextTex, - 'application/vnd.wordperfect' => StreamSvgIcons.filetypeTextWdp, - 'text/html' => StreamSvgIcons.filetypeCodeHtml, - 'text/csv' => StreamSvgIcons.filetypeCodeCsv, - 'application/xml' => StreamSvgIcons.filetypeCodeXml, - 'text/markdown' => StreamSvgIcons.filetypeCodeMd, - 'application/octet-stream' => StreamSvgIcons.filetypeOtherStandard, - 'application/pdf' => StreamSvgIcons.filetypeOtherPdf, - 'application/x-wiki' => StreamSvgIcons.filetypeOtherWkq, - _ => StreamSvgIcons.filetypeOtherStandard, - }, - ); -} - /// Wraps attachment widget with custom shape class WrapAttachmentWidget extends StatelessWidget { /// Builds a [WrapAttachmentWidget]. diff --git a/packages/stream_chat_flutter/lib/src/utils/message_preview_formatter.dart b/packages/stream_chat_flutter/lib/src/utils/message_preview_formatter.dart index 2ba4a6b726..a73d3debcd 100644 --- a/packages/stream_chat_flutter/lib/src/utils/message_preview_formatter.dart +++ b/packages/stream_chat_flutter/lib/src/utils/message_preview_formatter.dart @@ -1,19 +1,22 @@ import 'package:flutter/widgets.dart'; -import 'package:stream_chat_flutter/src/theme/stream_chat_theme.dart'; import 'package:stream_chat_flutter/src/utils/extensions.dart'; import 'package:stream_chat_flutter_core/stream_chat_flutter_core.dart'; +import 'package:stream_core_flutter/stream_core_flutter.dart'; -/// {@template messagePreviewFormatter} -/// Formats message previews for display. +/// Formats a [Message] or [DraftMessage] into a preview [TextSpan] suitable +/// for channel lists, quoted replies, and similar compact contexts. /// -/// This interface provides two main methods for formatting message previews: -/// [formatMessage] for regular messages and [formatDraftMessage] for drafts. +/// Implementations are responsible for producing a single [TextSpan] that +/// combines the message body, any attachment summary, and — when relevant — +/// the sender's name. The returned span can be rendered with a standard +/// [Text.rich] widget. /// -/// ## Default Implementation +/// The default implementation is [StreamMessagePreviewFormatter]; the unnamed +/// factory constructor returns an instance of it. /// -/// The factory constructor returns [StreamMessagePreviewFormatter], which -/// provides context-aware formatting based on message type, sender, and -/// channel configuration. +/// {@tool snippet} +/// +/// Format a message using the default implementation: /// /// ```dart /// final formatter = MessagePreviewFormatter(); @@ -24,10 +27,11 @@ import 'package:stream_chat_flutter_core/stream_chat_flutter_core.dart'; /// currentUser: currentUser, /// ); /// ``` +/// {@end-tool} /// -/// ## Configuration +/// {@tool snippet} /// -/// Set a custom formatter globally via [StreamChatConfigurationData]: +/// Install a custom formatter globally via [StreamChatConfigurationData]: /// /// ```dart /// StreamChat( @@ -38,127 +42,125 @@ import 'package:stream_chat_flutter_core/stream_chat_flutter_core.dart'; /// child: child, /// ); /// ``` +/// {@end-tool} /// -/// ## Custom Implementation +/// ## Customization /// -/// Extend [StreamMessagePreviewFormatter] to customize specific behaviors: +/// To change only part of the preview, extend [StreamMessagePreviewFormatter] +/// and override one of its `format*` methods — each one returns a [TextSpan] +/// fragment and is composed together by [formatMessage]. /// -/// ```dart -/// class CustomFormatter extends StreamMessagePreviewFormatter { -/// @override -/// String formatGroupMessage( -/// BuildContext context, -/// User? messageAuthor, -/// String messageText, -/// ) { -/// if (messageAuthor == null) return messageText; -/// return '${messageAuthor.name} says: $messageText'; -/// } -/// } -/// ``` -/// {@endtemplate} +/// See also: +/// +/// * [StreamMessagePreviewFormatter], the default implementation. +/// * [StreamChatConfigurationData.messagePreviewFormatter], which configures +/// the formatter used across the Stream Chat widget tree. abstract interface class MessagePreviewFormatter { /// Creates a [MessagePreviewFormatter]. /// /// Returns the default [StreamMessagePreviewFormatter] implementation. - factory MessagePreviewFormatter() { - return const StreamMessagePreviewFormatter(); - } + factory MessagePreviewFormatter() => const StreamMessagePreviewFormatter(); - /// A formatted message preview. + /// Formats [message] as a preview [TextSpan]. + /// + /// The output adapts to the message kind (regular, deleted, system, poll, + /// location) and to the surrounding [channel] — for example, group channels + /// prepend a bold "You:" or "FirstName:" prefix. Mentions in the message + /// text are automatically bolded. + /// + /// [showCaption] controls how attachment and location previews label + /// themselves: when `true` (the default) they display the message text; when + /// `false` (for example in quoted-reply previews) they fall back to a + /// type-based label such as "Photo", "2 Videos", or "Location". Pure text + /// messages always show their text. /// - /// Formats [message] based on its type, [channel], and [currentUser]. - /// Mentions are bolded. The [textStyle] applies to all text. + /// The returned span carries structural style only (bold mentions, bold + /// sender prefix, tinted deleted-message text). Base text color and font + /// should be applied by the caller via [Text.rich]'s `style` parameter or + /// an ambient [DefaultTextStyle]; inline icons pick up their color from the + /// ambient [IconTheme]. TextSpan formatMessage( BuildContext context, Message message, { ChannelModel? channel, User? currentUser, - TextStyle? textStyle, + bool showCaption = true, }); - /// A formatted draft message preview with highlighted prefix. + /// Formats [draftMessage] as a preview [TextSpan] with a highlighted + /// "Draft:" prefix. /// - /// Adds a bold, accent-colored "Draft:" prefix to [draftMessage]. - /// The [textStyle] applies to the message text. + /// The prefix is rendered in bold using the theme's accent color. The + /// draft body is formatted using the same rules as a regular message — + /// plain text, attachments, and polls all get a rich preview — with + /// mentions bolded automatically. + /// + /// [currentUser] is forwarded to body formatters that need viewer context + /// (for example, [formatPollMessage] to resolve the current user's vote). + /// + /// [showCaption] controls how attachment and location previews label + /// themselves: when `true` (the default) they display the draft text; + /// when `false` they fall back to a type-based label such as "Photo" or + /// "2 Videos". Pure text drafts always show their text. + /// + /// Like [formatMessage], the returned span carries only the structural + /// overrides the preview needs; the caller is responsible for the base + /// text style. TextSpan formatDraftMessage( BuildContext context, DraftMessage draftMessage, { - TextStyle? textStyle, + User? currentUser, + bool showCaption = true, }); } -/// {@template streamMessagePreviewFormatter} -/// Default implementation of [MessagePreviewFormatter]. +/// The default implementation of [MessagePreviewFormatter]. /// -/// This formatter applies context-aware formatting based on message type, -/// sender identity, and channel configuration. It handles various message -/// types including regular text, attachments, polls, system messages, and -/// deleted messages. +/// The preview is assembled in two layers, each produced by a separate +/// `format*` method so subclasses can override a specific piece without +/// reimplementing the rest: /// -/// ## Message Type Handling +/// * A body span for the message kind — [formatRegularMessage] (plain text +/// and/or attachments), [formatDeletedMessage], [formatSystemMessage], +/// [formatPollMessage], [formatLocationMessage], or [formatEmptyMessage]. +/// * A channel-context prefix — [formatCurrentUserMessage] ("You: "), +/// [formatGroupMessage] ("FirstName: "), or [formatDirectMessage] (no +/// prefix by default). /// -/// The formatter handles messages differently based on their type: +/// Mentions are applied to the body automatically and do not need to be +/// handled in overrides. /// -/// * **Deleted messages** - Shows "Message deleted" -/// * **System messages** - Shows the message text directly -/// * **Poll messages** - Shows poll emoji with voter/creator info -/// * **Regular messages** - Shows text with optional attachment previews +/// {@tool snippet} /// -/// ## Sender Context -/// -/// The formatting adapts based on who sent the message: -/// -/// * **Current user** - Adds "You:" prefix -/// * **Direct messages (1-on-1)** - No prefix -/// * **Group messages** - Adds sender name prefix -/// -/// ## Customization -/// -/// All formatting methods are marked [@protected] and can be overridden: +/// Customize only the "You:" prefix and poll rendering: /// /// ```dart /// class ShortFormatter extends StreamMessagePreviewFormatter { /// @override -/// String formatCurrentUserMessage(BuildContext context, String text) { -/// // Remove "You:" prefix for cleaner display. -/// return text; +/// TextSpan formatCurrentUserMessage( +/// BuildContext context, +/// TextSpan messageBody, +/// ) { +/// // Remove the "You:" prefix for cleaner display. +/// return messageBody; /// } /// /// @override -/// String formatPollMessage( +/// TextSpan formatPollMessage( /// BuildContext context, /// Poll poll, /// User? currentUser, /// ) { -/// // Always show just the poll name. -/// return poll.name.isEmpty ? '📊 Poll' : '📊 ${poll.name}'; +/// return TextSpan(text: poll.name.isEmpty ? 'Poll' : poll.name); /// } /// } /// ``` +/// {@end-tool} /// -/// ## Protected Methods -/// -/// These methods can be overridden for customization: +/// See also: /// -/// **Content Extraction:** -/// * [formatRegularMessage] - Extracts message content (text + attachments) -/// * [formatMessageAttachments] - Formats attachment previews -/// -/// **Message Types:** -/// * [formatDeletedMessage] - Formats deleted messages -/// * [formatSystemMessage] - Formats system messages -/// * [formatEmptyMessage] - Formats empty messages -/// * [formatPollMessage] - Formats poll messages -/// -/// **Sender Context:** -/// * [formatCurrentUserMessage] - Formats messages from current user -/// * [formatDirectMessage] - Formats messages in 1-on-1 channels -/// * [formatGroupMessage] - Formats messages in group channels -/// -/// **Draft Messages:** -/// * [getDraftPrefix] - Returns the draft message prefix text -/// {@endtemplate} +/// * [MessagePreviewFormatter], the public interface. +/// * [formatMessage], which composes the final preview span. class StreamMessagePreviewFormatter implements MessagePreviewFormatter { /// Creates a [StreamMessagePreviewFormatter]. const StreamMessagePreviewFormatter(); @@ -167,50 +169,38 @@ class StreamMessagePreviewFormatter implements MessagePreviewFormatter { TextSpan formatMessage( BuildContext context, Message message, { + bool showCaption = true, ChannelModel? channel, User? currentUser, - TextStyle? textStyle, }) { - final previewText = _buildPreviewText( + final content = _formatContent( context, message, - channel, - currentUser, + currentUser: currentUser, + showCaption: showCaption, ); - final mentionedUsers = message.mentionedUsers; - if (mentionedUsers.isEmpty) { - return TextSpan(text: previewText, style: textStyle); + if (channel == null) return content; + + if (message.user?.id == currentUser?.id) { + return formatCurrentUserMessage(context, content); } - final mentionedUsersRegex = RegExp( - mentionedUsers.map((it) => '@${RegExp.escape(it.name)}').join('|'), - ); + if (channel.memberCount > 2) { + return formatGroupMessage(context, message.user, content); + } - final children = [ - ...previewText.splitByRegExp(mentionedUsersRegex).map( - (text) { - if (mentionedUsers.any((it) => '@${it.name}' == text)) { - return TextSpan( - text: text, - style: textStyle?.copyWith(fontWeight: FontWeight.bold), - ); - } - - return TextSpan(text: text, style: textStyle); - }, - ) - ]; - - return TextSpan(children: children); + return formatDirectMessage(context, content); } - String _buildPreviewText( + // Dispatches to the `format*` method that matches the kind of [message]. + // Returns the bare body span, without the author prefix or mention pass. + TextSpan _formatContent( BuildContext context, - Message message, - ChannelModel? channel, + Message message, { User? currentUser, - ) { + bool showCaption = true, + }) { if (message.isDeleted) { return formatDeletedMessage(context, message); } @@ -223,302 +213,508 @@ class StreamMessagePreviewFormatter implements MessagePreviewFormatter { return formatPollMessage(context, poll, currentUser); } - final messagePreviewText = formatRegularMessage(context, message); - if (messagePreviewText == null) return formatEmptyMessage(context, message); - - if (channel == null) return messagePreviewText; - - if (message.user?.id == currentUser?.id) { - return formatCurrentUserMessage(context, messagePreviewText); + if (message.sharedLocation case final location?) { + return formatLocationMessage(context, message, location, showCaption: showCaption); } - if (channel.memberCount > 2) { - return formatGroupMessage(context, message.user, messagePreviewText); - } + final regular = formatRegularMessage(context, message, showCaption: showCaption); + if (regular != null) return regular; - return formatDirectMessage(context, messagePreviewText); + return formatEmptyMessage(context, message); } - /// The text content of a regular [message], including attachment previews. - /// - /// Extracts the message text and formats any attachments using - /// [formatMessageAttachments]. Returns `null` if the message has no text - /// or attachments. + /// Formats a regular [message] — plain text, attachments, or both — as a + /// preview [TextSpan]. /// - /// Override to customize how message content is extracted: + /// When the message has attachments, the message text is used as their + /// caption (subject to [showCaption]). Otherwise the plain message text is + /// shown on its own. /// - /// ```dart - /// @override - /// String? formatRegularMessage(BuildContext context, Message message) { - /// // Only show text, ignore attachments - /// return message.text; - /// } - /// ``` + /// Returns `null` when the message has neither text nor attachments. @protected - String? formatRegularMessage(BuildContext context, Message message) { - final messageText = switch (message.text?.trim()) { - final text? when text.isNotEmpty => text, - _ => null, - }; - + TextSpan? formatRegularMessage( + BuildContext context, + Message message, { + bool showCaption = true, + }) { + final messageText = message.text?.trim().nullIfEmpty; final attachments = message.attachments; - if (attachments.isEmpty) return messageText; - return formatMessageAttachments(context, messageText, message.attachments); + if (attachments.isNotEmpty) { + return formatMessageAttachments(context, messageText, attachments, showCaption: showCaption); + } + + if (messageText == null) return null; + return _labeledIcon(label: messageText); } - /// The preview text for a deleted [message]. + /// Formats a deleted [message] as a preview [TextSpan]. + /// + /// Shows a "no-sign" icon followed by the localized "Message deleted" + /// label, both tinted with the theme's tertiary text color to signal the + /// deleted state. The tint overrides the ambient text/icon color. @protected - String formatDeletedMessage(BuildContext context, Message message) { - return context.translations.messageDeletedLabel; + TextSpan formatDeletedMessage(BuildContext context, Message message) { + return _labeledIcon( + icon: context.streamIcons.noSign, + label: context.translations.messageDeletedLabel, + foregroundColor: context.streamColorScheme.textTertiary, + ); } - /// The preview text for a system [message]. + /// Formats a system [message] as a preview [TextSpan]. + /// + /// Uses the message text directly, or a localized fallback label when the + /// text is missing or empty. @protected - String formatSystemMessage(BuildContext context, Message message) { - if (message.text case final text? when text.isNotEmpty) return text; - return context.translations.systemMessageLabel; + TextSpan formatSystemMessage(BuildContext context, Message message) { + return TextSpan(text: message.text?.nullIfEmpty ?? context.translations.systemMessageLabel); } - /// The preview text for an empty [message]. + /// Formats an empty [message] — no text and no attachments — as a preview + /// [TextSpan]. + /// + /// Shows a localized "no-content" fallback label. @protected - String formatEmptyMessage(BuildContext context, Message message) { - return context.translations.emptyMessagePreviewText; + TextSpan formatEmptyMessage(BuildContext context, Message message) { + return TextSpan(text: context.translations.emptyMessagePreviewText); } - /// The formatted [messageText] with "You:" prefix for the current user. + /// Formats a regular message sent by the current user. /// - /// Override this to customize how messages from the current user are - /// displayed: + /// Prepends a bold, localized "You: " prefix to [messageBody] and returns + /// the combined span. Override to customize or remove the prefix: + /// + /// {@tool snippet} + /// + /// Remove the "You:" prefix entirely: /// /// ```dart /// @override - /// String formatCurrentUserMessage( + /// TextSpan formatCurrentUserMessage( /// BuildContext context, - /// String messageText, + /// TextSpan messageBody, /// ) { - /// return messageText; // Remove prefix + /// return messageBody; /// } /// ``` + /// {@end-tool} @protected - String formatCurrentUserMessage(BuildContext context, String messageText) { - return '${context.translations.youText}: $messageText'; + TextSpan formatCurrentUserMessage(BuildContext context, TextSpan messageBody) { + return TextSpan(children: [_boldSpan('${context.translations.youText}: '), messageBody]); } - /// The [messageText] without prefix for 1-on-1 channels. + /// Formats a regular message shown in a 1-on-1 channel. /// - /// No prefix is added since the other user's identity is clear from the - /// channel itself. Override to add context if needed: + /// No prefix is added by default — the other user's identity is clear from + /// the channel itself. Override to add context (for example a 💬 badge): + /// + /// {@tool snippet} /// /// ```dart /// @override - /// String formatDirectMessage(BuildContext context, String messageText) { - /// return '💬 $messageText'; + /// TextSpan formatDirectMessage( + /// BuildContext context, + /// TextSpan messageBody, + /// ) { + /// return TextSpan(children: [ + /// const TextSpan(text: '💬 '), + /// messageBody, + /// ]); /// } /// ``` + /// {@end-tool} @protected - String formatDirectMessage(BuildContext context, String messageText) { - return messageText; + TextSpan formatDirectMessage(BuildContext context, TextSpan messageBody) { + return messageBody; } - /// The formatted [messageText] with [messageAuthor] name prefix for groups. + /// Formats a regular message sent by another user in a group channel. + /// + /// Prepends a bold "FirstName: " prefix (the first word of + /// [messageAuthor]'s name) to [messageBody] and returns the combined span. + /// Returns [messageBody] unchanged when [messageAuthor] is `null` or has + /// no renderable name. /// - /// Adds the author's name as a prefix. Returns [messageText] without - /// prefix if [messageAuthor] is `null`. + /// Override to customize the prefix text: /// - /// Override to customize author name formatting: + /// {@tool snippet} /// /// ```dart /// @override - /// String formatGroupMessage( + /// TextSpan formatGroupMessage( /// BuildContext context, /// User? messageAuthor, - /// String messageText, + /// TextSpan messageBody, /// ) { - /// if (messageAuthor == null) return messageText; - /// return '${messageAuthor.name} says: $messageText'; + /// final authorName = messageAuthor?.name; + /// if (authorName == null || authorName.isEmpty) return messageBody; + /// return TextSpan(children: [ + /// TextSpan(text: '$authorName says: '), + /// messageBody, + /// ]); /// } /// ``` + /// {@end-tool} @protected - String formatGroupMessage( + TextSpan formatGroupMessage( BuildContext context, User? messageAuthor, - String messageText, + TextSpan messageBody, ) { - final authorName = messageAuthor?.name; - if (authorName == null || authorName.isEmpty) return messageText; + final authorName = messageAuthor?.name.trim().nullIfEmpty; + if (authorName == null) return formatDirectMessage(context, messageBody); - return '$authorName: $messageText'; + // Use only the first name to keep the prefix compact in narrow + // single-line preview rows. + final firstName = authorName.split(RegExp(r'\s+')).first; + return TextSpan(children: [_boldSpan('$firstName: '), messageBody]); } - /// The formatted preview for the first attachment in [attachments]. + /// Formats a list of [attachments] as a preview [TextSpan]. /// - /// Formats each attachment type with an emoji icon and title. The - /// [messageText] is used as fallback for certain types. Returns - /// [messageText] if no attachments are present or the type is unsupported. + /// Produces an "icon + label" preview, where the label prefers + /// [messageText] (when [showCaption] is `true`), then any + /// attachment-intrinsic text (filename, voice duration, link title), then + /// a localized type-based fallback ("Photo", "Video", "Audio", "File", + /// "Link", or pluralized counts for multi-attachment groups). /// - /// Supported types: Audio (🎧), File (📄), Image (📷), Video (📹), - /// Giphy (/giphy), and Voice Recording (🎤). + /// Grouping rules, summarized: /// - /// Override to handle custom attachment types: + /// * Single attachment → type-specific icon + caption, filename/title, or + /// a localized singular label ("Photo", "Video", "Audio", "File", + /// "Link"). + /// * Multiple same-type attachments → type-specific icon + pluralized + /// count ("2 Photos", "3 Videos", "4 files"). + /// * Mixed-type attachments → file icon + "N files". + /// * Voice recording → voice icon + "Voice Recording (mm:ss)" when no + /// caption is available. + /// * Giphy → file icon + caption, or "Giphy" when no caption is set. + /// * Unknown / custom types → unsupported-attachment icon + caption (if + /// any). /// - /// ```dart - /// @override - /// String? formatMessageAttachments( - /// BuildContext context, - /// String? messageText, - /// Iterable attachments, - /// ) { - /// final attachment = attachments.firstOrNull; - /// if (attachment?.type == 'product') { - /// return '🛍️ ${attachment?.extraData['title'] ?? "Product"}'; - /// } - /// return super.formatMessageAttachments( - /// context, - /// messageText, - /// attachments, - /// ); - /// } - /// ``` + /// Returns `null` when both [attachments] and [messageText] are empty. @protected - String? formatMessageAttachments( + TextSpan? formatMessageAttachments( BuildContext context, String? messageText, - Iterable attachments, - ) { - final translations = context.translations; - final attachment = attachments.firstOrNull; - if (attachment == null) return messageText; - - // If the message contains some attachments, we will show the first one - // and the text if it exists. - final attachmentIcon = switch (attachment.type) { - AttachmentType.audio => '🎧', - AttachmentType.file => '📄', - AttachmentType.image => '📷', - AttachmentType.video => '📹', - AttachmentType.giphy => '/giphy', - AttachmentType.voiceRecording => '🎤', - _ => null, - }; - - final attachmentTitle = switch (attachment.type) { - AttachmentType.audio => messageText ?? translations.audioAttachmentText, - AttachmentType.file => attachment.title ?? messageText, - AttachmentType.image => messageText ?? translations.imageAttachmentText, - AttachmentType.video => messageText ?? translations.videoAttachmentText, - AttachmentType.giphy => messageText, - AttachmentType.voiceRecording => translations.voiceRecordingText, - _ => null, - }; - - if (attachmentIcon != null || attachmentTitle != null) { - return [attachmentIcon, attachmentTitle].nonNulls.join(' '); + Iterable attachments, { + bool showCaption = true, + }) { + if (attachments.isEmpty) { + if (messageText == null) return null; + return _labeledIcon(label: messageText); } - return messageText; + final caption = showCaption ? messageText : null; + final preview = _resolveAttachmentPreview(context, attachments, caption); + + return _labeledIcon(icon: preview.icon, label: preview.label); } - /// The formatted preview for a [poll] message with voter or creator info. + /// Formats a [poll] message as a preview [TextSpan]. /// - /// Shows the latest voter and poll name if the poll has votes, otherwise - /// shows the creator and poll name. If the poll has no votes or creator, - /// shows just the poll name. Actions by [currentUser] show as "You", - /// while actions by other users show their name. + /// Shows the poll chart icon followed by the poll name. When the poll name + /// is empty, only the icon is shown. /// - /// Override to customize poll formatting: + /// [currentUser] is unused by the default implementation and provided for + /// overrides that want to render current-user context (for example, a + /// "You voted X" hint). + @protected + TextSpan formatPollMessage(BuildContext context, Poll poll, User? currentUser) { + return _labeledIcon(icon: context.streamIcons.poll, label: poll.name.trim()); + } + + /// Formats a shared [location] message as a preview [TextSpan]. /// - /// ```dart - /// @override - /// String formatPollMessage( - /// BuildContext context, - /// Poll poll, - /// User? currentUser, - /// ) { - /// return poll.name.isEmpty ? '📊 Poll' : '📊 ${poll.name}'; - /// } - /// ``` + /// Shows the map-pin icon followed by the [message] text when + /// [showCaption] is `true` (and text is available), or a localized + /// type-based fallback otherwise. Live locations and static locations use + /// distinct fallback labels. @protected - String formatPollMessage( + TextSpan formatLocationMessage( BuildContext context, - Poll poll, - User? currentUser, - ) { - final translations = context.translations; - - // If the poll already contains some votes, we will preview the latest voter - // and the poll name - if (poll.latestVotes.firstOrNull?.user case final latestVoter?) { - if (latestVoter.id == currentUser?.id) { - final youVoted = translations.pollYouVotedText; - return '📊 $youVoted: "${poll.name}"'; - } - - final someoneVoted = translations.pollSomeoneVotedText(latestVoter.name); - return '📊 $someoneVoted: "${poll.name}"'; - } - - // Otherwise, we will show the creator of the poll and the poll name - if (poll.createdBy case final creator?) { - if (creator.id == currentUser?.id) { - final youCreated = translations.pollYouCreatedText; - return '📊 $youCreated: "${poll.name}"'; - } - - final someoneCreated = translations.pollSomeoneCreatedText(creator.name); - return '📊 $someoneCreated: "${poll.name}"'; - } - - // Otherwise, we will show the poll name if it exists. - if (poll.name.trim() case final pollName when pollName.isNotEmpty) { - return '📊 $pollName'; - } + Message message, + Location location, { + bool showCaption = true, + }) { + final caption = showCaption ? message.text?.trim().nullIfEmpty : null; + final label = caption ?? context.translations.locationLabel(isLive: location.isLive); - // If nothing else, we will show the default poll emoji. - return '📊'; + return _labeledIcon(icon: context.streamIcons.location, label: label); } @override TextSpan formatDraftMessage( BuildContext context, DraftMessage draftMessage, { - TextStyle? textStyle, + User? currentUser, + bool showCaption = true, }) { - final theme = StreamChatTheme.of(context); - final colorTheme = theme.colorTheme; + final message = draftMessage.toMessage(); + + final content = _formatContent( + context, + message, + currentUser: currentUser, + showCaption: showCaption, + ); + + final prefix = _labeledIcon( + label: getDraftPrefix(context), + textStyle: const TextStyle(fontWeight: FontWeight.bold), + foregroundColor: context.streamColorScheme.accentPrimary, + ); return TextSpan( - text: getDraftPrefix(context), - style: textStyle?.copyWith( - fontWeight: FontWeight.bold, - color: colorTheme.accentPrimary, - ), children: [ - const TextSpan(text: ' '), // Space between prefix and message - TextSpan(text: draftMessage.text, style: textStyle), + prefix, + const TextSpan(text: ' '), + content, ], ); } - /// The draft message prefix text. + /// The text shown as the bold prefix of a draft preview. + /// + /// Defaults to the localized "Draft:" label. /// - /// Returns the localized "Draft" label. Override to customize the prefix: + /// {@tool snippet} + /// + /// Override to customize the prefix: /// /// ```dart /// @override - /// String getDraftPrefix(BuildContext context) { - /// return 'Unsent'; - /// } + /// String getDraftPrefix(BuildContext context) => 'Unsent:'; /// ``` + /// {@end-tool} @protected String getDraftPrefix(BuildContext context) { return '${context.translations.draftLabel}:'; } } +// --------------------------------------------------------------------------- +// Span primitives +// +// The preview is composed from three small helpers: +// +// * [_iconSpan] — an inline icon as a [WidgetSpan]. +// * [_labeledIcon] — the common "icon + space + label" pattern. +// * [_applyMentions] — a single post-pass that bolds "@Mention" fragments +// anywhere in the assembled span tree. +// +// Most `format*` methods above reduce to a single call to [_labeledIcon], and +// mention handling stays out of every individual formatter — it's applied +// once at the root by [formatMessage]. +// --------------------------------------------------------------------------- + +// Builds an "icon + space + label" [TextSpan]. The icon slot is dropped +// when [icon] is null; the text slot (and separator) is dropped when +// [label] is empty. +// +// [textStyle] sets typography; [foregroundColor] tints the label and +// overrides [textStyle.color]. The inline icon always renders at the +// resulting effective text color so text and icon stay in sync. +TextSpan _labeledIcon({ + IconData? icon, + required String label, + TextStyle? textStyle, + Color? foregroundColor, +}) { + final effectiveTextStyle = (textStyle ?? const TextStyle()).copyWith(color: foregroundColor); + + return TextSpan( + style: effectiveTextStyle, + children: [ + if (icon != null) ...[ + _iconSpan(icon, color: effectiveTextStyle.color), + if (label.isNotEmpty) const TextSpan(text: ' '), + ], + if (label.isNotEmpty) TextSpan(text: label), + ], + ); +} + +// Builds an inline icon [WidgetSpan] that vertically centers against the +// surrounding text. +// +// Uses [PlaceholderAlignment.middle] so the icon anchors to the line's +// vertical midline rather than a text baseline — this keeps icons visually +// aligned regardless of the icon font's intrinsic baseline, which rarely +// matches the body font. +WidgetSpan _iconSpan( + IconData icon, { + double size = 16, + Color? color, +}) { + return WidgetSpan( + alignment: PlaceholderAlignment.middle, + child: Icon(icon, size: size, color: color), + ); +} + +// Walks the assembled [span] tree once and bolds any text fragments that +// match a [mentionedUsers] entry (as "@UserName"). +// +// Kept as a single post-pass so individual `format*` methods don't need to +// know about mentions. [WidgetSpan]s (e.g. inline icons) and styled +// descendants pass through untouched; only plain text fragments are split +// and re-wrapped. +// ignore: unused_element +TextSpan _applyMentions(TextSpan span, List mentionedUsers) { + if (mentionedUsers.isEmpty) return span; + + final regex = RegExp(mentionedUsers.map((it) => '@${RegExp.escape(it.name)}').join('|')); + bool isMention(String s) => mentionedUsers.any((it) => '@${it.name}' == s); + + InlineSpan visit(InlineSpan node) { + if (node is! TextSpan) return node; + + final mappedChildren = node.children?.map(visit).toList(growable: false); + final text = node.text; + + if (text == null || text.isEmpty || !regex.hasMatch(text)) { + return TextSpan(text: text, style: node.style, children: mappedChildren); + } + + // Only the bolded mention fragments need an explicit style override; + // non-mention fragments inherit from [node.style] (and ancestors). + const boldStyle = TextStyle(fontWeight: FontWeight.bold); + return TextSpan( + style: node.style, + children: [ + for (final part in text.splitByRegExp(regex)) + if (isMention(part)) TextSpan(text: part, style: boldStyle) else TextSpan(text: part), + if (mappedChildren != null) ...mappedChildren, + ], + ); + } + + return visit(span) as TextSpan; +} + +// Builds a bold [TextSpan] wrapping [text]. Used for the "You:" / "FirstName:" +// sender prefix. +TextSpan _boldSpan(String text) => TextSpan( + text: text, + style: const TextStyle(fontWeight: FontWeight.bold), +); + +// --------------------------------------------------------------------------- +// Attachment presentation +// +// Resolving an attachment preview is split into three helpers: +// +// * [_resolveAttachmentPreview] — dispatcher: single vs multiple. +// * [_resolveSingleAttachmentPreview] — one attachment, one flat switch +// over [AttachmentType]. +// * [_resolveMultipleAttachmentsPreview] — 2+ attachments: same-type groups +// get a pluralized count, +// mixed-type groups fall back to +// the generic "N files" label. +// --------------------------------------------------------------------------- + +// A resolved attachment preview: the inline [icon] (null = no icon) and the +// text [label] to render next to it. +typedef _AttachmentPreview = ({IconData? icon, String label}); + +// Resolves the icon + label to show for a message with [attachments], +// optionally falling back to [caption] when provided. +// +// Thin dispatcher that picks between the single- and multi-attachment +// resolvers so `format*` callers don't need to branch on attachment count. +// Assumes [attachments] is non-empty. +_AttachmentPreview _resolveAttachmentPreview( + BuildContext context, + Iterable attachments, + String? caption, +) { + if (attachments.length > 1) { + return _resolveMultipleAttachmentsPreview(context, attachments, caption); + } + return _resolveSingleAttachmentPreview(context, attachments.first, caption); +} + +// Resolves the preview for a message with a single [attachment]. +// +// Produces a type-specific icon + label, preferring [caption], then any +// attachment-intrinsic text (filename, OG title, voice duration), and +// finally a localized type label ("Photo", "Video", "Audio", "File", "Link"). +_AttachmentPreview _resolveSingleAttachmentPreview( + BuildContext context, + Attachment attachment, + String? caption, +) { + final icons = context.streamIcons; + final translations = context.translations; + + return switch (attachment.type) { + // Giphy previews are branded — fall back to the literal "Giphy" label + // when no caption is available rather than a localized type name. + .giphy => (icon: icons.file, label: caption ?? 'Giphy'), + // Voice recordings embed the duration (mm:ss) in the label. + .voiceRecording => ( + icon: icons.voice, + label: caption ?? '${translations.voiceRecordingText} (${attachment.duration.toMinutesAndSeconds()})', + ), + .image => (icon: icons.camera, label: caption ?? translations.photosAttachmentCountText(1)), + .video => (icon: icons.video, label: caption ?? translations.videosAttachmentCountText(1)), + .file => (icon: icons.file, label: caption ?? attachment.title ?? translations.fileAttachmentText), + .audio => (icon: icons.voice, label: caption ?? translations.audioAttachmentText), + // Link previews are auto-generated from message text. Prefer the caption + // (original message text) or the OG-scraped title; fall back to "Link". + .urlPreview => (icon: icons.link, label: caption ?? attachment.title ?? translations.linkAttachmentText), + // Unknown / custom types: unsupported icon + caption (if any). + _ => (icon: icons.unsupportedAttachment, label: caption ?? ''), + }; +} + +// Resolves the preview for a message with two or more [attachments]. +// +// Same-type groups get a type-specific pluralized count ("2 Photos", +// "3 Videos", "4 files"). Mixed-type groups fall back to a generic file icon +// with "N files". Rarer same-type groups (audio, voice recording, giphy, +// link preview, custom) delegate to [_resolveSingleAttachmentPreview] of the +// first attachment — plural forms aren't defined for them. +// +// Assumes [attachments] has at least two entries. +_AttachmentPreview _resolveMultipleAttachmentsPreview( + BuildContext context, + Iterable attachments, + String? caption, +) { + final icons = context.streamIcons; + final translations = context.translations; + + final first = attachments.first; + final count = attachments.length; + + final hasMixedTypes = attachments.any((it) => it.type != first.type); + if (hasMixedTypes) return (icon: icons.file, label: caption ?? translations.filesAttachmentCountText(count)); + + return switch (first.type) { + .image => (icon: icons.camera, label: caption ?? translations.photosAttachmentCountText(count)), + .video => (icon: icons.video, label: caption ?? translations.videosAttachmentCountText(count)), + .file => (icon: icons.file, label: caption ?? translations.filesAttachmentCountText(count)), + _ => _resolveSingleAttachmentPreview(context, first, caption), + }; +} + +// Small [String] helpers used by the formatter. extension on String { + // Returns the string, or `null` when it is empty. Lets call sites collapse + // "missing or blank" into a single null-check. + String? get nullIfEmpty => isEmpty ? null : this; + + // Splits the string on every match of [regex], keeping the matched + // fragments in the result. + // + // Unlike [String.split], this preserves the delimiters as their own + // entries, so a mention-matching regex can split "Hi @Alice!" into + // `["Hi ", "@Alice", "!"]` — the caller can then style only the mention + // fragment. List splitByRegExp(RegExp regex) { - // If the pattern is empty, return the whole string if (regex.pattern.isEmpty) return [this]; final result = []; diff --git a/packages/stream_chat_flutter/lib/src/utils/stream_image_cdn.dart b/packages/stream_chat_flutter/lib/src/utils/stream_image_cdn.dart new file mode 100644 index 0000000000..2b9b569b78 --- /dev/null +++ b/packages/stream_chat_flutter/lib/src/utils/stream_image_cdn.dart @@ -0,0 +1,175 @@ +/// Resize mode for CDN image transformations. +/// +/// See the [Stream Image Resizing docs](https://getstream.io/chat/docs/flutter-dart/file_uploads/?language=dart#image-resizing) +/// for more information. +enum ResizeMode { + /// Resizes the image to fit within the given dimensions, preserving the + /// aspect ratio. The image may be smaller than the requested size. + clip('clip'), + + /// Resizes and crops the image to exactly fill the given dimensions. + crop('crop'), + + /// Stretches the image to exactly fill the given dimensions, + /// ignoring the aspect ratio. + scale('scale'), + + /// Resizes the image to fill the given dimensions, preserving the + /// aspect ratio. Parts of the image may be cropped. + fill('fill') + ; + + const ResizeMode(this.value); + + /// The raw string value used as a CDN query parameter. + final String value; +} + +/// Crop alignment for CDN image transformations. +/// +/// This determines which part of the image is preserved when cropping. +enum CropMode { + /// Crop from the center of the image. + center('center'), + + /// Crop from the top of the image. + top('top'), + + /// Crop from the bottom of the image. + bottom('bottom'), + + /// Crop from the left of the image. + left('left'), + + /// Crop from the right of the image. + right('right') + ; + + const CropMode(this.value); + + /// The raw string value used as a CDN query parameter. + final String value; +} + +/// Configuration for resizing an image via a CDN. +/// +/// When passed to [StreamImageCDN.resolveUrl], the CDN will resize the image +/// to the given [width] and [height] using the specified [mode] and [crop]. +class ImageResize { + /// Creates a new [ImageResize] configuration. + const ImageResize({ + required this.width, + required this.height, + this.mode = .clip, + this.crop = .center, + }); + + /// The target width in logical pixels. + final double width; + + /// The target height in logical pixels. + final double height; + + /// The resize mode to use. + /// + /// Defaults to [ResizeMode.clip]. + final ResizeMode mode; + + /// The crop alignment when the resize mode requires cropping. + /// + /// Defaults to [CropMode.center]. + final CropMode crop; +} + +/// Handles CDN URL resolution and cache key generation for Stream Chat images. +/// +/// The default implementation supports Stream's own CDN +/// (`stream-io-cdn.com`). +/// +/// To customize behavior for a custom CDN, extend this class and override +/// [resolveUrl] and/or [cacheKey]: +/// +/// ```dart +/// class MyImageCDN extends StreamImageCDN { +/// @override +/// String cacheKey(String imageUrl) { +/// // Custom cache key logic for your CDN. +/// return Uri.parse(imageUrl).path; +/// } +/// } +/// ``` +/// +/// Then inject it via [StreamChatConfigurationData]: +/// +/// ```dart +/// StreamChat( +/// client: client, +/// config: StreamChatConfigurationData( +/// imageCDN: MyImageCDN(), +/// ), +/// child: ..., +/// ) +/// ``` +class StreamImageCDN { + /// Creates a new [StreamImageCDN] instance. + const StreamImageCDN(); + + // The host suffix for Stream's image CDN. + static const _streamCDNHost = 'stream-io-cdn.com'; + + // Query parameter names that are preserved in cache keys. + // + // These are the image-transformation parameters that affect + // which rendition of the image is returned. All other parameters + // (e.g. signed URL tokens) are stripped. + static const _persistedParameters = {'w', 'h', 'resize', 'crop'}; + + /// Resolves the [sourceUrl] by appending resize/transform parameters + /// appropriate for the CDN. + /// + /// When [resize] is null, no resizing parameters are added and the + /// [sourceUrl] is returned unchanged. + /// + /// For non-Stream CDN URLs, returns [sourceUrl] unchanged regardless + /// of [resize]. + /// + /// Override this to customize URL rewriting for a custom CDN. + String resolveUrl(String sourceUrl, {ImageResize? resize}) { + final uri = Uri.tryParse(sourceUrl); + if (uri == null || !uri.host.contains(_streamCDNHost)) return sourceUrl; + if (resize == null) return sourceUrl; + + final queryParameters = { + ...uri.queryParameters, + 'w': resize.width == 0 ? '*' : resize.width.floor().toString(), + 'h': resize.height == 0 ? '*' : resize.height.floor().toString(), + 'resize': resize.mode.value, + 'ro': '0', + if (resize.mode == ResizeMode.crop) 'crop': resize.crop.value, + }; + + return uri.replace(queryParameters: queryParameters).toString(); + } + + /// Returns a stable cache key for [imageUrl], stripping volatile + /// authentication parameters (e.g. CloudFront signed URL tokens) + /// while preserving those that identify distinct image renditions. + /// + /// This uses an allowlist approach, keeping only the parameters in + /// [_persistedParameters] for Stream CDN URLs. + /// + /// For non-Stream CDN URLs, returns the full URL string unchanged. + /// + /// Override this to customize cache key generation for a custom CDN. + String cacheKey(String imageUrl) { + final uri = Uri.tryParse(imageUrl); + if (uri == null || !uri.host.contains(_streamCDNHost)) return imageUrl; + + final filteredParams = { + for (final MapEntry(:key, :value) in uri.queryParameters.entries) + if (_persistedParameters.contains(key)) key: value, + }; + + return uri.replace(queryParameters: filteredParams).toString(); + } +} diff --git a/packages/stream_chat_flutter/lib/src/utils/typedefs.dart b/packages/stream_chat_flutter/lib/src/utils/typedefs.dart index ab6a9d318a..a22555d899 100644 --- a/packages/stream_chat_flutter/lib/src/utils/typedefs.dart +++ b/packages/stream_chat_flutter/lib/src/utils/typedefs.dart @@ -1,5 +1,4 @@ import 'package:flutter/material.dart'; -import 'package:stream_chat_flutter/src/message_input/attachment_button.dart'; import 'package:stream_chat_flutter/src/message_input/command_button.dart'; import 'package:stream_chat_flutter/stream_chat_flutter.dart'; @@ -13,11 +12,6 @@ typedef InProgressBuilder = Widget Function(BuildContext, int, int); /// {@endtemplate} typedef FailedBuilder = Widget Function(BuildContext, String); -/// {@template successBuilder} -/// A widget builder for representing successful attachment uploads. -/// {@endtemplate} -typedef SuccessBuilder = WidgetBuilder; - /// {@template preparingBuilder} /// A widget builder for representing pre-upload attachment state. /// {@endtemplate} @@ -42,10 +36,11 @@ typedef ReplyMessageCallback = void Function(Message message); /// The action to perform when a specific image attachment in an [ImageGroup] /// is tapped or clicked. /// {@endtemplate} -typedef OnImageGroupAttachmentTap = void Function( - Message message, - Attachment attachment, -); +typedef OnImageGroupAttachmentTap = + void Function( + Message message, + Attachment attachment, + ); /// {@template onUserAvatarPress} /// The action to perform when a user's avatar is tapped, clicked, or @@ -68,11 +63,12 @@ typedef EditMessageInputBuilder = Widget Function(BuildContext, Message); /// {@template channelListHeaderTitleBuilder} /// A widget builder for custom [ChannelListHeader] title widgets. /// {@endtemplate} -typedef ChannelListHeaderTitleBuilder = Widget Function( - BuildContext context, - ConnectionStatus status, - StreamChatClient client, -); +typedef ChannelListHeaderTitleBuilder = + Widget Function( + BuildContext context, + ConnectionStatus status, + StreamChatClient client, + ); /// {@template channelTapCallback} /// The action to perform when a channel is tapped or clicked. @@ -97,71 +93,78 @@ typedef ViewInfoCallback = void Function(Channel); /// [defaultActionsModal] is the default [AttachmentActionsModal] configuration. /// Use [defaultActionsModal.copyWith] to easily customize it /// {@endtemplate} -typedef AttachmentActionsBuilder = Widget Function( - BuildContext context, - Attachment attachment, - AttachmentActionsModal defaultActionsModal, -); +typedef AttachmentActionsBuilder = + Widget Function( + BuildContext context, + Attachment attachment, + AttachmentActionsModal defaultActionsModal, + ); /// {@template errorListener} -/// A callback that can be passed to [StreamMessageInput.onError]. +/// A callback that can be passed to [StreamMessageComposer.onError]. /// /// This callback should not throw. /// /// It exists merely for error reporting, and should not be used otherwise. /// {@endtemplate} -typedef ErrorListener = void Function( - Object error, - StackTrace? stackTrace, -); +typedef ErrorListener = + void Function( + Object error, + StackTrace? stackTrace, + ); /// {@template attachmentLimitExceededListener} /// A callback that can be passed to -/// [StreamMessageInput.onAttachmentLimitExceed]. +/// [StreamMessageComposer.onAttachmentLimitExceed]. /// /// This callback should not throw. /// /// It exists merely for showing custom error, and should not be used otherwise. /// {@endtemplate} -typedef AttachmentLimitExceedListener = void Function( - int limit, - String error, -); +typedef AttachmentLimitExceedListener = + void Function( + int limit, + String error, + ); /// {@template attachmentThumbnailBuilder} /// A widget builder for representing attachment thumbnails. /// {@endtemplate} -typedef AttachmentThumbnailBuilder = Widget Function( - BuildContext, - Attachment, -); +typedef AttachmentThumbnailBuilder = + Widget Function( + BuildContext, + Attachment, + ); /// {@template mentionTileBuilder} /// A widget builder for representing a custom mention tile. /// {@endtemplate} -typedef MentionTileBuilder = Widget Function( - BuildContext context, - Member member, -); +typedef MentionTileBuilder = + Widget Function( + BuildContext context, + Member member, + ); /// {@template mentionTileOverlayBuilder} /// A widget builder for representing a custom mention tile within a /// [UserMentionsOverlay]. /// {@endtemplate} -typedef MentionTileOverlayBuilder = Widget Function( - BuildContext context, - User user, -); +typedef MentionTileOverlayBuilder = + Widget Function( + BuildContext context, + User user, + ); /// {@template userMentionTileBuilder} /// A builder function for representing a custom user mention tile. /// /// Use [UserMentionTile] for the default implementation. /// {@endtemplate} -typedef UserMentionTileBuilder = Widget Function( - BuildContext context, - User user, -); +typedef UserMentionTileBuilder = + Widget Function( + BuildContext context, + User user, + ); /// {@template actionButtonBuilder} /// A widget builder for building a custom command button. @@ -169,38 +172,30 @@ typedef UserMentionTileBuilder = Widget Function( /// [commandButton] is the default [CommandButton] configuration, /// use [commandButton.copyWith] to easily customize it. /// {@endtemplate} -typedef CommandButtonBuilder = Widget Function( - BuildContext context, - CommandButton commandButton, -); - -/// {@template actionButtonBuilder} -/// A widget builder for building a custom action button. -/// -/// [attachmentButton] is the default [AttachmentButton] configuration, -/// use [attachmentButton.copyWith] to easily customize it. -/// {@endtemplate} -typedef AttachmentButtonBuilder = Widget Function( - BuildContext context, - AttachmentButton attachmentButton, -); +typedef CommandButtonBuilder = + Widget Function( + BuildContext context, + CommandButton commandButton, + ); /// {@template quotedMessageAttachmentThumbnailBuilder} /// A widget builder for building a custom quoted message attachment thumbnail. /// {@endtemplate} -typedef QuotedMessageAttachmentThumbnailBuilder = Widget Function( - BuildContext, - Attachment, -); +typedef QuotedMessageAttachmentThumbnailBuilder = + Widget Function( + BuildContext, + Attachment, + ); /// {@template attachmentBuilder} /// A widget builder for representing attachments. /// {@endtemplate} -typedef AttachmentBuilder = Widget Function( - BuildContext, - Message, - List, -); +typedef AttachmentBuilder = + Widget Function( + BuildContext, + Message, + List, + ); /// {@template onQuotedMessageTap} /// The action to perform when a quoted message is tapped. @@ -237,59 +232,41 @@ typedef MessageSearchItemTapCallback = void Function(GetMessageResponse); /// {@template messageSearchItemBuilder} /// A widget builder used to create a custom [ListUserItem] from a [User]. /// {@endtemplate} -typedef MessageSearchItemBuilder = Widget Function( - BuildContext, - GetMessageResponse, -); +typedef MessageSearchItemBuilder = + Widget Function( + BuildContext, + GetMessageResponse, + ); -/// {@template messageBuilder} -/// A widget builder for creating custom message UI. -/// -/// [defaultMessageWidget] is the default [StreamMessageWidget] configuration. -/// Use [defaultMessageWidget.copyWith] to customize it. -/// {@endtemplate} -typedef MessageBuilder = Widget Function( - BuildContext, - MessageDetails, - List, - StreamMessageWidget defaultMessageWidget, -); - -/// {@template parentMessageBuilder} -/// A widget builder for creating custom parent message UI. -/// -/// [defaultMessageWidget] is the default [StreamMessageWidget] configuration. -/// Use [defaultMessageWidget.copyWith] to customize it. -/// {@endtemplate} -typedef ParentMessageBuilder = Widget Function( - BuildContext, - Message?, - StreamMessageWidget defaultMessageWidget, -); +// Legacy MessageBuilder and ParentMessageBuilder typedefs removed. +// Use StreamMessageItemBuilder from message_list_view.dart instead. /// {@template systemMessageBuilder} /// A widget builder for creating custom system messages. /// {@endtemplate} -typedef SystemMessageBuilder = Widget Function( - BuildContext, - Message, -); +typedef SystemMessageBuilder = + Widget Function( + BuildContext, + Message, + ); /// {@template ephemeralMessageBuilder} /// A widget builder for creating custom ephemeral messages. /// {@endtemplate} -typedef EphemeralMessageBuilder = Widget Function( - BuildContext, - Message, -); +typedef EphemeralMessageBuilder = + Widget Function( + BuildContext, + Message, + ); /// {@template moderatedMessageBuilder} /// A widget builder for creating custom moderated messages. /// {@endtemplate} -typedef ModeratedMessageBuilder = Widget Function( - BuildContext, - Message, -); +typedef ModeratedMessageBuilder = + Widget Function( + BuildContext, + Message, + ); /// {@template threadBuilder} /// A widget builder for creating custom thread UI. @@ -302,44 +279,53 @@ typedef ThreadBuilder = Widget Function(BuildContext context, Message? parent); typedef ThreadTapCallback = void Function(Message, Widget?); /// {@template spacingWidgetBuilder} -/// A widget builder for creating certain spacing after widgets. +/// Builds the spacing widget inserted between two adjacent messages on the +/// same calendar day in a [StreamMessageListView]. +/// +/// A list of [SpacingType] values describes why the gap exists — for example, +/// a sender change ([SpacingType.otherUser]), a time gap +/// ([SpacingType.timeDiff]), or messages within the same group +/// ([SpacingType.defaultSpacing]). /// -/// This spacing can be in the form of any widgets you like. +/// {@tool snippet} /// -/// A List of [SpacingType] is provided to help inform the decision of -/// what to build after the message (thread, difference in time between -/// current and last message, default spacing, etc). +/// Customise spacing per reason: /// -/// Example: /// ```dart -/// MessageListView( -/// spacingWidgetBuilder: (context, list) { -/// if(list.contains(SpacingType.defaultSpacing)) { -/// return SizedBox(height: 2.0,); -/// } else { -/// return SizedBox(height: 8.0,); +/// StreamMessageListView( +/// spacingWidgetBuilder: (context, spacingTypes) { +/// if (spacingTypes.contains(SpacingType.otherUser)) { +/// return const SizedBox(height: 16); +/// } else if (spacingTypes.contains(SpacingType.timeDiff)) { +/// return const SizedBox(height: 8); /// } +/// return const SizedBox(height: 2); /// }, -/// ), -/// ```dart +/// ) +/// ``` +/// {@end-tool} +/// +/// See also: +/// +/// * [SpacingType], which describes each possible reason for the gap. +/// * [StreamMessageListView.spacingWidgetBuilder], where this builder is +/// provided. /// {@endtemplate} -typedef SpacingWidgetBuilder = Widget Function( - BuildContext context, - List spacingTypes, -); +typedef SpacingWidgetBuilder = Widget Function(BuildContext context, List spacingTypes); /// {@template attachmentDownloader} /// A callback for downloading an attachment asset. /// {@endtemplate} /// Callback to download an attachment asset -typedef AttachmentDownloader = Future Function( - Attachment attachment, { - ProgressCallback? onReceiveProgress, - Map? queryParameters, - CancelToken? cancelToken, - bool deleteOnError, - Options? options, -}); +typedef AttachmentDownloader = + Future Function( + Attachment attachment, { + ProgressCallback? onReceiveProgress, + Map? queryParameters, + CancelToken? cancelToken, + bool deleteOnError, + Options? options, + }); /// Callback to receive the path once the attachment asset is downloaded typedef DownloadedPathCallback = void Function(String? path); @@ -365,11 +351,12 @@ typedef UserItemBuilder = Widget Function(BuildContext, User, bool); typedef OnScrollToBottom = Function(int unreadCount); /// Widget builder for widgets that may require data from the -/// [MessageInputController]. -typedef MessageRelatedBuilder = Widget Function( - BuildContext context, - StreamMessageInputController messageInputController, -); +/// [StreamMessageComposerController]. +typedef MessageRelatedBuilder = + Widget Function( + BuildContext context, + StreamMessageComposerController messageComposerController, + ); /// A function that returns true if the message is valid and can be sent. typedef MessageValidator = bool Function(Message message); diff --git a/packages/stream_chat_flutter/lib/src/video/video_thumbnail_image.dart b/packages/stream_chat_flutter/lib/src/video/video_thumbnail_image.dart index 21018113fc..8134b62191 100644 --- a/packages/stream_chat_flutter/lib/src/video/video_thumbnail_image.dart +++ b/packages/stream_chat_flutter/lib/src/video/video_thumbnail_image.dart @@ -41,8 +41,7 @@ import 'package:stream_chat_flutter/src/video/video_service.dart'; /// ``` /// {@end-tool} /// {@endtemplate} -class StreamVideoThumbnailImage - extends ImageProvider { +class StreamVideoThumbnailImage extends ImageProvider { /// {@macro video_thumbnail_image} const StreamVideoThumbnailImage({ required this.video, @@ -87,10 +86,9 @@ class StreamVideoThumbnailImage } @override - @Deprecated('Will get replaced by loadImage in the next major version.') - ImageStreamCompleter loadBuffer( + ImageStreamCompleter loadImage( StreamVideoThumbnailImage key, - DecoderBufferCallback decode, + ImageDecoderCallback decode, ) { return MultiFrameImageStreamCompleter( codec: _loadAsync(key, decode), @@ -103,10 +101,9 @@ class StreamVideoThumbnailImage ); } - @Deprecated('Will get replaced by loadImage in the next major version.') Future _loadAsync( StreamVideoThumbnailImage key, - DecoderBufferCallback decode, + ImageDecoderCallback decode, ) async { assert(key == this, '$key is not $this'); @@ -133,9 +130,7 @@ class StreamVideoThumbnailImage if (other.runtimeType != runtimeType) { return false; } - return other is StreamVideoThumbnailImage && - other.video == video && - other.scale == scale; + return other is StreamVideoThumbnailImage && other.video == video && other.scale == scale; } @override diff --git a/packages/stream_chat_flutter/lib/stream_chat_flutter.dart b/packages/stream_chat_flutter/lib/stream_chat_flutter.dart index 3d149312ea..396d7a0267 100644 --- a/packages/stream_chat_flutter/lib/stream_chat_flutter.dart +++ b/packages/stream_chat_flutter/lib/stream_chat_flutter.dart @@ -1,112 +1,239 @@ export 'package:jiffy/jiffy.dart'; -export 'package:photo_manager/photo_manager.dart' - show ThumbnailSize, ThumbnailFormat; +export 'package:photo_manager/photo_manager.dart' show ThumbnailSize, ThumbnailFormat; export 'package:stream_chat_flutter_core/stream_chat_flutter_core.dart'; +export 'package:stream_core_flutter/stream_core_flutter.dart' + show + StreamAppBar, + StreamAppBarProps, + StreamAppBarStyle, + StreamAppBarTheme, + StreamAppBarThemeData, + StreamAudioWaveformThemeData, + StreamAvatar, + StreamAvatarGroupSize, + StreamAvatarSize, + StreamAvatarStackSize, + StreamBottomAppBar, + StreamBottomAppBarStyle, + StreamBottomAppBarTheme, + StreamBottomAppBarThemeData, + StreamButton, + StreamButtonSize, + StreamButtonStyle, + StreamButtonType, + StreamButtonThemeStyle, + StreamBadgeCount, + StreamBadgeCountTheme, + StreamBadgeCountThemeData, + StreamBadgeNotification, + StreamBadgeNotificationTheme, + StreamBadgeNotificationThemeData, + StreamCheckbox, + StreamCheckboxSize, + StreamCheckboxStyle, + StreamColorScheme, + StreamColorSwatch, + StreamProgressBarStyle, + StreamPlaybackSpeedToggleStyle, + StreamAudioWaveformSlider, + StreamAudioWaveform, + StreamTheme, + StreamIcons, + StreamImageErrorPlaceholder, + StreamImageLoadingPlaceholder, + StreamImageSourceBadge, + StreamThemeExtension, + StreamComponentFactory, + StreamComponentBuilder, + StreamComponentBuilders, + StreamComponentBuilderExtension, + StreamContextMenu, + StreamContextMenuAction, + StreamContextMenuSeparator, + StreamEmoji, + StreamEmojiButton, + StreamEmojiChipBar, + StreamEmojiChipItem, + StreamEmojiContent, + StreamEmojiData, + StreamEmojiPickerSheet, + StreamEmojiSize, + StreamFileType, + StreamFileTypeIcon, + StreamFileTypeIconSize, + StreamImageEmoji, + StreamMessageAlignment, + StreamMessageLayout, + StreamMessageStackPosition, + StreamMessageChannelKind, + StreamNetworkImage, + StreamMessageListKind, + StreamMessageContentKind, + StreamMessageText, + StreamPlaybackSpeedToggle, + StreamPlaybackSpeed, + StreamReactionPicker, + StreamReactionPickerItem, + StreamReactionPickerProps, + StreamReactionPickerTheme, + StreamReactionPickerThemeData, + StreamReactionsPosition, + StreamReactionsType, + StreamListTile, + StreamListTileContainer, + StreamListTileTheme, + StreamListTileThemeData, + StreamLoadingSpinner, + StreamLoadingSpinnerSize, + StreamSheet, + StreamSheetDragHandle, + StreamSheetHeader, + StreamSheetHeaderStyle, + StreamSheetHeaderTheme, + StreamSheetHeaderThemeData, + StreamSheetRoute, + StreamSheetScrollableWidgetBuilder, + StreamSheetTheme, + StreamSheetThemeData, + StreamSheetTransition, + StreamSwitch, + StreamStepper, + StreamStepperProps, + StreamStepperStyle, + StreamStepperTheme, + StreamStepperThemeData, + StreamTextInputStyle, + StreamTextInputTheme, + StreamTextInput, + StreamTextInputThemeData, + StreamSwitchStyle, + StreamSwitchTheme, + StreamSwitchThemeData, + StreamUnicodeEmoji, + StreamVideoPlayIndicator, + StreamVideoPlayIndicatorSize, + StreamMessageAttachment, + StreamMessageAttachmentStyle, + StreamMediaBadge, + MediaBadgeType, + MediaBadgeDurationFormat, + StreamMediaViewer, + StreamMediaViewerTheme, + StreamMediaViewerThemeData, + StreamMessageItemTheme, + StreamMessageItemThemeData, + StreamMessageLayoutProperty, + StreamMessageLayoutVisibility, + StreamVisibility, + StreamColors, + kStreamToolbarHeight, + showStreamSheet, + streamSupportedEmojis; export 'src/ai_assistant/ai_typing_indicator_view.dart'; export 'src/ai_assistant/stream_typewriter_builder.dart'; export 'src/ai_assistant/streaming_message_view.dart'; export 'src/attachment/attachment.dart'; export 'src/attachment/builder/attachment_widget_builder.dart'; -export 'src/attachment/builder/voice_recording_attachment_builder/stream_voice_recording_list_player.dart'; -export 'src/attachment/builder/voice_recording_attachment_builder/stream_voice_recording_loading.dart'; -export 'src/attachment/builder/voice_recording_attachment_builder/stream_voice_recording_player.dart'; export 'src/attachment/gallery_attachment.dart'; export 'src/attachment/handler/stream_attachment_handler.dart'; export 'src/attachment/image_attachment.dart'; -export 'src/attachment/stream_attachment_package.dart'; -export 'src/attachment/thumbnail/file_attachment_thumbnail.dart'; +export 'src/attachment/link_preview_attachment.dart'; export 'src/attachment/thumbnail/giphy_attachment_thumbnail.dart'; export 'src/attachment/thumbnail/image_attachment_thumbnail.dart'; export 'src/attachment/thumbnail/media_attachment_thumbnail.dart'; export 'src/attachment/thumbnail/thumbnail_error.dart'; export 'src/attachment/thumbnail/video_attachment_thumbnail.dart'; -export 'src/attachment/url_attachment.dart'; export 'src/attachment/video_attachment.dart'; export 'src/attachment/voice_recording_attachment_playlist.dart'; export 'src/attachment_actions_modal/attachment_actions_modal.dart'; export 'src/autocomplete/stream_autocomplete.dart'; export 'src/avatars/gradient_avatar.dart'; -export 'src/avatars/group_avatar.dart'; -export 'src/avatars/user_avatar.dart'; -export 'src/bottom_sheets/attachment_modal_sheet.dart'; -export 'src/bottom_sheets/edit_message_sheet.dart'; -export 'src/bottom_sheets/error_alert_sheet.dart'; -export 'src/bottom_sheets/stream_channel_info_bottom_sheet.dart'; export 'src/channel/channel_header.dart'; export 'src/channel/channel_info.dart'; export 'src/channel/channel_list_header.dart'; export 'src/channel/channel_name.dart'; -export 'src/channel/stream_channel_avatar.dart'; export 'src/channel/stream_channel_name.dart'; export 'src/channel/stream_draft_message_preview_text.dart'; export 'src/channel/stream_message_preview_text.dart'; -export 'src/fullscreen_media/full_screen_media.dart'; -export 'src/fullscreen_media/full_screen_media_builder.dart'; -export 'src/gallery/gallery_footer.dart'; -export 'src/gallery/gallery_header.dart'; +// region SDK Design Refresh Components +export 'src/components/avatar/stream_channel_avatar.dart'; +export 'src/components/avatar/stream_user_avatar.dart'; +export 'src/components/avatar/stream_user_avatar_group.dart'; +export 'src/components/avatar/stream_user_avatar_stack.dart'; +export 'src/components/message_composer/message_composer.dart'; +export 'src/components/stream_chat_component_builders.dart'; +// endregion export 'src/icons/stream_svg_icon.dart'; export 'src/indicators/sending_indicator.dart'; export 'src/indicators/typing_indicator.dart'; export 'src/indicators/unread_indicator.dart'; -export 'src/indicators/upload_progress_indicator.dart'; export 'src/keyboard_shortcuts/keyboard_shortcut_runner.dart'; export 'src/localization/stream_chat_localizations.dart'; export 'src/localization/translations.dart' show DefaultTranslations; -export 'src/message_actions_modal/message_action.dart'; +export 'src/media_gallery/stream_media_gallery.dart'; +export 'src/media_gallery/stream_media_gallery_attachment.dart'; +export 'src/media_gallery/stream_media_gallery_item.dart'; +export 'src/media_gallery_preview/stream_media_gallery_preview.dart'; +export 'src/media_gallery_preview/stream_media_gallery_preview_footer.dart'; +export 'src/media_gallery_preview/stream_media_gallery_preview_header.dart'; +export 'src/media_gallery_preview/stream_media_gallery_preview_item.dart'; +export 'src/media_gallery_preview/video_player/stream_video_player.dart'; +export 'src/message_action/message_action.dart'; +export 'src/message_action/message_actions_builder.dart'; export 'src/message_input/attachment_picker/stream_attachment_picker.dart'; export 'src/message_input/attachment_picker/stream_attachment_picker_bottom_sheet.dart'; +export 'src/message_input/attachment_picker/stream_attachment_picker_controller.dart'; +export 'src/message_input/attachment_picker/stream_attachment_picker_option.dart'; +export 'src/message_input/attachment_picker/stream_attachment_picker_result.dart'; export 'src/message_input/audio_recorder/audio_recorder_controller.dart'; export 'src/message_input/audio_recorder/audio_recorder_feedback.dart'; export 'src/message_input/audio_recorder/audio_recorder_state.dart'; export 'src/message_input/audio_recorder/stream_audio_recorder.dart'; export 'src/message_input/countdown_button.dart'; export 'src/message_input/enums.dart'; -export 'src/message_input/quoted_message_widget.dart'; -export 'src/message_input/stream_message_input.dart'; -export 'src/message_input/stream_message_input_attachment_list.dart'; -export 'src/message_input/stream_message_send_button.dart'; +export 'src/message_input/message_input_placeholder.dart'; +export 'src/message_input/stream_message_composer.dart'; +export 'src/message_input/stream_message_composer_attachment_list.dart'; export 'src/message_input/stream_message_text_field.dart'; export 'src/message_list_view/message_details.dart'; export 'src/message_list_view/message_list_view.dart'; -export 'src/message_widget/deleted_message.dart'; -export 'src/message_widget/message_text.dart'; -export 'src/message_widget/message_widget.dart'; -export 'src/message_widget/message_widget_content_components.dart'; -export 'src/message_widget/moderated_message.dart'; -export 'src/message_widget/poll_message.dart'; -export 'src/message_widget/reactions/reaction_picker.dart'; -export 'src/message_widget/system_message.dart'; -export 'src/message_widget/text_bubble.dart'; +export 'src/message_list_view/unread_indicator_button.dart'; +export 'src/message_modal/message_action_confirmation_modal.dart'; +export 'src/message_modal/message_actions_modal.dart'; +export 'src/message_modal/message_modal.dart'; +export 'src/message_modal/moderated_message_actions_modal.dart'; +export 'src/message_widget/stream_message_item.dart'; +export 'src/message_widget/stream_moderated_message.dart'; +export 'src/message_widget/stream_quoted_message.dart'; +export 'src/message_widget/stream_system_message.dart'; +export 'src/misc/adaptive_dialog_action.dart'; export 'src/misc/animated_circle_border_painter.dart'; export 'src/misc/back_button.dart'; export 'src/misc/connection_status_builder.dart'; export 'src/misc/date_divider.dart'; export 'src/misc/info_tile.dart'; -export 'src/misc/markdown_message.dart'; export 'src/misc/option_list_tile.dart'; -export 'src/misc/reaction_icon.dart'; +export 'src/misc/reaction_icon_resolver.dart'; +export 'src/misc/stream_modal.dart'; export 'src/misc/stream_neumorphic_button.dart'; export 'src/misc/swipeable.dart'; export 'src/misc/thread_header.dart'; +export 'src/misc/timestamp.dart'; export 'src/misc/visible_footnote.dart'; -export 'src/poll/creator/stream_poll_creator_dialog.dart'; +export 'src/poll/creator/stream_poll_creator_sheet.dart'; export 'src/poll/creator/stream_poll_creator_widget.dart'; export 'src/poll/interactor/stream_poll_interactor.dart'; -export 'src/poll/stream_poll_comments_dialog.dart'; -export 'src/poll/stream_poll_option_votes_dialog.dart'; -export 'src/poll/stream_poll_options_dialog.dart'; -export 'src/poll/stream_poll_results_dialog.dart'; -export 'src/poll/stream_poll_text_field.dart'; -export 'src/scroll_view/channel_scroll_view/stream_channel_grid_tile.dart'; -export 'src/scroll_view/channel_scroll_view/stream_channel_grid_view.dart'; -export 'src/scroll_view/channel_scroll_view/stream_channel_list_tile.dart'; +export 'src/poll/stream_poll_comments_sheet.dart'; +export 'src/poll/stream_poll_option_votes_sheet.dart'; +export 'src/poll/stream_poll_options_sheet.dart'; +export 'src/poll/stream_poll_results_sheet.dart'; +export 'src/reactions/detail/reaction_detail_sheet.dart'; +export 'src/reactions/picker/reaction_picker.dart'; +export 'src/scroll_view/channel_scroll_view/stream_channel_list_item.dart'; export 'src/scroll_view/channel_scroll_view/stream_channel_list_view.dart'; -export 'src/scroll_view/draft_scroll_view/stream_draft_list_tile.dart'; -export 'src/scroll_view/draft_scroll_view/stream_draft_list_view.dart'; export 'src/scroll_view/member_scroll_view/stream_member_grid_view.dart'; export 'src/scroll_view/member_scroll_view/stream_member_list_view.dart'; -export 'src/scroll_view/message_search_scroll_view/stream_message_search_grid_view.dart'; export 'src/scroll_view/message_search_scroll_view/stream_message_search_list_tile.dart'; export 'src/scroll_view/message_search_scroll_view/stream_message_search_list_view.dart'; export 'src/scroll_view/photo_gallery/stream_photo_gallery.dart'; @@ -114,8 +241,13 @@ export 'src/scroll_view/photo_gallery/stream_photo_gallery_controller.dart'; export 'src/scroll_view/photo_gallery/stream_photo_gallery_tile.dart'; export 'src/scroll_view/poll_vote_scroll_view/stream_poll_vote_list_tile.dart'; export 'src/scroll_view/poll_vote_scroll_view/stream_poll_vote_list_view.dart'; +export 'src/scroll_view/reaction_scroll_view/stream_reaction_list_view.dart'; export 'src/scroll_view/stream_scroll_view_empty_widget.dart'; +export 'src/scroll_view/stream_scroll_view_error_widget.dart'; export 'src/scroll_view/stream_scroll_view_indexed_widget_builder.dart'; +export 'src/scroll_view/stream_scroll_view_load_more_error.dart'; +export 'src/scroll_view/stream_scroll_view_load_more_indicator.dart'; +export 'src/scroll_view/stream_scroll_view_loading_widget.dart'; export 'src/scroll_view/thread_scroll_view/stream_thread_list_tile.dart'; export 'src/scroll_view/thread_scroll_view/stream_thread_list_view.dart'; export 'src/scroll_view/thread_scroll_view/stream_unread_threads_banner.dart'; @@ -125,14 +257,13 @@ export 'src/scroll_view/user_scroll_view/stream_user_list_tile.dart'; export 'src/scroll_view/user_scroll_view/stream_user_list_view.dart'; export 'src/stream_chat.dart'; export 'src/stream_chat_configuration.dart'; -export 'src/theme/draft_list_tile_theme.dart'; export 'src/theme/stream_chat_theme.dart'; export 'src/theme/themes.dart'; export 'src/user/user_mention_tile.dart'; +export 'src/utils/date_formatter.dart'; export 'src/utils/device_segmentation.dart'; export 'src/utils/extensions.dart'; export 'src/utils/helpers.dart'; export 'src/utils/message_preview_formatter.dart'; +export 'src/utils/stream_image_cdn.dart'; export 'src/utils/typedefs.dart'; -// TODO: Remove this in favor of StreamVideoAttachmentThumbnail. -export 'src/video/video_thumbnail_image.dart'; diff --git a/packages/stream_chat_flutter/pubspec.yaml b/packages/stream_chat_flutter/pubspec.yaml index 71a69c7d6f..d294551821 100644 --- a/packages/stream_chat_flutter/pubspec.yaml +++ b/packages/stream_chat_flutter/pubspec.yaml @@ -1,10 +1,12 @@ name: stream_chat_flutter homepage: https://github.com/GetStream/stream-chat-flutter description: Stream Chat official Flutter SDK. Build your own chat experience using Dart and Flutter. -version: 9.23.0 +version: 10.0.0-beta.13 repository: https://github.com/GetStream/stream-chat-flutter issue_tracker: https://github.com/GetStream/stream-chat-flutter/issues +publish_to: none + # Note: The environment configuration and dependency versions are managed by Melos. # # Do not edit them manually. @@ -18,8 +20,8 @@ issue_tracker: https://github.com/GetStream/stream-chat-flutter/issues # 2. Add it to the melos.yaml file for future updates. environment: - sdk: ^3.6.2 - flutter: ">=3.27.4" + sdk: ^3.10.0 + flutter: ">=3.38.1" dependencies: cached_network_image: ^3.3.1 @@ -48,22 +50,29 @@ dependencies: media_kit_video: ^2.0.0 meta: ^1.9.1 path_provider: ^2.1.3 - photo_manager: ^3.2.0 + photo_manager: ^3.8.3 photo_view: ^0.15.0 rate_limiter: ^1.0.0 - record: ">=5.2.0 <7.0.0" + record: ^6.2.0 rxdart: ^0.28.0 share_plus: ">=11.0.0 <13.0.0" shimmer: ^3.0.0 - stream_chat_flutter_core: ^9.23.0 + stream_chat_flutter_core: ^10.0.0-beta.13 + stream_core_flutter: + git: + url: https://github.com/GetStream/stream-core-flutter.git + ref: 333f7b72485f308b282cc85973223a2919fd8153 + path: packages/stream_core_flutter svg_icon_widget: ^0.0.1 synchronized: ^3.1.0+1 + theme_extensions_builder_annotation: ^7.1.0 thumblr: ^0.0.4 url_launcher: ^6.3.0 video_player: ^2.8.7 dev_dependencies: - alchemist: ">=0.11.0 <0.14.0" + alchemist: ^0.14.0 + build_runner: ^2.4.9 connectivity_plus_platform_interface: ^2.0.0 faker_dart: ^0.2.1 flutter_test: @@ -72,6 +81,7 @@ dev_dependencies: path: ^1.8.3 path_provider_platform_interface: ^2.0.0 plugin_platform_interface: ^2.0.0 + theme_extensions_builder: ^7.2.0 flutter: assets: diff --git a/packages/stream_chat_flutter/test/conditional_parent_builder/conditional_parent_builder_test.dart b/packages/stream_chat_flutter/test/conditional_parent_builder/conditional_parent_builder_test.dart index c52c28ab4b..b029f97917 100644 --- a/packages/stream_chat_flutter/test/conditional_parent_builder/conditional_parent_builder_test.dart +++ b/packages/stream_chat_flutter/test/conditional_parent_builder/conditional_parent_builder_test.dart @@ -3,8 +3,7 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:stream_chat_flutter/conditional_parent_builder/conditional_parent_builder.dart'; void main() { - testWidgets('ConditionalParentBuilder builds the parent widget', - (tester) async { + testWidgets('ConditionalParentBuilder builds the parent widget', (tester) async { await tester.pumpWidget( MaterialApp( home: Scaffold( @@ -26,8 +25,7 @@ void main() { expect(find.byType(Text), findsOneWidget); }); - testWidgets('ConditionalParentBuilder does not build the parent widget', - (tester) async { + testWidgets('ConditionalParentBuilder does not build the parent widget', (tester) async { await tester.pumpWidget( MaterialApp( home: Scaffold( diff --git a/packages/stream_chat_flutter/test/flutter_test_config.dart b/packages/stream_chat_flutter/test/flutter_test_config.dart index 7b00df9385..5bc88f9814 100644 --- a/packages/stream_chat_flutter/test/flutter_test_config.dart +++ b/packages/stream_chat_flutter/test/flutter_test_config.dart @@ -4,14 +4,12 @@ import 'dart:io'; import 'package:alchemist/alchemist.dart'; Future testExecutable(FutureOr Function() testMain) async { - final isRunningInCi = Platform.environment.containsKey('CI') || - Platform.environment.containsKey('GITHUB_ACTIONS'); + final isRunningInCi = Platform.environment.containsKey('CI') || Platform.environment.containsKey('GITHUB_ACTIONS'); return AlchemistConfig.runWithConfig( config: AlchemistConfig( - platformGoldensConfig: PlatformGoldensConfig( - enabled: !isRunningInCi, - ), + ciGoldensConfig: CiGoldensConfig(enabled: isRunningInCi), + platformGoldensConfig: PlatformGoldensConfig(enabled: !isRunningInCi), ), run: testMain, ); diff --git a/packages/stream_chat_flutter/test/platform_widget_builder/desktop_widget_builder_test.dart b/packages/stream_chat_flutter/test/platform_widget_builder/desktop_widget_builder_test.dart index a7b867ce38..1136d8a5d1 100644 --- a/packages/stream_chat_flutter/test/platform_widget_builder/desktop_widget_builder_test.dart +++ b/packages/stream_chat_flutter/test/platform_widget_builder/desktop_widget_builder_test.dart @@ -1,5 +1,4 @@ -import 'package:flutter/foundation.dart' - show debugDefaultTargetPlatformOverride; +import 'package:flutter/foundation.dart' show debugDefaultTargetPlatformOverride; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:stream_chat_flutter/platform_widget_builder/platform_widget_builder.dart'; diff --git a/packages/stream_chat_flutter/test/platform_widget_builder/platform_widget_builder_test.dart b/packages/stream_chat_flutter/test/platform_widget_builder/platform_widget_builder_test.dart index 08425ebc9b..854eacf3f4 100644 --- a/packages/stream_chat_flutter/test/platform_widget_builder/platform_widget_builder_test.dart +++ b/packages/stream_chat_flutter/test/platform_widget_builder/platform_widget_builder_test.dart @@ -1,5 +1,4 @@ -import 'package:flutter/foundation.dart' - show debugDefaultTargetPlatformOverride; +import 'package:flutter/foundation.dart' show debugDefaultTargetPlatformOverride; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:stream_chat_flutter/platform_widget_builder/platform_widget_builder.dart'; @@ -71,4 +70,29 @@ void main() { }, variant: const TargetPlatformVariant({TargetPlatform.fuchsia}), // hacky :/ ); + + testWidgets( + 'PlatformWidgetBuilder builds the correct widget for desktopOrWeb', + (tester) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Center( + child: PlatformWidgetBuilder( + desktopOrWeb: (context, child) => const Text('DesktopOrWeb'), + ), + ), + ), + ), + ); + + expect(find.text('DesktopOrWeb'), findsOneWidget); + }, + variant: const TargetPlatformVariant({ + TargetPlatform.macOS, + TargetPlatform.windows, + TargetPlatform.linux, + TargetPlatform.fuchsia, // Quick hack for web variant. + }), + ); } diff --git a/packages/stream_chat_flutter/test/scrollable_positioned_list/horizontal_scrollable_positioned_list_test.dart b/packages/stream_chat_flutter/test/scrollable_positioned_list/horizontal_scrollable_positioned_list_test.dart index 6e851ada5c..8bceed8ffe 100644 --- a/packages/stream_chat_flutter/test/scrollable_positioned_list/horizontal_scrollable_positioned_list_test.dart +++ b/packages/stream_chat_flutter/test/scrollable_positioned_list/horizontal_scrollable_positioned_list_test.dart @@ -58,119 +58,94 @@ void main() { expect(tester.getBottomRight(find.text('Item 9')).dx, screenWidth); expect(find.text('Item 10'), findsNothing); + expect(itemPositionsListener.itemPositions.value.firstWhere((position) => position.index == 0).itemLeadingEdge, 0); expect( - itemPositionsListener.itemPositions.value - .firstWhere((position) => position.index == 0) - .itemLeadingEdge, - 0); - expect( - itemPositionsListener.itemPositions.value - .firstWhere((position) => position.index == 0) - .itemTrailingEdge, - 1 / 10); - expect( - itemPositionsListener.itemPositions.value - .firstWhere((position) => position.index == 9) - .itemTrailingEdge, - 1); + itemPositionsListener.itemPositions.value.firstWhere((position) => position.index == 0).itemTrailingEdge, + 1 / 10, + ); + expect(itemPositionsListener.itemPositions.value.firstWhere((position) => position.index == 9).itemTrailingEdge, 1); }); testWidgets('List positioned with 0 at right', (WidgetTester tester) async { final itemPositionsListener = ItemPositionsListener.create(); - await setUpWidgetTest(tester, - itemPositionsListener: itemPositionsListener, reverse: true); + await setUpWidgetTest(tester, itemPositionsListener: itemPositionsListener, reverse: true); expect(tester.getBottomRight(find.text('Item 0')).dx, screenWidth); expect(tester.getTopLeft(find.text('Item 9')).dx, 0); expect(find.text('Item 10'), findsNothing); + expect(itemPositionsListener.itemPositions.value.firstWhere((position) => position.index == 0).itemLeadingEdge, 0); expect( - itemPositionsListener.itemPositions.value - .firstWhere((position) => position.index == 0) - .itemLeadingEdge, - 0); - expect( - itemPositionsListener.itemPositions.value - .firstWhere((position) => position.index == 0) - .itemTrailingEdge, - 1 / 10); - expect( - itemPositionsListener.itemPositions.value - .firstWhere((position) => position.index == 9) - .itemTrailingEdge, - 1); + itemPositionsListener.itemPositions.value.firstWhere((position) => position.index == 0).itemTrailingEdge, + 1 / 10, + ); + expect(itemPositionsListener.itemPositions.value.firstWhere((position) => position.index == 9).itemTrailingEdge, 1); }); testWidgets('Scroll to 2 (already on screen)', (WidgetTester tester) async { final itemScrollController = ItemScrollController(); final itemPositionsListener = ItemPositionsListener.create(); - await setUpWidgetTest(tester, - itemScrollController: itemScrollController, - itemPositionsListener: itemPositionsListener); + await setUpWidgetTest( + tester, + itemScrollController: itemScrollController, + itemPositionsListener: itemPositionsListener, + ); - unawaited( - itemScrollController.scrollTo(index: 2, duration: scrollDuration)); + unawaited(itemScrollController.scrollTo(index: 2, duration: scrollDuration)); await tester.pump(); await tester.pump(scrollDuration); expect(find.text('Item 1'), findsNothing); expect(tester.getTopLeft(find.text('Item 2')).dx, 0); + expect(itemPositionsListener.itemPositions.value.firstWhere((position) => position.index == 2).itemLeadingEdge, 0); expect( - itemPositionsListener.itemPositions.value - .firstWhere((position) => position.index == 2) - .itemLeadingEdge, - 0); - expect( - itemPositionsListener.itemPositions.value - .firstWhere((position) => position.index == 2) - .itemTrailingEdge, - 1 / 10); + itemPositionsListener.itemPositions.value.firstWhere((position) => position.index == 2).itemTrailingEdge, + 1 / 10, + ); expect( - itemPositionsListener.itemPositions.value - .firstWhere((position) => position.index == 11) - .itemTrailingEdge, - 1); + itemPositionsListener.itemPositions.value.firstWhere((position) => position.index == 11).itemTrailingEdge, + 1, + ); }); - testWidgets('Scroll to 100 (not already on screen)', - (WidgetTester tester) async { + testWidgets('Scroll to 100 (not already on screen)', (WidgetTester tester) async { final itemScrollController = ItemScrollController(); final itemPositionsListener = ItemPositionsListener.create(); - await setUpWidgetTest(tester, - itemScrollController: itemScrollController, - itemPositionsListener: itemPositionsListener); + await setUpWidgetTest( + tester, + itemScrollController: itemScrollController, + itemPositionsListener: itemPositionsListener, + ); - unawaited( - itemScrollController.scrollTo(index: 100, duration: scrollDuration)); + unawaited(itemScrollController.scrollTo(index: 100, duration: scrollDuration)); await tester.pumpAndSettle(); expect(find.text('Item 99'), findsNothing); expect(find.text('Item 100'), findsOneWidget); expect( - itemPositionsListener.itemPositions.value - .firstWhere((position) => position.index == 100) - .itemLeadingEdge, - 0); + itemPositionsListener.itemPositions.value.firstWhere((position) => position.index == 100).itemLeadingEdge, + 0, + ); expect( - itemPositionsListener.itemPositions.value - .firstWhere((position) => position.index == 100) - .itemTrailingEdge, - 1 / 10); + itemPositionsListener.itemPositions.value.firstWhere((position) => position.index == 100).itemTrailingEdge, + 1 / 10, + ); expect( - itemPositionsListener.itemPositions.value - .firstWhere((position) => position.index == 109) - .itemTrailingEdge, - 1); + itemPositionsListener.itemPositions.value.firstWhere((position) => position.index == 109).itemTrailingEdge, + 1, + ); }); testWidgets('Jump to 100', (WidgetTester tester) async { final itemScrollController = ItemScrollController(); final itemPositionsListener = ItemPositionsListener.create(); - await setUpWidgetTest(tester, - itemScrollController: itemScrollController, - itemPositionsListener: itemPositionsListener); + await setUpWidgetTest( + tester, + itemScrollController: itemScrollController, + itemPositionsListener: itemPositionsListener, + ); itemScrollController.jumpTo(index: 100); await tester.pumpAndSettle(); @@ -179,39 +154,36 @@ void main() { expect(tester.getBottomRight(find.text('Item 109')).dy, screenHeight); expect( - itemPositionsListener.itemPositions.value - .firstWhere((position) => position.index == 100) - .itemLeadingEdge, - 0); + itemPositionsListener.itemPositions.value.firstWhere((position) => position.index == 100).itemLeadingEdge, + 0, + ); expect( - itemPositionsListener.itemPositions.value - .firstWhere((position) => position.index == 100) - .itemTrailingEdge, - 1 / 10); + itemPositionsListener.itemPositions.value.firstWhere((position) => position.index == 100).itemTrailingEdge, + 1 / 10, + ); expect( - itemPositionsListener.itemPositions.value - .firstWhere((position) => position.index == 109) - .itemLeadingEdge, - 9 / 10); + itemPositionsListener.itemPositions.value.firstWhere((position) => position.index == 109).itemLeadingEdge, + 9 / 10, + ); expect( - itemPositionsListener.itemPositions.value - .firstWhere((position) => position.index == 109) - .itemTrailingEdge, - 1); + itemPositionsListener.itemPositions.value.firstWhere((position) => position.index == 109).itemTrailingEdge, + 1, + ); }); testWidgets('Scroll to 20 without fading', (WidgetTester tester) async { final itemScrollController = ItemScrollController(); final itemPositionsListener = ItemPositionsListener.create(); - await setUpWidgetTest(tester, - itemScrollController: itemScrollController, - itemPositionsListener: itemPositionsListener); + await setUpWidgetTest( + tester, + itemScrollController: itemScrollController, + itemPositionsListener: itemPositionsListener, + ); var fadeTransition = tester.widget(fadeTransitionFinder); final initialOpacity = fadeTransition.opacity; - unawaited( - itemScrollController.scrollTo(index: 20, duration: scrollDuration)); + unawaited(itemScrollController.scrollTo(index: 20, duration: scrollDuration)); await tester.pump(); await tester.pump(); await tester.pump(scrollDuration ~/ 2); @@ -225,8 +197,7 @@ void main() { expect(find.text('Item 20'), findsOneWidget); }); - testWidgets('padding test - centered sliver at left', - (WidgetTester tester) async { + testWidgets('padding test - centered sliver at left', (WidgetTester tester) async { final itemScrollController = ItemScrollController(); await setUpWidgetTest( tester, @@ -235,25 +206,19 @@ void main() { ); expect(tester.getTopLeft(find.text('Item 0')), const Offset(10, 10)); - expect(tester.getTopLeft(find.text('Item 1')), - const Offset(itemWidth + 10, 10)); - expect(tester.getBottomRight(find.text('Item 1')), - const Offset(10 + itemWidth * 2, screenHeight - 10)); + expect(tester.getTopLeft(find.text('Item 1')), const Offset(itemWidth + 10, 10)); + expect(tester.getBottomRight(find.text('Item 1')), const Offset(10 + itemWidth * 2, screenHeight - 10)); - unawaited( - itemScrollController.scrollTo(index: 490, duration: scrollDuration)); + unawaited(itemScrollController.scrollTo(index: 490, duration: scrollDuration)); await tester.pumpAndSettle(); - await tester.drag( - find.byType(ScrollablePositionedList), const Offset(-100, 0)); + await tester.drag(find.byType(ScrollablePositionedList), const Offset(-100, 0)); await tester.pumpAndSettle(); - expect(tester.getBottomRight(find.text('Item 499')), - const Offset(screenWidth - 10, screenHeight - 10)); + expect(tester.getBottomRight(find.text('Item 499')), const Offset(screenWidth - 10, screenHeight - 10)); }); - testWidgets('padding test - centered sliver not at left', - (WidgetTester tester) async { + testWidgets('padding test - centered sliver not at left', (WidgetTester tester) async { final itemScrollController = ItemScrollController(); await setUpWidgetTest( tester, @@ -262,19 +227,15 @@ void main() { padding: const EdgeInsets.all(10), ); - await tester.drag( - find.byType(ScrollablePositionedList), const Offset(200, 0)); + await tester.drag(find.byType(ScrollablePositionedList), const Offset(200, 0)); await tester.pumpAndSettle(); expect(tester.getTopLeft(find.text('Item 0')), const Offset(10, 10)); - expect(tester.getTopLeft(find.text('Item 2')), - const Offset(10 + itemWidth * 2, 10)); - expect(tester.getTopLeft(find.text('Item 3')), - const Offset(10 + itemWidth * 3, 10)); + expect(tester.getTopLeft(find.text('Item 2')), const Offset(10 + itemWidth * 2, 10)); + expect(tester.getTopLeft(find.text('Item 3')), const Offset(10 + itemWidth * 3, 10)); }); - testWidgets('padding test - reversed - centered sliver at right', - (WidgetTester tester) async { + testWidgets('padding test - reversed - centered sliver at right', (WidgetTester tester) async { final itemScrollController = ItemScrollController(); await setUpWidgetTest( tester, @@ -283,26 +244,23 @@ void main() { reverse: true, ); - expect(tester.getTopRight(find.text('Item 0')), - const Offset(screenWidth - 10, 10)); - expect(tester.getTopRight(find.text('Item 1')), - const Offset(screenWidth - (itemWidth + 10), 10)); - expect(tester.getBottomLeft(find.text('Item 1')), - const Offset(screenWidth - (10 + itemWidth * 2), screenHeight - 10)); + expect(tester.getTopRight(find.text('Item 0')), const Offset(screenWidth - 10, 10)); + expect(tester.getTopRight(find.text('Item 1')), const Offset(screenWidth - (itemWidth + 10), 10)); + expect( + tester.getBottomLeft(find.text('Item 1')), + const Offset(screenWidth - (10 + itemWidth * 2), screenHeight - 10), + ); - unawaited( - itemScrollController.scrollTo(index: 490, duration: scrollDuration)); + unawaited(itemScrollController.scrollTo(index: 490, duration: scrollDuration)); await tester.pumpAndSettle(); - await tester.drag( - find.byType(ScrollablePositionedList), const Offset(100, 0)); + await tester.drag(find.byType(ScrollablePositionedList), const Offset(100, 0)); await tester.pumpAndSettle(); expect(tester.getTopLeft(find.text('Item 499')), const Offset(10, 10)); }); - testWidgets('padding test - reversed - centered sliver not at right', - (WidgetTester tester) async { + testWidgets('padding test - reversed - centered sliver not at right', (WidgetTester tester) async { final itemScrollController = ItemScrollController(); await setUpWidgetTest( tester, @@ -312,15 +270,11 @@ void main() { reverse: true, ); - await tester.drag( - find.byType(ScrollablePositionedList), const Offset(-200, 0)); + await tester.drag(find.byType(ScrollablePositionedList), const Offset(-200, 0)); await tester.pumpAndSettle(); - expect(tester.getTopRight(find.text('Item 0')), - const Offset(screenWidth - 10, 10)); - expect(tester.getTopRight(find.text('Item 2')), - const Offset(screenWidth - (10 + itemWidth * 2), 10)); - expect(tester.getTopRight(find.text('Item 3')), - const Offset(screenWidth - (10 + itemWidth * 3), 10)); + expect(tester.getTopRight(find.text('Item 0')), const Offset(screenWidth - 10, 10)); + expect(tester.getTopRight(find.text('Item 2')), const Offset(screenWidth - (10 + itemWidth * 2), 10)); + expect(tester.getTopRight(find.text('Item 3')), const Offset(screenWidth - (10 + itemWidth * 3), 10)); }); } diff --git a/packages/stream_chat_flutter/test/scrollable_positioned_list/positioned_list_test.dart b/packages/stream_chat_flutter/test/scrollable_positioned_list/positioned_list_test.dart index 887165a512..c43fea6fa9 100644 --- a/packages/stream_chat_flutter/test/scrollable_positioned_list/positioned_list_test.dart +++ b/packages/stream_chat_flutter/test/scrollable_positioned_list/positioned_list_test.dart @@ -53,16 +53,11 @@ void main() { expect(find.text('Item 4'), findsOneWidget); expect(find.text('Item 5'), findsNothing); + expect(itemPositionsNotifier.itemPositions.value.firstWhere((position) => position.index == 0).itemLeadingEdge, 0); expect( - itemPositionsNotifier.itemPositions.value - .firstWhere((position) => position.index == 0) - .itemLeadingEdge, - 0); - expect( - itemPositionsNotifier.itemPositions.value - .firstWhere((position) => position.index == 4) - .itemTrailingEdge, - 1 / 2); + itemPositionsNotifier.itemPositions.value.firstWhere((position) => position.index == 4).itemTrailingEdge, + 1 / 2, + ); }); testWidgets('List positioned with 0 at top', (WidgetTester tester) async { @@ -73,26 +68,13 @@ void main() { expect(find.text('Item 9'), findsOneWidget); expect(find.text('Item 10'), findsNothing); + expect(itemPositionsNotifier.itemPositions.value.firstWhere((position) => position.index == 0).itemLeadingEdge, 0); + expect(itemPositionsNotifier.itemPositions.value.firstWhere((position) => position.index == 9).itemTrailingEdge, 1); + expect(itemPositionsNotifier.itemPositions.value.firstWhere((position) => position.index == 10).itemLeadingEdge, 1); expect( - itemPositionsNotifier.itemPositions.value - .firstWhere((position) => position.index == 0) - .itemLeadingEdge, - 0); - expect( - itemPositionsNotifier.itemPositions.value - .firstWhere((position) => position.index == 9) - .itemTrailingEdge, - 1); - expect( - itemPositionsNotifier.itemPositions.value - .firstWhere((position) => position.index == 10) - .itemLeadingEdge, - 1); - expect( - itemPositionsNotifier.itemPositions.value - .firstWhere((position) => position.index == 10) - .itemTrailingEdge, - 11 / 10); + itemPositionsNotifier.itemPositions.value.firstWhere((position) => position.index == 10).itemTrailingEdge, + 11 / 10, + ); }); testWidgets('List positioned with 5 at top', (WidgetTester tester) async { @@ -105,25 +87,15 @@ void main() { expect(find.text('Item 15'), findsNothing); expect( - itemPositionsNotifier.itemPositions.value - .firstWhere((position) => position.index == 4) - .itemLeadingEdge, - -1 / 10); - expect( - itemPositionsNotifier.itemPositions.value - .firstWhere((position) => position.index == 4) - .itemTrailingEdge, - 0); - expect( - itemPositionsNotifier.itemPositions.value - .firstWhere((position) => position.index == 5) - .itemLeadingEdge, - 0); + itemPositionsNotifier.itemPositions.value.firstWhere((position) => position.index == 4).itemLeadingEdge, + -1 / 10, + ); + expect(itemPositionsNotifier.itemPositions.value.firstWhere((position) => position.index == 4).itemTrailingEdge, 0); + expect(itemPositionsNotifier.itemPositions.value.firstWhere((position) => position.index == 5).itemLeadingEdge, 0); expect( - itemPositionsNotifier.itemPositions.value - .firstWhere((position) => position.index == 14) - .itemTrailingEdge, - 1); + itemPositionsNotifier.itemPositions.value.firstWhere((position) => position.index == 14).itemTrailingEdge, + 1, + ); }); testWidgets('List positioned with 20 at bottom', (WidgetTester tester) async { @@ -134,69 +106,50 @@ void main() { expect(find.text('Item 19'), findsOneWidget); expect(find.text('Item 10'), findsOneWidget); + expect(itemPositionsNotifier.itemPositions.value.firstWhere((position) => position.index == 10).itemLeadingEdge, 0); expect( - itemPositionsNotifier.itemPositions.value - .firstWhere((position) => position.index == 10) - .itemLeadingEdge, - 0); - expect( - itemPositionsNotifier.itemPositions.value - .firstWhere((position) => position.index == 19) - .itemLeadingEdge, - 9 / 10); - expect( - itemPositionsNotifier.itemPositions.value - .firstWhere((position) => position.index == 19) - .itemTrailingEdge, - 1); + itemPositionsNotifier.itemPositions.value.firstWhere((position) => position.index == 19).itemLeadingEdge, + 9 / 10, + ); expect( - itemPositionsNotifier.itemPositions.value - .firstWhere((position) => position.index == 20) - .itemLeadingEdge, - 1); + itemPositionsNotifier.itemPositions.value.firstWhere((position) => position.index == 19).itemTrailingEdge, + 1, + ); + expect(itemPositionsNotifier.itemPositions.value.firstWhere((position) => position.index == 20).itemLeadingEdge, 1); }); - testWidgets('List positioned with 20 at halfway', - (WidgetTester tester) async { + testWidgets('List positioned with 20 at halfway', (WidgetTester tester) async { await setUpWidgetTest(tester, topItem: 20, anchor: 0.5); await tester.pump(); expect( - itemPositionsNotifier.itemPositions.value - .firstWhere((position) => position.index == 20) - .itemLeadingEdge, - 0.5); + itemPositionsNotifier.itemPositions.value.firstWhere((position) => position.index == 20).itemLeadingEdge, + 0.5, + ); expect( - itemPositionsNotifier.itemPositions.value - .firstWhere((position) => position.index == 20) - .itemTrailingEdge, - 0.5 + itemHeight / screenHeight); + itemPositionsNotifier.itemPositions.value.firstWhere((position) => position.index == 20).itemTrailingEdge, + 0.5 + itemHeight / screenHeight, + ); }); - testWidgets('List positioned with 20 half off top of screen', - (WidgetTester tester) async { - await setUpWidgetTest(tester, - topItem: 20, anchor: -(itemHeight / screenHeight) / 2); + testWidgets('List positioned with 20 half off top of screen', (WidgetTester tester) async { + await setUpWidgetTest(tester, topItem: 20, anchor: -(itemHeight / screenHeight) / 2); await tester.pump(); expect( - itemPositionsNotifier.itemPositions.value - .firstWhere((position) => position.index == 20) - .itemLeadingEdge, - -(itemHeight / screenHeight) / 2); + itemPositionsNotifier.itemPositions.value.firstWhere((position) => position.index == 20).itemLeadingEdge, + -(itemHeight / screenHeight) / 2, + ); expect( - itemPositionsNotifier.itemPositions.value - .firstWhere((position) => position.index == 20) - .itemTrailingEdge, - (itemHeight / screenHeight) / 2); + itemPositionsNotifier.itemPositions.value.firstWhere((position) => position.index == 20).itemTrailingEdge, + (itemHeight / screenHeight) / 2, + ); }); - testWidgets('List positioned with 5 at top then scroll up 2', - (WidgetTester tester) async { + testWidgets('List positioned with 5 at top then scroll up 2', (WidgetTester tester) async { await setUpWidgetTest(tester, topItem: 5); - await tester.drag( - find.byType(PositionedList), const Offset(0, itemHeight * 2)); + await tester.drag(find.byType(PositionedList), const Offset(0, itemHeight * 2)); await tester.pump(); expect(find.text('Item 2'), findsNothing); @@ -205,44 +158,33 @@ void main() { expect(find.text('Item 13'), findsNothing); expect( - itemPositionsNotifier.itemPositions.value - .firstWhere((position) => position.index == 2) - .itemLeadingEdge, - -1 / 10); - expect( - itemPositionsNotifier.itemPositions.value - .firstWhere((position) => position.index == 3) - .itemLeadingEdge, - 0); + itemPositionsNotifier.itemPositions.value.firstWhere((position) => position.index == 2).itemLeadingEdge, + -1 / 10, + ); + expect(itemPositionsNotifier.itemPositions.value.firstWhere((position) => position.index == 3).itemLeadingEdge, 0); expect( - itemPositionsNotifier.itemPositions.value - .firstWhere((position) => position.index == 12) - .itemTrailingEdge, - 1); + itemPositionsNotifier.itemPositions.value.firstWhere((position) => position.index == 12).itemTrailingEdge, + 1, + ); }); - testWidgets('List positioned with 5 at top then scroll down 1/2', - (WidgetTester tester) async { + testWidgets('List positioned with 5 at top then scroll down 1/2', (WidgetTester tester) async { await setUpWidgetTest(tester, topItem: 5); - await tester.drag( - find.byType(PositionedList), const Offset(0, -1 / 2 * itemHeight)); + await tester.drag(find.byType(PositionedList), const Offset(0, -1 / 2 * itemHeight)); await tester.pump(); expect( - itemPositionsNotifier.itemPositions.value - .firstWhere((position) => position.index == 5) - .itemTrailingEdge, - 1 / 20); + itemPositionsNotifier.itemPositions.value.firstWhere((position) => position.index == 5).itemTrailingEdge, + 1 / 20, + ); expect( - itemPositionsNotifier.itemPositions.value - .firstWhere((position) => position.index == 14) - .itemLeadingEdge, - 17 / 20); + itemPositionsNotifier.itemPositions.value.firstWhere((position) => position.index == 14).itemLeadingEdge, + 17 / 20, + ); }); - testWidgets('List positioned with 0 at top scroll up 5', - (WidgetTester tester) async { + testWidgets('List positioned with 0 at top scroll up 5', (WidgetTester tester) async { final scrollController = ScrollController(); await setUpWidgetTest(tester, scrollController: scrollController); await tester.pump(); @@ -256,23 +198,16 @@ void main() { expect(find.text('Item 14'), findsOneWidget); expect(find.text('Item 15'), findsNothing); + expect(itemPositionsNotifier.itemPositions.value.firstWhere((position) => position.index == 5).itemLeadingEdge, 0); expect( - itemPositionsNotifier.itemPositions.value - .firstWhere((position) => position.index == 5) - .itemLeadingEdge, - 0); - expect( - itemPositionsNotifier.itemPositions.value - .firstWhere((position) => position.index == 4) - .itemLeadingEdge, - -1 / 10); + itemPositionsNotifier.itemPositions.value.firstWhere((position) => position.index == 4).itemLeadingEdge, + -1 / 10, + ); }); - testWidgets('List positioned with 5 at top then scroll up 2 programatically', - (WidgetTester tester) async { + testWidgets('List positioned with 5 at top then scroll up 2 programatically', (WidgetTester tester) async { final scrollController = ScrollController(); - await setUpWidgetTest(tester, - topItem: 5, scrollController: scrollController); + await setUpWidgetTest(tester, topItem: 5, scrollController: scrollController); scrollController.jumpTo(-2 * itemHeight); await tester.pump(); @@ -283,92 +218,67 @@ void main() { expect(find.text('Item 13'), findsNothing); expect( - itemPositionsNotifier.itemPositions.value - .firstWhere((position) => position.index == 2) - .itemLeadingEdge, - -1 / 10); - expect( - itemPositionsNotifier.itemPositions.value - .firstWhere((position) => position.index == 3) - .itemLeadingEdge, - 0); + itemPositionsNotifier.itemPositions.value.firstWhere((position) => position.index == 2).itemLeadingEdge, + -1 / 10, + ); + expect(itemPositionsNotifier.itemPositions.value.firstWhere((position) => position.index == 3).itemLeadingEdge, 0); expect( - itemPositionsNotifier.itemPositions.value - .firstWhere((position) => position.index == 12) - .itemTrailingEdge, - 1); + itemPositionsNotifier.itemPositions.value.firstWhere((position) => position.index == 12).itemTrailingEdge, + 1, + ); }); - testWidgets( - 'List positioned with 5 at top then scroll down 20 programatically', - (WidgetTester tester) async { + testWidgets('List positioned with 5 at top then scroll down 20 programatically', (WidgetTester tester) async { final scrollController = ScrollController(); - await setUpWidgetTest(tester, - topItem: 5, scrollController: scrollController); + await setUpWidgetTest(tester, topItem: 5, scrollController: scrollController); scrollController.jumpTo(itemHeight * 20); await tester.pump(); expect( - itemPositionsNotifier.itemPositions.value - .firstWhere((position) => position.index == 23) - .itemLeadingEdge, - -2 / 10); - expect( - itemPositionsNotifier.itemPositions.value - .firstWhere((position) => position.index == 24) - .itemLeadingEdge, - -1 / 10); + itemPositionsNotifier.itemPositions.value.firstWhere((position) => position.index == 23).itemLeadingEdge, + -2 / 10, + ); expect( - itemPositionsNotifier.itemPositions.value - .firstWhere((position) => position.index == 25) - .itemLeadingEdge, - 0); + itemPositionsNotifier.itemPositions.value.firstWhere((position) => position.index == 24).itemLeadingEdge, + -1 / 10, + ); + expect(itemPositionsNotifier.itemPositions.value.firstWhere((position) => position.index == 25).itemLeadingEdge, 0); expect( - itemPositionsNotifier.itemPositions.value - .firstWhere((position) => position.index == 4) - .itemLeadingEdge, - -21 / 10); + itemPositionsNotifier.itemPositions.value.firstWhere((position) => position.index == 4).itemLeadingEdge, + -21 / 10, + ); expect( - itemPositionsNotifier.itemPositions.value - .firstWhere((position) => position.index == 5) - .itemLeadingEdge, - -20 / 10); + itemPositionsNotifier.itemPositions.value.firstWhere((position) => position.index == 5).itemLeadingEdge, + -20 / 10, + ); }); - testWidgets('List positioned with 5 at top and initial scroll offset', - (WidgetTester tester) async { - final scrollController = - ScrollController(initialScrollOffset: -2 * itemHeight); - await setUpWidgetTest(tester, - topItem: 5, scrollController: scrollController); + testWidgets('List positioned with 5 at top and initial scroll offset', (WidgetTester tester) async { + final scrollController = ScrollController(initialScrollOffset: -2 * itemHeight); + await setUpWidgetTest(tester, topItem: 5, scrollController: scrollController); expect(find.text('Item 2'), findsNothing); expect(find.text('Item 3'), findsOneWidget); expect(find.text('Item 12'), findsOneWidget); expect(find.text('Item 13'), findsNothing); + expect(itemPositionsNotifier.itemPositions.value.firstWhere((position) => position.index == 3).itemLeadingEdge, 0); expect( - itemPositionsNotifier.itemPositions.value - .firstWhere((position) => position.index == 3) - .itemLeadingEdge, - 0); - expect( - itemPositionsNotifier.itemPositions.value - .firstWhere((position) => position.index == 12) - .itemTrailingEdge, - 1); + itemPositionsNotifier.itemPositions.value.firstWhere((position) => position.index == 12).itemTrailingEdge, + 1, + ); }); - testWidgets('Does not crash when updated offscreen', - (WidgetTester tester) async { + testWidgets('Does not crash when updated offscreen', (WidgetTester tester) async { late StateSetter setState; var updated = false; // There's 0 relayout boundaries in this subtree. - final widget = StatefulBuilder(builder: (context, stateSetter) { - setState = stateSetter; - return Positioned( + final widget = StatefulBuilder( + builder: (context, stateSetter) { + setState = stateSetter; + return Positioned( left: 0, right: 0, child: PositionedList( @@ -378,17 +288,21 @@ void main() { // RenderIndexedSemantics to the render tree. addSemanticIndexes: updated, itemBuilder: (context, index) => const SizedBox(height: itemHeight), - )); - }); + ), + ); + }, + ); - await tester.pumpWidget(Directionality( - textDirection: TextDirection.ltr, - child: Overlay( - initialEntries: [ - OverlayEntry(builder: (context) => widget, maintainState: true), - ], + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: Overlay( + initialEntries: [ + OverlayEntry(builder: (context) => widget, maintainState: true), + ], + ), ), - )); + ); // Insert a new opaque OverlayEntry that would prevent the first // OverlayEntry from doing re-layout. Since there's no relayout boundaries diff --git a/packages/stream_chat_flutter/test/scrollable_positioned_list/reversed_positioned_list_test.dart b/packages/stream_chat_flutter/test/scrollable_positioned_list/reversed_positioned_list_test.dart index 828f17e390..3077126080 100644 --- a/packages/stream_chat_flutter/test/scrollable_positioned_list/reversed_positioned_list_test.dart +++ b/packages/stream_chat_flutter/test/scrollable_positioned_list/reversed_positioned_list_test.dart @@ -52,16 +52,11 @@ void main() { expect(find.text('Item 4'), findsOneWidget); expect(find.text('Item 5'), findsNothing); + expect(itemPositionsNotifier.itemPositions.value.firstWhere((position) => position.index == 0).itemLeadingEdge, 0); expect( - itemPositionsNotifier.itemPositions.value - .firstWhere((position) => position.index == 0) - .itemLeadingEdge, - 0); - expect( - itemPositionsNotifier.itemPositions.value - .firstWhere((position) => position.index == 4) - .itemTrailingEdge, - 1 / 2); + itemPositionsNotifier.itemPositions.value.firstWhere((position) => position.index == 4).itemTrailingEdge, + 1 / 2, + ); }); testWidgets('List positioned with 0 at bottom', (WidgetTester tester) async { @@ -72,16 +67,8 @@ void main() { expect(tester.getTopLeft(find.text('Item 9')).dy, 0); expect(find.text('Item 10'), findsNothing); - expect( - itemPositionsNotifier.itemPositions.value - .firstWhere((position) => position.index == 0) - .itemLeadingEdge, - 0); - expect( - itemPositionsNotifier.itemPositions.value - .firstWhere((position) => position.index == 9) - .itemTrailingEdge, - 1); + expect(itemPositionsNotifier.itemPositions.value.firstWhere((position) => position.index == 0).itemLeadingEdge, 0); + expect(itemPositionsNotifier.itemPositions.value.firstWhere((position) => position.index == 9).itemTrailingEdge, 1); }); testWidgets('List positioned with 5 at bottom', (WidgetTester tester) async { @@ -94,25 +81,15 @@ void main() { expect(find.text('Item 15'), findsNothing); expect( - itemPositionsNotifier.itemPositions.value - .firstWhere((position) => position.index == 4) - .itemLeadingEdge, - -1 / 10); - expect( - itemPositionsNotifier.itemPositions.value - .firstWhere((position) => position.index == 4) - .itemTrailingEdge, - 0); - expect( - itemPositionsNotifier.itemPositions.value - .firstWhere((position) => position.index == 5) - .itemLeadingEdge, - 0); + itemPositionsNotifier.itemPositions.value.firstWhere((position) => position.index == 4).itemLeadingEdge, + -1 / 10, + ); + expect(itemPositionsNotifier.itemPositions.value.firstWhere((position) => position.index == 4).itemTrailingEdge, 0); + expect(itemPositionsNotifier.itemPositions.value.firstWhere((position) => position.index == 5).itemLeadingEdge, 0); expect( - itemPositionsNotifier.itemPositions.value - .firstWhere((position) => position.index == 14) - .itemTrailingEdge, - 1); + itemPositionsNotifier.itemPositions.value.firstWhere((position) => position.index == 14).itemTrailingEdge, + 1, + ); }); testWidgets('List positioned with 15 at bottom', (WidgetTester tester) async { @@ -134,53 +111,35 @@ void main() { expect(find.text('Item 5'), findsOneWidget); expect(find.text('Item 4'), findsNothing); + expect(itemPositionsNotifier.itemPositions.value.firstWhere((position) => position.index == 15).itemLeadingEdge, 1); expect( - itemPositionsNotifier.itemPositions.value - .firstWhere((position) => position.index == 15) - .itemLeadingEdge, - 1); - expect( - itemPositionsNotifier.itemPositions.value - .firstWhere((position) => position.index == 14) - .itemTrailingEdge, - 1); - expect( - itemPositionsNotifier.itemPositions.value - .firstWhere((position) => position.index == 14) - .itemLeadingEdge, - 9 / 10); + itemPositionsNotifier.itemPositions.value.firstWhere((position) => position.index == 14).itemTrailingEdge, + 1, + ); expect( - itemPositionsNotifier.itemPositions.value - .firstWhere((position) => position.index == 5) - .itemLeadingEdge, - 0); + itemPositionsNotifier.itemPositions.value.firstWhere((position) => position.index == 14).itemLeadingEdge, + 9 / 10, + ); + expect(itemPositionsNotifier.itemPositions.value.firstWhere((position) => position.index == 5).itemLeadingEdge, 0); }); - testWidgets('List positioned with 5 at bottom then scroll up 2', - (WidgetTester tester) async { + testWidgets('List positioned with 5 at bottom then scroll up 2', (WidgetTester tester) async { await setUpWidgetTest(tester, topItem: 5); - await tester.drag( - find.byType(PositionedList), const Offset(0, itemHeight * 2)); + await tester.drag(find.byType(PositionedList), const Offset(0, itemHeight * 2)); await tester.pump(); expect(find.text('Item 6'), findsNothing); expect(find.text('Item 7'), findsOneWidget); + expect(itemPositionsNotifier.itemPositions.value.firstWhere((position) => position.index == 7).itemLeadingEdge, 0); expect( - itemPositionsNotifier.itemPositions.value - .firstWhere((position) => position.index == 7) - .itemLeadingEdge, - 0); - expect( - itemPositionsNotifier.itemPositions.value - .firstWhere((position) => position.index == 7) - .itemTrailingEdge, - 1 / 10); + itemPositionsNotifier.itemPositions.value.firstWhere((position) => position.index == 7).itemTrailingEdge, + 1 / 10, + ); }); - testWidgets('List positioned with 0 at bottom scroll to item 5', - (WidgetTester tester) async { + testWidgets('List positioned with 0 at bottom scroll to item 5', (WidgetTester tester) async { final scrollController = ScrollController(); await setUpWidgetTest(tester, scrollController: scrollController); await tester.pump(); @@ -194,24 +153,16 @@ void main() { expect(find.text('Item 14'), findsOneWidget); expect(find.text('Item 15'), findsNothing); + expect(itemPositionsNotifier.itemPositions.value.firstWhere((position) => position.index == 5).itemLeadingEdge, 0); expect( - itemPositionsNotifier.itemPositions.value - .firstWhere((position) => position.index == 5) - .itemLeadingEdge, - 0); - expect( - itemPositionsNotifier.itemPositions.value - .firstWhere((position) => position.index == 4) - .itemLeadingEdge, - -1 / 10); + itemPositionsNotifier.itemPositions.value.firstWhere((position) => position.index == 4).itemLeadingEdge, + -1 / 10, + ); }); - testWidgets( - 'List positioned with 5 at bottom then scroll up 2 programatically', - (WidgetTester tester) async { + testWidgets('List positioned with 5 at bottom then scroll up 2 programatically', (WidgetTester tester) async { final scrollController = ScrollController(); - await setUpWidgetTest(tester, - topItem: 5, scrollController: scrollController); + await setUpWidgetTest(tester, topItem: 5, scrollController: scrollController); scrollController.jumpTo(itemHeight * 2); await tester.pump(); @@ -222,28 +173,19 @@ void main() { expect(find.text('Item 17'), findsNothing); expect( - itemPositionsNotifier.itemPositions.value - .firstWhere((position) => position.index == 6) - .itemLeadingEdge, - -1 / 10); - expect( - itemPositionsNotifier.itemPositions.value - .firstWhere((position) => position.index == 7) - .itemLeadingEdge, - 0); + itemPositionsNotifier.itemPositions.value.firstWhere((position) => position.index == 6).itemLeadingEdge, + -1 / 10, + ); + expect(itemPositionsNotifier.itemPositions.value.firstWhere((position) => position.index == 7).itemLeadingEdge, 0); expect( - itemPositionsNotifier.itemPositions.value - .firstWhere((position) => position.index == 16) - .itemTrailingEdge, - 1); + itemPositionsNotifier.itemPositions.value.firstWhere((position) => position.index == 16).itemTrailingEdge, + 1, + ); }); - testWidgets('List positioned with 5 at bottom and initial scroll offset', - (WidgetTester tester) async { - final scrollController = - ScrollController(initialScrollOffset: itemHeight * 2); - await setUpWidgetTest(tester, - topItem: 5, scrollController: scrollController); + testWidgets('List positioned with 5 at bottom and initial scroll offset', (WidgetTester tester) async { + final scrollController = ScrollController(initialScrollOffset: itemHeight * 2); + await setUpWidgetTest(tester, topItem: 5, scrollController: scrollController); expect(find.text('Item 6'), findsNothing); expect(find.text('Item 7'), findsOneWidget); @@ -251,19 +193,13 @@ void main() { expect(find.text('Item 17'), findsNothing); expect( - itemPositionsNotifier.itemPositions.value - .firstWhere((position) => position.index == 6) - .itemLeadingEdge, - -1 / 10); - expect( - itemPositionsNotifier.itemPositions.value - .firstWhere((position) => position.index == 7) - .itemLeadingEdge, - 0); + itemPositionsNotifier.itemPositions.value.firstWhere((position) => position.index == 6).itemLeadingEdge, + -1 / 10, + ); + expect(itemPositionsNotifier.itemPositions.value.firstWhere((position) => position.index == 7).itemLeadingEdge, 0); expect( - itemPositionsNotifier.itemPositions.value - .firstWhere((position) => position.index == 16) - .itemTrailingEdge, - 1); + itemPositionsNotifier.itemPositions.value.firstWhere((position) => position.index == 16).itemTrailingEdge, + 1, + ); }); } diff --git a/packages/stream_chat_flutter/test/scrollable_positioned_list/reversed_scrollable_positioned_list_test.dart b/packages/stream_chat_flutter/test/scrollable_positioned_list/reversed_scrollable_positioned_list_test.dart index e2b16d8a4d..ddd1971659 100644 --- a/packages/stream_chat_flutter/test/scrollable_positioned_list/reversed_scrollable_positioned_list_test.dart +++ b/packages/stream_chat_flutter/test/scrollable_positioned_list/reversed_scrollable_positioned_list_test.dart @@ -51,122 +51,95 @@ void main() { expect(tester.getTopLeft(find.text('Item 9')).dy, 0); expect(find.text('Item 10'), findsNothing); - expect( - itemPositionsListener.itemPositions.value - .firstWhere((position) => position.index == 0) - .itemLeadingEdge, - 0); - expect( - itemPositionsListener.itemPositions.value - .firstWhere((position) => position.index == 9) - .itemTrailingEdge, - 1); + expect(itemPositionsListener.itemPositions.value.firstWhere((position) => position.index == 0).itemLeadingEdge, 0); + expect(itemPositionsListener.itemPositions.value.firstWhere((position) => position.index == 9).itemTrailingEdge, 1); }); - testWidgets('Scroll to 1 then 2 (both already on screen)', - (WidgetTester tester) async { + testWidgets('Scroll to 1 then 2 (both already on screen)', (WidgetTester tester) async { final itemScrollController = ItemScrollController(); final itemPositionsListener = ItemPositionsListener.create(); - await setUpWidgetTest(tester, - itemScrollController: itemScrollController, - itemPositionsListener: itemPositionsListener); + await setUpWidgetTest( + tester, + itemScrollController: itemScrollController, + itemPositionsListener: itemPositionsListener, + ); - unawaited( - itemScrollController.scrollTo(index: 1, duration: scrollDuration)); + unawaited(itemScrollController.scrollTo(index: 1, duration: scrollDuration)); await tester.pump(); await tester.pump(scrollDuration); expect(find.text('Item 0'), findsNothing); - expect( - itemPositionsListener.itemPositions.value - .firstWhere((position) => position.index == 1) - .itemLeadingEdge, - 0); + expect(itemPositionsListener.itemPositions.value.firstWhere((position) => position.index == 1).itemLeadingEdge, 0); expect(tester.getBottomRight(find.text('Item 1')).dy, screenHeight); - unawaited( - itemScrollController.scrollTo(index: 2, duration: scrollDuration)); + unawaited(itemScrollController.scrollTo(index: 2, duration: scrollDuration)); await tester.pump(); await tester.pump(scrollDuration); expect(find.text('Item 1'), findsNothing); expect(tester.getBottomRight(find.text('Item 2')).dy, screenHeight); + expect(itemPositionsListener.itemPositions.value.firstWhere((position) => position.index == 2).itemLeadingEdge, 0); expect( - itemPositionsListener.itemPositions.value - .firstWhere((position) => position.index == 2) - .itemLeadingEdge, - 0); - expect( - itemPositionsListener.itemPositions.value - .firstWhere((position) => position.index == 11) - .itemTrailingEdge, - 1); + itemPositionsListener.itemPositions.value.firstWhere((position) => position.index == 11).itemTrailingEdge, + 1, + ); }); - testWidgets('Scroll to 5 (already on screen) and then back to 0', - (WidgetTester tester) async { + testWidgets('Scroll to 5 (already on screen) and then back to 0', (WidgetTester tester) async { final itemScrollController = ItemScrollController(); final itemPositionsListener = ItemPositionsListener.create(); - await setUpWidgetTest(tester, - itemScrollController: itemScrollController, - itemPositionsListener: itemPositionsListener); + await setUpWidgetTest( + tester, + itemScrollController: itemScrollController, + itemPositionsListener: itemPositionsListener, + ); - unawaited( - itemScrollController.scrollTo(index: 5, duration: scrollDuration)); + unawaited(itemScrollController.scrollTo(index: 5, duration: scrollDuration)); await tester.pumpAndSettle(); - unawaited( - itemScrollController.scrollTo(index: 0, duration: scrollDuration)); + unawaited(itemScrollController.scrollTo(index: 0, duration: scrollDuration)); await tester.pumpAndSettle(); expect(find.text('Item 0'), findsOneWidget); expect(find.text('Item 9'), findsOneWidget); expect(find.text('Item 10'), findsNothing); - expect( - itemPositionsListener.itemPositions.value - .firstWhere((position) => position.index == 0) - .itemLeadingEdge, - 0); - expect( - itemPositionsListener.itemPositions.value - .firstWhere((position) => position.index == 9) - .itemTrailingEdge, - 1); + expect(itemPositionsListener.itemPositions.value.firstWhere((position) => position.index == 0).itemLeadingEdge, 0); + expect(itemPositionsListener.itemPositions.value.firstWhere((position) => position.index == 9).itemTrailingEdge, 1); }); - testWidgets('Scroll to 100 (not already on screen)', - (WidgetTester tester) async { + testWidgets('Scroll to 100 (not already on screen)', (WidgetTester tester) async { final itemScrollController = ItemScrollController(); final itemPositionsListener = ItemPositionsListener.create(); - await setUpWidgetTest(tester, - itemScrollController: itemScrollController, - itemPositionsListener: itemPositionsListener); + await setUpWidgetTest( + tester, + itemScrollController: itemScrollController, + itemPositionsListener: itemPositionsListener, + ); - unawaited( - itemScrollController.scrollTo(index: 100, duration: scrollDuration)); + unawaited(itemScrollController.scrollTo(index: 100, duration: scrollDuration)); await tester.pumpAndSettle(); expect(find.text('Item 99'), findsNothing); expect(find.text('Item 100'), findsOneWidget); expect( - itemPositionsListener.itemPositions.value - .firstWhere((position) => position.index == 100) - .itemLeadingEdge, - 0); + itemPositionsListener.itemPositions.value.firstWhere((position) => position.index == 100).itemLeadingEdge, + 0, + ); expect( - itemPositionsListener.itemPositions.value - .firstWhere((position) => position.index == 109) - .itemTrailingEdge, - 1); + itemPositionsListener.itemPositions.value.firstWhere((position) => position.index == 109).itemTrailingEdge, + 1, + ); }); testWidgets('Jump to 100', (WidgetTester tester) async { final itemScrollController = ItemScrollController(); final itemPositionsListener = ItemPositionsListener.create(); - await setUpWidgetTest(tester, - itemScrollController: itemScrollController, - itemPositionsListener: itemPositionsListener); + await setUpWidgetTest( + tester, + itemScrollController: itemScrollController, + itemPositionsListener: itemPositionsListener, + ); itemScrollController.jumpTo(index: 100); await tester.pumpAndSettle(); @@ -175,19 +148,16 @@ void main() { expect(tester.getTopLeft(find.text('Item 109')).dy, 0); expect( - itemPositionsListener.itemPositions.value - .firstWhere((position) => position.index == 100) - .itemLeadingEdge, - 0); + itemPositionsListener.itemPositions.value.firstWhere((position) => position.index == 100).itemLeadingEdge, + 0, + ); expect( - itemPositionsListener.itemPositions.value - .firstWhere((position) => position.index == 109) - .itemTrailingEdge, - 1); + itemPositionsListener.itemPositions.value.firstWhere((position) => position.index == 109).itemTrailingEdge, + 1, + ); }); - testWidgets('padding test - centered sliver at bottom', - (WidgetTester tester) async { + testWidgets('padding test - centered sliver at bottom', (WidgetTester tester) async { final itemScrollController = ItemScrollController(); await setUpWidgetTest( tester, @@ -195,26 +165,23 @@ void main() { padding: const EdgeInsets.all(10), ); - expect(tester.getBottomLeft(find.text('Item 0')), - const Offset(10, screenHeight - 10)); - expect(tester.getBottomLeft(find.text('Item 1')), - const Offset(10, screenHeight - (itemHeight + 10))); - expect(tester.getTopRight(find.text('Item 1')), - const Offset(screenWidth - 10, screenHeight - (10 + itemHeight * 2))); + expect(tester.getBottomLeft(find.text('Item 0')), const Offset(10, screenHeight - 10)); + expect(tester.getBottomLeft(find.text('Item 1')), const Offset(10, screenHeight - (itemHeight + 10))); + expect( + tester.getTopRight(find.text('Item 1')), + const Offset(screenWidth - 10, screenHeight - (10 + itemHeight * 2)), + ); - unawaited( - itemScrollController.scrollTo(index: 490, duration: scrollDuration)); + unawaited(itemScrollController.scrollTo(index: 490, duration: scrollDuration)); await tester.pumpAndSettle(); - await tester.drag( - find.byType(ScrollablePositionedList), const Offset(0, 100)); + await tester.drag(find.byType(ScrollablePositionedList), const Offset(0, 100)); await tester.pumpAndSettle(); expect(tester.getTopLeft(find.text('Item 499')), const Offset(10, 10)); }); - testWidgets('padding test - centered sliver not at bottom', - (WidgetTester tester) async { + testWidgets('padding test - centered sliver not at bottom', (WidgetTester tester) async { final itemScrollController = ItemScrollController(); await setUpWidgetTest( tester, @@ -223,15 +190,11 @@ void main() { padding: const EdgeInsets.all(10), ); - await tester.drag( - find.byType(ScrollablePositionedList), const Offset(0, -200)); + await tester.drag(find.byType(ScrollablePositionedList), const Offset(0, -200)); await tester.pumpAndSettle(); - expect(tester.getBottomLeft(find.text('Item 0')), - const Offset(10, screenHeight - 10)); - expect(tester.getBottomLeft(find.text('Item 2')), - const Offset(10, screenHeight - (10 + itemHeight * 2))); - expect(tester.getBottomLeft(find.text('Item 3')), - const Offset(10, screenHeight - (10 + itemHeight * 3))); + expect(tester.getBottomLeft(find.text('Item 0')), const Offset(10, screenHeight - 10)); + expect(tester.getBottomLeft(find.text('Item 2')), const Offset(10, screenHeight - (10 + itemHeight * 2))); + expect(tester.getBottomLeft(find.text('Item 3')), const Offset(10, screenHeight - (10 + itemHeight * 3))); }); } diff --git a/packages/stream_chat_flutter/test/scrollable_positioned_list/scrollable_positioned_list_test.dart b/packages/stream_chat_flutter/test/scrollable_positioned_list/scrollable_positioned_list_test.dart index d3c19b2f0f..baa5196c49 100644 --- a/packages/stream_chat_flutter/test/scrollable_positioned_list/scrollable_positioned_list_test.dart +++ b/packages/stream_chat_flutter/test/scrollable_positioned_list/scrollable_positioned_list_test.dart @@ -52,8 +52,7 @@ void main() { 'index must be in the range of 0 to itemCount - 1', ); return SizedBox( - height: - variableHeight ? (itemHeight + (index % 13) * 5) : itemHeight, + height: variableHeight ? (itemHeight + (index % 13) * 5) : itemHeight, child: Text('Item $index'), ); }, @@ -85,24 +84,12 @@ void main() { expect(find.text('Item 9'), findsOneWidget); expect(find.text('Item 10'), findsNothing); - expect( - itemPositionsListener.itemPositions.value - .firstWhere((position) => position.index == 0) - .itemLeadingEdge, - 0); - expect( - itemPositionsListener.itemPositions.value - .firstWhere((position) => position.index == 9) - .itemTrailingEdge, - 1); - expect( - itemPositionsListener.itemPositions.value - .where((position) => position.index == 10), - isEmpty); + expect(itemPositionsListener.itemPositions.value.firstWhere((position) => position.index == 0).itemLeadingEdge, 0); + expect(itemPositionsListener.itemPositions.value.firstWhere((position) => position.index == 9).itemTrailingEdge, 1); + expect(itemPositionsListener.itemPositions.value.where((position) => position.index == 10), isEmpty); }); - testWidgets('List positioned with 0 at top - use default values', - (WidgetTester tester) async { + testWidgets('List positioned with 0 at top - use default values', (WidgetTester tester) async { final itemPositionsListener = ItemPositionsListener.create(); tester.view.devicePixelRatio = 1.0; tester.view.physicalSize = const Size(screenWidth, screenHeight); @@ -124,88 +111,68 @@ void main() { expect(find.text('Item 9'), findsOneWidget); expect(find.text('Item 10'), findsNothing); - expect( - itemPositionsListener.itemPositions.value - .firstWhere((position) => position.index == 0) - .itemLeadingEdge, - 0); - expect( - itemPositionsListener.itemPositions.value - .firstWhere((position) => position.index == 9) - .itemTrailingEdge, - 1); + expect(itemPositionsListener.itemPositions.value.firstWhere((position) => position.index == 0).itemLeadingEdge, 0); + expect(itemPositionsListener.itemPositions.value.firstWhere((position) => position.index == 9).itemTrailingEdge, 1); }); testWidgets('List positioned with 5 at top', (WidgetTester tester) async { final itemPositionsListener = ItemPositionsListener.create(); - await setUpWidgetTest(tester, - itemPositionsListener: itemPositionsListener, initialIndex: 5); + await setUpWidgetTest(tester, itemPositionsListener: itemPositionsListener, initialIndex: 5); expect(find.text('Item 4'), findsNothing); expect(find.text('Item 5'), findsOneWidget); - expect( - itemPositionsListener.itemPositions.value - .where((position) => position.index == 4), - isEmpty); - expect( - itemPositionsListener.itemPositions.value - .firstWhere((position) => position.index == 5) - .itemLeadingEdge, - 0); + expect(itemPositionsListener.itemPositions.value.where((position) => position.index == 4), isEmpty); + expect(itemPositionsListener.itemPositions.value.firstWhere((position) => position.index == 5).itemLeadingEdge, 0); }); testWidgets('List positioned with 9 at middle', (WidgetTester tester) async { final itemPositionsListener = ItemPositionsListener.create(); - await setUpWidgetTest(tester, - itemPositionsListener: itemPositionsListener, - initialIndex: 9, - initialAlignment: 0.5); + await setUpWidgetTest(tester, itemPositionsListener: itemPositionsListener, initialIndex: 9, initialAlignment: 0.5); expect(tester.getTopLeft(find.text('Item 9')).dy, screenHeight / 2); expect( - itemPositionsListener.itemPositions.value - .firstWhere((position) => position.index == 9) - .itemLeadingEdge, - 0.5); + itemPositionsListener.itemPositions.value.firstWhere((position) => position.index == 9).itemLeadingEdge, + 0.5, + ); }); - testWidgets('List positioned with 9 half way off top', - (WidgetTester tester) async { + testWidgets('List positioned with 9 half way off top', (WidgetTester tester) async { final itemPositionsListener = ItemPositionsListener.create(); - await setUpWidgetTest(tester, - itemPositionsListener: itemPositionsListener, - initialIndex: 9, - initialAlignment: -(itemHeight / screenHeight) / 2); + await setUpWidgetTest( + tester, + itemPositionsListener: itemPositionsListener, + initialIndex: 9, + initialAlignment: -(itemHeight / screenHeight) / 2, + ); expect(tester.getTopLeft(find.text('Item 9')).dy, -itemHeight / 2); expect( - itemPositionsListener.itemPositions.value - .firstWhere((position) => position.index == 9) - .itemLeadingEdge, - -(itemHeight / screenHeight) / 2); + itemPositionsListener.itemPositions.value.firstWhere((position) => position.index == 9).itemLeadingEdge, + -(itemHeight / screenHeight) / 2, + ); expect( - itemPositionsListener.itemPositions.value - .firstWhere((position) => position.index == 9) - .itemTrailingEdge, - (itemHeight / screenHeight) / 2); + itemPositionsListener.itemPositions.value.firstWhere((position) => position.index == 9).itemTrailingEdge, + (itemHeight / screenHeight) / 2, + ); }); testWidgets('Scroll to 9 half way off top', (WidgetTester tester) async { final itemPositionsListener = ItemPositionsListener.create(); final itemScrollController = ItemScrollController(); expect(itemScrollController.isAttached, false); - await setUpWidgetTest(tester, - itemPositionsListener: itemPositionsListener, - itemScrollController: itemScrollController); + await setUpWidgetTest( + tester, + itemPositionsListener: itemPositionsListener, + itemScrollController: itemScrollController, + ); expect(itemScrollController.isAttached, true); - unawaited(itemScrollController.scrollTo( - index: 9, - duration: scrollDuration, - alignment: -(itemHeight / screenHeight) / 2)); + unawaited( + itemScrollController.scrollTo(index: 9, duration: scrollDuration, alignment: -(itemHeight / screenHeight) / 2), + ); await tester.pump(); await tester.pump(); await tester.pump(scrollDuration + scrollDurationTolerance); @@ -213,54 +180,51 @@ void main() { expect(tester.getTopLeft(find.text('Item 9')).dy, -itemHeight / 2); expect( - itemPositionsListener.itemPositions.value - .firstWhere((position) => position.index == 9) - .itemLeadingEdge, - -(itemHeight / screenHeight) / 2); + itemPositionsListener.itemPositions.value.firstWhere((position) => position.index == 9).itemLeadingEdge, + -(itemHeight / screenHeight) / 2, + ); expect( - itemPositionsListener.itemPositions.value - .firstWhere((position) => position.index == 9) - .itemTrailingEdge, - (itemHeight / screenHeight) / 2); + itemPositionsListener.itemPositions.value.firstWhere((position) => position.index == 9).itemTrailingEdge, + (itemHeight / screenHeight) / 2, + ); }); testWidgets('Jump to 9 half way off top', (WidgetTester tester) async { final itemPositionsListener = ItemPositionsListener.create(); final itemScrollController = ItemScrollController(); - await setUpWidgetTest(tester, - itemPositionsListener: itemPositionsListener, - itemScrollController: itemScrollController); + await setUpWidgetTest( + tester, + itemPositionsListener: itemPositionsListener, + itemScrollController: itemScrollController, + ); - itemScrollController.jumpTo( - index: 9, alignment: -(itemHeight / screenHeight) / 2); + itemScrollController.jumpTo(index: 9, alignment: -(itemHeight / screenHeight) / 2); await tester.pump(); expect(tester.getTopLeft(find.text('Item 9')).dy, -itemHeight / 2); expect( - itemPositionsListener.itemPositions.value - .firstWhere((position) => position.index == 9) - .itemLeadingEdge, - -(itemHeight / screenHeight) / 2); + itemPositionsListener.itemPositions.value.firstWhere((position) => position.index == 9).itemLeadingEdge, + -(itemHeight / screenHeight) / 2, + ); expect( - itemPositionsListener.itemPositions.value - .firstWhere((position) => position.index == 9) - .itemTrailingEdge, - (itemHeight / screenHeight) / 2); + itemPositionsListener.itemPositions.value.firstWhere((position) => position.index == 9).itemTrailingEdge, + (itemHeight / screenHeight) / 2, + ); }); - testWidgets('List positioned with 9 at middle scroll to 15 at bottom', - (WidgetTester tester) async { + testWidgets('List positioned with 9 at middle scroll to 15 at bottom', (WidgetTester tester) async { final itemScrollController = ItemScrollController(); final itemPositionsListener = ItemPositionsListener.create(); - await setUpWidgetTest(tester, - itemScrollController: itemScrollController, - itemPositionsListener: itemPositionsListener, - initialIndex: 9, - initialAlignment: 0.5); + await setUpWidgetTest( + tester, + itemScrollController: itemScrollController, + itemPositionsListener: itemPositionsListener, + initialIndex: 9, + initialAlignment: 0.5, + ); - unawaited(itemScrollController.scrollTo( - index: 16, duration: scrollDuration, alignment: 1)); + unawaited(itemScrollController.scrollTo(index: 16, duration: scrollDuration, alignment: 1)); await tester.pump(); await tester.pump(); await tester.pump(scrollDuration + scrollDurationTolerance); @@ -268,21 +232,21 @@ void main() { expect(tester.getBottomRight(find.text('Item 15')).dy, screenHeight); expect( - itemPositionsListener.itemPositions.value - .firstWhere((position) => position.index == 15) - .itemTrailingEdge, - 1.0); + itemPositionsListener.itemPositions.value.firstWhere((position) => position.index == 15).itemTrailingEdge, + 1.0, + ); }); testWidgets('Scroll to 1 (already on screen)', (WidgetTester tester) async { final itemScrollController = ItemScrollController(); final itemPositionsListener = ItemPositionsListener.create(); - await setUpWidgetTest(tester, - itemScrollController: itemScrollController, - itemPositionsListener: itemPositionsListener); + await setUpWidgetTest( + tester, + itemScrollController: itemScrollController, + itemPositionsListener: itemPositionsListener, + ); - unawaited( - itemScrollController.scrollTo(index: 1, duration: scrollDuration)); + unawaited(itemScrollController.scrollTo(index: 1, duration: scrollDuration)); await tester.pump(); await tester.pump(scrollDuration ~/ 2); @@ -295,83 +259,61 @@ void main() { expect(find.text('Item 1'), findsOneWidget); expect(find.text('Item 10'), findsOneWidget); + expect(itemPositionsListener.itemPositions.value.firstWhere((position) => position.index == 1).itemLeadingEdge, 0); expect( - itemPositionsListener.itemPositions.value - .firstWhere((position) => position.index == 1) - .itemLeadingEdge, - 0); - expect( - itemPositionsListener.itemPositions.value - .firstWhere((position) => position.index == 10) - .itemTrailingEdge, - 1); - expect( - itemPositionsListener.itemPositions.value - .where((position) => position.index == 11), - isEmpty); + itemPositionsListener.itemPositions.value.firstWhere((position) => position.index == 10).itemTrailingEdge, + 1, + ); + expect(itemPositionsListener.itemPositions.value.where((position) => position.index == 11), isEmpty); }); - testWidgets('Scroll to 1 then 2 (both already on screen)', - (WidgetTester tester) async { + testWidgets('Scroll to 1 then 2 (both already on screen)', (WidgetTester tester) async { final itemScrollController = ItemScrollController(); final itemPositionsListener = ItemPositionsListener.create(); - await setUpWidgetTest(tester, - itemScrollController: itemScrollController, - itemPositionsListener: itemPositionsListener); + await setUpWidgetTest( + tester, + itemScrollController: itemScrollController, + itemPositionsListener: itemPositionsListener, + ); - unawaited( - itemScrollController.scrollTo(index: 1, duration: scrollDuration)); + unawaited(itemScrollController.scrollTo(index: 1, duration: scrollDuration)); await tester.pump(); await tester.pump(scrollDuration); expect(find.text('Item 0'), findsNothing); expect(find.text('Item 1'), findsOneWidget); - unawaited( - itemScrollController.scrollTo(index: 2, duration: scrollDuration)); + unawaited(itemScrollController.scrollTo(index: 2, duration: scrollDuration)); await tester.pump(); await tester.pump(scrollDuration); expect(find.text('Item 1'), findsNothing); expect(find.text('Item 2'), findsOneWidget); + expect(itemPositionsListener.itemPositions.value.where((position) => position.index == 1), isEmpty); + expect(itemPositionsListener.itemPositions.value.firstWhere((position) => position.index == 2).itemLeadingEdge, 0); expect( - itemPositionsListener.itemPositions.value - .where((position) => position.index == 1), - isEmpty); - expect( - itemPositionsListener.itemPositions.value - .firstWhere((position) => position.index == 2) - .itemLeadingEdge, - 0); - expect( - itemPositionsListener.itemPositions.value - .firstWhere((position) => position.index == 11) - .itemTrailingEdge, - 1); + itemPositionsListener.itemPositions.value.firstWhere((position) => position.index == 11).itemTrailingEdge, + 1, + ); }); - testWidgets('Scroll to 5 (already on screen) and then back to 0', - (WidgetTester tester) async { + testWidgets('Scroll to 5 (already on screen) and then back to 0', (WidgetTester tester) async { final itemScrollController = ItemScrollController(); final itemPositionsListener = ItemPositionsListener.create(); - await setUpWidgetTest(tester, - itemScrollController: itemScrollController, - itemPositionsListener: itemPositionsListener); + await setUpWidgetTest( + tester, + itemScrollController: itemScrollController, + itemPositionsListener: itemPositionsListener, + ); - unawaited( - itemScrollController.scrollTo(index: 5, duration: scrollDuration)); + unawaited(itemScrollController.scrollTo(index: 5, duration: scrollDuration)); await tester.pump(); await tester.pump(); await tester.pump(scrollDuration + scrollDurationTolerance); - expect( - itemPositionsListener.itemPositions.value - .firstWhere((position) => position.index == 5) - .itemLeadingEdge, - 0); + expect(itemPositionsListener.itemPositions.value.firstWhere((position) => position.index == 5).itemLeadingEdge, 0); - unawaited( - itemScrollController.scrollTo(index: 0, duration: scrollDuration)); + unawaited(itemScrollController.scrollTo(index: 0, duration: scrollDuration)); await tester.pump(); await tester.pump(); await tester.pump(scrollDuration + scrollDurationTolerance); @@ -380,30 +322,23 @@ void main() { expect(find.text('Item 9'), findsOneWidget); expect(find.text('Item 10'), findsNothing); - expect( - itemPositionsListener.itemPositions.value - .firstWhere((position) => position.index == 0) - .itemLeadingEdge, - 0); - expect( - itemPositionsListener.itemPositions.value - .firstWhere((position) => position.index == 9) - .itemTrailingEdge, - 1); + expect(itemPositionsListener.itemPositions.value.firstWhere((position) => position.index == 0).itemLeadingEdge, 0); + expect(itemPositionsListener.itemPositions.value.firstWhere((position) => position.index == 9).itemTrailingEdge, 1); }); testWidgets('Scroll to 20 without fading', (WidgetTester tester) async { final itemScrollController = ItemScrollController(); final itemPositionsListener = ItemPositionsListener.create(); - await setUpWidgetTest(tester, - itemScrollController: itemScrollController, - itemPositionsListener: itemPositionsListener); + await setUpWidgetTest( + tester, + itemScrollController: itemScrollController, + itemPositionsListener: itemPositionsListener, + ); var fadeTransition = tester.widget(fadeTransitionFinder); final initialOpacity = fadeTransition.opacity; - unawaited( - itemScrollController.scrollTo(index: 20, duration: scrollDuration)); + unawaited(itemScrollController.scrollTo(index: 20, duration: scrollDuration)); await tester.pump(); await tester.pump(); await tester.pump(scrollDuration ~/ 2); @@ -417,16 +352,16 @@ void main() { expect(find.text('Item 20'), findsOneWidget); }); - testWidgets('Scroll to 100 (not already on screen)', - (WidgetTester tester) async { + testWidgets('Scroll to 100 (not already on screen)', (WidgetTester tester) async { final itemScrollController = ItemScrollController(); final itemPositionsListener = ItemPositionsListener.create(); - await setUpWidgetTest(tester, - itemScrollController: itemScrollController, - itemPositionsListener: itemPositionsListener); + await setUpWidgetTest( + tester, + itemScrollController: itemScrollController, + itemPositionsListener: itemPositionsListener, + ); - unawaited( - itemScrollController.scrollTo(index: 100, duration: scrollDuration)); + unawaited(itemScrollController.scrollTo(index: 100, duration: scrollDuration)); await tester.pump(); await tester.pump(); await tester.pump(scrollDuration + scrollDurationTolerance); @@ -435,26 +370,22 @@ void main() { expect(find.text('Item 100'), findsOneWidget); expect( - itemPositionsListener.itemPositions.value - .firstWhere((position) => position.index == 100) - .itemLeadingEdge, - 0); + itemPositionsListener.itemPositions.value.firstWhere((position) => position.index == 100).itemLeadingEdge, + 0, + ); expect( - itemPositionsListener.itemPositions.value - .firstWhere((position) => position.index == 109) - .itemTrailingEdge, - 1); + itemPositionsListener.itemPositions.value.firstWhere((position) => position.index == 109).itemTrailingEdge, + 1, + ); await tester.pumpAndSettle(); }); - testWidgets('Scroll to 100 (not already on screen) front scroll view', - (WidgetTester tester) async { + testWidgets('Scroll to 100 (not already on screen) front scroll view', (WidgetTester tester) async { final itemScrollController = ItemScrollController(); await setUpWidgetTest(tester, itemScrollController: itemScrollController); - unawaited( - itemScrollController.scrollTo(index: 100, duration: scrollDuration)); + unawaited(itemScrollController.scrollTo(index: 100, duration: scrollDuration)); await tester.pump(); await tester.pump(); expect(fadeTransitionFinder.evaluate().length, 2); @@ -489,13 +420,11 @@ void main() { ); }); - testWidgets('Scroll to 100 (not already on screen) back scroll view', - (WidgetTester tester) async { + testWidgets('Scroll to 100 (not already on screen) back scroll view', (WidgetTester tester) async { final itemScrollController = ItemScrollController(); await setUpWidgetTest(tester, itemScrollController: itemScrollController); - unawaited( - itemScrollController.scrollTo(index: 100, duration: scrollDuration)); + unawaited(itemScrollController.scrollTo(index: 100, duration: scrollDuration)); await tester.pump(); await tester.pump(); await tester.pump(scrollDuration ~/ 2); @@ -506,23 +435,22 @@ void main() { expect(find.text('Item 25', skipOffstage: false), findsNothing); }); - testWidgets('Scroll to 100 (not already on screen) then back to 0', - (WidgetTester tester) async { + testWidgets('Scroll to 100 (not already on screen) then back to 0', (WidgetTester tester) async { final itemScrollController = ItemScrollController(); final itemPositionsListener = ItemPositionsListener.create(); - await setUpWidgetTest(tester, - itemScrollController: itemScrollController, - itemPositionsListener: itemPositionsListener); + await setUpWidgetTest( + tester, + itemScrollController: itemScrollController, + itemPositionsListener: itemPositionsListener, + ); - unawaited( - itemScrollController.scrollTo(index: 100, duration: scrollDuration)); + unawaited(itemScrollController.scrollTo(index: 100, duration: scrollDuration)); await tester.pump(); await tester.pump(); await tester.pump(scrollDuration + scrollDurationTolerance); expect(find.text('Item 0'), findsNothing); - unawaited( - itemScrollController.scrollTo(index: 0, duration: scrollDuration)); + unawaited(itemScrollController.scrollTo(index: 0, duration: scrollDuration)); await tester.pump(); await tester.pump(); expect( @@ -538,29 +466,18 @@ void main() { expect(find.text('Item 0'), findsOneWidget); expect(tester.getTopLeft(find.text('Item 0')).dy, 0); - expect( - itemPositionsListener.itemPositions.value - .firstWhere((position) => position.index == 0) - .itemLeadingEdge, - 0); - expect( - itemPositionsListener.itemPositions.value - .firstWhere((position) => position.index == 9) - .itemTrailingEdge, - 1); + expect(itemPositionsListener.itemPositions.value.firstWhere((position) => position.index == 0).itemLeadingEdge, 0); + expect(itemPositionsListener.itemPositions.value.firstWhere((position) => position.index == 9).itemTrailingEdge, 1); }); - testWidgets('Scroll to 100 then back to 0 back scroll view', - (WidgetTester tester) async { + testWidgets('Scroll to 100 then back to 0 back scroll view', (WidgetTester tester) async { final itemScrollController = ItemScrollController(); await setUpWidgetTest(tester, itemScrollController: itemScrollController); - unawaited( - itemScrollController.scrollTo(index: 100, duration: scrollDuration)); + unawaited(itemScrollController.scrollTo(index: 100, duration: scrollDuration)); await tester.pumpAndSettle(); - unawaited( - itemScrollController.scrollTo(index: 0, duration: scrollDuration)); + unawaited(itemScrollController.scrollTo(index: 0, duration: scrollDuration)); await tester.pump(); await tester.pump(); await tester.pump(scrollDuration ~/ 2); @@ -571,19 +488,16 @@ void main() { await tester.pumpAndSettle(); }); - testWidgets('Scroll to 100 then back to 0 front scroll view', - (WidgetTester tester) async { + testWidgets('Scroll to 100 then back to 0 front scroll view', (WidgetTester tester) async { final itemScrollController = ItemScrollController(); await setUpWidgetTest(tester, itemScrollController: itemScrollController); - unawaited( - itemScrollController.scrollTo(index: 100, duration: scrollDuration)); + unawaited(itemScrollController.scrollTo(index: 100, duration: scrollDuration)); await tester.pump(); await tester.pump(); await tester.pump(scrollDuration + scrollDurationTolerance); - unawaited( - itemScrollController.scrollTo(index: 0, duration: scrollDuration)); + unawaited(itemScrollController.scrollTo(index: 0, duration: scrollDuration)); await tester.pump(); await tester.pump(); await tester.pump(scrollDuration ~/ 2); @@ -602,20 +516,17 @@ void main() { final itemScrollController = ItemScrollController(); await setUpWidgetTest(tester, itemScrollController: itemScrollController); - unawaited( - itemScrollController.scrollTo(index: 100, duration: scrollDuration)); + unawaited(itemScrollController.scrollTo(index: 100, duration: scrollDuration)); await tester.pump(); await tester.pump(); await tester.pump(scrollDuration + scrollDurationTolerance); - unawaited( - itemScrollController.scrollTo(index: 0, duration: scrollDuration)); + unawaited(itemScrollController.scrollTo(index: 0, duration: scrollDuration)); await tester.pump(); await tester.pump(); await tester.pump(scrollDuration + scrollDurationTolerance); - unawaited( - itemScrollController.scrollTo(index: 100, duration: scrollDuration)); + unawaited(itemScrollController.scrollTo(index: 100, duration: scrollDuration)); await tester.pump(); await tester.pump(); await tester.pump(scrollDuration ~/ 2); @@ -629,9 +540,11 @@ void main() { testWidgets('Jump to 100', (WidgetTester tester) async { final itemScrollController = ItemScrollController(); final itemPositionsListener = ItemPositionsListener.create(); - await setUpWidgetTest(tester, - itemScrollController: itemScrollController, - itemPositionsListener: itemPositionsListener); + await setUpWidgetTest( + tester, + itemScrollController: itemScrollController, + itemPositionsListener: itemPositionsListener, + ); itemScrollController.jumpTo(index: 100); await tester.pump(); @@ -642,24 +555,23 @@ void main() { expect(tester.getBottomLeft(find.text('Item 109')).dy, screenHeight); expect( - itemPositionsListener.itemPositions.value - .firstWhere((position) => position.index == 100) - .itemLeadingEdge, - 0); + itemPositionsListener.itemPositions.value.firstWhere((position) => position.index == 100).itemLeadingEdge, + 0, + ); expect( - itemPositionsListener.itemPositions.value - .firstWhere((position) => position.index == 109) - .itemTrailingEdge, - 1); + itemPositionsListener.itemPositions.value.firstWhere((position) => position.index == 109).itemTrailingEdge, + 1, + ); }); - testWidgets('Jump to 100 and position at bottom', - (WidgetTester tester) async { + testWidgets('Jump to 100 and position at bottom', (WidgetTester tester) async { final itemScrollController = ItemScrollController(); final itemPositionsListener = ItemPositionsListener.create(); - await setUpWidgetTest(tester, - itemScrollController: itemScrollController, - itemPositionsListener: itemPositionsListener); + await setUpWidgetTest( + tester, + itemScrollController: itemScrollController, + itemPositionsListener: itemPositionsListener, + ); itemScrollController.jumpTo(index: 100, alignment: 1); await tester.pump(); @@ -669,23 +581,20 @@ void main() { expect(tester.getBottomLeft(find.text('Item 99')).dy, screenHeight); expect( - itemPositionsListener.itemPositions.value - .firstWhere((position) => position.index == 99) - .itemTrailingEdge, - 1.0); - expect( - itemPositionsListener.itemPositions.value - .where((position) => position.index == 100), - isEmpty); + itemPositionsListener.itemPositions.value.firstWhere((position) => position.index == 99).itemTrailingEdge, + 1.0, + ); + expect(itemPositionsListener.itemPositions.value.where((position) => position.index == 100), isEmpty); }); - testWidgets('Jump to 100 and position at middle', - (WidgetTester tester) async { + testWidgets('Jump to 100 and position at middle', (WidgetTester tester) async { final itemScrollController = ItemScrollController(); final itemPositionsListener = ItemPositionsListener.create(); - await setUpWidgetTest(tester, - itemScrollController: itemScrollController, - itemPositionsListener: itemPositionsListener); + await setUpWidgetTest( + tester, + itemScrollController: itemScrollController, + itemPositionsListener: itemPositionsListener, + ); itemScrollController.jumpTo(index: 100, alignment: 0.5); await tester.pump(); @@ -695,21 +604,21 @@ void main() { expect(tester.getTopLeft(find.text('Item 100')).dy, screenHeight / 2); expect( - itemPositionsListener.itemPositions.value - .firstWhere((position) => position.index == 100) - .itemLeadingEdge, - 0.5); + itemPositionsListener.itemPositions.value.firstWhere((position) => position.index == 100).itemLeadingEdge, + 0.5, + ); }); - testWidgets('Manually scroll a significant distance, jump to 100', - (WidgetTester tester) async { + testWidgets('Manually scroll a significant distance, jump to 100', (WidgetTester tester) async { // Test for https://github.com/google/flutter.widgets/issues/144. final itemScrollController = ItemScrollController(); final itemPositionsListener = ItemPositionsListener.create(); - await setUpWidgetTest(tester, - itemScrollController: itemScrollController, - itemPositionsListener: itemPositionsListener, - variableHeight: true); + await setUpWidgetTest( + tester, + itemScrollController: itemScrollController, + itemPositionsListener: itemPositionsListener, + variableHeight: true, + ); final listFinder = find.byType(ScrollablePositionedList); for (var i = 0; i < 5; i += 1) { @@ -723,16 +632,16 @@ void main() { expect(tester.getTopLeft(find.text('Item 100')).dy, 0); }, skip: true); - testWidgets('Scroll to 100 and position at bottom', - (WidgetTester tester) async { + testWidgets('Scroll to 100 and position at bottom', (WidgetTester tester) async { final itemScrollController = ItemScrollController(); final itemPositionsListener = ItemPositionsListener.create(); - await setUpWidgetTest(tester, - itemScrollController: itemScrollController, - itemPositionsListener: itemPositionsListener); + await setUpWidgetTest( + tester, + itemScrollController: itemScrollController, + itemPositionsListener: itemPositionsListener, + ); - unawaited(itemScrollController.scrollTo( - index: 100, alignment: 1, duration: scrollDuration)); + unawaited(itemScrollController.scrollTo(index: 100, alignment: 1, duration: scrollDuration)); await tester.pump(); await tester.pump(); await tester.pump(scrollDuration + scrollDurationTolerance); @@ -740,26 +649,22 @@ void main() { expect(tester.getBottomLeft(find.text('Item 99')).dy, screenHeight); expect( - itemPositionsListener.itemPositions.value - .firstWhere((position) => position.index == 99) - .itemTrailingEdge, - 1.0); - expect( - itemPositionsListener.itemPositions.value - .where((position) => position.index == 100), - isEmpty); + itemPositionsListener.itemPositions.value.firstWhere((position) => position.index == 99).itemTrailingEdge, + 1.0, + ); + expect(itemPositionsListener.itemPositions.value.where((position) => position.index == 100), isEmpty); }); - testWidgets('Scroll to 100 and position at middle', - (WidgetTester tester) async { + testWidgets('Scroll to 100 and position at middle', (WidgetTester tester) async { final itemScrollController = ItemScrollController(); final itemPositionsListener = ItemPositionsListener.create(); - await setUpWidgetTest(tester, - itemScrollController: itemScrollController, - itemPositionsListener: itemPositionsListener); + await setUpWidgetTest( + tester, + itemScrollController: itemScrollController, + itemPositionsListener: itemPositionsListener, + ); - unawaited(itemScrollController.scrollTo( - index: 100, alignment: 0.5, duration: scrollDuration)); + unawaited(itemScrollController.scrollTo(index: 100, alignment: 0.5, duration: scrollDuration)); await tester.pump(); await tester.pump(); await tester.pump(scrollDuration + scrollDurationTolerance); @@ -767,22 +672,21 @@ void main() { expect(tester.getTopLeft(find.text('Item 100')).dy, screenHeight / 2); expect( - itemPositionsListener.itemPositions.value - .firstWhere((position) => position.index == 100) - .itemLeadingEdge, - 0.5); + itemPositionsListener.itemPositions.value.firstWhere((position) => position.index == 100).itemLeadingEdge, + 0.5, + ); }); - testWidgets('Scroll to 9 and position at middle', - (WidgetTester tester) async { + testWidgets('Scroll to 9 and position at middle', (WidgetTester tester) async { final itemScrollController = ItemScrollController(); final itemPositionsListener = ItemPositionsListener.create(); - await setUpWidgetTest(tester, - itemScrollController: itemScrollController, - itemPositionsListener: itemPositionsListener); + await setUpWidgetTest( + tester, + itemScrollController: itemScrollController, + itemPositionsListener: itemPositionsListener, + ); - unawaited(itemScrollController.scrollTo( - index: 9, alignment: 0.5, duration: scrollDuration)); + unawaited(itemScrollController.scrollTo(index: 9, alignment: 0.5, duration: scrollDuration)); await tester.pump(); await tester.pump(); await tester.pump(scrollDuration + scrollDurationTolerance); @@ -790,22 +694,21 @@ void main() { expect(tester.getTopLeft(find.text('Item 9')).dy, screenHeight / 2); expect( - itemPositionsListener.itemPositions.value - .firstWhere((position) => position.index == 9) - .itemLeadingEdge, - 0.5); + itemPositionsListener.itemPositions.value.firstWhere((position) => position.index == 9).itemLeadingEdge, + 0.5, + ); }); - testWidgets('Scroll up a little then jump to 100', - (WidgetTester tester) async { + testWidgets('Scroll up a little then jump to 100', (WidgetTester tester) async { final itemScrollController = ItemScrollController(); final itemPositionsListener = ItemPositionsListener.create(); - await setUpWidgetTest(tester, - itemScrollController: itemScrollController, - itemPositionsListener: itemPositionsListener); + await setUpWidgetTest( + tester, + itemScrollController: itemScrollController, + itemPositionsListener: itemPositionsListener, + ); - await tester.drag( - find.byType(ScrollablePositionedList), const Offset(0, -10)); + await tester.drag(find.byType(ScrollablePositionedList), const Offset(0, -10)); await tester.pumpAndSettle(); itemScrollController.jumpTo(index: 100); @@ -817,24 +720,20 @@ void main() { expect(tester.getBottomLeft(find.text('Item 109')).dy, screenHeight); expect( - itemPositionsListener.itemPositions.value - .firstWhere((position) => position.index == 100) - .itemLeadingEdge, - 0); + itemPositionsListener.itemPositions.value.firstWhere((position) => position.index == 100).itemLeadingEdge, + 0, + ); expect( - itemPositionsListener.itemPositions.value - .firstWhere((position) => position.index == 109) - .itemTrailingEdge, - 1); + itemPositionsListener.itemPositions.value.firstWhere((position) => position.index == 109).itemTrailingEdge, + 1, + ); }); - testWidgets('Scroll to 100 Jump to 0 Scroll to 100', - (WidgetTester tester) async { + testWidgets('Scroll to 100 Jump to 0 Scroll to 100', (WidgetTester tester) async { final itemScrollController = ItemScrollController(); await setUpWidgetTest(tester, itemScrollController: itemScrollController); - unawaited( - itemScrollController.scrollTo(index: 100, duration: scrollDuration)); + unawaited(itemScrollController.scrollTo(index: 100, duration: scrollDuration)); await tester.pump(); await tester.pump(); await tester.pump(scrollDuration + scrollDurationTolerance); @@ -844,8 +743,7 @@ void main() { await tester.pump(); await tester.pump(scrollDuration + scrollDurationTolerance); - unawaited( - itemScrollController.scrollTo(index: 100, duration: scrollDuration)); + unawaited(itemScrollController.scrollTo(index: 100, duration: scrollDuration)); await tester.pump(); await tester.pump(); await tester.pump(scrollDuration ~/ 2); @@ -859,13 +757,11 @@ void main() { expect(tester.getBottomLeft(find.text('Item 109')).dy, screenHeight); }); - testWidgets('Scroll to 100 stop before half way', - (WidgetTester tester) async { + testWidgets('Scroll to 100 stop before half way', (WidgetTester tester) async { final itemScrollController = ItemScrollController(); await setUpWidgetTest(tester, itemScrollController: itemScrollController); - unawaited( - itemScrollController.scrollTo(index: 100, duration: scrollDuration)); + unawaited(itemScrollController.scrollTo(index: 100, duration: scrollDuration)); await tester.pump(); await tester.pump(); await tester.pump(scrollDuration ~/ 2 - scrollDuration ~/ 20); @@ -884,8 +780,7 @@ void main() { final itemScrollController = ItemScrollController(); await setUpWidgetTest(tester, itemScrollController: itemScrollController); - unawaited( - itemScrollController.scrollTo(index: 100, duration: scrollDuration)); + unawaited(itemScrollController.scrollTo(index: 100, duration: scrollDuration)); await tester.pump(); await tester.pump(); await tester.pump(scrollDuration ~/ 2); @@ -904,12 +799,10 @@ void main() { final itemScrollController = ItemScrollController(); await setUpWidgetTest(tester, itemScrollController: itemScrollController); - unawaited( - itemScrollController.scrollTo(index: 100, duration: scrollDuration)); + unawaited(itemScrollController.scrollTo(index: 100, duration: scrollDuration)); await tester.pumpAndSettle(); - unawaited( - itemScrollController.scrollTo(index: 0, duration: scrollDuration)); + unawaited(itemScrollController.scrollTo(index: 0, duration: scrollDuration)); await tester.pump(); await tester.pump(); await tester.pump(scrollDuration ~/ 2 - scrollDuration ~/ 20); @@ -927,21 +820,18 @@ void main() { final itemScrollController = ItemScrollController(); await setUpWidgetTest(tester, itemScrollController: itemScrollController); - unawaited( - itemScrollController.scrollTo(index: 100, duration: scrollDuration)); + unawaited(itemScrollController.scrollTo(index: 100, duration: scrollDuration)); await tester.pump(); await tester.pump(); await tester.pump(scrollDuration ~/ 2 + scrollDuration ~/ 20); expect(find.text('Item 9', skipOffstage: false), findsOneWidget); - expect(tester.getBottomLeft(find.text('Item 100')).dy, - closeTo(screenHeight, tolerance)); + expect(tester.getBottomLeft(find.text('Item 100')).dy, closeTo(screenHeight, tolerance)); await tester.tap(find.byType(ScrollablePositionedList)); await tester.pump(); - expect(tester.getBottomLeft(find.text('Item 100')).dy, - closeTo(screenHeight, tolerance)); + expect(tester.getBottomLeft(find.text('Item 100')).dy, closeTo(screenHeight, tolerance)); expect(find.text('Item 9', skipOffstage: false), findsNothing); expect(fadeTransitionFinder, findsNWidgets(1)); @@ -952,12 +842,10 @@ void main() { final itemScrollController = ItemScrollController(); await setUpWidgetTest(tester, itemScrollController: itemScrollController); - unawaited( - itemScrollController.scrollTo(index: 100, duration: scrollDuration)); + unawaited(itemScrollController.scrollTo(index: 100, duration: scrollDuration)); await tester.pumpAndSettle(); - unawaited( - itemScrollController.scrollTo(index: 0, duration: scrollDuration)); + unawaited(itemScrollController.scrollTo(index: 0, duration: scrollDuration)); await tester.pump(); await tester.pump(); await tester.pump(scrollDuration ~/ 2 + scrollDuration ~/ 20); @@ -976,12 +864,10 @@ void main() { final itemScrollController = ItemScrollController(); await setUpWidgetTest(tester, itemScrollController: itemScrollController); - unawaited( - itemScrollController.scrollTo(index: 100, duration: scrollDuration)); + unawaited(itemScrollController.scrollTo(index: 100, duration: scrollDuration)); await tester.pumpAndSettle(); - unawaited( - itemScrollController.scrollTo(index: 0, duration: scrollDuration)); + unawaited(itemScrollController.scrollTo(index: 0, duration: scrollDuration)); await tester.pump(); await tester.pump(); await tester.pump(scrollDuration ~/ 2); @@ -995,13 +881,11 @@ void main() { await tester.pumpAndSettle(); }); - testWidgets('Scroll to 100 jump to 250 half way', - (WidgetTester tester) async { + testWidgets('Scroll to 100 jump to 250 half way', (WidgetTester tester) async { final itemScrollController = ItemScrollController(); await setUpWidgetTest(tester, itemScrollController: itemScrollController); - unawaited( - itemScrollController.scrollTo(index: 100, duration: scrollDuration)); + unawaited(itemScrollController.scrollTo(index: 100, duration: scrollDuration)); await tester.pump(); await tester.pump(); await tester.pump(scrollDuration ~/ 2); @@ -1016,17 +900,14 @@ void main() { await tester.pumpAndSettle(); }); - testWidgets('Scroll to 250, scroll to 100, jump to 0 half way', - (WidgetTester tester) async { + testWidgets('Scroll to 250, scroll to 100, jump to 0 half way', (WidgetTester tester) async { final itemScrollController = ItemScrollController(); await setUpWidgetTest(tester, itemScrollController: itemScrollController); - unawaited( - itemScrollController.scrollTo(index: 250, duration: scrollDuration)); + unawaited(itemScrollController.scrollTo(index: 250, duration: scrollDuration)); await tester.pumpAndSettle(); - unawaited( - itemScrollController.scrollTo(index: 100, duration: scrollDuration)); + unawaited(itemScrollController.scrollTo(index: 100, duration: scrollDuration)); await tester.pump(); await tester.pump(); await tester.pump(scrollDuration ~/ 2); @@ -1040,38 +921,32 @@ void main() { await tester.pumpAndSettle(); }); - testWidgets('Scroll to 100 scroll to 250 half way', - (WidgetTester tester) async { + testWidgets('Scroll to 100 scroll to 250 half way', (WidgetTester tester) async { final itemScrollController = ItemScrollController(); await setUpWidgetTest(tester, itemScrollController: itemScrollController); - unawaited( - itemScrollController.scrollTo(index: 100, duration: scrollDuration)); + unawaited(itemScrollController.scrollTo(index: 100, duration: scrollDuration)); await tester.pump(); await tester.pump(); await tester.pump(scrollDuration ~/ 2); - unawaited( - itemScrollController.scrollTo(index: 250, duration: scrollDuration)); + unawaited(itemScrollController.scrollTo(index: 250, duration: scrollDuration)); await tester.pumpAndSettle(); expect(tester.getTopLeft(find.text('Item 250')).dy, 0); expect(find.text('Item 100'), findsNothing); }); - testWidgets("Second scroll future doesn't complete until scroll is done", - (WidgetTester tester) async { + testWidgets("Second scroll future doesn't complete until scroll is done", (WidgetTester tester) async { final itemScrollController = ItemScrollController(); await setUpWidgetTest(tester, itemScrollController: itemScrollController); - unawaited( - itemScrollController.scrollTo(index: 100, duration: scrollDuration)); + unawaited(itemScrollController.scrollTo(index: 100, duration: scrollDuration)); await tester.pump(); await tester.pump(); await tester.pump(scrollDuration ~/ 2); - final scrollFuture2 = - itemScrollController.scrollTo(index: 250, duration: scrollDuration); + final scrollFuture2 = itemScrollController.scrollTo(index: 250, duration: scrollDuration); var futureComplete = false; unawaited(scrollFuture2.then((_) => futureComplete = true)); @@ -1087,42 +962,33 @@ void main() { expect(futureComplete, isTrue); }); - testWidgets('Scroll to 250, scroll to 100, scroll to 0 half way', - (WidgetTester tester) async { + testWidgets('Scroll to 250, scroll to 100, scroll to 0 half way', (WidgetTester tester) async { final itemScrollController = ItemScrollController(); await setUpWidgetTest(tester, itemScrollController: itemScrollController); - unawaited( - itemScrollController.scrollTo(index: 250, duration: scrollDuration)); + unawaited(itemScrollController.scrollTo(index: 250, duration: scrollDuration)); await tester.pumpAndSettle(); - unawaited( - itemScrollController.scrollTo(index: 100, duration: scrollDuration)); + unawaited(itemScrollController.scrollTo(index: 100, duration: scrollDuration)); await tester.pump(); await tester.pump(); await tester.pump(scrollDuration ~/ 2); - unawaited( - itemScrollController.scrollTo(index: 0, duration: scrollDuration)); + unawaited(itemScrollController.scrollTo(index: 0, duration: scrollDuration)); await tester.pumpAndSettle(); expect(tester.getTopLeft(find.text('Item 0')).dy, 0); expect(find.text('Item 100'), findsNothing); }); - testWidgets( - 'Scroll to 100, scroll to 200, then scroll to 300 without waiting', - (WidgetTester tester) async { + testWidgets('Scroll to 100, scroll to 200, then scroll to 300 without waiting', (WidgetTester tester) async { // Possibly https://github.com/google/flutter.widgets/issues/171. final itemScrollController = ItemScrollController(); await setUpWidgetTest(tester, itemScrollController: itemScrollController); - unawaited( - itemScrollController.scrollTo(index: 100, duration: scrollDuration)); - unawaited( - itemScrollController.scrollTo(index: 200, duration: scrollDuration)); - unawaited( - itemScrollController.scrollTo(index: 300, duration: scrollDuration)); + unawaited(itemScrollController.scrollTo(index: 100, duration: scrollDuration)); + unawaited(itemScrollController.scrollTo(index: 200, duration: scrollDuration)); + unawaited(itemScrollController.scrollTo(index: 300, duration: scrollDuration)); await tester.pumpAndSettle(); expect(find.text('Item 100'), findsNothing); @@ -1138,9 +1004,11 @@ void main() { (WidgetTester tester) async { final itemScrollController = ItemScrollController(); final itemPositionsListener = ItemPositionsListener.create(); - await setUpWidgetTest(tester, - itemScrollController: itemScrollController, - itemPositionsListener: itemPositionsListener); + await setUpWidgetTest( + tester, + itemScrollController: itemScrollController, + itemPositionsListener: itemPositionsListener, + ); itemScrollController.jumpTo(index: 400, alignment: 1); await tester.pumpAndSettle(); @@ -1150,12 +1018,10 @@ void main() { await tester.drag(listFinder, const Offset(0, -screenHeight)); await tester.pumpAndSettle(); - unawaited(itemScrollController.scrollTo( - index: 100, alignment: 1, duration: scrollDuration)); + unawaited(itemScrollController.scrollTo(index: 100, alignment: 1, duration: scrollDuration)); await tester.pumpAndSettle(); - unawaited(itemScrollController.scrollTo( - index: 400, alignment: 1, duration: scrollDuration)); + unawaited(itemScrollController.scrollTo(index: 400, alignment: 1, duration: scrollDuration)); await tester.pumpAndSettle(); final itemFinder = find.text('Item 399'); @@ -1166,12 +1032,9 @@ void main() { testWidgets('physics', (WidgetTester tester) async { final itemScrollController = ItemScrollController(); - await setUpWidgetTest(tester, - itemScrollController: itemScrollController, - physics: const BouncingScrollPhysics()); + await setUpWidgetTest(tester, itemScrollController: itemScrollController, physics: const BouncingScrollPhysics()); - await tester.drag( - find.byType(ScrollablePositionedList), const Offset(0, 50)); + await tester.drag(find.byType(ScrollablePositionedList), const Offset(0, 50)); await tester.pump(const Duration(milliseconds: 200)); expect(tester.getTopLeft(find.text('Item 0')).dy, greaterThan(0)); @@ -1179,14 +1042,12 @@ void main() { await tester.pumpAndSettle(); expect(tester.getTopLeft(find.text('Item 0')).dy, 0); - unawaited( - itemScrollController.scrollTo(index: 100, duration: scrollDuration)); + unawaited(itemScrollController.scrollTo(index: 100, duration: scrollDuration)); await tester.pumpAndSettle(); itemScrollController.jumpTo(index: 0); await tester.pumpAndSettle(); - await tester.drag( - find.byType(ScrollablePositionedList), const Offset(0, 50)); + await tester.drag(find.byType(ScrollablePositionedList), const Offset(0, 50)); await tester.pump(const Duration(milliseconds: 200)); expect(tester.getTopLeft(find.text('Item 0')).dy, greaterThan(0)); @@ -1197,47 +1058,51 @@ void main() { testWidgets('correct index sematics', (WidgetTester tester) async { final itemScrollController = ItemScrollController(); - await setUpWidgetTest(tester, - itemScrollController: itemScrollController, initialIndex: 5); + await setUpWidgetTest(tester, itemScrollController: itemScrollController, initialIndex: 5); - await tester.drag( - find.byType(ScrollablePositionedList), const Offset(0, itemHeight * 2)); + await tester.drag(find.byType(ScrollablePositionedList), const Offset(0, itemHeight * 2)); await tester.pumpAndSettle(); - final indexSemantics3 = tester.widget(find.ancestor( - of: find.text('Item 3'), matching: find.byType(IndexedSemantics))); + final indexSemantics3 = tester.widget( + find.ancestor(of: find.text('Item 3'), matching: find.byType(IndexedSemantics)), + ); expect(indexSemantics3.index, 3); - final indexSemantics4 = tester.widget(find.ancestor( - of: find.text('Item 4'), matching: find.byType(IndexedSemantics))); + final indexSemantics4 = tester.widget( + find.ancestor(of: find.text('Item 4'), matching: find.byType(IndexedSemantics)), + ); expect(indexSemantics4.index, 4); - final indexSemantics5 = tester.widget(find.ancestor( - of: find.text('Item 5'), matching: find.byType(IndexedSemantics))); + final indexSemantics5 = tester.widget( + find.ancestor(of: find.text('Item 5'), matching: find.byType(IndexedSemantics)), + ); expect(indexSemantics5.index, 5); - final indexSemantics6 = tester.widget(find.ancestor( - of: find.text('Item 6'), matching: find.byType(IndexedSemantics))); + final indexSemantics6 = tester.widget( + find.ancestor(of: find.text('Item 6'), matching: find.byType(IndexedSemantics)), + ); expect(indexSemantics6.index, 6); - unawaited( - itemScrollController.scrollTo(index: 100, duration: scrollDuration)); + unawaited(itemScrollController.scrollTo(index: 100, duration: scrollDuration)); await tester.pumpAndSettle(); itemScrollController.jumpTo(index: 0); await tester.pumpAndSettle(); - await tester.drag( - find.byType(ScrollablePositionedList), const Offset(0, itemHeight * 2)); + await tester.drag(find.byType(ScrollablePositionedList), const Offset(0, itemHeight * 2)); await tester.pumpAndSettle(); - final indexSemantics3b = tester.widget(find.ancestor( - of: find.text('Item 3'), matching: find.byType(IndexedSemantics))); + final indexSemantics3b = tester.widget( + find.ancestor(of: find.text('Item 3'), matching: find.byType(IndexedSemantics)), + ); expect(indexSemantics3b.index, 3); - final indexSemantics4b = tester.widget(find.ancestor( - of: find.text('Item 4'), matching: find.byType(IndexedSemantics))); + final indexSemantics4b = tester.widget( + find.ancestor(of: find.text('Item 4'), matching: find.byType(IndexedSemantics)), + ); expect(indexSemantics4b.index, 4); - final indexSemantics5b = tester.widget(find.ancestor( - of: find.text('Item 5'), matching: find.byType(IndexedSemantics))); + final indexSemantics5b = tester.widget( + find.ancestor(of: find.text('Item 5'), matching: find.byType(IndexedSemantics)), + ); expect(indexSemantics5b.index, 5); - final indexSemantics6b = tester.widget(find.ancestor( - of: find.text('Item 6'), matching: find.byType(IndexedSemantics))); + final indexSemantics6b = tester.widget( + find.ancestor(of: find.text('Item 6'), matching: find.byType(IndexedSemantics)), + ); expect(indexSemantics6b.index, 6); }); @@ -1252,8 +1117,7 @@ void main() { expect(find.byType(IndexedSemantics), findsNothing); - unawaited( - itemScrollController.scrollTo(index: 100, duration: scrollDuration)); + unawaited(itemScrollController.scrollTo(index: 100, duration: scrollDuration)); await tester.pumpAndSettle(); itemScrollController.jumpTo(index: 0); await tester.pumpAndSettle(); @@ -1270,16 +1134,13 @@ void main() { itemScrollController: itemScrollController, ); - final customScrollView = - tester.widget(find.byType(UnboundedCustomScrollView)); + final customScrollView = tester.widget(find.byType(UnboundedCustomScrollView)); expect(customScrollView.semanticChildCount, 30); - unawaited( - itemScrollController.scrollTo(index: 100, duration: scrollDuration)); + unawaited(itemScrollController.scrollTo(index: 100, duration: scrollDuration)); await tester.pumpAndSettle(); - final customScrollView2 = - tester.widget(find.byType(UnboundedCustomScrollView)); + final customScrollView2 = tester.widget(find.byType(UnboundedCustomScrollView)); expect(customScrollView2.semanticChildCount, 30); }); @@ -1290,16 +1151,13 @@ void main() { itemScrollController: itemScrollController, ); - final customScrollView = - tester.widget(find.byType(UnboundedCustomScrollView)); + final customScrollView = tester.widget(find.byType(UnboundedCustomScrollView)); expect(customScrollView.semanticChildCount, defaultItemCount); - unawaited( - itemScrollController.scrollTo(index: 100, duration: scrollDuration)); + unawaited(itemScrollController.scrollTo(index: 100, duration: scrollDuration)); await tester.pumpAndSettle(); - final customScrollView2 = - tester.widget(find.byType(UnboundedCustomScrollView)); + final customScrollView2 = tester.widget(find.byType(UnboundedCustomScrollView)); expect(customScrollView2.semanticChildCount, defaultItemCount); }); @@ -1329,25 +1187,19 @@ void main() { ); expect(tester.getTopLeft(find.text('Item 0')), const Offset(10, 10)); - expect(tester.getTopLeft(find.text('Item 1')), - const Offset(10, itemHeight + 10)); - expect(tester.getTopRight(find.text('Item 1')), - const Offset(screenWidth - 10, itemHeight + 10)); + expect(tester.getTopLeft(find.text('Item 1')), const Offset(10, itemHeight + 10)); + expect(tester.getTopRight(find.text('Item 1')), const Offset(screenWidth - 10, itemHeight + 10)); - unawaited( - itemScrollController.scrollTo(index: 490, duration: scrollDuration)); + unawaited(itemScrollController.scrollTo(index: 490, duration: scrollDuration)); await tester.pumpAndSettle(); - await tester.drag( - find.byType(ScrollablePositionedList), const Offset(0, -100)); + await tester.drag(find.byType(ScrollablePositionedList), const Offset(0, -100)); await tester.pumpAndSettle(); - expect(tester.getBottomRight(find.text('Item 499')), - const Offset(screenWidth - 10, screenHeight - 10)); + expect(tester.getBottomRight(find.text('Item 499')), const Offset(screenWidth - 10, screenHeight - 10)); }); - testWidgets('padding test - centered not at top', - (WidgetTester tester) async { + testWidgets('padding test - centered not at top', (WidgetTester tester) async { final itemScrollController = ItemScrollController(); await setUpWidgetTest( tester, @@ -1356,19 +1208,15 @@ void main() { padding: const EdgeInsets.all(10), ); - await tester.drag( - find.byType(ScrollablePositionedList), const Offset(0, 200)); + await tester.drag(find.byType(ScrollablePositionedList), const Offset(0, 200)); await tester.pumpAndSettle(); expect(tester.getTopLeft(find.text('Item 0')), const Offset(10, 10)); - expect(tester.getTopLeft(find.text('Item 2')), - const Offset(10, 10 + itemHeight * 2)); - expect(tester.getTopLeft(find.text('Item 3')), - const Offset(10, 10 + itemHeight * 3)); + expect(tester.getTopLeft(find.text('Item 2')), const Offset(10, 10 + itemHeight * 2)); + expect(tester.getTopLeft(find.text('Item 3')), const Offset(10, 10 + itemHeight * 3)); }); - testWidgets('padding - first element centered - scroll up', - (WidgetTester tester) async { + testWidgets('padding - first element centered - scroll up', (WidgetTester tester) async { final itemScrollController = ItemScrollController(); await setUpWidgetTest( tester, @@ -1376,15 +1224,13 @@ void main() { padding: const EdgeInsets.all(10), ); - await tester.drag( - find.byType(ScrollablePositionedList), const Offset(0, 100)); + await tester.drag(find.byType(ScrollablePositionedList), const Offset(0, 100)); await tester.pumpAndSettle(); expect(tester.getTopLeft(find.text('Item 0')), const Offset(10, 10)); }); - testWidgets('padding - last element centered - scroll down', - (WidgetTester tester) async { + testWidgets('padding - last element centered - scroll down', (WidgetTester tester) async { final itemScrollController = ItemScrollController(); await setUpWidgetTest( tester, @@ -1392,12 +1238,10 @@ void main() { padding: const EdgeInsets.all(10), ); - unawaited(itemScrollController.scrollTo( - index: defaultItemCount - 1, duration: scrollDuration)); + unawaited(itemScrollController.scrollTo(index: defaultItemCount - 1, duration: scrollDuration)); await tester.pumpAndSettle(); - await tester.drag( - find.byType(ScrollablePositionedList), const Offset(0, -100)); + await tester.drag(find.byType(ScrollablePositionedList), const Offset(0, -100)); await tester.pumpAndSettle(); expect( @@ -1417,12 +1261,13 @@ void main() { ); expect( - tester - .widgetList(find.descendant( - of: find.byType(ScrollablePositionedList), - matching: find.byType(RepaintBoundary))) - .length, - lessThan(5)); + tester + .widgetList( + find.descendant(of: find.byType(ScrollablePositionedList), matching: find.byType(RepaintBoundary)), + ) + .length, + lessThan(5), + ); }); testWidgets('no automatic keep alives', (WidgetTester tester) async { @@ -1436,10 +1281,9 @@ void main() { ); expect( - find.descendant( - of: find.byType(ScrollablePositionedList), - matching: find.byType(AutomaticKeepAlive)), - findsNothing); + find.descendant(of: find.byType(ScrollablePositionedList), matching: find.byType(AutomaticKeepAlive)), + findsNothing, + ); }); testWidgets('Jump to end of list', (WidgetTester tester) async { @@ -1449,61 +1293,49 @@ void main() { itemScrollController.jumpTo(index: defaultItemCount - 1); await tester.pumpAndSettle(); - expect(tester.getBottomLeft(find.text('Item ${defaultItemCount - 1}')).dy, - screenHeight); + expect(tester.getBottomLeft(find.text('Item ${defaultItemCount - 1}')).dy, screenHeight); }); testWidgets('Scroll to end of list', (WidgetTester tester) async { final itemScrollController = ItemScrollController(); await setUpWidgetTest(tester, itemScrollController: itemScrollController); - unawaited(itemScrollController.scrollTo( - index: defaultItemCount - 1, duration: scrollDuration)); + unawaited(itemScrollController.scrollTo(index: defaultItemCount - 1, duration: scrollDuration)); await tester.pumpAndSettle(); - expect(tester.getBottomLeft(find.text('Item ${defaultItemCount - 1}')).dy, - screenHeight); + expect(tester.getBottomLeft(find.text('Item ${defaultItemCount - 1}')).dy, screenHeight); }); - testWidgets('Scroll to end of list, jump to beginning, jump to end', - (WidgetTester tester) async { + testWidgets('Scroll to end of list, jump to beginning, jump to end', (WidgetTester tester) async { final itemScrollController = ItemScrollController(); await setUpWidgetTest(tester, itemScrollController: itemScrollController); - unawaited(itemScrollController.scrollTo( - index: defaultItemCount - 1, duration: scrollDuration)); + unawaited(itemScrollController.scrollTo(index: defaultItemCount - 1, duration: scrollDuration)); await tester.pumpAndSettle(); itemScrollController.jumpTo(index: 0); await tester.pumpAndSettle(); itemScrollController.jumpTo(index: defaultItemCount - 1); await tester.pumpAndSettle(); - expect(tester.getBottomLeft(find.text('Item ${defaultItemCount - 1}')).dy, - screenHeight); + expect(tester.getBottomLeft(find.text('Item ${defaultItemCount - 1}')).dy, screenHeight); }); - testWidgets('Jump to end of list, scroll to beginning, scroll to end', - (WidgetTester tester) async { + testWidgets('Jump to end of list, scroll to beginning, scroll to end', (WidgetTester tester) async { final itemScrollController = ItemScrollController(); await setUpWidgetTest(tester, itemScrollController: itemScrollController); itemScrollController.jumpTo(index: defaultItemCount - 1); await tester.pumpAndSettle(); - unawaited( - itemScrollController.scrollTo(index: 0, duration: scrollDuration)); + unawaited(itemScrollController.scrollTo(index: 0, duration: scrollDuration)); await tester.pumpAndSettle(); - unawaited(itemScrollController.scrollTo( - index: defaultItemCount - 1, duration: scrollDuration)); + unawaited(itemScrollController.scrollTo(index: defaultItemCount - 1, duration: scrollDuration)); await tester.pumpAndSettle(); - expect(tester.getBottomLeft(find.text('Item ${defaultItemCount - 1}')).dy, - screenHeight); + expect(tester.getBottomLeft(find.text('Item ${defaultItemCount - 1}')).dy, screenHeight); }); - testWidgets( - 'Jump to end of list, jump to beginning with alignment not at top', - (WidgetTester tester) async { + testWidgets('Jump to end of list, jump to beginning with alignment not at top', (WidgetTester tester) async { final itemScrollController = ItemScrollController(); await setUpWidgetTest(tester, itemScrollController: itemScrollController); @@ -1518,11 +1350,9 @@ void main() { testWidgets("Short list, can't scroll past end", (WidgetTester tester) async { final itemScrollController = ItemScrollController(); - await setUpWidgetTest(tester, - itemScrollController: itemScrollController, itemCount: 3); + await setUpWidgetTest(tester, itemScrollController: itemScrollController, itemCount: 3); - await tester.drag( - find.byType(ScrollablePositionedList), const Offset(0, -10)); + await tester.drag(find.byType(ScrollablePositionedList), const Offset(0, -10)); await tester.pumpAndSettle(); expect(tester.getTopLeft(find.text('Item 0')).dy, 0); @@ -1536,9 +1366,7 @@ void main() { expect(find.byKey(key), findsOneWidget); }); - testWidgets( - 'Maintain programmatic position (9 half way off top) in page view', - (WidgetTester tester) async { + testWidgets('Maintain programmatic position (9 half way off top) in page view', (WidgetTester tester) async { final itemPositionsListener = ItemPositionsListener.create(); final itemScrollController = ItemScrollController(); @@ -1563,14 +1391,13 @@ void main() { ), const Center( child: Text('Test'), - ) + ), ], ), ), ); - itemScrollController.jumpTo( - index: 9, alignment: -(itemHeight / screenHeight) / 2); + itemScrollController.jumpTo(index: 9, alignment: -(itemHeight / screenHeight) / 2); await tester.pump(); await tester.drag(find.byType(PageView), const Offset(-500, 0)); @@ -1582,19 +1409,16 @@ void main() { expect(tester.getTopLeft(find.text('Item 9')).dy, -itemHeight / 2); expect( - itemPositionsListener.itemPositions.value - .firstWhere((position) => position.index == 9) - .itemLeadingEdge, - -(itemHeight / screenHeight) / 2); + itemPositionsListener.itemPositions.value.firstWhere((position) => position.index == 9).itemLeadingEdge, + -(itemHeight / screenHeight) / 2, + ); expect( - itemPositionsListener.itemPositions.value - .firstWhere((position) => position.index == 9) - .itemTrailingEdge, - (itemHeight / screenHeight) / 2); + itemPositionsListener.itemPositions.value.firstWhere((position) => position.index == 9).itemTrailingEdge, + (itemHeight / screenHeight) / 2, + ); }); - testWidgets('Maintain user scroll position (1 half way off top) in page view', - (WidgetTester tester) async { + testWidgets('Maintain user scroll position (1 half way off top) in page view', (WidgetTester tester) async { final itemPositionsListener = ItemPositionsListener.create(); final itemScrollController = ItemScrollController(); @@ -1619,14 +1443,13 @@ void main() { ), const Center( child: Text('Test'), - ) + ), ], ), ), ); - await tester.drag( - find.byType(ScrollablePositionedList), const Offset(0, -itemHeight)); + await tester.drag(find.byType(ScrollablePositionedList), const Offset(0, -itemHeight)); await tester.pumpAndSettle(); final item0Bottom = tester.getBottomRight(find.text('Item 0')).dy; @@ -1641,15 +1464,13 @@ void main() { expect(tester.getBottomRight(find.text('Item 0')).dy, item0Bottom); expect( - itemPositionsListener.itemPositions.value - .firstWhere((position) => position.index == 0) - .itemLeadingEdge, - -(itemHeight / screenHeight) / 2); + itemPositionsListener.itemPositions.value.firstWhere((position) => position.index == 0).itemLeadingEdge, + -(itemHeight / screenHeight) / 2, + ); expect( - itemPositionsListener.itemPositions.value - .firstWhere((position) => position.index == 0) - .itemTrailingEdge, - (itemHeight / screenHeight) / 2); + itemPositionsListener.itemPositions.value.firstWhere((position) => position.index == 0).itemTrailingEdge, + (itemHeight / screenHeight) / 2, + ); }); testWidgets( @@ -1679,7 +1500,7 @@ void main() { ), const Center( child: Text('Test'), - ) + ), ], ), ), @@ -1690,8 +1511,7 @@ void main() { expect(tester.getBottomRight(find.text('Item 9')).dy, itemHeight); - await tester.drag( - find.byType(ScrollablePositionedList), const Offset(0, -itemHeight)); + await tester.drag(find.byType(ScrollablePositionedList), const Offset(0, -itemHeight)); await tester.pumpAndSettle(); final item9Bottom = tester.getBottomRight(find.text('Item 9')).dy; @@ -1706,28 +1526,24 @@ void main() { expect(tester.getBottomRight(find.text('Item 9')).dy, item9Bottom); expect( - itemPositionsListener.itemPositions.value - .firstWhere((position) => position.index == 9) - .itemLeadingEdge, - -(itemHeight / screenHeight) / 2); + itemPositionsListener.itemPositions.value.firstWhere((position) => position.index == 9).itemLeadingEdge, + -(itemHeight / screenHeight) / 2, + ); expect( - itemPositionsListener.itemPositions.value - .firstWhere((position) => position.index == 9) - .itemTrailingEdge, - (itemHeight / screenHeight) / 2); + itemPositionsListener.itemPositions.value.firstWhere((position) => position.index == 9).itemTrailingEdge, + (itemHeight / screenHeight) / 2, + ); }, ); testWidgets('List with no items', (WidgetTester tester) async { final itemScrollController = ItemScrollController(); - await setUpWidgetTest(tester, - itemScrollController: itemScrollController, itemCount: 0); + await setUpWidgetTest(tester, itemScrollController: itemScrollController, itemCount: 0); expect(find.text('Item 0'), findsNothing); }); - testWidgets('Jump to 100 then set itemCount to 0', - (WidgetTester tester) async { + testWidgets('Jump to 100 then set itemCount to 0', (WidgetTester tester) async { tester.view.devicePixelRatio = 1.0; tester.view.physicalSize = const Size(screenWidth, screenHeight); @@ -1774,8 +1590,7 @@ void main() { expect(itemPositionsListener.itemPositions.value.isEmpty, isTrue); }); - testWidgets('List positioned with 100 at top then set itemCount to 100', - (WidgetTester tester) async { + testWidgets('List positioned with 100 at top then set itemCount to 100', (WidgetTester tester) async { tester.view.devicePixelRatio = 1.0; tester.view.physicalSize = const Size(screenWidth, screenHeight); @@ -1815,8 +1630,7 @@ void main() { expect(tester.getBottomLeft(find.text('Item 99')).dy, screenHeight); }); - testWidgets('List positioned with 499 at bottom then set itemCount to 100', - (WidgetTester tester) async { + testWidgets('List positioned with 499 at bottom then set itemCount to 100', (WidgetTester tester) async { tester.view.devicePixelRatio = 1.0; tester.view.physicalSize = const Size(screenWidth, screenHeight); @@ -1862,8 +1676,7 @@ void main() { expect(find.text('Item 100', skipOffstage: false), findsOneWidget); }); - testWidgets('Scroll to 20 without fading small minCacheExtent', - (WidgetTester tester) async { + testWidgets('Scroll to 20 without fading small minCacheExtent', (WidgetTester tester) async { final itemScrollController = ItemScrollController(); final itemPositionsListener = ItemPositionsListener.create(); await setUpWidgetTest( @@ -1876,8 +1689,7 @@ void main() { var fadeTransition = tester.widget(fadeTransitionFinder); final initialOpacity = fadeTransition.opacity; - unawaited( - itemScrollController.scrollTo(index: 20, duration: scrollDuration)); + unawaited(itemScrollController.scrollTo(index: 20, duration: scrollDuration)); await tester.pump(); await tester.pump(); await tester.pump(scrollDuration ~/ 2); @@ -1891,8 +1703,7 @@ void main() { expect(find.text('Item 20'), findsOneWidget); }); - testWidgets('Scroll to 100 without fading for large minCacheExtent', - (WidgetTester tester) async { + testWidgets('Scroll to 100 without fading for large minCacheExtent', (WidgetTester tester) async { final itemScrollController = ItemScrollController(); await setUpWidgetTest( @@ -1906,8 +1717,7 @@ void main() { ); final initialOpacity = fadeTransition.opacity; - unawaited( - itemScrollController.scrollTo(index: 100, duration: scrollDuration)); + unawaited(itemScrollController.scrollTo(index: 100, duration: scrollDuration)); await tester.pump(); await tester.pump(); await tester.pump(scrollDuration ~/ 2); @@ -1920,8 +1730,7 @@ void main() { expect(find.text('Item 100'), findsOneWidget); }); - testWidgets('Position list when not enough above top item to fill viewport', - (WidgetTester tester) async { + testWidgets('Position list when not enough above top item to fill viewport', (WidgetTester tester) async { const alignment = 0.8; await setUpWidgetTest( @@ -1969,15 +1778,13 @@ void main() { key.value = const ValueKey('newKey'); await tester.pumpAndSettle(); - unawaited( - itemScrollController.scrollTo(index: 100, duration: scrollDuration)); + unawaited(itemScrollController.scrollTo(index: 100, duration: scrollDuration)); await tester.pumpAndSettle(); expect(find.text('Item 100'), findsOneWidget); }); - testWidgets('Double rebuild with scroll controller', - (WidgetTester tester) async { + testWidgets('Double rebuild with scroll controller', (WidgetTester tester) async { tester.view.devicePixelRatio = 1.0; tester.view.physicalSize = const Size(screenWidth, screenHeight); final outerKey = ValueNotifier(const ValueKey('outerKey')); @@ -2019,8 +1826,7 @@ void main() { listKey.value = const ValueKey('newListKey'); await tester.pumpAndSettle(); - unawaited( - itemScrollController.scrollTo(index: 100, duration: scrollDuration)); + unawaited(itemScrollController.scrollTo(index: 100, duration: scrollDuration)); await tester.pumpAndSettle(); expect(find.text('Item 100'), findsOneWidget); @@ -2093,15 +1899,13 @@ void main() { key.value = const ValueKey('newKey'); await tester.pumpAndSettle(); - unawaited( - itemScrollController.scrollTo(index: 70, duration: scrollDuration)); + unawaited(itemScrollController.scrollTo(index: 70, duration: scrollDuration)); await tester.pumpAndSettle(); expect(find.text('Item 70'), findsOneWidget); }); - testWidgets('Scroll after rebuild when resusing state', - (WidgetTester tester) async { + testWidgets('Scroll after rebuild when resusing state', (WidgetTester tester) async { tester.view.devicePixelRatio = 1.0; tester.view.physicalSize = const Size(screenWidth, screenHeight); final containerKey = ValueNotifier(const ValueKey('key')); @@ -2137,15 +1941,13 @@ void main() { containerKey.value = const ValueKey('newKey'); await tester.pumpAndSettle(); - unawaited( - itemScrollController.scrollTo(index: 70, duration: scrollDuration)); + unawaited(itemScrollController.scrollTo(index: 70, duration: scrollDuration)); await tester.pumpAndSettle(); expect(find.text('Item 70'), findsOneWidget); }); - testWidgets('Scroll after changing scroll controller', - (WidgetTester tester) async { + testWidgets('Scroll after changing scroll controller', (WidgetTester tester) async { tester.view.devicePixelRatio = 1.0; tester.view.physicalSize = const Size(screenWidth, screenHeight); @@ -2183,65 +1985,63 @@ void main() { expect(itemScrollController0.isAttached, false); expect(itemScrollController1.isAttached, true); - unawaited( - itemScrollController1.scrollTo(index: 70, duration: scrollDuration)); + unawaited(itemScrollController1.scrollTo(index: 70, duration: scrollDuration)); await tester.pumpAndSettle(); expect(find.text('Item 70'), findsOneWidget); }); - testWidgets('Scroll after swapping scroll controllers', - (WidgetTester tester) async { + testWidgets('Scroll after swapping scroll controllers', (WidgetTester tester) async { tester.view.devicePixelRatio = 1.0; tester.view.physicalSize = const Size(screenWidth, screenHeight); final itemScrollController0 = ItemScrollController(); final itemScrollController1 = ItemScrollController(); - final topItemScrollControllerListenable = - ValueNotifier(itemScrollController0); - final bottomItemScrollControllerListenable = - ValueNotifier(itemScrollController1); - - await tester.pumpWidget(MaterialApp( - home: Column( - children: [ - Expanded( - child: ValueListenableBuilder( - valueListenable: topItemScrollControllerListenable, - builder: (context, itemScrollController, child) { - return ScrollablePositionedList.builder( - itemCount: 100, - itemScrollController: itemScrollController, - itemBuilder: (context, index) { - return SizedBox( - height: itemHeight, - child: Text('Item $index'), - ); - }, - ); - }, + final topItemScrollControllerListenable = ValueNotifier(itemScrollController0); + final bottomItemScrollControllerListenable = ValueNotifier(itemScrollController1); + + await tester.pumpWidget( + MaterialApp( + home: Column( + children: [ + Expanded( + child: ValueListenableBuilder( + valueListenable: topItemScrollControllerListenable, + builder: (context, itemScrollController, child) { + return ScrollablePositionedList.builder( + itemCount: 100, + itemScrollController: itemScrollController, + itemBuilder: (context, index) { + return SizedBox( + height: itemHeight, + child: Text('Item $index'), + ); + }, + ); + }, + ), ), - ), - Expanded( - child: ValueListenableBuilder( - valueListenable: bottomItemScrollControllerListenable, - builder: (context, itemScrollController, child) { - return ScrollablePositionedList.builder( - itemCount: 100, - itemScrollController: itemScrollController, - itemBuilder: (context, index) { - return SizedBox( - height: itemHeight, - child: Text('Item $index'), - ); - }, - ); - }, + Expanded( + child: ValueListenableBuilder( + valueListenable: bottomItemScrollControllerListenable, + builder: (context, itemScrollController, child) { + return ScrollablePositionedList.builder( + itemCount: 100, + itemScrollController: itemScrollController, + itemBuilder: (context, index) { + return SizedBox( + height: itemHeight, + child: Text('Item $index'), + ); + }, + ); + }, + ), ), - ), - ], + ], + ), ), - )); + ); await tester.pumpAndSettle(); expect(itemScrollController0.isAttached, true); @@ -2254,10 +2054,8 @@ void main() { expect(itemScrollController0.isAttached, true); expect(itemScrollController1.isAttached, true); - unawaited( - itemScrollController1.scrollTo(index: 70, duration: scrollDuration)); - unawaited( - itemScrollController0.scrollTo(index: 50, duration: scrollDuration)); + unawaited(itemScrollController1.scrollTo(index: 70, duration: scrollDuration)); + unawaited(itemScrollController0.scrollTo(index: 50, duration: scrollDuration)); await tester.pumpAndSettle(); expect(find.text('Item 70'), findsOneWidget); diff --git a/packages/stream_chat_flutter/test/scrollable_positioned_list/separated_positioned_list_test.dart b/packages/stream_chat_flutter/test/scrollable_positioned_list/separated_positioned_list_test.dart index f6dc2b5cef..f947d38bf7 100644 --- a/packages/stream_chat_flutter/test/scrollable_positioned_list/separated_positioned_list_test.dart +++ b/packages/stream_chat_flutter/test/scrollable_positioned_list/separated_positioned_list_test.dart @@ -68,24 +68,17 @@ void main() { expect(find.text('Separator 2'), findsNothing); expect(find.text('Item 3'), findsNothing); + expect(itemPositionsNotifier.itemPositions.value.firstWhere((position) => position.index == 0).itemLeadingEdge, 0); expect( - itemPositionsNotifier.itemPositions.value - .firstWhere((position) => position.index == 0) - .itemLeadingEdge, - 0); - expect( - itemPositionsNotifier.itemPositions.value - .firstWhere((position) => position.index == 2) - .itemTrailingEdge, - _screenProportion(numberOfItems: 3, numberOfSeparators: 2)); + itemPositionsNotifier.itemPositions.value.firstWhere((position) => position.index == 2).itemTrailingEdge, + _screenProportion(numberOfItems: 3, numberOfSeparators: 2), + ); }); - testWidgets('Short list centered at 1 scrolled up', - (WidgetTester tester) async { + testWidgets('Short list centered at 1 scrolled up', (WidgetTester tester) async { await setUpWidgetTest(tester, itemCount: 3, topItem: 1); - await tester.drag( - find.byType(PositionedList), const Offset(0, itemHeight * 2)); + await tester.drag(find.byType(PositionedList), const Offset(0, itemHeight * 2)); await tester.pumpAndSettle(); expect(find.text('Item 0'), findsOneWidget); @@ -96,16 +89,11 @@ void main() { expect(find.text('Separator 2'), findsNothing); expect(find.text('Item 3'), findsNothing); + expect(itemPositionsNotifier.itemPositions.value.firstWhere((position) => position.index == 0).itemLeadingEdge, 0); expect( - itemPositionsNotifier.itemPositions.value - .firstWhere((position) => position.index == 0) - .itemLeadingEdge, - 0); - expect( - itemPositionsNotifier.itemPositions.value - .firstWhere((position) => position.index == 2) - .itemTrailingEdge, - _screenProportion(numberOfItems: 3, numberOfSeparators: 2)); + itemPositionsNotifier.itemPositions.value.firstWhere((position) => position.index == 2).itemTrailingEdge, + _screenProportion(numberOfItems: 3, numberOfSeparators: 2), + ); }); testWidgets('List positioned with 0 at top', (WidgetTester tester) async { @@ -118,22 +106,13 @@ void main() { expect(find.text('Separator 6'), findsNothing); expect(find.text('Item 7'), findsNothing); + expect(itemPositionsNotifier.itemPositions.value.firstWhere((position) => position.index == 0).itemLeadingEdge, 0); expect( - itemPositionsNotifier.itemPositions.value - .firstWhere((position) => position.index == 0) - .itemLeadingEdge, - 0); - expect( - itemPositionsNotifier.itemPositions.value - .firstWhere((position) => position.index == 5) - .itemTrailingEdge, - 1 - _screenProportion(numberOfItems: 1, numberOfSeparators: 1)); + itemPositionsNotifier.itemPositions.value.firstWhere((position) => position.index == 5).itemTrailingEdge, + 1 - _screenProportion(numberOfItems: 1, numberOfSeparators: 1), + ); - expect( - itemPositionsNotifier.itemPositions.value - .firstWhere((position) => position.index == 6) - .itemTrailingEdge, - 1); + expect(itemPositionsNotifier.itemPositions.value.firstWhere((position) => position.index == 6).itemTrailingEdge, 1); }); testWidgets('List positioned with 5 at top', (WidgetTester tester) async { @@ -149,21 +128,15 @@ void main() { expect(find.text('Item 11'), findsOneWidget); expect(find.text('Separator 11'), findsNothing); + expect(itemPositionsNotifier.itemPositions.value.firstWhere((position) => position.index == 5).itemLeadingEdge, 0); expect( - itemPositionsNotifier.itemPositions.value - .firstWhere((position) => position.index == 5) - .itemLeadingEdge, - 0); - expect( - itemPositionsNotifier.itemPositions.value - .firstWhere((position) => position.index == 6) - .itemLeadingEdge, - _screenProportion(numberOfItems: 1, numberOfSeparators: 1)); + itemPositionsNotifier.itemPositions.value.firstWhere((position) => position.index == 6).itemLeadingEdge, + _screenProportion(numberOfItems: 1, numberOfSeparators: 1), + ); expect( - itemPositionsNotifier.itemPositions.value - .firstWhere((position) => position.index == 11) - .itemTrailingEdge, - 1); + itemPositionsNotifier.itemPositions.value.firstWhere((position) => position.index == 11).itemTrailingEdge, + 1, + ); }); testWidgets('List positioned with 20 at bottom', (WidgetTester tester) async { @@ -179,82 +152,60 @@ void main() { expect(find.text('Separator 12'), findsNothing); expect( - itemPositionsNotifier.itemPositions.value - .firstWhere((position) => position.index == 19) - .itemTrailingEdge, - 1 - _screenProportion(numberOfItems: 0, numberOfSeparators: 1)); - expect( - itemPositionsNotifier.itemPositions.value - .firstWhere((position) => position.index == 20) - .itemLeadingEdge, - 1); + itemPositionsNotifier.itemPositions.value.firstWhere((position) => position.index == 19).itemTrailingEdge, + 1 - _screenProportion(numberOfItems: 0, numberOfSeparators: 1), + ); + expect(itemPositionsNotifier.itemPositions.value.firstWhere((position) => position.index == 20).itemLeadingEdge, 1); expect( - itemPositionsNotifier.itemPositions.value - .firstWhere((position) => position.index == 13) - .itemLeadingEdge, - _screenProportion(numberOfItems: -0.5, numberOfSeparators: 0)); + itemPositionsNotifier.itemPositions.value.firstWhere((position) => position.index == 13).itemLeadingEdge, + _screenProportion(numberOfItems: -0.5, numberOfSeparators: 0), + ); }); - testWidgets('List positioned with item 20 at halfway', - (WidgetTester tester) async { + testWidgets('List positioned with item 20 at halfway', (WidgetTester tester) async { await setUpWidgetTest(tester, topItem: 20, anchor: 0.5); await tester.pump(); expect( - itemPositionsNotifier.itemPositions.value - .firstWhere((position) => position.index == 20) - .itemLeadingEdge, - 0.5); + itemPositionsNotifier.itemPositions.value.firstWhere((position) => position.index == 20).itemLeadingEdge, + 0.5, + ); expect( - itemPositionsNotifier.itemPositions.value - .firstWhere((position) => position.index == 20) - .itemTrailingEdge, - 0.5 + itemHeight / screenHeight); + itemPositionsNotifier.itemPositions.value.firstWhere((position) => position.index == 20).itemTrailingEdge, + 0.5 + itemHeight / screenHeight, + ); }); - testWidgets('List positioned with item 20 half off top of screen', - (WidgetTester tester) async { - await setUpWidgetTest(tester, - topItem: 20, anchor: -(itemHeight / screenHeight) / 2); + testWidgets('List positioned with item 20 half off top of screen', (WidgetTester tester) async { + await setUpWidgetTest(tester, topItem: 20, anchor: -(itemHeight / screenHeight) / 2); await tester.pump(); expect( - itemPositionsNotifier.itemPositions.value - .firstWhere((position) => position.index == 20) - .itemLeadingEdge, - _screenProportion(numberOfItems: -0.5, numberOfSeparators: 0)); + itemPositionsNotifier.itemPositions.value.firstWhere((position) => position.index == 20).itemLeadingEdge, + _screenProportion(numberOfItems: -0.5, numberOfSeparators: 0), + ); expect( - itemPositionsNotifier.itemPositions.value - .firstWhere((position) => position.index == 20) - .itemTrailingEdge, - _screenProportion(numberOfItems: 0.5, numberOfSeparators: 0)); + itemPositionsNotifier.itemPositions.value.firstWhere((position) => position.index == 20).itemTrailingEdge, + _screenProportion(numberOfItems: 0.5, numberOfSeparators: 0), + ); }); - testWidgets('List positioned with 5 at top then scroll up 2 items', - (WidgetTester tester) async { + testWidgets('List positioned with 5 at top then scroll up 2 items', (WidgetTester tester) async { await setUpWidgetTest(tester, topItem: 5); - await tester.drag(find.byType(PositionedList), - const Offset(0, 2 * (itemHeight + separatorHeight))); + await tester.drag(find.byType(PositionedList), const Offset(0, 2 * (itemHeight + separatorHeight))); await tester.pump(); expect(find.text('Separator 2'), findsNothing); expect(find.text('Item 3'), findsOneWidget); expect( - itemPositionsNotifier.itemPositions.value - .firstWhere((position) => position.index == 2) - .itemLeadingEdge, - _screenProportion(numberOfItems: -1, numberOfSeparators: -1)); - expect( - itemPositionsNotifier.itemPositions.value - .firstWhere((position) => position.index == 3) - .itemLeadingEdge, - 0); + itemPositionsNotifier.itemPositions.value.firstWhere((position) => position.index == 2).itemLeadingEdge, + _screenProportion(numberOfItems: -1, numberOfSeparators: -1), + ); + expect(itemPositionsNotifier.itemPositions.value.firstWhere((position) => position.index == 3).itemLeadingEdge, 0); }); } -double _screenProportion( - {required double numberOfItems, required double numberOfSeparators}) => - (numberOfItems * itemHeight + numberOfSeparators * separatorHeight) / - screenHeight; +double _screenProportion({required double numberOfItems, required double numberOfSeparators}) => + (numberOfItems * itemHeight + numberOfSeparators * separatorHeight) / screenHeight; diff --git a/packages/stream_chat_flutter/test/scrollable_positioned_list/separated_scrollable_positioned_list_test.dart b/packages/stream_chat_flutter/test/scrollable_positioned_list/separated_scrollable_positioned_list_test.dart index d4d99595dd..d6ef7bc527 100644 --- a/packages/stream_chat_flutter/test/scrollable_positioned_list/separated_scrollable_positioned_list_test.dart +++ b/packages/stream_chat_flutter/test/scrollable_positioned_list/separated_scrollable_positioned_list_test.dart @@ -75,30 +75,17 @@ void main() { expect(find.text('Separator 6'), findsNothing); expect(find.text('Item 7'), findsNothing); + expect(itemPositionsListener.itemPositions.value.firstWhere((position) => position.index == 0).itemLeadingEdge, 0); expect( - itemPositionsListener.itemPositions.value - .firstWhere((position) => position.index == 0) - .itemLeadingEdge, - 0); - expect( - itemPositionsListener.itemPositions.value - .firstWhere((position) => position.index == 5) - .itemTrailingEdge, - 1 - _screenProportion(numberOfItems: 1, numberOfSeparators: 1)); + itemPositionsListener.itemPositions.value.firstWhere((position) => position.index == 5).itemTrailingEdge, + 1 - _screenProportion(numberOfItems: 1, numberOfSeparators: 1), + ); - expect( - itemPositionsListener.itemPositions.value - .firstWhere((position) => position.index == 6) - .itemTrailingEdge, - 1); - expect( - itemPositionsListener.itemPositions.value - .where((position) => position.index == 7), - isEmpty); + expect(itemPositionsListener.itemPositions.value.firstWhere((position) => position.index == 6).itemTrailingEdge, 1); + expect(itemPositionsListener.itemPositions.value.where((position) => position.index == 7), isEmpty); }); - testWidgets('List positioned with 0 at top - use default values', - (WidgetTester tester) async { + testWidgets('List positioned with 0 at top - use default values', (WidgetTester tester) async { final itemPositionsListener = ItemPositionsListener.create(); tester.view.devicePixelRatio = 1.0; tester.view.physicalSize = const Size(screenWidth, screenHeight); @@ -126,32 +113,19 @@ void main() { expect(find.text('Separator 6'), findsNothing); expect(find.text('Item 7'), findsNothing); + expect(itemPositionsListener.itemPositions.value.firstWhere((position) => position.index == 0).itemLeadingEdge, 0); expect( - itemPositionsListener.itemPositions.value - .firstWhere((position) => position.index == 0) - .itemLeadingEdge, - 0); - expect( - itemPositionsListener.itemPositions.value - .firstWhere((position) => position.index == 5) - .itemTrailingEdge, - 1 - _screenProportion(numberOfItems: 1, numberOfSeparators: 1)); + itemPositionsListener.itemPositions.value.firstWhere((position) => position.index == 5).itemTrailingEdge, + 1 - _screenProportion(numberOfItems: 1, numberOfSeparators: 1), + ); - expect( - itemPositionsListener.itemPositions.value - .firstWhere((position) => position.index == 6) - .itemTrailingEdge, - 1); - expect( - itemPositionsListener.itemPositions.value - .where((position) => position.index == 7), - isEmpty); + expect(itemPositionsListener.itemPositions.value.firstWhere((position) => position.index == 6).itemTrailingEdge, 1); + expect(itemPositionsListener.itemPositions.value.where((position) => position.index == 7), isEmpty); }); testWidgets('List positioned with 5 at top', (WidgetTester tester) async { final itemPositionsListener = ItemPositionsListener.create(); - await setUpWidgetTest(tester, - itemPositionsListener: itemPositionsListener, initialIndex: 5); + await setUpWidgetTest(tester, itemPositionsListener: itemPositionsListener, initialIndex: 5); expect(find.text('Item 4'), findsNothing); expect(find.text('Separator 4'), findsNothing); @@ -161,58 +135,44 @@ void main() { expect(find.text('Item 11'), findsOneWidget); expect(find.text('Separator 11'), findsNothing); - expect( - itemPositionsListener.itemPositions.value - .where((position) => position.index == 4), - isEmpty); - expect( - itemPositionsListener.itemPositions.value - .firstWhere((position) => position.index == 5) - .itemLeadingEdge, - 0); + expect(itemPositionsListener.itemPositions.value.where((position) => position.index == 4), isEmpty); + expect(itemPositionsListener.itemPositions.value.firstWhere((position) => position.index == 5).itemLeadingEdge, 0); }); testWidgets('List positioned with 9 at middle', (WidgetTester tester) async { final itemPositionsListener = ItemPositionsListener.create(); - await setUpWidgetTest(tester, - itemPositionsListener: itemPositionsListener, - initialIndex: 9, - initialAlignment: 0.5); + await setUpWidgetTest(tester, itemPositionsListener: itemPositionsListener, initialIndex: 9, initialAlignment: 0.5); expect(tester.getTopLeft(find.text('Item 9')).dy, screenHeight / 2); - expect(tester.getTopLeft(find.text('Item 8')).dy, - screenHeight / 2 - itemHeight - separatorHeight); - expect(tester.getTopLeft(find.text('Item 10')).dy, - screenHeight / 2 + itemHeight + separatorHeight); + expect(tester.getTopLeft(find.text('Item 8')).dy, screenHeight / 2 - itemHeight - separatorHeight); + expect(tester.getTopLeft(find.text('Item 10')).dy, screenHeight / 2 + itemHeight + separatorHeight); expect( - itemPositionsListener.itemPositions.value - .firstWhere((position) => position.index == 9) - .itemLeadingEdge, - 0.5); + itemPositionsListener.itemPositions.value.firstWhere((position) => position.index == 9).itemLeadingEdge, + 0.5, + ); expect( - itemPositionsListener.itemPositions.value - .firstWhere((position) => position.index == 8) - .itemLeadingEdge, - 0.5 - _screenProportion(numberOfItems: 1, numberOfSeparators: 1)); + itemPositionsListener.itemPositions.value.firstWhere((position) => position.index == 8).itemLeadingEdge, + 0.5 - _screenProportion(numberOfItems: 1, numberOfSeparators: 1), + ); expect( - itemPositionsListener.itemPositions.value - .firstWhere((position) => position.index == 10) - .itemLeadingEdge, - 0.5 + _screenProportion(numberOfItems: 1, numberOfSeparators: 1)); + itemPositionsListener.itemPositions.value.firstWhere((position) => position.index == 10).itemLeadingEdge, + 0.5 + _screenProportion(numberOfItems: 1, numberOfSeparators: 1), + ); }); testWidgets('Scroll to 9 half way off top', (WidgetTester tester) async { final itemPositionsListener = ItemPositionsListener.create(); final itemScrollController = ItemScrollController(); - await setUpWidgetTest(tester, - itemPositionsListener: itemPositionsListener, - itemScrollController: itemScrollController); - - unawaited(itemScrollController.scrollTo( - index: 9, - duration: scrollDuration, - alignment: -(itemHeight / screenHeight) / 2)); + await setUpWidgetTest( + tester, + itemPositionsListener: itemPositionsListener, + itemScrollController: itemScrollController, + ); + + unawaited( + itemScrollController.scrollTo(index: 9, duration: scrollDuration, alignment: -(itemHeight / screenHeight) / 2), + ); await tester.pump(); await tester.pump(); await tester.pump(scrollDuration + scrollDurationTolerance); @@ -220,76 +180,68 @@ void main() { expect(tester.getTopLeft(find.text('Item 9')).dy, -itemHeight / 2); expect( - itemPositionsListener.itemPositions.value - .firstWhere((position) => position.index == 9) - .itemLeadingEdge, - _screenProportion(numberOfItems: -0.5, numberOfSeparators: 0)); + itemPositionsListener.itemPositions.value.firstWhere((position) => position.index == 9).itemLeadingEdge, + _screenProportion(numberOfItems: -0.5, numberOfSeparators: 0), + ); expect( - itemPositionsListener.itemPositions.value - .firstWhere((position) => position.index == 9) - .itemTrailingEdge, - _screenProportion(numberOfItems: 0.5, numberOfSeparators: 0)); + itemPositionsListener.itemPositions.value.firstWhere((position) => position.index == 9).itemTrailingEdge, + _screenProportion(numberOfItems: 0.5, numberOfSeparators: 0), + ); }); testWidgets('Jump to 9 half way off top', (WidgetTester tester) async { final itemPositionsListener = ItemPositionsListener.create(); final itemScrollController = ItemScrollController(); - await setUpWidgetTest(tester, - itemPositionsListener: itemPositionsListener, - itemScrollController: itemScrollController); + await setUpWidgetTest( + tester, + itemPositionsListener: itemPositionsListener, + itemScrollController: itemScrollController, + ); - itemScrollController.jumpTo( - index: 9, alignment: -(itemHeight / screenHeight) / 2); + itemScrollController.jumpTo(index: 9, alignment: -(itemHeight / screenHeight) / 2); await tester.pump(); expect(tester.getTopLeft(find.text('Item 9')).dy, -itemHeight / 2); expect( - itemPositionsListener.itemPositions.value - .firstWhere((position) => position.index == 9) - .itemLeadingEdge, - _screenProportion(numberOfItems: -0.5, numberOfSeparators: 0)); + itemPositionsListener.itemPositions.value.firstWhere((position) => position.index == 9).itemLeadingEdge, + _screenProportion(numberOfItems: -0.5, numberOfSeparators: 0), + ); expect( - itemPositionsListener.itemPositions.value - .firstWhere((position) => position.index == 9) - .itemTrailingEdge, - _screenProportion(numberOfItems: 0.5, numberOfSeparators: 0)); + itemPositionsListener.itemPositions.value.firstWhere((position) => position.index == 9).itemTrailingEdge, + _screenProportion(numberOfItems: 0.5, numberOfSeparators: 0), + ); }); - testWidgets('List positioned with 9 at middle scroll to 16 at bottom', - (WidgetTester tester) async { + testWidgets('List positioned with 9 at middle scroll to 16 at bottom', (WidgetTester tester) async { final itemScrollController = ItemScrollController(); final itemPositionsListener = ItemPositionsListener.create(); - await setUpWidgetTest(tester, - itemScrollController: itemScrollController, - itemPositionsListener: itemPositionsListener, - initialIndex: 9, - initialAlignment: 0.5); - - unawaited(itemScrollController.scrollTo( - index: 16, duration: scrollDuration, alignment: 1)); + await setUpWidgetTest( + tester, + itemScrollController: itemScrollController, + itemPositionsListener: itemPositionsListener, + initialIndex: 9, + initialAlignment: 0.5, + ); + + unawaited(itemScrollController.scrollTo(index: 16, duration: scrollDuration, alignment: 1)); await tester.pump(); await tester.pump(); await tester.pump(scrollDuration + scrollDurationTolerance); - expect(tester.getBottomRight(find.text('Item 15')).dy, - screenHeight - separatorHeight); + expect(tester.getBottomRight(find.text('Item 15')).dy, screenHeight - separatorHeight); expect( - itemPositionsListener.itemPositions.value - .firstWhere((position) => position.index == 15) - .itemTrailingEdge, - 1 - _screenProportion(numberOfItems: 0, numberOfSeparators: 1)); + itemPositionsListener.itemPositions.value.firstWhere((position) => position.index == 15).itemTrailingEdge, + 1 - _screenProportion(numberOfItems: 0, numberOfSeparators: 1), + ); }); testWidgets('physics', (WidgetTester tester) async { final itemScrollController = ItemScrollController(); - await setUpWidgetTest(tester, - itemScrollController: itemScrollController, - physics: const BouncingScrollPhysics()); + await setUpWidgetTest(tester, itemScrollController: itemScrollController, physics: const BouncingScrollPhysics()); - await tester.drag( - find.byType(ScrollablePositionedList), const Offset(0, 50)); + await tester.drag(find.byType(ScrollablePositionedList), const Offset(0, 50)); await tester.pump(const Duration(milliseconds: 200)); expect(tester.getTopLeft(find.text('Item 0')).dy, greaterThan(0)); @@ -297,14 +249,12 @@ void main() { await tester.pumpAndSettle(); expect(tester.getTopLeft(find.text('Item 0')).dy, 0); - unawaited( - itemScrollController.scrollTo(index: 100, duration: scrollDuration)); + unawaited(itemScrollController.scrollTo(index: 100, duration: scrollDuration)); await tester.pumpAndSettle(); itemScrollController.jumpTo(index: 0); await tester.pumpAndSettle(); - await tester.drag( - find.byType(ScrollablePositionedList), const Offset(0, 50)); + await tester.drag(find.byType(ScrollablePositionedList), const Offset(0, 50)); await tester.pump(const Duration(milliseconds: 200)); expect(tester.getTopLeft(find.text('Item 0')).dy, greaterThan(0)); @@ -316,15 +266,16 @@ void main() { testWidgets('correct index semantics', (WidgetTester tester) async { await setUpWidgetTest(tester, initialIndex: 5); - await tester.drag( - find.byType(ScrollablePositionedList), const Offset(0, itemHeight * 4)); + await tester.drag(find.byType(ScrollablePositionedList), const Offset(0, itemHeight * 4)); await tester.pumpAndSettle(); - final indexSemantics3 = tester.widget(find.ancestor( - of: find.text('Item 3'), matching: find.byType(IndexedSemantics))); + final indexSemantics3 = tester.widget( + find.ancestor(of: find.text('Item 3'), matching: find.byType(IndexedSemantics)), + ); expect(indexSemantics3.index, 3); - final indexSemantics4 = tester.widget(find.ancestor( - of: find.text('Item 4'), matching: find.byType(IndexedSemantics))); + final indexSemantics4 = tester.widget( + find.ancestor(of: find.text('Item 4'), matching: find.byType(IndexedSemantics)), + ); expect(indexSemantics4.index, 4); }); @@ -339,8 +290,7 @@ void main() { expect(find.byType(IndexedSemantics), findsNothing); - unawaited( - itemScrollController.scrollTo(index: 100, duration: scrollDuration)); + unawaited(itemScrollController.scrollTo(index: 100, duration: scrollDuration)); await tester.pumpAndSettle(); expect(find.byType(IndexedSemantics), findsNothing); @@ -355,16 +305,13 @@ void main() { itemScrollController: itemScrollController, ); - final customScrollView = - tester.widget(find.byType(UnboundedCustomScrollView)); + final customScrollView = tester.widget(find.byType(UnboundedCustomScrollView)); expect(customScrollView.semanticChildCount, 30); - unawaited( - itemScrollController.scrollTo(index: 100, duration: scrollDuration)); + unawaited(itemScrollController.scrollTo(index: 100, duration: scrollDuration)); await tester.pumpAndSettle(); - final customScrollView2 = - tester.widget(find.byType(UnboundedCustomScrollView)); + final customScrollView2 = tester.widget(find.byType(UnboundedCustomScrollView)); expect(customScrollView2.semanticChildCount, 30); }); @@ -375,16 +322,13 @@ void main() { itemScrollController: itemScrollController, ); - final customScrollView = - tester.widget(find.byType(UnboundedCustomScrollView)); + final customScrollView = tester.widget(find.byType(UnboundedCustomScrollView)); expect(customScrollView.semanticChildCount, defaultItemCount); - unawaited( - itemScrollController.scrollTo(index: 100, duration: scrollDuration)); + unawaited(itemScrollController.scrollTo(index: 100, duration: scrollDuration)); await tester.pumpAndSettle(); - final customScrollView2 = - tester.widget(find.byType(UnboundedCustomScrollView)); + final customScrollView2 = tester.widget(find.byType(UnboundedCustomScrollView)); expect(customScrollView2.semanticChildCount, defaultItemCount); }); @@ -397,25 +341,19 @@ void main() { ); expect(tester.getTopLeft(find.text('Item 0')), const Offset(10, 10)); - expect(tester.getTopLeft(find.text('Item 1')), - const Offset(10, itemHeight + 10 + separatorHeight)); - expect(tester.getTopRight(find.text('Item 1')), - const Offset(screenWidth - 10, itemHeight + 10 + separatorHeight)); + expect(tester.getTopLeft(find.text('Item 1')), const Offset(10, itemHeight + 10 + separatorHeight)); + expect(tester.getTopRight(find.text('Item 1')), const Offset(screenWidth - 10, itemHeight + 10 + separatorHeight)); - unawaited( - itemScrollController.scrollTo(index: 494, duration: scrollDuration)); + unawaited(itemScrollController.scrollTo(index: 494, duration: scrollDuration)); await tester.pumpAndSettle(); - await tester.drag( - find.byType(ScrollablePositionedList), const Offset(0, -500)); + await tester.drag(find.byType(ScrollablePositionedList), const Offset(0, -500)); await tester.pumpAndSettle(); - expect(tester.getBottomRight(find.text('Item 499')), - const Offset(screenWidth - 10, screenHeight - 10)); + expect(tester.getBottomRight(find.text('Item 499')), const Offset(screenWidth - 10, screenHeight - 10)); }); - testWidgets('padding test - centered sliver not at top', - (WidgetTester tester) async { + testWidgets('padding test - centered sliver not at top', (WidgetTester tester) async { final itemScrollController = ItemScrollController(); await setUpWidgetTest( tester, @@ -424,17 +362,15 @@ void main() { padding: const EdgeInsets.all(10), ); - await tester.drag( - find.byType(ScrollablePositionedList), const Offset(0, 200)); + await tester.drag(find.byType(ScrollablePositionedList), const Offset(0, 200)); await tester.pumpAndSettle(); expect(tester.getTopLeft(find.text('Item 0')), const Offset(10, 10)); - expect(tester.getTopLeft(find.text('Item 2')), - const Offset(10, 10 + 2 * (separatorHeight + itemHeight))); + expect(tester.getTopLeft(find.text('Item 2')), const Offset(10, 10 + 2 * (separatorHeight + itemHeight))); expect( - tester.getTopRight(find.text('Item 3')), - const Offset( - screenWidth - 10, 10 + 3 * (itemHeight + separatorHeight))); + tester.getTopRight(find.text('Item 3')), + const Offset(screenWidth - 10, 10 + 3 * (itemHeight + separatorHeight)), + ); }); testWidgets('no repaint bounderies', (WidgetTester tester) async { @@ -448,12 +384,13 @@ void main() { ); expect( - tester - .widgetList(find.descendant( - of: find.byType(ScrollablePositionedList), - matching: find.byType(RepaintBoundary))) - .length, - lessThan(5)); + tester + .widgetList( + find.descendant(of: find.byType(ScrollablePositionedList), matching: find.byType(RepaintBoundary)), + ) + .length, + lessThan(5), + ); }); testWidgets('no automatic keep alives', (WidgetTester tester) async { @@ -467,10 +404,9 @@ void main() { ); expect( - find.descendant( - of: find.byType(ScrollablePositionedList), - matching: find.byType(AutomaticKeepAlive)), - findsNothing); + find.descendant(of: find.byType(ScrollablePositionedList), matching: find.byType(AutomaticKeepAlive)), + findsNothing, + ); }); testWidgets('List can be keyed', (WidgetTester tester) async { @@ -481,8 +417,7 @@ void main() { expect(find.byKey(key), findsOneWidget); }); - testWidgets('Empty list then update to single item list', - (WidgetTester tester) async { + testWidgets('Empty list then update to single item list', (WidgetTester tester) async { tester.view.devicePixelRatio = 1.0; tester.view.physicalSize = const Size(screenWidth, screenHeight); @@ -522,8 +457,7 @@ void main() { expect(find.text('Separator 0'), findsNothing); }); - testWidgets('ItemPositions: Empty list then update to 10 items list', - (WidgetTester tester) async { + testWidgets('ItemPositions: Empty list then update to 10 items list', (WidgetTester tester) async { tester.view.devicePixelRatio = 1.0; tester.view.physicalSize = const Size(screenWidth, screenHeight); @@ -570,30 +504,16 @@ void main() { expect(find.text('Item 7'), findsNothing); expect(itemPositionsListener.itemPositions.value, isNotEmpty); + expect(itemPositionsListener.itemPositions.value.firstWhere((position) => position.index == 0).itemLeadingEdge, 0); expect( - itemPositionsListener.itemPositions.value - .firstWhere((position) => position.index == 0) - .itemLeadingEdge, - 0); - expect( - itemPositionsListener.itemPositions.value - .firstWhere((position) => position.index == 5) - .itemTrailingEdge, - 1 - _screenProportion(numberOfItems: 1, numberOfSeparators: 1)); + itemPositionsListener.itemPositions.value.firstWhere((position) => position.index == 5).itemTrailingEdge, + 1 - _screenProportion(numberOfItems: 1, numberOfSeparators: 1), + ); - expect( - itemPositionsListener.itemPositions.value - .firstWhere((position) => position.index == 6) - .itemTrailingEdge, - 1); - expect( - itemPositionsListener.itemPositions.value - .where((position) => position.index == 7), - isEmpty); + expect(itemPositionsListener.itemPositions.value.firstWhere((position) => position.index == 6).itemTrailingEdge, 1); + expect(itemPositionsListener.itemPositions.value.where((position) => position.index == 7), isEmpty); }); } -double _screenProportion( - {required double numberOfItems, required double numberOfSeparators}) => - (numberOfItems * itemHeight + numberOfSeparators * separatorHeight) / - screenHeight; +double _screenProportion({required double numberOfItems, required double numberOfSeparators}) => + (numberOfItems * itemHeight + numberOfSeparators * separatorHeight) / screenHeight; diff --git a/packages/stream_chat_flutter/test/scrollable_positioned_list/seperated_horizontal_scrollable_positioned_list_test.dart b/packages/stream_chat_flutter/test/scrollable_positioned_list/seperated_horizontal_scrollable_positioned_list_test.dart index 88b0fae0ac..b0c151666e 100644 --- a/packages/stream_chat_flutter/test/scrollable_positioned_list/seperated_horizontal_scrollable_positioned_list_test.dart +++ b/packages/stream_chat_flutter/test/scrollable_positioned_list/seperated_horizontal_scrollable_positioned_list_test.dart @@ -56,105 +56,90 @@ void main() { await setUpWidgetTest(tester, itemPositionsListener: itemPositionsListener); expect(tester.getTopLeft(find.text('Item 0')).dx, 0); - expect(tester.getBottomLeft(find.text('Item 1')).dx, - itemWidth + separatorWidth); + expect(tester.getBottomLeft(find.text('Item 1')).dx, itemWidth + separatorWidth); + expect(itemPositionsListener.itemPositions.value.firstWhere((position) => position.index == 0).itemLeadingEdge, 0); expect( - itemPositionsListener.itemPositions.value - .firstWhere((position) => position.index == 0) - .itemLeadingEdge, - 0); - expect( - itemPositionsListener.itemPositions.value - .firstWhere((position) => position.index == 1) - .itemLeadingEdge, - _screenProportion(numberOfItems: 1, numberOfSeparators: 1)); + itemPositionsListener.itemPositions.value.firstWhere((position) => position.index == 1).itemLeadingEdge, + _screenProportion(numberOfItems: 1, numberOfSeparators: 1), + ); }); testWidgets('Scroll to 2 (already on screen)', (WidgetTester tester) async { final itemScrollController = ItemScrollController(); final itemPositionsListener = ItemPositionsListener.create(); - await setUpWidgetTest(tester, - itemScrollController: itemScrollController, - itemPositionsListener: itemPositionsListener); + await setUpWidgetTest( + tester, + itemScrollController: itemScrollController, + itemPositionsListener: itemPositionsListener, + ); - unawaited( - itemScrollController.scrollTo(index: 2, duration: scrollDuration)); + unawaited(itemScrollController.scrollTo(index: 2, duration: scrollDuration)); await tester.pump(); await tester.pump(scrollDuration); expect(find.text('Item 1'), findsNothing); expect(tester.getTopLeft(find.text('Item 2')).dx, 0); - expect( - tester.getTopLeft(find.text('Item 3')).dx, itemWidth + separatorWidth); + expect(tester.getTopLeft(find.text('Item 3')).dx, itemWidth + separatorWidth); + expect(itemPositionsListener.itemPositions.value.firstWhere((position) => position.index == 2).itemLeadingEdge, 0); expect( - itemPositionsListener.itemPositions.value - .firstWhere((position) => position.index == 2) - .itemLeadingEdge, - 0); - expect( - itemPositionsListener.itemPositions.value - .firstWhere((position) => position.index == 3) - .itemLeadingEdge, - _screenProportion(numberOfItems: 1, numberOfSeparators: 1)); + itemPositionsListener.itemPositions.value.firstWhere((position) => position.index == 3).itemLeadingEdge, + _screenProportion(numberOfItems: 1, numberOfSeparators: 1), + ); }); - testWidgets('Scroll to 100 (not already on screen)', - (WidgetTester tester) async { + testWidgets('Scroll to 100 (not already on screen)', (WidgetTester tester) async { final itemScrollController = ItemScrollController(); final itemPositionsListener = ItemPositionsListener.create(); - await setUpWidgetTest(tester, - itemScrollController: itemScrollController, - itemPositionsListener: itemPositionsListener); + await setUpWidgetTest( + tester, + itemScrollController: itemScrollController, + itemPositionsListener: itemPositionsListener, + ); - unawaited( - itemScrollController.scrollTo(index: 100, duration: scrollDuration)); + unawaited(itemScrollController.scrollTo(index: 100, duration: scrollDuration)); await tester.pumpAndSettle(); expect(find.text('Item 99'), findsNothing); expect(find.text('Item 100'), findsOneWidget); expect( - itemPositionsListener.itemPositions.value - .firstWhere((position) => position.index == 100) - .itemLeadingEdge, - 0); + itemPositionsListener.itemPositions.value.firstWhere((position) => position.index == 100).itemLeadingEdge, + 0, + ); expect( - itemPositionsListener.itemPositions.value - .firstWhere((position) => position.index == 101) - .itemLeadingEdge, - _screenProportion(numberOfItems: 1, numberOfSeparators: 1)); + itemPositionsListener.itemPositions.value.firstWhere((position) => position.index == 101).itemLeadingEdge, + _screenProportion(numberOfItems: 1, numberOfSeparators: 1), + ); }); testWidgets('Jump to 100', (WidgetTester tester) async { final itemScrollController = ItemScrollController(); final itemPositionsListener = ItemPositionsListener.create(); - await setUpWidgetTest(tester, - itemScrollController: itemScrollController, - itemPositionsListener: itemPositionsListener); + await setUpWidgetTest( + tester, + itemScrollController: itemScrollController, + itemPositionsListener: itemPositionsListener, + ); itemScrollController.jumpTo(index: 100); await tester.pumpAndSettle(); expect(tester.getTopLeft(find.text('Item 100')).dx, 0); - expect(tester.getTopLeft(find.text('Item 101')).dx, - itemWidth + separatorWidth); + expect(tester.getTopLeft(find.text('Item 101')).dx, itemWidth + separatorWidth); expect( - itemPositionsListener.itemPositions.value - .firstWhere((position) => position.index == 100) - .itemLeadingEdge, - 0); + itemPositionsListener.itemPositions.value.firstWhere((position) => position.index == 100).itemLeadingEdge, + 0, + ); expect( - itemPositionsListener.itemPositions.value - .firstWhere((position) => position.index == 101) - .itemLeadingEdge, - _screenProportion(numberOfItems: 1, numberOfSeparators: 1)); + itemPositionsListener.itemPositions.value.firstWhere((position) => position.index == 101).itemLeadingEdge, + _screenProportion(numberOfItems: 1, numberOfSeparators: 1), + ); }); - testWidgets('padding test - centered sliver at left', - (WidgetTester tester) async { + testWidgets('padding test - centered sliver at left', (WidgetTester tester) async { final itemScrollController = ItemScrollController(); await setUpWidgetTest( tester, @@ -163,25 +148,22 @@ void main() { ); expect(tester.getTopLeft(find.text('Item 0')), const Offset(10, 10)); - expect(tester.getTopLeft(find.text('Item 1')), - const Offset(itemWidth + 10 + separatorWidth, 10)); - expect(tester.getBottomRight(find.text('Item 1')), - const Offset(10 + itemWidth * 2 + separatorWidth, screenHeight - 10)); + expect(tester.getTopLeft(find.text('Item 1')), const Offset(itemWidth + 10 + separatorWidth, 10)); + expect( + tester.getBottomRight(find.text('Item 1')), + const Offset(10 + itemWidth * 2 + separatorWidth, screenHeight - 10), + ); - unawaited( - itemScrollController.scrollTo(index: 494, duration: scrollDuration)); + unawaited(itemScrollController.scrollTo(index: 494, duration: scrollDuration)); await tester.pumpAndSettle(); - await tester.drag( - find.byType(ScrollablePositionedList), const Offset(-500, 0)); + await tester.drag(find.byType(ScrollablePositionedList), const Offset(-500, 0)); await tester.pumpAndSettle(); - expect(tester.getBottomRight(find.text('Item 499')), - const Offset(screenWidth - 10, screenHeight - 10)); + expect(tester.getBottomRight(find.text('Item 499')), const Offset(screenWidth - 10, screenHeight - 10)); }); - testWidgets('padding test - centered sliver not at left', - (WidgetTester tester) async { + testWidgets('padding test - centered sliver not at left', (WidgetTester tester) async { final itemScrollController = ItemScrollController(); final itemPositionsListener = ItemPositionsListener.create(); await setUpWidgetTest( @@ -192,27 +174,19 @@ void main() { padding: const EdgeInsets.all(10), ); - await tester.drag( - find.byType(ScrollablePositionedList), const Offset(300, 0)); + await tester.drag(find.byType(ScrollablePositionedList), const Offset(300, 0)); await tester.pumpAndSettle(); expect(tester.getTopLeft(find.text('Item 0')), const Offset(10, 10)); - expect(tester.getTopLeft(find.text('Item 2')), - const Offset(10 + 2 * (itemWidth + separatorWidth), 10)); - expect(tester.getTopLeft(find.text('Item 3')), - const Offset(10 + 3 * (itemWidth + separatorWidth), 10)); + expect(tester.getTopLeft(find.text('Item 2')), const Offset(10 + 2 * (itemWidth + separatorWidth), 10)); + expect(tester.getTopLeft(find.text('Item 3')), const Offset(10 + 3 * (itemWidth + separatorWidth), 10)); expect( - itemPositionsListener.itemPositions.value - .firstWhere((position) => position.index == 2) - .itemLeadingEdge, - closeTo( - 10 / screenWidth + 2 * ((itemWidth + separatorWidth) / screenWidth), - tolerance)); + itemPositionsListener.itemPositions.value.firstWhere((position) => position.index == 2).itemLeadingEdge, + closeTo(10 / screenWidth + 2 * ((itemWidth + separatorWidth) / screenWidth), tolerance), + ); }); } -double _screenProportion( - {required double numberOfItems, required double numberOfSeparators}) => - (numberOfItems * itemWidth + numberOfSeparators * separatorWidth) / - screenHeight; +double _screenProportion({required double numberOfItems, required double numberOfSeparators}) => + (numberOfItems * itemWidth + numberOfSeparators * separatorWidth) / screenHeight; diff --git a/packages/stream_chat_flutter/test/scrollable_positioned_list/shrink_wrap_position_list_test.dart b/packages/stream_chat_flutter/test/scrollable_positioned_list/shrink_wrap_position_list_test.dart index 8cf4303c4f..2c13c0d601 100644 --- a/packages/stream_chat_flutter/test/scrollable_positioned_list/shrink_wrap_position_list_test.dart +++ b/packages/stream_chat_flutter/test/scrollable_positioned_list/shrink_wrap_position_list_test.dart @@ -32,28 +32,28 @@ void main() { MaterialApp( // Use flex layout to ensure that the minimum height is not limited to // screenHeight. - home: Column(children: [ - // Use Constrained to make max height not more than screenHeight - ConstrainedBox( - constraints: const BoxConstraints( - maxHeight: screenHeight, maxWidth: screenWidth), - child: PositionedList( - key: key, - itemCount: itemCount, - positionedIndex: topItem, - alignment: anchor, - controller: scrollController, - itemBuilder: (context, index) => SizedBox( - height: itemHeight, - child: Text('Item $index'), + home: Column( + children: [ + // Use Constrained to make max height not more than screenHeight + ConstrainedBox( + constraints: const BoxConstraints(maxHeight: screenHeight, maxWidth: screenWidth), + child: PositionedList( + key: key, + itemCount: itemCount, + positionedIndex: topItem, + alignment: anchor, + controller: scrollController, + itemBuilder: (context, index) => SizedBox( + height: itemHeight, + child: Text('Item $index'), + ), + itemPositionsNotifier: itemPositionsNotifier as ItemPositionsNotifier, + shrinkWrap: true, + reverse: reverse, ), - itemPositionsNotifier: - itemPositionsNotifier as ItemPositionsNotifier, - shrinkWrap: true, - reverse: reverse, ), - ), - ]), + ], + ), ), ); } @@ -64,28 +64,21 @@ void main() { await setUpWidgetTest(tester, itemCount: itemCount, key: key); await tester.pump(); - expect( - tester.getBottomRight(find.text('Item 4')).dy, itemHeight * itemCount); + expect(tester.getBottomRight(find.text('Item 4')).dy, itemHeight * itemCount); expect(find.text('Item 4'), findsOneWidget); expect(find.text('Item 5'), findsNothing); final positionList = find.byKey(key); expect(tester.getBottomRight(positionList).dy, itemHeight * itemCount); + expect(itemPositionsNotifier.itemPositions.value.firstWhere((position) => position.index == 0).itemLeadingEdge, 0); expect( - itemPositionsNotifier.itemPositions.value - .firstWhere((position) => position.index == 0) - .itemLeadingEdge, - 0); - expect( - itemPositionsNotifier.itemPositions.value - .firstWhere((position) => position.index == 4) - .itemTrailingEdge, - 1.0); + itemPositionsNotifier.itemPositions.value.firstWhere((position) => position.index == 4).itemTrailingEdge, + 1.0, + ); }); - testWidgets('List positioned with 0 at top and shrink wrap', - (WidgetTester tester) async { + testWidgets('List positioned with 0 at top and shrink wrap', (WidgetTester tester) async { await setUpWidgetTest(tester); await tester.pump(); @@ -93,30 +86,16 @@ void main() { expect(find.text('Item 9'), findsOneWidget); expect(find.text('Item 10'), findsNothing); + expect(itemPositionsNotifier.itemPositions.value.firstWhere((position) => position.index == 0).itemLeadingEdge, 0); + expect(itemPositionsNotifier.itemPositions.value.firstWhere((position) => position.index == 9).itemTrailingEdge, 1); + expect(itemPositionsNotifier.itemPositions.value.firstWhere((position) => position.index == 10).itemLeadingEdge, 1); expect( - itemPositionsNotifier.itemPositions.value - .firstWhere((position) => position.index == 0) - .itemLeadingEdge, - 0); - expect( - itemPositionsNotifier.itemPositions.value - .firstWhere((position) => position.index == 9) - .itemTrailingEdge, - 1); - expect( - itemPositionsNotifier.itemPositions.value - .firstWhere((position) => position.index == 10) - .itemLeadingEdge, - 1); - expect( - itemPositionsNotifier.itemPositions.value - .firstWhere((position) => position.index == 10) - .itemTrailingEdge, - 11 / 10); + itemPositionsNotifier.itemPositions.value.firstWhere((position) => position.index == 10).itemTrailingEdge, + 11 / 10, + ); }); - testWidgets('List positioned with 5 at top and shrink wrap', - (WidgetTester tester) async { + testWidgets('List positioned with 5 at top and shrink wrap', (WidgetTester tester) async { await setUpWidgetTest(tester, topItem: 5); await tester.pump(); @@ -126,29 +105,18 @@ void main() { expect(find.text('Item 15'), findsNothing); expect( - itemPositionsNotifier.itemPositions.value - .firstWhere((position) => position.index == 4) - .itemLeadingEdge, - -1 / 10); - expect( - itemPositionsNotifier.itemPositions.value - .firstWhere((position) => position.index == 4) - .itemTrailingEdge, - 0); - expect( - itemPositionsNotifier.itemPositions.value - .firstWhere((position) => position.index == 5) - .itemLeadingEdge, - 0); + itemPositionsNotifier.itemPositions.value.firstWhere((position) => position.index == 4).itemLeadingEdge, + -1 / 10, + ); + expect(itemPositionsNotifier.itemPositions.value.firstWhere((position) => position.index == 4).itemTrailingEdge, 0); + expect(itemPositionsNotifier.itemPositions.value.firstWhere((position) => position.index == 5).itemLeadingEdge, 0); expect( - itemPositionsNotifier.itemPositions.value - .firstWhere((position) => position.index == 14) - .itemTrailingEdge, - 1); + itemPositionsNotifier.itemPositions.value.firstWhere((position) => position.index == 14).itemTrailingEdge, + 1, + ); }); - testWidgets('List positioned with 20 at bottom and shrink wrap', - (WidgetTester tester) async { + testWidgets('List positioned with 20 at bottom and shrink wrap', (WidgetTester tester) async { await setUpWidgetTest(tester, topItem: 20, anchor: 1); await tester.pump(); @@ -156,69 +124,50 @@ void main() { expect(find.text('Item 19'), findsOneWidget); expect(find.text('Item 10'), findsOneWidget); + expect(itemPositionsNotifier.itemPositions.value.firstWhere((position) => position.index == 10).itemLeadingEdge, 0); expect( - itemPositionsNotifier.itemPositions.value - .firstWhere((position) => position.index == 10) - .itemLeadingEdge, - 0); - expect( - itemPositionsNotifier.itemPositions.value - .firstWhere((position) => position.index == 19) - .itemLeadingEdge, - 9 / 10); - expect( - itemPositionsNotifier.itemPositions.value - .firstWhere((position) => position.index == 19) - .itemTrailingEdge, - 1); + itemPositionsNotifier.itemPositions.value.firstWhere((position) => position.index == 19).itemLeadingEdge, + 9 / 10, + ); expect( - itemPositionsNotifier.itemPositions.value - .firstWhere((position) => position.index == 20) - .itemLeadingEdge, - 1); + itemPositionsNotifier.itemPositions.value.firstWhere((position) => position.index == 19).itemTrailingEdge, + 1, + ); + expect(itemPositionsNotifier.itemPositions.value.firstWhere((position) => position.index == 20).itemLeadingEdge, 1); }); - testWidgets('List positioned with 20 at halfway and shrink wrap', - (WidgetTester tester) async { + testWidgets('List positioned with 20 at halfway and shrink wrap', (WidgetTester tester) async { await setUpWidgetTest(tester, topItem: 20, anchor: 0.5); await tester.pump(); expect( - itemPositionsNotifier.itemPositions.value - .firstWhere((position) => position.index == 20) - .itemLeadingEdge, - 0.5); + itemPositionsNotifier.itemPositions.value.firstWhere((position) => position.index == 20).itemLeadingEdge, + 0.5, + ); expect( - itemPositionsNotifier.itemPositions.value - .firstWhere((position) => position.index == 20) - .itemTrailingEdge, - 0.5 + itemHeight / screenHeight); + itemPositionsNotifier.itemPositions.value.firstWhere((position) => position.index == 20).itemTrailingEdge, + 0.5 + itemHeight / screenHeight, + ); }); - testWidgets('List positioned with 20 half off top of screen and shrink wrap', - (WidgetTester tester) async { - await setUpWidgetTest(tester, - topItem: 20, anchor: -(itemHeight / screenHeight) / 2); + testWidgets('List positioned with 20 half off top of screen and shrink wrap', (WidgetTester tester) async { + await setUpWidgetTest(tester, topItem: 20, anchor: -(itemHeight / screenHeight) / 2); await tester.pump(); expect( - itemPositionsNotifier.itemPositions.value - .firstWhere((position) => position.index == 20) - .itemLeadingEdge, - -(itemHeight / screenHeight) / 2); + itemPositionsNotifier.itemPositions.value.firstWhere((position) => position.index == 20).itemLeadingEdge, + -(itemHeight / screenHeight) / 2, + ); expect( - itemPositionsNotifier.itemPositions.value - .firstWhere((position) => position.index == 20) - .itemTrailingEdge, - (itemHeight / screenHeight) / 2); + itemPositionsNotifier.itemPositions.value.firstWhere((position) => position.index == 20).itemTrailingEdge, + (itemHeight / screenHeight) / 2, + ); }); - testWidgets('List positioned with 5 at top then scroll up 2 and shrink wrap', - (WidgetTester tester) async { + testWidgets('List positioned with 5 at top then scroll up 2 and shrink wrap', (WidgetTester tester) async { await setUpWidgetTest(tester, topItem: 5); - await tester.drag( - find.byType(PositionedList), const Offset(0, itemHeight * 2)); + await tester.drag(find.byType(PositionedList), const Offset(0, itemHeight * 2)); await tester.pump(); expect(find.text('Item 2'), findsNothing); @@ -227,45 +176,33 @@ void main() { expect(find.text('Item 13'), findsNothing); expect( - itemPositionsNotifier.itemPositions.value - .firstWhere((position) => position.index == 2) - .itemLeadingEdge, - -1 / 10); - expect( - itemPositionsNotifier.itemPositions.value - .firstWhere((position) => position.index == 3) - .itemLeadingEdge, - 0); + itemPositionsNotifier.itemPositions.value.firstWhere((position) => position.index == 2).itemLeadingEdge, + -1 / 10, + ); + expect(itemPositionsNotifier.itemPositions.value.firstWhere((position) => position.index == 3).itemLeadingEdge, 0); expect( - itemPositionsNotifier.itemPositions.value - .firstWhere((position) => position.index == 12) - .itemTrailingEdge, - 1); + itemPositionsNotifier.itemPositions.value.firstWhere((position) => position.index == 12).itemTrailingEdge, + 1, + ); }); - testWidgets( - 'List positioned with 5 at top then scroll down 1/2 and shrink wrap', - (WidgetTester tester) async { + testWidgets('List positioned with 5 at top then scroll down 1/2 and shrink wrap', (WidgetTester tester) async { await setUpWidgetTest(tester, topItem: 5); - await tester.drag( - find.byType(PositionedList), const Offset(0, -1 / 2 * itemHeight)); + await tester.drag(find.byType(PositionedList), const Offset(0, -1 / 2 * itemHeight)); await tester.pump(); expect( - itemPositionsNotifier.itemPositions.value - .firstWhere((position) => position.index == 5) - .itemTrailingEdge, - 1 / 20); + itemPositionsNotifier.itemPositions.value.firstWhere((position) => position.index == 5).itemTrailingEdge, + 1 / 20, + ); expect( - itemPositionsNotifier.itemPositions.value - .firstWhere((position) => position.index == 14) - .itemLeadingEdge, - 17 / 20); + itemPositionsNotifier.itemPositions.value.firstWhere((position) => position.index == 14).itemLeadingEdge, + 17 / 20, + ); }); - testWidgets('List positioned with 0 at top scroll up 5 and shrink wrap', - (WidgetTester tester) async { + testWidgets('List positioned with 0 at top scroll up 5 and shrink wrap', (WidgetTester tester) async { final scrollController = ScrollController(); await setUpWidgetTest(tester, scrollController: scrollController); await tester.pump(); @@ -279,24 +216,18 @@ void main() { expect(find.text('Item 14'), findsOneWidget); expect(find.text('Item 15'), findsNothing); + expect(itemPositionsNotifier.itemPositions.value.firstWhere((position) => position.index == 5).itemLeadingEdge, 0); expect( - itemPositionsNotifier.itemPositions.value - .firstWhere((position) => position.index == 5) - .itemLeadingEdge, - 0); - expect( - itemPositionsNotifier.itemPositions.value - .firstWhere((position) => position.index == 4) - .itemLeadingEdge, - -1 / 10); + itemPositionsNotifier.itemPositions.value.firstWhere((position) => position.index == 4).itemLeadingEdge, + -1 / 10, + ); }); testWidgets( '''List positioned with 5 at top then scroll up 2 programatically and shrink wrap''', (WidgetTester tester) async { final scrollController = ScrollController(); - await setUpWidgetTest(tester, - topItem: 5, scrollController: scrollController); + await setUpWidgetTest(tester, topItem: 5, scrollController: scrollController); scrollController.jumpTo(-2 * itemHeight); await tester.pump(); @@ -307,20 +238,17 @@ void main() { expect(find.text('Item 13'), findsNothing); expect( - itemPositionsNotifier.itemPositions.value - .firstWhere((position) => position.index == 2) - .itemLeadingEdge, - -1 / 10); + itemPositionsNotifier.itemPositions.value.firstWhere((position) => position.index == 2).itemLeadingEdge, + -1 / 10, + ); expect( - itemPositionsNotifier.itemPositions.value - .firstWhere((position) => position.index == 3) - .itemLeadingEdge, - 0); + itemPositionsNotifier.itemPositions.value.firstWhere((position) => position.index == 3).itemLeadingEdge, + 0, + ); expect( - itemPositionsNotifier.itemPositions.value - .firstWhere((position) => position.index == 12) - .itemTrailingEdge, - 1); + itemPositionsNotifier.itemPositions.value.firstWhere((position) => position.index == 12).itemTrailingEdge, + 1, + ); }, ); @@ -328,93 +256,70 @@ void main() { '''List positioned with 5 at top then scroll down 20 programatically and shrink wrap''', (WidgetTester tester) async { final scrollController = ScrollController(); - await setUpWidgetTest(tester, - topItem: 5, scrollController: scrollController); + await setUpWidgetTest(tester, topItem: 5, scrollController: scrollController); scrollController.jumpTo(itemHeight * 20); await tester.pump(); expect( - itemPositionsNotifier.itemPositions.value - .firstWhere((position) => position.index == 23) - .itemLeadingEdge, - -2 / 10); + itemPositionsNotifier.itemPositions.value.firstWhere((position) => position.index == 23).itemLeadingEdge, + -2 / 10, + ); expect( - itemPositionsNotifier.itemPositions.value - .firstWhere((position) => position.index == 24) - .itemLeadingEdge, - -1 / 10); + itemPositionsNotifier.itemPositions.value.firstWhere((position) => position.index == 24).itemLeadingEdge, + -1 / 10, + ); expect( - itemPositionsNotifier.itemPositions.value - .firstWhere((position) => position.index == 25) - .itemLeadingEdge, - 0); + itemPositionsNotifier.itemPositions.value.firstWhere((position) => position.index == 25).itemLeadingEdge, + 0, + ); expect( - itemPositionsNotifier.itemPositions.value - .firstWhere((position) => position.index == 4) - .itemLeadingEdge, - -21 / 10); + itemPositionsNotifier.itemPositions.value.firstWhere((position) => position.index == 4).itemLeadingEdge, + -21 / 10, + ); expect( - itemPositionsNotifier.itemPositions.value - .firstWhere((position) => position.index == 5) - .itemLeadingEdge, - -20 / 10); + itemPositionsNotifier.itemPositions.value.firstWhere((position) => position.index == 5).itemLeadingEdge, + -20 / 10, + ); }, ); - testWidgets( - 'List positioned with 5 at top and initial scroll offset and shrink wrap', - (WidgetTester tester) async { - final scrollController = - ScrollController(initialScrollOffset: -2 * itemHeight); - await setUpWidgetTest(tester, - topItem: 5, scrollController: scrollController); + testWidgets('List positioned with 5 at top and initial scroll offset and shrink wrap', (WidgetTester tester) async { + final scrollController = ScrollController(initialScrollOffset: -2 * itemHeight); + await setUpWidgetTest(tester, topItem: 5, scrollController: scrollController); expect(find.text('Item 2'), findsNothing); expect(find.text('Item 3'), findsOneWidget); expect(find.text('Item 12'), findsOneWidget); expect(find.text('Item 13'), findsNothing); + expect(itemPositionsNotifier.itemPositions.value.firstWhere((position) => position.index == 3).itemLeadingEdge, 0); expect( - itemPositionsNotifier.itemPositions.value - .firstWhere((position) => position.index == 3) - .itemLeadingEdge, - 0); - expect( - itemPositionsNotifier.itemPositions.value - .firstWhere((position) => position.index == 12) - .itemTrailingEdge, - 1); + itemPositionsNotifier.itemPositions.value.firstWhere((position) => position.index == 12).itemTrailingEdge, + 1, + ); }); - testWidgets('short List with reverse and shrink wrap', - (WidgetTester tester) async { + testWidgets('short List with reverse and shrink wrap', (WidgetTester tester) async { const itemCount = 5; const key = Key('short_list'); - await setUpWidgetTest(tester, - itemCount: itemCount, key: key, reverse: true); + await setUpWidgetTest(tester, itemCount: itemCount, key: key, reverse: true); await tester.pump(); expect(find.text('Item 4'), findsOneWidget); expect(find.text('Item 5'), findsNothing); - expect( - tester.getBottomRight(find.text('Item 0')).dy, itemHeight * itemCount); + expect(tester.getBottomRight(find.text('Item 0')).dy, itemHeight * itemCount); expect(tester.getTopLeft(find.text('Item 4')).dy, 0); final positionList = find.byKey(key); expect(tester.getBottomRight(positionList).dy, itemHeight * itemCount); expect(tester.getTopLeft(positionList).dy, 0); + expect(itemPositionsNotifier.itemPositions.value.firstWhere((position) => position.index == 0).itemLeadingEdge, 0); expect( - itemPositionsNotifier.itemPositions.value - .firstWhere((position) => position.index == 0) - .itemLeadingEdge, - 0); - expect( - itemPositionsNotifier.itemPositions.value - .firstWhere((position) => position.index == 4) - .itemTrailingEdge, - 1.0); + itemPositionsNotifier.itemPositions.value.firstWhere((position) => position.index == 4).itemTrailingEdge, + 1.0, + ); }); testWidgets('test nested positioned list', (WidgetTester tester) async { @@ -432,13 +337,14 @@ void main() { itemBuilder: (context, index) { if (index == 0) { return PositionedList( - key: key, - itemCount: itemCount, - shrinkWrap: true, - itemBuilder: (context, idx) => SizedBox( - height: itemHeight, - child: Text('Item $idx'), - )); + key: key, + itemCount: itemCount, + shrinkWrap: true, + itemBuilder: (context, idx) => SizedBox( + height: itemHeight, + child: Text('Item $idx'), + ), + ); } else { return SizedBox( height: itemHeight, @@ -461,15 +367,10 @@ void main() { expect(tester.getBottomRight(positionList).dy, itemHeight * itemCount); expect(tester.getTopLeft(positionList).dy, 0); + expect(itemPositionsNotifier.itemPositions.value.firstWhere((position) => position.index == 0).itemLeadingEdge, 0); expect( - itemPositionsNotifier.itemPositions.value - .firstWhere((position) => position.index == 0) - .itemLeadingEdge, - 0); - expect( - itemPositionsNotifier.itemPositions.value - .firstWhere((position) => position.index == 0) - .itemTrailingEdge, - 5.0); + itemPositionsNotifier.itemPositions.value.firstWhere((position) => position.index == 0).itemTrailingEdge, + 5.0, + ); }); } diff --git a/packages/stream_chat_flutter/test/scrollable_positioned_list/shrink_wrap_scrollable_position_list_test.dart b/packages/stream_chat_flutter/test/scrollable_positioned_list/shrink_wrap_scrollable_position_list_test.dart index 37830e00c9..7b0655b150 100644 --- a/packages/stream_chat_flutter/test/scrollable_positioned_list/shrink_wrap_scrollable_position_list_test.dart +++ b/packages/stream_chat_flutter/test/scrollable_positioned_list/shrink_wrap_scrollable_position_list_test.dart @@ -29,31 +29,31 @@ void main() { MaterialApp( // Use flex layout to ensure that the minimum height is not limited to // screenHeight. - home: Column(children: [ - // Use Constrained to make max height not more than screenHeight - ConstrainedBox( - constraints: const BoxConstraints( - maxHeight: screenHeight, maxWidth: screenWidth), - child: ScrollablePositionedList.builder( - itemCount: itemCount, - initialScrollIndex: initialIndex, - itemScrollController: itemScrollController, - itemBuilder: (context, index) => SizedBox( - height: itemHeight, - child: Text('Item $index'), + home: Column( + children: [ + // Use Constrained to make max height not more than screenHeight + ConstrainedBox( + constraints: const BoxConstraints(maxHeight: screenHeight, maxWidth: screenWidth), + child: ScrollablePositionedList.builder( + itemCount: itemCount, + initialScrollIndex: initialIndex, + itemScrollController: itemScrollController, + itemBuilder: (context, index) => SizedBox( + height: itemHeight, + child: Text('Item $index'), + ), + itemPositionsListener: itemPositionsListener, + shrinkWrap: true, + padding: padding, ), - itemPositionsListener: itemPositionsListener, - shrinkWrap: true, - padding: padding, ), - ), - ]), + ], + ), ), ); } - testWidgets('List positioned with 0 at top and shrink wrap', - (WidgetTester tester) async { + testWidgets('List positioned with 0 at top and shrink wrap', (WidgetTester tester) async { final itemPositionsListener = ItemPositionsListener.create(); await setUpWidgetTest(tester, itemPositionsListener: itemPositionsListener); @@ -61,123 +61,95 @@ void main() { expect(tester.getBottomRight(find.text('Item 9')).dy, screenHeight); expect(find.text('Item 10'), findsNothing); - expect( - itemPositionsListener.itemPositions.value - .firstWhere((position) => position.index == 0) - .itemLeadingEdge, - 0); - expect( - itemPositionsListener.itemPositions.value - .firstWhere((position) => position.index == 9) - .itemTrailingEdge, - 1); + expect(itemPositionsListener.itemPositions.value.firstWhere((position) => position.index == 0).itemLeadingEdge, 0); + expect(itemPositionsListener.itemPositions.value.firstWhere((position) => position.index == 9).itemTrailingEdge, 1); }); - testWidgets('Scroll to 1 then 2 (both already on screen) with shrink wrap', - (WidgetTester tester) async { + testWidgets('Scroll to 1 then 2 (both already on screen) with shrink wrap', (WidgetTester tester) async { final itemScrollController = ItemScrollController(); final itemPositionsListener = ItemPositionsListener.create(); - await setUpWidgetTest(tester, - itemScrollController: itemScrollController, - itemPositionsListener: itemPositionsListener); + await setUpWidgetTest( + tester, + itemScrollController: itemScrollController, + itemPositionsListener: itemPositionsListener, + ); - unawaited( - itemScrollController.scrollTo(index: 1, duration: scrollDuration)); + unawaited(itemScrollController.scrollTo(index: 1, duration: scrollDuration)); await tester.pump(); await tester.pump(scrollDuration); expect(find.text('Item 0'), findsNothing); - expect( - itemPositionsListener.itemPositions.value - .firstWhere((position) => position.index == 1) - .itemLeadingEdge, - 0); + expect(itemPositionsListener.itemPositions.value.firstWhere((position) => position.index == 1).itemLeadingEdge, 0); expect(tester.getTopLeft(find.text('Item 1')).dy, 0); - unawaited( - itemScrollController.scrollTo(index: 2, duration: scrollDuration)); + unawaited(itemScrollController.scrollTo(index: 2, duration: scrollDuration)); await tester.pump(); await tester.pump(scrollDuration); expect(find.text('Item 1'), findsNothing); expect(tester.getTopLeft(find.text('Item 2')).dy, 0); + expect(itemPositionsListener.itemPositions.value.firstWhere((position) => position.index == 2).itemLeadingEdge, 0); expect( - itemPositionsListener.itemPositions.value - .firstWhere((position) => position.index == 2) - .itemLeadingEdge, - 0); - expect( - itemPositionsListener.itemPositions.value - .firstWhere((position) => position.index == 11) - .itemTrailingEdge, - 1); + itemPositionsListener.itemPositions.value.firstWhere((position) => position.index == 11).itemTrailingEdge, + 1, + ); }); - testWidgets( - 'Scroll to 5 (already on screen) and then back to 0 with shrink wrap', - (WidgetTester tester) async { + testWidgets('Scroll to 5 (already on screen) and then back to 0 with shrink wrap', (WidgetTester tester) async { final itemScrollController = ItemScrollController(); final itemPositionsListener = ItemPositionsListener.create(); - await setUpWidgetTest(tester, - itemScrollController: itemScrollController, - itemPositionsListener: itemPositionsListener); + await setUpWidgetTest( + tester, + itemScrollController: itemScrollController, + itemPositionsListener: itemPositionsListener, + ); - unawaited( - itemScrollController.scrollTo(index: 5, duration: scrollDuration)); + unawaited(itemScrollController.scrollTo(index: 5, duration: scrollDuration)); await tester.pumpAndSettle(); - unawaited( - itemScrollController.scrollTo(index: 0, duration: scrollDuration)); + unawaited(itemScrollController.scrollTo(index: 0, duration: scrollDuration)); await tester.pumpAndSettle(); expect(find.text('Item 0'), findsOneWidget); expect(find.text('Item 9'), findsOneWidget); expect(find.text('Item 10'), findsNothing); - expect( - itemPositionsListener.itemPositions.value - .firstWhere((position) => position.index == 0) - .itemLeadingEdge, - 0); - expect( - itemPositionsListener.itemPositions.value - .firstWhere((position) => position.index == 9) - .itemTrailingEdge, - 1); + expect(itemPositionsListener.itemPositions.value.firstWhere((position) => position.index == 0).itemLeadingEdge, 0); + expect(itemPositionsListener.itemPositions.value.firstWhere((position) => position.index == 9).itemTrailingEdge, 1); }); - testWidgets('Scroll to 100 (not already on screen) with shrink wrap', - (WidgetTester tester) async { + testWidgets('Scroll to 100 (not already on screen) with shrink wrap', (WidgetTester tester) async { final itemScrollController = ItemScrollController(); final itemPositionsListener = ItemPositionsListener.create(); - await setUpWidgetTest(tester, - itemScrollController: itemScrollController, - itemPositionsListener: itemPositionsListener); + await setUpWidgetTest( + tester, + itemScrollController: itemScrollController, + itemPositionsListener: itemPositionsListener, + ); - unawaited( - itemScrollController.scrollTo(index: 100, duration: scrollDuration)); + unawaited(itemScrollController.scrollTo(index: 100, duration: scrollDuration)); await tester.pumpAndSettle(); expect(find.text('Item 99'), findsNothing); expect(find.text('Item 100'), findsOneWidget); expect( - itemPositionsListener.itemPositions.value - .firstWhere((position) => position.index == 100) - .itemLeadingEdge, - 0); + itemPositionsListener.itemPositions.value.firstWhere((position) => position.index == 100).itemLeadingEdge, + 0, + ); expect( - itemPositionsListener.itemPositions.value - .firstWhere((position) => position.index == 109) - .itemTrailingEdge, - 1); + itemPositionsListener.itemPositions.value.firstWhere((position) => position.index == 109).itemTrailingEdge, + 1, + ); }); testWidgets('Jump to 100 with shrink wrap', (WidgetTester tester) async { final itemScrollController = ItemScrollController(); final itemPositionsListener = ItemPositionsListener.create(); - await setUpWidgetTest(tester, - itemScrollController: itemScrollController, - itemPositionsListener: itemPositionsListener); + await setUpWidgetTest( + tester, + itemScrollController: itemScrollController, + itemPositionsListener: itemPositionsListener, + ); itemScrollController.jumpTo(index: 100); await tester.pumpAndSettle(); @@ -186,19 +158,16 @@ void main() { expect(tester.getBottomRight(find.text('Item 109')).dy, screenHeight); expect( - itemPositionsListener.itemPositions.value - .firstWhere((position) => position.index == 100) - .itemLeadingEdge, - 0); + itemPositionsListener.itemPositions.value.firstWhere((position) => position.index == 100).itemLeadingEdge, + 0, + ); expect( - itemPositionsListener.itemPositions.value - .firstWhere((position) => position.index == 109) - .itemTrailingEdge, - 1); + itemPositionsListener.itemPositions.value.firstWhere((position) => position.index == 109).itemTrailingEdge, + 1, + ); }); - testWidgets('padding test - centered sliver at bottom with shrink wrap', - (WidgetTester tester) async { + testWidgets('padding test - centered sliver at bottom with shrink wrap', (WidgetTester tester) async { final itemScrollController = ItemScrollController(); await setUpWidgetTest( tester, @@ -207,25 +176,19 @@ void main() { ); expect(tester.getTopLeft(find.text('Item 0')), const Offset(10, 10)); - expect(tester.getTopLeft(find.text('Item 1')), - const Offset(10, itemHeight + 10)); - expect(tester.getBottomRight(find.text('Item 1')), - const Offset(screenWidth - 10, 10 + itemHeight * 2)); + expect(tester.getTopLeft(find.text('Item 1')), const Offset(10, itemHeight + 10)); + expect(tester.getBottomRight(find.text('Item 1')), const Offset(screenWidth - 10, 10 + itemHeight * 2)); - unawaited( - itemScrollController.scrollTo(index: 490, duration: scrollDuration)); + unawaited(itemScrollController.scrollTo(index: 490, duration: scrollDuration)); await tester.pumpAndSettle(); - await tester.drag( - find.byType(ScrollablePositionedList), const Offset(0, -100)); + await tester.drag(find.byType(ScrollablePositionedList), const Offset(0, -100)); await tester.pumpAndSettle(); - expect(tester.getTopLeft(find.text('Item 499')), - const Offset(10, screenHeight - itemHeight - 10)); + expect(tester.getTopLeft(find.text('Item 499')), const Offset(10, screenHeight - itemHeight - 10)); }); - testWidgets('padding test - centered sliver not at bottom', - (WidgetTester tester) async { + testWidgets('padding test - centered sliver not at bottom', (WidgetTester tester) async { final itemScrollController = ItemScrollController(); await setUpWidgetTest( tester, @@ -234,14 +197,11 @@ void main() { padding: const EdgeInsets.all(10), ); - await tester.drag( - find.byType(ScrollablePositionedList), const Offset(0, 200)); + await tester.drag(find.byType(ScrollablePositionedList), const Offset(0, 200)); await tester.pumpAndSettle(); expect(tester.getTopLeft(find.text('Item 0')), const Offset(10, 10)); - expect(tester.getTopLeft(find.text('Item 2')), - const Offset(10, 10 + itemHeight * 2)); - expect(tester.getTopLeft(find.text('Item 3')), - const Offset(10, 10 + itemHeight * 3)); + expect(tester.getTopLeft(find.text('Item 2')), const Offset(10, 10 + itemHeight * 2)); + expect(tester.getTopLeft(find.text('Item 3')), const Offset(10, 10 + itemHeight * 3)); }); } diff --git a/packages/stream_chat_flutter/test/src/attachment/attachment_handler_test.dart b/packages/stream_chat_flutter/test/src/attachment/attachment_handler_test.dart index 6765434452..b7f449e271 100644 --- a/packages/stream_chat_flutter/test/src/attachment/attachment_handler_test.dart +++ b/packages/stream_chat_flutter/test/src/attachment/attachment_handler_test.dart @@ -17,8 +17,7 @@ void main() { final attachmentHandler = MockAttachmentHandler(); - when(() => attachmentHandler.downloadAttachment(attachment)) - .thenAnswer((invocation) async => 'filePath'); + when(() => attachmentHandler.downloadAttachment(attachment)).thenAnswer((invocation) async => 'filePath'); expect( await attachmentHandler.downloadAttachment(attachment), @@ -31,15 +30,13 @@ void main() { title: 'test giphy attachment', type: 'giphy', extraData: const { - 'original': - 'https://giphy.com/gifs/nrkp3-dance-happy-3o7TKnCdBx5cMg0qti', + 'original': 'https://giphy.com/gifs/nrkp3-dance-happy-3o7TKnCdBx5cMg0qti', }, ); final attachmentHandler = MockAttachmentHandler(); - when(() => attachmentHandler.downloadAttachment(attachment)) - .thenAnswer((invocation) async => 'filePath'); + when(() => attachmentHandler.downloadAttachment(attachment)).thenAnswer((invocation) async => 'filePath'); expect( await attachmentHandler.downloadAttachment(attachment), @@ -56,8 +53,7 @@ void main() { final attachmentHandler = MockAttachmentHandler(); - when(() => attachmentHandler.downloadAttachment(attachment)) - .thenAnswer((invocation) async => 'filePath'); + when(() => attachmentHandler.downloadAttachment(attachment)).thenAnswer((invocation) async => 'filePath'); expect( await attachmentHandler.downloadAttachment(attachment), diff --git a/packages/stream_chat_flutter/test/src/attachment/attachment_upload_state_builder_test.dart b/packages/stream_chat_flutter/test/src/attachment/attachment_upload_state_builder_test.dart index f87bcdedf5..104f254be7 100644 --- a/packages/stream_chat_flutter/test/src/attachment/attachment_upload_state_builder_test.dart +++ b/packages/stream_chat_flutter/test/src/attachment/attachment_upload_state_builder_test.dart @@ -5,9 +5,7 @@ import 'package:stream_chat_flutter/stream_chat_flutter.dart'; import '../mocks.dart'; void main() { - testWidgets( - 'AttachmentUploadStateBuilder returns Offstage when message is sent', - (tester) async { + testWidgets('AttachmentUploadStateBuilder returns Offstage when message is sent', (tester) async { await tester.pumpWidget( MaterialApp( home: Scaffold( diff --git a/packages/stream_chat_flutter/test/src/attachment/builder/voice_recording_attachment_playlist_builder.dart b/packages/stream_chat_flutter/test/src/attachment/builder/voice_recording_attachment_playlist_builder.dart index eef7c8279e..28c0ba6c93 100644 --- a/packages/stream_chat_flutter/test/src/attachment/builder/voice_recording_attachment_playlist_builder.dart +++ b/packages/stream_chat_flutter/test/src/attachment/builder/voice_recording_attachment_playlist_builder.dart @@ -48,11 +48,10 @@ void main() { await tester.pumpWidget( _wrapWithStreamChatApp( Builder( - builder: (context) => builder.build( - context, - Message(), - attachments, - ), + builder: (context) { + final attachment = builder.build(context, Message(), attachments); + return attachment ?? const SizedBox.shrink(); + }, ), ), ); @@ -74,13 +73,15 @@ Widget _wrapWithStreamChatApp( return MaterialApp( home: StreamChatTheme( data: StreamChatThemeData(brightness: brightness), - child: Builder(builder: (context) { - final theme = StreamChatTheme.of(context); - return Scaffold( - backgroundColor: theme.colorTheme.appBg, - body: Center(child: widget), - ); - }), + child: Builder( + builder: (context) { + final theme = StreamChatTheme.of(context); + return Scaffold( + backgroundColor: theme.colorTheme.appBg, + body: Center(child: widget), + ); + }, + ), ), ); } diff --git a/packages/stream_chat_flutter/test/src/attachment/file_attachment_test.dart b/packages/stream_chat_flutter/test/src/attachment/file_attachment_test.dart index 578eabe6fa..d3105d2457 100644 --- a/packages/stream_chat_flutter/test/src/attachment/file_attachment_test.dart +++ b/packages/stream_chat_flutter/test/src/attachment/file_attachment_test.dart @@ -25,10 +25,12 @@ void main() { channel: channel, child: SizedBox( child: StreamFileAttachment( - constraints: BoxConstraints.tight(const Size( - 300, - 300, - )), + constraints: BoxConstraints.tight( + const Size( + 300, + 300, + ), + ), message: Message(), file: Attachment( type: 'file', diff --git a/packages/stream_chat_flutter/test/src/attachment/gallery_attachment_test.dart b/packages/stream_chat_flutter/test/src/attachment/gallery_attachment_test.dart index 8b08044a00..cabd566306 100644 --- a/packages/stream_chat_flutter/test/src/attachment/gallery_attachment_test.dart +++ b/packages/stream_chat_flutter/test/src/attachment/gallery_attachment_test.dart @@ -1,4 +1,3 @@ -import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:mocktail/mocktail.dart'; @@ -22,8 +21,7 @@ void main() { Attachment( type: 'image', title: 'example.png', - imageUrl: - 'https://logowik.com/content/uploads/images/flutter5786.jpg', + imageUrl: 'https://logowik.com/content/uploads/images/flutter5786.jpg', extraData: const { 'mime_type': 'png', }, @@ -31,8 +29,7 @@ void main() { Attachment( type: 'image', title: 'example.png', - imageUrl: - 'https://logowik.com/content/uploads/images/flutter5786.jpg', + imageUrl: 'https://logowik.com/content/uploads/images/flutter5786.jpg', extraData: const { 'mime_type': 'png', }, @@ -47,10 +44,12 @@ void main() { channel: channel, child: SizedBox( child: StreamGalleryAttachment( - constraints: BoxConstraints.tight(const Size( - 300, - 300, - )), + constraints: BoxConstraints.tight( + const Size( + 300, + 300, + ), + ), message: Message(), attachments: attachments, itemBuilder: (context, index) { @@ -73,7 +72,7 @@ void main() { // wait for the initial state to be rendered. await tester.pump(Duration.zero); - expect(find.byType(CachedNetworkImage), findsNWidgets(2)); + expect(find.byType(StreamNetworkImage), findsNWidgets(2)); }, ); } diff --git a/packages/stream_chat_flutter/test/src/attachment/giphy_attachment_test.dart b/packages/stream_chat_flutter/test/src/attachment/giphy_attachment_test.dart index 42a604c791..a61b47e080 100644 --- a/packages/stream_chat_flutter/test/src/attachment/giphy_attachment_test.dart +++ b/packages/stream_chat_flutter/test/src/attachment/giphy_attachment_test.dart @@ -25,16 +25,17 @@ void main() { channel: channel, child: SizedBox( child: StreamGiphyAttachment( - constraints: BoxConstraints.tight(const Size( - 300, - 300, - )), + constraints: BoxConstraints.tight( + const Size( + 300, + 300, + ), + ), message: Message(), giphy: Attachment( type: 'giphy', title: 'example.gif', - imageUrl: - 'https://media.giphy.com/media/35H0pwQNaO2iLTnnBf/giphy.gif', + imageUrl: 'https://media.giphy.com/media/35H0pwQNaO2iLTnnBf/giphy.gif', extraData: const { 'mime_type': 'gif', }, diff --git a/packages/stream_chat_flutter/test/src/attachment/goldens/ci/stream_unsupported_attachment_dark.png b/packages/stream_chat_flutter/test/src/attachment/goldens/ci/stream_unsupported_attachment_dark.png new file mode 100644 index 0000000000..54761bdf2b Binary files /dev/null and b/packages/stream_chat_flutter/test/src/attachment/goldens/ci/stream_unsupported_attachment_dark.png differ diff --git a/packages/stream_chat_flutter/test/src/attachment/goldens/ci/stream_unsupported_attachment_light.png b/packages/stream_chat_flutter/test/src/attachment/goldens/ci/stream_unsupported_attachment_light.png new file mode 100644 index 0000000000..0e24f3b00f Binary files /dev/null and b/packages/stream_chat_flutter/test/src/attachment/goldens/ci/stream_unsupported_attachment_light.png differ diff --git a/packages/stream_chat_flutter/test/src/attachment/goldens/ci/stream_voice_recording_attachment_idle_dark.png b/packages/stream_chat_flutter/test/src/attachment/goldens/ci/stream_voice_recording_attachment_idle_dark.png index fd5b200325..bae8e7974a 100644 Binary files a/packages/stream_chat_flutter/test/src/attachment/goldens/ci/stream_voice_recording_attachment_idle_dark.png and b/packages/stream_chat_flutter/test/src/attachment/goldens/ci/stream_voice_recording_attachment_idle_dark.png differ diff --git a/packages/stream_chat_flutter/test/src/attachment/goldens/ci/stream_voice_recording_attachment_idle_light.png b/packages/stream_chat_flutter/test/src/attachment/goldens/ci/stream_voice_recording_attachment_idle_light.png index a73b7e6956..7b5b5bb929 100644 Binary files a/packages/stream_chat_flutter/test/src/attachment/goldens/ci/stream_voice_recording_attachment_idle_light.png and b/packages/stream_chat_flutter/test/src/attachment/goldens/ci/stream_voice_recording_attachment_idle_light.png differ diff --git a/packages/stream_chat_flutter/test/src/attachment/goldens/ci/stream_voice_recording_attachment_playing_dark.png b/packages/stream_chat_flutter/test/src/attachment/goldens/ci/stream_voice_recording_attachment_playing_dark.png index 12ad13a8a9..0248cfec61 100644 Binary files a/packages/stream_chat_flutter/test/src/attachment/goldens/ci/stream_voice_recording_attachment_playing_dark.png and b/packages/stream_chat_flutter/test/src/attachment/goldens/ci/stream_voice_recording_attachment_playing_dark.png differ diff --git a/packages/stream_chat_flutter/test/src/attachment/goldens/ci/stream_voice_recording_attachment_playing_light.png b/packages/stream_chat_flutter/test/src/attachment/goldens/ci/stream_voice_recording_attachment_playing_light.png index 74a2647b67..200113c6bc 100644 Binary files a/packages/stream_chat_flutter/test/src/attachment/goldens/ci/stream_voice_recording_attachment_playing_light.png and b/packages/stream_chat_flutter/test/src/attachment/goldens/ci/stream_voice_recording_attachment_playing_light.png differ diff --git a/packages/stream_chat_flutter/test/src/attachment/goldens/ci/stream_voice_recording_attachment_playlist_dark.png b/packages/stream_chat_flutter/test/src/attachment/goldens/ci/stream_voice_recording_attachment_playlist_dark.png index bd309b5d25..3c931fd8a8 100644 Binary files a/packages/stream_chat_flutter/test/src/attachment/goldens/ci/stream_voice_recording_attachment_playlist_dark.png and b/packages/stream_chat_flutter/test/src/attachment/goldens/ci/stream_voice_recording_attachment_playlist_dark.png differ diff --git a/packages/stream_chat_flutter/test/src/attachment/goldens/ci/stream_voice_recording_attachment_playlist_light.png b/packages/stream_chat_flutter/test/src/attachment/goldens/ci/stream_voice_recording_attachment_playlist_light.png index a0f1b02e81..cf90498b2d 100644 Binary files a/packages/stream_chat_flutter/test/src/attachment/goldens/ci/stream_voice_recording_attachment_playlist_light.png and b/packages/stream_chat_flutter/test/src/attachment/goldens/ci/stream_voice_recording_attachment_playlist_light.png differ diff --git a/packages/stream_chat_flutter/test/src/attachment/image_attachment_test.dart b/packages/stream_chat_flutter/test/src/attachment/image_attachment_test.dart index 1bab6234d1..c288dee6cd 100644 --- a/packages/stream_chat_flutter/test/src/attachment/image_attachment_test.dart +++ b/packages/stream_chat_flutter/test/src/attachment/image_attachment_test.dart @@ -1,4 +1,3 @@ -import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:mocktail/mocktail.dart'; @@ -26,16 +25,17 @@ void main() { channel: channel, child: SizedBox( child: StreamImageAttachment( - constraints: BoxConstraints.tight(const Size( - 300, - 300, - )), + constraints: BoxConstraints.tight( + const Size( + 300, + 300, + ), + ), message: Message(), image: Attachment( type: 'image', title: 'example.png', - imageUrl: - 'https://logowik.com/content/uploads/images/flutter5786.jpg', + imageUrl: 'https://logowik.com/content/uploads/images/flutter5786.jpg', extraData: const { 'mime_type': 'png', }, @@ -50,7 +50,7 @@ void main() { // wait for the initial state to be rendered. await tester.pump(Duration.zero); - expect(find.byType(CachedNetworkImage), findsOneWidget); + expect(find.byType(StreamNetworkImage), findsOneWidget); }, ); } diff --git a/packages/stream_chat_flutter/test/src/attachment/thumbnail/thumbnail_size_calculator_test.dart b/packages/stream_chat_flutter/test/src/attachment/thumbnail/thumbnail_size_calculator_test.dart index 21c47644fa..1e93886026 100644 --- a/packages/stream_chat_flutter/test/src/attachment/thumbnail/thumbnail_size_calculator_test.dart +++ b/packages/stream_chat_flutter/test/src/attachment/thumbnail/thumbnail_size_calculator_test.dart @@ -1,7 +1,6 @@ // ignore_for_file: avoid_redundant_argument_values -import 'dart:ui'; - +import 'package:flutter/painting.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:stream_chat_flutter/src/attachment/thumbnail/thumbnail_size_calculator.dart'; @@ -221,6 +220,9 @@ void main() { originalSize: const Size(1920, 1080), targetSize: const Size(4000, 3000), pixelRatio: 1, + // Default (scaleDown) would keep 1920x1080. Force contain to + // exercise upscale-to-fit behavior. + fit: BoxFit.contain, ); expect(result, isNotNull); @@ -260,6 +262,9 @@ void main() { originalSize: const Size(100, 100), targetSize: const Size(400, 300), pixelRatio: 1, + // Default (scaleDown) would keep 100x100. Force contain to + // exercise upscale-to-fit behavior. + fit: BoxFit.contain, ); expect(result, isNotNull); @@ -268,5 +273,157 @@ void main() { expect(result.height, closeTo(300, 0.01)); }); }); + + group('with fit', () { + // 16:9 wider-than-target image used across most cases: + // originalSize 1920x1080, targetSize 400x300. + + test('null defaults to BoxFit.scaleDown', () { + // Image (100x80) fits inside the box, so scaleDown returns + // original — divergent from contain (which would upscale). + final result = ThumbnailSizeCalculator.calculate( + originalSize: const Size(100, 80), + targetSize: const Size(400, 300), + pixelRatio: 1, + ); + + expect(result, isNotNull); + expect(result!.width, closeTo(100, 0.01)); + expect(result.height, closeTo(80, 0.01)); + }); + + test('BoxFit.cover pins height when image is wider than box', () { + final result = ThumbnailSizeCalculator.calculate( + originalSize: const Size(1920, 1080), + targetSize: const Size(400, 300), + pixelRatio: 1, + fit: BoxFit.cover, + ); + + expect(result, isNotNull); + // 300 * (1920/1080) = 533.33 + expect(result!.width, closeTo(533.33, 0.01)); + expect(result.height, closeTo(300, 0.01)); + }); + + test('BoxFit.cover pins width when image is taller than box', () { + final result = ThumbnailSizeCalculator.calculate( + originalSize: const Size(1080, 1920), + targetSize: const Size(400, 300), + pixelRatio: 1, + fit: BoxFit.cover, + ); + + expect(result, isNotNull); + // 400 / (1080/1920) = 711.11 + expect(result!.width, closeTo(400, 0.01)); + expect(result.height, closeTo(711.11, 0.01)); + }); + + test('BoxFit.fill returns the target size literally', () { + final result = ThumbnailSizeCalculator.calculate( + originalSize: const Size(1920, 1080), + targetSize: const Size(400, 300), + pixelRatio: 1, + fit: BoxFit.fill, + ); + + expect(result, isNotNull); + expect(result!.width, closeTo(400, 0.01)); + expect(result.height, closeTo(300, 0.01)); + }); + + test('BoxFit.fitWidth pins width and derives height from aspect', () { + final result = ThumbnailSizeCalculator.calculate( + originalSize: const Size(1920, 1080), + targetSize: const Size(400, 300), + pixelRatio: 1, + fit: BoxFit.fitWidth, + ); + + expect(result, isNotNull); + expect(result!.width, closeTo(400, 0.01)); + expect(result.height, closeTo(225, 0.01)); + }); + + test('BoxFit.fitHeight pins height and derives width from aspect', () { + final result = ThumbnailSizeCalculator.calculate( + originalSize: const Size(1920, 1080), + targetSize: const Size(400, 300), + pixelRatio: 1, + fit: BoxFit.fitHeight, + ); + + expect(result, isNotNull); + expect(result!.width, closeTo(533.33, 0.01)); + expect(result.height, closeTo(300, 0.01)); + }); + + test('BoxFit.none clamps overflowing original to target', () { + final result = ThumbnailSizeCalculator.calculate( + originalSize: const Size(1920, 1080), + targetSize: const Size(400, 300), + pixelRatio: 1, + fit: BoxFit.none, + ); + + expect(result, isNotNull); + expect(result!.width, closeTo(400, 0.01)); + expect(result.height, closeTo(300, 0.01)); + }); + + test('BoxFit.none keeps original when smaller than target', () { + final result = ThumbnailSizeCalculator.calculate( + originalSize: const Size(100, 80), + targetSize: const Size(400, 300), + pixelRatio: 1, + fit: BoxFit.none, + ); + + expect(result, isNotNull); + expect(result!.width, closeTo(100, 0.01)); + expect(result.height, closeTo(80, 0.01)); + }); + + test('BoxFit.scaleDown matches contain when image overflows', () { + final result = ThumbnailSizeCalculator.calculate( + originalSize: const Size(1920, 1080), + targetSize: const Size(400, 300), + pixelRatio: 1, + fit: BoxFit.scaleDown, + ); + + expect(result, isNotNull); + expect(result!.width, closeTo(400, 0.01)); + expect(result.height, closeTo(225, 0.01)); + }); + + test('BoxFit.scaleDown keeps original when image fits inside', () { + final result = ThumbnailSizeCalculator.calculate( + originalSize: const Size(100, 80), + targetSize: const Size(400, 300), + pixelRatio: 1, + fit: BoxFit.scaleDown, + ); + + expect(result, isNotNull); + expect(result!.width, closeTo(100, 0.01)); + expect(result.height, closeTo(80, 0.01)); + }); + + test('applies pixel ratio after fit calculation', () { + final result = ThumbnailSizeCalculator.calculate( + originalSize: const Size(1920, 1080), + targetSize: const Size(400, 300), + pixelRatio: 2, + fit: BoxFit.cover, + ); + + expect(result, isNotNull); + // Cover at 1x = 533.33x300; at 2x = 1066.66x600. + expect(result!.width, closeTo(1066.66, 0.01)); + expect(result.height, closeTo(600, 0.01)); + }); + }); }); } diff --git a/packages/stream_chat_flutter/test/src/attachment/unsupported_attachment_test.dart b/packages/stream_chat_flutter/test/src/attachment/unsupported_attachment_test.dart new file mode 100644 index 0000000000..80e313a868 --- /dev/null +++ b/packages/stream_chat_flutter/test/src/attachment/unsupported_attachment_test.dart @@ -0,0 +1,39 @@ +import 'package:alchemist/alchemist.dart'; +import 'package:flutter/material.dart'; +import 'package:stream_chat_flutter/stream_chat_flutter.dart'; + +void main() { + for (final brightness in Brightness.values) { + final theme = brightness.name; + goldenTest( + '[$theme] -> should look correct', + fileName: 'stream_unsupported_attachment_$theme', + constraints: const BoxConstraints.tightFor(width: 300, height: 100), + builder: () => _wrapWithStreamChatApp( + brightness: brightness, + StreamUnsupportedAttachment(message: Message()), + ), + ); + } +} + +Widget _wrapWithStreamChatApp( + Widget widget, { + Brightness? brightness, +}) { + return MaterialApp( + theme: ThemeData(brightness: brightness), + home: StreamChatTheme( + data: StreamChatThemeData(brightness: brightness), + child: Builder( + builder: (context) { + final theme = StreamChatTheme.of(context); + return Scaffold( + backgroundColor: theme.colorTheme.appBg, + body: Center(child: widget), + ); + }, + ), + ), + ); +} diff --git a/packages/stream_chat_flutter/test/src/attachment/url_attachment_test.dart b/packages/stream_chat_flutter/test/src/attachment/url_attachment_test.dart index 21c990545f..012688ad52 100644 --- a/packages/stream_chat_flutter/test/src/attachment/url_attachment_test.dart +++ b/packages/stream_chat_flutter/test/src/attachment/url_attachment_test.dart @@ -24,10 +24,8 @@ void main() { child: StreamChannel( channel: channel, child: SizedBox( - child: StreamUrlAttachment( - messageTheme: streamTheme.ownMessageTheme, + child: StreamLinkPreviewAttachment( message: Message(), - hostDisplayName: 'Test', urlAttachment: Attachment( title: 'Flutter', titleLink: 'https://flutter.dev', diff --git a/packages/stream_chat_flutter/test/src/attachment/voice_recording_attachment_playlist_test.dart b/packages/stream_chat_flutter/test/src/attachment/voice_recording_attachment_playlist_test.dart index 0f6786f3bb..f5b2674d07 100644 --- a/packages/stream_chat_flutter/test/src/attachment/voice_recording_attachment_playlist_test.dart +++ b/packages/stream_chat_flutter/test/src/attachment/voice_recording_attachment_playlist_test.dart @@ -1,7 +1,7 @@ import 'package:alchemist/alchemist.dart'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; -import 'package:stream_chat_flutter/src/attachment/voice_recording_attachment.dart'; +import 'package:stream_chat_flutter/src/audio/audio_playlist_state.dart'; import 'package:stream_chat_flutter/stream_chat_flutter.dart'; import '../mocks.dart'; @@ -50,33 +50,6 @@ void main() { }, ); - testWidgets( - 'uses custom shape when provided', - (WidgetTester tester) async { - final customShape = RoundedRectangleBorder( - borderRadius: BorderRadius.circular(20), - ); - - await tester.pumpWidget( - _wrapWithStreamChatApp( - StreamVoiceRecordingAttachmentPlaylist( - message: MockMessage(), - voiceRecordings: [fakeAudioRecording1], - shape: customShape, - ), - ), - ); - - expect(find.byType(StreamVoiceRecordingAttachment), findsOneWidget); - - final attachment = tester.widget( - find.byType(StreamVoiceRecordingAttachment), - ); - - expect(attachment.shape, customShape); - }, - ); - testWidgets( 'updates playlist when recordings change', (WidgetTester tester) async { @@ -143,7 +116,7 @@ void main() { find.byType(StreamVoiceRecordingAttachment), ); - expect(attachment.constraints, constraints); + expect(attachment.props.constraints, constraints); }, ); @@ -175,6 +148,63 @@ void main() { }, ); + testWidgets( + 'does not play audio when track seek changes', + (WidgetTester tester) async { + await tester.pumpWidget( + _wrapWithStreamChatApp( + StreamVoiceRecordingAttachmentPlaylist( + message: MockMessage(), + voiceRecordings: [fakeAudioRecording1], + ), + ), + ); + + final attachment = tester.widget( + find.byType(StreamVoiceRecordingAttachment), + ); + + // onTrackSeekEnd must be null so that play() is never called + // when the user lifts their finger after scrubbing. + expect(attachment.props.onTrackSeekEnd, isNull); + }, + ); + + testWidgets( + 'seek callback does not resume playback', + (WidgetTester tester) async { + await tester.pumpWidget( + _wrapWithStreamChatApp( + StreamVoiceRecordingAttachmentPlaylist( + message: MockMessage(), + voiceRecordings: [fakeAudioRecording1], + ), + ), + ); + + final attachmentFinder = find.byType(StreamVoiceRecordingAttachment); + final attachment = tester.widget( + attachmentFinder, + ); + + // Invoking onTrackSeekChanged should not throw and should not change + // the track to a playing state (track is idle, no AudioPlayer action + // is triggered because there is no active audio source). + expect( + () => attachment.props.onTrackSeekChanged?.call(0.5), + returnsNormally, + ); + + await tester.pump(); + + // The track should still not be in a playing state. + final updatedAttachment = tester.widget( + attachmentFinder, + ); + expect(updatedAttachment.props.track.state, isNot(TrackState.playing)); + }, + ); + testWidgets( 'allows custom item', (WidgetTester tester) async { @@ -254,15 +284,26 @@ Widget _wrapWithStreamChatApp( Brightness? brightness, }) { return MaterialApp( + theme: ThemeData( + brightness: .light, + extensions: [StreamTheme.light()], + ), + darkTheme: ThemeData( + brightness: .dark, + extensions: [StreamTheme.dark()], + ), + themeMode: brightness == Brightness.light ? ThemeMode.light : ThemeMode.dark, home: StreamChatTheme( data: StreamChatThemeData(brightness: brightness), - child: Builder(builder: (context) { - final theme = StreamChatTheme.of(context); - return Scaffold( - backgroundColor: theme.colorTheme.appBg, - body: widget, - ); - }), + child: Builder( + builder: (context) { + final theme = StreamChatTheme.of(context); + return Scaffold( + backgroundColor: theme.colorTheme.appBg, + body: widget, + ); + }, + ), ), ); } diff --git a/packages/stream_chat_flutter/test/src/attachment/voice_recording_attachment_test.dart b/packages/stream_chat_flutter/test/src/attachment/voice_recording_attachment_test.dart index ef95e4b220..e34bdec0da 100644 --- a/packages/stream_chat_flutter/test/src/attachment/voice_recording_attachment_test.dart +++ b/packages/stream_chat_flutter/test/src/attachment/voice_recording_attachment_test.dart @@ -2,13 +2,10 @@ import 'package:alchemist/alchemist.dart'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:mocktail/mocktail.dart'; -import 'package:stream_chat_flutter/src/attachment/voice_recording_attachment.dart'; import 'package:stream_chat_flutter/src/audio/audio_playlist_state.dart'; -import 'package:stream_chat_flutter/src/misc/audio_waveform.dart'; import 'package:stream_chat_flutter/stream_chat_flutter.dart'; import '../mocks.dart'; -import '../utils/finders.dart'; void main() { group( @@ -28,7 +25,7 @@ void main() { _wrapWithStreamChatApp( StreamVoiceRecordingAttachment( track: fakePlaylistTrack, - speed: PlaybackSpeed.regular, + speed: StreamPlaybackSpeed.x1, ), ), ); @@ -36,8 +33,6 @@ void main() { // Verify key components are present expect(find.byType(AudioControlButton), findsOneWidget); expect(find.byType(StreamAudioWaveformSlider), findsOneWidget); - expect( - find.bySvgIcon(StreamSvgIcons.filetypeAudioM4a), findsOneWidget); }, ); @@ -49,7 +44,7 @@ void main() { StreamVoiceRecordingAttachment( showTitle: true, track: fakePlaylistTrack, - speed: PlaybackSpeed.regular, + speed: StreamPlaybackSpeed.x1, ), ), ); @@ -68,7 +63,7 @@ void main() { StreamVoiceRecordingAttachment( showTitle: true, track: fakePlaylistTrack, - speed: PlaybackSpeed.regular, + speed: StreamPlaybackSpeed.x1, ), ), ); @@ -86,13 +81,13 @@ void main() { _wrapWithStreamChatApp( StreamVoiceRecordingAttachment( track: fakePlaylistTrack.copyWith(state: TrackState.playing), - speed: PlaybackSpeed.regular, + speed: StreamPlaybackSpeed.x1, ), ), ); - expect(find.text('x1.0'), findsOneWidget); - expect(find.byType(SpeedControlButton), findsOneWidget); + expect(find.text('x1'), findsOneWidget); + expect(find.byType(StreamPlaybackSpeedToggle), findsOneWidget); }, ); @@ -106,7 +101,7 @@ void main() { _wrapWithStreamChatApp( StreamVoiceRecordingAttachment( track: fakePlaylistTrack.copyWith(state: state), - speed: PlaybackSpeed.regular, + speed: StreamPlaybackSpeed.x1, onTrackPlay: onTrackPlay, ), ), @@ -130,7 +125,7 @@ void main() { _wrapWithStreamChatApp( StreamVoiceRecordingAttachment( track: fakePlaylistTrack.copyWith(state: TrackState.playing), - speed: PlaybackSpeed.regular, + speed: StreamPlaybackSpeed.x1, onTrackPause: onTrackPause, ), ), @@ -155,7 +150,7 @@ void main() { _wrapWithStreamChatApp( StreamVoiceRecordingAttachment( track: fakePlaylistTrack.copyWith(state: TrackState.playing), - speed: PlaybackSpeed.regular, + speed: StreamPlaybackSpeed.x1, onTrackSeekStart: onTrackSeekStart, onTrackSeekChanged: onTrackSeekChanged, onTrackSeekEnd: onTrackSeekEnd, @@ -188,8 +183,8 @@ void main() { testWidgets( 'handles speed change callback', (WidgetTester tester) async { - for (final speed in PlaybackSpeed.values) { - final onChangeSpeed = MockValueChanged(); + for (final speed in StreamPlaybackSpeed.values) { + final onChangeSpeed = MockValueChanged(); await tester.pumpWidget( _wrapWithStreamChatApp( @@ -201,40 +196,12 @@ void main() { ), ); - await tester.tap(find.byType(SpeedControlButton)); + await tester.tap(find.byType(StreamPlaybackSpeedToggle)); verify(() => onChangeSpeed(speed.next)).called(1); } }, ); - testWidgets( - 'custom trailing builder works', - (WidgetTester tester) async { - Widget customTrailingBuilder( - BuildContext context, - PlaylistTrack track, - PlaybackSpeed speed, - ValueChanged? onChangeSpeed, - ) { - return const StreamSvgIcon(icon: StreamSvgIcons.closeSmall); - } - - await tester.pumpWidget( - _wrapWithStreamChatApp( - StreamVoiceRecordingAttachment( - track: fakePlaylistTrack, - speed: PlaybackSpeed.regular, - trailingBuilder: customTrailingBuilder, - ), - ), - ); - - // Verify custom trailing widget is rendered - expect(find.bySvgIcon(StreamSvgIcons.closeSmall), findsOneWidget); - expect(find.bySvgIcon(StreamSvgIcons.filetypeAudioM4a), findsNothing); - }, - ); - for (final brightness in Brightness.values) { final theme = brightness.name; goldenTest( @@ -248,7 +215,7 @@ void main() { child: StreamVoiceRecordingAttachment( showTitle: true, track: fakePlaylistTrack, - speed: PlaybackSpeed.regular, + speed: StreamPlaybackSpeed.x1, ), ), ), @@ -268,7 +235,7 @@ void main() { state: TrackState.playing, position: const Duration(seconds: 10), ), - speed: PlaybackSpeed.regular, + speed: StreamPlaybackSpeed.x1, ), ), ), @@ -283,15 +250,18 @@ Widget _wrapWithStreamChatApp( Brightness? brightness, }) { return MaterialApp( + theme: ThemeData(brightness: brightness), home: StreamChatTheme( data: StreamChatThemeData(brightness: brightness), - child: Builder(builder: (context) { - final theme = StreamChatTheme.of(context); - return Scaffold( - backgroundColor: theme.colorTheme.appBg, - body: Center(child: widget), - ); - }), + child: Builder( + builder: (context) { + final theme = StreamChatTheme.of(context); + return Scaffold( + backgroundColor: theme.colorTheme.appBg, + body: Center(child: widget), + ); + }, + ), ), ); } diff --git a/packages/stream_chat_flutter/test/src/attachment_actions_modal/attachment_actions_modal_test.dart b/packages/stream_chat_flutter/test/src/attachment_actions_modal/attachment_actions_modal_test.dart index 08213b0981..511089188c 100644 --- a/packages/stream_chat_flutter/test/src/attachment_actions_modal/attachment_actions_modal_test.dart +++ b/packages/stream_chat_flutter/test/src/attachment_actions_modal/attachment_actions_modal_test.dart @@ -26,8 +26,7 @@ class MockAttachmentDownloader extends Mock { void main() { setUpAll(() { - registerFallbackValue( - MaterialPageRoute(builder: (context) => const SizedBox())); + registerFallbackValue(MaterialPageRoute(builder: (context) => const SizedBox())); registerFallbackValue(Message()); }); @@ -265,8 +264,7 @@ void main() { final clientState = MockClientState(); final mockChannel = MockChannel(); - when(() => mockChannel.updateMessage(any())) - .thenAnswer((_) async => UpdateMessageResponse()); + when(() => mockChannel.updateMessage(any())).thenAnswer((_) async => UpdateMessageResponse()); when(() => client.state).thenReturn(clientState); when(() => clientState.currentUser).thenReturn(OwnUser(id: 'user-id')); @@ -306,11 +304,15 @@ void main() { ), ); await tester.tap(find.text('Delete')); - verify(() => mockChannel.updateMessage(message.copyWith( + verify( + () => mockChannel.updateMessage( + message.copyWith( attachments: [ message.attachments[1], ], - ))).called(1); + ), + ), + ).called(1); }, ); @@ -321,8 +323,7 @@ void main() { final clientState = MockClientState(); final mockChannel = MockChannel(); - when(() => mockChannel.updateMessage(any())) - .thenAnswer((_) async => UpdateMessageResponse()); + when(() => mockChannel.updateMessage(any())).thenAnswer((_) async => UpdateMessageResponse()); when(() => client.state).thenReturn(clientState); when(() => clientState.currentUser).thenReturn(OwnUser(id: 'user-id')); @@ -357,9 +358,13 @@ void main() { ), ); await tester.tap(find.text('Delete')); - verify(() => mockChannel.updateMessage(message.copyWith( + verify( + () => mockChannel.updateMessage( + message.copyWith( attachments: [], - ))).called(1); + ), + ), + ).called(1); }, ); @@ -371,8 +376,7 @@ void main() { final clientState = MockClientState(); final mockChannel = MockChannel(); - when(() => mockChannel.deleteMessage(any())) - .thenAnswer((_) async => EmptyResponse()); + when(() => mockChannel.deleteMessage(any())).thenAnswer((_) async => EmptyResponse()); when(() => client.state).thenReturn(clientState); when(() => clientState.currentUser).thenReturn(OwnUser(id: 'user-id')); diff --git a/packages/stream_chat_flutter/test/src/audio/audio_playlist_controller_test.dart b/packages/stream_chat_flutter/test/src/audio/audio_playlist_controller_test.dart index 2a5cb09b5c..f94eecadfd 100644 --- a/packages/stream_chat_flutter/test/src/audio/audio_playlist_controller_test.dart +++ b/packages/stream_chat_flutter/test/src/audio/audio_playlist_controller_test.dart @@ -4,6 +4,7 @@ import 'package:mocktail/mocktail.dart'; import 'package:rxdart/rxdart.dart'; import 'package:stream_chat_flutter/src/audio/audio_playlist_controller.dart'; import 'package:stream_chat_flutter/src/audio/audio_playlist_state.dart'; +import 'package:stream_core_flutter/stream_core_flutter.dart'; class MockAudioPlayer extends Mock implements AudioPlayer {} @@ -52,12 +53,9 @@ void main() { speedController = PublishSubject(); // Default mock behaviors - when(() => mockPlayer.playerStateStream) - .thenAnswer((_) => stateController.stream); - when(() => mockPlayer.positionStream) - .thenAnswer((_) => positionController.stream); - when(() => mockPlayer.speedStream) - .thenAnswer((_) => speedController.stream); + when(() => mockPlayer.playerStateStream).thenAnswer((_) => stateController.stream); + when(() => mockPlayer.positionStream).thenAnswer((_) => positionController.stream); + when(() => mockPlayer.speedStream).thenAnswer((_) => speedController.stream); controller = StreamAudioPlaylistController.raw( player: mockPlayer, @@ -75,7 +73,7 @@ void main() { test('controller initializes with correct default state', () { expect(controller.value.tracks.length, equals(2)); expect(controller.value.currentIndex, isNull); - expect(controller.value.speed, equals(PlaybackSpeed.regular)); + expect(controller.value.speed, equals(StreamPlaybackSpeed.x1)); expect(controller.value.loopMode, equals(PlaylistLoopMode.off)); }); @@ -124,7 +122,7 @@ void main() { PlaylistTrack( title: 'new-track.mp3', uri: Uri.parse('https://example.com/new-track.mp3'), - ) + ), ]; await controller.updatePlaylist(newTracks); @@ -166,7 +164,7 @@ void main() { test('setSpeed changes playback rate', () async { when(() => mockPlayer.setSpeed(any())).thenAnswer((_) async {}); - const playbackSpeed = PlaybackSpeed.faster; + const playbackSpeed = StreamPlaybackSpeed.x2; await controller.setSpeed(playbackSpeed); verify(() => mockPlayer.setSpeed(playbackSpeed.speed)).called(1); @@ -216,17 +214,17 @@ void main() { }); test('speedStream updates playback speed', () async { - speedController.add(1.5); + speedController.add(2); await Future.delayed(Duration.zero); - expect(controller.value.speed, equals(PlaybackSpeed.faster)); + expect(controller.value.speed, equals(StreamPlaybackSpeed.x2)); - speedController.add(2); + speedController.add(0.5); await Future.delayed(Duration.zero); - expect(controller.value.speed, equals(PlaybackSpeed.fastest)); + expect(controller.value.speed, equals(StreamPlaybackSpeed.x0_5)); speedController.add(1); await Future.delayed(Duration.zero); - expect(controller.value.speed, equals(PlaybackSpeed.regular)); + expect(controller.value.speed, equals(StreamPlaybackSpeed.x1)); }); test('track completes and auto-advances', () async { diff --git a/packages/stream_chat_flutter/test/src/audio/audio_sampling_test.dart b/packages/stream_chat_flutter/test/src/audio/audio_sampling_test.dart index a1f2918274..e48ab7a14c 100644 --- a/packages/stream_chat_flutter/test/src/audio/audio_sampling_test.dart +++ b/packages/stream_chat_flutter/test/src/audio/audio_sampling_test.dart @@ -76,8 +76,7 @@ void main() { countMap[value] = (countMap[value] ?? 0) + 1; } // Each value should appear either 2 or 3 times - expect( - countMap.values.every((count) => count == 2 || count == 3), isTrue); + expect(countMap.values.every((count) => count == 2 || count == 3), isTrue); }); test('returns original data when target size is smaller', () { diff --git a/packages/stream_chat_flutter/test/src/avatars/goldens/ci/gradient_avatar_0.png b/packages/stream_chat_flutter/test/src/avatars/goldens/ci/gradient_avatar_0.png index 7069fe981d..6e685f7848 100644 Binary files a/packages/stream_chat_flutter/test/src/avatars/goldens/ci/gradient_avatar_0.png and b/packages/stream_chat_flutter/test/src/avatars/goldens/ci/gradient_avatar_0.png differ diff --git a/packages/stream_chat_flutter/test/src/avatars/goldens/ci/gradient_avatar_1.png b/packages/stream_chat_flutter/test/src/avatars/goldens/ci/gradient_avatar_1.png index 5838bbf505..8779ad28ae 100644 Binary files a/packages/stream_chat_flutter/test/src/avatars/goldens/ci/gradient_avatar_1.png and b/packages/stream_chat_flutter/test/src/avatars/goldens/ci/gradient_avatar_1.png differ diff --git a/packages/stream_chat_flutter/test/src/avatars/goldens/ci/gradient_avatar_2.png b/packages/stream_chat_flutter/test/src/avatars/goldens/ci/gradient_avatar_2.png index 7069fe981d..6e685f7848 100644 Binary files a/packages/stream_chat_flutter/test/src/avatars/goldens/ci/gradient_avatar_2.png and b/packages/stream_chat_flutter/test/src/avatars/goldens/ci/gradient_avatar_2.png differ diff --git a/packages/stream_chat_flutter/test/src/avatars/goldens/ci/gradient_avatar_3.png b/packages/stream_chat_flutter/test/src/avatars/goldens/ci/gradient_avatar_3.png index c711450831..88abc34ded 100644 Binary files a/packages/stream_chat_flutter/test/src/avatars/goldens/ci/gradient_avatar_3.png and b/packages/stream_chat_flutter/test/src/avatars/goldens/ci/gradient_avatar_3.png differ diff --git a/packages/stream_chat_flutter/test/src/avatars/goldens/ci/gradient_avatar_issue_2369.png b/packages/stream_chat_flutter/test/src/avatars/goldens/ci/gradient_avatar_issue_2369.png index 110b053c0b..bff58396b2 100644 Binary files a/packages/stream_chat_flutter/test/src/avatars/goldens/ci/gradient_avatar_issue_2369.png and b/packages/stream_chat_flutter/test/src/avatars/goldens/ci/gradient_avatar_issue_2369.png differ diff --git a/packages/stream_chat_flutter/test/src/avatars/goldens/ci/group_avatar_0.png b/packages/stream_chat_flutter/test/src/avatars/goldens/ci/group_avatar_0.png index 7a3fe14616..eaa5cea6b3 100644 Binary files a/packages/stream_chat_flutter/test/src/avatars/goldens/ci/group_avatar_0.png and b/packages/stream_chat_flutter/test/src/avatars/goldens/ci/group_avatar_0.png differ diff --git a/packages/stream_chat_flutter/test/src/avatars/goldens/ci/user_avatar_0.png b/packages/stream_chat_flutter/test/src/avatars/goldens/ci/user_avatar_0.png index a511a95448..48e867a140 100644 Binary files a/packages/stream_chat_flutter/test/src/avatars/goldens/ci/user_avatar_0.png and b/packages/stream_chat_flutter/test/src/avatars/goldens/ci/user_avatar_0.png differ diff --git a/packages/stream_chat_flutter/test/src/avatars/goldens/ci/user_avatar_1.png b/packages/stream_chat_flutter/test/src/avatars/goldens/ci/user_avatar_1.png index 531f4ac3ae..87e44f277c 100644 Binary files a/packages/stream_chat_flutter/test/src/avatars/goldens/ci/user_avatar_1.png and b/packages/stream_chat_flutter/test/src/avatars/goldens/ci/user_avatar_1.png differ diff --git a/packages/stream_chat_flutter/test/src/avatars/gradient_avatar_test.dart b/packages/stream_chat_flutter/test/src/avatars/gradient_avatar_test.dart index e63fe05246..478f834361 100644 --- a/packages/stream_chat_flutter/test/src/avatars/gradient_avatar_test.dart +++ b/packages/stream_chat_flutter/test/src/avatars/gradient_avatar_test.dart @@ -28,8 +28,7 @@ void main() { child: SizedBox( width: 100, height: 100, - child: StreamGradientAvatar( - name: 'demo user', userId: 'demo123'), + child: StreamGradientAvatar(name: 'demo user', userId: 'demo123'), ), ), ), @@ -368,16 +367,18 @@ Widget _wrapWithMaterialApp( return MaterialApp( home: StreamChatTheme( data: StreamChatThemeData(brightness: brightness), - child: Builder(builder: (context) { - final theme = StreamChatTheme.of(context); - return Scaffold( - backgroundColor: theme.colorTheme.appBg, - body: Container( - padding: const EdgeInsets.all(16), - child: Center(child: widget), - ), - ); - }), + child: Builder( + builder: (context) { + final theme = StreamChatTheme.of(context); + return Scaffold( + backgroundColor: theme.colorTheme.appBg, + body: Container( + padding: const EdgeInsets.all(16), + child: Center(child: widget), + ), + ); + }, + ), ), ); } diff --git a/packages/stream_chat_flutter/test/src/avatars/group_avatar_test.dart b/packages/stream_chat_flutter/test/src/avatars/group_avatar_test.dart index 3cc769213d..9614b24e92 100644 --- a/packages/stream_chat_flutter/test/src/avatars/group_avatar_test.dart +++ b/packages/stream_chat_flutter/test/src/avatars/group_avatar_test.dart @@ -16,8 +16,8 @@ void main() { late MockChannel channel; late MockChannelState channelState; - late final member = Member(user: User(id: 'alice', name: 'Alice')); - late final member2 = Member(user: User(id: 'bob', name: 'Bob')); + late final user1 = User(id: 'alice', name: 'Alice'); + late final user2 = User(id: 'bob', name: 'Bob'); setUpAll(() { client = MockClient(); @@ -26,7 +26,10 @@ void main() { when(() => channel.state!).thenReturn(channelState); when(() => channelState.membersStream).thenAnswer( - (_) => Stream>.value([member, member2]), + (_) => Stream>.value([ + Member(user: user1), + Member(user: user2), + ]), ); }); @@ -46,11 +49,8 @@ void main() { channel: channel, child: Scaffold( body: Center( - child: StreamGroupAvatar( - members: [ - member, - member2, - ], + child: StreamUserAvatarGroup( + users: [user1, user2], ), ), ), @@ -82,11 +82,8 @@ void main() { child: SizedBox( width: 100, height: 100, - child: StreamGroupAvatar( - members: [ - member, - member2, - ], + child: StreamUserAvatarGroup( + users: [user1, user2], ), ), ), diff --git a/packages/stream_chat_flutter/test/src/avatars/user_avatar_test.dart b/packages/stream_chat_flutter/test/src/avatars/user_avatar_test.dart index 6a95d3637e..6ec007a992 100644 --- a/packages/stream_chat_flutter/test/src/avatars/user_avatar_test.dart +++ b/packages/stream_chat_flutter/test/src/avatars/user_avatar_test.dart @@ -28,15 +28,17 @@ void main() { home: StreamChat( client: client, streamChatThemeData: StreamChatThemeData.light(), - child: Builder(builder: (context) { - return Scaffold( - body: Center( - child: StreamUserAvatar( - user: user, + child: Builder( + builder: (context) { + return Scaffold( + body: Center( + child: StreamUserAvatar( + user: user, + ), ), - ), - ); - }), + ); + }, + ), ), ), ); diff --git a/packages/stream_chat_flutter/test/src/bottom_sheets/attachment_modal_sheet_test.dart b/packages/stream_chat_flutter/test/src/bottom_sheets/attachment_modal_sheet_test.dart deleted file mode 100644 index 0779c67e87..0000000000 --- a/packages/stream_chat_flutter/test/src/bottom_sheets/attachment_modal_sheet_test.dart +++ /dev/null @@ -1,137 +0,0 @@ -import 'package:alchemist/alchemist.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:stream_chat_flutter/stream_chat_flutter.dart'; - -import '../material_app_wrapper.dart'; - -void main() { - group('AttachmentModalSheet tests', () { - testWidgets('Appears on tap', (tester) async { - await tester.pumpWidget( - MaterialApp( - home: Scaffold( - body: Builder(builder: (context) { - return Center( - child: ElevatedButton( - child: const Text('Show Modal'), - onPressed: () => showModalBottomSheet( - context: context, - builder: (_) => AttachmentModalSheet( - onFileTap: () {}, - onPhotoTap: () {}, - onVideoTap: () {}, - ), - ), - ), - ); - }), - ), - ), - ); - - final button = find.byType(ElevatedButton); - await tester.tap(button); - await tester.pumpAndSettle(); - expect(find.byType(AttachmentModalSheet), findsOneWidget); - expect(find.byType(ListTile), findsNWidgets(4)); - }); - - testWidgets('onPhotoTap works', (tester) async { - var called = 0; - await tester.pumpWidget( - MaterialApp( - home: Scaffold( - body: Builder(builder: (context) { - return Center( - child: AttachmentModalSheet( - onPhotoTap: () => called = 1, - onFileTap: () {}, - onVideoTap: () {}, - ), - ); - }), - ), - ), - ); - - expect(find.byType(AttachmentModalSheet), findsOneWidget); - final photoTile = find.widgetWithIcon(ListTile, Icons.image); - expect(photoTile, findsOneWidget); - await tester.tap(photoTile); - await tester.pumpAndSettle(); - expect(called, 1); - }); - - testWidgets('onVideoTap works', (tester) async { - var called = 0; - await tester.pumpWidget( - MaterialApp( - home: Scaffold( - body: Builder(builder: (context) { - return Center( - child: AttachmentModalSheet( - onPhotoTap: () {}, - onVideoTap: () => called = 1, - onFileTap: () {}, - ), - ); - }), - ), - ), - ); - - expect(find.byType(AttachmentModalSheet), findsOneWidget); - final videoTile = find.widgetWithIcon(ListTile, Icons.video_library); - expect(videoTile, findsOneWidget); - await tester.tap(videoTile); - await tester.pumpAndSettle(); - expect(called, 1); - }); - - testWidgets('onFileTap works', (tester) async { - var called = 0; - await tester.pumpWidget( - MaterialApp( - home: Scaffold( - body: Builder(builder: (context) { - return Center( - child: AttachmentModalSheet( - onPhotoTap: () {}, - onVideoTap: () {}, - onFileTap: () => called = 1, - ), - ); - }), - ), - ), - ); - - expect(find.byType(AttachmentModalSheet), findsOneWidget); - final fileTile = find.widgetWithIcon(ListTile, Icons.insert_drive_file); - expect(fileTile, findsOneWidget); - await tester.tap(fileTile); - await tester.pumpAndSettle(); - expect(called, 1); - }); - - goldenTest( - 'golden test for AttachmentModalSheet', - fileName: 'attachment_modal_sheet_0', - constraints: const BoxConstraints.tightFor(width: 300, height: 300), - builder: () => MaterialAppWrapper( - home: Scaffold( - body: Builder(builder: (context) { - return Center( - child: AttachmentModalSheet( - onPhotoTap: () {}, - onVideoTap: () {}, - onFileTap: () {}, - ), - ); - }), - ), - ), - ); - }); -} diff --git a/packages/stream_chat_flutter/test/src/bottom_sheets/edit_message_sheet_test.dart b/packages/stream_chat_flutter/test/src/bottom_sheets/edit_message_sheet_test.dart deleted file mode 100644 index 3314bba235..0000000000 --- a/packages/stream_chat_flutter/test/src/bottom_sheets/edit_message_sheet_test.dart +++ /dev/null @@ -1,84 +0,0 @@ -import 'package:alchemist/alchemist.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:mocktail/mocktail.dart'; -import 'package:record/record.dart'; -import 'package:stream_chat_flutter/stream_chat_flutter.dart'; - -import '../fakes.dart'; -import '../material_app_wrapper.dart'; -import '../mocks.dart'; - -void main() { - TestWidgetsFlutterBinding.ensureInitialized(); - - final originalRecordPlatform = RecordPlatform.instance; - setUp(() => RecordPlatform.instance = FakeRecordPlatform()); - tearDown(() => RecordPlatform.instance = originalRecordPlatform); - - group('EditMessageSheet tests', () { - testWidgets('appears on tap', (tester) async { - final channel = MockChannel(); - when(channel.getRemainingCooldown).thenReturn(0); - - await tester.pumpWidget( - MaterialApp( - builder: (context, child) => StreamChat( - client: MockClient(), - connectivityStream: Stream.value([ConnectivityResult.wifi]), - child: child, - ), - home: Scaffold( - body: Builder( - builder: (context) { - return Center( - child: ElevatedButton( - child: const Text('Show Modal'), - onPressed: () => showModalBottomSheet( - context: context, - builder: (_) => EditMessageSheet( - channel: channel, - message: Message(id: 'msg123', text: 'Hello World!'), - ), - ), - ), - ); - }, - ), - ), - ), - ); - - final button = find.byType(ElevatedButton); - await tester.tap(button); - await tester.pumpAndSettle(); - expect(find.byType(EditMessageSheet), findsOneWidget); - expect(find.text('Edit Message'), findsOneWidget); - expect(find.byType(StreamMessageInput), findsOneWidget); - }); - - goldenTest( - 'golden test for EditMessageSheet', - fileName: 'edit_message_sheet_0', - constraints: const BoxConstraints.tightFor(width: 300, height: 300), - builder: () { - final channel = MockChannel(); - when(channel.getRemainingCooldown).thenReturn(0); - - return MaterialAppWrapper( - builder: (context, child) => StreamChat( - client: MockClient(), - connectivityStream: Stream.value([ConnectivityResult.wifi]), - child: child, - ), - home: Scaffold( - bottomSheet: EditMessageSheet( - channel: channel, - message: Message(id: 'msg123', text: 'Hello World!'), - ), - ), - ); - }, - ); - }); -} diff --git a/packages/stream_chat_flutter/test/src/bottom_sheets/goldens/ci/attachment_modal_sheet_0.png b/packages/stream_chat_flutter/test/src/bottom_sheets/goldens/ci/attachment_modal_sheet_0.png deleted file mode 100644 index d37466f32f..0000000000 Binary files a/packages/stream_chat_flutter/test/src/bottom_sheets/goldens/ci/attachment_modal_sheet_0.png and /dev/null differ diff --git a/packages/stream_chat_flutter/test/src/bottom_sheets/goldens/ci/edit_message_sheet_0.png b/packages/stream_chat_flutter/test/src/bottom_sheets/goldens/ci/edit_message_sheet_0.png deleted file mode 100644 index 19410a8b8b..0000000000 Binary files a/packages/stream_chat_flutter/test/src/bottom_sheets/goldens/ci/edit_message_sheet_0.png and /dev/null differ diff --git a/packages/stream_chat_flutter/test/src/bottom_sheets/goldens/ci/error_alert_sheet_0.png b/packages/stream_chat_flutter/test/src/bottom_sheets/goldens/ci/error_alert_sheet_0.png deleted file mode 100644 index 30c4d610b7..0000000000 Binary files a/packages/stream_chat_flutter/test/src/bottom_sheets/goldens/ci/error_alert_sheet_0.png and /dev/null differ diff --git a/packages/stream_chat_flutter/test/src/channel/channel_header_test.dart b/packages/stream_chat_flutter/test/src/channel/channel_header_test.dart index 0518fd84fd..5155f2505d 100644 --- a/packages/stream_chat_flutter/test/src/channel/channel_header_test.dart +++ b/packages/stream_chat_flutter/test/src/channel/channel_header_test.dart @@ -1,4 +1,3 @@ -import 'package:alchemist/alchemist.dart'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:mocktail/mocktail.dart'; @@ -28,8 +27,7 @@ void main() { when(() => client.state).thenReturn(clientState); when(() => clientState.currentUser).thenReturn(user); - when(() => clientState.currentUserStream) - .thenAnswer((_) => Stream.value(user)); + when(() => clientState.currentUserStream).thenAnswer((_) => Stream.value(user)); when(() => channel.lastMessageAt).thenReturn(lastMessageAt); when(() => channel.state).thenReturn(channelState); when(() => channel.client).thenReturn(client); @@ -37,23 +35,19 @@ void main() { when(() => channel.isMutedStream).thenAnswer((i) => Stream.value(false)); when(() => channel.nameStream).thenAnswer((_) => Stream.value('test')); when(() => channel.name).thenReturn('test'); - when(() => channel.imageStream) - .thenAnswer((i) => Stream.value('https://bit.ly/321RmWb')); + when(() => channel.imageStream).thenAnswer((i) => Stream.value('https://bit.ly/321RmWb')); when(() => channel.image).thenReturn('https://bit.ly/321RmWb'); when(() => channelState.unreadCount).thenReturn(1); - when(() => client.wsConnectionStatusStream) - .thenAnswer((_) => Stream.value(ConnectionStatus.connected)); - when(() => channelState.unreadCountStream) - .thenAnswer((i) => Stream.value(1)); + when(() => client.wsConnectionStatusStream).thenAnswer((_) => Stream.value(ConnectionStatus.connected)); + when(() => channelState.unreadCountStream).thenAnswer((i) => Stream.value(1)); when(() => clientState.totalUnreadCount).thenAnswer((i) => 1); - when(() => clientState.totalUnreadCountStream) - .thenAnswer((i) => Stream.value(1)); + when(() => clientState.totalUnreadCountStream).thenAnswer((i) => Stream.value(1)); when(() => channelState.membersStream).thenAnswer( (i) => Stream.value([ Member( userId: 'user-id', user: User(id: 'user-id'), - ) + ), ]), ); when(() => channelState.members).thenReturn([ @@ -82,7 +76,6 @@ void main() { expect(find.text('test'), findsOneWidget); expect(find.byType(StreamChannelAvatar), findsOneWidget); - expect(find.byType(StreamBackButton), findsOneWidget); expect(find.byType(StreamChannelInfo), findsOneWidget); }, ); @@ -99,8 +92,7 @@ void main() { when(() => client.state).thenReturn(clientState); when(() => clientState.currentUser).thenReturn(user); - when(() => clientState.currentUserStream) - .thenAnswer((_) => Stream.value(user)); + when(() => clientState.currentUserStream).thenAnswer((_) => Stream.value(user)); when(() => channel.lastMessageAt).thenReturn(lastMessageAt); when(() => channel.state).thenReturn(channelState); when(() => channel.client).thenReturn(client); @@ -108,18 +100,16 @@ void main() { when(() => channel.isMutedStream).thenAnswer((i) => Stream.value(false)); when(() => channel.nameStream).thenAnswer((_) => Stream.value('test')); when(() => channel.name).thenReturn('test'); - when(() => channel.imageStream) - .thenAnswer((i) => Stream.value('https://bit.ly/321RmWb')); + when(() => channel.imageStream).thenAnswer((i) => Stream.value('https://bit.ly/321RmWb')); when(() => channel.image).thenReturn('https://bit.ly/321RmWb'); when(() => channelState.unreadCount).thenReturn(1); - when(() => channelState.unreadCountStream) - .thenAnswer((i) => Stream.value(1)); + when(() => channelState.unreadCountStream).thenAnswer((i) => Stream.value(1)); when(() => channelState.membersStream).thenAnswer( (i) => Stream.value([ Member( userId: 'user-id', user: User(id: 'user-id'), - ) + ), ]), ); when(() => channelState.members).thenReturn([ @@ -128,13 +118,10 @@ void main() { user: User(id: 'user-id'), ), ]); - when(() => client.wsConnectionStatusStream) - .thenAnswer((_) => Stream.value(ConnectionStatus.disconnected)); - when(() => client.wsConnectionStatus) - .thenReturn(ConnectionStatus.disconnected); + when(() => client.wsConnectionStatusStream).thenAnswer((_) => Stream.value(ConnectionStatus.disconnected)); + when(() => client.wsConnectionStatus).thenReturn(ConnectionStatus.disconnected); when(() => clientState.totalUnreadCount).thenAnswer((i) => 1); - when(() => clientState.totalUnreadCountStream) - .thenAnswer((i) => Stream.value(1)); + when(() => clientState.totalUnreadCountStream).thenAnswer((i) => Stream.value(1)); await tester.pumpWidget( MaterialApp( @@ -155,13 +142,8 @@ void main() { // wait for the initial state to be rendered. await tester.pumpAndSettle(); - expect( - tester - .widget(find.byType(StreamInfoTile)) - .showMessage, - true); - expect(tester.widget(find.byType(StreamInfoTile)).message, - 'Disconnected'); + expect(tester.widget(find.byType(StreamInfoTile)).showMessage, true); + expect(tester.widget(find.byType(StreamInfoTile)).message, 'Disconnected'); }, ); @@ -177,8 +159,7 @@ void main() { when(() => client.state).thenReturn(clientState); when(() => clientState.currentUser).thenReturn(user); - when(() => clientState.currentUserStream) - .thenAnswer((_) => Stream.value(user)); + when(() => clientState.currentUserStream).thenAnswer((_) => Stream.value(user)); when(() => channel.lastMessageAt).thenReturn(lastMessageAt); when(() => channel.state).thenReturn(channelState); when(() => channel.client).thenReturn(client); @@ -186,18 +167,16 @@ void main() { when(() => channel.isMutedStream).thenAnswer((i) => Stream.value(false)); when(() => channel.nameStream).thenAnswer((_) => Stream.value('test')); when(() => channel.name).thenReturn('test'); - when(() => channel.imageStream) - .thenAnswer((i) => Stream.value('https://bit.ly/321RmWb')); + when(() => channel.imageStream).thenAnswer((i) => Stream.value('https://bit.ly/321RmWb')); when(() => channel.image).thenReturn('https://bit.ly/321RmWb'); when(() => channelState.unreadCount).thenReturn(1); - when(() => channelState.unreadCountStream) - .thenAnswer((i) => Stream.value(1)); + when(() => channelState.unreadCountStream).thenAnswer((i) => Stream.value(1)); when(() => channelState.membersStream).thenAnswer( (i) => Stream.value([ Member( userId: 'user-id', user: User(id: 'user-id'), - ) + ), ]), ); when(() => channelState.members).thenReturn([ @@ -206,11 +185,9 @@ void main() { user: User(id: 'user-id'), ), ]); - when(() => client.wsConnectionStatusStream) - .thenAnswer((_) => Stream.value(ConnectionStatus.connecting)); + when(() => client.wsConnectionStatusStream).thenAnswer((_) => Stream.value(ConnectionStatus.connecting)); when(() => clientState.totalUnreadCount).thenAnswer((i) => 1); - when(() => clientState.totalUnreadCountStream) - .thenAnswer((i) => Stream.value(1)); + when(() => clientState.totalUnreadCountStream).thenAnswer((i) => Stream.value(1)); await tester.pumpWidget( MaterialApp( @@ -231,13 +208,8 @@ void main() { await tester.pump(); - expect( - tester - .widget(find.byType(StreamInfoTile)) - .showMessage, - true); - expect(tester.widget(find.byType(StreamInfoTile)).message, - 'Reconnecting...'); + expect(tester.widget(find.byType(StreamInfoTile)).showMessage, true); + expect(tester.widget(find.byType(StreamInfoTile)).message, 'Reconnecting...'); }, ); @@ -253,28 +225,28 @@ void main() { when(() => client.state).thenReturn(clientState); when(() => clientState.currentUser).thenReturn(user); - when(() => clientState.currentUserStream) - .thenAnswer((_) => Stream.value(user)); + when(() => clientState.currentUserStream).thenAnswer((_) => Stream.value(user)); when(() => channel.lastMessageAt).thenReturn(lastMessageAt); when(() => channel.state).thenReturn(channelState); when(() => channel.client).thenReturn(client); when(() => channel.isMuted).thenReturn(false); when(() => channel.isMutedStream).thenAnswer((i) => Stream.value(false)); - when(() => channel.extraDataStream).thenAnswer((i) => Stream.value({ - 'name': 'test', - })); + when(() => channel.extraDataStream).thenAnswer( + (i) => Stream.value({ + 'name': 'test', + }), + ); when(() => channel.extraData).thenReturn({ 'name': 'test', }); when(() => channelState.unreadCount).thenReturn(1); - when(() => channelState.unreadCountStream) - .thenAnswer((i) => Stream.value(1)); + when(() => channelState.unreadCountStream).thenAnswer((i) => Stream.value(1)); when(() => channelState.membersStream).thenAnswer( (i) => Stream.value([ Member( userId: 'user-id', user: User(id: 'user-id'), - ) + ), ]), ); when(() => channelState.members).thenReturn([ @@ -283,10 +255,8 @@ void main() { user: User(id: 'user-id'), ), ]); - when(() => client.wsConnectionStatusStream) - .thenAnswer((_) => Stream.value(ConnectionStatus.connecting)); - when(() => clientState.totalUnreadCountStream) - .thenAnswer((i) => Stream.value(1)); + when(() => client.wsConnectionStatusStream).thenAnswer((_) => Stream.value(ConnectionStatus.connecting)); + when(() => clientState.totalUnreadCountStream).thenAnswer((i) => Stream.value(1)); await tester.pumpWidget( MaterialAppWrapper( @@ -298,9 +268,7 @@ void main() { body: StreamChannelHeader( leading: Text('leading'), subtitle: Text('subtitle'), - actions: [ - Text('action'), - ], + trailing: Text('action'), title: Text('title'), ), ), @@ -337,8 +305,7 @@ void main() { when(() => client.state).thenReturn(clientState); when(() => clientState.currentUser).thenReturn(user); - when(() => clientState.currentUserStream) - .thenAnswer((_) => Stream.value(user)); + when(() => clientState.currentUserStream).thenAnswer((_) => Stream.value(user)); when(() => channel.lastMessageAt).thenReturn(lastMessageAt); when(() => channel.state).thenReturn(channelState); when(() => channel.client).thenReturn(client); @@ -346,18 +313,16 @@ void main() { when(() => channel.isMutedStream).thenAnswer((i) => Stream.value(false)); when(() => channel.nameStream).thenAnswer((_) => Stream.value('test')); when(() => channel.name).thenReturn('test'); - when(() => channel.imageStream) - .thenAnswer((i) => Stream.value('https://bit.ly/321RmWb')); + when(() => channel.imageStream).thenAnswer((i) => Stream.value('https://bit.ly/321RmWb')); when(() => channel.image).thenReturn('https://bit.ly/321RmWb'); when(() => channelState.unreadCount).thenReturn(1); - when(() => channelState.unreadCountStream) - .thenAnswer((i) => Stream.value(1)); + when(() => channelState.unreadCountStream).thenAnswer((i) => Stream.value(1)); when(() => channelState.membersStream).thenAnswer( (i) => Stream.value([ Member( userId: 'user-id', user: User(id: 'user-id'), - ) + ), ]), ); when(() => channelState.members).thenReturn([ @@ -366,8 +331,7 @@ void main() { user: User(id: 'user-id'), ), ]); - when(() => client.wsConnectionStatusStream) - .thenAnswer((_) => Stream.value(ConnectionStatus.disconnected)); + when(() => client.wsConnectionStatusStream).thenAnswer((_) => Stream.value(ConnectionStatus.disconnected)); await tester.pumpWidget( MaterialApp( @@ -377,8 +341,8 @@ void main() { channel: channel, child: const Scaffold( body: StreamChannelHeader( - showTypingIndicator: false, - showBackButton: false, + automaticallyImplyLeading: false, + leading: SizedBox(), ), ), ), @@ -390,17 +354,7 @@ void main() { await tester.pumpAndSettle(); expect(find.byType(StreamBackButton), findsNothing); - expect( - tester - .widget(find.byType(StreamChannelInfo)) - .showTypingIndicator, - false, - ); - expect( - tester - .widget(find.byType(StreamInfoTile)) - .showMessage, - false); + expect(tester.widget(find.byType(StreamInfoTile)).showMessage, false); }, ); @@ -416,8 +370,7 @@ void main() { when(() => client.state).thenReturn(clientState); when(() => clientState.currentUser).thenReturn(user); - when(() => clientState.currentUserStream) - .thenAnswer((_) => Stream.value(user)); + when(() => clientState.currentUserStream).thenAnswer((_) => Stream.value(user)); when(() => channel.lastMessageAt).thenReturn(lastMessageAt); when(() => channel.state).thenReturn(channelState); when(() => channel.client).thenReturn(client); @@ -425,18 +378,16 @@ void main() { when(() => channel.isMutedStream).thenAnswer((i) => Stream.value(false)); when(() => channel.nameStream).thenAnswer((_) => Stream.value('test')); when(() => channel.name).thenReturn('test'); - when(() => channel.imageStream) - .thenAnswer((i) => Stream.value('https://bit.ly/321RmWb')); + when(() => channel.imageStream).thenAnswer((i) => Stream.value('https://bit.ly/321RmWb')); when(() => channel.image).thenReturn('https://bit.ly/321RmWb'); when(() => channelState.unreadCount).thenReturn(1); - when(() => channelState.unreadCountStream) - .thenAnswer((i) => Stream.value(1)); + when(() => channelState.unreadCountStream).thenAnswer((i) => Stream.value(1)); when(() => channelState.membersStream).thenAnswer( (i) => Stream.value([ Member( userId: 'user-id', user: User(id: 'user-id'), - ) + ), ]), ); when(() => channelState.members).thenReturn([ @@ -445,11 +396,9 @@ void main() { user: User(id: 'user-id'), ), ]); - when(() => client.wsConnectionStatusStream) - .thenAnswer((_) => Stream.value(ConnectionStatus.connecting)); + when(() => client.wsConnectionStatusStream).thenAnswer((_) => Stream.value(ConnectionStatus.connecting)); when(() => clientState.totalUnreadCount).thenAnswer((i) => 1); - when(() => clientState.totalUnreadCountStream) - .thenAnswer((i) => Stream.value(1)); + when(() => clientState.totalUnreadCountStream).thenAnswer((i) => Stream.value(1)); var backPressed = false; var imageTapped = false; @@ -463,9 +412,15 @@ void main() { channel: channel, child: Scaffold( body: StreamChannelHeader( - onBackPressed: () => backPressed = true, - onImageTap: () => imageTapped = true, - onTitleTap: () => titleTapped = true, + leading: StreamBackButton(onPressed: () => backPressed = true), + trailing: GestureDetector( + onTap: () => imageTapped = true, + child: StreamChannelAvatar(size: .lg, channel: channel), + ), + title: GestureDetector( + onTap: () => titleTapped = true, + child: StreamChannelName(channel: channel), + ), ), ), ), @@ -485,73 +440,4 @@ void main() { expect(titleTapped, true); }, ); - - goldenTest( - 'golden test for StreamChannelHeader with bottom widget', - fileName: 'channel_header_bottom_widget', - constraints: const BoxConstraints.tightFor(width: 300, height: 60), - builder: () { - final client = MockClient(); - final clientState = MockClientState(); - final channel = MockChannel(); - final channelState = MockChannelState(); - final user = OwnUser(id: 'user-id'); - final lastMessageAt = DateTime.parse('2020-06-22 12:00:00'); - - when(() => client.state).thenReturn(clientState); - when(() => clientState.currentUser).thenReturn(user); - when(() => clientState.currentUserStream) - .thenAnswer((_) => Stream.value(user)); - when(() => channel.lastMessageAt).thenReturn(lastMessageAt); - when(() => channel.state).thenReturn(channelState); - when(() => channel.client).thenReturn(client); - when(() => channel.isMuted).thenReturn(false); - when(() => channel.isMutedStream).thenAnswer((i) => Stream.value(false)); - when(() => channel.nameStream).thenAnswer((_) => Stream.value('test')); - when(() => channel.name).thenReturn('test'); - when(() => channel.imageStream) - .thenAnswer((i) => Stream.value('https://bit.ly/321RmWb')); - when(() => channel.image).thenReturn('https://bit.ly/321RmWb'); - when(() => channelState.unreadCount).thenReturn(1); - when(() => client.wsConnectionStatusStream) - .thenAnswer((_) => Stream.value(ConnectionStatus.connected)); - when(() => channelState.unreadCountStream) - .thenAnswer((i) => Stream.value(1)); - when(() => clientState.totalUnreadCount).thenAnswer((i) => 1); - when(() => clientState.totalUnreadCountStream) - .thenAnswer((i) => Stream.value(1)); - when(() => channelState.membersStream).thenAnswer( - (i) => Stream.value([ - Member( - userId: 'user-id', - user: User(id: 'user-id'), - ) - ]), - ); - when(() => channelState.members).thenReturn([ - Member( - userId: 'user-id', - user: User(id: 'user-id'), - ), - ]); - - return MaterialAppWrapper( - home: StreamChat( - client: client, - connectivityStream: Stream.value([ConnectivityResult.wifi]), - child: StreamChannel( - channel: channel, - child: const Scaffold( - body: StreamChannelHeader( - bottom: PreferredSize( - preferredSize: Size.fromHeight(1), - child: Divider(height: 1, color: Colors.red), - ), - ), - ), - ), - ), - ); - }, - ); } diff --git a/packages/stream_chat_flutter/test/src/channel/channel_image_test.dart b/packages/stream_chat_flutter/test/src/channel/channel_image_test.dart index 6bcde77973..c6593b5f9c 100644 --- a/packages/stream_chat_flutter/test/src/channel/channel_image_test.dart +++ b/packages/stream_chat_flutter/test/src/channel/channel_image_test.dart @@ -1,4 +1,3 @@ -import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:mocktail/mocktail.dart'; @@ -21,8 +20,7 @@ void main() { when(() => channel.client).thenReturn(client); when(() => channel.nameStream).thenAnswer((_) => Stream.value('test')); when(() => channel.name).thenReturn('test'); - when(() => channel.imageStream) - .thenAnswer((i) => Stream.value('https://bit.ly/321RmWb')); + when(() => channel.imageStream).thenAnswer((i) => Stream.value('https://bit.ly/321RmWb')); when(() => channel.image).thenReturn('https://bit.ly/321RmWb'); await tester.pumpWidget( @@ -42,9 +40,8 @@ void main() { // wait for the initial state to be rendered. await tester.pumpAndSettle(); - final image = - tester.widget(find.byType(CachedNetworkImage)); - expect(image.imageUrl, 'https://bit.ly/321RmWb'); + final image = tester.widget(find.byType(StreamNetworkImage)); + expect(image.props.url, 'https://bit.ly/321RmWb'); }, ); @@ -62,6 +59,7 @@ void main() { when(() => channel.client).thenReturn(client); when(() => channel.nameStream).thenAnswer((_) => Stream.value('test')); when(() => channel.name).thenReturn('test'); + when(() => channel.isDistinct).thenReturn(true); when(() => channel.imageStream).thenAnswer((i) => Stream.value(null)); when(() => channel.image).thenReturn(null); when(() => channelState.membersStream).thenAnswer( @@ -76,7 +74,7 @@ void main() { id: 'user-id2', image: 'testimage', ), - ) + ), ]), ); when(() => channelState.members).thenReturn([ @@ -90,7 +88,7 @@ void main() { Member( userId: 'user-id', user: User(id: 'user-id'), - ) + ), ]); when(() => clientState.usersStream).thenAnswer( (i) => Stream.value({ @@ -121,9 +119,8 @@ void main() { // wait for the initial state to be rendered. await tester.pumpAndSettle(); - final image = - tester.widget(find.byType(CachedNetworkImage)); - expect(image.imageUrl, 'testimage'); + final image = tester.widget(find.byType(StreamNetworkImage)); + expect(image.props.url, 'testimage'); }, ); @@ -140,6 +137,7 @@ void main() { when(() => clientState.currentUser).thenReturn(currentUser); when(() => channel.state).thenReturn(channelState); when(() => channel.client).thenReturn(client); + when(() => channel.isDistinct).thenReturn(false); when(() => channel.nameStream).thenAnswer((_) => Stream.value('test')); when(() => channel.name).thenReturn('test'); when(() => channel.imageStream).thenAnswer((i) => Stream.value(null)); @@ -167,8 +165,7 @@ void main() { ), ]; when(() => channelState.members).thenReturn(members); - when(() => channelState.membersStream) - .thenAnswer((_) => Stream.value(members)); + when(() => channelState.membersStream).thenAnswer((_) => Stream.value(members)); await tester.pumpWidget( MaterialApp( @@ -187,55 +184,17 @@ void main() { // wait for the initial state to be rendered. await tester.pumpAndSettle(); - final image = - tester.widget(find.byType(StreamGroupAvatar)); - final otherMembers = members.where((it) => it.userId != currentUser.id); - expect( - image.members.map((it) => it.user?.id), - otherMembers.map((it) => it.user?.id), - ); - }, - ); + // The new StreamChannelAvatar uses StreamUserAvatarGroup internally + // for multi-member channels + final avatarGroup = find.byType(StreamUserAvatarGroup); + expect(avatarGroup, findsOneWidget); - testWidgets( - 'using select: true should show a selection border', - (tester) async { - final client = MockClient(); - final clientState = MockClientState(); - final channel = MockChannel(); - final channelState = MockChannelState(); - - when(() => client.state).thenReturn(clientState); - when(() => clientState.currentUser).thenReturn(OwnUser(id: 'user-id')); - when(() => channel.state).thenReturn(channelState); - when(() => channel.client).thenReturn(client); - when(() => channel.nameStream).thenAnswer((_) => Stream.value('test')); - when(() => channel.name).thenReturn('test'); - when(() => channel.imageStream) - .thenAnswer((i) => Stream.value('https://bit.ly/321RmWb')); - when(() => channel.image).thenReturn('https://bit.ly/321RmWb'); - - await tester.pumpWidget( - MaterialApp( - home: StreamChat( - client: client, - child: StreamChannel( - channel: channel, - child: Scaffold( - body: StreamChannelAvatar( - channel: channel, - selected: true, - ), - ), - ), - ), - ), - ); - - // wait for the initial state to be rendered. - await tester.pumpAndSettle(); - - expect(find.byKey(const Key('selectedImage')), findsOneWidget); + // Verify user avatars are shown for all members + expect(find.byType(StreamUserAvatar), findsNWidgets(members.length)); }, ); + + // Note: The 'selected' parameter has been removed in the redesigned + // StreamChannelAvatar component. Selection states should now be handled + // at the parent widget level if needed. } diff --git a/packages/stream_chat_flutter/test/src/channel/channel_list_header_test.dart b/packages/stream_chat_flutter/test/src/channel/channel_list_header_test.dart index 6193d08d97..e23e450846 100644 --- a/packages/stream_chat_flutter/test/src/channel/channel_list_header_test.dart +++ b/packages/stream_chat_flutter/test/src/channel/channel_list_header_test.dart @@ -14,8 +14,7 @@ void main() { when(() => client.state).thenReturn(clientState); when(() => clientState.currentUser).thenReturn(OwnUser(id: 'user-id')); - when(() => client.wsConnectionStatusStream) - .thenAnswer((_) => Stream.value(ConnectionStatus.connected)); + when(() => client.wsConnectionStatusStream).thenAnswer((_) => Stream.value(ConnectionStatus.connected)); await tester.pumpWidget( MaterialApp( @@ -29,10 +28,8 @@ void main() { ); await tester.pumpAndSettle(); - final userAvatar = - tester.widget(find.byType(StreamUserAvatar)); + final userAvatar = tester.widget(find.byType(StreamUserAvatar)); expect(userAvatar.user, clientState.currentUser); - expect(find.byType(StreamNeumorphicButton), findsOneWidget); expect(find.text('Stream Chat'), findsOneWidget); }, ); @@ -45,8 +42,7 @@ void main() { when(() => client.state).thenReturn(clientState); when(() => clientState.currentUser).thenReturn(OwnUser(id: 'user-id')); - when(() => client.wsConnectionStatusStream) - .thenAnswer((_) => Stream.value(ConnectionStatus.disconnected)); + when(() => client.wsConnectionStatusStream).thenAnswer((_) => Stream.value(ConnectionStatus.disconnected)); await tester.pumpWidget( MaterialApp( @@ -74,8 +70,7 @@ void main() { when(() => client.state).thenReturn(clientState); when(() => clientState.currentUser).thenReturn(OwnUser(id: 'user-id')); - when(() => client.wsConnectionStatusStream) - .thenAnswer((_) => Stream.value(ConnectionStatus.connecting)); + when(() => client.wsConnectionStatusStream).thenAnswer((_) => Stream.value(ConnectionStatus.connecting)); await tester.pumpWidget( MaterialApp( @@ -103,8 +98,7 @@ void main() { when(() => client.state).thenReturn(clientState); when(() => clientState.currentUser).thenReturn(OwnUser(id: 'user-id')); - when(() => client.wsConnectionStatusStream) - .thenAnswer((_) => Stream.value(ConnectionStatus.connecting)); + when(() => client.wsConnectionStatusStream).thenAnswer((_) => Stream.value(ConnectionStatus.connecting)); await tester.pumpWidget( MaterialApp( @@ -112,12 +106,9 @@ void main() { client: client, child: Scaffold( body: StreamChannelListHeader( - titleBuilder: (context, status, client) => const Text('TITLE'), + title: const Text('TITLE'), subtitle: const Text('SUBTITLE'), - leading: const Text('LEADING'), - actions: const [ - Text('ACTION'), - ], + trailing: const Text('ACTION'), client: client, ), ), @@ -128,69 +119,31 @@ void main() { expect(find.text('TITLE'), findsOneWidget); expect(find.text('SUBTITLE'), findsOneWidget); - expect(find.text('LEADING'), findsOneWidget); expect(find.text('ACTION'), findsOneWidget); }, ); testWidgets( - 'it should apply prenavigationcallback', + 'trailing slot receives caller-provided widget', (WidgetTester tester) async { final client = MockClient(); final clientState = MockClientState(); when(() => client.state).thenReturn(clientState); when(() => clientState.currentUser).thenReturn(OwnUser(id: 'user-id')); - when(() => client.wsConnectionStatusStream) - .thenAnswer((_) => Stream.value(ConnectionStatus.connecting)); + when(() => client.wsConnectionStatusStream).thenAnswer((_) => Stream.value(ConnectionStatus.connected)); - var tapped = false; - - await tester.pumpWidget( - MaterialApp( - home: StreamChat( - client: client, - child: Scaffold( - body: StreamChannelListHeader( - preNavigationCallback: () { - tapped = true; - }, - ), - ), - ), - ), - ); - await tester.pump(); - - await tester.tap(find.byType(StreamUserAvatar)); - expect(tapped, true); - }, - ); - - testWidgets( - 'it should apply passed callbacks', - (WidgetTester tester) async { - final client = MockClient(); - final clientState = MockClientState(); - - when(() => client.state).thenReturn(clientState); - when(() => clientState.currentUser).thenReturn(OwnUser(id: 'user-id')); - when(() => client.wsConnectionStatusStream) - .thenAnswer((_) => Stream.value(ConnectionStatus.connecting)); - - var tapped = 0; + var trailingTapped = 0; await tester.pumpWidget( MaterialApp( home: StreamChat( client: client, child: Scaffold( body: StreamChannelListHeader( - onUserAvatarTap: (u) { - tapped++; - }, - onNewChatButtonTap: () { - tapped++; - }, + trailing: GestureDetector( + onTap: () => trailingTapped++, + child: const Text('trailing-slot'), + ), ), ), ), @@ -198,9 +151,8 @@ void main() { ); await tester.pump(); - await tester.tap(find.byType(StreamUserAvatar)); - await tester.tap(find.byType(StreamNeumorphicButton)); - expect(tapped, 2); + await tester.tap(find.text('trailing-slot')); + expect(trailingTapped, 1); }, ); } diff --git a/packages/stream_chat_flutter/test/src/channel/channel_name_test.dart b/packages/stream_chat_flutter/test/src/channel/channel_name_test.dart index 22cea8a99b..5bd913308c 100644 --- a/packages/stream_chat_flutter/test/src/channel/channel_name_test.dart +++ b/packages/stream_chat_flutter/test/src/channel/channel_name_test.dart @@ -25,14 +25,13 @@ void main() { when(() => channel.nameStream).thenAnswer((_) => Stream.value('test')); when(() => channel.name).thenReturn('test'); when(() => channelState.unreadCount).thenReturn(1); - when(() => channelState.unreadCountStream) - .thenAnswer((i) => Stream.value(1)); + when(() => channelState.unreadCountStream).thenAnswer((i) => Stream.value(1)); when(() => channelState.membersStream).thenAnswer( (_) => Stream.value([ Member( userId: 'user-id', user: User(id: 'user-id'), - ) + ), ]), ); when(() => channelState.members).thenReturn([ @@ -45,14 +44,14 @@ void main() { Message( text: 'hello', user: User(id: 'other-user'), - ) + ), ]); when(() => channelState.messagesStream).thenAnswer( (i) => Stream.value([ Message( text: 'hello', user: User(id: 'other-user'), - ) + ), ]), ); diff --git a/packages/stream_chat_flutter/test/src/channel/goldens/ci/channel_header_bottom_widget.png b/packages/stream_chat_flutter/test/src/channel/goldens/ci/channel_header_bottom_widget.png deleted file mode 100644 index 756f0f9c68..0000000000 Binary files a/packages/stream_chat_flutter/test/src/channel/goldens/ci/channel_header_bottom_widget.png and /dev/null differ diff --git a/packages/stream_chat_flutter/test/src/channel/stream_message_preview_text_test.dart b/packages/stream_chat_flutter/test/src/channel/stream_message_preview_text_test.dart index 4585e577c2..feac11c473 100644 --- a/packages/stream_chat_flutter/test/src/channel/stream_message_preview_text_test.dart +++ b/packages/stream_chat_flutter/test/src/channel/stream_message_preview_text_test.dart @@ -1,3 +1,5 @@ +import 'dart:typed_data'; + import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:mocktail/mocktail.dart'; @@ -19,7 +21,6 @@ void main() { when(() => clientState.currentUser).thenReturn(currentUser); }); - // Helper to pump the message preview widget Future pumpMessagePreview( WidgetTester tester, Message message, { @@ -57,7 +58,7 @@ void main() { await tester.pump(); } - group('StreamMessagePreviewText', () { + group('Message types', () { testWidgets('renders regular text message', (tester) async { final message = Message( text: 'Hello, world!', @@ -67,9 +68,10 @@ void main() { await pumpMessagePreview(tester, message); expect(find.text('Hello, world!'), findsOneWidget); + expect(_findIcons(tester), isEmpty); }); - testWidgets('renders deleted message', (tester) async { + testWidgets('renders deleted message with ban icon', (tester) async { final message = Message( text: 'Original message', type: MessageType.deleted, @@ -79,10 +81,19 @@ void main() { await pumpMessagePreview(tester, message); - expect(find.text('Message deleted'), findsOneWidget); + final icons = _findIcons(tester); + expect(icons, hasLength(1)); + expect(icons.first.size, 16); + + expect(_extractText(tester), 'Message deleted'); + + final span = _getPreviewSpan(tester); + final styledSpans = _findTextSpans(span); + final deletedSpan = styledSpans.firstWhere((s) => s.text == 'Message deleted'); + expect(deletedSpan.style?.color, isNotNull); }); - testWidgets('renders system message', (tester) async { + testWidgets('renders system message with text', (tester) async { final message = Message( text: 'User joined the channel', type: MessageType.system, @@ -91,9 +102,10 @@ void main() { await pumpMessagePreview(tester, message); expect(find.text('User joined the channel'), findsOneWidget); + expect(_findIcons(tester), isEmpty); }); - testWidgets('renders empty system message', (tester) async { + testWidgets('renders system message without text as fallback label', (tester) async { final message = Message(type: MessageType.system); await pumpMessagePreview(tester, message); @@ -101,7 +113,7 @@ void main() { expect(find.text('System Message'), findsOneWidget); }); - testWidgets('renders empty message with no attachments', (tester) async { + testWidgets('renders empty message with no text or attachments', (tester) async { final message = Message( text: '', user: User(id: 'other-user-id', name: 'Other User'), @@ -109,10 +121,35 @@ void main() { await pumpMessagePreview(tester, message); - expect(find.text(''), findsOneWidget); + expect(find.byType(StreamMessagePreviewText), findsOneWidget); + expect(_findIcons(tester), isEmpty); + }); + + testWidgets('renders null text message as empty', (tester) async { + final message = Message( + user: User(id: 'other-user-id', name: 'Other User'), + ); + + await pumpMessagePreview(tester, message); + + expect(find.byType(StreamMessagePreviewText), findsOneWidget); + }); + + testWidgets('trims whitespace-only text as empty', (tester) async { + final message = Message( + text: ' ', + user: User(id: 'other-user-id', name: 'Other User'), + ); + + await pumpMessagePreview(tester, message); + + expect(find.byType(StreamMessagePreviewText), findsOneWidget); + expect(_findIcons(tester), isEmpty); }); + }); - testWidgets('renders message with mentioned users in bold', (tester) async { + group('Mentions', () { + testWidgets('renders mentioned users as plain text', (tester) async { final mentionedUser = User(id: 'mentioned-id', name: 'Mentioned User'); final message = Message( text: 'Hello @Mentioned User, how are you?', @@ -124,428 +161,713 @@ void main() { expect(find.text('Hello @Mentioned User, how are you?'), findsOneWidget); - // Find the rich text and verify that it contains a valid TextSpan - final textWidget = tester.widget(find.byType(Text).last); - expect(textWidget.textSpan, isNotNull); + final span = _getPreviewSpan(tester); + final textSpans = _findTextSpans(span); + expect( + textSpans.any((s) => s.style?.fontWeight == FontWeight.bold), + isFalse, + ); }); - group('Attachments', () { - testWidgets('renders image attachment', (tester) async { - final message = Message( - text: '', - user: User(id: 'other-user-id', name: 'Other User'), - attachments: [ - Attachment( - type: AttachmentType.image, - ), - ], - ); + testWidgets('renders multiple mentions as plain text', (tester) async { + final user1 = User(id: 'user-1', name: 'Alice'); + final user2 = User(id: 'user-2', name: 'Bob'); + final message = Message( + text: 'Hey @Alice and @Bob!', + user: User(id: 'other-user-id', name: 'Other User'), + mentionedUsers: [user1, user2], + ); - await pumpMessagePreview(tester, message); + await pumpMessagePreview(tester, message, textStyle: const TextStyle()); - expect(find.text('📷 Image'), findsOneWidget); - }); + expect(find.text('Hey @Alice and @Bob!'), findsOneWidget); - testWidgets('renders image attachment with text', (tester) async { - final message = Message( - text: 'Check this out', - user: User(id: 'other-user-id', name: 'Other User'), - attachments: [ - Attachment( - type: AttachmentType.image, - ), - ], - ); + final span = _getPreviewSpan(tester); + final textSpans = _findTextSpans(span); + expect( + textSpans.any((s) => s.style?.fontWeight == FontWeight.bold), + isFalse, + ); + }); - await pumpMessagePreview(tester, message); + testWidgets('renders message without matching mention as plain text', (tester) async { + final mentionedUser = User(id: 'mentioned-id', name: 'NoMatch'); + final message = Message( + text: 'Hello @SomeoneElse', + user: User(id: 'other-user-id', name: 'Other User'), + mentionedUsers: [mentionedUser], + ); - expect(find.text('📷 Check this out'), findsOneWidget); - }); + await pumpMessagePreview(tester, message); - testWidgets('renders video attachment', (tester) async { - final message = Message( - text: '', - user: User(id: 'other-user-id', name: 'Other User'), - attachments: [ - Attachment( - type: AttachmentType.video, - ), - ], - ); + expect(find.text('Hello @SomeoneElse'), findsOneWidget); + }); + }); - await pumpMessagePreview(tester, message); + group('Single attachments', () { + testWidgets('image attachment shows camera icon and "Photo" label', (tester) async { + final message = Message( + text: '', + user: User(id: 'other-user-id', name: 'Other User'), + attachments: [Attachment(type: AttachmentType.image)], + ); - expect(find.text('📹 Video'), findsOneWidget); - }); + await pumpMessagePreview(tester, message); - testWidgets('renders video attachment with text', (tester) async { - final message = Message( - text: 'Check this out', - user: User(id: 'other-user-id', name: 'Other User'), - attachments: [ - Attachment( - type: AttachmentType.video, - ), - ], - ); + final icons = _findIcons(tester); + expect(icons, hasLength(1)); + expect(icons.first.size, 16); + expect(_extractText(tester), 'Photo'); + }); - await pumpMessagePreview(tester, message); + testWidgets('image attachment with caption shows icon and caption', (tester) async { + final message = Message( + text: 'Check this out', + user: User(id: 'other-user-id', name: 'Other User'), + attachments: [Attachment(type: AttachmentType.image)], + ); - expect(find.text('📹 Check this out'), findsOneWidget); - }); + await pumpMessagePreview(tester, message); - testWidgets('renders file attachment', (tester) async { - final message = Message( - text: '', - user: User(id: 'other-user-id', name: 'Other User'), - attachments: [ - Attachment( - type: AttachmentType.file, - title: 'document.pdf', - ), - ], - ); + expect(_findIcons(tester), hasLength(1)); + expect(_extractText(tester), 'Check this out'); + }); - await pumpMessagePreview(tester, message); + testWidgets('video attachment shows video icon and "Video" label', (tester) async { + final message = Message( + text: '', + user: User(id: 'other-user-id', name: 'Other User'), + attachments: [Attachment(type: AttachmentType.video)], + ); - expect(find.text('📄 document.pdf'), findsOneWidget); - }); + await pumpMessagePreview(tester, message); - testWidgets('renders audio attachment', (tester) async { - final message = Message( - text: '', - user: User(id: 'other-user-id', name: 'Other User'), - attachments: [ - Attachment( - type: AttachmentType.audio, - ), - ], - ); + expect(_findIcons(tester), hasLength(1)); + expect(_extractText(tester), 'Video'); + }); - await pumpMessagePreview(tester, message); + testWidgets('video attachment with caption shows icon and caption', (tester) async { + final message = Message( + text: 'Watch this', + user: User(id: 'other-user-id', name: 'Other User'), + attachments: [Attachment(type: AttachmentType.video)], + ); - expect(find.text('🎧 Audio'), findsOneWidget); - }); + await pumpMessagePreview(tester, message); - testWidgets('renders audio attachment with text', (tester) async { - final message = Message( - text: 'Check this out', - user: User(id: 'other-user-id', name: 'Other User'), - attachments: [ - Attachment( - type: AttachmentType.audio, - ), - ], - ); + expect(_findIcons(tester), hasLength(1)); + expect(_extractText(tester), 'Watch this'); + }); - await pumpMessagePreview(tester, message); + testWidgets('file attachment shows file icon and "File" fallback label', (tester) async { + final message = Message( + text: '', + user: User(id: 'other-user-id', name: 'Other User'), + attachments: [Attachment(type: AttachmentType.file)], + ); - expect(find.text('🎧 Check this out'), findsOneWidget); - }); + await pumpMessagePreview(tester, message); - testWidgets('renders giphy attachment with text', (tester) async { - final message = Message( - text: 'Check this out', - user: User(id: 'other-user-id', name: 'Other User'), - attachments: [ - Attachment( - type: AttachmentType.giphy, - ), - ], - ); + expect(_findIcons(tester), hasLength(1)); + expect(_extractText(tester), 'File'); + }); - await pumpMessagePreview(tester, message); + testWidgets('file attachment with file name shows the file name', (tester) async { + final message = Message( + text: '', + user: User(id: 'other-user-id', name: 'Other User'), + attachments: [ + Attachment( + type: AttachmentType.file, + file: AttachmentFile(size: 100, bytes: Uint8List(100), name: 'report.pdf'), + ), + ], + ); - expect(find.text('/giphy Check this out'), findsOneWidget); - }); + await pumpMessagePreview(tester, message); - testWidgets('renders voice recording attachment', (tester) async { - final message = Message( - text: '', - user: User(id: 'other-user-id', name: 'Other User'), - attachments: [ - Attachment( - type: AttachmentType.voiceRecording, - ), - ], - ); + expect(_findIcons(tester), hasLength(1)); + expect(_extractText(tester), 'report.pdf'); + }); - await pumpMessagePreview(tester, message); + testWidgets('audio attachment shows microphone icon and "Audio" label', (tester) async { + final message = Message( + text: '', + user: User(id: 'other-user-id', name: 'Other User'), + attachments: [Attachment(type: AttachmentType.audio)], + ); - expect(find.text('🎤 Voice Recording'), findsOneWidget); - }); + await pumpMessagePreview(tester, message); - testWidgets('renders unknown attachment type with text', (tester) async { - final message = Message( - text: 'Some text', - user: User(id: 'other-user-id', name: 'Other User'), - attachments: [ - Attachment( - type: 'unknown', - ), - ], - ); + expect(_findIcons(tester), hasLength(1)); + expect(_extractText(tester), 'Audio'); + }); + + testWidgets('audio attachment with caption shows icon and caption', (tester) async { + final message = Message( + text: 'New podcast episode', + user: User(id: 'other-user-id', name: 'Other User'), + attachments: [Attachment(type: AttachmentType.audio)], + ); + + await pumpMessagePreview(tester, message); + + expect(_findIcons(tester), hasLength(1)); + expect(_extractText(tester), 'New podcast episode'); + }); + + testWidgets('voice recording shows microphone icon with duration', (tester) async { + final message = Message( + text: '', + user: User(id: 'other-user-id', name: 'Other User'), + attachments: [Attachment(type: AttachmentType.voiceRecording)], + ); + + await pumpMessagePreview(tester, message); + + expect(_findIcons(tester), hasLength(1)); + expect(_extractText(tester), contains('Voice Recording')); + expect(_extractText(tester), contains('00:00')); + }); + + testWidgets('voice recording with duration shows formatted time', (tester) async { + final message = Message( + text: '', + user: User(id: 'other-user-id', name: 'Other User'), + attachments: [ + Attachment( + type: AttachmentType.voiceRecording, + extraData: const {'duration': 125}, + ), + ], + ); + + await pumpMessagePreview(tester, message); - await pumpMessagePreview(tester, message); + expect(_findIcons(tester), hasLength(1)); + expect(_extractText(tester), contains('Voice Recording')); + expect(_extractText(tester), contains('02:05')); + }); + + testWidgets('giphy attachment with caption shows file icon and caption', (tester) async { + final message = Message( + text: 'funny cat', + user: User(id: 'other-user-id', name: 'Other User'), + attachments: [Attachment(type: AttachmentType.giphy)], + ); + + await pumpMessagePreview(tester, message); + + expect(_findIcons(tester), hasLength(1)); + expect(_extractText(tester), 'funny cat'); + }); + + testWidgets('giphy attachment without caption shows file icon and Giphy label', (tester) async { + final message = Message( + text: '', + user: User(id: 'other-user-id', name: 'Other User'), + attachments: [Attachment(type: AttachmentType.giphy)], + ); + + await pumpMessagePreview(tester, message); + + expect(_findIcons(tester), hasLength(1)); + expect(_extractText(tester), 'Giphy'); + }); + + testWidgets('link preview with caption shows link icon and caption', (tester) async { + final message = Message( + text: 'check out this article', + user: User(id: 'other-user-id', name: 'Other User'), + attachments: [ + Attachment( + type: AttachmentType.urlPreview, + title: 'Example article', + titleLink: 'https://example.com', + ), + ], + ); + + await pumpMessagePreview(tester, message); + + expect(_findIcons(tester), hasLength(1)); + expect(_extractText(tester), 'check out this article'); + }); + + testWidgets('link preview without caption falls back to attachment title', (tester) async { + final message = Message( + text: '', + user: User(id: 'other-user-id', name: 'Other User'), + attachments: [ + Attachment( + type: AttachmentType.urlPreview, + title: 'Example article', + titleLink: 'https://example.com', + ), + ], + ); + + await pumpMessagePreview(tester, message); + + expect(_findIcons(tester), hasLength(1)); + expect(_extractText(tester), 'Example article'); + }); + + testWidgets('link preview without caption or title falls back to "Link"', (tester) async { + final message = Message( + text: '', + user: User(id: 'other-user-id', name: 'Other User'), + attachments: [Attachment(type: AttachmentType.urlPreview)], + ); + + await pumpMessagePreview(tester, message); + + expect(_findIcons(tester), hasLength(1)); + expect(_extractText(tester), 'Link'); + }); + + testWidgets('unknown attachment type shows file icon with text', (tester) async { + final message = Message( + text: 'Some text', + user: User(id: 'other-user-id', name: 'Other User'), + attachments: [Attachment(type: 'custom_type')], + ); + + await pumpMessagePreview(tester, message); + + expect(_findIcons(tester), hasLength(1)); + expect(_extractText(tester), 'Some text'); + }); + + testWidgets('unknown attachment type without text shows file icon only', (tester) async { + final message = Message( + text: '', + user: User(id: 'other-user-id', name: 'Other User'), + attachments: [Attachment(type: 'custom_type')], + ); + + await pumpMessagePreview(tester, message); + + expect(_findIcons(tester), hasLength(1)); + expect(_extractText(tester), isEmpty); + }); + }); + + group('Multiple same-type attachments', () { + testWidgets('multiple images show camera icon and photo count', (tester) async { + final message = Message( + text: '', + user: User(id: 'other-user-id', name: 'Other User'), + attachments: [ + Attachment(type: AttachmentType.image), + Attachment(type: AttachmentType.image), + Attachment(type: AttachmentType.image), + ], + ); + + await pumpMessagePreview(tester, message); - expect(find.text('Some text'), findsOneWidget); - }); + expect(_findIcons(tester), hasLength(1)); + expect(_extractText(tester), '3 photos'); }); - group('Poll Tests', () { - testWidgets('renders poll with latest voter (current user)', - (tester) async { - final voterPoll = Poll( + testWidgets('multiple videos show video icon and video count', (tester) async { + final message = Message( + text: '', + user: User(id: 'other-user-id', name: 'Other User'), + attachments: [ + Attachment(type: AttachmentType.video), + Attachment(type: AttachmentType.video), + ], + ); + + await pumpMessagePreview(tester, message); + + expect(_findIcons(tester), hasLength(1)); + expect(_extractText(tester), '2 videos'); + }); + + testWidgets('multiple files show file icon and file count', (tester) async { + final message = Message( + text: '', + user: User(id: 'other-user-id', name: 'Other User'), + attachments: [ + Attachment(type: AttachmentType.file), + Attachment(type: AttachmentType.file), + Attachment(type: AttachmentType.file), + Attachment(type: AttachmentType.file), + ], + ); + + await pumpMessagePreview(tester, message); + + expect(_findIcons(tester), hasLength(1)); + expect(_extractText(tester), '4 files'); + }); + + testWidgets('multiple images with caption show icon and caption text', (tester) async { + final message = Message( + text: 'Vacation photos', + user: User(id: 'other-user-id', name: 'Other User'), + attachments: [ + Attachment(type: AttachmentType.image), + Attachment(type: AttachmentType.image), + ], + ); + + await pumpMessagePreview(tester, message); + + expect(_findIcons(tester), hasLength(1)); + expect(_extractText(tester), 'Vacation photos'); + }); + }); + + group('Mixed-type attachments', () { + testWidgets('mixed types show generic file icon and total count', (tester) async { + final message = Message( + text: '', + user: User(id: 'other-user-id', name: 'Other User'), + attachments: [ + Attachment(type: AttachmentType.image), + Attachment(type: AttachmentType.video), + ], + ); + + await pumpMessagePreview(tester, message); + + expect(_findIcons(tester), hasLength(1)); + expect(_extractText(tester), '2 files'); + }); + + testWidgets('three mixed types show count of all', (tester) async { + final message = Message( + text: '', + user: User(id: 'other-user-id', name: 'Other User'), + attachments: [ + Attachment(type: AttachmentType.image), + Attachment(type: AttachmentType.video), + Attachment(type: AttachmentType.file), + ], + ); + + await pumpMessagePreview(tester, message); + + expect(_findIcons(tester), hasLength(1)); + expect(_extractText(tester), '3 files'); + }); + + testWidgets('mixed types with caption show generic icon and caption', (tester) async { + final message = Message( + text: 'Mixed media', + user: User(id: 'other-user-id', name: 'Other User'), + attachments: [ + Attachment(type: AttachmentType.image), + Attachment(type: AttachmentType.file), + ], + ); + + await pumpMessagePreview(tester, message); + + expect(_findIcons(tester), hasLength(1)); + expect(_extractText(tester), 'Mixed media'); + }); + }); + + group('Polls', () { + testWidgets('poll shows chart icon and poll name', (tester) async { + final message = Message( + user: User(id: 'other-user-id', name: 'Poll Creator'), + poll: Poll( name: 'Favorite Color?', options: const [ PollOption(id: 'option-1', text: 'Red'), PollOption(id: 'option-2', text: 'Blue'), ], - latestVotesByOption: { - 'option-1': [ - PollVote( - user: currentUser, - optionId: 'option-1', - ), - ], - }, - ); - - final message = Message( - user: User(id: 'other-user-id', name: 'Poll Creator'), - poll: voterPoll, - ); + ), + ); - await pumpMessagePreview(tester, message); + await pumpMessagePreview(tester, message); - expect(find.text('📊 You voted: "Favorite Color?"'), findsOneWidget); - }); + expect(_findIcons(tester), hasLength(1)); + expect(_extractText(tester), 'Favorite Color?'); + }); - testWidgets('renders poll with latest voter (another user)', - (tester) async { - final voter = User(id: 'voter-id', name: 'Voter'); - final voterPoll = Poll( - name: 'Favorite Color?', + testWidgets('poll with empty name shows chart icon only', (tester) async { + final message = Message( + user: User(id: 'other-user-id', name: 'Message Sender'), + poll: Poll( + name: ' ', options: const [ PollOption(id: 'option-1', text: 'Red'), PollOption(id: 'option-2', text: 'Blue'), ], - latestVotesByOption: { - 'option-1': [ - PollVote( - user: voter, - optionId: 'option-1', - ), - ], - }, - ); - - final message = Message( - user: User(id: 'other-user-id', name: 'Poll Creator'), - poll: voterPoll, - ); - - await pumpMessagePreview(tester, message); - - expect(find.text('📊 Voter voted: "Favorite Color?"'), findsOneWidget); - }); - - testWidgets('renders poll with creator (current user)', (tester) async { - final message = Message( - user: User(id: 'other-user-id', name: 'Message Sender'), - poll: Poll( - name: 'Favorite Color?', - options: const [ - PollOption(id: 'option-1', text: 'Red'), - PollOption(id: 'option-2', text: 'Blue'), - ], - createdBy: currentUser, - ), - ); - - await pumpMessagePreview(tester, message); - - expect(find.text('📊 You created: "Favorite Color?"'), findsOneWidget); - }); - - testWidgets('renders poll with creator (another user)', (tester) async { - final creator = User(id: 'creator-id', name: 'Alex'); - final message = Message( - user: User(id: 'other-user-id', name: 'Message Sender'), - poll: Poll( - name: 'Favorite Color?', - options: const [ - PollOption(id: 'option-1', text: 'Red'), - PollOption(id: 'option-2', text: 'Blue'), - ], - createdBy: creator, - ), - ); - - await pumpMessagePreview(tester, message); - - expect(find.text('📊 Alex created: "Favorite Color?"'), findsOneWidget); - }); - - testWidgets('renders poll with only name', (tester) async { - final message = Message( - user: User(id: 'other-user-id', name: 'Message Sender'), - poll: Poll( - name: 'Favorite Color?', - options: const [ - PollOption(id: 'option-1', text: 'Red'), - PollOption(id: 'option-2', text: 'Blue'), - ], - ), - ); - - await pumpMessagePreview(tester, message); - - expect(find.text('📊 Favorite Color?'), findsOneWidget); - }); - - testWidgets('renders poll with empty name', (tester) async { - final message = Message( - user: User(id: 'other-user-id', name: 'Message Sender'), - poll: Poll( - name: ' ', - options: const [ - PollOption(id: 'option-1', text: 'Red'), - PollOption(id: 'option-2', text: 'Blue'), - ], - ), - ); + ), + ); - await pumpMessagePreview(tester, message); + await pumpMessagePreview(tester, message); - expect(find.text('📊'), findsOneWidget); - }); + expect(_findIcons(tester), hasLength(1)); + expect(_extractText(tester), isEmpty); }); - testWidgets('supports different language for translation', (tester) async { + testWidgets('poll in group channel includes sender prefix', (tester) async { + final channel = ChannelModel( + id: 'test-channel', + type: 'messaging', + memberCount: 3, + ); + final message = Message( - text: 'Hello, world!', + user: User(id: 'test-user-id', name: 'Test User'), + poll: Poll( + name: 'Lunch spot?', + options: const [ + PollOption(id: 'option-1', text: 'Pizza'), + PollOption(id: 'option-2', text: 'Sushi'), + ], + ), + ); + + await pumpMessagePreview(tester, message, channel: channel); + + expect(_findIcons(tester), hasLength(1)); + + final text = _extractText(tester); + expect(text, contains('You: ')); + expect(text, contains('Lunch spot?')); + }); + }); + + group('Locations', () { + testWidgets('static location shows map pin icon and location label', (tester) async { + final message = Message( + text: '', user: User(id: 'other-user-id', name: 'Other User'), - i18n: const { - 'fr_text': 'Bonjour, monde!', - }, + sharedLocation: Location( + latitude: 37.7749, + longitude: -122.4194, + ), ); - await pumpMessagePreview(tester, message, language: 'fr'); + await pumpMessagePreview(tester, message); - expect(find.text('Bonjour, monde!'), findsOneWidget); + expect(_findIcons(tester), hasLength(1)); + expect(_extractText(tester), contains('Location')); + expect(_extractText(tester), isNot(contains('Live'))); }); - group('Channel-specific behaviors', () { - testWidgets( - 'prepends "You:" for current user\'s messages in group channels', - (tester) async { - final channel = ChannelModel( - id: 'test-channel', - type: 'messaging', - memberCount: 3, - ); + testWidgets('live location shows map pin icon and live location label', (tester) async { + final message = Message( + text: '', + user: User(id: 'other-user-id', name: 'Other User'), + sharedLocation: Location( + latitude: 37.7749, + longitude: -122.4194, + endAt: DateTime.now().add(const Duration(minutes: 15)), + ), + ); + + await pumpMessagePreview(tester, message); - final message = Message( - text: 'Hello everyone', - user: User(id: 'test-user-id', name: 'Test User'), // Current user - ); + expect(_findIcons(tester), hasLength(1)); + expect(_extractText(tester), contains('Live Location')); + }); - await pumpMessagePreview(tester, message, channel: channel); + testWidgets('location with caption shows map pin icon and caption text', (tester) async { + final message = Message( + text: 'Meet me here', + user: User(id: 'other-user-id', name: 'Other User'), + sharedLocation: Location( + latitude: 37.7749, + longitude: -122.4194, + ), + ); - expect(find.text('You: Hello everyone'), findsOneWidget); - }, + await pumpMessagePreview(tester, message); + + expect(_findIcons(tester), hasLength(1)); + expect(_extractText(tester), 'Meet me here'); + }); + }); + + group('Channel context', () { + testWidgets('group channel prepends bold "You:" for current user', (tester) async { + final channel = ChannelModel( + id: 'test-channel', + type: 'messaging', + memberCount: 3, + ); + + final message = Message( + text: 'Hello everyone', + user: User(id: 'test-user-id', name: 'Test User'), ); - testWidgets( - 'prepends author name for other messages in group channels', - (tester) async { - final channel = ChannelModel( - id: 'test-channel', - type: 'messaging', - memberCount: 3, - ); + await pumpMessagePreview(tester, message, channel: channel); - final message = Message( - text: 'Hello everyone', - user: User(id: 'other-user-id', name: 'Jane Doe'), - ); + expect(find.text('You: Hello everyone'), findsOneWidget); - await pumpMessagePreview(tester, message, channel: channel); + final span = _getPreviewSpan(tester); + final youSpan = _findTextSpans(span).firstWhere((s) => s.text == 'You: '); + expect(youSpan.style?.fontWeight, FontWeight.bold); + }); - expect(find.text('Jane Doe: Hello everyone'), findsOneWidget); + testWidgets('group channel prepends bold first name for other users', (tester) async { + final channel = ChannelModel( + id: 'test-channel', + type: 'messaging', + memberCount: 3, + ); + + final message = Message( + text: 'Hello everyone', + user: User(id: 'other-user-id', name: 'Jane Doe'), + ); + + await pumpMessagePreview(tester, message, channel: channel); + + expect(find.text('Jane: Hello everyone'), findsOneWidget); + + final span = _getPreviewSpan(tester); + final nameSpan = _findTextSpans(span).firstWhere((s) => s.text == 'Jane: '); + expect(nameSpan.style?.fontWeight, FontWeight.bold); + }); + + testWidgets('group channel skips prefix when message has no user', (tester) async { + final channel = ChannelModel( + id: 'test-channel', + type: 'messaging', + memberCount: 3, + ); + + final message = Message(text: 'Hello'); + + await pumpMessagePreview(tester, message, channel: channel); + + expect(find.text('Hello'), findsOneWidget); + }); + + testWidgets('1:1 channel does not prepend author name', (tester) async { + final channel = ChannelModel( + id: 'test-channel', + type: 'messaging', + memberCount: 2, + ); + + final message = Message( + text: 'Hello there', + user: User(id: 'other-user-id', name: 'Jane Doe'), + ); + + await pumpMessagePreview(tester, message, channel: channel); + + expect(find.text('Hello there'), findsOneWidget); + }); + + testWidgets('no channel does not prepend author name', (tester) async { + final message = Message( + text: 'Hello there', + user: User(id: 'other-user-id', name: 'Jane Doe'), + ); + + await pumpMessagePreview(tester, message); + + expect(find.text('Hello there'), findsOneWidget); + }); + + testWidgets('group channel with attachment includes sender prefix', (tester) async { + final channel = ChannelModel( + id: 'test-channel', + type: 'messaging', + memberCount: 3, + ); + + final message = Message( + text: '', + user: User(id: 'other-user-id', name: 'Jane Doe'), + attachments: [Attachment(type: AttachmentType.image)], + ); + + await pumpMessagePreview(tester, message, channel: channel); + + final text = _extractText(tester); + expect(text, contains('Jane: ')); + expect(text, contains('Photo')); + }); + }); + + group('Translations', () { + testWidgets('uses explicit language parameter for translation', (tester) async { + final message = Message( + text: 'Hello, world!', + user: User(id: 'other-user-id', name: 'Other User'), + i18n: const { + 'fr_text': 'Bonjour, monde!', }, ); - testWidgets( - 'does not prepend author name in 1:1 channels', - (tester) async { - final channel = ChannelModel( - id: 'test-channel', - type: 'messaging', - memberCount: 2, - ); + await pumpMessagePreview(tester, message, language: 'fr'); + + expect(find.text('Bonjour, monde!'), findsOneWidget); + }); - final message = Message( - text: 'Hello there', - user: User(id: 'other-user-id', name: 'Jane Doe'), - ); + testWidgets('falls back to user language when no explicit language', (tester) async { + final client = MockClient(); + final clientState = MockClientState(); + final currentUser = OwnUser( + id: 'test-user-id', + name: 'Test User', + language: 'es', + ); - await pumpMessagePreview(tester, message, channel: channel); + when(() => client.state).thenReturn(clientState); + when(() => clientState.currentUser).thenReturn(currentUser); - expect(find.text('Hello there'), findsOneWidget); + final message = Message( + text: 'Hello, world!', + user: User(id: 'other-user-id', name: 'Other User'), + i18n: const { + 'es_text': 'Hola, mundo!', }, ); - testWidgets( - 'falls back to user language for translation when available', - (tester) async { - final client = MockClient(); - final clientState = MockClientState(); - final currentUser = OwnUser( - id: 'test-user-id', - name: 'Test User', - language: 'es', // Spanish language - ); - - when(() => client.state).thenReturn(clientState); - when(() => clientState.currentUser).thenReturn(currentUser); - - final message = Message( - text: 'Hello, world!', - user: User(id: 'other-user-id', name: 'Other User'), - i18n: const { - 'es_text': 'Hola, mundo!', - }, - ); - - await tester.pumpWidget( - MaterialApp( - home: Scaffold( - body: StreamChat( - client: client, - streamChatThemeData: StreamChatThemeData.light(), - child: Center( - child: StreamMessagePreviewText( - message: message, - ), - ), - ), + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: StreamChat( + client: client, + streamChatThemeData: StreamChatThemeData.light(), + child: Center( + child: StreamMessagePreviewText(message: message), ), ), - ); - await tester.pump(); + ), + ), + ); + await tester.pump(); + + expect(find.text('Hola, mundo!'), findsOneWidget); + }); - expect(find.text('Hola, mundo!'), findsOneWidget); + testWidgets('falls back to original text when translation missing', (tester) async { + final message = Message( + text: 'Hello, world!', + user: User(id: 'other-user-id', name: 'Other User'), + i18n: const { + 'fr_text': 'Bonjour, monde!', }, ); + + await pumpMessagePreview(tester, message, language: 'de'); + + expect(find.text('Hello, world!'), findsOneWidget); }); }); group('Custom MessagePreviewFormatter', () { const customFormatter = _CustomMessagePreviewFormatter(); - testWidgets('can override formatCurrentUserMessage', (tester) async { + testWidgets('can remove current user prefix via formatCurrentUserMessage', (tester) async { final channel = ChannelModel( id: 'test-channel', type: 'messaging', @@ -554,7 +876,7 @@ void main() { final message = Message( text: 'Hello everyone', - user: User(id: 'test-user-id', name: 'Test User'), // Current user + user: User(id: 'test-user-id', name: 'Test User'), ); await pumpMessagePreview( @@ -566,12 +888,11 @@ void main() { ), ); - // Custom formatter removes "You:" prefix expect(find.text('Hello everyone'), findsOneWidget); expect(find.text('You: Hello everyone'), findsNothing); }); - testWidgets('can override formatGroupMessage', (tester) async { + testWidgets('can customize group message prefix via formatGroupMessage', (tester) async { final channel = ChannelModel( id: 'test-channel', type: 'messaging', @@ -592,11 +913,10 @@ void main() { ), ); - // Custom formatter uses "says:" instead of ":" expect(find.text('John Doe says: Hello'), findsOneWidget); }); - testWidgets('can override formatPollMessage', (tester) async { + testWidgets('can customize poll formatting via formatPollMessage', (tester) async { final message = Message( user: User(id: 'other-user-id', name: 'Message Sender'), poll: Poll( @@ -616,11 +936,32 @@ void main() { ), ); - // Custom formatter uses different format expect(find.text('📊 Poll: Favorite Color?'), findsOneWidget); + expect(_findIcons(tester), isEmpty); }); - testWidgets('can override formatMessageAttachments', (tester) async { + testWidgets('can customize location formatting via formatLocationMessage', (tester) async { + final message = Message( + user: User(id: 'other-user-id', name: 'Message Sender'), + sharedLocation: Location( + latitude: 37.7749, + longitude: -122.4194, + ), + ); + + await pumpMessagePreview( + tester, + message, + configData: StreamChatConfigurationData( + messagePreviewFormatter: customFormatter, + ), + ); + + expect(find.text('🗺️ -> Location Shared'), findsOneWidget); + expect(_findIcons(tester), isEmpty); + }); + + testWidgets('can handle custom attachment types via formatMessageAttachments', (tester) async { final message = Message( text: '', user: User(id: 'user-id'), @@ -640,11 +981,29 @@ void main() { ), ); - // Custom formatter handles custom attachment type expect(find.text('🛍️ iPhone'), findsOneWidget); }); - testWidgets('can override formatDirectMessage', (tester) async { + testWidgets('custom formatter falls through to default for known types', (tester) async { + final message = Message( + text: '', + user: User(id: 'user-id'), + attachments: [Attachment(type: AttachmentType.image)], + ); + + await pumpMessagePreview( + tester, + message, + configData: StreamChatConfigurationData( + messagePreviewFormatter: customFormatter, + ), + ); + + expect(_findIcons(tester), hasLength(1)); + expect(_extractText(tester), 'Photo'); + }); + + testWidgets('can customize direct message via formatMessage override', (tester) async { final channel = ChannelModel( id: 'test-channel', type: 'messaging', @@ -665,66 +1024,186 @@ void main() { ), ); - // Custom formatter adds emoji prefix expect(find.text('💬 Hey there'), findsOneWidget); }); }); } -// Custom formatter for testing overrides +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/// Extracts concatenated text from all [TextSpan]s in the preview, skipping +/// icon placeholders ([WidgetSpan]s). +String _extractText(WidgetTester tester) { + final span = _getPreviewSpan(tester); + return _spanText(span).trim(); +} + +String _spanText(InlineSpan span) { + if (span is! TextSpan) return ''; + final buffer = StringBuffer(span.text ?? ''); + for (final child in span.children ?? []) { + buffer.write(_spanText(child)); + } + return buffer.toString(); +} + +/// Returns the root [TextSpan] rendered by the [StreamMessagePreviewText]. +TextSpan _getPreviewSpan(WidgetTester tester) { + final text = tester.widget( + find.descendant( + of: find.byType(StreamMessagePreviewText), + matching: find.byType(Text), + ), + ); + return text.textSpan! as TextSpan; +} + +/// Recursively collects all leaf [TextSpan]s that have non-null [text]. +/// +/// The returned spans carry their *effective* style — merged top-down with +/// every ancestor [TextSpan.style] — since the formatter applies style at +/// the root and lets children inherit it via Flutter's normal span-style +/// inheritance. +List _findTextSpans(InlineSpan span, [TextStyle? inheritedStyle]) { + final result = []; + if (span is! TextSpan) return result; + + final effectiveStyle = inheritedStyle?.merge(span.style) ?? span.style; + if (span.text != null) { + result.add(TextSpan(text: span.text, style: effectiveStyle)); + } + for (final child in span.children ?? []) { + result.addAll(_findTextSpans(child, effectiveStyle)); + } + return result; +} + +/// Finds all icon [WidgetSpan]s rendered inside the [StreamMessagePreviewText]. +/// +/// Icons are emitted as [WidgetSpan]s wrapping an [Icon] so they can be +/// vertically centered against the surrounding text via +/// [PlaceholderAlignment.middle]. +List<_PreviewIcon> _findIcons(WidgetTester tester) { + final span = _getPreviewSpan(tester); + final result = <_PreviewIcon>[]; + void visit(InlineSpan span) { + if (span is WidgetSpan) { + final child = span.child; + if (child is Icon) { + result.add(_PreviewIcon(icon: child.icon, size: child.size)); + } + return; + } + if (span is TextSpan) { + for (final child in span.children ?? []) { + visit(child); + } + } + } + + visit(span); + return result; +} + +/// Minimal stand-in for the old `Icon` widget in tests. +class _PreviewIcon { + const _PreviewIcon({this.icon, this.size}); + + /// The [IconData] rendered. + final IconData? icon; + + /// Nominal icon render size. + final double? size; +} + +// --------------------------------------------------------------------------- +// Custom formatter for override tests +// --------------------------------------------------------------------------- + class _CustomMessagePreviewFormatter extends StreamMessagePreviewFormatter { const _CustomMessagePreviewFormatter(); @override - String formatCurrentUserMessage(BuildContext context, String messageText) { - // Remove "You:" prefix - return messageText; + TextSpan formatMessage( + BuildContext context, + Message message, { + bool showCaption = true, + ChannelModel? channel, + User? currentUser, + }) { + if (channel != null && channel.memberCount <= 2) { + final text = message.text ?? ''; + return TextSpan(text: '💬 $text'); + } + return super.formatMessage( + context, + message, + showCaption: showCaption, + channel: channel, + currentUser: currentUser, + ); } @override - String formatGroupMessage( + TextSpan formatCurrentUserMessage(BuildContext context, TextSpan messageBody) { + return messageBody; + } + + @override + TextSpan formatGroupMessage( BuildContext context, User? messageAuthor, - String messageText, + TextSpan messageBody, ) { final authorName = messageAuthor?.name; - if (authorName == null || authorName.isEmpty) return messageText; + if (authorName == null || authorName.isEmpty) return messageBody; - // Use "says:" instead of ":" - return '$authorName says: $messageText'; + return TextSpan( + children: [ + TextSpan(text: '$authorName says: '), + messageBody, + ], + ); } @override - String formatPollMessage( - BuildContext context, - Poll poll, - User? currentUser, - ) { - // Simple format with "Poll:" prefix - return poll.name.isEmpty ? '📊 Poll' : '📊 Poll: ${poll.name}'; + TextSpan formatPollMessage(BuildContext context, Poll poll, User? currentUser) { + return TextSpan( + text: poll.name.trim().isEmpty ? '📊 Poll' : '📊 Poll: ${poll.name}', + ); } @override - String formatDirectMessage(BuildContext context, String messageText) { - // Add emoji prefix - return '💬 $messageText'; + TextSpan formatLocationMessage( + BuildContext context, + Message message, + Location location, { + bool showCaption = true, + }) { + return const TextSpan(text: '🗺️ -> Location Shared'); } @override - String? formatMessageAttachments( + TextSpan? formatMessageAttachments( BuildContext context, String? messageText, - Iterable attachments, - ) { + Iterable attachments, { + bool showCaption = true, + }) { final attachment = attachments.firstOrNull; - // Handle custom product attachment type if (attachment?.type == 'product') { final title = attachment?.extraData['title'] as String?; - return '🛍️ ${title ?? "Product"}'; + return TextSpan(text: '🛍️ ${title ?? "Product"}'); } - // Fallback to default implementation - return super.formatMessageAttachments(context, messageText, attachments); + return super.formatMessageAttachments( + context, + messageText, + attachments, + showCaption: showCaption, + ); } } diff --git a/packages/stream_chat_flutter/test/src/context_menu_items/download_menu_item_test.dart b/packages/stream_chat_flutter/test/src/context_menu_items/download_menu_item_test.dart deleted file mode 100644 index 23b3a4c533..0000000000 --- a/packages/stream_chat_flutter/test/src/context_menu_items/download_menu_item_test.dart +++ /dev/null @@ -1,51 +0,0 @@ -import 'package:alchemist/alchemist.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:stream_chat_flutter/src/context_menu_items/download_menu_item.dart'; -import 'package:stream_chat_flutter/stream_chat_flutter.dart'; - -import '../material_app_wrapper.dart'; -import '../mocks.dart'; - -void main() { - group('DownloadMenuItem tests', () { - testWidgets('renders ListTile widget', (tester) async { - await tester.pumpWidget( - MaterialApp( - builder: (context, child) => StreamChat( - client: MockClient(), - child: child, - ), - home: Scaffold( - body: Center( - child: DownloadMenuItem( - attachment: MockAttachment(), - ), - ), - ), - ), - ); - - expect(find.byType(ListTile), findsOneWidget); - }); - - goldenTest( - 'golden test for DownloadMenuItem', - fileName: 'download_menu_item_0', - constraints: const BoxConstraints.tightFor(width: 300, height: 100), - builder: () => MaterialAppWrapper( - builder: (context, child) => StreamChatTheme( - data: StreamChatThemeData.light(), - child: child!, - ), - home: Scaffold( - body: Center( - child: DownloadMenuItem( - attachment: MockAttachment(), - ), - ), - ), - ), - ); - }); -} diff --git a/packages/stream_chat_flutter/test/src/context_menu_items/stream_chat_context_menu_item_test.dart b/packages/stream_chat_flutter/test/src/context_menu_items/stream_chat_context_menu_item_test.dart deleted file mode 100644 index 798e649cfb..0000000000 --- a/packages/stream_chat_flutter/test/src/context_menu_items/stream_chat_context_menu_item_test.dart +++ /dev/null @@ -1,51 +0,0 @@ -import 'package:alchemist/alchemist.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:stream_chat_flutter/src/context_menu_items/stream_chat_context_menu_item.dart'; -import 'package:stream_chat_flutter/stream_chat_flutter.dart'; - -import '../material_app_wrapper.dart'; -import '../mocks.dart'; - -void main() { - group('StreamChatContextMenuItem tests', () { - testWidgets('renders ListTile widget', (tester) async { - await tester.pumpWidget( - MaterialApp( - builder: (context, child) => StreamChat( - client: MockClient(), - child: child, - ), - home: const Scaffold( - body: Center( - child: StreamChatContextMenuItem(), - ), - ), - ), - ); - - expect(find.byType(ListTile), findsOneWidget); - }); - - goldenTest( - 'golden test for StreamChatContextMenuItem', - fileName: 'stream_chat_context_menu_item_0', - constraints: const BoxConstraints.tightFor(width: 300, height: 80), - builder: () => MaterialAppWrapper( - builder: (context, child) => StreamChatTheme( - data: StreamChatThemeData.light(), - child: child!, - ), - home: Scaffold( - body: Center( - child: StreamChatContextMenuItem( - leading: const Icon(Icons.download), - title: const Text('Download'), - onClick: () {}, - ), - ), - ), - ), - ); - }); -} diff --git a/packages/stream_chat_flutter/test/src/dialogs/channel_info_dialog_test.dart b/packages/stream_chat_flutter/test/src/dialogs/channel_info_dialog_test.dart deleted file mode 100644 index 6d411750b2..0000000000 --- a/packages/stream_chat_flutter/test/src/dialogs/channel_info_dialog_test.dart +++ /dev/null @@ -1,81 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:mocktail/mocktail.dart'; -import 'package:stream_chat_flutter/src/dialogs/channel_info_dialog.dart'; -import 'package:stream_chat_flutter/stream_chat_flutter.dart'; - -import '../mocks.dart'; - -void main() { - late MockClient client; - late MockClientState clientState; - late MockOwnUser user; - late MockChannel channel; - late MockChannelState channelState; - - setUpAll(() { - client = MockClient(); - clientState = MockClientState(); - user = MockOwnUser(); - channel = MockChannel(); - channelState = MockChannelState(); - when(() => client.state).thenReturn(clientState); - when(() => clientState.currentUser).thenReturn(user); - when(() => user.id).thenReturn('1'); - when(() => channel.state).thenReturn(channelState); - when(() => channelState.members).thenReturn([ - Member( - user: User( - id: '1', - ), - ), - Member( - user: User( - id: '2', - ), - ), - ]); - when(() => channel.name).thenReturn('test-channel'); - when(() => channel.isDistinct).thenReturn(true); - when(() => channel.memberCount).thenReturn(2); - when(() => channelState.membersStream).thenAnswer( - (_) => Stream.value([ - Member( - user: User( - id: '1', - ), - ), - Member( - user: User( - id: '2', - ), - ), - ]), - ); - }); - - testWidgets('ChannelInfoDialog shows info and members', (tester) async { - await tester.pumpWidget( - MaterialApp( - home: StreamChat( - client: client, - streamChatThemeData: StreamChatThemeData.light(), - child: Scaffold( - body: Center( - child: ChannelInfoDialog( - channel: channel, - ), - ), - ), - ), - ), - ); - - // wait for the initial state to be rendered. - await tester.pump(Duration.zero); - - expect(find.byType(SimpleDialog), findsOneWidget); - expect(find.byType(StreamChannelInfo), findsOneWidget); - expect(find.byType(StreamUserAvatar), findsOneWidget); - }); -} diff --git a/packages/stream_chat_flutter/test/src/dialogs/confirmation_dialog_test.dart b/packages/stream_chat_flutter/test/src/dialogs/confirmation_dialog_test.dart deleted file mode 100644 index 27e74c5892..0000000000 --- a/packages/stream_chat_flutter/test/src/dialogs/confirmation_dialog_test.dart +++ /dev/null @@ -1,72 +0,0 @@ -import 'package:alchemist/alchemist.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:stream_chat_flutter/src/dialogs/confirmation_dialog.dart'; -import 'package:stream_chat_flutter/stream_chat_flutter.dart'; - -import '../material_app_wrapper.dart'; - -void main() { - group('ConfirmationDialog tests', () { - testWidgets('renders with title, prompt, and action', (tester) async { - await tester.pumpWidget( - MaterialApp( - home: Scaffold( - body: Builder( - builder: (context) { - return Center( - child: StreamChatTheme( - data: StreamChatThemeData.light(), - child: ConfirmationDialog( - titleText: context.translations - .toggleMuteUnmuteUserText(isMuted: false), - promptText: context.translations - .toggleMuteUnmuteUserQuestion(isMuted: false), - affirmativeText: context.translations - .toggleMuteUnmuteAction(isMuted: false), - onConfirmation: () {}, - ), - ), - ); - }, - ), - ), - ), - ); - - expect(find.byType(AlertDialog), findsOneWidget); - expect(find.text('Mute User'), findsOneWidget); - expect(find.text('Are you sure you want to mute this user?'), - findsOneWidget); - expect(find.text('MUTE'), findsOneWidget); - }); - - goldenTest( - 'golden test for ConfirmationDialog', - fileName: 'confirmation_dialog_0', - constraints: const BoxConstraints.tightFor(width: 400, height: 300), - builder: () => MaterialAppWrapper( - home: Scaffold( - body: Builder( - builder: (context) { - return Center( - child: StreamChatTheme( - data: StreamChatThemeData.light(), - child: ConfirmationDialog( - titleText: context.translations - .toggleMuteUnmuteUserText(isMuted: false), - promptText: context.translations - .toggleMuteUnmuteUserQuestion(isMuted: false), - affirmativeText: context.translations - .toggleMuteUnmuteAction(isMuted: false), - onConfirmation: () {}, - ), - ), - ); - }, - ), - ), - ), - ); - }); -} diff --git a/packages/stream_chat_flutter/test/src/dialogs/delete_message_dialog_test.dart b/packages/stream_chat_flutter/test/src/dialogs/delete_message_dialog_test.dart deleted file mode 100644 index f1f1547708..0000000000 --- a/packages/stream_chat_flutter/test/src/dialogs/delete_message_dialog_test.dart +++ /dev/null @@ -1,54 +0,0 @@ -import 'package:alchemist/alchemist.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:stream_chat_flutter/src/dialogs/delete_message_dialog.dart'; -import 'package:stream_chat_flutter/stream_chat_flutter.dart'; - -import '../material_app_wrapper.dart'; - -void main() { - group('DeleteMessageDialog tests', () { - testWidgets('renders with correct title and actions', (tester) async { - await tester.pumpWidget( - MaterialApp( - home: Scaffold( - body: Builder( - builder: (context) { - return Center( - child: StreamChatTheme( - data: StreamChatThemeData.light(), - child: const DeleteMessageDialog(), - ), - ); - }, - ), - ), - ), - ); - - expect(find.byType(AlertDialog), findsOneWidget); - expect(find.text('Delete Message'), findsOneWidget); - expect(find.text('DELETE'), findsOneWidget); - }); - - goldenTest( - 'golden test for DeleteMessageDialog', - fileName: 'delete_message_dialog_0', - constraints: const BoxConstraints.tightFor(width: 400, height: 300), - builder: () => MaterialAppWrapper( - home: Scaffold( - body: Builder( - builder: (context) { - return Center( - child: StreamChatTheme( - data: StreamChatThemeData.light(), - child: const DeleteMessageDialog(), - ), - ); - }, - ), - ), - ), - ); - }); -} diff --git a/packages/stream_chat_flutter/test/src/dialogs/goldens/ci/confirmation_dialog_0.png b/packages/stream_chat_flutter/test/src/dialogs/goldens/ci/confirmation_dialog_0.png deleted file mode 100644 index e84c749682..0000000000 Binary files a/packages/stream_chat_flutter/test/src/dialogs/goldens/ci/confirmation_dialog_0.png and /dev/null differ diff --git a/packages/stream_chat_flutter/test/src/dialogs/goldens/ci/delete_message_dialog_0.png b/packages/stream_chat_flutter/test/src/dialogs/goldens/ci/delete_message_dialog_0.png deleted file mode 100644 index aa8b12cb51..0000000000 Binary files a/packages/stream_chat_flutter/test/src/dialogs/goldens/ci/delete_message_dialog_0.png and /dev/null differ diff --git a/packages/stream_chat_flutter/test/src/dialogs/goldens/ci/message_dialog_0.png b/packages/stream_chat_flutter/test/src/dialogs/goldens/ci/message_dialog_0.png deleted file mode 100644 index d8fc7a77cf..0000000000 Binary files a/packages/stream_chat_flutter/test/src/dialogs/goldens/ci/message_dialog_0.png and /dev/null differ diff --git a/packages/stream_chat_flutter/test/src/dialogs/goldens/ci/message_dialog_1.png b/packages/stream_chat_flutter/test/src/dialogs/goldens/ci/message_dialog_1.png deleted file mode 100644 index 2ad3e344c8..0000000000 Binary files a/packages/stream_chat_flutter/test/src/dialogs/goldens/ci/message_dialog_1.png and /dev/null differ diff --git a/packages/stream_chat_flutter/test/src/dialogs/goldens/ci/message_dialog_2.png b/packages/stream_chat_flutter/test/src/dialogs/goldens/ci/message_dialog_2.png deleted file mode 100644 index d8fc7a77cf..0000000000 Binary files a/packages/stream_chat_flutter/test/src/dialogs/goldens/ci/message_dialog_2.png and /dev/null differ diff --git a/packages/stream_chat_flutter/test/src/dialogs/message_dialog_test.dart b/packages/stream_chat_flutter/test/src/dialogs/message_dialog_test.dart deleted file mode 100644 index 5a44836080..0000000000 --- a/packages/stream_chat_flutter/test/src/dialogs/message_dialog_test.dart +++ /dev/null @@ -1,126 +0,0 @@ -import 'package:alchemist/alchemist.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:stream_chat_flutter/src/dialogs/message_dialog.dart'; -import 'package:stream_chat_flutter/stream_chat_flutter.dart'; - -import '../material_app_wrapper.dart'; - -void main() { - group('MessageDialog tests', () { - testWidgets('shows default info', (tester) async { - await tester.pumpWidget( - MaterialApp( - home: Scaffold( - body: Builder( - builder: (context) { - return Center( - child: StreamChatTheme( - data: StreamChatThemeData.light(), - child: const MessageDialog(), - ), - ); - }, - ), - ), - ), - ); - - expect(find.byType(AlertDialog), findsOneWidget); - expect(find.text('Something went wrong'), findsOneWidget); - expect(find.text('OK'), findsOneWidget); - }); - - testWidgets('shows custom info', (tester) async { - await tester.pumpWidget( - MaterialApp( - home: Scaffold( - body: Builder( - builder: (context) { - return Center( - child: StreamChatTheme( - data: StreamChatThemeData.light(), - child: const MessageDialog( - titleText: 'Message', - messageText: 'Message body', - ), - ), - ); - }, - ), - ), - ), - ); - - expect(find.byType(AlertDialog), findsOneWidget); - expect(find.text('Message'), findsOneWidget); - expect(find.text('Message body'), findsOneWidget); - expect(find.text('OK'), findsOneWidget); - }); - - goldenTest( - 'golden test for default MessageDialog', - fileName: 'message_dialog_0', - constraints: const BoxConstraints.tightFor(width: 400, height: 300), - builder: () => MaterialAppWrapper( - home: Scaffold( - body: Builder( - builder: (context) { - return Center( - child: StreamChatTheme( - data: StreamChatThemeData.light(), - child: const MessageDialog(), - ), - ); - }, - ), - ), - ), - ); - - goldenTest( - 'golden test for custom MessageDialog', - fileName: 'message_dialog_1', - constraints: const BoxConstraints.tightFor(width: 400, height: 300), - builder: () => MaterialAppWrapper( - home: Scaffold( - body: Builder( - builder: (context) { - return Center( - child: StreamChatTheme( - data: StreamChatThemeData.light(), - child: const MessageDialog( - titleText: 'Message', - messageText: 'Message body', - ), - ), - ); - }, - ), - ), - ), - ); - - goldenTest( - 'golden test for custom MessageDialog with no body', - fileName: 'message_dialog_2', - constraints: const BoxConstraints.tightFor(width: 400, height: 300), - builder: () => MaterialAppWrapper( - home: Scaffold( - body: Builder( - builder: (context) { - return Center( - child: StreamChatTheme( - data: StreamChatThemeData.light(), - child: const MessageDialog( - titleText: 'Message', - ), - ), - ); - }, - ), - ), - ), - ); - }); -} diff --git a/packages/stream_chat_flutter/test/src/fakes.dart b/packages/stream_chat_flutter/test/src/fakes.dart index 3c54a85aa6..d488a3f365 100644 --- a/packages/stream_chat_flutter/test/src/fakes.dart +++ b/packages/stream_chat_flutter/test/src/fakes.dart @@ -12,9 +12,7 @@ const String kApplicationDocumentsPath = 'applicationDocumentsPath'; const String kExternalCachePath = 'externalCachePath'; const String kExternalStoragePath = 'externalStoragePath'; -class FakePathProviderPlatform extends Fake - with MockPlatformInterfaceMixin - implements PathProviderPlatform { +class FakePathProviderPlatform extends Fake with MockPlatformInterfaceMixin implements PathProviderPlatform { @override Future getTemporaryPath() async { return kTemporaryPath; @@ -58,9 +56,7 @@ class FakePathProviderPlatform extends Fake } } -class AllNullFakePathProviderPlatform extends Fake - with MockPlatformInterfaceMixin - implements PathProviderPlatform { +class AllNullFakePathProviderPlatform extends Fake with MockPlatformInterfaceMixin implements PathProviderPlatform { @override Future getTemporaryPath() async { return null; @@ -104,9 +100,7 @@ class AllNullFakePathProviderPlatform extends Fake } } -class FakeRecordPlatform extends Fake - with MockPlatformInterfaceMixin - implements RecordPlatform { +class FakeRecordPlatform extends Fake with MockPlatformInterfaceMixin implements RecordPlatform { @override Future create(String recorderId) async {} @@ -143,9 +137,7 @@ class FakeRecordPlatform extends Fake Future dispose(String recorderId) async {} } -class FakeConnectivityPlatform extends Fake - with MockPlatformInterfaceMixin - implements ConnectivityPlatform { +class FakeConnectivityPlatform extends Fake with MockPlatformInterfaceMixin implements ConnectivityPlatform { @override Future> checkConnectivity() { return Future.value([ConnectivityResult.wifi]); diff --git a/packages/stream_chat_flutter/test/src/full_screen_media/full_screen_media_test.dart b/packages/stream_chat_flutter/test/src/full_screen_media/full_screen_media_test.dart deleted file mode 100644 index 43cb1bb35a..0000000000 --- a/packages/stream_chat_flutter/test/src/full_screen_media/full_screen_media_test.dart +++ /dev/null @@ -1,108 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:mocktail/mocktail.dart'; -import 'package:photo_view/photo_view.dart'; -import 'package:stream_chat_flutter/stream_chat_flutter.dart'; - -import '../mocks.dart'; - -void main() { - testWidgets( - 'it should show channel typing', - (WidgetTester tester) async { - final client = MockClient(); - final clientState = MockClientState(); - final channel = MockChannel(); - final channelState = MockChannelState(); - final lastMessageAt = DateTime.parse('2020-06-22 12:00:00'); - - when(() => client.state).thenReturn(clientState); - when(() => clientState.currentUser).thenReturn(OwnUser(id: 'user-id')); - when(() => channel.lastMessageAt).thenReturn(lastMessageAt); - when(() => channel.state).thenReturn(channelState); - when(() => channel.client).thenReturn(client); - when(() => channel.isMuted).thenReturn(false); - when(() => channel.isMutedStream).thenAnswer((i) => Stream.value(false)); - when(() => channel.extraDataStream).thenAnswer( - (i) => Stream.value({ - 'name': 'test', - }), - ); - when(() => channel.extraData).thenReturn({ - 'name': 'test', - }); - when(() => channelState.membersStream).thenAnswer( - (i) => Stream.value([ - Member( - userId: 'user-id', - user: User(id: 'user-id'), - ) - ]), - ); - when(() => channelState.members).thenReturn([ - Member( - userId: 'user-id', - user: User(id: 'user-id'), - ), - ]); - when(() => channelState.messages).thenReturn([ - Message( - text: 'hello', - user: User(id: 'other-user'), - ) - ]); - when(() => channelState.messagesStream).thenAnswer( - (i) => Stream.value([ - Message( - text: 'hello', - user: User(id: 'other-user'), - ) - ]), - ); - when(() => channelState.typingEvents).thenAnswer((i) => { - User(id: 'other-user', extraData: const {'name': 'demo'}): - Event(type: EventType.typingStart), - }); - when(() => channelState.typingEventsStream).thenAnswer( - (i) => Stream.value({ - User(id: 'other-user', extraData: const {'name': 'demo'}): - Event(type: EventType.typingStart), - }), - ); - - final attachment = Attachment( - type: 'image', - title: 'demo image', - imageUrl: '', - ); - final message = Message( - createdAt: DateTime.now(), - attachments: [ - attachment, - ], - ); - await tester.pumpWidget(MaterialApp( - home: StreamChat( - client: client, - child: StreamChannel( - channel: channel, - child: StreamFullScreenMedia( - mediaAttachmentPackages: [ - StreamAttachmentPackage( - attachment: attachment, - message: message, - ), - ], - ), - ), - ), - )); - - // wait for the initial state to be rendered. - await tester.pump(Duration.zero); - - expect(find.byType(PhotoView), findsOneWidget); - expect(find.byType(StreamSvgIcon), findsNWidgets(4)); - }, - ); -} diff --git a/packages/stream_chat_flutter/test/src/gallery/gallery_footer_test.dart b/packages/stream_chat_flutter/test/src/gallery/gallery_footer_test.dart deleted file mode 100644 index 7480968063..0000000000 --- a/packages/stream_chat_flutter/test/src/gallery/gallery_footer_test.dart +++ /dev/null @@ -1,118 +0,0 @@ -import 'package:alchemist/alchemist.dart'; // Changed from golden_toolkit -import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:mocktail/mocktail.dart'; -import 'package:stream_chat_flutter/stream_chat_flutter.dart'; - -import '../material_app_wrapper.dart'; -import '../mocks.dart'; - -void main() { - late MockClient client; - late MockClientState clientState; - late MockChannel channel; - late MockChannelState channelState; - const methodChannel = - MethodChannel('dev.fluttercommunity.plus/connectivity_status'); - - setUpAll(() { - client = MockClient(); - clientState = MockClientState(); - channel = MockChannel(); - channelState = MockChannelState(); - final lastMessageAt = DateTime.parse('2020-06-22 12:00:00'); - - when(() => client.state).thenReturn(clientState); - when(() => clientState.currentUser).thenReturn(OwnUser(id: 'user-id')); - when(() => channel.lastMessageAt).thenReturn(lastMessageAt); - when(() => channel.state).thenReturn(channelState); - when(() => channel.client).thenReturn(client); - when(() => channel.isMuted).thenReturn(false); - when(() => channel.isMutedStream).thenAnswer((i) => Stream.value(false)); - when(() => channel.extraDataStream).thenAnswer( - (i) => Stream.value({ - 'name': 'test', - }), - ); - when(() => channel.extraData).thenReturn({ - 'name': 'test', - }); - }); - - setUp(() { - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger - .setMockMethodCallHandler(methodChannel, (MethodCall methodCall) async { - if (methodCall.method == 'listen') { - try { - await TestDefaultBinaryMessengerBinding - .instance.defaultBinaryMessenger - .handlePlatformMessage( - methodChannel.name, - methodChannel.codec.encodeSuccessEnvelope(['wifi']), - (_) {}, - ); - } catch (e) { - print(e); - } - } - return null; - }); - }); - - testWidgets( - 'it should show channel typing', - (WidgetTester tester) async { - await tester.pumpWidget( - MaterialApp( - home: StreamChat( - client: client, - child: StreamChannel( - channel: channel, - child: PopScope( - onPopInvokedWithResult: (bool didPop, res) async => false, - child: const Scaffold( - body: StreamGalleryFooter( - mediaAttachmentPackages: [], - ), - ), - ), - ), - ), - ), - ); - - // wait for the initial state to be rendered. - await tester.pumpAndSettle(); - - expect(find.byType(StreamSvgIcon), findsNWidgets(2)); - }, - ); - - goldenTest( - 'golden test for GalleryFooter', - fileName: 'gallery_footer_0', - constraints: const BoxConstraints.tightFor(width: 400, height: 300), - builder: () => MaterialAppWrapper( - home: StreamChat( - client: client, - child: StreamChannel( - channel: channel, - child: PopScope( - onPopInvokedWithResult: (bool didPop, res) async => false, - child: const Scaffold( - bottomNavigationBar: StreamGalleryFooter( - mediaAttachmentPackages: [], - ), - ), - ), - ), - ), - ), - ); - - tearDown(() { - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger - .setMockMethodCallHandler(methodChannel, null); - }); -} diff --git a/packages/stream_chat_flutter/test/src/gallery/gallery_header_test.dart b/packages/stream_chat_flutter/test/src/gallery/gallery_header_test.dart deleted file mode 100644 index f29f9e596d..0000000000 --- a/packages/stream_chat_flutter/test/src/gallery/gallery_header_test.dart +++ /dev/null @@ -1,124 +0,0 @@ -import 'package:alchemist/alchemist.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:mocktail/mocktail.dart'; -import 'package:stream_chat_flutter/stream_chat_flutter.dart'; - -import '../material_app_wrapper.dart'; -import '../mocks.dart'; - -void main() { - late MockClient client; - late MockClientState clientState; - late MockChannel channel; - late MockChannelState channelState; - const methodChannel = - MethodChannel('dev.fluttercommunity.plus/connectivity_status'); - - setUpAll(() { - client = MockClient(); - clientState = MockClientState(); - channel = MockChannel(); - channelState = MockChannelState(); - final lastMessageAt = DateTime.parse('2020-06-22 12:00:00'); - - when(() => client.state).thenReturn(clientState); - when(() => clientState.currentUser).thenReturn(OwnUser(id: 'user-id')); - when(() => channel.lastMessageAt).thenReturn(lastMessageAt); - when(() => channel.state).thenReturn(channelState); - when(() => channel.client).thenReturn(client); - when(() => channel.isMuted).thenReturn(false); - when(() => channel.isMutedStream).thenAnswer((i) => Stream.value(false)); - when(() => channel.extraDataStream).thenAnswer( - (i) => Stream.value({ - 'name': 'test', - }), - ); - when(() => channel.extraData).thenReturn({ - 'name': 'test', - }); - }); - - setUp(() { - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger - .setMockMethodCallHandler(methodChannel, (MethodCall methodCall) async { - if (methodCall.method == 'listen') { - try { - await TestDefaultBinaryMessengerBinding - .instance.defaultBinaryMessenger - .handlePlatformMessage( - methodChannel.name, - methodChannel.codec.encodeSuccessEnvelope(['wifi']), - (_) {}, - ); - } catch (e) { - print(e); - } - } - return null; - }); - }); - - testWidgets( - 'it should show channel typing', - (WidgetTester tester) async { - await tester.pumpWidget( - MaterialApp( - home: StreamChat( - client: client, - child: StreamChannel( - channel: channel, - child: PopScope( - onPopInvokedWithResult: (bool didPop, res) async => false, - child: Scaffold( - appBar: StreamGalleryHeader( - attachment: MockAttachment(), - message: Message(), - ), - ), - ), - ), - ), - ), - ); - - // wait for the initial state to be rendered. - await tester.pumpAndSettle(); - - expect(find.byType(StreamSvgIcon), findsNWidgets(2)); - }, - ); - - goldenTest( - 'golden test for GalleryHeader', - fileName: 'gallery_header_0', - constraints: const BoxConstraints.tightFor(width: 300, height: 300), - builder: () { - return MaterialAppWrapper( - home: StreamChat( - client: client, - child: StreamChannel( - channel: channel, - child: PopScope( - onPopInvokedWithResult: (bool didPop, res) async => false, - child: Scaffold( - appBar: StreamGalleryHeader( - userName: 'User', - sentAt: '12:02 AM', - message: Message(), - attachment: MockAttachment(), - ), - ), - ), - ), - ), - ); - }, - ); - - tearDown(() { - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger - .setMockMethodCallHandler(methodChannel, null); - }); -} diff --git a/packages/stream_chat_flutter/test/src/gallery/goldens/ci/gallery_footer_0.png b/packages/stream_chat_flutter/test/src/gallery/goldens/ci/gallery_footer_0.png deleted file mode 100644 index 7217acb047..0000000000 Binary files a/packages/stream_chat_flutter/test/src/gallery/goldens/ci/gallery_footer_0.png and /dev/null differ diff --git a/packages/stream_chat_flutter/test/src/gallery/goldens/ci/gallery_header_0.png b/packages/stream_chat_flutter/test/src/gallery/goldens/ci/gallery_header_0.png deleted file mode 100644 index 9a3f6097fe..0000000000 Binary files a/packages/stream_chat_flutter/test/src/gallery/goldens/ci/gallery_header_0.png and /dev/null differ diff --git a/packages/stream_chat_flutter/test/src/icons/goldens/ci/stream_svg_icon_dark.png b/packages/stream_chat_flutter/test/src/icons/goldens/ci/stream_svg_icon_dark.png index e498b63bf8..a181028960 100644 Binary files a/packages/stream_chat_flutter/test/src/icons/goldens/ci/stream_svg_icon_dark.png and b/packages/stream_chat_flutter/test/src/icons/goldens/ci/stream_svg_icon_dark.png differ diff --git a/packages/stream_chat_flutter/test/src/icons/goldens/ci/stream_svg_icon_light.png b/packages/stream_chat_flutter/test/src/icons/goldens/ci/stream_svg_icon_light.png index 3e82952ab4..8098dacd46 100644 Binary files a/packages/stream_chat_flutter/test/src/icons/goldens/ci/stream_svg_icon_light.png and b/packages/stream_chat_flutter/test/src/icons/goldens/ci/stream_svg_icon_light.png differ diff --git a/packages/stream_chat_flutter/test/src/icons/stream_svg_icon_test.dart b/packages/stream_chat_flutter/test/src/icons/stream_svg_icon_test.dart index 5c4fc86d9c..47f28610a5 100644 --- a/packages/stream_chat_flutter/test/src/icons/stream_svg_icon_test.dart +++ b/packages/stream_chat_flutter/test/src/icons/stream_svg_icon_test.dart @@ -181,13 +181,15 @@ Widget _wrapWithMaterialApp( data: ThemeData(brightness: brightness), child: StreamChatTheme( data: StreamChatThemeData(brightness: brightness), - child: Builder(builder: (context) { - final theme = StreamChatTheme.of(context); - return Scaffold( - backgroundColor: theme.colorTheme.appBg, - body: Center(child: widget), - ); - }), + child: Builder( + builder: (context) { + final theme = StreamChatTheme.of(context); + return Scaffold( + backgroundColor: theme.colorTheme.appBg, + body: Center(child: widget), + ); + }, + ), ), ), ); diff --git a/packages/stream_chat_flutter/test/src/indicators/goldens/ci/sending_indicator_0.png b/packages/stream_chat_flutter/test/src/indicators/goldens/ci/sending_indicator_0.png index 838561febd..791691a4fd 100644 Binary files a/packages/stream_chat_flutter/test/src/indicators/goldens/ci/sending_indicator_0.png and b/packages/stream_chat_flutter/test/src/indicators/goldens/ci/sending_indicator_0.png differ diff --git a/packages/stream_chat_flutter/test/src/indicators/goldens/ci/sending_indicator_1.png b/packages/stream_chat_flutter/test/src/indicators/goldens/ci/sending_indicator_1.png index efb6048218..87606274eb 100644 Binary files a/packages/stream_chat_flutter/test/src/indicators/goldens/ci/sending_indicator_1.png and b/packages/stream_chat_flutter/test/src/indicators/goldens/ci/sending_indicator_1.png differ diff --git a/packages/stream_chat_flutter/test/src/indicators/goldens/ci/sending_indicator_2.png b/packages/stream_chat_flutter/test/src/indicators/goldens/ci/sending_indicator_2.png index be1ab4d6f5..87606274eb 100644 Binary files a/packages/stream_chat_flutter/test/src/indicators/goldens/ci/sending_indicator_2.png and b/packages/stream_chat_flutter/test/src/indicators/goldens/ci/sending_indicator_2.png differ diff --git a/packages/stream_chat_flutter/test/src/indicators/goldens/ci/sending_indicator_3.png b/packages/stream_chat_flutter/test/src/indicators/goldens/ci/sending_indicator_3.png index 0c31a3c88b..87606274eb 100644 Binary files a/packages/stream_chat_flutter/test/src/indicators/goldens/ci/sending_indicator_3.png and b/packages/stream_chat_flutter/test/src/indicators/goldens/ci/sending_indicator_3.png differ diff --git a/packages/stream_chat_flutter/test/src/indicators/goldens/ci/upload_progress_indicator_0.png b/packages/stream_chat_flutter/test/src/indicators/goldens/ci/upload_progress_indicator_0.png index 4724897fe5..834961f86a 100644 Binary files a/packages/stream_chat_flutter/test/src/indicators/goldens/ci/upload_progress_indicator_0.png and b/packages/stream_chat_flutter/test/src/indicators/goldens/ci/upload_progress_indicator_0.png differ diff --git a/packages/stream_chat_flutter/test/src/indicators/goldens/ci/upload_progress_indicator_1.png b/packages/stream_chat_flutter/test/src/indicators/goldens/ci/upload_progress_indicator_1.png index 3ee1a56391..419582b6f3 100644 Binary files a/packages/stream_chat_flutter/test/src/indicators/goldens/ci/upload_progress_indicator_1.png and b/packages/stream_chat_flutter/test/src/indicators/goldens/ci/upload_progress_indicator_1.png differ diff --git a/packages/stream_chat_flutter/test/src/indicators/goldens/ci/upload_progress_indicator_2.png b/packages/stream_chat_flutter/test/src/indicators/goldens/ci/upload_progress_indicator_2.png index 8d3d9a3c78..e53612c8e7 100644 Binary files a/packages/stream_chat_flutter/test/src/indicators/goldens/ci/upload_progress_indicator_2.png and b/packages/stream_chat_flutter/test/src/indicators/goldens/ci/upload_progress_indicator_2.png differ diff --git a/packages/stream_chat_flutter/test/src/indicators/sending_indicator_test.dart b/packages/stream_chat_flutter/test/src/indicators/sending_indicator_test.dart index a686a966e3..c7b7867e73 100644 --- a/packages/stream_chat_flutter/test/src/indicators/sending_indicator_test.dart +++ b/packages/stream_chat_flutter/test/src/indicators/sending_indicator_test.dart @@ -2,6 +2,7 @@ import 'package:alchemist/alchemist.dart'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:stream_chat_flutter/stream_chat_flutter.dart'; +import 'package:stream_core_flutter/stream_core_flutter.dart'; import '../material_app_wrapper.dart'; void main() { @@ -28,7 +29,7 @@ void main() { ); goldenTest( - 'golden test for StreamSendingIndicator with StreamSvgIcon.checkAll', + 'golden test for StreamSendingIndicator with Icon checkAll', fileName: 'sending_indicator_0', constraints: const BoxConstraints.tightFor(width: 50, height: 50), builder: () => MaterialAppWrapper( @@ -47,7 +48,7 @@ void main() { ); goldenTest( - 'golden test for StreamSendingIndicator with StreamSvgIcon.checkAll ' + 'golden test for StreamSendingIndicator with Icon checkAll ' '(delivered)', fileName: 'sending_indicator_1', constraints: const BoxConstraints.tightFor(width: 50, height: 50), @@ -69,7 +70,7 @@ void main() { ); goldenTest( - 'golden test for StreamSendingIndicator with StreamSvgIcon.check', + 'golden test for StreamSendingIndicator with Icon check', fileName: 'sending_indicator_2', constraints: const BoxConstraints.tightFor(width: 50, height: 50), builder: () => MaterialAppWrapper( @@ -89,7 +90,7 @@ void main() { ); goldenTest( - 'golden test for StreamSendingIndicator with StreamSvgIcons.time', + 'golden test for StreamSendingIndicator with clock icon', fileName: 'sending_indicator_3', constraints: const BoxConstraints.tightFor(width: 50, height: 50), builder: () => MaterialAppWrapper( @@ -129,13 +130,13 @@ void main() { ), ); - final streamSvgIcon = tester.widget( - find.byType(StreamSvgIcon), + final icon = tester.widget( + find.byType(Icon), ); - expect(streamSvgIcon.icon, StreamSvgIcons.checkAll); + expect(icon.icon, StreamIconData.checks); expect( - streamSvgIcon.color, + icon.color, StreamChatThemeData.light().colorTheme.textLowEmphasis, ); }, @@ -162,13 +163,13 @@ void main() { ), ); - final streamSvgIcon = tester.widget( - find.byType(StreamSvgIcon), + final icon = tester.widget( + find.byType(Icon), ); - expect(streamSvgIcon.icon, StreamSvgIcons.checkAll); + expect(icon.icon, StreamIconData.checks); expect( - streamSvgIcon.color, + icon.color, StreamChatThemeData.light().colorTheme.accentPrimary, ); }, @@ -196,14 +197,14 @@ void main() { ), ); - final streamSvgIcon = tester.widget( - find.byType(StreamSvgIcon), + final icon = tester.widget( + find.byType(Icon), ); - expect(streamSvgIcon.icon, StreamSvgIcons.checkAll); + expect(icon.icon, StreamIconData.checks); // Should use accentPrimary (read) not textLowEmphasis (delivered) expect( - streamSvgIcon.color, + icon.color, StreamChatThemeData.light().colorTheme.accentPrimary, ); }, diff --git a/packages/stream_chat_flutter/test/src/indicators/typing_indicator_test.dart b/packages/stream_chat_flutter/test/src/indicators/typing_indicator_test.dart index bcc2f13701..0a55b81f2c 100644 --- a/packages/stream_chat_flutter/test/src/indicators/typing_indicator_test.dart +++ b/packages/stream_chat_flutter/test/src/indicators/typing_indicator_test.dart @@ -35,7 +35,7 @@ void main() { Member( userId: 'user-id', user: User(id: 'user-id'), - ) + ), ]), ); when(() => channelState.members).thenReturn([ @@ -48,43 +48,45 @@ void main() { Message( text: 'hello', user: User(id: 'other-user'), - ) + ), ]); when(() => channelState.messagesStream).thenAnswer( (i) => Stream.value([ Message( text: 'hello', user: User(id: 'other-user'), - ) + ), ]), ); - when(() => channelState.typingEvents).thenAnswer((i) => { - User(id: 'other-user', extraData: const {'name': 'demo'}): - Event(type: EventType.typingStart), - }); + when(() => channelState.typingEvents).thenAnswer( + (i) => { + User(id: 'other-user', extraData: const {'name': 'demo'}): Event(type: EventType.typingStart), + }, + ); when(() => channelState.typingEventsStream).thenAnswer( (i) => Stream.value({ - User(id: 'other-user', extraData: const {'name': 'demo'}): - Event(type: EventType.typingStart), + User(id: 'other-user', extraData: const {'name': 'demo'}): Event(type: EventType.typingStart), }), ); const typingKey = Key('typing'); - await tester.pumpWidget(MaterialApp( - home: StreamChat( - client: client, - child: StreamChannel( - channel: channel, - child: const Scaffold( - body: StreamTypingIndicator( - key: typingKey, + await tester.pumpWidget( + MaterialApp( + home: StreamChat( + client: client, + child: StreamChannel( + channel: channel, + child: const Scaffold( + body: StreamTypingIndicator( + key: typingKey, + ), ), ), ), ), - )); + ); // wait for the initial state to be rendered. await tester.pump(Duration.zero); diff --git a/packages/stream_chat_flutter/test/src/indicators/unread_indicator_test.dart b/packages/stream_chat_flutter/test/src/indicators/unread_indicator_test.dart index 61cf5b941a..c6fe1a058a 100644 --- a/packages/stream_chat_flutter/test/src/indicators/unread_indicator_test.dart +++ b/packages/stream_chat_flutter/test/src/indicators/unread_indicator_test.dart @@ -30,20 +30,21 @@ void main() { }); when(() => clientState.totalUnreadCount).thenReturn(10); - when(() => clientState.totalUnreadCountStream) - .thenAnswer((i) => Stream.value(10)); - - await tester.pumpWidget(MaterialApp( - home: StreamChat( - client: client, - child: StreamChannel( - channel: channel, - child: Scaffold( - body: StreamUnreadIndicator(), + when(() => clientState.totalUnreadCountStream).thenAnswer((i) => Stream.value(10)); + + await tester.pumpWidget( + MaterialApp( + home: StreamChat( + client: client, + child: StreamChannel( + channel: channel, + child: const Scaffold( + body: StreamUnreadIndicator(), + ), ), ), ), - )); + ); // wait for the initial state to be rendered. await tester.pumpAndSettle(); @@ -70,22 +71,23 @@ void main() { when(() => channel.state).thenReturn(channelState); when(() => channel.client).thenReturn(client); when(() => channelState.unreadCount).thenReturn(0); - when(() => channelState.unreadCountStream) - .thenAnswer((i) => Stream.value(0)); - - await tester.pumpWidget(MaterialApp( - home: StreamChat( - client: client, - child: StreamChannel( - channel: channel, - child: Scaffold( - body: StreamUnreadIndicator.channels( - cid: channel.cid, + when(() => channelState.unreadCountStream).thenAnswer((i) => Stream.value(0)); + + await tester.pumpWidget( + MaterialApp( + home: StreamChat( + client: client, + child: StreamChannel( + channel: channel, + child: Scaffold( + body: StreamUnreadIndicator.channels( + cid: channel.cid, + ), ), ), ), ), - )); + ); // wait for the initial state to be rendered. await tester.pumpAndSettle(); @@ -112,22 +114,23 @@ void main() { when(() => channel.state).thenReturn(channelState); when(() => channel.client).thenReturn(client); when(() => channelState.unreadCount).thenReturn(100); - when(() => channelState.unreadCountStream) - .thenAnswer((i) => Stream.value(100)); - - await tester.pumpWidget(MaterialApp( - home: StreamChat( - client: client, - child: StreamChannel( - channel: channel, - child: Scaffold( - body: StreamUnreadIndicator.channels( - cid: channel.cid, + when(() => channelState.unreadCountStream).thenAnswer((i) => Stream.value(100)); + + await tester.pumpWidget( + MaterialApp( + home: StreamChat( + client: client, + child: StreamChannel( + channel: channel, + child: Scaffold( + body: StreamUnreadIndicator.channels( + cid: channel.cid, + ), ), ), ), ), - )); + ); // wait for the initial state to be rendered. await tester.pumpAndSettle(); diff --git a/packages/stream_chat_flutter/test/src/indicators/upload_progress_indicator_test.dart b/packages/stream_chat_flutter/test/src/indicators/upload_progress_indicator_test.dart deleted file mode 100644 index 09a9237d81..0000000000 --- a/packages/stream_chat_flutter/test/src/indicators/upload_progress_indicator_test.dart +++ /dev/null @@ -1,160 +0,0 @@ -import 'package:alchemist/alchemist.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:stream_chat_flutter/stream_chat_flutter.dart'; - -import '../material_app_wrapper.dart'; - -void main() { - testWidgets('StreamUploadProgressIndicator at 0% with no background', - (tester) async { - await tester.pumpWidget( - MaterialApp( - home: StreamChatTheme( - data: StreamChatThemeData.light(), - child: const Scaffold( - body: Center( - child: StreamUploadProgressIndicator( - total: 100, - uploaded: 0, - showBackground: false, - ), - ), - ), - ), - ), - ); - - expect(find.text('0%'), findsOneWidget); - }); - - testWidgets('StreamUploadProgressIndicator at 50% with no background', - (tester) async { - await tester.pumpWidget( - MaterialApp( - home: StreamChatTheme( - data: StreamChatThemeData.light(), - child: const Scaffold( - body: Center( - child: StreamUploadProgressIndicator( - total: 100, - uploaded: 50, - showBackground: false, - ), - ), - ), - ), - ), - ); - - expect(find.text('50%'), findsOneWidget); - }); - - testWidgets('StreamUploadProgressIndicator at 100% with no background', - (tester) async { - await tester.pumpWidget( - MaterialApp( - home: StreamChatTheme( - data: StreamChatThemeData.light(), - child: const Scaffold( - body: Center( - child: StreamUploadProgressIndicator( - total: 100, - uploaded: 100, - showBackground: false, - ), - ), - ), - ), - ), - ); - - expect(find.text('100%'), findsOneWidget); - }); - - testWidgets('StreamUploadProgressIndicator at 50% with background', - (tester) async { - await tester.pumpWidget( - MaterialApp( - home: StreamChatTheme( - data: StreamChatThemeData.light(), - child: const Scaffold( - body: Center( - child: StreamUploadProgressIndicator( - total: 100, - uploaded: 50, - ), - ), - ), - ), - ), - ); - - final backgroundColor = - ((find.byType(DecoratedBox).evaluate().first.widget as DecoratedBox) - .decoration as BoxDecoration) - .color; - - expect(const Color(0x99000000), backgroundColor); - }); - - goldenTest( - 'golden test for StreamUploadProgressIndicator at 0% with background', - fileName: 'upload_progress_indicator_0', - constraints: const BoxConstraints.tightFor(width: 300, height: 300), - pumpBeforeTest: pumpOnce, - builder: () => MaterialAppWrapper( - home: StreamChatTheme( - data: StreamChatThemeData.light(), - child: const Scaffold( - body: Center( - child: StreamUploadProgressIndicator( - total: 100, - uploaded: 0, - ), - ), - ), - ), - ), - ); - - goldenTest( - 'golden test for StreamUploadProgressIndicator at 50% with background', - fileName: 'upload_progress_indicator_1', - constraints: const BoxConstraints.tightFor(width: 300, height: 300), - pumpBeforeTest: pumpOnce, - builder: () => MaterialAppWrapper( - home: StreamChatTheme( - data: StreamChatThemeData.light(), - child: const Scaffold( - body: Center( - child: StreamUploadProgressIndicator( - total: 100, - uploaded: 50, - ), - ), - ), - ), - ), - ); - - goldenTest( - 'golden test for StreamUploadProgressIndicator at 100% with background', - fileName: 'upload_progress_indicator_2', - constraints: const BoxConstraints.tightFor(width: 300, height: 300), - pumpBeforeTest: pumpOnce, - builder: () => MaterialAppWrapper( - home: StreamChatTheme( - data: StreamChatThemeData.light(), - child: const Scaffold( - body: Center( - child: StreamUploadProgressIndicator( - total: 100, - uploaded: 100, - ), - ), - ), - ), - ), - ); -} diff --git a/packages/stream_chat_flutter/test/src/localization/default_translations_test.dart b/packages/stream_chat_flutter/test/src/localization/default_translations_test.dart index 068d798243..6adfe4c114 100644 --- a/packages/stream_chat_flutter/test/src/localization/default_translations_test.dart +++ b/packages/stream_chat_flutter/test/src/localization/default_translations_test.dart @@ -27,7 +27,7 @@ void main() { expect(translations.onlyVisibleToYouText, isNotNull); expect(translations.threadReplyCountText(3), isNotNull); expect( - translations.attachmentsUploadProgressText(remaining: 3, total: 10), + translations.attachmentsUploadProgressText(completed: 3, total: 10), isNotNull, ); expect( @@ -44,6 +44,9 @@ void main() { expect(translations.messageDeletedText, isNotNull); expect(translations.messageDeletedLabel, isNotNull); expect(translations.messageReactionsLabel, isNotNull); + // singular vs. plural — both branches exercised + expect(translations.reactionsCountText(1), isNotNull); + expect(translations.reactionsCountText(5), isNotNull); expect(translations.emptyChatMessagesText, isNotNull); expect(translations.threadSeparatorText(3), isNotNull); expect(translations.connectedLabel, isNotNull); diff --git a/packages/stream_chat_flutter/test/src/material_app_wrapper.dart b/packages/stream_chat_flutter/test/src/material_app_wrapper.dart index 904b9a080d..b0e354997e 100644 --- a/packages/stream_chat_flutter/test/src/material_app_wrapper.dart +++ b/packages/stream_chat_flutter/test/src/material_app_wrapper.dart @@ -13,16 +13,15 @@ class MaterialAppWrapper extends MaterialApp { TransitionBuilder? builder, Widget? home, }) : super( - key: key, - builder: builder, - localizationsDelegates: localizations, - supportedLocales: localeOverrides ?? const [Locale('en')], - theme: theme?.copyWith(platform: platform) ?? - ThemeData(platform: platform, useMaterial3: false), - debugShowCheckedModeBanner: false, - home: home, - navigatorObservers: [ - if (navigatorObserver != null) navigatorObserver, - ], - ); + key: key, + builder: builder, + localizationsDelegates: localizations, + supportedLocales: localeOverrides ?? const [Locale('en')], + theme: theme?.copyWith(platform: platform) ?? ThemeData(platform: platform, useMaterial3: false), + debugShowCheckedModeBanner: false, + home: home, + navigatorObservers: [ + if (navigatorObserver != null) navigatorObserver, + ], + ); } diff --git a/packages/stream_chat_flutter/test/src/message_action/goldens/ci/stream_message_action_item_custom_child_dark.png b/packages/stream_chat_flutter/test/src/message_action/goldens/ci/stream_message_action_item_custom_child_dark.png new file mode 100644 index 0000000000..23412da5e8 Binary files /dev/null and b/packages/stream_chat_flutter/test/src/message_action/goldens/ci/stream_message_action_item_custom_child_dark.png differ diff --git a/packages/stream_chat_flutter/test/src/message_action/goldens/ci/stream_message_action_item_custom_child_light.png b/packages/stream_chat_flutter/test/src/message_action/goldens/ci/stream_message_action_item_custom_child_light.png new file mode 100644 index 0000000000..e3ddc3bab7 Binary files /dev/null and b/packages/stream_chat_flutter/test/src/message_action/goldens/ci/stream_message_action_item_custom_child_light.png differ diff --git a/packages/stream_chat_flutter/test/src/message_action/goldens/ci/stream_message_action_item_custom_styling_dark.png b/packages/stream_chat_flutter/test/src/message_action/goldens/ci/stream_message_action_item_custom_styling_dark.png new file mode 100644 index 0000000000..2305901cff Binary files /dev/null and b/packages/stream_chat_flutter/test/src/message_action/goldens/ci/stream_message_action_item_custom_styling_dark.png differ diff --git a/packages/stream_chat_flutter/test/src/message_action/goldens/ci/stream_message_action_item_custom_styling_light.png b/packages/stream_chat_flutter/test/src/message_action/goldens/ci/stream_message_action_item_custom_styling_light.png new file mode 100644 index 0000000000..3f2d369e17 Binary files /dev/null and b/packages/stream_chat_flutter/test/src/message_action/goldens/ci/stream_message_action_item_custom_styling_light.png differ diff --git a/packages/stream_chat_flutter/test/src/message_action/goldens/ci/stream_message_action_item_delete_dark.png b/packages/stream_chat_flutter/test/src/message_action/goldens/ci/stream_message_action_item_delete_dark.png new file mode 100644 index 0000000000..5ff006f9db Binary files /dev/null and b/packages/stream_chat_flutter/test/src/message_action/goldens/ci/stream_message_action_item_delete_dark.png differ diff --git a/packages/stream_chat_flutter/test/src/message_action/goldens/ci/stream_message_action_item_delete_light.png b/packages/stream_chat_flutter/test/src/message_action/goldens/ci/stream_message_action_item_delete_light.png new file mode 100644 index 0000000000..b681b7092b Binary files /dev/null and b/packages/stream_chat_flutter/test/src/message_action/goldens/ci/stream_message_action_item_delete_light.png differ diff --git a/packages/stream_chat_flutter/test/src/message_action/goldens/ci/stream_message_action_item_reply_dark.png b/packages/stream_chat_flutter/test/src/message_action/goldens/ci/stream_message_action_item_reply_dark.png new file mode 100644 index 0000000000..be0eb556f4 Binary files /dev/null and b/packages/stream_chat_flutter/test/src/message_action/goldens/ci/stream_message_action_item_reply_dark.png differ diff --git a/packages/stream_chat_flutter/test/src/message_action/goldens/ci/stream_message_action_item_reply_light.png b/packages/stream_chat_flutter/test/src/message_action/goldens/ci/stream_message_action_item_reply_light.png new file mode 100644 index 0000000000..22133451fa Binary files /dev/null and b/packages/stream_chat_flutter/test/src/message_action/goldens/ci/stream_message_action_item_reply_light.png differ diff --git a/packages/stream_chat_flutter/test/src/message_action/message_actions_builder_test.dart b/packages/stream_chat_flutter/test/src/message_action/message_actions_builder_test.dart new file mode 100644 index 0000000000..00b288f2b5 --- /dev/null +++ b/packages/stream_chat_flutter/test/src/message_action/message_actions_builder_test.dart @@ -0,0 +1,577 @@ +// ignore_for_file: cascade_invocations, avoid_redundant_argument_values + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:stream_chat_flutter/stream_chat_flutter.dart'; + +import '../mocks.dart'; + +/// Creates a test message with customizable properties. +Message createTestMessage({ + String id = 'test-message', + String text = 'Test message', + String userId = 'test-user', + bool pinned = false, + String? parentId, + Poll? poll, + MessageType type = MessageType.regular, + int? replyCount, +}) { + final message = Message( + id: id, + text: text, + user: User(id: userId), + pinned: pinned, + parentId: parentId, + poll: poll, + type: type, + deletedAt: type == MessageType.deleted ? DateTime.now() : null, + replyCount: replyCount, + moderation: switch (type) { + MessageType.error => const Moderation( + action: ModerationAction.bounce, + originalText: 'Original message text that violated policy', + ), + _ => null, + }, + ); + + var state = MessageState.sent; + if (message.deletedAt != null) { + state = MessageState.softDeleted; + } else if (message.updatedAt.isAfter(message.createdAt)) { + state = MessageState.updated; + } + + return message.copyWith(state: state); +} + +const allChannelCapabilities = [ + ChannelCapability.sendMessage, + ChannelCapability.sendReply, + ChannelCapability.pinMessage, + ChannelCapability.readEvents, + ChannelCapability.deleteOwnMessage, + ChannelCapability.deleteAnyMessage, + ChannelCapability.updateOwnMessage, + ChannelCapability.updateAnyMessage, + ChannelCapability.quoteMessage, +]; + +void main() { + final message = createTestMessage(); + final currentUser = OwnUser(id: 'current-user'); + + setUpAll(() { + registerFallbackValue(Message()); + // registerFallbackValue(const StreamMessageActionType('any')); + }); + + MockChannel _getChannelWithCapabilities( + List capabilities, { + bool enableMutes = true, + }) { + final customChannel = MockChannel(ownCapabilities: capabilities); + final channelConfig = ChannelConfig(mutes: enableMutes); + when(() => customChannel.config).thenReturn(channelConfig); + return customChannel; + } + + Future _getContext(WidgetTester tester) async { + late BuildContext context; + await tester.pumpWidget( + StreamChatTheme( + data: StreamChatThemeData.light(), + child: Builder( + builder: (ctx) { + context = ctx; + return const SizedBox.shrink(); + }, + ), + ), + ); + return context; + } + + testWidgets('builds default message actions', (tester) async { + final context = await _getContext(tester); + + final channel = _getChannelWithCapabilities(allChannelCapabilities); + final actions = StreamMessageActionsBuilder.buildActions( + context: context, + message: message, + channel: channel, + currentUser: currentUser, + ); + + // Verify default actions + actions.expects(); + actions.expects(); + actions.expects(); + actions.expects(); + actions.expects(); + actions.expects(); + actions.expects(); + actions.expects(); + actions.expects(); + }); + + testWidgets('returns empty set for deleted messages', (tester) async { + final context = await _getContext(tester); + final deletedMessage = createTestMessage(type: MessageType.deleted); + + final channel = _getChannelWithCapabilities(allChannelCapabilities); + final actions = StreamMessageActionsBuilder.buildActions( + context: context, + message: deletedMessage, + channel: channel, + currentUser: currentUser, + ); + + expect(actions.isEmpty, isTrue); + }); + + group('permission-based actions', () { + testWidgets( + 'includes/excludes edit action based on authorship', + (tester) async { + final context = await _getContext(tester); + + // Own message test + final channel = _getChannelWithCapabilities(allChannelCapabilities); + final ownMessage = createTestMessage(userId: currentUser.id); + final ownActions = StreamMessageActionsBuilder.buildActions( + context: context, + message: ownMessage, + channel: channel, + currentUser: currentUser, + ); + + ownActions.expects( + reason: 'Edit action should be available for own messages', + ); + + // Other user's message test + final otherUserActions = StreamMessageActionsBuilder.buildActions( + context: context, + message: message, + channel: channel, + currentUser: currentUser, + ); + + otherUserActions.expects( + reason: 'Edit action should be available for others messages', + ); + }, + ); + + testWidgets('excludes edit action for messages with polls', (tester) async { + final context = await _getContext(tester); + + final pollMessage = createTestMessage( + userId: currentUser.id, + poll: Poll( + id: 'poll-id', + name: 'What is your favorite color?', + createdAt: DateTime.now(), + updatedAt: DateTime.now(), + options: const [ + PollOption(text: 'Option 1'), + PollOption(text: 'Option 2'), + ], + ), + ); + + final channel = _getChannelWithCapabilities(allChannelCapabilities); + final actions = StreamMessageActionsBuilder.buildActions( + context: context, + message: pollMessage, + channel: channel, + currentUser: currentUser, + ); + + actions.notExpects( + reason: 'Edit action should not be available for poll messages', + ); + }); + + testWidgets( + 'includes/excludes delete action based on permission', + (tester) async { + final context = await _getContext(tester); + + // With delete permission + final channel = _getChannelWithCapabilities([ + ChannelCapability.sendMessage, + ChannelCapability.updateOwnMessage, + ChannelCapability.deleteOwnMessage, + ]); + + final ownMessage = createTestMessage(userId: currentUser.id); + final actionsWithPerm = StreamMessageActionsBuilder.buildActions( + context: context, + message: ownMessage, + channel: channel, + currentUser: currentUser, + ); + + actionsWithPerm.expects( + reason: 'Delete action should be available with permission', + ); + + // Without delete permission + final channelWithoutDeletePerm = _getChannelWithCapabilities([ + ChannelCapability.sendMessage, + ChannelCapability.updateOwnMessage, + ]); + + final actionsWithoutPerm = StreamMessageActionsBuilder.buildActions( + context: context, + message: message, + channel: channelWithoutDeletePerm, + currentUser: currentUser, + ); + + actionsWithoutPerm.notExpects( + reason: 'Delete action should not be available without permission', + ); + }, + ); + + testWidgets( + 'includes/excludes pin action based on permission', + (tester) async { + final context = await _getContext(tester); + + // With pin permission + final channel = _getChannelWithCapabilities([ + ChannelCapability.sendMessage, + ChannelCapability.sendReply, + ChannelCapability.pinMessage, + ]); + + final actionsWithPerm = StreamMessageActionsBuilder.buildActions( + context: context, + message: message, + channel: channel, + currentUser: currentUser, + ); + + actionsWithPerm.expects( + reason: 'Pin action should be available with pin permission', + ); + + // Without pin permission + final channelWithoutPinPerm = _getChannelWithCapabilities([ + ChannelCapability.sendMessage, + ChannelCapability.sendReply, + ]); + + final actionsWithoutPerm = StreamMessageActionsBuilder.buildActions( + context: context, + message: message, + channel: channelWithoutPinPerm, + currentUser: currentUser, + ); + + actionsWithoutPerm.notExpects( + reason: 'Pin action should not be available without permission', + ); + }, + ); + + testWidgets('shows unpin action for pinned messages', (tester) async { + final context = await _getContext(tester); + + final channel = _getChannelWithCapabilities(allChannelCapabilities); + final pinnedMessage = createTestMessage(pinned: true); + final actions = StreamMessageActionsBuilder.buildActions( + context: context, + message: pinnedMessage, + channel: channel, + currentUser: currentUser, + ); + + actions.expects( + reason: 'Unpin action should be available for pinned messages', + ); + }); + + testWidgets( + 'includes/excludes flag action based on authorship', + (tester) async { + final context = await _getContext(tester); + + // Other user's message + final channel = _getChannelWithCapabilities(allChannelCapabilities); + final actionsOtherUser = StreamMessageActionsBuilder.buildActions( + context: context, + message: message, + channel: channel, + currentUser: currentUser, + ); + + actionsOtherUser.expects( + reason: "Flag action should be available for others' messages", + ); + + // Own message + final ownMessage = createTestMessage(userId: currentUser.id); + final actionsOwnMessage = StreamMessageActionsBuilder.buildActions( + context: context, + message: ownMessage, + channel: channel, + currentUser: currentUser, + ); + + actionsOwnMessage.notExpects( + reason: 'Flag action should not be available for own messages', + ); + }, + ); + + testWidgets( + 'handles mute action correctly based on user and config', + (tester) async { + final context = await _getContext(tester); + + // User with no mutes + final channel = _getChannelWithCapabilities(allChannelCapabilities); + final userWithNoMutes = OwnUser(id: 'current-user', mutes: const []); + final actionsForNoMutes = StreamMessageActionsBuilder.buildActions( + context: context, + message: message, + channel: channel, + currentUser: userWithNoMutes, + ); + + actionsForNoMutes.expects( + reason: 'Mute action should be available for users with no mutes', + ); + + // User with mutes + final userWithMutes = OwnUser( + id: 'current-user', + mutes: [ + Mute( + user: User(id: 'test-user'), + target: User(id: 'test-user'), + createdAt: DateTime.now(), + updatedAt: DateTime.now(), + ), + ], + ); + + final actionsForMutedUser = StreamMessageActionsBuilder.buildActions( + context: context, + message: message, + channel: channel, + currentUser: userWithMutes, + ); + + actionsForMutedUser.expects( + reason: 'Unmute action should be available for already muted users', + ); + + // Own message + final ownMessage = createTestMessage(userId: currentUser.id); + final actionsForOwnMessage = StreamMessageActionsBuilder.buildActions( + context: context, + message: ownMessage, + channel: channel, + currentUser: currentUser, + ); + + actionsForOwnMessage.notExpects( + reason: 'Mute action should not be available for own messages', + ); + + // Channel without mutes enabled + final channelWithoutMutes = _getChannelWithCapabilities( + allChannelCapabilities, + enableMutes: false, + ); + + final muteDisabledActions = StreamMessageActionsBuilder.buildActions( + context: context, + message: message, + channel: channelWithoutMutes, + currentUser: currentUser, + ); + + muteDisabledActions.notExpects( + reason: 'Mute action unavailable when channel mutes are disabled', + ); + }, + ); + + testWidgets( + 'handles thread and quote reply actions correctly', + (tester) async { + final context = await _getContext(tester); + + // Thread message + final channel = _getChannelWithCapabilities(allChannelCapabilities); + final threadMessage = createTestMessage(parentId: 'parent-message-id'); + final actionsForThreadMessage = StreamMessageActionsBuilder.buildActions( + context: context, + message: threadMessage, + channel: channel, + currentUser: currentUser, + ); + + actionsForThreadMessage.notExpects( + reason: 'Thread reply unavailable for thread messages', + ); + + // Channel without quote permission + final channelWithoutQuote = _getChannelWithCapabilities([ + ChannelCapability.sendMessage, + ChannelCapability.sendReply, + ]); + + final actionsWithoutQuote = StreamMessageActionsBuilder.buildActions( + context: context, + message: message, + channel: channelWithoutQuote, + currentUser: currentUser, + ); + + actionsWithoutQuote.notExpects( + reason: 'Quote reply unavailable without quote permission', + ); + }, + ); + + testWidgets('handles mark unread action correctly', (tester) async { + final context = await _getContext(tester); + + // With read events capability + final parentMessage = createTestMessage( + id: 'parent-message', + text: 'Parent message', + replyCount: 5, + ); + + final channelWithReadEvents = _getChannelWithCapabilities([ + ChannelCapability.sendMessage, + ChannelCapability.sendReply, + ChannelCapability.readEvents, + ]); + + final actionsWithReadEvents = StreamMessageActionsBuilder.buildActions( + context: context, + message: parentMessage, + channel: channelWithReadEvents, + currentUser: currentUser, + ); + + actionsWithReadEvents.expects( + reason: 'Mark unread available with read events capability', + ); + + // Without read events capability + final channelWithoutReadEvents = _getChannelWithCapabilities([ + ChannelCapability.sendMessage, + ChannelCapability.sendReply, + ]); + + final actionsWithoutReadEvents = StreamMessageActionsBuilder.buildActions( + context: context, + message: message, + channel: channelWithoutReadEvents, + currentUser: currentUser, + ); + + actionsWithoutReadEvents.notExpects( + reason: 'Mark unread unavailable without read events capability', + ); + }); + + testWidgets( + 'excludes mark unread action for own messages even if parent message', + (tester) async { + final context = await _getContext(tester); + + final channelWithReadEvents = _getChannelWithCapabilities([ + ChannelCapability.sendMessage, + ChannelCapability.sendReply, + ChannelCapability.readEvents, + ]); + + final ownParentMessage = createTestMessage( + userId: currentUser.id, + replyCount: 5, + ); + + final actions = StreamMessageActionsBuilder.buildActions( + context: context, + message: ownParentMessage, + channel: channelWithReadEvents, + currentUser: currentUser, + ); + + actions.notExpects( + reason: + 'Mark unread should not be available for own messages, ' + 'even if it is a parent message', + ); + }, + ); + }); + + group('buildBouncedErrorActions', () { + testWidgets('returns empty set for non-bounced messages', (tester) async { + final context = await _getContext(tester); + final regularMessage = createTestMessage(); + + final actions = StreamMessageActionsBuilder.buildBouncedErrorActions( + context: context, + message: regularMessage, + ); + + expect(actions.isEmpty, isTrue, reason: 'No actions for regular message'); + }); + + testWidgets( + 'builds actions for bounced messages with error', + (tester) async { + final context = await _getContext(tester); + final bouncedMessage = createTestMessage(type: MessageType.error); + + final actions = StreamMessageActionsBuilder.buildBouncedErrorActions( + context: context, + message: bouncedMessage, + ); + + // Verify the specific actions for bounced messages + actions.expects( + reason: 'Send Anyway action should be included', + ); + actions.expects( + reason: 'Edit Message action should be included', + ); + actions.expects( + reason: 'Delete Message action should be included', + ); + + // Verify the count is correct + expect(actions.length, 3, reason: 'Should have exactly 3 actions'); + }, + ); + }); +} + +/// Extension on action lists to simplify message action type checks. +extension StreamMessageActionSetExtension on List { + void expects({String? reason}) { + final containsActionType = this.any((it) => it.props.value is T); + return expect(containsActionType, isTrue, reason: reason); + } + + void notExpects({String? reason}) { + final containsActionType = this.any((it) => it.props.value is T); + return expect(containsActionType, isFalse, reason: reason); + } +} diff --git a/packages/stream_chat_flutter/test/src/message_actions_modal/message_actions_modal_test.dart b/packages/stream_chat_flutter/test/src/message_actions_modal/message_actions_modal_test.dart deleted file mode 100644 index b23c6854db..0000000000 --- a/packages/stream_chat_flutter/test/src/message_actions_modal/message_actions_modal_test.dart +++ /dev/null @@ -1,926 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:mocktail/mocktail.dart'; -import 'package:record/record.dart'; -import 'package:stream_chat_flutter/src/message_actions_modal/message_actions_modal.dart'; -import 'package:stream_chat_flutter/stream_chat_flutter.dart'; - -import '../fakes.dart'; -import '../mocks.dart'; - -void main() { - TestWidgetsFlutterBinding.ensureInitialized(); - - setUpAll(() { - registerFallbackValue( - MaterialPageRoute(builder: (context) => const SizedBox()), - ); - - registerFallbackValue(Message()); - }); - - final originalRecordPlatform = RecordPlatform.instance; - setUp(() => RecordPlatform.instance = FakeRecordPlatform()); - tearDown(() => RecordPlatform.instance = originalRecordPlatform); - - testWidgets( - 'it should show the all actions', - (WidgetTester tester) async { - final client = MockClient(); - final clientState = MockClientState(); - final channel = MockChannel(); - - when(() => client.state).thenReturn(clientState); - when(() => clientState.currentUser).thenReturn(OwnUser(id: 'user-id')); - - final themeData = ThemeData(); - final streamTheme = StreamChatThemeData.fromTheme(themeData); - await tester.pumpWidget( - MaterialApp( - theme: themeData, - home: StreamChat( - streamChatThemeData: streamTheme, - client: client, - child: SizedBox( - child: StreamChannel( - channel: channel, - child: MessageActionsModal( - message: Message( - text: 'test', - user: User( - id: 'user-id', - ), - state: MessageState.sent, - ), - messageWidget: const Text( - 'test', - key: Key('MessageWidget'), - ), - messageTheme: streamTheme.ownMessageTheme, - ), - ), - ), - ), - ), - ); - await tester.pumpAndSettle(); - - expect(find.byKey(const Key('MessageWidget')), findsOneWidget); - expect(find.text('Thread Reply'), findsOneWidget); - expect(find.text('Reply'), findsOneWidget); - expect(find.text('Edit Message'), findsOneWidget); - expect(find.text('Delete Message'), findsOneWidget); - expect(find.text('Copy Message'), findsOneWidget); - expect(find.text('Mark as Unread'), findsOneWidget); - }, - ); - - testWidgets( - 'it should show the reaction picker', - (WidgetTester tester) async { - final client = MockClient(); - final clientState = MockClientState(); - final channel = MockChannel( - ownCapabilities: [ - ChannelCapability.sendMessage, - ChannelCapability.sendReaction, - ], - ); - - when(() => client.state).thenReturn(clientState); - when(() => clientState.currentUser).thenReturn(OwnUser(id: 'user-id')); - - final themeData = ThemeData(); - final streamTheme = StreamChatThemeData.fromTheme(themeData); - await tester.pumpWidget( - MaterialApp( - theme: themeData, - home: StreamChat( - streamChatThemeData: streamTheme, - client: client, - child: SizedBox( - child: StreamChannel( - channel: channel, - child: MessageActionsModal( - message: Message( - text: 'test', - user: User( - id: 'user-id', - ), - state: MessageState.sent, - ), - messageWidget: const Text( - 'test', - key: Key('MessageWidget'), - ), - messageTheme: streamTheme.ownMessageTheme, - ), - ), - ), - ), - ), - ); - await tester.pumpAndSettle(); - - expect(find.byType(StreamReactionPicker), findsOneWidget); - }, - ); - - testWidgets( - 'it should not show the reaction picker', - (WidgetTester tester) async { - final client = MockClient(); - final clientState = MockClientState(); - final channel = MockChannel(); - - when(() => client.state).thenReturn(clientState); - when(() => clientState.currentUser).thenReturn(OwnUser(id: 'user-id')); - - final themeData = ThemeData(); - final streamTheme = StreamChatThemeData.fromTheme(themeData); - await tester.pumpWidget( - MaterialApp( - theme: themeData, - home: StreamChat( - streamChatThemeData: streamTheme, - client: client, - child: SizedBox( - child: StreamChannel( - channel: channel, - child: MessageActionsModal( - showReactionPicker: false, - message: Message( - text: 'test', - user: User( - id: 'user-id', - ), - state: MessageState.sent, - ), - messageWidget: const Text( - 'test', - key: Key('MessageWidget'), - ), - messageTheme: streamTheme.ownMessageTheme, - ), - ), - ), - ), - ), - ); - await tester.pumpAndSettle(); - - expect(find.byType(StreamReactionPicker), findsNothing); - }, - ); - - testWidgets( - 'it should show some actions', - (WidgetTester tester) async { - final client = MockClient(); - final clientState = MockClientState(); - final channel = MockChannel(); - - when(() => client.state).thenReturn(clientState); - when(() => clientState.currentUser).thenReturn(OwnUser(id: 'user-id')); - - final themeData = ThemeData(); - final streamTheme = StreamChatThemeData.fromTheme(themeData); - await tester.pumpWidget( - MaterialApp( - theme: themeData, - home: StreamChat( - streamChatThemeData: streamTheme, - client: client, - child: SizedBox( - child: StreamChannel( - channel: channel, - child: MessageActionsModal( - showCopyMessage: false, - showReplyMessage: false, - showThreadReplyMessage: false, - message: Message( - text: 'test', - user: User( - id: 'user-id', - ), - ), - messageTheme: streamTheme.ownMessageTheme, - messageWidget: const Text( - 'test', - key: Key('MessageWidget'), - ), - ), - ), - ), - ), - ), - ); - await tester.pumpAndSettle(); - - expect(find.byKey(const Key('MessageWidget')), findsOneWidget); - expect(find.text('Reply'), findsNothing); - expect(find.text('Thread reply'), findsNothing); - expect(find.text('Edit message'), findsNothing); - expect(find.text('Delete message'), findsNothing); - expect(find.text('Copy message'), findsNothing); - }, - ); - - testWidgets( - 'it should show custom actions', - (WidgetTester tester) async { - final client = MockClient(); - final clientState = MockClientState(); - final channel = MockChannel(); - - when(() => client.state).thenReturn(clientState); - when(() => clientState.currentUser).thenReturn(OwnUser(id: 'user-id')); - - final themeData = ThemeData(); - final streamTheme = StreamChatThemeData.fromTheme(themeData); - - var tapped = false; - await tester.pumpWidget(MaterialApp( - theme: themeData, - home: StreamChat( - streamChatThemeData: streamTheme, - client: client, - child: SizedBox( - child: StreamChannel( - channel: channel, - child: StreamChannel( - channel: channel, - child: MessageActionsModal( - messageWidget: const Text('test'), - message: Message( - text: 'test', - user: User( - id: 'user-id', - ), - ), - messageTheme: streamTheme.ownMessageTheme, - customActions: [ - StreamMessageAction( - leading: const Icon(Icons.check), - title: const Text('title'), - onTap: (m) { - tapped = true; - }, - ), - ], - ), - ), - ), - ), - ), - )); - - await tester.pumpAndSettle(); - - expect(find.byIcon(Icons.check), findsOneWidget); - expect(find.text('title'), findsOneWidget); - - await tester.tap(find.text('title')); - - expect(tapped, true); - }, - ); - - testWidgets( - 'tapping on reply should call the callback', - (WidgetTester tester) async { - final client = MockClient(); - final clientState = MockClientState(); - final channel = MockChannel(); - - when(() => client.state).thenReturn(clientState); - when(() => clientState.currentUser).thenReturn(OwnUser(id: 'user-id')); - - final themeData = ThemeData(); - final streamTheme = StreamChatThemeData.fromTheme(themeData); - - var tapped = false; - - await tester.pumpWidget( - MaterialApp( - theme: themeData, - home: StreamChat( - streamChatThemeData: streamTheme, - client: client, - child: SizedBox( - child: StreamChannel( - channel: channel, - child: MessageActionsModal( - messageWidget: const Text('test'), - onReplyTap: (m) { - tapped = true; - }, - message: Message( - text: 'test', - user: User( - id: 'user-id', - ), - state: MessageState.sent, - ), - messageTheme: streamTheme.ownMessageTheme, - ), - ), - ), - ), - ), - ); - await tester.pumpAndSettle(); - - await tester.tap(find.text('Reply')); - - expect(tapped, true); - }, - ); - - testWidgets( - 'tapping on thread reply should call the callback', - (WidgetTester tester) async { - final client = MockClient(); - final clientState = MockClientState(); - final channel = MockChannel(); - - when(() => client.state).thenReturn(clientState); - when(() => clientState.currentUser).thenReturn(OwnUser(id: 'user-id')); - - final themeData = ThemeData(); - final streamTheme = StreamChatThemeData.fromTheme(themeData); - - var tapped = false; - - await tester.pumpWidget( - MaterialApp( - theme: themeData, - home: StreamChat( - streamChatThemeData: streamTheme, - client: client, - child: SizedBox( - child: StreamChannel( - channel: channel, - child: MessageActionsModal( - messageWidget: const Text('test'), - onThreadReplyTap: (m) { - tapped = true; - }, - message: Message( - text: 'test', - user: User( - id: 'user-id', - ), - state: MessageState.sent, - ), - messageTheme: streamTheme.ownMessageTheme, - ), - ), - ), - ), - ), - ); - await tester.pumpAndSettle(); - - await tester.tap(find.text('Thread Reply')); - - expect(tapped, true); - }, - ); - - testWidgets( - 'tapping on edit should show the edit bottom sheet', - (WidgetTester tester) async { - final client = MockClient(); - final clientState = MockClientState(); - final channel = MockChannel(); - final channelState = MockChannelState(); - - when(() => client.state).thenReturn(clientState); - when(() => clientState.currentUser).thenReturn(OwnUser(id: 'user-id')); - when(() => channel.state).thenReturn(channelState); - when(channel.getRemainingCooldown).thenReturn(0); - - final themeData = ThemeData(); - final streamTheme = StreamChatThemeData.fromTheme(themeData); - - await tester.pumpWidget( - MaterialApp( - builder: (context, child) => StreamChat( - client: client, - streamChatThemeData: streamTheme, - child: child, - ), - theme: themeData, - home: Builder( - builder: (context) => StreamChannel( - showLoading: false, - channel: channel, - child: MessageActionsModal( - messageWidget: const Text('test'), - message: Message( - text: 'test', - user: User( - id: 'user-id', - ), - ), - messageTheme: streamTheme.ownMessageTheme, - onEditMessageTap: (message) => showEditMessageSheet( - context: context, - message: message, - channel: channel, - ), - ), - ), - ), - ), - ); - await tester.pumpAndSettle(); - - await tester.tap(find.text('Edit Message')); - - await tester.pumpAndSettle(); - - expect(find.byType(StreamMessageInput), findsOneWidget); - }, - ); - - testWidgets( - 'tapping on edit should show use the custom builder', - (WidgetTester tester) async { - final client = MockClient(); - final clientState = MockClientState(); - final channel = MockChannel(); - - when(() => client.state).thenReturn(clientState); - when(() => clientState.currentUser).thenReturn(OwnUser(id: 'user-id')); - - final themeData = ThemeData(); - final streamTheme = StreamChatThemeData.fromTheme(themeData); - - await tester.pumpWidget( - MaterialApp( - builder: (context, child) => StreamChat( - client: client, - streamChatThemeData: streamTheme, - child: child, - ), - theme: themeData, - home: StreamChannel( - showLoading: false, - channel: channel, - child: SizedBox( - child: MessageActionsModal( - messageWidget: const Text('test'), - editMessageInputBuilder: (context, m) => const Text('test'), - message: Message( - text: 'test', - user: User( - id: 'user-id', - ), - ), - messageTheme: streamTheme.ownMessageTheme, - ), - ), - ), - ), - ); - await tester.pumpAndSettle(); - - await tester.tap(find.text('Edit Message')); - - await tester.pumpAndSettle(); - - expect(find.text('test'), findsOneWidget); - }, - ); - - testWidgets( - 'tapping on copy should use the callback', - (WidgetTester tester) async { - final client = MockClient(); - final clientState = MockClientState(); - final channel = MockChannel(); - - when(() => client.state).thenReturn(clientState); - when(() => clientState.currentUser).thenReturn(OwnUser(id: 'user-id')); - - final themeData = ThemeData(); - final streamTheme = StreamChatThemeData.fromTheme(themeData); - - var tapped = false; - - await tester.pumpWidget( - MaterialApp( - builder: (context, child) => StreamChat( - client: client, - streamChatThemeData: streamTheme, - child: child, - ), - theme: themeData, - home: StreamChannel( - showLoading: false, - channel: channel, - child: SizedBox( - child: MessageActionsModal( - messageWidget: const Text('test'), - onCopyTap: (m) => tapped = true, - message: Message( - text: 'test', - user: User( - id: 'user-id', - ), - ), - messageTheme: streamTheme.ownMessageTheme, - ), - ), - ), - ), - ); - await tester.pumpAndSettle(); - - await tester.tap(find.text('Copy Message')); - - expect(tapped, true); - }, - ); - - testWidgets( - 'tapping on resend should call retry message', - (WidgetTester tester) async { - final client = MockClient(); - final clientState = MockClientState(); - final channel = MockChannel(); - - final message = Message( - state: MessageState.sendingFailed, - text: 'test', - user: User( - id: 'user-id', - ), - ); - - when(() => client.state).thenReturn(clientState); - when(() => clientState.currentUser).thenReturn(OwnUser(id: 'user-id')); - when(() => channel.retryMessage(message)) - .thenAnswer((_) async => SendMessageResponse()); - - final themeData = ThemeData(); - final streamTheme = StreamChatThemeData.fromTheme(themeData); - - await tester.pumpWidget( - MaterialApp( - builder: (context, child) => StreamChat( - client: client, - streamChatThemeData: streamTheme, - child: child, - ), - theme: themeData, - home: StreamChannel( - showLoading: false, - channel: channel, - child: SizedBox( - child: MessageActionsModal( - messageWidget: const Text('test'), - message: message, - messageTheme: streamTheme.ownMessageTheme, - ), - ), - ), - ), - ); - await tester.pumpAndSettle(); - - await tester.tap(find.text('Resend')); - - verify(() => channel.retryMessage(message)).called(1); - }, - ); - - testWidgets( - 'tapping on flag message should show the dialog', - (WidgetTester tester) async { - final client = MockClient(); - final clientState = MockClientState(); - final channel = MockChannel(); - - when(() => client.state).thenReturn(clientState); - when(() => clientState.currentUser).thenReturn(OwnUser(id: 'user-id')); - - final themeData = ThemeData(); - final streamTheme = StreamChatThemeData.fromTheme(themeData); - - await tester.pumpWidget( - MaterialApp( - builder: (context, child) => StreamChat( - client: client, - streamChatThemeData: streamTheme, - child: child, - ), - theme: themeData, - home: StreamChannel( - showLoading: false, - channel: channel, - child: SizedBox( - child: MessageActionsModal( - messageWidget: const Text('test'), - message: Message( - id: 'testid', - text: 'test', - user: User( - id: 'user-id', - ), - ), - messageTheme: streamTheme.ownMessageTheme, - ), - ), - ), - ), - ); - await tester.pumpAndSettle(); - - await tester.tap(find.text('Flag Message')); - await tester.pumpAndSettle(); - - expect(find.text('Flag Message'), findsNWidgets(2)); - - await tester.tap(find.text('FLAG')); - await tester.pumpAndSettle(); - - verify(() => client.flagMessage('testid')).called(1); - }, - ); - - testWidgets( - 'if flagging a message throws an error the error dialog should appear', - (WidgetTester tester) async { - final client = MockClient(); - final clientState = MockClientState(); - final channel = MockChannel(); - - when(() => client.state).thenReturn(clientState); - when(() => clientState.currentUser).thenReturn(OwnUser(id: 'user-id')); - when(() => client.flagMessage(any())) - .thenThrow(StreamChatNetworkError(ChatErrorCode.internalSystemError)); - - final themeData = ThemeData(); - final streamTheme = StreamChatThemeData.fromTheme(themeData); - - await tester.pumpWidget( - MaterialApp( - builder: (context, child) => StreamChat( - client: client, - streamChatThemeData: streamTheme, - child: child, - ), - theme: themeData, - home: StreamChannel( - showLoading: false, - channel: channel, - child: SizedBox( - child: MessageActionsModal( - messageWidget: const Text('test'), - message: Message( - id: 'testid', - text: 'test', - user: User( - id: 'user-id', - ), - ), - messageTheme: streamTheme.ownMessageTheme, - ), - ), - ), - ), - ); - await tester.pumpAndSettle(); - - await tester.tap(find.text('Flag Message')); - await tester.pumpAndSettle(); - - expect(find.text('Flag Message'), findsNWidgets(2)); - - await tester.tap(find.text('FLAG')); - await tester.pumpAndSettle(); - - expect(find.text('Something went wrong'), findsOneWidget); - }, - ); - - testWidgets( - 'if flagging an already flagged message no error should appear', - (WidgetTester tester) async { - final client = MockClient(); - final clientState = MockClientState(); - final channel = MockChannel(); - - when(() => client.state).thenReturn(clientState); - when(() => clientState.currentUser).thenReturn(OwnUser(id: 'user-id')); - when(() => client.flagMessage(any())) - .thenThrow(StreamChatNetworkError(ChatErrorCode.inputError)); - - final themeData = ThemeData(); - final streamTheme = StreamChatThemeData.fromTheme(themeData); - - await tester.pumpWidget( - MaterialApp( - builder: (context, child) => StreamChat( - client: client, - streamChatThemeData: streamTheme, - child: child, - ), - theme: themeData, - home: StreamChannel( - showLoading: false, - channel: channel, - child: SizedBox( - child: MessageActionsModal( - messageWidget: const Text('test'), - message: Message( - id: 'testid', - text: 'test', - user: User( - id: 'user-id', - ), - ), - messageTheme: streamTheme.ownMessageTheme, - ), - ), - ), - ), - ); - await tester.pumpAndSettle(); - - await tester.tap(find.text('Flag Message')); - await tester.pumpAndSettle(); - - expect(find.text('Flag Message'), findsNWidgets(2)); - - await tester.tap(find.text('FLAG')); - await tester.pumpAndSettle(); - - expect(find.text('Message flagged'), findsOneWidget); - }, - ); - - testWidgets( - 'tapping on delete message should call client.delete', - (WidgetTester tester) async { - final client = MockClient(); - final clientState = MockClientState(); - final channel = MockChannel(); - - when(() => client.state).thenReturn(clientState); - when(() => clientState.currentUser).thenReturn(OwnUser(id: 'user-id')); - - final themeData = ThemeData(); - final streamTheme = StreamChatThemeData.fromTheme(themeData); - - await tester.pumpWidget( - MaterialApp( - builder: (context, child) => StreamChat( - client: client, - streamChatThemeData: streamTheme, - child: child, - ), - theme: themeData, - home: StreamChannel( - showLoading: false, - channel: channel, - child: SizedBox( - child: MessageActionsModal( - messageWidget: const Text('test'), - message: Message( - id: 'testid', - text: 'test', - user: User( - id: 'user-id', - ), - ), - messageTheme: streamTheme.ownMessageTheme, - ), - ), - ), - ), - ); - await tester.pumpAndSettle(); - - await tester.tap(find.text('Delete Message')); - await tester.pumpAndSettle(); - - expect(find.text('Delete Message'), findsOneWidget); - - await tester.tap(find.text('DELETE')); - await tester.pumpAndSettle(); - - verify(() => channel.deleteMessage(any())).called(1); - }, - ); - - testWidgets( - 'tapping on delete message should call client.delete', - (WidgetTester tester) async { - final client = MockClient(); - final clientState = MockClientState(); - final channel = MockChannel(); - - when(() => client.state).thenReturn(clientState); - when(() => clientState.currentUser).thenReturn(OwnUser(id: 'user-id')); - when(() => channel.deleteMessage(any())) - .thenThrow(StreamChatNetworkError(ChatErrorCode.internalSystemError)); - - final themeData = ThemeData(); - final streamTheme = StreamChatThemeData.fromTheme(themeData); - - await tester.pumpWidget( - MaterialApp( - builder: (context, child) => StreamChat( - client: client, - streamChatThemeData: streamTheme, - child: child, - ), - theme: themeData, - home: StreamChannel( - showLoading: false, - channel: channel, - child: SizedBox( - child: MessageActionsModal( - messageWidget: const Text('test'), - message: Message( - id: 'testid', - text: 'test', - user: User( - id: 'user-id', - ), - ), - messageTheme: streamTheme.ownMessageTheme, - ), - ), - ), - ), - ); - await tester.pumpAndSettle(); - - await tester.tap(find.text('Delete Message')); - await tester.pumpAndSettle(); - - expect(find.text('Delete Message'), findsOneWidget); - - await tester.tap(find.text('DELETE')); - await tester.pumpAndSettle(); - - expect(find.text('Something went wrong'), findsOneWidget); - }, - ); - - testWidgets( - 'tapping on unread message should call client.unread', - (WidgetTester tester) async { - final client = MockClient(); - final clientState = MockClientState(); - final channel = MockChannel(); - - when(() => client.state).thenReturn(clientState); - when(() => clientState.currentUser).thenReturn(OwnUser(id: 'user-id')); - - final themeData = ThemeData(); - final streamTheme = StreamChatThemeData.fromTheme(themeData); - - await tester.pumpWidget( - MaterialApp( - builder: (context, child) => StreamChat( - client: client, - streamChatThemeData: streamTheme, - child: child, - ), - theme: themeData, - home: Scaffold( - body: StreamChannel( - showLoading: false, - channel: channel, - child: SizedBox( - child: MessageActionsModal( - messageWidget: const Text('test'), - message: Message( - id: 'testid', - text: 'test', - user: User( - id: 'user-id', - ), - ), - messageTheme: streamTheme.ownMessageTheme, - ), - ), - ), - ), - ), - ); - await tester.pumpAndSettle(); - - await tester.tap(find.text('Mark as Unread')); - await tester.pumpAndSettle(); - - verify(() => channel.markUnread(any())).called(1); - }, - ); -} diff --git a/packages/stream_chat_flutter/test/src/message_input/attachment_button_test.dart b/packages/stream_chat_flutter/test/src/message_input/attachment_button_test.dart deleted file mode 100644 index 7ac928d201..0000000000 --- a/packages/stream_chat_flutter/test/src/message_input/attachment_button_test.dart +++ /dev/null @@ -1,90 +0,0 @@ -import 'package:alchemist/alchemist.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:stream_chat_flutter/src/message_input/attachment_button.dart'; -import 'package:stream_chat_flutter/stream_chat_flutter.dart'; - -import '../material_app_wrapper.dart'; - -void main() { - testWidgets('AttachmentButton onPressed works', (tester) async { - var count = 0; - await tester.pumpWidget( - MaterialApp( - home: Scaffold( - body: Center( - child: AttachmentButton( - color: Colors.red, - onPressed: () { - count++; - }, - ), - ), - ), - ), - ); - - final button = find.byType(IconButton); - expect(button, findsOneWidget); - expect(find.byType(StreamSvgIcon), findsOneWidget); - await tester.tap(button); - expect(count, 1); - }); - - testWidgets('AttachmentButton should accept icon', (tester) async { - const icon = Icon(Icons.attachment); - await tester.pumpWidget( - MaterialApp( - home: Scaffold( - body: Center( - child: AttachmentButton( - icon: icon, - onPressed: () {}, - ), - ), - ), - ), - ); - - expect(find.byIcon(Icons.attachment), findsOneWidget); - }); - - testWidgets('AttachmentButton should accept color', (tester) async { - await tester.pumpWidget( - MaterialApp( - home: Scaffold( - body: Center( - child: AttachmentButton( - color: Colors.red, - onPressed: () {}, - ), - ), - ), - ), - ); - - final buttonFinder = find.byType(AttachmentButton); - expect(buttonFinder, findsOneWidget); - - final button = tester.widget(buttonFinder); - expect(button.color, Colors.red); - }); - - goldenTest( - 'golden test for AttachmentButton', - fileName: 'attachment_button_0', - constraints: const BoxConstraints.tightFor(width: 50, height: 50), - builder: () => MaterialAppWrapper( - home: Scaffold( - body: Center( - child: AttachmentButton( - color: StreamChatThemeData.light() - .messageInputTheme - .actionButtonIdleColor, - onPressed: () {}, - ), - ), - ), - ), - ); -} diff --git a/packages/stream_chat_flutter/test/src/message_input/attachment_picker/stream_attachment_picker_test.dart b/packages/stream_chat_flutter/test/src/message_input/attachment_picker/stream_attachment_picker_test.dart new file mode 100644 index 0000000000..ca2ff3baf9 --- /dev/null +++ b/packages/stream_chat_flutter/test/src/message_input/attachment_picker/stream_attachment_picker_test.dart @@ -0,0 +1,333 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:stream_chat_flutter/stream_chat_flutter.dart'; + +void main() { + group('tabbedAttachmentPickerBuilder', () { + group('optionsBuilder', () { + testWidgets( + 'should call optionsBuilder with default options', + (tester) async { + var builderCalled = false; + int? defaultOptionsCount; + + final controller = StreamAttachmentPickerController(); + addTearDown(controller.dispose); + + await tester.pumpWidget( + _wrapWithStreamChatApp( + Builder( + builder: (context) { + return SizedBox( + height: 400, + child: tabbedAttachmentPickerBuilder( + context: context, + controller: controller, + optionsBuilder: (context, defaultOptions) { + builderCalled = true; + defaultOptionsCount = defaultOptions.length; + return defaultOptions; + }, + ), + ); + }, + ), + ), + ); + + await tester.pumpAndSettle(); + + expect(builderCalled, isTrue); + expect(defaultOptionsCount, isNotNull); + expect(defaultOptionsCount, greaterThan(0)); + }, + ); + + testWidgets( + 'should allow filtering default options', + (tester) async { + int? defaultOptionsCount; + + final controller = StreamAttachmentPickerController(); + addTearDown(controller.dispose); + + await tester.pumpWidget( + _wrapWithStreamChatApp( + Builder( + builder: (context) { + return SizedBox( + height: 400, + child: tabbedAttachmentPickerBuilder( + context: context, + controller: controller, + optionsBuilder: (context, defaultOptions) { + defaultOptionsCount = defaultOptions.length; + return [defaultOptions.first]; + }, + ), + ); + }, + ), + ), + ); + + await tester.pumpAndSettle(); + + final picker = tester.widget( + find.byType(StreamTabbedAttachmentPicker), + ); + + expect(picker.options.length, equals(1)); + expect(picker.options.length, lessThan(defaultOptionsCount!)); + }, + ); + + testWidgets( + 'should throw ArgumentError when wrong option types are provided', + (tester) async { + final controller = StreamAttachmentPickerController(); + addTearDown(controller.dispose); + + await tester.pumpWidget( + _wrapWithStreamChatApp( + Builder( + builder: (context) { + return SizedBox( + height: 400, + child: tabbedAttachmentPickerBuilder( + context: context, + controller: controller, + optionsBuilder: (context, defaultOptions) { + return [ + SystemAttachmentPickerOption( + key: 'wrong', + icon: Icons.error, + title: 'Wrong', + supportedTypes: [AttachmentPickerType.images], + onTap: (context, controller) async {}, + ), + ]; + }, + ), + ); + }, + ), + ), + ); + + expect(tester.takeException(), isA()); + }, + ); + }); + + group('allowedTypes', () { + testWidgets( + 'should filter options based on allowedTypes', + (tester) async { + final controller = StreamAttachmentPickerController(); + addTearDown(controller.dispose); + + await tester.pumpWidget( + _wrapWithStreamChatApp( + Builder( + builder: (context) { + return SizedBox( + height: 400, + child: tabbedAttachmentPickerBuilder( + context: context, + controller: controller, + allowedTypes: [AttachmentPickerType.images], + ), + ); + }, + ), + ), + ); + + await tester.pumpAndSettle(); + + final picker = tester.widget( + find.byType(StreamTabbedAttachmentPicker), + ); + + expect( + picker.options.every( + (option) => option.supportedTypes.contains(AttachmentPickerType.images), + ), + isTrue, + ); + }, + ); + }); + }); + + group('systemAttachmentPickerBuilder', () { + group('optionsBuilder', () { + testWidgets( + 'should call optionsBuilder with default options', + (tester) async { + var builderCalled = false; + + final controller = StreamAttachmentPickerController(); + addTearDown(controller.dispose); + + await tester.pumpWidget( + _wrapWithStreamChatApp( + Builder( + builder: (context) { + return systemAttachmentPickerBuilder( + context: context, + controller: controller, + optionsBuilder: (context, defaultOptions) { + builderCalled = true; + return defaultOptions; + }, + ); + }, + ), + ), + ); + + await tester.pumpAndSettle(); + + expect(builderCalled, isTrue); + expect( + find.byType(StreamSystemAttachmentPicker), + findsOneWidget, + ); + }, + ); + + testWidgets( + 'should allow adding custom system picker options', + (tester) async { + final controller = StreamAttachmentPickerController(); + addTearDown(controller.dispose); + + await tester.pumpWidget( + _wrapWithStreamChatApp( + Builder( + builder: (context) { + return systemAttachmentPickerBuilder( + context: context, + controller: controller, + optionsBuilder: (context, defaultOptions) { + return [ + ...defaultOptions, + SystemAttachmentPickerOption( + key: 'custom-upload', + icon: Icons.cloud_upload, + title: 'Custom Upload', + supportedTypes: [AttachmentPickerType.files], + onTap: (context, controller) async {}, + ), + ]; + }, + ); + }, + ), + ), + ); + + await tester.pumpAndSettle(); + + expect(find.text('Custom Upload'), findsOneWidget); + }, + ); + + testWidgets( + 'should throw ArgumentError when wrong option types are provided', + (tester) async { + final controller = StreamAttachmentPickerController(); + addTearDown(controller.dispose); + + await tester.pumpWidget( + _wrapWithStreamChatApp( + Builder( + builder: (context) { + return systemAttachmentPickerBuilder( + context: context, + controller: controller, + optionsBuilder: (context, defaultOptions) { + return [ + TabbedAttachmentPickerOption( + key: 'wrong', + icon: Icons.error, + title: 'Wrong', + supportedTypes: [AttachmentPickerType.images], + optionViewBuilder: (context, controller) { + return const Text('Wrong'); + }, + ), + ]; + }, + ); + }, + ), + ), + ); + + expect(tester.takeException(), isA()); + }, + ); + }); + + group('allowedTypes', () { + testWidgets( + 'should filter options based on allowedTypes', + (tester) async { + final controller = StreamAttachmentPickerController(); + addTearDown(controller.dispose); + + await tester.pumpWidget( + _wrapWithStreamChatApp( + Builder( + builder: (context) { + return systemAttachmentPickerBuilder( + context: context, + controller: controller, + allowedTypes: [AttachmentPickerType.images], + ); + }, + ), + ), + ); + + await tester.pumpAndSettle(); + + final picker = tester.widget( + find.byType(StreamSystemAttachmentPicker), + ); + + expect( + picker.options.every( + (option) => option.supportedTypes.contains(AttachmentPickerType.images), + ), + isTrue, + ); + }, + ); + }); + }); +} + +Widget _wrapWithStreamChatApp( + Widget widget, { + Brightness? brightness, +}) { + return MaterialApp( + debugShowCheckedModeBanner: false, + home: StreamChatTheme( + data: StreamChatThemeData(brightness: brightness), + child: Builder( + builder: (context) { + final theme = StreamChatTheme.of(context); + return Scaffold( + backgroundColor: theme.colorTheme.appBg, + body: widget, + ); + }, + ), + ), + ); +} diff --git a/packages/stream_chat_flutter/test/src/message_input/audio_recorder/audio_recorder_controller_test.dart b/packages/stream_chat_flutter/test/src/message_input/audio_recorder/audio_recorder_controller_test.dart index 86ad93a548..82fb045413 100644 --- a/packages/stream_chat_flutter/test/src/message_input/audio_recorder/audio_recorder_controller_test.dart +++ b/packages/stream_chat_flutter/test/src/message_input/audio_recorder/audio_recorder_controller_test.dart @@ -33,8 +33,7 @@ void main() { when(() => mockRecorder.dispose()).thenAnswer((_) async {}); amplitudeController = PublishSubject(); - when(() => mockRecorder.onAmplitudeChanged(any())) - .thenAnswer((_) => amplitudeController.stream); + when(() => mockRecorder.onAmplitudeChanged(any())).thenAnswer((_) => amplitudeController.stream); controller = StreamAudioRecorderController.raw( config: config, @@ -53,30 +52,43 @@ void main() { group('startRecord', () { setUp(() { - when(() => mockRecorder.start(config, path: any(named: 'path'))) - .thenAnswer((_) async {}); + when(() => mockRecorder.start(config, path: any(named: 'path'))).thenAnswer((_) async {}); }); test( - 'starts recording when permission is granted', + 'starts recording when permission is already granted', () async { - when(() => mockRecorder.hasPermission()).thenAnswer((_) async => true); + when(() => mockRecorder.hasPermission(request: false)).thenAnswer((_) async => true); await controller.startRecord(); expect(controller.value, isA()); verify(() => mockRecorder.start(config, path: any(named: 'path'))); + // Should not prompt for permission when already granted. + verifyNever(() => mockRecorder.hasPermission(request: true)); }, ); - test('does not start recording when permission is denied', () async { - when(() => mockRecorder.hasPermission()).thenAnswer((_) async => false); + test('does not start recording when permission is not pre-granted', () async { + when(() => mockRecorder.hasPermission(request: false)).thenAnswer((_) async => false); + when(() => mockRecorder.hasPermission(request: true)).thenAnswer((_) async => false); await controller.startRecord(); expect(controller.value, isA()); verifyNever(() => mockRecorder.start(config, path: any(named: 'path'))); }); + + test('requests permission when not pre-granted', () async { + when(() => mockRecorder.hasPermission(request: false)).thenAnswer((_) async => false); + when(() => mockRecorder.hasPermission(request: true)).thenAnswer((_) async => false); + + await controller.startRecord(); + + // Should first check without prompting, then prompt the user. + verify(() => mockRecorder.hasPermission(request: false)).called(1); + verify(() => mockRecorder.hasPermission(request: true)).called(1); + }); }); group('stopRecord', () { @@ -84,10 +96,9 @@ void main() { const testPath = '$pathPrefix/audio.m4a'; setUp(() async { - when(() => mockRecorder.hasPermission()).thenAnswer((_) async => true); + when(() => mockRecorder.hasPermission(request: false)).thenAnswer((_) async => true); when(() => mockRecorder.stop()).thenAnswer((_) async => testPath); - when(() => mockRecorder.start(config, path: any(named: 'path'))) - .thenAnswer((_) async {}); + when(() => mockRecorder.start(config, path: any(named: 'path'))).thenAnswer((_) async {}); }); test('stops recording and updates state to stopped', () async { @@ -120,10 +131,9 @@ void main() { group('cancelRecord', () { setUp(() { - when(() => mockRecorder.hasPermission()).thenAnswer((_) async => true); + when(() => mockRecorder.hasPermission(request: false)).thenAnswer((_) async => true); when(() => mockRecorder.cancel()).thenAnswer((_) async {}); - when(() => mockRecorder.start(config, path: any(named: 'path'))) - .thenAnswer((_) async {}); + when(() => mockRecorder.start(config, path: any(named: 'path'))).thenAnswer((_) async {}); }); test('cancels recording and returns to idle state', () async { @@ -207,9 +217,8 @@ void main() { group('amplitude changes', () { setUp(() { - when(() => mockRecorder.hasPermission()).thenAnswer((_) async => true); - when(() => mockRecorder.start(config, path: any(named: 'path'))) - .thenAnswer((_) async {}); + when(() => mockRecorder.hasPermission(request: false)).thenAnswer((_) async => true); + when(() => mockRecorder.start(config, path: any(named: 'path'))).thenAnswer((_) async {}); }); test('updates waveform data when amplitude changes', () async { diff --git a/packages/stream_chat_flutter/test/src/message_input/audio_recorder/goldens/ci/stream_audio_recorder_button_idle_dark.png b/packages/stream_chat_flutter/test/src/message_input/audio_recorder/goldens/ci/stream_audio_recorder_button_idle_dark.png index a928f3e658..4541388576 100644 Binary files a/packages/stream_chat_flutter/test/src/message_input/audio_recorder/goldens/ci/stream_audio_recorder_button_idle_dark.png and b/packages/stream_chat_flutter/test/src/message_input/audio_recorder/goldens/ci/stream_audio_recorder_button_idle_dark.png differ diff --git a/packages/stream_chat_flutter/test/src/message_input/audio_recorder/goldens/ci/stream_audio_recorder_button_idle_light.png b/packages/stream_chat_flutter/test/src/message_input/audio_recorder/goldens/ci/stream_audio_recorder_button_idle_light.png index 68f02ee6f6..045d20ba78 100644 Binary files a/packages/stream_chat_flutter/test/src/message_input/audio_recorder/goldens/ci/stream_audio_recorder_button_idle_light.png and b/packages/stream_chat_flutter/test/src/message_input/audio_recorder/goldens/ci/stream_audio_recorder_button_idle_light.png differ diff --git a/packages/stream_chat_flutter/test/src/message_input/audio_recorder/goldens/ci/stream_audio_recorder_button_recording_hold_dark.png b/packages/stream_chat_flutter/test/src/message_input/audio_recorder/goldens/ci/stream_audio_recorder_button_recording_hold_dark.png index 2eafbc2070..fc09c9cf6b 100644 Binary files a/packages/stream_chat_flutter/test/src/message_input/audio_recorder/goldens/ci/stream_audio_recorder_button_recording_hold_dark.png and b/packages/stream_chat_flutter/test/src/message_input/audio_recorder/goldens/ci/stream_audio_recorder_button_recording_hold_dark.png differ diff --git a/packages/stream_chat_flutter/test/src/message_input/audio_recorder/goldens/ci/stream_audio_recorder_button_recording_hold_light.png b/packages/stream_chat_flutter/test/src/message_input/audio_recorder/goldens/ci/stream_audio_recorder_button_recording_hold_light.png index 2865138e0c..1899c36723 100644 Binary files a/packages/stream_chat_flutter/test/src/message_input/audio_recorder/goldens/ci/stream_audio_recorder_button_recording_hold_light.png and b/packages/stream_chat_flutter/test/src/message_input/audio_recorder/goldens/ci/stream_audio_recorder_button_recording_hold_light.png differ diff --git a/packages/stream_chat_flutter/test/src/message_input/audio_recorder/goldens/ci/stream_audio_recorder_button_recording_locked_dark.png b/packages/stream_chat_flutter/test/src/message_input/audio_recorder/goldens/ci/stream_audio_recorder_button_recording_locked_dark.png index b00d4f798e..18abf051e8 100644 Binary files a/packages/stream_chat_flutter/test/src/message_input/audio_recorder/goldens/ci/stream_audio_recorder_button_recording_locked_dark.png and b/packages/stream_chat_flutter/test/src/message_input/audio_recorder/goldens/ci/stream_audio_recorder_button_recording_locked_dark.png differ diff --git a/packages/stream_chat_flutter/test/src/message_input/audio_recorder/goldens/ci/stream_audio_recorder_button_recording_locked_light.png b/packages/stream_chat_flutter/test/src/message_input/audio_recorder/goldens/ci/stream_audio_recorder_button_recording_locked_light.png index 5669b3a7b7..09f0f9fcce 100644 Binary files a/packages/stream_chat_flutter/test/src/message_input/audio_recorder/goldens/ci/stream_audio_recorder_button_recording_locked_light.png and b/packages/stream_chat_flutter/test/src/message_input/audio_recorder/goldens/ci/stream_audio_recorder_button_recording_locked_light.png differ diff --git a/packages/stream_chat_flutter/test/src/message_input/audio_recorder/goldens/ci/stream_audio_recorder_button_recording_stopped_dark.png b/packages/stream_chat_flutter/test/src/message_input/audio_recorder/goldens/ci/stream_audio_recorder_button_recording_stopped_dark.png index f89f3cffd1..352d20c9ae 100644 Binary files a/packages/stream_chat_flutter/test/src/message_input/audio_recorder/goldens/ci/stream_audio_recorder_button_recording_stopped_dark.png and b/packages/stream_chat_flutter/test/src/message_input/audio_recorder/goldens/ci/stream_audio_recorder_button_recording_stopped_dark.png differ diff --git a/packages/stream_chat_flutter/test/src/message_input/audio_recorder/goldens/ci/stream_audio_recorder_button_recording_stopped_light.png b/packages/stream_chat_flutter/test/src/message_input/audio_recorder/goldens/ci/stream_audio_recorder_button_recording_stopped_light.png index 350827d356..296ad93c41 100644 Binary files a/packages/stream_chat_flutter/test/src/message_input/audio_recorder/goldens/ci/stream_audio_recorder_button_recording_stopped_light.png and b/packages/stream_chat_flutter/test/src/message_input/audio_recorder/goldens/ci/stream_audio_recorder_button_recording_stopped_light.png differ diff --git a/packages/stream_chat_flutter/test/src/message_input/audio_recorder/stream_audio_recorder_test.dart b/packages/stream_chat_flutter/test/src/message_input/audio_recorder/stream_audio_recorder_test.dart index 736649f03a..d3c392bdb7 100644 --- a/packages/stream_chat_flutter/test/src/message_input/audio_recorder/stream_audio_recorder_test.dart +++ b/packages/stream_chat_flutter/test/src/message_input/audio_recorder/stream_audio_recorder_test.dart @@ -3,9 +3,8 @@ import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; import 'package:flutter_portal/flutter_portal.dart'; import 'package:flutter_test/flutter_test.dart'; -import 'package:stream_chat_flutter/src/misc/audio_waveform.dart'; import 'package:stream_chat_flutter/stream_chat_flutter.dart'; -import '../../utils/finders.dart'; +import 'package:stream_core_flutter/stream_core_flutter.dart'; void main() { group('StreamAudioRecorderButton', () { @@ -32,7 +31,7 @@ void main() { ), ); - expect(find.bySvgIcon(StreamSvgIcons.mic), findsOneWidget); + expect(find.byIcon(StreamIconData.voice), findsOneWidget); }, ); @@ -176,9 +175,9 @@ void main() { ); expect(find.byType(StreamAudioWaveform), findsOneWidget); - expect(find.bySvgIcon(StreamSvgIcons.delete), findsOneWidget); - expect(find.bySvgIcon(StreamSvgIcons.stop), findsOneWidget); - expect(find.bySvgIcon(StreamSvgIcons.checkSend), findsOneWidget); + expect(find.byIcon(StreamIconData.delete), findsOneWidget); + expect(find.byIcon(StreamIconData.stopFill), findsOneWidget); + expect(find.byIcon(StreamIconData.checkmark), findsOneWidget); }, ); @@ -200,7 +199,7 @@ void main() { ), ); - await tester.tap(find.bySvgIcon(StreamSvgIcons.delete)); + await tester.tap(find.byIcon(StreamIconData.delete)); expect(onRecordCancelCalled, true); }, @@ -224,7 +223,7 @@ void main() { ), ); - await tester.tap(find.bySvgIcon(StreamSvgIcons.stop)); + await tester.tap(find.byIcon(StreamIconData.stopFill)); expect(onRecordStopCalled, true); }, @@ -248,7 +247,7 @@ void main() { ), ); - await tester.tap(find.bySvgIcon(StreamSvgIcons.checkSend)); + await tester.tap(find.byIcon(StreamIconData.checkmark)); expect(onRecordFinishCalled, true); }, @@ -270,8 +269,8 @@ void main() { expect(find.byType(PlaybackControlButton), findsOneWidget); expect(find.byType(PlaybackTimerText), findsOneWidget); expect(find.byType(StreamAudioWaveformSlider), findsOneWidget); - expect(find.bySvgIcon(StreamSvgIcons.delete), findsOneWidget); - expect(find.bySvgIcon(StreamSvgIcons.checkSend), findsOneWidget); + expect(find.byIcon(StreamIconData.delete), findsOneWidget); + expect(find.byIcon(StreamIconData.checkmark), findsOneWidget); }, ); @@ -292,7 +291,7 @@ void main() { ), ); - await tester.tap(find.bySvgIcon(StreamSvgIcons.checkSend)); + await tester.tap(find.byIcon(StreamIconData.checkmark)); expect(onRecordFinishCalled, true); }, @@ -315,7 +314,7 @@ void main() { ), ); - await tester.tap(find.bySvgIcon(StreamSvgIcons.delete)); + await tester.tap(find.byIcon(StreamIconData.delete)); expect(onRecordCancelCalled, true); }, @@ -455,7 +454,7 @@ void main() { ), ); - await tester.tap(find.bySvgIcon(StreamSvgIcons.stop)); + await tester.tap(find.byIcon(StreamIconData.stopFill)); expect(feedbackCalled, isTrue); }, @@ -482,7 +481,7 @@ void main() { ), ); - await tester.tap(find.bySvgIcon(StreamSvgIcons.delete)); + await tester.tap(find.byIcon(StreamIconData.delete)); expect(feedbackCalled, isTrue); }, @@ -509,7 +508,7 @@ void main() { ), ); - await tester.tap(find.bySvgIcon(StreamSvgIcons.checkSend)); + await tester.tap(find.byIcon(StreamIconData.checkmark)); expect(feedbackCalled, isTrue); }, @@ -535,7 +534,7 @@ void main() { ), ); - await tester.tap(find.bySvgIcon(StreamSvgIcons.delete)); + await tester.tap(find.byIcon(StreamIconData.delete)); expect(feedbackCalled, isTrue); }, @@ -561,7 +560,7 @@ void main() { ), ); - await tester.tap(find.bySvgIcon(StreamSvgIcons.checkSend)); + await tester.tap(find.byIcon(StreamIconData.checkmark)); expect(feedbackCalled, isTrue); }, @@ -637,8 +636,7 @@ void main() { goldenTest( '[${brightness.name}] -> should look fine in recording hold state', - fileName: - 'stream_audio_recorder_button_recording_hold_${brightness.name}', + fileName: 'stream_audio_recorder_button_recording_hold_${brightness.name}', constraints: const BoxConstraints.tightFor(width: 400, height: 160), builder: () => _wrapWithStreamChatApp( brightness: brightness, @@ -653,8 +651,7 @@ void main() { goldenTest( '[${brightness.name}] -> should look fine in recording locked state', - fileName: - 'stream_audio_recorder_button_recording_locked_${brightness.name}', + fileName: 'stream_audio_recorder_button_recording_locked_${brightness.name}', constraints: const BoxConstraints.tightFor(width: 400, height: 160), builder: () => _wrapWithStreamChatApp( brightness: brightness, @@ -672,8 +669,7 @@ void main() { goldenTest( '[${brightness.name}] -> should look fine in recording stopped state', - fileName: - 'stream_audio_recorder_button_recording_stopped_${brightness.name}', + fileName: 'stream_audio_recorder_button_recording_stopped_${brightness.name}', constraints: const BoxConstraints.tightFor(width: 400, height: 160), builder: () => _wrapWithStreamChatApp( brightness: brightness, @@ -695,24 +691,27 @@ Widget _wrapWithStreamChatApp( Brightness? brightness, }) { return MaterialApp( + theme: ThemeData(brightness: brightness), debugShowCheckedModeBanner: false, home: Portal( child: StreamChatTheme( data: StreamChatThemeData(brightness: brightness), - child: Builder(builder: (context) { - final theme = StreamChatTheme.of(context); - return Scaffold( - backgroundColor: theme.colorTheme.appBg, - bottomNavigationBar: Material( - elevation: 10, - color: theme.colorTheme.barsBg, - child: Padding( - padding: const EdgeInsets.all(8), - child: widget, + child: Builder( + builder: (context) { + final theme = StreamChatTheme.of(context); + return Scaffold( + backgroundColor: theme.colorTheme.appBg, + bottomNavigationBar: Material( + elevation: 10, + color: theme.colorTheme.barsBg, + child: Padding( + padding: const EdgeInsets.all(8), + child: widget, + ), ), - ), - ); - }), + ); + }, + ), ), ), ); diff --git a/packages/stream_chat_flutter/test/src/message_input/clear_input_item_test.dart b/packages/stream_chat_flutter/test/src/message_input/clear_input_item_test.dart index 68d1f430fd..8f896a164d 100644 --- a/packages/stream_chat_flutter/test/src/message_input/clear_input_item_test.dart +++ b/packages/stream_chat_flutter/test/src/message_input/clear_input_item_test.dart @@ -28,7 +28,7 @@ void main() { final button = find.byType(RawMaterialButton); expect(button, findsOneWidget); - expect(find.byType(StreamSvgIcon), findsOneWidget); + expect(find.byType(Icon), findsOneWidget); await tester.tap(button); expect(count, 1); }); diff --git a/packages/stream_chat_flutter/test/src/message_input/dm_checkbox_test.dart b/packages/stream_chat_flutter/test/src/message_input/dm_checkbox_test.dart deleted file mode 100644 index 36e67a15e6..0000000000 --- a/packages/stream_chat_flutter/test/src/message_input/dm_checkbox_test.dart +++ /dev/null @@ -1,134 +0,0 @@ -import 'package:alchemist/alchemist.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:stream_chat_flutter/src/message_input/dm_checkbox.dart'; -import 'package:stream_chat_flutter/stream_chat_flutter.dart'; - -import '../material_app_wrapper.dart'; - -@Deprecated('') -void main() { - testWidgets('DmCheckbox onTap works', (tester) async { - var count = 0; - await tester.pumpWidget( - MaterialApp( - home: StreamChatTheme( - data: StreamChatThemeData.light(), - child: Scaffold( - body: Center( - child: DmCheckbox( - foregroundDecoration: BoxDecoration( - border: Border.all( - color: StreamChatThemeData.light() - .colorTheme - .textHighEmphasis - // ignore: deprecated_member_use - .withOpacity(0.5), - width: 2, - ), - borderRadius: BorderRadius.circular(3), - ), - color: StreamChatThemeData.light().colorTheme.accentPrimary, - onTap: () { - count++; - }, - crossFadeState: CrossFadeState.showFirst, - ), - ), - ), - ), - ), - ); - - expect(find.byType(AnimatedCrossFade), findsOneWidget); - final checkbox = find.byType(InkWell); - await tester.tap(checkbox); - await tester.pumpAndSettle(); - expect(count, 1); - }); - - goldenTest( - 'golden test for checked DmCheckbox with border', - fileName: 'dm_checkbox_0', - constraints: const BoxConstraints.tightFor(width: 200, height: 50), - builder: () => MaterialAppWrapper( - home: StreamChatTheme( - data: StreamChatThemeData.light(), - child: Scaffold( - body: Center( - child: DmCheckbox( - foregroundDecoration: BoxDecoration( - border: Border.all( - color: StreamChatThemeData.light() - .colorTheme - .textHighEmphasis - // ignore: deprecated_member_use - .withOpacity(0.5), - width: 2, - ), - borderRadius: BorderRadius.circular(3), - ), - color: StreamChatThemeData.light().colorTheme.accentPrimary, - onTap: () {}, - crossFadeState: CrossFadeState.showFirst, - ), - ), - ), - ), - ), - ); - - goldenTest( - 'golden test for checked DmCheckbox without border', - fileName: 'dm_checkbox_1', - constraints: const BoxConstraints.tightFor(width: 200, height: 50), - builder: () => MaterialAppWrapper( - home: StreamChatTheme( - data: StreamChatThemeData.light(), - child: Scaffold( - body: Center( - child: DmCheckbox( - foregroundDecoration: BoxDecoration( - borderRadius: BorderRadius.circular(3), - ), - color: StreamChatThemeData.light().colorTheme.accentPrimary, - onTap: () {}, - crossFadeState: CrossFadeState.showFirst, - ), - ), - ), - ), - ), - ); - - goldenTest( - 'golden test for unchecked DmCheckbox with border', - fileName: 'dm_checkbox_2', - constraints: const BoxConstraints.tightFor(width: 200, height: 50), - builder: () => MaterialAppWrapper( - home: StreamChatTheme( - data: StreamChatThemeData.light(), - child: Scaffold( - body: Center( - child: DmCheckbox( - foregroundDecoration: BoxDecoration( - border: Border.all( - color: StreamChatThemeData.light() - .colorTheme - .textHighEmphasis - // ignore: deprecated_member_use - .withOpacity(0.5), - width: 2, - ), - borderRadius: BorderRadius.circular(3), - ), - color: StreamChatThemeData.light().colorTheme.barsBg, - onTap: () {}, - crossFadeState: CrossFadeState.showSecond, - ), - ), - ), - ), - ), - ); -} diff --git a/packages/stream_chat_flutter/test/src/bottom_sheets/error_alert_sheet_test.dart b/packages/stream_chat_flutter/test/src/message_input/error_alert_sheet_test.dart similarity index 86% rename from packages/stream_chat_flutter/test/src/bottom_sheets/error_alert_sheet_test.dart rename to packages/stream_chat_flutter/test/src/message_input/error_alert_sheet_test.dart index 7dc89683b8..32b08862e3 100644 --- a/packages/stream_chat_flutter/test/src/bottom_sheets/error_alert_sheet_test.dart +++ b/packages/stream_chat_flutter/test/src/message_input/error_alert_sheet_test.dart @@ -2,6 +2,7 @@ import 'package:alchemist/alchemist.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:stream_chat_flutter/src/message_input/error_alert_sheet.dart'; import 'package:stream_chat_flutter/stream_chat_flutter.dart'; import '../material_app_wrapper.dart'; @@ -9,18 +10,15 @@ import '../mocks.dart'; void main() { group('ErrorAlertSheet tests', () { - const methodChannel = - MethodChannel('dev.fluttercommunity.plus/connectivity_status'); + const methodChannel = MethodChannel('dev.fluttercommunity.plus/connectivity_status'); setUp(() { - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger - .setMockMethodCallHandler(methodChannel, - (MethodCall methodCall) async { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.setMockMethodCallHandler(methodChannel, ( + MethodCall methodCall, + ) async { if (methodCall.method == 'listen') { try { - await TestDefaultBinaryMessengerBinding - .instance.defaultBinaryMessenger - .handlePlatformMessage( + await TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.handlePlatformMessage( methodChannel.name, methodChannel.codec.encodeSuccessEnvelope(['wifi']), (_) {}, @@ -93,8 +91,7 @@ void main() { ); tearDown(() { - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger - .setMockMethodCallHandler(methodChannel, null); + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.setMockMethodCallHandler(methodChannel, null); }); }); } diff --git a/packages/stream_chat_flutter/test/src/message_input/goldens/ci/attachment_button_0.png b/packages/stream_chat_flutter/test/src/message_input/goldens/ci/attachment_button_0.png index cac3a620ac..1292f30fe9 100644 Binary files a/packages/stream_chat_flutter/test/src/message_input/goldens/ci/attachment_button_0.png and b/packages/stream_chat_flutter/test/src/message_input/goldens/ci/attachment_button_0.png differ diff --git a/packages/stream_chat_flutter/test/src/message_input/goldens/ci/clear_input_item_0.png b/packages/stream_chat_flutter/test/src/message_input/goldens/ci/clear_input_item_0.png index 989e925b4e..7976f061ed 100644 Binary files a/packages/stream_chat_flutter/test/src/message_input/goldens/ci/clear_input_item_0.png and b/packages/stream_chat_flutter/test/src/message_input/goldens/ci/clear_input_item_0.png differ diff --git a/packages/stream_chat_flutter/test/src/message_input/goldens/ci/command_button_0.png b/packages/stream_chat_flutter/test/src/message_input/goldens/ci/command_button_0.png index 588d580602..22ee636463 100644 Binary files a/packages/stream_chat_flutter/test/src/message_input/goldens/ci/command_button_0.png and b/packages/stream_chat_flutter/test/src/message_input/goldens/ci/command_button_0.png differ diff --git a/packages/stream_chat_flutter/test/src/message_input/goldens/ci/countdown_button_0.png b/packages/stream_chat_flutter/test/src/message_input/goldens/ci/countdown_button_0.png index 4425571a1c..8f5524d281 100644 Binary files a/packages/stream_chat_flutter/test/src/message_input/goldens/ci/countdown_button_0.png and b/packages/stream_chat_flutter/test/src/message_input/goldens/ci/countdown_button_0.png differ diff --git a/packages/stream_chat_flutter/test/src/message_input/goldens/ci/error_alert_sheet_0.png b/packages/stream_chat_flutter/test/src/message_input/goldens/ci/error_alert_sheet_0.png new file mode 100644 index 0000000000..edb2795c3a Binary files /dev/null and b/packages/stream_chat_flutter/test/src/message_input/goldens/ci/error_alert_sheet_0.png differ diff --git a/packages/stream_chat_flutter/test/src/message_input/message_input_attachment_list_test.dart b/packages/stream_chat_flutter/test/src/message_input/message_input_attachment_list_test.dart index 15a655d9c1..6f2c045cfc 100644 --- a/packages/stream_chat_flutter/test/src/message_input/message_input_attachment_list_test.dart +++ b/packages/stream_chat_flutter/test/src/message_input/message_input_attachment_list_test.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:stream_chat_flutter/stream_chat_flutter.dart'; +import 'package:stream_core_flutter/stream_core_flutter.dart'; import '../mocks.dart'; @@ -17,9 +18,9 @@ Widget wrapWithStreamChat( } void main() { - group('StreamMessageInputAttachmentList tests', () { + group('StreamMessageComposerAttachmentList tests', () { testWidgets( - 'StreamMessageInputAttachmentList should render attachments', + 'StreamMessageComposerAttachmentList should render attachments', (WidgetTester tester) async { final attachments = [ Attachment(type: 'file', id: 'file1'), @@ -29,22 +30,21 @@ void main() { await tester.pumpWidget( wrapWithStreamChat( - StreamMessageInputAttachmentList( + StreamMessageComposerAttachmentList( attachments: attachments, ), ), ); // Expect 2 file attachments and 1 media attachment - expect(find.byType(MessageInputFileAttachments), findsOneWidget); - expect(find.byType(StreamFileAttachment), findsNWidgets(2)); + expect(find.byType(StreamMessageComposerFileAttachment), findsNWidgets(2)); expect(find.byType(MessageInputMediaAttachments), findsOneWidget); expect(find.byType(StreamMediaAttachmentThumbnail), findsOneWidget); }, ); testWidgets( - 'StreamMessageInputAttachmentList should call onRemovePressed callback', + 'StreamMessageComposerAttachmentList should call onRemovePressed callback', (WidgetTester tester) async { Attachment? removedAttachment; @@ -55,7 +55,7 @@ void main() { await tester.pumpWidget( wrapWithStreamChat( - StreamMessageInputAttachmentList( + StreamMessageComposerAttachmentList( attachments: attachments, onRemovePressed: (attachment) { removedAttachment = attachment; @@ -64,7 +64,7 @@ void main() { ), ); - final removeButtons = find.byType(RemoveAttachmentButton); + final removeButtons = find.byType(StreamRemoveControl); // Tap the first remove button await tester.tap(removeButtons.first); @@ -72,18 +72,18 @@ void main() { // Expect the onRemovePressed callback to be called with the second // attachment as they are reversed in the UI. - expect(removedAttachment, attachments[1]); + expect(removedAttachment, attachments[0]); }, ); testWidgets( - '''StreamMessageInputAttachmentList should display empty box if no attachments''', + '''StreamMessageComposerAttachmentList should display empty box if no attachments''', (WidgetTester tester) async { final attachments = []; await tester.pumpWidget( wrapWithStreamChat( - StreamMessageInputAttachmentList( + StreamMessageComposerAttachmentList( attachments: attachments, ), ), @@ -106,14 +106,14 @@ void main() { await tester.pumpWidget( wrapWithStreamChat( - MessageInputFileAttachments( + StreamMessageComposerAttachmentList( attachments: attachments, ), ), ); // Expect 2 file attachments - expect(find.byType(StreamFileAttachment), findsNWidgets(2)); + expect(find.byType(StreamMessageComposerFileAttachment), findsNWidgets(2)); }, ); @@ -128,7 +128,7 @@ void main() { await tester.pumpWidget( wrapWithStreamChat( - MessageInputFileAttachments( + StreamMessageComposerAttachmentList( attachments: attachments, onRemovePressed: (attachment) { removedAttachment = attachment; @@ -137,7 +137,7 @@ void main() { ), ); - final removeButton = find.byType(RemoveAttachmentButton); + final removeButton = find.byType(StreamRemoveControl); // Tap the remove button await tester.tap(removeButton); @@ -154,8 +154,8 @@ void main() { 'MessageInputMediaAttachments should render media attachments', (WidgetTester tester) async { final attachments = [ - Attachment(type: 'media', id: 'media1'), - Attachment(type: 'media', id: 'media2'), + Attachment(type: 'image', id: 'image1'), + Attachment(type: 'video', id: 'video1'), ]; await tester.pumpWidget( @@ -167,7 +167,7 @@ void main() { ); // Expect 2 media attachments - expect(find.byType(Stack), findsNWidgets(2)); + expect(find.byType(StreamMediaAttachmentBuilder), findsNWidgets(2)); }, ); @@ -188,6 +188,27 @@ void main() { expect(find.byType(SizedBox), findsOneWidget); }, ); + + testWidgets( + 'MessageInputMediaAttachments should render unsupported attachment for unknown types', + (WidgetTester tester) async { + final attachments = [ + Attachment(type: 'unknown', id: 'unknown1'), + Attachment(type: 'something_else', id: 'unknown2'), + ]; + + await tester.pumpWidget( + wrapWithStreamChat( + MessageInputMediaAttachments( + attachments: attachments, + ), + ), + ); + + // Expect a fallback unsupported attachment widget for each unknown type. + expect(find.byType(StreamMessageComposerUnsupportedAttachment), findsNWidgets(2)); + }, + ); }); group('StreamMediaAttachmentBuilder tests', () { @@ -228,7 +249,7 @@ void main() { ), ); - final removeButton = find.byType(RemoveAttachmentButton); + final removeButton = find.byType(StreamRemoveControl); // Tap the remove button await tester.tap(removeButton); diff --git a/packages/stream_chat_flutter/test/src/message_input/message_input_test.dart b/packages/stream_chat_flutter/test/src/message_input/message_input_test.dart index cfb48916e2..2f3b787f22 100644 --- a/packages/stream_chat_flutter/test/src/message_input/message_input_test.dart +++ b/packages/stream_chat_flutter/test/src/message_input/message_input_test.dart @@ -1,6 +1,7 @@ // ignore_for_file: lines_longer_than_80_chars -import 'package:desktop_drop/desktop_drop.dart'; +import 'dart:async'; + import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -12,6 +13,7 @@ import 'package:stream_chat_flutter/stream_chat_flutter.dart'; import '../fakes.dart'; import '../mocks.dart'; +/// TODO: remove skip once we have a proper message input test. void main() { final originalRecordPlatform = RecordPlatform.instance; setUp(() => RecordPlatform.instance = FakeRecordPlatform()); @@ -20,15 +22,16 @@ void main() { testWidgets( 'checks message input features', (WidgetTester tester) async { - await tester.pumpWidget(buildWidget( - const StreamMessageInput(), - )); + await tester.pumpWidget( + buildWidget( + StreamMessageComposer(), + ), + ); // wait for the initial state to be rendered. await tester.pumpAndSettle(); expect(find.byType(TextField), findsOneWidget); - expect(find.byKey(const Key('messageInputText')), findsOneWidget); }, ); @@ -62,7 +65,7 @@ void main() { Member( userId: 'user-id', user: User(id: 'user-id'), - ) + ), ]), ); when(() => channelState.members).thenReturn([ @@ -75,14 +78,14 @@ void main() { Message( text: 'hello', user: User(id: 'other-user'), - ) + ), ]); when(() => channelState.messagesStream).thenAnswer( (i) => Stream.value([ Message( text: 'hello', user: User(id: 'other-user'), - ) + ), ]), ); @@ -92,8 +95,8 @@ void main() { client: client, child: StreamChannel( channel: channel, - child: const Scaffold( - body: StreamMessageInput(), + child: Scaffold( + body: StreamMessageComposer(), ), ), ), @@ -107,53 +110,6 @@ void main() { }, ); - testWidgets( - 'allows setting padding on message input', - (WidgetTester tester) async { - await tester.pumpWidget( - buildWidget( - const StreamMessageInput( - padding: EdgeInsets.only(left: 50), - ), - ), - ); - - // wait for the initial state to be rendered. - await tester.pumpAndSettle(); - - expect( - find.descendant( - of: find.byType(StreamMessageValueListenableBuilder), - matching: find.byWidgetPredicate((w) => - w is Padding && - w.padding == const EdgeInsets.only(left: 50))), - findsOneWidget); - }, - ); - - testWidgets( - 'allows setting explicit margin on text field', - (WidgetTester tester) async { - await tester.pumpWidget( - buildWidget( - const StreamMessageInput( - textInputMargin: EdgeInsets.only(left: 50), - ), - ), - ); - // wait for the initial state to be rendered. - await tester.pumpAndSettle(); - - expect( - find.descendant( - of: find.byType(DropTarget), - matching: find.byWidgetPredicate((w) => - w is Container && - w.margin == const EdgeInsets.only(left: 50))), - findsOneWidget); - }, - ); - group('MessageInput keyboard interactions', () { final client = MockClient(); final clientState = MockClientState(); @@ -191,8 +147,8 @@ void main() { client: client, child: StreamChannel( channel: channel, - child: const Scaffold( - bottomNavigationBar: StreamMessageInput(), + child: Scaffold( + bottomNavigationBar: StreamMessageComposer(), ), ), ), @@ -202,7 +158,7 @@ void main() { await tester.pumpAndSettle(); // Add some text to the input field - final textField = find.byType(StreamMessageTextField); + final textField = find.byType(TextField); await tester.enterText(textField, 'Hello world'); await tester.pump(); @@ -232,8 +188,8 @@ void main() { client: client, child: StreamChannel( channel: channel, - child: const Scaffold( - bottomNavigationBar: StreamMessageInput(), + child: Scaffold( + bottomNavigationBar: StreamMessageComposer(), ), ), ), @@ -243,7 +199,7 @@ void main() { await tester.pumpAndSettle(); // Add some text to the input field - final textField = find.byType(StreamMessageTextField); + final textField = find.byType(TextField); await tester.enterText(textField, 'Hello world'); await tester.pump(); @@ -267,7 +223,7 @@ void main() { final quotedMessage = Message(text: 'I am a quoted message'); final initialMessage = Message(quotedMessage: quotedMessage); - final messageInputController = StreamMessageInputController( + final messageInputController = StreamMessageComposerController( message: initialMessage, ); @@ -280,8 +236,8 @@ void main() { child: StreamChannel( channel: channel, child: Scaffold( - bottomNavigationBar: StreamMessageInput( - messageInputController: messageInputController, + bottomNavigationBar: StreamMessageComposer( + messageComposerController: messageInputController, onQuotedMessageCleared: () { onQuotedMessageClearedCalled = true; }, @@ -295,7 +251,7 @@ void main() { await tester.pumpAndSettle(); // Tap the message input to focus it - final textField = find.byType(StreamMessageTextField); + final textField = find.byType(TextField); await tester.tap(textField); await tester.pump(); @@ -315,7 +271,7 @@ void main() { final quotedMessage = Message(text: 'I am a quoted message'); final initialMessage = Message(quotedMessage: quotedMessage); - final messageInputController = StreamMessageInputController( + final messageInputController = StreamMessageComposerController( message: initialMessage, ); @@ -328,8 +284,8 @@ void main() { child: StreamChannel( channel: channel, child: Scaffold( - bottomNavigationBar: StreamMessageInput( - messageInputController: messageInputController, + bottomNavigationBar: StreamMessageComposer( + messageComposerController: messageInputController, onQuotedMessageCleared: () { onQuotedMessageClearedCalled = true; }, @@ -343,7 +299,7 @@ void main() { await tester.pumpAndSettle(); // Add some text to the input field - final textField = find.byType(StreamMessageTextField); + final textField = find.byType(TextField); await tester.enterText(textField, 'Hello world'); await tester.pump(); @@ -358,6 +314,140 @@ void main() { ); }); + group('Edit message routing', () { + final client = MockClient(); + final clientState = MockClientState(); + final channel = MockChannel(); + final channelState = MockChannelState(); + + setUp(() { + registerFallbackValue(Message()); + + when(() => client.state).thenReturn(clientState); + when(() => clientState.currentUser).thenReturn(OwnUser(id: 'user-id')); + when(() => clientState.currentUserStream).thenAnswer( + (_) => Stream.value(OwnUser(id: 'user-id')), + ); + + when(() => channel.state).thenReturn(channelState); + when(() => channel.client).thenReturn(client); + when(channel.getRemainingCooldown).thenReturn(0); + when(() => channel.isMuted).thenReturn(false); + when(() => channel.isMutedStream).thenAnswer((_) => Stream.value(false)); + when(() => channel.extraData).thenReturn({'name': 'test'}); + when(() => channel.extraDataStream).thenAnswer((_) => Stream.value({'name': 'test'})); + when(() => channelState.isUpToDate).thenReturn(true); + when(() => channelState.members).thenReturn([ + Member( + userId: 'user-id', + user: User(id: 'user-id'), + ), + ]); + when(() => channelState.membersStream).thenAnswer( + (_) => Stream.value([ + Member( + userId: 'user-id', + user: User(id: 'user-id'), + ), + ]), + ); + when(() => channelState.messages).thenReturn([]); + when(() => channelState.messagesStream).thenAnswer((_) => Stream.value([])); + }); + + testWidgets( + 'calls updateMessage when controller is in edit state', + (tester) async { + when(() => channel.updateMessage(any())).thenAnswer( + (_) async => UpdateMessageResponse()..message = Message(id: 'msg-1', text: 'Edited text'), + ); + + final existingMessage = Message( + id: 'msg-1', + text: 'Original text', + createdAt: DateTime.now(), + ); + + final messageInputController = StreamMessageComposerController()..editMessage(existingMessage); + addTearDown(messageInputController.dispose); + + final key = GlobalKey(); + + await tester.pumpWidget( + MaterialApp( + home: StreamChat( + client: client, + child: StreamChannel( + channel: channel, + child: Scaffold( + bottomNavigationBar: DefaultStreamMessageComposer( + key: key, + props: MessageComposerProps( + messageComposerController: messageInputController, + ), + ), + ), + ), + ), + ), + ); + + await tester.pumpAndSettle(); + + await key.currentState!.sendMessage(); + // Pump past the debounce/throttle timers (350ms) + await tester.pump(const Duration(seconds: 1)); + + verify(() => channel.updateMessage(any())).called(1); + verifyNever(() => channel.sendMessage(any())); + }, + ); + + testWidgets( + 'calls sendMessage when controller is in normal (non-edit) state', + (tester) async { + when(() => channel.sendMessage(any())).thenAnswer( + (_) async => SendMessageResponse()..message = Message(text: 'Hello'), + ); + + final messageInputController = StreamMessageComposerController( + message: Message(text: 'Hello'), + ); + addTearDown(messageInputController.dispose); + + final key = GlobalKey(); + + await tester.pumpWidget( + MaterialApp( + home: StreamChat( + client: client, + child: StreamChannel( + channel: channel, + child: Scaffold( + bottomNavigationBar: DefaultStreamMessageComposer( + key: key, + props: MessageComposerProps( + messageComposerController: messageInputController, + ), + ), + ), + ), + ), + ), + ); + + await tester.pumpAndSettle(); + + await key.currentState!.sendMessage(); + // Pump past the debounce/throttle timers (350ms) + await tester.pump(const Duration(seconds: 1)); + + verify(() => channel.sendMessage(any())).called(1); + verifyNever(() => channel.updateMessage(any())); + }, + ); + }); + group('DmCheckboxListTile integration in MessageInput', () { final client = MockClient(); final clientState = MockClientState(); @@ -381,14 +471,14 @@ void main() { Message( text: 'hello', user: User(id: 'other-user'), - ) + ), ]); when(() => channelState.messagesStream).thenAnswer( (i) => Stream.value([ Message( text: 'hello', user: User(id: 'other-user'), - ) + ), ]), ); }); @@ -402,9 +492,9 @@ void main() { client: client, child: StreamChannel( channel: channel, - child: const Scaffold( - bottomNavigationBar: StreamMessageInput( - hideSendAsDm: true, + child: Scaffold( + bottomNavigationBar: StreamMessageComposer( + canAlsoSendToChannelFromThread: false, ), ), ), @@ -427,8 +517,8 @@ void main() { client: client, child: StreamChannel( channel: channel, - child: const Scaffold( - bottomNavigationBar: StreamMessageInput(), + child: Scaffold( + bottomNavigationBar: StreamMessageComposer(), ), ), ), @@ -445,7 +535,7 @@ void main() { 'should show DmCheckboxListTile when in a thread and hideSendAsDm is false', (tester) async { // Set up a message controller with a parent message ID (thread) - final messageInputController = StreamMessageInputController( + final messageInputController = StreamMessageComposerController( message: Message(parentId: 'parent-message-id'), ); @@ -456,8 +546,8 @@ void main() { child: StreamChannel( channel: channel, child: Scaffold( - bottomNavigationBar: StreamMessageInput( - messageInputController: messageInputController, + bottomNavigationBar: StreamMessageComposer( + messageComposerController: messageInputController, ), ), ), @@ -475,7 +565,7 @@ void main() { 'should toggle showInChannel value when DmCheckboxListTile is tapped', (tester) async { // Set up a message controller with a parent message ID (thread) - final messageInputController = StreamMessageInputController( + final messageInputController = StreamMessageComposerController( message: Message(parentId: 'parent-message-id'), ); @@ -491,8 +581,8 @@ void main() { child: StreamChannel( channel: channel, child: Scaffold( - bottomNavigationBar: StreamMessageInput( - messageInputController: messageInputController, + bottomNavigationBar: StreamMessageComposer( + messageComposerController: messageInputController, ), ), ), @@ -518,9 +608,431 @@ void main() { }, ); }); + + group('Composer sync with remote events', () { + late MockClient client; + late MockClientState clientState; + late MockChannel channel; + late MockChannelState channelState; + late StreamController eventController; + + setUp(() { + registerFallbackValue(Message()); + + eventController = StreamController.broadcast(); + + client = MockClient(); + clientState = MockClientState(); + channel = MockChannel(eventStream: eventController.stream); + channelState = MockChannelState(); + + when(() => client.state).thenReturn(clientState); + when(() => clientState.currentUser).thenReturn(OwnUser(id: 'user-id')); + when(() => clientState.currentUserStream).thenAnswer( + (_) => Stream.value(OwnUser(id: 'user-id')), + ); + + when(() => channel.state).thenReturn(channelState); + when(() => channel.client).thenReturn(client); + when(channel.getRemainingCooldown).thenReturn(0); + when(() => channelState.isUpToDate).thenReturn(true); + when(() => channelState.members).thenReturn([]); + when(() => channelState.membersStream).thenAnswer((_) => Stream.value([])); + when(() => channelState.messages).thenReturn([]); + when(() => channelState.messagesStream).thenAnswer((_) => Stream.value([])); + }); + + tearDown(() => eventController.close()); + + group('quoted message', () { + testWidgets( + 'clears quoted message on message.deleted event', + (tester) async { + final quotedMessage = Message( + id: 'quoted-msg-id', + text: 'Original message', + user: User(id: 'other-user'), + ); + final controller = StreamMessageComposerController( + message: Message( + quotedMessage: quotedMessage, + quotedMessageId: quotedMessage.id, + ), + ); + addTearDown(controller.dispose); + + var onQuotedMessageClearedCalled = false; + + await tester.pumpWidget( + MaterialApp( + home: StreamChat( + client: client, + child: StreamChannel( + channel: channel, + child: Scaffold( + bottomNavigationBar: StreamMessageComposer( + messageComposerController: controller, + onQuotedMessageCleared: () { + onQuotedMessageClearedCalled = true; + }, + ), + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + + expect(controller.message.quotedMessageId, 'quoted-msg-id'); + + eventController.add( + Event( + type: EventType.messageDeleted, + message: Message(id: 'quoted-msg-id'), + ), + ); + await tester.pump(); + + expect(onQuotedMessageClearedCalled, isTrue); + }, + ); + + testWidgets( + 'does not clear quoted message when a different message is deleted', + (tester) async { + final quotedMessage = Message( + id: 'quoted-msg-id', + text: 'Original message', + user: User(id: 'other-user'), + ); + final controller = StreamMessageComposerController( + message: Message( + quotedMessage: quotedMessage, + quotedMessageId: quotedMessage.id, + ), + ); + addTearDown(controller.dispose); + + var onQuotedMessageClearedCalled = false; + + await tester.pumpWidget( + MaterialApp( + home: StreamChat( + client: client, + child: StreamChannel( + channel: channel, + child: Scaffold( + bottomNavigationBar: StreamMessageComposer( + messageComposerController: controller, + onQuotedMessageCleared: () { + onQuotedMessageClearedCalled = true; + }, + ), + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + + eventController.add( + Event( + type: EventType.messageDeleted, + message: Message(id: 'some-other-msg-id'), + ), + ); + await tester.pump(); + + expect(controller.message.quotedMessageId, 'quoted-msg-id'); + expect(controller.message.quotedMessage, isNotNull); + expect(onQuotedMessageClearedCalled, isFalse); + }, + ); + + testWidgets( + 'updates quoted message on message.updated event', + (tester) async { + final quotedMessage = Message( + id: 'quoted-msg-id', + text: 'Original text', + user: User(id: 'other-user'), + ); + final controller = StreamMessageComposerController( + message: Message( + quotedMessage: quotedMessage, + quotedMessageId: quotedMessage.id, + ), + ); + addTearDown(controller.dispose); + + await tester.pumpWidget( + MaterialApp( + home: StreamChat( + client: client, + child: StreamChannel( + channel: channel, + child: Scaffold( + bottomNavigationBar: StreamMessageComposer( + messageComposerController: controller, + ), + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + + expect(controller.message.quotedMessage?.text, 'Original text'); + + eventController.add( + Event( + type: EventType.messageUpdated, + message: Message( + id: 'quoted-msg-id', + text: 'Edited text', + user: User(id: 'other-user'), + ), + ), + ); + await tester.pump(); + + expect(controller.message.quotedMessageId, 'quoted-msg-id'); + expect(controller.message.quotedMessage?.text, 'Edited text'); + }, + ); + + testWidgets( + 'does not update quoted message when a different message is updated', + (tester) async { + final quotedMessage = Message( + id: 'quoted-msg-id', + text: 'Original text', + user: User(id: 'other-user'), + ); + final controller = StreamMessageComposerController( + message: Message( + quotedMessage: quotedMessage, + quotedMessageId: quotedMessage.id, + ), + ); + addTearDown(controller.dispose); + + await tester.pumpWidget( + MaterialApp( + home: StreamChat( + client: client, + child: StreamChannel( + channel: channel, + child: Scaffold( + bottomNavigationBar: StreamMessageComposer( + messageComposerController: controller, + ), + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + + eventController.add( + Event( + type: EventType.messageUpdated, + message: Message( + id: 'some-other-msg-id', + text: 'Edited text', + user: User(id: 'other-user'), + ), + ), + ); + await tester.pump(); + + expect(controller.message.quotedMessage?.text, 'Original text'); + }, + ); + }); + + group('editing message', () { + testWidgets( + 'refreshes editing message on message.updated event', + (tester) async { + final existingMessage = Message( + id: 'editing-msg-id', + text: 'Original text', + user: User(id: 'user-id'), + ); + final controller = StreamMessageComposerController()..editMessage(existingMessage); + addTearDown(controller.dispose); + + await tester.pumpWidget( + MaterialApp( + home: StreamChat( + client: client, + child: StreamChannel( + channel: channel, + child: Scaffold( + bottomNavigationBar: StreamMessageComposer( + messageComposerController: controller, + ), + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + + expect(controller.message.id, 'editing-msg-id'); + expect(controller.message.text, 'Original text'); + + eventController.add( + Event( + type: EventType.messageUpdated, + message: Message( + id: 'editing-msg-id', + text: 'Updated by another device', + user: User(id: 'user-id'), + ), + ), + ); + await tester.pump(); + + expect(controller.message.id, 'editing-msg-id'); + expect(controller.message.text, 'Updated by another device'); + }, + ); + + testWidgets( + 'does not refresh editing message when a different message is updated', + (tester) async { + final existingMessage = Message( + id: 'editing-msg-id', + text: 'Original text', + user: User(id: 'user-id'), + ); + final controller = StreamMessageComposerController()..editMessage(existingMessage); + addTearDown(controller.dispose); + + await tester.pumpWidget( + MaterialApp( + home: StreamChat( + client: client, + child: StreamChannel( + channel: channel, + child: Scaffold( + bottomNavigationBar: StreamMessageComposer( + messageComposerController: controller, + ), + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + + eventController.add( + Event( + type: EventType.messageUpdated, + message: Message( + id: 'some-other-msg-id', + text: 'Edited text', + user: User(id: 'other-user'), + ), + ), + ); + await tester.pump(); + + expect(controller.message.text, 'Original text'); + }, + ); + + testWidgets( + 'cancels edit on message.deleted event', + (tester) async { + final existingMessage = Message( + id: 'editing-msg-id', + text: 'Being edited', + user: User(id: 'user-id'), + ); + final controller = StreamMessageComposerController()..editMessage(existingMessage); + addTearDown(controller.dispose); + + await tester.pumpWidget( + MaterialApp( + home: StreamChat( + client: client, + child: StreamChannel( + channel: channel, + child: Scaffold( + bottomNavigationBar: StreamMessageComposer( + messageComposerController: controller, + ), + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + + expect(controller.message.id, 'editing-msg-id'); + expect(controller.message.state.isUpdating, isTrue); + + eventController.add( + Event( + type: EventType.messageDeleted, + message: Message(id: 'editing-msg-id'), + ), + ); + await tester.pump(); + + expect(controller.message.id, isNot('editing-msg-id')); + expect(controller.message.state.isInitial, isTrue); + }, + ); + + testWidgets( + 'does not cancel edit when a different message is deleted', + (tester) async { + final existingMessage = Message( + id: 'editing-msg-id', + text: 'Being edited', + user: User(id: 'user-id'), + ); + final controller = StreamMessageComposerController()..editMessage(existingMessage); + addTearDown(controller.dispose); + + await tester.pumpWidget( + MaterialApp( + home: StreamChat( + client: client, + child: StreamChannel( + channel: channel, + child: Scaffold( + bottomNavigationBar: StreamMessageComposer( + messageComposerController: controller, + ), + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + + eventController.add( + Event( + type: EventType.messageDeleted, + message: Message(id: 'some-other-msg-id'), + ), + ); + await tester.pump(); + + expect(controller.message.id, 'editing-msg-id'); + expect(controller.message.state.isUpdating, isTrue); + }, + ); + }); + }); } -MaterialApp buildWidget(StreamMessageInput input) { +MaterialApp buildWidget(StreamMessageComposer input) { final client = MockClient(); final clientState = MockClientState(); final channel = MockChannel(); @@ -548,7 +1060,7 @@ MaterialApp buildWidget(StreamMessageInput input) { Member( userId: 'user-id', user: User(id: 'user-id'), - ) + ), ]), ); when(() => channelState.members).thenReturn([ @@ -561,14 +1073,14 @@ MaterialApp buildWidget(StreamMessageInput input) { Message( text: 'hello', user: User(id: 'other-user'), - ) + ), ]); when(() => channelState.messagesStream).thenAnswer( (i) => Stream.value([ Message( text: 'hello', user: User(id: 'other-user'), - ) + ), ]), ); diff --git a/packages/stream_chat_flutter/test/src/message_input/stream_message_send_button_test.dart b/packages/stream_chat_flutter/test/src/message_input/stream_message_send_button_test.dart deleted file mode 100644 index a9e4d95052..0000000000 --- a/packages/stream_chat_flutter/test/src/message_input/stream_message_send_button_test.dart +++ /dev/null @@ -1,195 +0,0 @@ -// ignore_for_file: avoid_redundant_argument_values - -import 'package:flutter/material.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:stream_chat_flutter/src/icons/stream_svg_icon.dart'; -import 'package:stream_chat_flutter/src/message_input/stream_message_input_icon_button.dart'; -import 'package:stream_chat_flutter/src/message_input/stream_message_send_button.dart'; -import 'package:stream_chat_flutter/src/theme/message_input_theme.dart'; -import 'package:stream_chat_flutter/src/theme/stream_chat_theme.dart'; - -import '../utils/finders.dart'; - -void main() { - group('StreamMessageSendButton', () { - testWidgets( - 'renders countdown button when timeout > 0', - (tester) async { - await tester.pumpWidget( - _wrapWithStreamChatApp( - StreamMessageSendButton( - timeOut: 5, - onSendMessage: () {}, - ), - ), - ); - - expect(find.byKey(const Key('countdown_button')), findsOneWidget); - expect(find.byKey(const Key('send_button')), findsNothing); - }, - ); - - testWidgets( - 'renders idle send button when isIdle is true', - (tester) async { - await tester.pumpWidget( - _wrapWithStreamChatApp( - StreamMessageSendButton( - isIdle: true, - onSendMessage: () {}, - ), - ), - ); - - final button = find.byKey(const Key('send_button')); - expect(button, findsOneWidget); - - // Verify the button is disabled - final iconButton = tester.widget(button); - expect(iconButton.onPressed, isNull); - - // Verify default idle icon is shown - expect(find.bySvgIcon(StreamSvgIcons.sendMessage), findsOneWidget); - }, - ); - - testWidgets( - 'renders active send button when isIdle is false', - (tester) async { - await tester.pumpWidget( - _wrapWithStreamChatApp( - StreamMessageSendButton( - isIdle: false, - onSendMessage: () {}, - ), - ), - ); - - final button = find.byKey(const Key('send_button')); - expect(button, findsOneWidget); - - // Verify the button is enabled - final iconButton = tester.widget(button); - expect(iconButton.onPressed, isNotNull); - - // Verify default active icon is shown - expect(find.bySvgIcon(StreamSvgIcons.circleUp), findsOneWidget); - }, - ); - - testWidgets( - 'uses custom idle button when provided', - (tester) async { - final customIdleButton = Container(key: const Key('custom_idle')); - - await tester.pumpWidget( - _wrapWithStreamChatApp( - StreamMessageSendButton( - isIdle: true, - idleSendIcon: customIdleButton, - onSendMessage: () {}, - ), - ), - ); - - expect(find.byKey(const Key('custom_idle')), findsOneWidget); - expect(find.byType(StreamSvgIcon), findsNothing); - }, - ); - - testWidgets( - 'uses custom active button when provided', - (tester) async { - final customActiveButton = Container(key: const Key('custom_active')); - - await tester.pumpWidget( - _wrapWithStreamChatApp( - StreamMessageSendButton( - isIdle: false, - activeSendIcon: customActiveButton, - onSendMessage: () {}, - ), - ), - ); - - expect(find.byKey(const Key('custom_active')), findsOneWidget); - expect(find.byType(StreamSvgIcon), findsNothing); - }, - ); - - testWidgets( - 'calls onSendMessage when active button is pressed', - (tester) async { - var wasPressed = false; - - await tester.pumpWidget( - _wrapWithStreamChatApp( - StreamMessageSendButton( - isIdle: false, - onSendMessage: () => wasPressed = true, - ), - ), - ); - - await tester.tap(find.byKey(const Key('send_button'))); - expect(wasPressed, isTrue); - }, - ); - - testWidgets( - 'applies theme colors correctly', - (tester) async { - const theme = StreamMessageInputThemeData( - sendButtonColor: Colors.blue, - sendButtonIdleColor: Colors.grey, - sendAnimationDuration: Duration(milliseconds: 100), - ); - - await tester.pumpWidget( - _wrapWithStreamChatApp( - StreamMessageInputTheme( - data: theme, - child: StreamMessageSendButton( - isIdle: false, - onSendMessage: () {}, - ), - ), - ), - ); - - final iconButton = tester.widget( - find.byKey(const Key('send_button')), - ); - - expect(iconButton.color, Colors.blue); - expect(iconButton.disabledColor, Colors.grey); - }, - ); - }); -} - -Widget _wrapWithStreamChatApp( - Widget widget, { - Brightness? brightness, -}) { - return MaterialApp( - debugShowCheckedModeBanner: false, - home: StreamChatTheme( - data: StreamChatThemeData(brightness: brightness), - child: Builder(builder: (context) { - final theme = StreamChatTheme.of(context); - return Scaffold( - backgroundColor: theme.colorTheme.appBg, - bottomNavigationBar: Material( - elevation: 10, - color: theme.colorTheme.barsBg, - child: Padding( - padding: const EdgeInsets.all(8), - child: widget, - ), - ), - ); - }), - ), - ); -} diff --git a/packages/stream_chat_flutter/test/src/message_list_view/bottom_row_test.dart b/packages/stream_chat_flutter/test/src/message_list_view/bottom_row_test.dart deleted file mode 100644 index 4f3059b99c..0000000000 --- a/packages/stream_chat_flutter/test/src/message_list_view/bottom_row_test.dart +++ /dev/null @@ -1,74 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:mocktail/mocktail.dart'; -import 'package:stream_chat_flutter/stream_chat_flutter.dart'; - -import '../mocks.dart'; - -void main() { - late Channel channel; - late ChannelClientState channelClientState; - - setUp(() { - channel = MockChannel(); - when(() => channel.on(any(), any(), any(), any())) - .thenAnswer((_) => const Stream.empty()); - channelClientState = MockChannelState(); - when(() => channel.state).thenReturn(channelClientState); - when(() => channelClientState.messages).thenReturn([ - Message( - id: 'parentId', - ) - ]); - }); - - setUpAll(() { - registerFallbackValue(Message()); - }); - - testWidgets('BottomRow', (tester) async { - final theme = StreamChatThemeData.light(); - final onThreadTap = MockValueChanged(); - - await tester.pumpWidget( - MaterialApp( - home: Scaffold( - body: Center( - child: StreamChannel( - channel: channel, - child: BottomRow( - message: Message( - parentId: 'parentId', - ), - isDeleted: false, - showThreadReplyIndicator: false, - showUsername: false, - showInChannel: true, - showTimeStamp: false, - showEditedLabel: false, - reverse: false, - showSendingIndicator: false, - hasUrlAttachments: false, - isGiphy: false, - isOnlyEmoji: false, - messageTheme: theme.otherMessageTheme, - streamChatTheme: theme, - hasNonUrlAttachments: false, - streamChat: StreamChatState(), - onThreadTap: onThreadTap, - ), - ), - ), - ), - ), - ); - - // wait for the initial state to be rendered. - await tester.pump(Duration.zero); - - await tester.tap(find.byType(GestureDetector)); - await tester.pumpAndSettle(); - - verify(() => onThreadTap.call(any())); - }); -} diff --git a/packages/stream_chat_flutter/test/src/message_list_view/floating_date_divider_test.dart b/packages/stream_chat_flutter/test/src/message_list_view/floating_date_divider_test.dart index 9af5114ba1..9535a19643 100644 --- a/packages/stream_chat_flutter/test/src/message_list_view/floating_date_divider_test.dart +++ b/packages/stream_chat_flutter/test/src/message_list_view/floating_date_divider_test.dart @@ -311,6 +311,7 @@ void main() { FloatingDateDivider( itemPositionListener: itemPositionListener, reverse: false, + fadeNearInlineDivider: false, messages: messages, itemCount: itemCount, ), @@ -381,6 +382,7 @@ void main() { FloatingDateDivider( itemPositionListener: itemPositionListener, reverse: true, // Use getBottomElementIndex + fadeNearInlineDivider: false, messages: messages, itemCount: itemCount, ), diff --git a/packages/stream_chat_flutter/test/src/message_list_view/message_list_view_test.dart b/packages/stream_chat_flutter/test/src/message_list_view/message_list_view_test.dart index 0fd7ae3eeb..cf9571074b 100644 --- a/packages/stream_chat_flutter/test/src/message_list_view/message_list_view_test.dart +++ b/packages/stream_chat_flutter/test/src/message_list_view/message_list_view_test.dart @@ -18,34 +18,24 @@ void main() { clientState = MockClientState(); when(() => client.state).thenAnswer((_) => clientState); when(() => clientState.currentUser).thenReturn(OwnUser(id: 'testid')); - when(() => clientState.currentUserStream) - .thenAnswer((_) => Stream.value(OwnUser(id: 'testid'))); + when(() => clientState.currentUserStream).thenAnswer((_) => Stream.value(OwnUser(id: 'testid'))); channel = MockChannel(); - when(() => channel.on(any(), any(), any(), any())) - .thenAnswer((_) => const Stream.empty()); channelClientState = MockChannelState(); when(() => channel.client).thenReturn(client); when(() => channel.state).thenReturn(channelClientState); - when(() => channelClientState.threadsStream) - .thenAnswer((_) => const Stream.empty()); - when(() => channelClientState.messagesStream) - .thenAnswer((_) => const Stream.empty()); + when(() => channelClientState.threadsStream).thenAnswer((_) => const Stream.empty()); + when(() => channelClientState.messagesStream).thenAnswer((_) => const Stream.empty()); when(() => channelClientState.messages).thenReturn([]); when(() => channelClientState.isUpToDate).thenReturn(true); - when(() => channelClientState.isUpToDateStream) - .thenAnswer((_) => Stream.value(true)); - when(() => channelClientState.unreadCountStream) - .thenAnswer((_) => Stream.value(0)); + when(() => channelClientState.isUpToDateStream).thenAnswer((_) => Stream.value(true)); + when(() => channelClientState.unreadCountStream).thenAnswer((_) => Stream.value(0)); when(() => channelClientState.unreadCount).thenReturn(0); - when(() => channelClientState.readStream) - .thenAnswer((_) => const Stream.empty()); + when(() => channelClientState.readStream).thenAnswer((_) => const Stream.empty()); when(() => channelClientState.read).thenReturn([]); - when(() => channelClientState.membersStream) - .thenAnswer((_) => const Stream.empty()); + when(() => channelClientState.membersStream).thenAnswer((_) => const Stream.empty()); when(() => channelClientState.members).thenReturn([]); when(() => channelClientState.currentUserRead).thenReturn(null); - when(() => channelClientState.currentUserReadStream) - .thenAnswer((_) => const Stream.empty()); + when(() => channelClientState.currentUserReadStream).thenAnswer((_) => const Stream.empty()); }); // https://github.com/GetStream/stream-chat-flutter/issues/674 @@ -71,8 +61,7 @@ void main() { expect(find.byKey(emptyWidgetKey), findsOneWidget); }); - testWidgets('renders a non empty message list view with custom background', - (tester) async { + testWidgets('renders a non empty message list view with custom background', (tester) async { final message = Message( id: 'message1', text: 'Hello world!', @@ -136,8 +125,7 @@ void main() { ); }); - testWidgets('renders a non empty message list view with unread messages', - (tester) async { + testWidgets('renders a non empty message list view with unread messages', (tester) async { final user = OwnUser(id: 'testid'); final message = Message( id: 'message1', @@ -148,8 +136,7 @@ void main() { ), ); - when(() => channelClientState.read) - .thenReturn([Read(lastRead: DateTime.now(), user: user)]); + when(() => channelClientState.read).thenReturn([Read(lastRead: DateTime.now(), user: user)]); when(() => channelClientState.messagesStream).thenAnswer( (_) => Stream.value([message]), @@ -237,11 +224,11 @@ void main() { await tester.pumpAndSettle(); }); - expect(find.byType(FloatingActionButton), findsOneWidget); + expect(find.byType(StreamButton), findsOneWidget); - await tester.tap(find.byType(FloatingActionButton)); + await tester.tap(find.byType(StreamButton)); await tester.pumpAndSettle(); - expect(find.byType(FloatingActionButton), findsNothing); + expect(find.byType(StreamButton), findsNothing); }); } diff --git a/packages/stream_chat_flutter/test/src/message_list_view/thread_separator_test.dart b/packages/stream_chat_flutter/test/src/message_list_view/thread_separator_test.dart index c0cfcb50e3..2f9f866584 100644 --- a/packages/stream_chat_flutter/test/src/message_list_view/thread_separator_test.dart +++ b/packages/stream_chat_flutter/test/src/message_list_view/thread_separator_test.dart @@ -4,16 +4,13 @@ import 'package:stream_chat_flutter/src/message_list_view/thread_separator.dart' import 'package:stream_chat_flutter/stream_chat_flutter.dart'; void main() { - testWidgets('ThreadSeparator', (tester) async { + testWidgets('ThreadSeparator renders text and decoration', (tester) async { await tester.pumpWidget( MaterialApp( - home: StreamChatTheme( - data: StreamChatThemeData.light(), - child: Scaffold( - body: Center( - child: ThreadSeparator( - parentMessage: Message(), - ), + home: Scaffold( + body: Center( + child: ThreadSeparator( + parentMessage: Message(replyCount: 3), ), ), ), diff --git a/packages/stream_chat_flutter/test/src/message_modal/goldens/ci/moderated_message_actions_modal_dark.png b/packages/stream_chat_flutter/test/src/message_modal/goldens/ci/moderated_message_actions_modal_dark.png new file mode 100644 index 0000000000..9ff73e9a3d Binary files /dev/null and b/packages/stream_chat_flutter/test/src/message_modal/goldens/ci/moderated_message_actions_modal_dark.png differ diff --git a/packages/stream_chat_flutter/test/src/message_modal/goldens/ci/moderated_message_actions_modal_light.png b/packages/stream_chat_flutter/test/src/message_modal/goldens/ci/moderated_message_actions_modal_light.png new file mode 100644 index 0000000000..a8603da2ac Binary files /dev/null and b/packages/stream_chat_flutter/test/src/message_modal/goldens/ci/moderated_message_actions_modal_light.png differ diff --git a/packages/stream_chat_flutter/test/src/message_modal/goldens/ci/stream_message_actions_modal_dark.png b/packages/stream_chat_flutter/test/src/message_modal/goldens/ci/stream_message_actions_modal_dark.png new file mode 100644 index 0000000000..b1ee809f7f Binary files /dev/null and b/packages/stream_chat_flutter/test/src/message_modal/goldens/ci/stream_message_actions_modal_dark.png differ diff --git a/packages/stream_chat_flutter/test/src/message_modal/goldens/ci/stream_message_actions_modal_light.png b/packages/stream_chat_flutter/test/src/message_modal/goldens/ci/stream_message_actions_modal_light.png new file mode 100644 index 0000000000..35c9974b10 Binary files /dev/null and b/packages/stream_chat_flutter/test/src/message_modal/goldens/ci/stream_message_actions_modal_light.png differ diff --git a/packages/stream_chat_flutter/test/src/message_modal/goldens/ci/stream_message_actions_modal_reversed_dark.png b/packages/stream_chat_flutter/test/src/message_modal/goldens/ci/stream_message_actions_modal_reversed_dark.png new file mode 100644 index 0000000000..7c237eb9bb Binary files /dev/null and b/packages/stream_chat_flutter/test/src/message_modal/goldens/ci/stream_message_actions_modal_reversed_dark.png differ diff --git a/packages/stream_chat_flutter/test/src/message_modal/goldens/ci/stream_message_actions_modal_reversed_light.png b/packages/stream_chat_flutter/test/src/message_modal/goldens/ci/stream_message_actions_modal_reversed_light.png new file mode 100644 index 0000000000..86e242080d Binary files /dev/null and b/packages/stream_chat_flutter/test/src/message_modal/goldens/ci/stream_message_actions_modal_reversed_light.png differ diff --git a/packages/stream_chat_flutter/test/src/message_modal/goldens/ci/stream_message_actions_modal_reversed_with_reactions_dark.png b/packages/stream_chat_flutter/test/src/message_modal/goldens/ci/stream_message_actions_modal_reversed_with_reactions_dark.png new file mode 100644 index 0000000000..6dcf57ca13 Binary files /dev/null and b/packages/stream_chat_flutter/test/src/message_modal/goldens/ci/stream_message_actions_modal_reversed_with_reactions_dark.png differ diff --git a/packages/stream_chat_flutter/test/src/message_modal/goldens/ci/stream_message_actions_modal_reversed_with_reactions_light.png b/packages/stream_chat_flutter/test/src/message_modal/goldens/ci/stream_message_actions_modal_reversed_with_reactions_light.png new file mode 100644 index 0000000000..925a01fe36 Binary files /dev/null and b/packages/stream_chat_flutter/test/src/message_modal/goldens/ci/stream_message_actions_modal_reversed_with_reactions_light.png differ diff --git a/packages/stream_chat_flutter/test/src/message_modal/goldens/ci/stream_message_actions_modal_with_reactions_dark.png b/packages/stream_chat_flutter/test/src/message_modal/goldens/ci/stream_message_actions_modal_with_reactions_dark.png new file mode 100644 index 0000000000..04cffd0976 Binary files /dev/null and b/packages/stream_chat_flutter/test/src/message_modal/goldens/ci/stream_message_actions_modal_with_reactions_dark.png differ diff --git a/packages/stream_chat_flutter/test/src/message_modal/goldens/ci/stream_message_actions_modal_with_reactions_light.png b/packages/stream_chat_flutter/test/src/message_modal/goldens/ci/stream_message_actions_modal_with_reactions_light.png new file mode 100644 index 0000000000..e8b83c313a Binary files /dev/null and b/packages/stream_chat_flutter/test/src/message_modal/goldens/ci/stream_message_actions_modal_with_reactions_light.png differ diff --git a/packages/stream_chat_flutter/test/src/message_modal/message_actions_modal_test.dart b/packages/stream_chat_flutter/test/src/message_modal/message_actions_modal_test.dart new file mode 100644 index 0000000000..d0fb2ccb0a --- /dev/null +++ b/packages/stream_chat_flutter/test/src/message_modal/message_actions_modal_test.dart @@ -0,0 +1,300 @@ +// ignore_for_file: lines_longer_than_80_chars + +import 'package:alchemist/alchemist.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_portal/flutter_portal.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:stream_chat_flutter/stream_chat_flutter.dart'; +import 'package:stream_core_flutter/stream_core_flutter.dart' show StreamIconData; + +void main() { + final message = Message( + id: 'test-message', + text: 'This is a test message', + createdAt: DateTime.now(), + user: User(id: 'test-user', name: 'Test User'), + ); + + final messageActions = >[ + StreamContextMenuAction( + label: const Text('Reply'), + leading: const Icon(StreamIconData.reply), + value: QuotedReply(message: message), + ), + StreamContextMenuAction( + label: const Text('Thread Reply'), + leading: const Icon(StreamIconData.thread), + value: ThreadReply(message: message), + ), + StreamContextMenuAction( + label: const Text('Copy Message'), + leading: const Icon(StreamIconData.copy), + value: CopyMessage(message: message), + ), + StreamContextMenuAction.destructive( + label: const Text('Delete Message'), + leading: const Icon(StreamIconData.delete), + value: DeleteMessage(message: message), + ), + ]; + + group('StreamMessageActionsModal', () { + testWidgets('renders message widget and actions correctly', (tester) async { + await tester.pumpWidget( + _wrapWithMaterialApp( + StreamMessageActionsModal( + message: message, + messageActions: messageActions, + messageWidget: const Text('Message Widget'), + alignment: AlignmentDirectional.centerStart, + ), + ), + ); + + // Use a longer timeout to ensure everything is rendered + await tester.pumpAndSettle(const Duration(seconds: 1)); + expect(find.text('Message Widget'), findsOneWidget); + expect(find.text('Reply'), findsOneWidget); + expect(find.text('Thread Reply'), findsOneWidget); + expect(find.text('Copy Message'), findsOneWidget); + expect(find.text('Delete Message'), findsOneWidget); + }); + + testWidgets('renders with reaction picker when enabled', (tester) async { + await tester.pumpWidget( + _wrapWithMaterialApp( + StreamMessageActionsModal( + message: message, + messageActions: messageActions, + messageWidget: const Text('Message Widget'), + alignment: AlignmentDirectional.centerStart, + showReactionPicker: true, + ), + ), + ); + + // Use a longer timeout to ensure everything is rendered + await tester.pumpAndSettle(const Duration(seconds: 1)); + expect(find.byType(StreamMessageReactionPicker), findsOneWidget); + }); + + testWidgets( + 'pops with SelectReaction when reaction is selected', + (tester) async { + MessageAction? messageAction; + + // Define custom reaction icons via resolver for testing. + const testReactionResolver = _TestReactionIconResolver( + defaultReactionTypes: {'like', 'love'}, + ); + + await tester.pumpWidget( + _wrapWithMaterialApp( + reactionIconResolver: testReactionResolver, + Builder( + builder: (context) => TextButton( + onPressed: () async { + messageAction = await showStreamDialog( + context: context, + builder: (_) => StreamMessageActionsModal( + message: message, + messageActions: messageActions, + messageWidget: const Text('Message Widget'), + alignment: AlignmentDirectional.centerStart, + showReactionPicker: true, + ), + ); + }, + child: const Text('Open Dialog'), + ), + ), + ), + ); + await tester.tap(find.text('Open Dialog')); + + // Use a longer timeout to ensure everything is rendered + await tester.pumpAndSettle(const Duration(seconds: 1)); + + // Verify reaction picker is shown + expect(find.byType(StreamMessageReactionPicker), findsOneWidget); + + // Reactions are rendered as StreamEmojiButton widgets. The resolver + // maps 'like' → 👍 and 'love' → ❤️. Find and tap the 'like' emoji. + final likeEmoji = find.text('👍'); + expect(likeEmoji, findsOneWidget); + await tester.tap(likeEmoji); + await tester.pumpAndSettle(); + + expect(messageAction, isA()); + // Verify the popped value has correct reaction type + expect((messageAction! as SelectReaction).reaction.type, 'like'); + + // Open dialog again and tap the second reaction (love) + await tester.tap(find.text('Open Dialog')); + await tester.pumpAndSettle(const Duration(seconds: 1)); + + final loveEmoji = find.text('❤️'); + expect(loveEmoji, findsOneWidget); + await tester.tap(loveEmoji); + await tester.pumpAndSettle(); + + expect(messageAction, isA()); + // Verify the popped value has correct reaction type + expect((messageAction! as SelectReaction).reaction.type, 'love'); + }, + ); + }); + + group('StreamMessageActionsModal Golden Tests', () { + Widget buildMessageWidget({bool reverse = false}) { + return Builder( + builder: (context) { + final colorScheme = context.streamColorScheme; + final backgroundColor = reverse ? colorScheme.brand.shade100 : colorScheme.backgroundSurface; + final textColor = reverse ? colorScheme.brand.shade900 : colorScheme.textPrimary; + + return Container( + padding: const EdgeInsets.symmetric( + vertical: 8, + horizontal: 16, + ), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(14), + color: backgroundColor, + ), + child: Text( + message.text ?? '', + style: TextStyle(color: textColor), + ), + ); + }, + ); + } + + for (final brightness in Brightness.values) { + final theme = brightness.name; + + goldenTest( + 'StreamMessageActionsModal in $theme theme', + fileName: 'stream_message_actions_modal_$theme', + constraints: const BoxConstraints(maxWidth: 400, maxHeight: 600), + builder: () => _wrapWithMaterialApp( + brightness: brightness, + StreamMessageActionsModal( + message: message, + messageActions: messageActions, + messageWidget: buildMessageWidget(), + alignment: AlignmentDirectional.centerStart, + ), + ), + ); + + goldenTest( + 'StreamMessageActionsModal with reaction picker in $theme theme', + fileName: 'stream_message_actions_modal_with_reactions_$theme', + constraints: const BoxConstraints(maxWidth: 400, maxHeight: 600), + builder: () => _wrapWithMaterialApp( + brightness: brightness, + StreamMessageActionsModal( + message: message, + messageActions: messageActions, + messageWidget: buildMessageWidget(), + alignment: AlignmentDirectional.centerStart, + showReactionPicker: true, + ), + ), + ); + + goldenTest( + 'StreamMessageActionsModal reversed in $theme theme', + fileName: 'stream_message_actions_modal_reversed_$theme', + constraints: const BoxConstraints(maxWidth: 400, maxHeight: 600), + builder: () => _wrapWithMaterialApp( + brightness: brightness, + StreamMessageActionsModal( + message: message, + messageActions: messageActions, + messageWidget: buildMessageWidget(reverse: true), + alignment: AlignmentDirectional.centerEnd, + ), + ), + ); + + goldenTest( + 'StreamMessageActionsModal reversed with reaction picker in $theme theme', + fileName: 'stream_message_actions_modal_reversed_with_reactions_$theme', + constraints: const BoxConstraints(maxWidth: 400, maxHeight: 600), + builder: () => _wrapWithMaterialApp( + brightness: brightness, + StreamMessageActionsModal( + message: message, + messageActions: messageActions, + messageWidget: buildMessageWidget(reverse: true), + alignment: AlignmentDirectional.centerEnd, + showReactionPicker: true, + ), + ), + ); + } + }); +} + +Widget _wrapWithMaterialApp( + Widget child, { + Brightness? brightness, + ReactionIconResolver? reactionIconResolver, +}) { + return Portal( + child: MaterialApp( + debugShowCheckedModeBanner: false, + theme: ThemeData(brightness: brightness), + builder: (context, child) => StreamChatConfiguration( + data: StreamChatConfigurationData( + reactionIconResolver: reactionIconResolver ?? const _TestReactionIconResolver(), + ), + child: StreamChatTheme( + data: StreamChatThemeData(brightness: brightness), + child: child ?? const SizedBox.shrink(), + ), + ), + home: Builder( + builder: (context) { + final theme = StreamChatTheme.of(context); + return Scaffold( + backgroundColor: theme.colorTheme.appBg, + body: ColoredBox( + color: theme.colorTheme.overlay, + child: Padding( + padding: const EdgeInsets.all(8), + child: child, + ), + ), + ); + }, + ), + ), + ); +} + +class _TestReactionIconResolver extends ReactionIconResolver { + const _TestReactionIconResolver({ + this.defaultReactionTypes = const {'like', 'love', 'haha', 'wow', 'sad'}, + }); + + final Set defaultReactionTypes; + + @override + Set get defaultReactions => defaultReactionTypes; + + @override + Set get supportedReactions => defaultReactionTypes; + + @override + String? emojiCode(String type) => streamSupportedEmojis[type]?.emoji; + + @override + StreamEmojiContent resolve(String type) { + if (emojiCode(type) case final emoji?) return StreamUnicodeEmoji(emoji); + return const StreamUnicodeEmoji('❓'); + } +} diff --git a/packages/stream_chat_flutter/test/src/message_modal/moderated_message_actions_modal_test.dart b/packages/stream_chat_flutter/test/src/message_modal/moderated_message_actions_modal_test.dart new file mode 100644 index 0000000000..b4acdf8e00 --- /dev/null +++ b/packages/stream_chat_flutter/test/src/message_modal/moderated_message_actions_modal_test.dart @@ -0,0 +1,161 @@ +// ignore_for_file: lines_longer_than_80_chars + +import 'package:alchemist/alchemist.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:stream_chat_flutter/stream_chat_flutter.dart'; +import 'package:stream_core_flutter/stream_core_flutter.dart'; + +void main() { + final message = Message( + id: 'test-message', + type: MessageType.error, + text: 'This is a test message', + createdAt: DateTime.now(), + user: User(id: 'test-user', name: 'Test User'), + moderation: const Moderation( + action: ModerationAction.bounce, + originalText: 'This is a test message with flagged content', + ), + ); + + final messageActions = >[ + StreamContextMenuAction( + label: const Text('Send Anyway'), + leading: const Icon(StreamIconData.send), + value: ResendMessage(message: message), + ), + StreamContextMenuAction( + label: const Text('Edit Message'), + leading: const Icon(StreamIconData.edit), + value: EditMessage(message: message), + ), + StreamContextMenuAction.destructive( + label: const Text('Delete Message'), + leading: const Icon(StreamIconData.delete), + value: HardDeleteMessage(message: message), + ), + ]; + + group('ModeratedMessageActionsModal', () { + testWidgets('renders title, content and actions correctly', (tester) async { + await tester.pumpWidget( + _wrapWithMaterialApp( + ModeratedMessageActionsModal( + message: message, + messageActions: messageActions, + ), + ), + ); + + // Use a longer timeout to ensure everything is rendered + await tester.pumpAndSettle(const Duration(seconds: 1)); + + // Check for icon, title and content + expect(find.byType(Icon), findsWidgets); + expect(find.byType(Text), findsWidgets); + + // Check for actions + expect(find.text('Send Anyway'), findsOneWidget); + expect(find.text('Edit Message'), findsOneWidget); + expect(find.text('Delete Message'), findsOneWidget); + }); + + testWidgets('action buttons pop with the correct value', (tester) async { + MessageAction? messageAction; + + await tester.pumpWidget( + _wrapWithMaterialApp( + Builder( + builder: (context) => TextButton( + onPressed: () async { + messageAction = await showStreamDialog( + context: context, + builder: (_) => ModeratedMessageActionsModal( + message: message, + messageActions: messageActions, + ), + ); + }, + child: const Text('Open Dialog'), + ), + ), + ), + ); + + // Open dialog and tap Send Anyway + await tester.tap(find.text('Open Dialog')); + await tester.pumpAndSettle(); + + // Tap on Send Anyway button + await tester.tap(find.text('Send Anyway')); + await tester.pumpAndSettle(); + expect(messageAction, isA()); + + // Open dialog and tap Edit Message + await tester.tap(find.text('Open Dialog')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Edit Message')); + await tester.pumpAndSettle(); + expect(messageAction, isA()); + + // Open dialog and tap Delete Message + await tester.tap(find.text('Open Dialog')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Delete Message')); + await tester.pumpAndSettle(); + expect(messageAction, isA()); + }); + }); + + group('ModeratedMessageActionsModal Golden Tests', () { + for (final brightness in Brightness.values) { + final theme = brightness.name; + + goldenTest( + 'ModeratedMessageActionsModal in $theme theme', + fileName: 'moderated_message_actions_modal_$theme', + constraints: const BoxConstraints(maxWidth: 400, maxHeight: 350), + builder: () => _wrapWithMaterialApp( + brightness: brightness, + ModeratedMessageActionsModal( + message: message, + messageActions: messageActions, + ), + ), + ); + } + }); +} + +Widget _wrapWithMaterialApp( + Widget child, { + Brightness? brightness, +}) { + return MaterialApp( + debugShowCheckedModeBanner: false, + theme: ThemeData(brightness: brightness), + builder: (context, child) => StreamChatConfiguration( + data: StreamChatConfigurationData(), + child: StreamChatTheme( + data: StreamChatThemeData(brightness: brightness), + child: child ?? const SizedBox.shrink(), + ), + ), + home: Builder( + builder: (context) { + final theme = StreamChatTheme.of(context); + return Scaffold( + backgroundColor: theme.colorTheme.appBg, + body: ColoredBox( + color: theme.colorTheme.overlay, + child: Padding( + padding: const EdgeInsets.all(8), + child: child, + ), + ), + ); + }, + ), + ); +} diff --git a/packages/stream_chat_flutter/test/src/message_reactions_modal/message_reactions_modal_test.dart b/packages/stream_chat_flutter/test/src/message_reactions_modal/message_reactions_modal_test.dart deleted file mode 100644 index 8cf78680b3..0000000000 --- a/packages/stream_chat_flutter/test/src/message_reactions_modal/message_reactions_modal_test.dart +++ /dev/null @@ -1,121 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:mocktail/mocktail.dart'; -import 'package:stream_chat_flutter/stream_chat_flutter.dart'; - -import '../mocks.dart'; - -void main() { - testWidgets( - 'control test', - (WidgetTester tester) async { - final client = MockClient(); - final clientState = MockClientState(); - final channel = MockChannel(); - final themeData = ThemeData(); - - when(() => client.state).thenReturn(clientState); - when(() => clientState.currentUser).thenReturn(OwnUser(id: 'user-id')); - - final streamTheme = StreamChatThemeData.fromTheme(themeData); - - final message = Message( - id: 'test', - text: 'test message', - user: User( - id: 'test-user', - ), - ); - await tester.pumpWidget( - MaterialApp( - theme: themeData, - home: StreamChat( - client: client, - streamChatThemeData: streamTheme, - child: StreamChannel( - channel: channel, - child: StreamMessageReactionsModal( - messageWidget: const Text( - 'test', - key: Key('MessageWidget'), - ), - message: message, - messageTheme: streamTheme.ownMessageTheme, - ), - ), - ), - ), - ); - - await tester.pump(const Duration(milliseconds: 1000)); - - expect(find.byType(StreamReactionBubble), findsNothing); - - expect(find.byType(StreamUserAvatar), findsNothing); - }, - ); - - testWidgets( - 'it should apply passed parameters', - (WidgetTester tester) async { - final client = MockClient(); - final clientState = MockClientState(); - final channel = MockChannel(); - final themeData = ThemeData(); - - when(() => client.state).thenReturn(clientState); - when(() => clientState.currentUser).thenReturn(OwnUser(id: 'user-id')); - - final streamTheme = StreamChatThemeData.fromTheme(themeData); - - final message = Message( - id: 'test', - text: 'test message', - user: User( - id: 'test-user', - ), - latestReactions: [ - Reaction( - messageId: 'test', - user: User(id: 'testid'), - type: 'test', - ), - ], - ); - - // ignore: prefer_function_declarations_over_variables - final onUserAvatarTap = (u) => print('ok'); - - await tester.pumpWidget( - MaterialApp( - theme: themeData, - home: StreamChat( - client: client, - streamChatThemeData: streamTheme, - child: StreamChannel( - channel: channel, - child: StreamMessageReactionsModal( - messageWidget: const Text( - 'test', - key: Key('MessageWidget'), - ), - message: message, - messageTheme: streamTheme.ownMessageTheme, - reverse: true, - showReactionPicker: false, - onUserAvatarTap: onUserAvatarTap, - ), - ), - ), - ), - ); - - await tester.pump(const Duration(milliseconds: 1000)); - - expect(find.byKey(const Key('MessageWidget')), findsOneWidget); - - expect(find.byType(StreamReactionBubble), findsOneWidget); - expect(find.byType(StreamUserAvatar), findsOneWidget); - }, - ); -} diff --git a/packages/stream_chat_flutter/test/src/message_widget/deleted_message_test.dart b/packages/stream_chat_flutter/test/src/message_widget/deleted_message_test.dart deleted file mode 100644 index 6fc7bfe6eb..0000000000 --- a/packages/stream_chat_flutter/test/src/message_widget/deleted_message_test.dart +++ /dev/null @@ -1,217 +0,0 @@ -import 'package:alchemist/alchemist.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:mocktail/mocktail.dart'; -import 'package:stream_chat_flutter/stream_chat_flutter.dart'; - -import '../material_app_wrapper.dart'; -import '../mocks.dart'; - -void main() { - testWidgets('control test', (tester) async { - final client = MockClient(); - final clientState = MockClientState(); - - when(() => client.state).thenReturn(clientState); - when(() => clientState.currentUser).thenReturn(OwnUser(id: 'user-id')); - - await tester.pumpWidget( - MaterialApp( - home: StreamChat( - client: client, - child: const Scaffold( - body: StreamDeletedMessage( - messageTheme: StreamMessageThemeData( - createdAtStyle: TextStyle( - color: Colors.black, - ), - messageTextStyle: TextStyle(), - ), - ), - ), - ), - ), - ); - - expect(find.text('Message deleted'), findsOneWidget); - }); - - goldenTest( - 'control golden light', - fileName: 'deleted_message_light', - constraints: const BoxConstraints.tightFor(width: 200, height: 200), - builder: () { - final client = MockClient(); - final clientState = MockClientState(); - final channel = MockChannel(); - final channelState = MockChannelState(); - final lastMessageAt = DateTime.parse('2020-06-22 12:00:00'); - - when(() => client.state).thenReturn(clientState); - when(() => clientState.currentUser).thenReturn(OwnUser(id: 'user-id')); - when(() => channel.lastMessageAt).thenReturn(lastMessageAt); - when(() => channel.state).thenReturn(channelState); - when(() => channel.client).thenReturn(client); - when(() => channel.extraDataStream).thenAnswer( - (i) => Stream.value({ - 'name': 'test', - }), - ); - when(() => channel.extraData).thenReturn({ - 'name': 'test', - }); - - when(() => clientState.totalUnreadCount).thenReturn(10); - when(() => clientState.totalUnreadCountStream) - .thenAnswer((i) => Stream.value(10)); - - final materialTheme = ThemeData.light( - useMaterial3: false, - ); - final theme = StreamChatThemeData.fromTheme(materialTheme); - return MaterialAppWrapper( - theme: materialTheme, - home: StreamChat( - streamChatThemeData: theme, - client: client, - connectivityStream: Stream.value([ConnectivityResult.mobile]), - child: StreamChannel( - showLoading: false, - channel: channel, - child: Scaffold( - body: Center( - child: StreamDeletedMessage( - messageTheme: theme.ownMessageTheme, - ), - ), - ), - ), - ), - ); - }, - ); - - goldenTest( - 'control golden dark', - fileName: 'deleted_message_dark', - constraints: const BoxConstraints.tightFor(width: 200, height: 200), - builder: () { - final client = MockClient(); - final clientState = MockClientState(); - final channel = MockChannel(); - final channelState = MockChannelState(); - final lastMessageAt = DateTime.parse('2020-06-22 12:00:00'); - - when(() => client.state).thenReturn(clientState); - when(() => clientState.currentUser).thenReturn(OwnUser(id: 'user-id')); - when(() => channel.lastMessageAt).thenReturn(lastMessageAt); - when(() => channel.state).thenReturn(channelState); - when(() => channel.client).thenReturn(client); - when(() => channel.extraDataStream).thenAnswer( - (i) => Stream.value({ - 'name': 'test', - }), - ); - when(() => channel.extraData).thenReturn({ - 'name': 'test', - }); - - when(() => clientState.totalUnreadCount).thenReturn(10); - when(() => clientState.totalUnreadCountStream) - .thenAnswer((i) => Stream.value(10)); - - final materialTheme = ThemeData.dark( - useMaterial3: false, - ); - final theme = StreamChatThemeData.fromTheme(materialTheme); - return MaterialAppWrapper( - theme: materialTheme, - home: StreamChat( - streamChatThemeData: theme, - client: client, - connectivityStream: Stream.value([ConnectivityResult.mobile]), - child: StreamChannel( - showLoading: false, - channel: channel, - child: Scaffold( - body: Center( - child: StreamDeletedMessage( - messageTheme: theme.ownMessageTheme, - ), - ), - ), - ), - ), - ); - }, - ); - - goldenTest( - 'golden customization test', - fileName: 'deleted_message_custom', - constraints: const BoxConstraints.tightFor(width: 200, height: 200), - builder: () { - final client = MockClient(); - final clientState = MockClientState(); - final channel = MockChannel(); - final channelState = MockChannelState(); - final lastMessageAt = DateTime.parse('2020-06-22 12:00:00'); - - when(() => client.state).thenReturn(clientState); - when(() => clientState.currentUser).thenReturn(OwnUser(id: 'user-id')); - when(() => channel.lastMessageAt).thenReturn(lastMessageAt); - when(() => channel.state).thenReturn(channelState); - when(() => channel.client).thenReturn(client); - when(() => channel.extraDataStream).thenAnswer( - (i) => Stream.value({ - 'name': 'test', - }), - ); - when(() => channel.extraData).thenReturn({ - 'name': 'test', - }); - - when(() => clientState.totalUnreadCount).thenReturn(10); - when(() => clientState.totalUnreadCountStream) - .thenAnswer((i) => Stream.value(10)); - - final materialTheme = ThemeData.light( - useMaterial3: false, - ); - - var theme = StreamChatThemeData.fromTheme(materialTheme); - theme = theme.copyWith( - ownMessageTheme: theme.ownMessageTheme.copyWith( - messageDeletedStyle: theme.ownMessageTheme.messageTextStyle!.copyWith( - fontWeight: FontWeight.bold, - color: Colors.red, - ), - ), - ); - - return MaterialAppWrapper( - theme: materialTheme, - home: StreamChat( - streamChatThemeData: theme, - client: client, - connectivityStream: Stream.value([ConnectivityResult.mobile]), - child: StreamChannel( - showLoading: false, - channel: channel, - child: Scaffold( - body: Center( - child: StreamDeletedMessage( - messageTheme: theme.ownMessageTheme, - reverse: true, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(8), - ), - ), - ), - ), - ), - ), - ); - }, - ); -} diff --git a/packages/stream_chat_flutter/test/src/message_widget/goldens/ci/deleted_message_custom.png b/packages/stream_chat_flutter/test/src/message_widget/goldens/ci/deleted_message_custom.png deleted file mode 100644 index ec3cb12740..0000000000 Binary files a/packages/stream_chat_flutter/test/src/message_widget/goldens/ci/deleted_message_custom.png and /dev/null differ diff --git a/packages/stream_chat_flutter/test/src/message_widget/goldens/ci/deleted_message_dark.png b/packages/stream_chat_flutter/test/src/message_widget/goldens/ci/deleted_message_dark.png deleted file mode 100644 index 0664144d1f..0000000000 Binary files a/packages/stream_chat_flutter/test/src/message_widget/goldens/ci/deleted_message_dark.png and /dev/null differ diff --git a/packages/stream_chat_flutter/test/src/message_widget/goldens/ci/deleted_message_light.png b/packages/stream_chat_flutter/test/src/message_widget/goldens/ci/deleted_message_light.png deleted file mode 100644 index 0eaf38d8c2..0000000000 Binary files a/packages/stream_chat_flutter/test/src/message_widget/goldens/ci/deleted_message_light.png and /dev/null differ diff --git a/packages/stream_chat_flutter/test/src/message_widget/goldens/ci/message_text.png b/packages/stream_chat_flutter/test/src/message_widget/goldens/ci/message_text.png deleted file mode 100644 index 656da4e1cc..0000000000 Binary files a/packages/stream_chat_flutter/test/src/message_widget/goldens/ci/message_text.png and /dev/null differ diff --git a/packages/stream_chat_flutter/test/src/message_widget/message_text_test.dart b/packages/stream_chat_flutter/test/src/message_widget/message_text_test.dart deleted file mode 100644 index 1b294f9c21..0000000000 --- a/packages/stream_chat_flutter/test/src/message_widget/message_text_test.dart +++ /dev/null @@ -1,251 +0,0 @@ -import 'package:alchemist/alchemist.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_markdown/flutter_markdown.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:mocktail/mocktail.dart'; -import 'package:stream_chat_flutter/stream_chat_flutter.dart'; - -import '../material_app_wrapper.dart'; -import '../mocks.dart'; -import '../simple_frame.dart'; - -void expectTextStrings(Iterable widgets, List strings) { - var currentString = 0; - for (final widget in widgets) { - if (widget is RichText) { - final span = widget.text as TextSpan; - final text = _extractTextFromTextSpan(span); - expect(text, equals(strings[currentString])); - currentString += 1; - } - } -} - -String _extractTextFromTextSpan(TextSpan span) { - var text = span.text ?? ''; - if (span.children != null) { - for (final child in span.children! as Iterable) { - text += _extractTextFromTextSpan(child); - } - } - return text; -} - -void main() { - testWidgets( - 'it should show correct message text', - (WidgetTester tester) async { - final currentUser = OwnUser(id: 'user-id'); - final client = MockClient(); - final clientState = MockClientState(); - final channel = MockChannel(); - final channelState = MockChannelState(); - final lastMessageAt = DateTime.parse('2020-06-22 12:00:00'); - final themeData = ThemeData(); - final streamTheme = StreamChatThemeData.fromTheme(themeData); - - when(() => client.state).thenReturn(clientState); - when(() => clientState.currentUser).thenReturn(currentUser); - when(() => clientState.currentUserStream) - .thenAnswer((_) => Stream.value(currentUser)); - when(() => channel.lastMessageAt).thenReturn(lastMessageAt); - when(() => channel.state).thenReturn(channelState); - when(() => channel.client).thenReturn(client); - when(() => channel.isMuted).thenReturn(false); - when(() => channel.isMutedStream).thenAnswer((i) => Stream.value(false)); - when(() => channel.extraDataStream).thenAnswer((i) => Stream.value({ - 'name': 'test', - })); - when(() => channel.extraData).thenReturn({ - 'name': 'test', - }); - - await tester.pumpWidget(MaterialApp( - home: StreamChat( - client: client, - child: StreamChannel( - channel: channel, - child: Scaffold( - body: StreamMessageText( - message: Message( - text: 'demo', - ), - messageTheme: streamTheme.otherMessageTheme), - ), - ), - ), - )); - - // wait for the initial state to be rendered. - await tester.pumpAndSettle(); - - expect(find.byType(MarkdownBody), findsOneWidget); - }, - ); - - group('Message with i18n field', () { - final client = MockClient(); - final clientState = MockClientState(); - final channel = MockChannel(); - final channelState = MockChannelState(); - const messageTheme = StreamMessageThemeData(); - - final currentUser = OwnUser( - id: 'sahil', - language: 'hi', - ); - - setUp(() { - when(() => client.state).thenReturn(clientState); - when(() => clientState.currentUser).thenReturn(currentUser); - when(() => clientState.currentUserStream) - .thenAnswer((_) => Stream.value(currentUser)); - - when(() => channel.state).thenReturn(channelState); - when(() => channel.client).thenReturn(client); - when(() => channel.isMuted).thenReturn(false); - when(() => channel.isMutedStream).thenAnswer((_) => Stream.value(false)); - }); - - testWidgets( - 'should show correct translated message text as per user language', - (WidgetTester tester) async { - final message = Message( - text: 'Hello', - i18n: const { - 'en_text': 'Hello', - 'hi_text': 'नमस्ते', - 'language': 'en', - }, - ); - - await tester.pumpWidget( - MaterialApp( - home: StreamChat( - client: client, - child: StreamChannel( - channel: channel, - child: Scaffold( - body: StreamMessageText( - message: message, - messageTheme: messageTheme, - ), - ), - ), - ), - ), - ); - - // wait for the initial state to be rendered. - await tester.pump(Duration.zero); - - expect(find.byType(MarkdownBody), findsOneWidget); - - final widgets = tester.allWidgets; - expectTextStrings(widgets, ['नमस्ते']); - }, - ); - - testWidgets( - '''should show default text if i18n does not contain translations as per user language''', - (WidgetTester tester) async { - final message = Message( - text: 'Hello', - i18n: const { - 'en_text': 'Hello', - 'fr_text': 'Bonjour', - 'language': 'en', - }, - ); - - await tester.pumpWidget( - MaterialApp( - home: StreamChat( - client: client, - child: StreamChannel( - channel: channel, - child: Scaffold( - body: StreamMessageText( - message: message, - messageTheme: messageTheme, - ), - ), - ), - ), - ), - ); - - // wait for the initial state to be rendered. - await tester.pump(Duration.zero); - - expect(find.byType(MarkdownBody), findsOneWidget); - - final widgets = tester.allWidgets; - expectTextStrings(widgets, ['Hello']); - }, - ); - }); - - goldenTest( - 'control test', - fileName: 'message_text', - constraints: const BoxConstraints.tightFor(width: 300, height: 200), - builder: () { - final currentUser = OwnUser(id: 'user-id'); - final client = MockClient(); - final clientState = MockClientState(); - final channel = MockChannel(); - final channelState = MockChannelState(); - final lastMessageAt = DateTime.parse('2020-06-22 12:00:00'); - final themeData = ThemeData(); - final streamTheme = StreamChatThemeData.fromTheme(themeData); - - when(() => client.state).thenReturn(clientState); - when(() => clientState.currentUser).thenReturn(currentUser); - when(() => clientState.currentUserStream) - .thenAnswer((_) => Stream.value(currentUser)); - when(() => channel.lastMessageAt).thenReturn(lastMessageAt); - when(() => channel.state).thenReturn(channelState); - when(() => channel.client).thenReturn(client); - when(() => channel.isMuted).thenReturn(false); - when(() => channel.isMutedStream).thenAnswer((i) => Stream.value(false)); - when(() => channel.extraDataStream).thenAnswer( - (i) => Stream.value({ - 'name': 'test', - }), - ); - when(() => channel.extraData).thenReturn({ - 'name': 'test', - }); - - const messageText = ''' -a message. -with multiple lines -and a list: -- a. okasd -- b lllll - -cool.'''; - - return MaterialAppWrapper( - home: SimpleFrame( - child: StreamChat( - client: client, - connectivityStream: Stream.value([ConnectivityResult.wifi]), - child: StreamChannel( - channel: channel, - child: Scaffold( - body: StreamMessageText( - message: Message( - text: messageText, - ), - messageTheme: streamTheme.otherMessageTheme, - ), - ), - ), - ), - ), - ); - }, - ); -} diff --git a/packages/stream_chat_flutter/test/src/message_widget/username_test.dart b/packages/stream_chat_flutter/test/src/message_widget/username_test.dart deleted file mode 100644 index bde411cb38..0000000000 --- a/packages/stream_chat_flutter/test/src/message_widget/username_test.dart +++ /dev/null @@ -1,23 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:stream_chat_flutter/src/message_widget/username.dart'; -import 'package:stream_chat_flutter/stream_chat_flutter.dart'; - -void main() { - testWidgets('Username', (tester) async { - await tester.pumpWidget( - MaterialApp( - home: Scaffold( - body: Center( - child: Username( - message: Message(), - messageTheme: StreamChatThemeData.light().ownMessageTheme, - ), - ), - ), - ), - ); - - expect(find.byType(Text), findsOneWidget); - }); -} diff --git a/packages/stream_chat_flutter/test/src/misc/audio_waveform_test.dart b/packages/stream_chat_flutter/test/src/misc/audio_waveform_test.dart index e9a8b75902..f9663694f9 100644 --- a/packages/stream_chat_flutter/test/src/misc/audio_waveform_test.dart +++ b/packages/stream_chat_flutter/test/src/misc/audio_waveform_test.dart @@ -2,7 +2,6 @@ import 'package:alchemist/alchemist.dart'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:mocktail/mocktail.dart'; -import 'package:stream_chat_flutter/src/misc/audio_waveform.dart'; import 'package:stream_chat_flutter/stream_chat_flutter.dart'; import '../mocks.dart'; @@ -215,16 +214,19 @@ Widget _wrapWithMaterialApp( Brightness? brightness, }) { return MaterialApp( + theme: ThemeData(brightness: brightness), debugShowCheckedModeBanner: false, home: StreamChatTheme( data: StreamChatThemeData(brightness: brightness), - child: Builder(builder: (context) { - final theme = StreamChatTheme.of(context); - return Scaffold( - backgroundColor: theme.colorTheme.appBg, - body: Center(child: widget), - ); - }), + child: Builder( + builder: (context) { + final theme = StreamChatTheme.of(context); + return Scaffold( + backgroundColor: theme.colorTheme.appBg, + body: Center(child: widget), + ); + }, + ), ), ); } diff --git a/packages/stream_chat_flutter/test/src/misc/back_button_test.dart b/packages/stream_chat_flutter/test/src/misc/back_button_test.dart index c4ae6efc2a..5025c69c86 100644 --- a/packages/stream_chat_flutter/test/src/misc/back_button_test.dart +++ b/packages/stream_chat_flutter/test/src/misc/back_button_test.dart @@ -122,8 +122,7 @@ void main() { when(() => client.state).thenReturn(clientState); when(() => clientState.totalUnreadCount).thenAnswer((_) => 0); - when(() => clientState.totalUnreadCountStream) - .thenAnswer((_) => Stream.value(0)); + when(() => clientState.totalUnreadCountStream).thenAnswer((_) => Stream.value(0)); await tester.pumpWidget( MaterialApp( diff --git a/packages/stream_chat_flutter/test/src/misc/date_divider_test.dart b/packages/stream_chat_flutter/test/src/misc/date_divider_test.dart index af500a2039..a9eb6fb83a 100644 --- a/packages/stream_chat_flutter/test/src/misc/date_divider_test.dart +++ b/packages/stream_chat_flutter/test/src/misc/date_divider_test.dart @@ -49,8 +49,7 @@ void main() { child: Scaffold( body: StreamDateDivider( dateTime: testDate, - formatter: (context, date) => - 'Custom: ${date.day}/${date.month}', + formatter: (context, date) => 'Custom: ${date.day}/${date.month}', ), ), ), diff --git a/packages/stream_chat_flutter/test/src/misc/goldens/ci/reaction_bubble_2.png b/packages/stream_chat_flutter/test/src/misc/goldens/ci/reaction_bubble_2.png index a7daaa3051..b982f08bc4 100644 Binary files a/packages/stream_chat_flutter/test/src/misc/goldens/ci/reaction_bubble_2.png and b/packages/stream_chat_flutter/test/src/misc/goldens/ci/reaction_bubble_2.png differ diff --git a/packages/stream_chat_flutter/test/src/misc/goldens/ci/reaction_bubble_3_dark.png b/packages/stream_chat_flutter/test/src/misc/goldens/ci/reaction_bubble_3_dark.png index 2a88d12d61..8cf9709e62 100644 Binary files a/packages/stream_chat_flutter/test/src/misc/goldens/ci/reaction_bubble_3_dark.png and b/packages/stream_chat_flutter/test/src/misc/goldens/ci/reaction_bubble_3_dark.png differ diff --git a/packages/stream_chat_flutter/test/src/misc/goldens/ci/reaction_bubble_3_light.png b/packages/stream_chat_flutter/test/src/misc/goldens/ci/reaction_bubble_3_light.png index a2f9afc0bb..4bef4095f2 100644 Binary files a/packages/stream_chat_flutter/test/src/misc/goldens/ci/reaction_bubble_3_light.png and b/packages/stream_chat_flutter/test/src/misc/goldens/ci/reaction_bubble_3_light.png differ diff --git a/packages/stream_chat_flutter/test/src/misc/goldens/ci/reaction_bubble_like_dark.png b/packages/stream_chat_flutter/test/src/misc/goldens/ci/reaction_bubble_like_dark.png index b9fdc003ce..f3cfab3aa4 100644 Binary files a/packages/stream_chat_flutter/test/src/misc/goldens/ci/reaction_bubble_like_dark.png and b/packages/stream_chat_flutter/test/src/misc/goldens/ci/reaction_bubble_like_dark.png differ diff --git a/packages/stream_chat_flutter/test/src/misc/goldens/ci/reaction_bubble_like_light.png b/packages/stream_chat_flutter/test/src/misc/goldens/ci/reaction_bubble_like_light.png index 5d5d6e1ba8..eef3745e53 100644 Binary files a/packages/stream_chat_flutter/test/src/misc/goldens/ci/reaction_bubble_like_light.png and b/packages/stream_chat_flutter/test/src/misc/goldens/ci/reaction_bubble_like_light.png differ diff --git a/packages/stream_chat_flutter/test/src/misc/goldens/ci/stream_audio_waveform_slider_custom_dark.png b/packages/stream_chat_flutter/test/src/misc/goldens/ci/stream_audio_waveform_slider_custom_dark.png index 8480b35ee1..f9b6f05925 100644 Binary files a/packages/stream_chat_flutter/test/src/misc/goldens/ci/stream_audio_waveform_slider_custom_dark.png and b/packages/stream_chat_flutter/test/src/misc/goldens/ci/stream_audio_waveform_slider_custom_dark.png differ diff --git a/packages/stream_chat_flutter/test/src/misc/goldens/ci/stream_audio_waveform_slider_custom_light.png b/packages/stream_chat_flutter/test/src/misc/goldens/ci/stream_audio_waveform_slider_custom_light.png index 38e1ce5bf7..32fe1bae91 100644 Binary files a/packages/stream_chat_flutter/test/src/misc/goldens/ci/stream_audio_waveform_slider_custom_light.png and b/packages/stream_chat_flutter/test/src/misc/goldens/ci/stream_audio_waveform_slider_custom_light.png differ diff --git a/packages/stream_chat_flutter/test/src/misc/goldens/ci/stream_audio_waveform_slider_dark.png b/packages/stream_chat_flutter/test/src/misc/goldens/ci/stream_audio_waveform_slider_dark.png index d0e6aab285..d15af71fc8 100644 Binary files a/packages/stream_chat_flutter/test/src/misc/goldens/ci/stream_audio_waveform_slider_dark.png and b/packages/stream_chat_flutter/test/src/misc/goldens/ci/stream_audio_waveform_slider_dark.png differ diff --git a/packages/stream_chat_flutter/test/src/misc/goldens/ci/stream_audio_waveform_slider_empty_dark.png b/packages/stream_chat_flutter/test/src/misc/goldens/ci/stream_audio_waveform_slider_empty_dark.png index 6d63fa50ba..f4809eeaee 100644 Binary files a/packages/stream_chat_flutter/test/src/misc/goldens/ci/stream_audio_waveform_slider_empty_dark.png and b/packages/stream_chat_flutter/test/src/misc/goldens/ci/stream_audio_waveform_slider_empty_dark.png differ diff --git a/packages/stream_chat_flutter/test/src/misc/goldens/ci/stream_audio_waveform_slider_empty_light.png b/packages/stream_chat_flutter/test/src/misc/goldens/ci/stream_audio_waveform_slider_empty_light.png index f914ffb2ad..9774ed5ad8 100644 Binary files a/packages/stream_chat_flutter/test/src/misc/goldens/ci/stream_audio_waveform_slider_empty_light.png and b/packages/stream_chat_flutter/test/src/misc/goldens/ci/stream_audio_waveform_slider_empty_light.png differ diff --git a/packages/stream_chat_flutter/test/src/misc/goldens/ci/stream_audio_waveform_slider_inverted_dark.png b/packages/stream_chat_flutter/test/src/misc/goldens/ci/stream_audio_waveform_slider_inverted_dark.png index 2d05d18ac3..de9e300313 100644 Binary files a/packages/stream_chat_flutter/test/src/misc/goldens/ci/stream_audio_waveform_slider_inverted_dark.png and b/packages/stream_chat_flutter/test/src/misc/goldens/ci/stream_audio_waveform_slider_inverted_dark.png differ diff --git a/packages/stream_chat_flutter/test/src/misc/goldens/ci/stream_audio_waveform_slider_inverted_light.png b/packages/stream_chat_flutter/test/src/misc/goldens/ci/stream_audio_waveform_slider_inverted_light.png index 1434322d64..a3a682b912 100644 Binary files a/packages/stream_chat_flutter/test/src/misc/goldens/ci/stream_audio_waveform_slider_inverted_light.png and b/packages/stream_chat_flutter/test/src/misc/goldens/ci/stream_audio_waveform_slider_inverted_light.png differ diff --git a/packages/stream_chat_flutter/test/src/misc/goldens/ci/stream_audio_waveform_slider_less_data_dark.png b/packages/stream_chat_flutter/test/src/misc/goldens/ci/stream_audio_waveform_slider_less_data_dark.png index 77a1e1299b..fa045a518a 100644 Binary files a/packages/stream_chat_flutter/test/src/misc/goldens/ci/stream_audio_waveform_slider_less_data_dark.png and b/packages/stream_chat_flutter/test/src/misc/goldens/ci/stream_audio_waveform_slider_less_data_dark.png differ diff --git a/packages/stream_chat_flutter/test/src/misc/goldens/ci/stream_audio_waveform_slider_less_data_light.png b/packages/stream_chat_flutter/test/src/misc/goldens/ci/stream_audio_waveform_slider_less_data_light.png index 12b942eebc..54385c30f5 100644 Binary files a/packages/stream_chat_flutter/test/src/misc/goldens/ci/stream_audio_waveform_slider_less_data_light.png and b/packages/stream_chat_flutter/test/src/misc/goldens/ci/stream_audio_waveform_slider_less_data_light.png differ diff --git a/packages/stream_chat_flutter/test/src/misc/goldens/ci/stream_audio_waveform_slider_light.png b/packages/stream_chat_flutter/test/src/misc/goldens/ci/stream_audio_waveform_slider_light.png index 26add2a992..66f6094871 100644 Binary files a/packages/stream_chat_flutter/test/src/misc/goldens/ci/stream_audio_waveform_slider_light.png and b/packages/stream_chat_flutter/test/src/misc/goldens/ci/stream_audio_waveform_slider_light.png differ diff --git a/packages/stream_chat_flutter/test/src/misc/goldens/ci/stream_audio_waveform_slider_progress_dark.png b/packages/stream_chat_flutter/test/src/misc/goldens/ci/stream_audio_waveform_slider_progress_dark.png index 9d1854a8e0..7e60fc17c7 100644 Binary files a/packages/stream_chat_flutter/test/src/misc/goldens/ci/stream_audio_waveform_slider_progress_dark.png and b/packages/stream_chat_flutter/test/src/misc/goldens/ci/stream_audio_waveform_slider_progress_dark.png differ diff --git a/packages/stream_chat_flutter/test/src/misc/goldens/ci/stream_audio_waveform_slider_progress_light.png b/packages/stream_chat_flutter/test/src/misc/goldens/ci/stream_audio_waveform_slider_progress_light.png index 4951487559..d7b8a4dfea 100644 Binary files a/packages/stream_chat_flutter/test/src/misc/goldens/ci/stream_audio_waveform_slider_progress_light.png and b/packages/stream_chat_flutter/test/src/misc/goldens/ci/stream_audio_waveform_slider_progress_light.png differ diff --git a/packages/stream_chat_flutter/test/src/misc/goldens/ci/stream_timestamp_dark.png b/packages/stream_chat_flutter/test/src/misc/goldens/ci/stream_timestamp_dark.png index 90adba2dc0..4581216051 100644 Binary files a/packages/stream_chat_flutter/test/src/misc/goldens/ci/stream_timestamp_dark.png and b/packages/stream_chat_flutter/test/src/misc/goldens/ci/stream_timestamp_dark.png differ diff --git a/packages/stream_chat_flutter/test/src/misc/goldens/ci/stream_timestamp_light.png b/packages/stream_chat_flutter/test/src/misc/goldens/ci/stream_timestamp_light.png index 68cab44d12..0fe2ab210e 100644 Binary files a/packages/stream_chat_flutter/test/src/misc/goldens/ci/stream_timestamp_light.png and b/packages/stream_chat_flutter/test/src/misc/goldens/ci/stream_timestamp_light.png differ diff --git a/packages/stream_chat_flutter/test/src/misc/goldens/ci/system_message_dark.png b/packages/stream_chat_flutter/test/src/misc/goldens/ci/system_message_dark.png index b4f7b3cf34..6a29e42a86 100644 Binary files a/packages/stream_chat_flutter/test/src/misc/goldens/ci/system_message_dark.png and b/packages/stream_chat_flutter/test/src/misc/goldens/ci/system_message_dark.png differ diff --git a/packages/stream_chat_flutter/test/src/misc/goldens/ci/system_message_light.png b/packages/stream_chat_flutter/test/src/misc/goldens/ci/system_message_light.png index c3a015a176..2dd28d99ba 100644 Binary files a/packages/stream_chat_flutter/test/src/misc/goldens/ci/system_message_light.png and b/packages/stream_chat_flutter/test/src/misc/goldens/ci/system_message_light.png differ diff --git a/packages/stream_chat_flutter/test/src/misc/reaction_bubble_test.dart b/packages/stream_chat_flutter/test/src/misc/reaction_bubble_test.dart deleted file mode 100644 index 2dd7ef0b19..0000000000 --- a/packages/stream_chat_flutter/test/src/misc/reaction_bubble_test.dart +++ /dev/null @@ -1,238 +0,0 @@ -import 'package:alchemist/alchemist.dart'; -import 'package:flutter/material.dart'; -import 'package:mocktail/mocktail.dart'; -import 'package:stream_chat_flutter/stream_chat_flutter.dart'; - -import '../material_app_wrapper.dart'; -import '../mocks.dart'; - -void main() { - goldenTest( - 'it should show a like - light theme', - fileName: 'reaction_bubble_like_light', - constraints: const BoxConstraints.tightFor(width: 100, height: 100), - builder: () { - final client = MockClient(); - final clientState = MockClientState(); - final themeData = ThemeData.light( - useMaterial3: false, - ); - - when(() => client.state).thenReturn(clientState); - when(() => clientState.currentUser).thenReturn(OwnUser(id: 'user-id')); - - final theme = StreamChatThemeData.fromTheme(themeData); - return MaterialAppWrapper( - theme: themeData, - home: StreamChat( - client: client, - streamChatThemeData: theme, - connectivityStream: Stream.value([ConnectivityResult.mobile]), - child: Scaffold( - body: Center( - child: StreamReactionBubble( - reactions: [ - Reaction( - type: 'like', - user: User(id: 'test'), - ), - ], - borderColor: theme.ownMessageTheme.reactionsBorderColor!, - backgroundColor: - theme.ownMessageTheme.reactionsBackgroundColor!, - maskColor: theme.ownMessageTheme.reactionsMaskColor!, - ), - ), - ), - ), - ); - }, - ); - - goldenTest( - 'it should show a like - dark theme', - fileName: 'reaction_bubble_like_dark', - constraints: const BoxConstraints.tightFor(width: 100, height: 100), - builder: () { - final client = MockClient(); - final clientState = MockClientState(); - final themeData = ThemeData.dark(); - final theme = StreamChatThemeData.fromTheme(themeData); - - when(() => client.state).thenReturn(clientState); - when(() => clientState.currentUser).thenReturn(OwnUser(id: 'user-id')); - - return MaterialAppWrapper( - theme: themeData, - home: StreamChat( - client: client, - streamChatThemeData: StreamChatThemeData.fromTheme(themeData), - connectivityStream: Stream.value([ConnectivityResult.mobile]), - child: Scaffold( - body: Center( - child: StreamReactionBubble( - reactions: [ - Reaction( - type: 'like', - user: User(id: 'test'), - ), - ], - borderColor: theme.ownMessageTheme.reactionsBorderColor!, - backgroundColor: - theme.ownMessageTheme.reactionsBackgroundColor!, - maskColor: theme.ownMessageTheme.reactionsMaskColor!, - ), - ), - ), - ), - ); - }, - ); - - goldenTest( - 'it should show three reactions - light theme', - fileName: 'reaction_bubble_3_light', - constraints: const BoxConstraints.tightFor(width: 140, height: 140), - builder: () { - final client = MockClient(); - final clientState = MockClientState(); - final themeData = ThemeData.light(); - final theme = StreamChatThemeData.fromTheme(themeData); - - when(() => client.state).thenReturn(clientState); - when(() => clientState.currentUser).thenReturn(OwnUser(id: 'user-id')); - - return MaterialAppWrapper( - theme: themeData, - home: StreamChat( - client: client, - streamChatThemeData: StreamChatThemeData.fromTheme(themeData), - connectivityStream: Stream.value([ConnectivityResult.mobile]), - child: Scaffold( - body: Center( - child: StreamReactionBubble( - reactions: [ - Reaction( - type: 'like', - user: User(id: 'test'), - ), - Reaction( - type: 'like', - user: User(id: 'user-id'), - ), - Reaction( - type: 'like', - user: User(id: 'test'), - ), - ], - borderColor: theme.ownMessageTheme.reactionsBorderColor!, - backgroundColor: - theme.ownMessageTheme.reactionsBackgroundColor!, - maskColor: theme.ownMessageTheme.reactionsMaskColor!, - ), - ), - ), - ), - ); - }, - ); - - goldenTest( - 'it should show three reactions - dark theme', - fileName: 'reaction_bubble_3_dark', - constraints: const BoxConstraints.tightFor(width: 140, height: 140), - builder: () { - final client = MockClient(); - final clientState = MockClientState(); - final themeData = ThemeData.dark(); - final theme = StreamChatThemeData.fromTheme(themeData); - - when(() => client.state).thenReturn(clientState); - when(() => clientState.currentUser).thenReturn(OwnUser(id: 'user-id')); - - return MaterialAppWrapper( - theme: themeData, - home: StreamChat( - client: client, - streamChatThemeData: StreamChatThemeData.fromTheme(themeData), - connectivityStream: Stream.value([ConnectivityResult.mobile]), - child: Scaffold( - body: Center( - child: StreamReactionBubble( - reactions: [ - Reaction( - type: 'like', - user: User(id: 'test'), - ), - Reaction( - type: 'like', - user: User(id: 'user-id'), - ), - Reaction( - type: 'like', - user: User(id: 'test'), - ), - ], - borderColor: theme.ownMessageTheme.reactionsBorderColor!, - backgroundColor: - theme.ownMessageTheme.reactionsBackgroundColor!, - maskColor: theme.ownMessageTheme.reactionsMaskColor!, - ), - ), - ), - ), - ); - }, - ); - - goldenTest( - 'it should show two reactions with customized ui', - fileName: 'reaction_bubble_2', - constraints: const BoxConstraints.tightFor(width: 200, height: 200), - builder: () { - final client = MockClient(); - final clientState = MockClientState(); - final themeData = ThemeData( - useMaterial3: false, - ); - - when(() => client.state).thenReturn(clientState); - when(() => clientState.currentUser).thenReturn(OwnUser(id: 'user-id')); - - return MaterialAppWrapper( - theme: themeData, - home: StreamChat( - client: client, - connectivityStream: Stream.value([ConnectivityResult.mobile]), - streamChatThemeData: StreamChatThemeData.fromTheme(themeData), - child: Scaffold( - body: Center( - child: StreamReactionBubble( - reactions: [ - Reaction( - type: 'like', - user: User(id: 'test'), - ), - Reaction( - type: 'love', - user: User(id: 'user-id'), - ), - Reaction( - type: 'unknown', - user: User(id: 'test'), - ), - ], - borderColor: Colors.red, - backgroundColor: Colors.blue, - maskColor: Colors.green, - reverse: true, - flipTail: true, - tailCirclesSpacing: 4, - ), - ), - ), - ), - ); - }, - ); -} diff --git a/packages/stream_chat_flutter/test/src/misc/system_message_test.dart b/packages/stream_chat_flutter/test/src/misc/system_message_test.dart index bbbb8dd896..4c8a81f60f 100644 --- a/packages/stream_chat_flutter/test/src/misc/system_message_test.dart +++ b/packages/stream_chat_flutter/test/src/misc/system_message_test.dart @@ -32,27 +32,28 @@ void main() { }); when(() => clientState.totalUnreadCount).thenReturn(10); - when(() => clientState.totalUnreadCountStream) - .thenAnswer((i) => Stream.value(10)); + when(() => clientState.totalUnreadCountStream).thenAnswer((i) => Stream.value(10)); var tapped = false; - await tester.pumpWidget(MaterialApp( - home: StreamChat( - client: client, - child: StreamChannel( - channel: channel, - child: Scaffold( - body: StreamSystemMessage( - onMessageTap: (m) => tapped = true, - message: Message( - text: 'demo message', + await tester.pumpWidget( + MaterialApp( + home: StreamChat( + client: client, + child: StreamChannel( + channel: channel, + child: Scaffold( + body: StreamSystemMessage( + onMessageTap: (m) => tapped = true, + message: Message( + text: 'demo message', + ), ), ), ), ), ), - )); + ); // wait for the initial state to be rendered. await tester.pumpAndSettle(); @@ -90,8 +91,7 @@ void main() { }); when(() => clientState.totalUnreadCount).thenReturn(10); - when(() => clientState.totalUnreadCountStream) - .thenAnswer((i) => Stream.value(10)); + when(() => clientState.totalUnreadCountStream).thenAnswer((i) => Stream.value(10)); return MaterialAppWrapper( theme: ThemeData.light(), @@ -102,11 +102,9 @@ void main() { showLoading: false, channel: channel, child: Scaffold( - body: Center( - child: StreamSystemMessage( - message: Message( - text: 'demo message', - ), + body: StreamSystemMessage( + message: Message( + text: 'demo message', ), ), ), @@ -142,8 +140,7 @@ void main() { }); when(() => clientState.totalUnreadCount).thenReturn(10); - when(() => clientState.totalUnreadCountStream) - .thenAnswer((i) => Stream.value(10)); + when(() => clientState.totalUnreadCountStream).thenAnswer((i) => Stream.value(10)); return MaterialAppWrapper( theme: ThemeData.dark(), @@ -154,11 +151,9 @@ void main() { showLoading: false, channel: channel, child: Scaffold( - body: Center( - child: StreamSystemMessage( - message: Message( - text: 'demo message', - ), + body: StreamSystemMessage( + message: Message( + text: 'demo message', ), ), ), diff --git a/packages/stream_chat_flutter/test/src/misc/thread_header_test.dart b/packages/stream_chat_flutter/test/src/misc/thread_header_test.dart index b7cbe4e96c..a51b819aab 100644 --- a/packages/stream_chat_flutter/test/src/misc/thread_header_test.dart +++ b/packages/stream_chat_flutter/test/src/misc/thread_header_test.dart @@ -27,14 +27,13 @@ void main() { when(() => channel.name).thenReturn('test'); when(() => channel.nameStream).thenAnswer((i) => Stream.value('test')); when(() => channelState.unreadCount).thenReturn(1); - when(() => channelState.unreadCountStream) - .thenAnswer((i) => Stream.value(1)); + when(() => channelState.unreadCountStream).thenAnswer((i) => Stream.value(1)); when(() => channelState.membersStream).thenAnswer( (i) => Stream.value([ Member( userId: 'user-id', user: User(id: 'user-id'), - ) + ), ]), ); when(() => channelState.members).thenReturn([ @@ -43,34 +42,32 @@ void main() { user: User(id: 'user-id'), ), ]); - when(() => client.wsConnectionStatusStream) - .thenAnswer((_) => Stream.value(ConnectionStatus.connecting)); + when(() => client.wsConnectionStatusStream).thenAnswer((_) => Stream.value(ConnectionStatus.connecting)); when(() => clientState.totalUnreadCount).thenAnswer((i) => 1); - when(() => clientState.totalUnreadCountStream) - .thenAnswer((i) => Stream.value(1)); + when(() => clientState.totalUnreadCountStream).thenAnswer((i) => Stream.value(1)); - await tester.pumpWidget(MaterialApp( - home: StreamChat( - client: client, - child: StreamChannel( - channel: channel, - child: Scaffold( - body: StreamThreadHeader( - parent: Message(), + await tester.pumpWidget( + MaterialApp( + home: StreamChat( + client: client, + child: StreamChannel( + channel: channel, + child: Scaffold( + body: StreamThreadHeader( + parent: Message(replyCount: 1), + subtitle: const Text('1 reply'), + ), ), ), ), ), - )); + ); // wait for the initial state to be rendered. await tester.pumpAndSettle(); - expect(find.text('with '), findsOneWidget); - expect(find.byType(StreamChannelName), findsOneWidget); - expect(find.byType(StreamBackButton), findsOneWidget); - expect(find.text('1'), findsOneWidget); - expect(find.text('Thread Reply'), findsOneWidget); + expect(find.text('1 reply'), findsOneWidget); + expect(find.text('Thread'), findsOneWidget); }, ); @@ -99,14 +96,13 @@ void main() { 'name': 'test', }); when(() => channelState.unreadCount).thenReturn(1); - when(() => channelState.unreadCountStream) - .thenAnswer((i) => Stream.value(1)); + when(() => channelState.unreadCountStream).thenAnswer((i) => Stream.value(1)); when(() => channelState.membersStream).thenAnswer( (i) => Stream.value([ Member( userId: 'user-id', user: User(id: 'user-id'), - ) + ), ]), ); when(() => channelState.members).thenReturn([ @@ -117,28 +113,28 @@ void main() { ]); var tapped = false; - await tester.pumpWidget(MaterialAppWrapper( - home: StreamChat( - client: client, - child: StreamChannel( - channel: channel, - child: Scaffold( - body: StreamThreadHeader( - parent: Message(), - subtitle: const Text('subtitle'), - leading: const Text('leading'), - title: const Text('title'), - onTitleTap: () { - tapped = true; - }, - actions: const [ - Text('action'), - ], + await tester.pumpWidget( + MaterialAppWrapper( + home: StreamChat( + client: client, + child: StreamChannel( + channel: channel, + child: Scaffold( + body: StreamThreadHeader( + parent: Message(), + subtitle: const Text('subtitle'), + leading: const Text('leading'), + title: GestureDetector( + onTap: () => tapped = true, + child: const Text('title'), + ), + trailing: const Text('action'), + ), ), ), ), ), - )); + ); // wait for the initial state to be rendered. await tester.pumpAndSettle(); diff --git a/packages/stream_chat_flutter/test/src/misc/timestamp_test.dart b/packages/stream_chat_flutter/test/src/misc/timestamp_test.dart index f08263ae77..0be181e0b6 100644 --- a/packages/stream_chat_flutter/test/src/misc/timestamp_test.dart +++ b/packages/stream_chat_flutter/test/src/misc/timestamp_test.dart @@ -34,13 +34,15 @@ Widget _wrapWithMaterialApp( return MaterialApp( home: StreamChatTheme( data: StreamChatThemeData(brightness: brightness), - child: Builder(builder: (context) { - final theme = StreamChatTheme.of(context); - return Scaffold( - backgroundColor: theme.colorTheme.appBg, - body: Center(child: widget), - ); - }), + child: Builder( + builder: (context) { + final theme = StreamChatTheme.of(context); + return Scaffold( + backgroundColor: theme.colorTheme.appBg, + body: Center(child: widget), + ); + }, + ), ), ); } diff --git a/packages/stream_chat_flutter/test/src/mocks.dart b/packages/stream_chat_flutter/test/src/mocks.dart index 5418d29303..8fd0231363 100644 --- a/packages/stream_chat_flutter/test/src/mocks.dart +++ b/packages/stream_chat_flutter/test/src/mocks.dart @@ -6,8 +6,8 @@ import 'package:stream_chat_flutter/stream_chat_flutter.dart'; class MockClient extends Mock implements StreamChatClient { MockClient() { when(() => wsConnectionStatus).thenReturn(ConnectionStatus.connected); - when(() => wsConnectionStatusStream) - .thenAnswer((_) => Stream.value(ConnectionStatus.connected)); + when(() => wsConnectionStatusStream).thenAnswer((_) => Stream.value(ConnectionStatus.connected)); + when(() => state).thenReturn(MockClientState()); } } @@ -21,7 +21,8 @@ class MockChannel extends Mock implements Channel { ChannelCapability.sendMessage, ChannelCapability.uploadFile, ], - }); + Stream? eventStream, + }) : _eventStream = eventStream ?? const Stream.empty(); @override final String type; @@ -61,6 +62,19 @@ class MockChannel extends Mock implements Channel { Future keyStroke([String? parentId]) async { return; } + + final Stream _eventStream; + + @override + Stream on([ + String? eventType, + String? eventType2, + String? eventType3, + String? eventType4, + ]) { + if (eventType == null) return _eventStream; + return _eventStream.where((e) => e.type == eventType); + } } class MockChannelState extends Mock implements ChannelClientState { @@ -95,8 +109,7 @@ class MockAttachment extends Mock implements Attachment {} class MockVlcManagerDesktop extends Mock implements VlcManagerDesktop {} -class MockStreamMemberListController extends Mock - implements StreamMemberListController { +class MockStreamMemberListController extends Mock implements StreamMemberListController { @override PagedValue value = const PagedValue.loading(); } diff --git a/packages/stream_chat_flutter/test/src/poll/creator/goldens/ci/poll_delete_option_dialog_dark.png b/packages/stream_chat_flutter/test/src/poll/creator/goldens/ci/poll_delete_option_dialog_dark.png index 365f508f16..eb2eadbb24 100644 Binary files a/packages/stream_chat_flutter/test/src/poll/creator/goldens/ci/poll_delete_option_dialog_dark.png and b/packages/stream_chat_flutter/test/src/poll/creator/goldens/ci/poll_delete_option_dialog_dark.png differ diff --git a/packages/stream_chat_flutter/test/src/poll/creator/goldens/ci/poll_delete_option_dialog_light.png b/packages/stream_chat_flutter/test/src/poll/creator/goldens/ci/poll_delete_option_dialog_light.png index 507c29ed23..eb2eadbb24 100644 Binary files a/packages/stream_chat_flutter/test/src/poll/creator/goldens/ci/poll_delete_option_dialog_light.png and b/packages/stream_chat_flutter/test/src/poll/creator/goldens/ci/poll_delete_option_dialog_light.png differ diff --git a/packages/stream_chat_flutter/test/src/poll/creator/goldens/ci/poll_option_reorderable_list_view_dark.png b/packages/stream_chat_flutter/test/src/poll/creator/goldens/ci/poll_option_reorderable_list_view_dark.png index f41c7e807d..d118215ec6 100644 Binary files a/packages/stream_chat_flutter/test/src/poll/creator/goldens/ci/poll_option_reorderable_list_view_dark.png and b/packages/stream_chat_flutter/test/src/poll/creator/goldens/ci/poll_option_reorderable_list_view_dark.png differ diff --git a/packages/stream_chat_flutter/test/src/poll/creator/goldens/ci/poll_option_reorderable_list_view_error_dark.png b/packages/stream_chat_flutter/test/src/poll/creator/goldens/ci/poll_option_reorderable_list_view_error_dark.png index 6cc73bd1f2..676c150338 100644 Binary files a/packages/stream_chat_flutter/test/src/poll/creator/goldens/ci/poll_option_reorderable_list_view_error_dark.png and b/packages/stream_chat_flutter/test/src/poll/creator/goldens/ci/poll_option_reorderable_list_view_error_dark.png differ diff --git a/packages/stream_chat_flutter/test/src/poll/creator/goldens/ci/poll_option_reorderable_list_view_error_light.png b/packages/stream_chat_flutter/test/src/poll/creator/goldens/ci/poll_option_reorderable_list_view_error_light.png index 6b7efe88a2..43b34de693 100644 Binary files a/packages/stream_chat_flutter/test/src/poll/creator/goldens/ci/poll_option_reorderable_list_view_error_light.png and b/packages/stream_chat_flutter/test/src/poll/creator/goldens/ci/poll_option_reorderable_list_view_error_light.png differ diff --git a/packages/stream_chat_flutter/test/src/poll/creator/goldens/ci/poll_option_reorderable_list_view_light.png b/packages/stream_chat_flutter/test/src/poll/creator/goldens/ci/poll_option_reorderable_list_view_light.png index b9948d4951..cd5d022e63 100644 Binary files a/packages/stream_chat_flutter/test/src/poll/creator/goldens/ci/poll_option_reorderable_list_view_light.png and b/packages/stream_chat_flutter/test/src/poll/creator/goldens/ci/poll_option_reorderable_list_view_light.png differ diff --git a/packages/stream_chat_flutter/test/src/poll/creator/goldens/ci/poll_question_text_field_dark.png b/packages/stream_chat_flutter/test/src/poll/creator/goldens/ci/poll_question_text_field_dark.png index ffb9a6793d..de93063695 100644 Binary files a/packages/stream_chat_flutter/test/src/poll/creator/goldens/ci/poll_question_text_field_dark.png and b/packages/stream_chat_flutter/test/src/poll/creator/goldens/ci/poll_question_text_field_dark.png differ diff --git a/packages/stream_chat_flutter/test/src/poll/creator/goldens/ci/poll_question_text_field_error_dark.png b/packages/stream_chat_flutter/test/src/poll/creator/goldens/ci/poll_question_text_field_error_dark.png index 60c54343d2..60903085b8 100644 Binary files a/packages/stream_chat_flutter/test/src/poll/creator/goldens/ci/poll_question_text_field_error_dark.png and b/packages/stream_chat_flutter/test/src/poll/creator/goldens/ci/poll_question_text_field_error_dark.png differ diff --git a/packages/stream_chat_flutter/test/src/poll/creator/goldens/ci/poll_question_text_field_error_light.png b/packages/stream_chat_flutter/test/src/poll/creator/goldens/ci/poll_question_text_field_error_light.png index dfdeaadcbc..0e835b3419 100644 Binary files a/packages/stream_chat_flutter/test/src/poll/creator/goldens/ci/poll_question_text_field_error_light.png and b/packages/stream_chat_flutter/test/src/poll/creator/goldens/ci/poll_question_text_field_error_light.png differ diff --git a/packages/stream_chat_flutter/test/src/poll/creator/goldens/ci/poll_question_text_field_light.png b/packages/stream_chat_flutter/test/src/poll/creator/goldens/ci/poll_question_text_field_light.png index 0ce9d73fb2..989d43be33 100644 Binary files a/packages/stream_chat_flutter/test/src/poll/creator/goldens/ci/poll_question_text_field_light.png and b/packages/stream_chat_flutter/test/src/poll/creator/goldens/ci/poll_question_text_field_light.png differ diff --git a/packages/stream_chat_flutter/test/src/poll/creator/goldens/ci/stream_poll_creator_dialog_dark.png b/packages/stream_chat_flutter/test/src/poll/creator/goldens/ci/stream_poll_creator_dialog_dark.png deleted file mode 100644 index bccb8b50d3..0000000000 Binary files a/packages/stream_chat_flutter/test/src/poll/creator/goldens/ci/stream_poll_creator_dialog_dark.png and /dev/null differ diff --git a/packages/stream_chat_flutter/test/src/poll/creator/goldens/ci/stream_poll_creator_dialog_light.png b/packages/stream_chat_flutter/test/src/poll/creator/goldens/ci/stream_poll_creator_dialog_light.png deleted file mode 100644 index f0ab2e22ba..0000000000 Binary files a/packages/stream_chat_flutter/test/src/poll/creator/goldens/ci/stream_poll_creator_dialog_light.png and /dev/null differ diff --git a/packages/stream_chat_flutter/test/src/poll/creator/goldens/ci/stream_poll_creator_full_screen_dialog_dark.png b/packages/stream_chat_flutter/test/src/poll/creator/goldens/ci/stream_poll_creator_full_screen_dialog_dark.png deleted file mode 100644 index 66e08bc545..0000000000 Binary files a/packages/stream_chat_flutter/test/src/poll/creator/goldens/ci/stream_poll_creator_full_screen_dialog_dark.png and /dev/null differ diff --git a/packages/stream_chat_flutter/test/src/poll/creator/goldens/ci/stream_poll_creator_full_screen_dialog_light.png b/packages/stream_chat_flutter/test/src/poll/creator/goldens/ci/stream_poll_creator_full_screen_dialog_light.png deleted file mode 100644 index 1e35c96ac7..0000000000 Binary files a/packages/stream_chat_flutter/test/src/poll/creator/goldens/ci/stream_poll_creator_full_screen_dialog_light.png and /dev/null differ diff --git a/packages/stream_chat_flutter/test/src/poll/creator/goldens/ci/stream_poll_creator_sheet_dark.png b/packages/stream_chat_flutter/test/src/poll/creator/goldens/ci/stream_poll_creator_sheet_dark.png new file mode 100644 index 0000000000..5cbe0e663e Binary files /dev/null and b/packages/stream_chat_flutter/test/src/poll/creator/goldens/ci/stream_poll_creator_sheet_dark.png differ diff --git a/packages/stream_chat_flutter/test/src/poll/creator/goldens/ci/stream_poll_creator_sheet_light.png b/packages/stream_chat_flutter/test/src/poll/creator/goldens/ci/stream_poll_creator_sheet_light.png new file mode 100644 index 0000000000..9ab8682257 Binary files /dev/null and b/packages/stream_chat_flutter/test/src/poll/creator/goldens/ci/stream_poll_creator_sheet_light.png differ diff --git a/packages/stream_chat_flutter/test/src/poll/creator/poll_option_reorderable_list_view_test.dart b/packages/stream_chat_flutter/test/src/poll/creator/poll_option_reorderable_list_view_test.dart index d0d6ab7e66..f98acf1af7 100644 --- a/packages/stream_chat_flutter/test/src/poll/creator/poll_option_reorderable_list_view_test.dart +++ b/packages/stream_chat_flutter/test/src/poll/creator/poll_option_reorderable_list_view_test.dart @@ -5,8 +5,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:stream_chat_flutter/src/poll/creator/poll_option_reorderable_list_view.dart'; import 'package:stream_chat_flutter/stream_chat_flutter.dart'; - -import '../../utils/finders.dart'; +import 'package:stream_core_flutter/stream_core_flutter.dart'; void main() { for (final brightness in Brightness.values) { @@ -53,15 +52,17 @@ void main() { testWidgets('should enforce minimum options requirement', (tester) async { var optionsChanged = []; - await tester.pumpWidget(_wrapWithMaterialApp( - PollOptionReorderableListView( - optionsRange: (min: 3, max: null), - initialOptions: [ - PollOptionItem(text: 'Option 1'), - ], - onOptionsChanged: (options) => optionsChanged = options, + await tester.pumpWidget( + _wrapWithMaterialApp( + PollOptionReorderableListView( + optionsRange: (min: 3, max: null), + initialOptions: [ + PollOptionItem(text: 'Option 1'), + ], + onOptionsChanged: (options) => optionsChanged = options, + ), ), - )); + ); // Should automatically add options to meet minimum requirement final textFields = find.byType(TextField); @@ -73,44 +74,42 @@ void main() { }); testWidgets('should respect maximum options limit', (tester) async { - await tester.pumpWidget(_wrapWithMaterialApp( - PollOptionReorderableListView( - optionsRange: (min: null, max: 3), - initialOptions: [ - PollOptionItem(text: 'Option 1'), - PollOptionItem(text: 'Option 2'), - PollOptionItem(text: 'Option 3'), - ], + await tester.pumpWidget( + _wrapWithMaterialApp( + PollOptionReorderableListView( + optionsRange: (min: null, max: 3), + initialOptions: [ + PollOptionItem(text: 'Option 1'), + PollOptionItem(text: 'Option 2'), + PollOptionItem(text: 'Option 3'), + ], + ), ), - )); - - // Find the add button - final addButton = find.byType(FilledButton); - expect(addButton, findsOneWidget); + ); - // The button should be disabled since we're at max options - final button = tester.widget(addButton); - expect(button.onPressed, isNull); + // The add button should be hidden since we're at max options. + expect(find.addOptionButton(), findsNothing); }); testWidgets('should respect both min and max options', (tester) async { - await tester.pumpWidget(_wrapWithMaterialApp( - PollOptionReorderableListView( - optionsRange: (min: 2, max: 4), - initialOptions: [ - PollOptionItem(text: 'Option 1'), - PollOptionItem(text: 'Option 2'), - ], + await tester.pumpWidget( + _wrapWithMaterialApp( + PollOptionReorderableListView( + optionsRange: (min: 2, max: 4), + initialOptions: [ + PollOptionItem(text: 'Option 1'), + PollOptionItem(text: 'Option 2'), + ], + ), ), - )); + ); // Should have 2 options initially (meeting minimum) final textFields = find.byType(TextField); expect(textFields, findsNWidgets(2)); // Add two more options to reach maximum - final addButton = find.byType(FilledButton); - await tester.tap(addButton); + await tester.tap(find.addOptionButton()); await tester.pumpAndSettle(); // Fill the newly added option so we can add another @@ -119,52 +118,54 @@ void main() { await tester.pumpAndSettle(); // Add one more option - await tester.tap(addButton); + await tester.tap(find.addOptionButton()); await tester.pumpAndSettle(); // Should now have 4 options (max reached) expect(find.byType(TextField), findsNWidgets(4)); - // Add button should now be disabled since we reached max - final button = tester.widget(addButton); - expect(button.onPressed, isNull); + // Add button should now be hidden since we reached max. + expect(find.addOptionButton(), findsNothing); }); testWidgets( 'should work with unlimited options when max is null', (tester) async { - await tester.pumpWidget(_wrapWithMaterialApp( - PollOptionReorderableListView( - optionsRange: (min: 2, max: null), - initialOptions: [ - PollOptionItem(text: 'Option 1'), - PollOptionItem(text: 'Option 2'), - ], + await tester.pumpWidget( + _wrapWithMaterialApp( + PollOptionReorderableListView( + optionsRange: (min: 2, max: null), + initialOptions: [ + PollOptionItem(text: 'Option 1'), + PollOptionItem(text: 'Option 2'), + ], + ), ), - )); + ); // Add button should be enabled for unlimited options - final addButton = find.byType(FilledButton); - final button = tester.widget(addButton); - expect(button.onPressed, isNotNull); + final addButton = find.addOptionButton(); + final button = tester.widget(addButton); + expect(button.props.onPressed, isNotNull); }, ); }); group('Auto-Focus Functionality', () { testWidgets('should auto-focus on newly added option', (tester) async { - await tester.pumpWidget(_wrapWithMaterialApp( - PollOptionReorderableListView( - optionsRange: (min: 1, max: null), - initialOptions: [ - PollOptionItem(text: 'Option 1'), - ], + await tester.pumpWidget( + _wrapWithMaterialApp( + PollOptionReorderableListView( + optionsRange: (min: 1, max: null), + initialOptions: [ + PollOptionItem(text: 'Option 1'), + ], + ), ), - )); + ); // Find the add button and tap it - final addButton = find.byType(FilledButton); - await tester.tap(addButton); + await tester.tap(find.addOptionButton()); await tester.pumpAndSettle(); // Verify that there are now 2 text fields @@ -180,79 +181,77 @@ void main() { group('Empty Options Prevention', () { testWidgets( - 'should disable add button when empty option exists', + 'should hide add button when empty option exists', (tester) async { - await tester.pumpWidget(_wrapWithMaterialApp( - PollOptionReorderableListView( - optionsRange: (min: null, max: 5), - initialOptions: [ - PollOptionItem(text: 'Option 1'), - PollOptionItem(text: 'Option 2'), - PollOptionItem(text: ''), // Empty option - ], + await tester.pumpWidget( + _wrapWithMaterialApp( + PollOptionReorderableListView( + optionsRange: (min: null, max: 5), + initialOptions: [ + PollOptionItem(text: 'Option 1'), + PollOptionItem(text: 'Option 2'), + PollOptionItem(text: ''), // Empty option + ], + ), ), - )); - - // Find the add button - final addButton = find.byType(FilledButton); - expect(addButton, findsOneWidget); + ); - // The button should be disabled since there's already an empty option - final button = tester.widget(addButton); - expect(button.onPressed, isNull); + // The button should be hidden since there's already an empty option. + expect(find.addOptionButton(), findsNothing); }, ); testWidgets( - 'should enable add button when no empty options exist', + 'should show add button when no empty options exist', (tester) async { - await tester.pumpWidget(_wrapWithMaterialApp( - PollOptionReorderableListView( - optionsRange: (min: null, max: 5), - initialOptions: [ - PollOptionItem(text: 'Option 1'), - PollOptionItem(text: 'Option 2'), - ], + await tester.pumpWidget( + _wrapWithMaterialApp( + PollOptionReorderableListView( + optionsRange: (min: null, max: 5), + initialOptions: [ + PollOptionItem(text: 'Option 1'), + PollOptionItem(text: 'Option 2'), + ], + ), ), - )); + ); - // Find the add button - final addButton = find.byType(FilledButton); + // The button should be visible since no empty options exist. + final addButton = find.addOptionButton(); expect(addButton, findsOneWidget); - - // The button should be enabled since no empty options exist - final button = tester.widget(addButton); - expect(button.onPressed, isNotNull); + final button = tester.widget(addButton); + expect(button.props.onPressed, isNotNull); }, ); testWidgets( - 'should re-enable add button after filling empty option', + 'should reveal add button after filling empty option', (tester) async { - await tester.pumpWidget(_wrapWithMaterialApp( - PollOptionReorderableListView( - optionsRange: (min: null, max: 5), - initialOptions: [ - PollOptionItem(text: 'Option 1'), - PollOptionItem(text: ''), // Empty option - ], + await tester.pumpWidget( + _wrapWithMaterialApp( + PollOptionReorderableListView( + optionsRange: (min: null, max: 5), + initialOptions: [ + PollOptionItem(text: 'Option 1'), + PollOptionItem(text: ''), // Empty option + ], + ), ), - )); + ); - // Initially, add button should be disabled - var addButton = find.byType(FilledButton); - var button = tester.widget(addButton); - expect(button.onPressed, isNull); + // Initially, the add button should be hidden. + expect(find.addOptionButton(), findsNothing); - // Fill the empty option + // Fill the empty option. final textFields = find.byType(TextField); await tester.enterText(textFields.last, 'Option 2'); await tester.pumpAndSettle(); - // Now add button should be enabled - addButton = find.byType(FilledButton); - button = tester.widget(addButton); - expect(button.onPressed, isNotNull); + // The add button should now be visible and enabled. + final addButton = find.addOptionButton(); + expect(addButton, findsOneWidget); + final button = tester.widget(addButton); + expect(button.props.onPressed, isNotNull); }, ); }); @@ -263,20 +262,21 @@ void main() { (tester) async { var optionsChanged = []; - await tester.pumpWidget(_wrapWithMaterialApp( - PollOptionReorderableListView( - optionsRange: (min: 2, max: 5), - initialOptions: [ - PollOptionItem(text: 'Option 1'), - PollOptionItem(text: 'Option 2'), - ], - onOptionsChanged: (options) => optionsChanged = options, + await tester.pumpWidget( + _wrapWithMaterialApp( + PollOptionReorderableListView( + optionsRange: (min: 2, max: 5), + initialOptions: [ + PollOptionItem(text: 'Option 1'), + PollOptionItem(text: 'Option 2'), + ], + onOptionsChanged: (options) => optionsChanged = options, + ), ), - )); + ); // Find the add button and tap it - final addButton = find.byType(FilledButton); - await tester.tap(addButton); + await tester.tap(find.addOptionButton()); await tester.pumpAndSettle(); // Verify a new option was added @@ -291,13 +291,15 @@ void main() { (tester) async { var optionsChanged = []; - await tester.pumpWidget(_wrapWithMaterialApp( - PollOptionReorderableListView( - optionsRange: (min: 2, max: null), - initialOptions: const [], // No initial options - onOptionsChanged: (options) => optionsChanged = options, + await tester.pumpWidget( + _wrapWithMaterialApp( + PollOptionReorderableListView( + optionsRange: (min: 2, max: null), + initialOptions: const [], // No initial options + onOptionsChanged: (options) => optionsChanged = options, + ), ), - )); + ); // Should auto-add options to meet minimum requirement final textFields = find.byType(TextField); @@ -307,27 +309,31 @@ void main() { ); testWidgets('should handle updating initial options', (tester) async { - await tester.pumpWidget(_wrapWithMaterialApp( - PollOptionReorderableListView( - initialOptions: [ - PollOptionItem(text: 'Option 1'), - ], + await tester.pumpWidget( + _wrapWithMaterialApp( + PollOptionReorderableListView( + initialOptions: [ + PollOptionItem(text: 'Option 1'), + ], + ), ), - )); + ); // Initially should have 1 option expect(find.byType(TextField), findsNWidgets(1)); // Update with new options - await tester.pumpWidget(_wrapWithMaterialApp( - PollOptionReorderableListView( - initialOptions: [ - PollOptionItem(text: 'Option 1'), - PollOptionItem(text: 'Option 2'), - PollOptionItem(text: 'Option 3'), - ], + await tester.pumpWidget( + _wrapWithMaterialApp( + PollOptionReorderableListView( + initialOptions: [ + PollOptionItem(text: 'Option 1'), + PollOptionItem(text: 'Option 2'), + PollOptionItem(text: 'Option 3'), + ], + ), ), - )); + ); // Should now have 3 options expect(find.byType(TextField), findsNWidgets(3)); @@ -336,19 +342,21 @@ void main() { group('Delete Option Functionality', () { testWidgets('should show delete confirmation dialog', (tester) async { - await tester.pumpWidget(_wrapWithMaterialApp( - PollOptionReorderableListView( - optionsRange: (min: 2, max: null), - initialOptions: [ - PollOptionItem(text: 'Option 1'), - PollOptionItem(text: 'Option 2'), - PollOptionItem(text: 'Option 3'), - ], + await tester.pumpWidget( + _wrapWithMaterialApp( + PollOptionReorderableListView( + optionsRange: (min: 2, max: null), + initialOptions: [ + PollOptionItem(text: 'Option 1'), + PollOptionItem(text: 'Option 2'), + PollOptionItem(text: 'Option 3'), + ], + ), ), - )); + ); // Find the delete buttons - final deleteButtons = find.bySvgIcon(StreamSvgIcons.delete); + final deleteButtons = find.byIcon(StreamIconData.minusCircle); expect(deleteButtons, findsNWidgets(3)); // Tap the first delete button @@ -361,35 +369,37 @@ void main() { find.text('Are you sure you want to delete this option?'), findsOneWidget, ); - expect(find.text('CANCEL'), findsOneWidget); - expect(find.text('DELETE'), findsOneWidget); + expect(find.text('Cancel'), findsOneWidget); + expect(find.text('Delete'), findsOneWidget); }); testWidgets('should delete option when confirmed', (tester) async { var optionsChanged = []; - await tester.pumpWidget(_wrapWithMaterialApp( - PollOptionReorderableListView( - optionsRange: (min: 2, max: null), - initialOptions: [ - PollOptionItem(text: 'Option 1'), - PollOptionItem(text: 'Option 2'), - PollOptionItem(text: 'Option 3'), - ], - onOptionsChanged: (options) => optionsChanged = options, + await tester.pumpWidget( + _wrapWithMaterialApp( + PollOptionReorderableListView( + optionsRange: (min: 2, max: null), + initialOptions: [ + PollOptionItem(text: 'Option 1'), + PollOptionItem(text: 'Option 2'), + PollOptionItem(text: 'Option 3'), + ], + onOptionsChanged: (options) => optionsChanged = options, + ), ), - )); + ); // Initially should have 3 options expect(find.byType(TextField), findsNWidgets(3)); // Find and tap the delete button for the first option - final deleteButtons = find.bySvgIcon(StreamSvgIcons.delete); + final deleteButtons = find.byIcon(StreamIconData.minusCircle); await tester.tap(deleteButtons.first); await tester.pumpAndSettle(); // Confirm deletion - await tester.tap(find.text('DELETE')); + await tester.tap(find.text('Delete')); await tester.pumpAndSettle(); // Should now have 2 options @@ -400,28 +410,30 @@ void main() { testWidgets('should not delete option when cancelled', (tester) async { var optionsChanged = []; - await tester.pumpWidget(_wrapWithMaterialApp( - PollOptionReorderableListView( - optionsRange: (min: 2, max: null), - initialOptions: [ - PollOptionItem(text: 'Option 1'), - PollOptionItem(text: 'Option 2'), - PollOptionItem(text: 'Option 3'), - ], - onOptionsChanged: (options) => optionsChanged = options, + await tester.pumpWidget( + _wrapWithMaterialApp( + PollOptionReorderableListView( + optionsRange: (min: 2, max: null), + initialOptions: [ + PollOptionItem(text: 'Option 1'), + PollOptionItem(text: 'Option 2'), + PollOptionItem(text: 'Option 3'), + ], + onOptionsChanged: (options) => optionsChanged = options, + ), ), - )); + ); // Initially should have 3 options expect(find.byType(TextField), findsNWidgets(3)); // Find and tap the delete button for the first option - final deleteButtons = find.bySvgIcon(StreamSvgIcons.delete); + final deleteButtons = find.byIcon(StreamIconData.minusCircle); await tester.tap(deleteButtons.first); await tester.pumpAndSettle(); // Cancel deletion - await tester.tap(find.text('CANCEL')); + await tester.tap(find.text('Cancel')); await tester.pumpAndSettle(); // Should still have 3 options @@ -436,24 +448,26 @@ void main() { final option1 = PollOptionItem(text: 'Option 1'); final option2 = PollOptionItem(text: 'Option 2'); - await tester.pumpWidget(_wrapWithMaterialApp( - PollOptionReorderableListView( - optionsRange: (min: 2, max: null), - initialOptions: [option1, option2], - onOptionsChanged: (options) => optionsChanged = options, + await tester.pumpWidget( + _wrapWithMaterialApp( + PollOptionReorderableListView( + optionsRange: (min: 2, max: null), + initialOptions: [option1, option2], + onOptionsChanged: (options) => optionsChanged = options, + ), ), - )); + ); // Should have 2 options (minimum) expect(find.byType(TextField), findsNWidgets(2)); // Try to delete the first option - final deleteButtons = find.bySvgIcon(StreamSvgIcons.delete); + final deleteButtons = find.byIcon(StreamIconData.minusCircle); await tester.tap(deleteButtons.first); await tester.pumpAndSettle(); // Confirm deletion - await tester.tap(find.text('DELETE')); + await tester.tap(find.text('Delete')); await tester.pumpAndSettle(); // Should still have 2 options (minimum enforced) @@ -476,6 +490,11 @@ void main() { }); } +extension on CommonFinders { + /// Finds the "Add an option" [StreamButton] at the bottom of the list. + Finder addOptionButton() => find.widgetWithText(StreamButton, 'Add an option'); +} + Widget _wrapWithMaterialApp( Widget widget, { Brightness? brightness, diff --git a/packages/stream_chat_flutter/test/src/poll/creator/poll_question_text_field_test.dart b/packages/stream_chat_flutter/test/src/poll/creator/poll_question_text_field_test.dart index 8a07c4e7a9..8a77888e51 100644 --- a/packages/stream_chat_flutter/test/src/poll/creator/poll_question_text_field_test.dart +++ b/packages/stream_chat_flutter/test/src/poll/creator/poll_question_text_field_test.dart @@ -47,18 +47,20 @@ Widget _wrapWithMaterialApp( return MaterialApp( home: StreamChatTheme( data: StreamChatThemeData(brightness: brightness), - child: Builder(builder: (context) { - final theme = StreamChatTheme.of(context); - return Scaffold( - backgroundColor: theme.colorTheme.appBg, - body: Center( - child: Padding( - padding: const EdgeInsets.all(8), - child: widget, + child: Builder( + builder: (context) { + final theme = StreamChatTheme.of(context); + return Scaffold( + backgroundColor: theme.colorTheme.appBg, + body: Center( + child: Padding( + padding: const EdgeInsets.all(8), + child: widget, + ), ), - ), - ); - }), + ); + }, + ), ), ); } diff --git a/packages/stream_chat_flutter/test/src/poll/creator/stream_delete_option_dialog_test.dart b/packages/stream_chat_flutter/test/src/poll/creator/stream_delete_option_dialog_test.dart index d12b0adfcd..6cd252321b 100644 --- a/packages/stream_chat_flutter/test/src/poll/creator/stream_delete_option_dialog_test.dart +++ b/packages/stream_chat_flutter/test/src/poll/creator/stream_delete_option_dialog_test.dart @@ -29,7 +29,7 @@ void main() { await tester.tap(find.text('Delete Option')); await tester.pumpAndSettle(); - await tester.tap(find.text('DELETE')); + await tester.tap(find.text('Delete')); await tester.pumpAndSettle(); expect(value, isTrue); @@ -59,7 +59,7 @@ void main() { await tester.tap(find.text('Delete Option')); await tester.pumpAndSettle(); - await tester.tap(find.text('CANCEL')); + await tester.tap(find.text('Cancel')); await tester.pumpAndSettle(); expect(value, isFalse); diff --git a/packages/stream_chat_flutter/test/src/poll/creator/stream_poll_creator_dialog_test.dart b/packages/stream_chat_flutter/test/src/poll/creator/stream_poll_creator_dialog_test.dart deleted file mode 100644 index 71bc9dc0e4..0000000000 --- a/packages/stream_chat_flutter/test/src/poll/creator/stream_poll_creator_dialog_test.dart +++ /dev/null @@ -1,42 +0,0 @@ -// ignore_for_file: lines_longer_than_80_chars - -import 'package:alchemist/alchemist.dart'; -import 'package:flutter/material.dart'; -import 'package:stream_chat_flutter/src/poll/creator/stream_poll_creator_dialog.dart'; -import 'package:stream_chat_flutter/src/theme/stream_chat_theme.dart'; - -void main() { - for (final brightness in Brightness.values) { - goldenTest( - '[${brightness.name}] -> StreamPollCreatorDialog should look fine', - fileName: 'stream_poll_creator_dialog_${brightness.name}', - constraints: const BoxConstraints.tightFor(width: 1280, height: 800), - builder: () => _wrapWithMaterialApp( - brightness: brightness, - const StreamPollCreatorDialog(), - ), - ); - - goldenTest( - '[${brightness.name}] -> StreamPollCreatorFullScreenDialog should look fine', - fileName: 'stream_poll_creator_full_screen_dialog_${brightness.name}', - constraints: const BoxConstraints.tightFor(width: 412, height: 916), - builder: () => _wrapWithMaterialApp( - brightness: brightness, - const StreamPollCreatorFullScreenDialog(), - ), - ); - } -} - -Widget _wrapWithMaterialApp( - Widget widget, { - Brightness? brightness, -}) { - return MaterialApp( - home: StreamChatTheme( - data: StreamChatThemeData(brightness: brightness), - child: widget, - ), - ); -} diff --git a/packages/stream_chat_flutter/test/src/poll/creator/stream_poll_creator_sheet_test.dart b/packages/stream_chat_flutter/test/src/poll/creator/stream_poll_creator_sheet_test.dart new file mode 100644 index 0000000000..df3d76cdc4 --- /dev/null +++ b/packages/stream_chat_flutter/test/src/poll/creator/stream_poll_creator_sheet_test.dart @@ -0,0 +1,44 @@ +// ignore_for_file: lines_longer_than_80_chars + +import 'package:alchemist/alchemist.dart'; +import 'package:flutter/material.dart'; +import 'package:stream_chat_flutter/src/poll/creator/stream_poll_creator_sheet.dart'; +import 'package:stream_chat_flutter/src/stream_chat_configuration.dart'; +import 'package:stream_chat_flutter/src/theme/stream_chat_theme.dart'; + +void main() { + for (final brightness in Brightness.values) { + goldenTest( + '[${brightness.name}] -> StreamPollCreatorSheet should look fine', + fileName: 'stream_poll_creator_sheet_${brightness.name}', + constraints: const BoxConstraints.tightFor(width: 412, height: 916), + builder: () => _wrapWithMaterialApp( + brightness: brightness, + const StreamPollCreatorSheet(), + ), + ); + } +} + +Widget _wrapWithMaterialApp( + Widget widget, { + Brightness? brightness, +}) { + return MaterialApp( + home: StreamChatConfiguration( + data: StreamChatConfigurationData(), + child: StreamChatTheme( + data: StreamChatThemeData(brightness: brightness), + child: Builder( + builder: (context) { + final theme = StreamChatTheme.of(context); + return Scaffold( + backgroundColor: theme.colorTheme.appBg, + body: widget, + ); + }, + ), + ), + ), + ); +} diff --git a/packages/stream_chat_flutter/test/src/poll/creator/stream_poll_creator_widget_test.dart b/packages/stream_chat_flutter/test/src/poll/creator/stream_poll_creator_widget_test.dart index ba45ab36cb..b1c74d2f89 100644 --- a/packages/stream_chat_flutter/test/src/poll/creator/stream_poll_creator_widget_test.dart +++ b/packages/stream_chat_flutter/test/src/poll/creator/stream_poll_creator_widget_test.dart @@ -1,8 +1,8 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:stream_chat_flutter/src/poll/creator/poll_config_option.dart'; import 'package:stream_chat_flutter/src/poll/creator/poll_option_reorderable_list_view.dart'; import 'package:stream_chat_flutter/src/poll/creator/poll_question_text_field.dart'; -import 'package:stream_chat_flutter/src/poll/creator/poll_switch_list_tile.dart'; import 'package:stream_chat_flutter/stream_chat_flutter.dart'; void main() { @@ -25,11 +25,10 @@ void main() { // Verify that the widget is rendered correctly expect(find.byType(PollQuestionTextField), findsOneWidget); expect(find.byType(PollOptionReorderableListView), findsOneWidget); - expect(find.byType(PollSwitchListTile), findsNWidgets(4)); + expect(find.byType(PollConfigOption), findsNWidgets(4)); }); - testWidgets('StreamPollCreatorWidget updates poll state correctly', - (tester) async { + testWidgets('StreamPollCreatorWidget updates poll state correctly', (tester) async { final controller = StreamPollController( config: const PollConfig( nameRange: (min: 1, max: 150), @@ -53,62 +52,63 @@ void main() { await tester.pumpAndSettle(); expect(controller.value.name, 'What is your favorite color?'); - await tester.tap(find.switchListTileText('Multiple answers')); + await tester.tap(find.switchTileText('Multiple answers')); await tester.pumpAndSettle(); expect(controller.value.enforceUniqueVote, false); - await tester.tap( - find.descendant( - of: find.byType(PollSwitchTextField), - matching: find.byType(Switch), - ), - ); + // The nested "Maximum votes per person" tile is now visible. + await tester.tap(find.switchTileText('Maximum votes per person')); await tester.pumpAndSettle(); - expect(controller.value.maxVotesAllowed, null); + expect(controller.value.maxVotesAllowed, 1); - await tester.enterText( - find.descendant( - of: find.byType(PollSwitchTextField), - matching: find.byType(TextField), - ), - '3', - ); - await tester.pumpAndSettle(); - expect(controller.value.maxVotesAllowed, 3); - - await tester.tap(find.switchListTileText('Anonymous poll')); + await tester.tap(find.switchTileText('Anonymous poll')); await tester.pumpAndSettle(); expect(controller.value.votingVisibility, VotingVisibility.anonymous); - await tester.tap(find.switchListTileText('Suggest an option')); + await tester.dragUntilVisible( + find.switchTileText('Suggest an option'), + find.byType(SingleChildScrollView), + const Offset(0, -100), + ); + + await tester.tap(find.switchTileText('Suggest an option')); await tester.pumpAndSettle(); expect(controller.value.allowUserSuggestedOptions, true); await tester.dragUntilVisible( - find.switchListTileText('Add a comment'), + find.switchTileText('Add a comment'), find.byType(SingleChildScrollView), - const Offset(0, 500), + const Offset(0, -100), ); - await tester.tap(find.switchListTileText('Add a comment')); + await tester.tap(find.switchTileText('Add a comment')); await tester.pumpAndSettle(); expect(controller.value.allowAnswers, true); }); } extension on CommonFinders { - Finder switchListTileText(String title) { - return ancestor( + Finder switchTileText(String title) { + final card = find.ancestor( of: find.text(title), - matching: find.byType(SwitchListTile), + matching: find.byType(PollConfigOption), ); + return find + .descendant( + of: card.first, + matching: find.byType(StreamSwitch), + ) + .first; } } -Widget _wrapWithMaterialApp(Widget widget) { +Widget _wrapWithMaterialApp( + Widget widget, { + Brightness? brightness, +}) { return MaterialApp( home: StreamChatTheme( - data: StreamChatThemeData(), + data: StreamChatThemeData(brightness: brightness), child: Scaffold( body: widget, ), diff --git a/packages/stream_chat_flutter/test/src/poll/goldens/ci/poll_option_reorderable_list_view_dark.png b/packages/stream_chat_flutter/test/src/poll/goldens/ci/poll_option_reorderable_list_view_dark.png index f41c7e807d..d118215ec6 100644 Binary files a/packages/stream_chat_flutter/test/src/poll/goldens/ci/poll_option_reorderable_list_view_dark.png and b/packages/stream_chat_flutter/test/src/poll/goldens/ci/poll_option_reorderable_list_view_dark.png differ diff --git a/packages/stream_chat_flutter/test/src/poll/goldens/ci/poll_option_reorderable_list_view_error.png b/packages/stream_chat_flutter/test/src/poll/goldens/ci/poll_option_reorderable_list_view_error.png index 6b7efe88a2..43b34de693 100644 Binary files a/packages/stream_chat_flutter/test/src/poll/goldens/ci/poll_option_reorderable_list_view_error.png and b/packages/stream_chat_flutter/test/src/poll/goldens/ci/poll_option_reorderable_list_view_error.png differ diff --git a/packages/stream_chat_flutter/test/src/poll/goldens/ci/poll_option_reorderable_list_view_light.png b/packages/stream_chat_flutter/test/src/poll/goldens/ci/poll_option_reorderable_list_view_light.png index b9948d4951..cd5d022e63 100644 Binary files a/packages/stream_chat_flutter/test/src/poll/goldens/ci/poll_option_reorderable_list_view_light.png and b/packages/stream_chat_flutter/test/src/poll/goldens/ci/poll_option_reorderable_list_view_light.png differ diff --git a/packages/stream_chat_flutter/test/src/poll/goldens/ci/poll_question_text_field_dark.png b/packages/stream_chat_flutter/test/src/poll/goldens/ci/poll_question_text_field_dark.png index ffb9a6793d..de93063695 100644 Binary files a/packages/stream_chat_flutter/test/src/poll/goldens/ci/poll_question_text_field_dark.png and b/packages/stream_chat_flutter/test/src/poll/goldens/ci/poll_question_text_field_dark.png differ diff --git a/packages/stream_chat_flutter/test/src/poll/goldens/ci/poll_question_text_field_error.png b/packages/stream_chat_flutter/test/src/poll/goldens/ci/poll_question_text_field_error.png index dfdeaadcbc..0e835b3419 100644 Binary files a/packages/stream_chat_flutter/test/src/poll/goldens/ci/poll_question_text_field_error.png and b/packages/stream_chat_flutter/test/src/poll/goldens/ci/poll_question_text_field_error.png differ diff --git a/packages/stream_chat_flutter/test/src/poll/goldens/ci/poll_question_text_field_light.png b/packages/stream_chat_flutter/test/src/poll/goldens/ci/poll_question_text_field_light.png index 0ce9d73fb2..989d43be33 100644 Binary files a/packages/stream_chat_flutter/test/src/poll/goldens/ci/poll_question_text_field_light.png and b/packages/stream_chat_flutter/test/src/poll/goldens/ci/poll_question_text_field_light.png differ diff --git a/packages/stream_chat_flutter/test/src/poll/goldens/ci/stream_poll_creator_dialog_dark.png b/packages/stream_chat_flutter/test/src/poll/goldens/ci/stream_poll_creator_dialog_dark.png deleted file mode 100644 index bccb8b50d3..0000000000 Binary files a/packages/stream_chat_flutter/test/src/poll/goldens/ci/stream_poll_creator_dialog_dark.png and /dev/null differ diff --git a/packages/stream_chat_flutter/test/src/poll/goldens/ci/stream_poll_creator_dialog_light.png b/packages/stream_chat_flutter/test/src/poll/goldens/ci/stream_poll_creator_dialog_light.png deleted file mode 100644 index f0ab2e22ba..0000000000 Binary files a/packages/stream_chat_flutter/test/src/poll/goldens/ci/stream_poll_creator_dialog_light.png and /dev/null differ diff --git a/packages/stream_chat_flutter/test/src/poll/goldens/ci/stream_poll_creator_full_screen_dialog_dark.png b/packages/stream_chat_flutter/test/src/poll/goldens/ci/stream_poll_creator_full_screen_dialog_dark.png deleted file mode 100644 index 66e08bc545..0000000000 Binary files a/packages/stream_chat_flutter/test/src/poll/goldens/ci/stream_poll_creator_full_screen_dialog_dark.png and /dev/null differ diff --git a/packages/stream_chat_flutter/test/src/poll/goldens/ci/stream_poll_creator_full_screen_dialog_light.png b/packages/stream_chat_flutter/test/src/poll/goldens/ci/stream_poll_creator_full_screen_dialog_light.png deleted file mode 100644 index 1e35c96ac7..0000000000 Binary files a/packages/stream_chat_flutter/test/src/poll/goldens/ci/stream_poll_creator_full_screen_dialog_light.png and /dev/null differ diff --git a/packages/stream_chat_flutter/test/src/poll/goldens/ci/stream_poll_options_dialog_dark.png b/packages/stream_chat_flutter/test/src/poll/goldens/ci/stream_poll_options_dialog_dark.png index ec1ddc32ad..4481a069b6 100644 Binary files a/packages/stream_chat_flutter/test/src/poll/goldens/ci/stream_poll_options_dialog_dark.png and b/packages/stream_chat_flutter/test/src/poll/goldens/ci/stream_poll_options_dialog_dark.png differ diff --git a/packages/stream_chat_flutter/test/src/poll/goldens/ci/stream_poll_options_dialog_light.png b/packages/stream_chat_flutter/test/src/poll/goldens/ci/stream_poll_options_dialog_light.png index 75b26f4636..99acd1d71f 100644 Binary files a/packages/stream_chat_flutter/test/src/poll/goldens/ci/stream_poll_options_dialog_light.png and b/packages/stream_chat_flutter/test/src/poll/goldens/ci/stream_poll_options_dialog_light.png differ diff --git a/packages/stream_chat_flutter/test/src/poll/goldens/ci/stream_poll_options_sheet_dark.png b/packages/stream_chat_flutter/test/src/poll/goldens/ci/stream_poll_options_sheet_dark.png new file mode 100644 index 0000000000..0e2a283af0 Binary files /dev/null and b/packages/stream_chat_flutter/test/src/poll/goldens/ci/stream_poll_options_sheet_dark.png differ diff --git a/packages/stream_chat_flutter/test/src/poll/goldens/ci/stream_poll_options_sheet_light.png b/packages/stream_chat_flutter/test/src/poll/goldens/ci/stream_poll_options_sheet_light.png new file mode 100644 index 0000000000..86dfabfc01 Binary files /dev/null and b/packages/stream_chat_flutter/test/src/poll/goldens/ci/stream_poll_options_sheet_light.png differ diff --git a/packages/stream_chat_flutter/test/src/poll/goldens/ci/stream_poll_results_dialog_dark.png b/packages/stream_chat_flutter/test/src/poll/goldens/ci/stream_poll_results_dialog_dark.png index 0b7d62a345..5e5af1f17e 100644 Binary files a/packages/stream_chat_flutter/test/src/poll/goldens/ci/stream_poll_results_dialog_dark.png and b/packages/stream_chat_flutter/test/src/poll/goldens/ci/stream_poll_results_dialog_dark.png differ diff --git a/packages/stream_chat_flutter/test/src/poll/goldens/ci/stream_poll_results_dialog_light.png b/packages/stream_chat_flutter/test/src/poll/goldens/ci/stream_poll_results_dialog_light.png index 96b3b2e4e0..ae2f84ec54 100644 Binary files a/packages/stream_chat_flutter/test/src/poll/goldens/ci/stream_poll_results_dialog_light.png and b/packages/stream_chat_flutter/test/src/poll/goldens/ci/stream_poll_results_dialog_light.png differ diff --git a/packages/stream_chat_flutter/test/src/poll/goldens/ci/stream_poll_results_dialog_with_show_all_dark.png b/packages/stream_chat_flutter/test/src/poll/goldens/ci/stream_poll_results_dialog_with_show_all_dark.png index 025eabde0c..469a292081 100644 Binary files a/packages/stream_chat_flutter/test/src/poll/goldens/ci/stream_poll_results_dialog_with_show_all_dark.png and b/packages/stream_chat_flutter/test/src/poll/goldens/ci/stream_poll_results_dialog_with_show_all_dark.png differ diff --git a/packages/stream_chat_flutter/test/src/poll/goldens/ci/stream_poll_results_dialog_with_show_all_light.png b/packages/stream_chat_flutter/test/src/poll/goldens/ci/stream_poll_results_dialog_with_show_all_light.png index cd3061ecdc..9f26c876db 100644 Binary files a/packages/stream_chat_flutter/test/src/poll/goldens/ci/stream_poll_results_dialog_with_show_all_light.png and b/packages/stream_chat_flutter/test/src/poll/goldens/ci/stream_poll_results_dialog_with_show_all_light.png differ diff --git a/packages/stream_chat_flutter/test/src/poll/goldens/ci/stream_poll_results_sheet_dark.png b/packages/stream_chat_flutter/test/src/poll/goldens/ci/stream_poll_results_sheet_dark.png new file mode 100644 index 0000000000..a9199e74b5 Binary files /dev/null and b/packages/stream_chat_flutter/test/src/poll/goldens/ci/stream_poll_results_sheet_dark.png differ diff --git a/packages/stream_chat_flutter/test/src/poll/goldens/ci/stream_poll_results_sheet_light.png b/packages/stream_chat_flutter/test/src/poll/goldens/ci/stream_poll_results_sheet_light.png new file mode 100644 index 0000000000..6c25f83f31 Binary files /dev/null and b/packages/stream_chat_flutter/test/src/poll/goldens/ci/stream_poll_results_sheet_light.png differ diff --git a/packages/stream_chat_flutter/test/src/poll/goldens/ci/stream_poll_results_sheet_with_show_all_dark.png b/packages/stream_chat_flutter/test/src/poll/goldens/ci/stream_poll_results_sheet_with_show_all_dark.png new file mode 100644 index 0000000000..9c032a8708 Binary files /dev/null and b/packages/stream_chat_flutter/test/src/poll/goldens/ci/stream_poll_results_sheet_with_show_all_dark.png differ diff --git a/packages/stream_chat_flutter/test/src/poll/goldens/ci/stream_poll_results_sheet_with_show_all_light.png b/packages/stream_chat_flutter/test/src/poll/goldens/ci/stream_poll_results_sheet_with_show_all_light.png new file mode 100644 index 0000000000..320721ae0d Binary files /dev/null and b/packages/stream_chat_flutter/test/src/poll/goldens/ci/stream_poll_results_sheet_with_show_all_light.png differ diff --git a/packages/stream_chat_flutter/test/src/poll/interactor/goldens/ci/poll_add_comment_dialog_dark.png b/packages/stream_chat_flutter/test/src/poll/interactor/goldens/ci/poll_add_comment_dialog_dark.png index 5886d2ed80..8f0e562191 100644 Binary files a/packages/stream_chat_flutter/test/src/poll/interactor/goldens/ci/poll_add_comment_dialog_dark.png and b/packages/stream_chat_flutter/test/src/poll/interactor/goldens/ci/poll_add_comment_dialog_dark.png differ diff --git a/packages/stream_chat_flutter/test/src/poll/interactor/goldens/ci/poll_add_comment_dialog_light.png b/packages/stream_chat_flutter/test/src/poll/interactor/goldens/ci/poll_add_comment_dialog_light.png index 1548f48b97..8f0e562191 100644 Binary files a/packages/stream_chat_flutter/test/src/poll/interactor/goldens/ci/poll_add_comment_dialog_light.png and b/packages/stream_chat_flutter/test/src/poll/interactor/goldens/ci/poll_add_comment_dialog_light.png differ diff --git a/packages/stream_chat_flutter/test/src/poll/interactor/goldens/ci/poll_add_comment_dialog_with_initial_value_dark.png b/packages/stream_chat_flutter/test/src/poll/interactor/goldens/ci/poll_add_comment_dialog_with_initial_value_dark.png index 2d57a6786e..ae654b983d 100644 Binary files a/packages/stream_chat_flutter/test/src/poll/interactor/goldens/ci/poll_add_comment_dialog_with_initial_value_dark.png and b/packages/stream_chat_flutter/test/src/poll/interactor/goldens/ci/poll_add_comment_dialog_with_initial_value_dark.png differ diff --git a/packages/stream_chat_flutter/test/src/poll/interactor/goldens/ci/poll_add_comment_dialog_with_initial_value_light.png b/packages/stream_chat_flutter/test/src/poll/interactor/goldens/ci/poll_add_comment_dialog_with_initial_value_light.png index f0e42978e5..ae654b983d 100644 Binary files a/packages/stream_chat_flutter/test/src/poll/interactor/goldens/ci/poll_add_comment_dialog_with_initial_value_light.png and b/packages/stream_chat_flutter/test/src/poll/interactor/goldens/ci/poll_add_comment_dialog_with_initial_value_light.png differ diff --git a/packages/stream_chat_flutter/test/src/poll/interactor/goldens/ci/poll_end_vote_dialog_dark.png b/packages/stream_chat_flutter/test/src/poll/interactor/goldens/ci/poll_end_vote_dialog_dark.png index 33575c2c74..522fa8bee0 100644 Binary files a/packages/stream_chat_flutter/test/src/poll/interactor/goldens/ci/poll_end_vote_dialog_dark.png and b/packages/stream_chat_flutter/test/src/poll/interactor/goldens/ci/poll_end_vote_dialog_dark.png differ diff --git a/packages/stream_chat_flutter/test/src/poll/interactor/goldens/ci/poll_end_vote_dialog_light.png b/packages/stream_chat_flutter/test/src/poll/interactor/goldens/ci/poll_end_vote_dialog_light.png index 4f80cb952f..522fa8bee0 100644 Binary files a/packages/stream_chat_flutter/test/src/poll/interactor/goldens/ci/poll_end_vote_dialog_light.png and b/packages/stream_chat_flutter/test/src/poll/interactor/goldens/ci/poll_end_vote_dialog_light.png differ diff --git a/packages/stream_chat_flutter/test/src/poll/interactor/goldens/ci/poll_header_dark.png b/packages/stream_chat_flutter/test/src/poll/interactor/goldens/ci/poll_header_dark.png index 7f546a1b99..65f1794b38 100644 Binary files a/packages/stream_chat_flutter/test/src/poll/interactor/goldens/ci/poll_header_dark.png and b/packages/stream_chat_flutter/test/src/poll/interactor/goldens/ci/poll_header_dark.png differ diff --git a/packages/stream_chat_flutter/test/src/poll/interactor/goldens/ci/poll_header_light.png b/packages/stream_chat_flutter/test/src/poll/interactor/goldens/ci/poll_header_light.png index 2e47747f1c..375c7b8b60 100644 Binary files a/packages/stream_chat_flutter/test/src/poll/interactor/goldens/ci/poll_header_light.png and b/packages/stream_chat_flutter/test/src/poll/interactor/goldens/ci/poll_header_light.png differ diff --git a/packages/stream_chat_flutter/test/src/poll/interactor/goldens/ci/poll_header_long_question_dark.png b/packages/stream_chat_flutter/test/src/poll/interactor/goldens/ci/poll_header_long_question_dark.png index 866b92da65..c8619f89a5 100644 Binary files a/packages/stream_chat_flutter/test/src/poll/interactor/goldens/ci/poll_header_long_question_dark.png and b/packages/stream_chat_flutter/test/src/poll/interactor/goldens/ci/poll_header_long_question_dark.png differ diff --git a/packages/stream_chat_flutter/test/src/poll/interactor/goldens/ci/poll_header_long_question_light.png b/packages/stream_chat_flutter/test/src/poll/interactor/goldens/ci/poll_header_long_question_light.png index 4c8650e0d7..dc46957c29 100644 Binary files a/packages/stream_chat_flutter/test/src/poll/interactor/goldens/ci/poll_header_long_question_light.png and b/packages/stream_chat_flutter/test/src/poll/interactor/goldens/ci/poll_header_long_question_light.png differ diff --git a/packages/stream_chat_flutter/test/src/poll/interactor/goldens/ci/poll_header_subtitle_voting_mode_all_dark.png b/packages/stream_chat_flutter/test/src/poll/interactor/goldens/ci/poll_header_subtitle_voting_mode_all_dark.png index 06ef0acf10..6e321373a7 100644 Binary files a/packages/stream_chat_flutter/test/src/poll/interactor/goldens/ci/poll_header_subtitle_voting_mode_all_dark.png and b/packages/stream_chat_flutter/test/src/poll/interactor/goldens/ci/poll_header_subtitle_voting_mode_all_dark.png differ diff --git a/packages/stream_chat_flutter/test/src/poll/interactor/goldens/ci/poll_header_subtitle_voting_mode_all_light.png b/packages/stream_chat_flutter/test/src/poll/interactor/goldens/ci/poll_header_subtitle_voting_mode_all_light.png index 32f08a3db9..6b0fc025b2 100644 Binary files a/packages/stream_chat_flutter/test/src/poll/interactor/goldens/ci/poll_header_subtitle_voting_mode_all_light.png and b/packages/stream_chat_flutter/test/src/poll/interactor/goldens/ci/poll_header_subtitle_voting_mode_all_light.png differ diff --git a/packages/stream_chat_flutter/test/src/poll/interactor/goldens/ci/poll_header_subtitle_voting_mode_disabled_dark.png b/packages/stream_chat_flutter/test/src/poll/interactor/goldens/ci/poll_header_subtitle_voting_mode_disabled_dark.png index db9034b504..f184ea8d46 100644 Binary files a/packages/stream_chat_flutter/test/src/poll/interactor/goldens/ci/poll_header_subtitle_voting_mode_disabled_dark.png and b/packages/stream_chat_flutter/test/src/poll/interactor/goldens/ci/poll_header_subtitle_voting_mode_disabled_dark.png differ diff --git a/packages/stream_chat_flutter/test/src/poll/interactor/goldens/ci/poll_header_subtitle_voting_mode_disabled_light.png b/packages/stream_chat_flutter/test/src/poll/interactor/goldens/ci/poll_header_subtitle_voting_mode_disabled_light.png index 012afaf352..ce1143f7ab 100644 Binary files a/packages/stream_chat_flutter/test/src/poll/interactor/goldens/ci/poll_header_subtitle_voting_mode_disabled_light.png and b/packages/stream_chat_flutter/test/src/poll/interactor/goldens/ci/poll_header_subtitle_voting_mode_disabled_light.png differ diff --git a/packages/stream_chat_flutter/test/src/poll/interactor/goldens/ci/poll_header_subtitle_voting_mode_limited_dark.png b/packages/stream_chat_flutter/test/src/poll/interactor/goldens/ci/poll_header_subtitle_voting_mode_limited_dark.png index 73177c6775..4b06c19324 100644 Binary files a/packages/stream_chat_flutter/test/src/poll/interactor/goldens/ci/poll_header_subtitle_voting_mode_limited_dark.png and b/packages/stream_chat_flutter/test/src/poll/interactor/goldens/ci/poll_header_subtitle_voting_mode_limited_dark.png differ diff --git a/packages/stream_chat_flutter/test/src/poll/interactor/goldens/ci/poll_header_subtitle_voting_mode_limited_light.png b/packages/stream_chat_flutter/test/src/poll/interactor/goldens/ci/poll_header_subtitle_voting_mode_limited_light.png index 87bc90e9c9..3704324551 100644 Binary files a/packages/stream_chat_flutter/test/src/poll/interactor/goldens/ci/poll_header_subtitle_voting_mode_limited_light.png and b/packages/stream_chat_flutter/test/src/poll/interactor/goldens/ci/poll_header_subtitle_voting_mode_limited_light.png differ diff --git a/packages/stream_chat_flutter/test/src/poll/interactor/goldens/ci/poll_header_subtitle_voting_mode_unique_dark.png b/packages/stream_chat_flutter/test/src/poll/interactor/goldens/ci/poll_header_subtitle_voting_mode_unique_dark.png index 7f546a1b99..65f1794b38 100644 Binary files a/packages/stream_chat_flutter/test/src/poll/interactor/goldens/ci/poll_header_subtitle_voting_mode_unique_dark.png and b/packages/stream_chat_flutter/test/src/poll/interactor/goldens/ci/poll_header_subtitle_voting_mode_unique_dark.png differ diff --git a/packages/stream_chat_flutter/test/src/poll/interactor/goldens/ci/poll_header_subtitle_voting_mode_unique_light.png b/packages/stream_chat_flutter/test/src/poll/interactor/goldens/ci/poll_header_subtitle_voting_mode_unique_light.png index 2e47747f1c..375c7b8b60 100644 Binary files a/packages/stream_chat_flutter/test/src/poll/interactor/goldens/ci/poll_header_subtitle_voting_mode_unique_light.png and b/packages/stream_chat_flutter/test/src/poll/interactor/goldens/ci/poll_header_subtitle_voting_mode_unique_light.png differ diff --git a/packages/stream_chat_flutter/test/src/poll/interactor/goldens/ci/poll_suggest_option_dialog_dark.png b/packages/stream_chat_flutter/test/src/poll/interactor/goldens/ci/poll_suggest_option_dialog_dark.png index 1ef86b9ee9..bdbf32dadb 100644 Binary files a/packages/stream_chat_flutter/test/src/poll/interactor/goldens/ci/poll_suggest_option_dialog_dark.png and b/packages/stream_chat_flutter/test/src/poll/interactor/goldens/ci/poll_suggest_option_dialog_dark.png differ diff --git a/packages/stream_chat_flutter/test/src/poll/interactor/goldens/ci/poll_suggest_option_dialog_light.png b/packages/stream_chat_flutter/test/src/poll/interactor/goldens/ci/poll_suggest_option_dialog_light.png index 87af27ccac..c1621b48f1 100644 Binary files a/packages/stream_chat_flutter/test/src/poll/interactor/goldens/ci/poll_suggest_option_dialog_light.png and b/packages/stream_chat_flutter/test/src/poll/interactor/goldens/ci/poll_suggest_option_dialog_light.png differ diff --git a/packages/stream_chat_flutter/test/src/poll/interactor/goldens/ci/poll_suggest_option_dialog_with_initial_option_dark.png b/packages/stream_chat_flutter/test/src/poll/interactor/goldens/ci/poll_suggest_option_dialog_with_initial_option_dark.png index d1ed1d271f..3efb8c2da6 100644 Binary files a/packages/stream_chat_flutter/test/src/poll/interactor/goldens/ci/poll_suggest_option_dialog_with_initial_option_dark.png and b/packages/stream_chat_flutter/test/src/poll/interactor/goldens/ci/poll_suggest_option_dialog_with_initial_option_dark.png differ diff --git a/packages/stream_chat_flutter/test/src/poll/interactor/goldens/ci/poll_suggest_option_dialog_with_initial_option_light.png b/packages/stream_chat_flutter/test/src/poll/interactor/goldens/ci/poll_suggest_option_dialog_with_initial_option_light.png index 77c4f22054..b8a4a03607 100644 Binary files a/packages/stream_chat_flutter/test/src/poll/interactor/goldens/ci/poll_suggest_option_dialog_with_initial_option_light.png and b/packages/stream_chat_flutter/test/src/poll/interactor/goldens/ci/poll_suggest_option_dialog_with_initial_option_light.png differ diff --git a/packages/stream_chat_flutter/test/src/poll/interactor/goldens/ci/stream_poll_interactor_closed_dark.png b/packages/stream_chat_flutter/test/src/poll/interactor/goldens/ci/stream_poll_interactor_closed_dark.png index 20e74ef186..5576824161 100644 Binary files a/packages/stream_chat_flutter/test/src/poll/interactor/goldens/ci/stream_poll_interactor_closed_dark.png and b/packages/stream_chat_flutter/test/src/poll/interactor/goldens/ci/stream_poll_interactor_closed_dark.png differ diff --git a/packages/stream_chat_flutter/test/src/poll/interactor/goldens/ci/stream_poll_interactor_closed_light.png b/packages/stream_chat_flutter/test/src/poll/interactor/goldens/ci/stream_poll_interactor_closed_light.png index 2297e20808..6f34d71ed8 100644 Binary files a/packages/stream_chat_flutter/test/src/poll/interactor/goldens/ci/stream_poll_interactor_closed_light.png and b/packages/stream_chat_flutter/test/src/poll/interactor/goldens/ci/stream_poll_interactor_closed_light.png differ diff --git a/packages/stream_chat_flutter/test/src/poll/interactor/goldens/ci/stream_poll_interactor_dark.png b/packages/stream_chat_flutter/test/src/poll/interactor/goldens/ci/stream_poll_interactor_dark.png index c4a779c87c..e2d7900636 100644 Binary files a/packages/stream_chat_flutter/test/src/poll/interactor/goldens/ci/stream_poll_interactor_dark.png and b/packages/stream_chat_flutter/test/src/poll/interactor/goldens/ci/stream_poll_interactor_dark.png differ diff --git a/packages/stream_chat_flutter/test/src/poll/interactor/goldens/ci/stream_poll_interactor_light.png b/packages/stream_chat_flutter/test/src/poll/interactor/goldens/ci/stream_poll_interactor_light.png index 0930a1f988..c6d108482f 100644 Binary files a/packages/stream_chat_flutter/test/src/poll/interactor/goldens/ci/stream_poll_interactor_light.png and b/packages/stream_chat_flutter/test/src/poll/interactor/goldens/ci/stream_poll_interactor_light.png differ diff --git a/packages/stream_chat_flutter/test/src/poll/interactor/poll_end_vote_dialog_test.dart b/packages/stream_chat_flutter/test/src/poll/interactor/poll_end_vote_dialog_test.dart index a255dac23d..e260fee957 100644 --- a/packages/stream_chat_flutter/test/src/poll/interactor/poll_end_vote_dialog_test.dart +++ b/packages/stream_chat_flutter/test/src/poll/interactor/poll_end_vote_dialog_test.dart @@ -31,7 +31,7 @@ void main() { await tester.tap(find.text('End Vote')); await tester.pumpAndSettle(); - await tester.tap(find.text('END')); + await tester.tap(find.text('End')); await tester.pumpAndSettle(); expect(value, isTrue); @@ -61,7 +61,7 @@ void main() { await tester.tap(find.text('End Vote')); await tester.pumpAndSettle(); - await tester.tap(find.text('CANCEL')); + await tester.tap(find.text('Cancel')); await tester.pumpAndSettle(); expect(value, isFalse); @@ -102,7 +102,7 @@ void main() { goldenTest( '[${brightness.name}] -> PollEndVoteDialog looks fine', fileName: 'poll_end_vote_dialog_${brightness.name}', - constraints: const BoxConstraints.tightFor(width: 400, height: 200), + constraints: const BoxConstraints.tightFor(width: 400, height: 220), builder: () => _wrapWithMaterialApp( brightness: brightness, const PollEndVoteDialog(), diff --git a/packages/stream_chat_flutter/test/src/poll/interactor/poll_footer_test.dart b/packages/stream_chat_flutter/test/src/poll/interactor/poll_footer_test.dart index 1a61932345..53fa68ee13 100644 --- a/packages/stream_chat_flutter/test/src/poll/interactor/poll_footer_test.dart +++ b/packages/stream_chat_flutter/test/src/poll/interactor/poll_footer_test.dart @@ -3,6 +3,7 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:stream_chat_flutter/src/poll/interactor/poll_footer.dart'; import 'package:stream_chat_flutter/src/theme/stream_chat_theme.dart'; import 'package:stream_chat_flutter_core/stream_chat_flutter_core.dart'; +import 'package:stream_core_flutter/stream_core_flutter.dart'; void main() async { final currentUser = User(id: 'user-1', name: 'User'); @@ -18,44 +19,48 @@ void main() async { ); testWidgets( - 'End Vote button is visible and enabled for the creator on open poll', + 'End Poll button is visible and enabled for the creator on open poll', (WidgetTester tester) async { - await tester.pumpWidget(_wrapWithMaterialApp( - PollFooter( - poll: poll.copyWith(createdBy: currentUser), - currentUser: currentUser, - onEndVote: () {}, + await tester.pumpWidget( + _wrapWithMaterialApp( + PollFooter( + poll: poll.copyWith(createdBy: currentUser), + currentUser: currentUser, + onEndVote: () {}, + ), ), - )); + ); final endVoteButton = find.ancestor( - of: find.text('End Vote'), - matching: find.byType(PollFooterButton), + of: find.text('End Poll'), + matching: find.byType(StreamButton), ); expect(endVoteButton, findsOneWidget); expect( - tester.widget(endVoteButton).onPressed, + tester.widget(endVoteButton).props.onPressed, isNotNull, ); }, ); testWidgets( - 'End Vote button is not visible for non-creator', + 'End Poll button is not visible for non-creator', (WidgetTester tester) async { - await tester.pumpWidget(_wrapWithMaterialApp( - PollFooter( - poll: poll, - currentUser: currentUser, - onEndVote: () {}, + await tester.pumpWidget( + _wrapWithMaterialApp( + PollFooter( + poll: poll, + currentUser: currentUser, + onEndVote: () {}, + ), ), - )); + ); final endVoteButton = find.ancestor( - of: find.text('End Vote'), - matching: find.byType(PollFooterButton), + of: find.text('End Poll'), + matching: find.byType(StreamButton), ); expect(endVoteButton, findsNothing); @@ -63,22 +68,24 @@ void main() async { ); testWidgets( - 'End Vote button is not visible for closed poll', + 'End Poll button is not visible for closed poll', (WidgetTester tester) async { - await tester.pumpWidget(_wrapWithMaterialApp( - PollFooter( - poll: poll.copyWith( - isClosed: true, - createdBy: currentUser, + await tester.pumpWidget( + _wrapWithMaterialApp( + PollFooter( + poll: poll.copyWith( + isClosed: true, + createdBy: currentUser, + ), + currentUser: currentUser, + onEndVote: () {}, ), - currentUser: currentUser, - onEndVote: () {}, ), - )); + ); final endVoteButton = find.ancestor( - of: find.text('End Vote'), - matching: find.byType(PollFooterButton), + of: find.text('End Poll'), + matching: find.byType(StreamButton), ); expect(endVoteButton, findsNothing); @@ -88,22 +95,24 @@ void main() async { testWidgets( 'Add Comment button is visible and enabled when poll allows answers', (WidgetTester tester) async { - await tester.pumpWidget(_wrapWithMaterialApp( - PollFooter( - poll: poll.copyWith(allowAnswers: true), - currentUser: currentUser, - onAddComment: () {}, + await tester.pumpWidget( + _wrapWithMaterialApp( + PollFooter( + poll: poll.copyWith(allowAnswers: true), + currentUser: currentUser, + onAddComment: () {}, + ), ), - )); + ); final addCommentButton = find.ancestor( of: find.text('Add a comment'), - matching: find.byType(PollFooterButton), + matching: find.byType(StreamButton), ); expect(addCommentButton, findsOneWidget); expect( - tester.widget(addCommentButton).onPressed, + tester.widget(addCommentButton).props.onPressed, isNotNull, ); }, @@ -112,20 +121,22 @@ void main() async { testWidgets( 'Add Comment button is not visible when poll is closed', (WidgetTester tester) async { - await tester.pumpWidget(_wrapWithMaterialApp( - PollFooter( - poll: poll.copyWith( - isClosed: true, - allowAnswers: true, + await tester.pumpWidget( + _wrapWithMaterialApp( + PollFooter( + poll: poll.copyWith( + isClosed: true, + allowAnswers: true, + ), + currentUser: currentUser, + onAddComment: () {}, ), - currentUser: currentUser, - onAddComment: () {}, ), - )); + ); final addCommentButton = find.ancestor( of: find.text('Add a comment'), - matching: find.byType(PollFooterButton), + matching: find.byType(StreamButton), ); expect(addCommentButton, findsNothing); @@ -135,22 +146,24 @@ void main() async { testWidgets( 'View Comments button is visible and enabled if there are answers', (WidgetTester tester) async { - await tester.pumpWidget(_wrapWithMaterialApp( - PollFooter( - poll: poll.copyWith(answersCount: 1), - currentUser: currentUser, - onViewComments: () {}, + await tester.pumpWidget( + _wrapWithMaterialApp( + PollFooter( + poll: poll.copyWith(answersCount: 1), + currentUser: currentUser, + onViewComments: () {}, + ), ), - )); + ); final viewCommentsButton = find.ancestor( of: find.text('View Comments'), - matching: find.byType(PollFooterButton), + matching: find.byType(StreamButton), ); expect(viewCommentsButton, findsOneWidget); expect( - tester.widget(viewCommentsButton).onPressed, + tester.widget(viewCommentsButton).props.onPressed, isNotNull, ); }, @@ -159,19 +172,21 @@ void main() async { testWidgets( 'View Comments button is not visible when there are no answers', (WidgetTester tester) async { - await tester.pumpWidget(_wrapWithMaterialApp( - PollFooter( - poll: poll.copyWith( - answersCount: 0, + await tester.pumpWidget( + _wrapWithMaterialApp( + PollFooter( + poll: poll.copyWith( + answersCount: 0, + ), + currentUser: currentUser, + onViewComments: () {}, ), - currentUser: currentUser, - onViewComments: () {}, ), - )); + ); final viewCommentsButton = find.ancestor( of: find.text('View Comments'), - matching: find.byType(PollFooterButton), + matching: find.byType(StreamButton), ); expect(viewCommentsButton, findsNothing); @@ -181,24 +196,26 @@ void main() async { testWidgets( 'Suggest Option button is visible and enabled when allowed', (WidgetTester tester) async { - await tester.pumpWidget(_wrapWithMaterialApp( - PollFooter( - poll: poll.copyWith( - allowUserSuggestedOptions: true, + await tester.pumpWidget( + _wrapWithMaterialApp( + PollFooter( + poll: poll.copyWith( + allowUserSuggestedOptions: true, + ), + currentUser: currentUser, + onSuggestOption: () {}, ), - currentUser: currentUser, - onSuggestOption: () {}, ), - )); + ); final suggestOptionButton = find.ancestor( of: find.text('Suggest an option'), - matching: find.byType(PollFooterButton), + matching: find.byType(StreamButton), ); expect(suggestOptionButton, findsOneWidget); expect( - tester.widget(suggestOptionButton).onPressed, + tester.widget(suggestOptionButton).props.onPressed, isNotNull, ); }, @@ -207,20 +224,22 @@ void main() async { testWidgets( 'Suggest Option button is not visible when poll is closed', (WidgetTester tester) async { - await tester.pumpWidget(_wrapWithMaterialApp( - PollFooter( - poll: poll.copyWith( - isClosed: true, - allowUserSuggestedOptions: true, + await tester.pumpWidget( + _wrapWithMaterialApp( + PollFooter( + poll: poll.copyWith( + isClosed: true, + allowUserSuggestedOptions: true, + ), + currentUser: currentUser, + onSuggestOption: () {}, ), - currentUser: currentUser, - onSuggestOption: () {}, ), - )); + ); final suggestOptionButton = find.ancestor( of: find.text('Suggest an option'), - matching: find.byType(PollFooterButton), + matching: find.byType(StreamButton), ); expect(suggestOptionButton, findsNothing); @@ -242,19 +261,19 @@ void main() async { final viewResultsButton = find.ancestor( of: find.text('View Results'), - matching: find.byType(PollFooterButton), + matching: find.byType(StreamButton), ); expect(viewResultsButton, findsOneWidget); expect( - tester.widget(viewResultsButton).onPressed, + tester.widget(viewResultsButton).props.onPressed, isNotNull, ); }, ); testWidgets( - 'View Results button is disabled if there are no votes', + 'View Results button is not visible if there are no votes', (WidgetTester tester) async { await tester.pumpWidget( _wrapWithMaterialApp( @@ -268,63 +287,10 @@ void main() async { final viewResultsButton = find.ancestor( of: find.text('View Results'), - matching: find.byType(PollFooterButton), - ); - - expect(viewResultsButton, findsOneWidget); - expect( - tester.widget(viewResultsButton).onPressed, - isNull, - ); - }, - ); - - testWidgets( - 'See More Options button is visible if there are more options', - (WidgetTester tester) async { - await tester.pumpWidget( - _wrapWithMaterialApp( - PollFooter( - poll: poll, - visibleOptionCount: 2, - currentUser: currentUser, - onSeeMoreOptions: () {}, - ), - ), - ); - - final seeMoreOptionsButton = find.ancestor( - of: find.text('See all ${poll.options.length} options'), - matching: find.byType(PollFooterButton), - ); - - expect(seeMoreOptionsButton, findsOneWidget); - expect( - tester.widget(seeMoreOptionsButton).onPressed, - isNotNull, - ); - }, - ); - - testWidgets( - 'See More Options button is not visible when all options are visible', - (WidgetTester tester) async { - await tester.pumpWidget( - _wrapWithMaterialApp( - PollFooter( - poll: poll, - currentUser: currentUser, - onSeeMoreOptions: () {}, - ), - ), - ); - - final seeMoreOptionsButton = find.ancestor( - of: find.text('See all ${poll.options.length} options'), - matching: find.byType(PollFooterButton), + matching: find.byType(StreamButton), ); - expect(seeMoreOptionsButton, findsNothing); + expect(viewResultsButton, findsNothing); }, ); } diff --git a/packages/stream_chat_flutter/test/src/poll/interactor/poll_header_test.dart b/packages/stream_chat_flutter/test/src/poll/interactor/poll_header_test.dart index 301dd5d5f5..2f2324316b 100644 --- a/packages/stream_chat_flutter/test/src/poll/interactor/poll_header_test.dart +++ b/packages/stream_chat_flutter/test/src/poll/interactor/poll_header_test.dart @@ -21,7 +21,7 @@ void main() { goldenTest( '[${brightness.name}] -> PollHeader looks fine', fileName: 'poll_header_${brightness.name}', - constraints: const BoxConstraints.tightFor(width: 300, height: 100), + constraints: const BoxConstraints.tightFor(width: 300, height: 120), builder: () => _wrapWithMaterialApp( brightness: brightness, PollHeader(poll: poll), @@ -31,7 +31,7 @@ void main() { goldenTest( '[${brightness.name}] -> PollHeader with long question looks fine', fileName: 'poll_header_long_question_${brightness.name}', - constraints: const BoxConstraints.tightFor(width: 300, height: 100), + constraints: const BoxConstraints.tightFor(width: 300, height: 150), builder: () => _wrapWithMaterialApp( brightness: brightness, PollHeader( @@ -45,7 +45,7 @@ void main() { goldenTest( '[${brightness.name}] -> PollHeader subtitle with voting mode disabled looks fine', fileName: 'poll_header_subtitle_voting_mode_disabled_${brightness.name}', - constraints: const BoxConstraints.tightFor(width: 300, height: 100), + constraints: const BoxConstraints.tightFor(width: 300, height: 120), builder: () => _wrapWithMaterialApp( brightness: brightness, PollHeader( @@ -57,7 +57,7 @@ void main() { goldenTest( '[${brightness.name}] -> PollHeader subtitle with voting mode unique looks fine', fileName: 'poll_header_subtitle_voting_mode_unique_${brightness.name}', - constraints: const BoxConstraints.tightFor(width: 300, height: 100), + constraints: const BoxConstraints.tightFor(width: 300, height: 120), builder: () => _wrapWithMaterialApp( brightness: brightness, PollHeader( @@ -69,7 +69,7 @@ void main() { goldenTest( '[${brightness.name}] -> PollHeader subtitle with voting mode limited looks fine', fileName: 'poll_header_subtitle_voting_mode_limited_${brightness.name}', - constraints: const BoxConstraints.tightFor(width: 300, height: 100), + constraints: const BoxConstraints.tightFor(width: 300, height: 120), builder: () => _wrapWithMaterialApp( brightness: brightness, PollHeader( @@ -81,7 +81,7 @@ void main() { goldenTest( '[${brightness.name}] -> PollHeader subtitle with voting mode all looks fine', fileName: 'poll_header_subtitle_voting_mode_all_${brightness.name}', - constraints: const BoxConstraints.tightFor(width: 300, height: 100), + constraints: const BoxConstraints.tightFor(width: 300, height: 120), builder: () => _wrapWithMaterialApp( brightness: brightness, PollHeader( @@ -99,17 +99,19 @@ Widget _wrapWithMaterialApp( return MaterialApp( home: StreamChatTheme( data: StreamChatThemeData(brightness: brightness), - child: Builder(builder: (context) { - final theme = StreamChatTheme.of(context); - return Scaffold( - backgroundColor: theme.colorTheme.appBg, - body: Container( - color: theme.colorTheme.disabled, - padding: const EdgeInsets.all(16), - child: Center(child: widget), - ), - ); - }), + child: Builder( + builder: (context) { + final theme = StreamChatTheme.of(context); + return Scaffold( + backgroundColor: theme.colorTheme.appBg, + body: Container( + color: theme.colorTheme.disabled, + padding: const EdgeInsets.all(16), + child: Center(child: widget), + ), + ); + }, + ), ), ); } diff --git a/packages/stream_chat_flutter/test/src/poll/interactor/poll_suggest_option_dialog_test.dart b/packages/stream_chat_flutter/test/src/poll/interactor/poll_suggest_option_dialog_test.dart index fb66e438df..3cb4b0bd11 100644 --- a/packages/stream_chat_flutter/test/src/poll/interactor/poll_suggest_option_dialog_test.dart +++ b/packages/stream_chat_flutter/test/src/poll/interactor/poll_suggest_option_dialog_test.dart @@ -19,8 +19,7 @@ void main() { goldenTest( '[${brightness.name}] -> PollSuggestOptionDialog with initialOption looks fine', - fileName: - 'poll_suggest_option_dialog_with_initial_option_${brightness.name}', + fileName: 'poll_suggest_option_dialog_with_initial_option_${brightness.name}', constraints: const BoxConstraints.tightFor(width: 600, height: 300), builder: () => _wrapWithMaterialApp( brightness: brightness, @@ -39,17 +38,19 @@ Widget _wrapWithMaterialApp( return MaterialApp( home: StreamChatTheme( data: StreamChatThemeData(brightness: brightness), - child: Builder(builder: (context) { - final theme = StreamChatTheme.of(context); - return Scaffold( - backgroundColor: theme.colorTheme.appBg, - body: Container( - color: theme.colorTheme.disabled, - padding: const EdgeInsets.all(16), - child: Center(child: widget), - ), - ); - }), + child: Builder( + builder: (context) { + final theme = StreamChatTheme.of(context); + return Scaffold( + backgroundColor: theme.colorTheme.appBg, + body: Container( + color: theme.colorTheme.disabled, + padding: const EdgeInsets.all(16), + child: Center(child: widget), + ), + ); + }, + ), ), ); } diff --git a/packages/stream_chat_flutter/test/src/poll/interactor/stream_poll_interactor_test.dart b/packages/stream_chat_flutter/test/src/poll/interactor/stream_poll_interactor_test.dart index acc34896a3..edbdb33d95 100644 --- a/packages/stream_chat_flutter/test/src/poll/interactor/stream_poll_interactor_test.dart +++ b/packages/stream_chat_flutter/test/src/poll/interactor/stream_poll_interactor_test.dart @@ -114,13 +114,15 @@ Widget _wrapWithMaterialApp( data: StreamChatConfigurationData(), child: StreamChatTheme( data: StreamChatThemeData(brightness: brightness), - child: Builder(builder: (context) { - final theme = StreamChatTheme.of(context); - return Scaffold( - backgroundColor: theme.colorTheme.appBg, - body: Center(child: widget), - ); - }), + child: Builder( + builder: (context) { + final theme = StreamChatTheme.of(context); + return Scaffold( + backgroundColor: theme.colorTheme.appBg, + body: Center(child: widget), + ); + }, + ), ), ), ); diff --git a/packages/stream_chat_flutter/test/src/poll/poll_option_reorderable_list_view_test.dart b/packages/stream_chat_flutter/test/src/poll/poll_option_reorderable_list_view_test.dart index 2609417970..6919559d5c 100644 --- a/packages/stream_chat_flutter/test/src/poll/poll_option_reorderable_list_view_test.dart +++ b/packages/stream_chat_flutter/test/src/poll/poll_option_reorderable_list_view_test.dart @@ -68,18 +68,20 @@ Widget _wrapWithMaterialApp( return MaterialApp( home: StreamChatTheme( data: StreamChatThemeData(brightness: brightness), - child: Builder(builder: (context) { - final theme = StreamChatTheme.of(context); - return Scaffold( - backgroundColor: theme.colorTheme.appBg, - body: Center( - child: Padding( - padding: const EdgeInsets.all(8), - child: widget, + child: Builder( + builder: (context) { + final theme = StreamChatTheme.of(context); + return Scaffold( + backgroundColor: theme.colorTheme.appBg, + body: Center( + child: Padding( + padding: const EdgeInsets.all(8), + child: widget, + ), ), - ), - ); - }), + ); + }, + ), ), ); } diff --git a/packages/stream_chat_flutter/test/src/poll/poll_question_text_field_test.dart b/packages/stream_chat_flutter/test/src/poll/poll_question_text_field_test.dart index 24928bbc67..2c203c10ae 100644 --- a/packages/stream_chat_flutter/test/src/poll/poll_question_text_field_test.dart +++ b/packages/stream_chat_flutter/test/src/poll/poll_question_text_field_test.dart @@ -55,18 +55,20 @@ Widget _wrapWithMaterialApp( return MaterialApp( home: StreamChatTheme( data: StreamChatThemeData(brightness: brightness), - child: Builder(builder: (context) { - final theme = StreamChatTheme.of(context); - return Scaffold( - backgroundColor: theme.colorTheme.appBg, - body: Center( - child: Padding( - padding: const EdgeInsets.all(8), - child: widget, + child: Builder( + builder: (context) { + final theme = StreamChatTheme.of(context); + return Scaffold( + backgroundColor: theme.colorTheme.appBg, + body: Center( + child: Padding( + padding: const EdgeInsets.all(8), + child: widget, + ), ), - ), - ); - }), + ); + }, + ), ), ); } diff --git a/packages/stream_chat_flutter/test/src/poll/stream_poll_creator_dialog_test.dart b/packages/stream_chat_flutter/test/src/poll/stream_poll_creator_dialog_test.dart deleted file mode 100644 index 4e20086fa2..0000000000 --- a/packages/stream_chat_flutter/test/src/poll/stream_poll_creator_dialog_test.dart +++ /dev/null @@ -1,58 +0,0 @@ -import 'package:alchemist/alchemist.dart'; -import 'package:flutter/material.dart'; -import 'package:stream_chat_flutter/src/poll/creator/stream_poll_creator_dialog.dart'; -import 'package:stream_chat_flutter/src/theme/stream_chat_theme.dart'; - -void main() { - goldenTest( - '[Light] -> StreamPollCreatorDialog should look fine', - fileName: 'stream_poll_creator_dialog_light', - constraints: const BoxConstraints.tightFor(width: 1280, height: 800), - builder: () => _wrapWithMaterialApp( - brightness: Brightness.light, - const StreamPollCreatorDialog(), - ), - ); - - goldenTest( - '[Dark] -> StreamPollCreatorDialog should look fine', - fileName: 'stream_poll_creator_dialog_dark', - constraints: const BoxConstraints.tightFor(width: 1280, height: 800), - builder: () => _wrapWithMaterialApp( - brightness: Brightness.dark, - const StreamPollCreatorDialog(), - ), - ); - - goldenTest( - '[Light] -> StreamPollCreatorFullScreenDialog should look fine', - fileName: 'stream_poll_creator_full_screen_dialog_light', - constraints: const BoxConstraints.tightFor(width: 412, height: 916), - builder: () => _wrapWithMaterialApp( - brightness: Brightness.light, - const StreamPollCreatorFullScreenDialog(), - ), - ); - - goldenTest( - '[Dark] -> StreamPollCreatorFullScreenDialog should look fine', - fileName: 'stream_poll_creator_full_screen_dialog_dark', - constraints: const BoxConstraints.tightFor(width: 412, height: 916), - builder: () => _wrapWithMaterialApp( - brightness: Brightness.dark, - const StreamPollCreatorFullScreenDialog(), - ), - ); -} - -Widget _wrapWithMaterialApp( - Widget widget, { - Brightness? brightness, -}) { - return MaterialApp( - home: StreamChatTheme( - data: StreamChatThemeData(brightness: brightness), - child: widget, - ), - ); -} diff --git a/packages/stream_chat_flutter/test/src/poll/stream_poll_options_dialog_test.dart b/packages/stream_chat_flutter/test/src/poll/stream_poll_options_sheet_test.dart similarity index 85% rename from packages/stream_chat_flutter/test/src/poll/stream_poll_options_dialog_test.dart rename to packages/stream_chat_flutter/test/src/poll/stream_poll_options_sheet_test.dart index 81ebd027ac..c779d4b22b 100644 --- a/packages/stream_chat_flutter/test/src/poll/stream_poll_options_dialog_test.dart +++ b/packages/stream_chat_flutter/test/src/poll/stream_poll_options_sheet_test.dart @@ -1,6 +1,6 @@ import 'package:alchemist/alchemist.dart'; import 'package:flutter/material.dart'; -import 'package:stream_chat_flutter/src/poll/stream_poll_options_dialog.dart'; +import 'package:stream_chat_flutter/src/poll/stream_poll_options_sheet.dart'; import 'package:stream_chat_flutter/src/stream_chat_configuration.dart'; import 'package:stream_chat_flutter/src/theme/stream_chat_theme.dart'; import 'package:stream_chat_flutter_core/stream_chat_flutter_core.dart'; @@ -74,12 +74,12 @@ void main() { for (final brightness in Brightness.values) { goldenTest( - '[${brightness.name}] -> StreamPollOptionsDialog looks fine', - fileName: 'stream_poll_options_dialog_${brightness.name}', + '[${brightness.name}] -> StreamPollOptionsSheet looks fine', + fileName: 'stream_poll_options_sheet_${brightness.name}', constraints: const BoxConstraints.tightFor(width: 412, height: 916), builder: () => _wrapWithMaterialApp( brightness: brightness, - StreamPollOptionsDialog(poll: poll), + StreamPollOptionsSheet(poll: poll), ), ); } @@ -94,13 +94,15 @@ Widget _wrapWithMaterialApp( data: StreamChatConfigurationData(), child: StreamChatTheme( data: StreamChatThemeData(brightness: brightness), - child: Builder(builder: (context) { - final theme = StreamChatTheme.of(context); - return Scaffold( - backgroundColor: theme.colorTheme.appBg, - body: Center(child: widget), - ); - }), + child: Builder( + builder: (context) { + final theme = StreamChatTheme.of(context); + return Scaffold( + backgroundColor: theme.colorTheme.appBg, + body: Center(child: widget), + ); + }, + ), ), ), ); diff --git a/packages/stream_chat_flutter/test/src/poll/stream_poll_results_dialog_test.dart b/packages/stream_chat_flutter/test/src/poll/stream_poll_results_sheet_test.dart similarity index 82% rename from packages/stream_chat_flutter/test/src/poll/stream_poll_results_dialog_test.dart rename to packages/stream_chat_flutter/test/src/poll/stream_poll_results_sheet_test.dart index e5b8df69d0..5867acddc1 100644 --- a/packages/stream_chat_flutter/test/src/poll/stream_poll_results_dialog_test.dart +++ b/packages/stream_chat_flutter/test/src/poll/stream_poll_results_sheet_test.dart @@ -2,7 +2,7 @@ import 'package:alchemist/alchemist.dart'; import 'package:flutter/material.dart'; -import 'package:stream_chat_flutter/src/poll/stream_poll_results_dialog.dart'; +import 'package:stream_chat_flutter/src/poll/stream_poll_results_sheet.dart'; import 'package:stream_chat_flutter/src/stream_chat_configuration.dart'; import 'package:stream_chat_flutter/src/theme/stream_chat_theme.dart'; import 'package:stream_chat_flutter_core/stream_chat_flutter_core.dart'; @@ -80,22 +80,22 @@ void main() { for (final brightness in Brightness.values) { goldenTest( - '[${brightness.name}] -> StreamPollResultsDialog looks fine', - fileName: 'stream_poll_results_dialog_${brightness.name}', + '[${brightness.name}] -> StreamPollResultsSheet looks fine', + fileName: 'stream_poll_results_sheet_${brightness.name}', constraints: const BoxConstraints.tightFor(width: 412, height: 916), builder: () => _wrapWithMaterialApp( brightness: brightness, - StreamPollResultsDialog(poll: poll), + StreamPollResultsSheet(poll: poll), ), ); goldenTest( - '[${brightness.name}] -> StreamPollResultsDialog with Show all looks fine', - fileName: 'stream_poll_results_dialog_with_show_all_${brightness.name}', + '[${brightness.name}] -> StreamPollResultsSheet with Show all looks fine', + fileName: 'stream_poll_results_sheet_with_show_all_${brightness.name}', constraints: const BoxConstraints.tightFor(width: 412, height: 916), builder: () => _wrapWithMaterialApp( brightness: brightness, - StreamPollResultsDialog( + StreamPollResultsSheet( poll: poll, visibleVotesCount: 2, onShowAllVotesPressed: (_) {}, @@ -114,13 +114,15 @@ Widget _wrapWithMaterialApp( data: StreamChatConfigurationData(), child: StreamChatTheme( data: StreamChatThemeData(brightness: brightness), - child: Builder(builder: (context) { - final theme = StreamChatTheme.of(context); - return Scaffold( - backgroundColor: theme.colorTheme.appBg, - body: Center(child: widget), - ); - }), + child: Builder( + builder: (context) { + final theme = StreamChatTheme.of(context); + return Scaffold( + backgroundColor: theme.colorTheme.appBg, + body: Center(child: widget), + ); + }, + ), ), ), ); diff --git a/packages/stream_chat_flutter/test/src/reactions/detail/goldens/ci/reaction_detail_sheet_dark.png b/packages/stream_chat_flutter/test/src/reactions/detail/goldens/ci/reaction_detail_sheet_dark.png new file mode 100644 index 0000000000..53712733ec Binary files /dev/null and b/packages/stream_chat_flutter/test/src/reactions/detail/goldens/ci/reaction_detail_sheet_dark.png differ diff --git a/packages/stream_chat_flutter/test/src/reactions/detail/goldens/ci/reaction_detail_sheet_filtered_dark.png b/packages/stream_chat_flutter/test/src/reactions/detail/goldens/ci/reaction_detail_sheet_filtered_dark.png new file mode 100644 index 0000000000..cd91fa4257 Binary files /dev/null and b/packages/stream_chat_flutter/test/src/reactions/detail/goldens/ci/reaction_detail_sheet_filtered_dark.png differ diff --git a/packages/stream_chat_flutter/test/src/reactions/detail/goldens/ci/reaction_detail_sheet_filtered_light.png b/packages/stream_chat_flutter/test/src/reactions/detail/goldens/ci/reaction_detail_sheet_filtered_light.png new file mode 100644 index 0000000000..0b5dd1068c Binary files /dev/null and b/packages/stream_chat_flutter/test/src/reactions/detail/goldens/ci/reaction_detail_sheet_filtered_light.png differ diff --git a/packages/stream_chat_flutter/test/src/reactions/detail/goldens/ci/reaction_detail_sheet_light.png b/packages/stream_chat_flutter/test/src/reactions/detail/goldens/ci/reaction_detail_sheet_light.png new file mode 100644 index 0000000000..d2103b4925 Binary files /dev/null and b/packages/stream_chat_flutter/test/src/reactions/detail/goldens/ci/reaction_detail_sheet_light.png differ diff --git a/packages/stream_chat_flutter/test/src/reactions/detail/reaction_detail_sheet_test.dart b/packages/stream_chat_flutter/test/src/reactions/detail/reaction_detail_sheet_test.dart new file mode 100644 index 0000000000..dde8d56a9f --- /dev/null +++ b/packages/stream_chat_flutter/test/src/reactions/detail/reaction_detail_sheet_test.dart @@ -0,0 +1,448 @@ +import 'package:alchemist/alchemist.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:stream_chat_flutter/stream_chat_flutter.dart'; + +import '../../mocks.dart'; + +void main() { + late MockClient mockClient; + + setUpAll(() { + registerFallbackValue(const PaginationParams()); + registerFallbackValue(Filter.equal('type', 'like')); + }); + + setUp(() { + mockClient = MockClient(); + + final mockClientState = MockClientState(); + when(() => mockClient.state).thenReturn(mockClientState); + + final currentUser = OwnUser(id: 'current-user', name: 'Current User'); + when(() => mockClientState.currentUser).thenReturn(currentUser); + }); + + tearDown(() => reset(mockClient)); + + testWidgets('shows total reaction count and all reactions by default', (tester) async { + final reactions = [ + Reaction( + type: 'love', + messageId: 'test-message', + userId: 'user-1', + user: User(id: 'user-1', name: 'User 1'), + createdAt: DateTime.now(), + ), + Reaction( + type: 'like', + messageId: 'test-message', + userId: 'user-2', + user: User(id: 'user-2', name: 'User 2'), + createdAt: DateTime.now(), + ), + ]; + + when( + () => mockClient.queryReactions( + 'test-message', + filter: any(named: 'filter'), + sort: any(named: 'sort'), + pagination: any(named: 'pagination'), + ), + ).thenAnswer( + (_) async => QueryReactionsResponse() + ..reactions = reactions + ..next = null, + ); + + final message = _buildMessage( + reactionGroups: { + 'love': ReactionGroup(count: 1, sumScores: 1), + 'like': ReactionGroup(count: 1, sumScores: 1), + }, + ); + + await tester.pumpWidget( + _wrapWithMaterialApp( + client: mockClient, + _ReactionDetailSheetLauncher(message: message), + ), + ); + + await tester.tap(find.text('Open Sheet')); + await tester.pumpAndSettle(); + + expect(find.byType(ReactionDetailSheet), findsOneWidget); + expect(find.text('2 Reactions'), findsOneWidget); + expect(find.byType(StreamUserAvatar), findsNWidgets(2)); + expect(find.text('User 1'), findsOneWidget); + expect(find.text('User 2'), findsOneWidget); + }); + + testWidgets('applies initial reaction filter when provided', (tester) async { + final loveReaction = Reaction( + type: 'love', + messageId: 'test-message', + userId: 'user-1', + user: User(id: 'user-1', name: 'User 1'), + createdAt: DateTime.now(), + ); + + // The controller is initialised with filter: type == 'love', so + // queryReactions will return only the love reaction. + when( + () => mockClient.queryReactions( + 'test-message', + filter: any(named: 'filter'), + sort: any(named: 'sort'), + pagination: any(named: 'pagination'), + ), + ).thenAnswer( + (_) async => QueryReactionsResponse() + ..reactions = [loveReaction] + ..next = null, + ); + + final message = _buildMessage( + reactionGroups: { + 'love': ReactionGroup(count: 1, sumScores: 1), + 'like': ReactionGroup(count: 1, sumScores: 1), + }, + ); + + await tester.pumpWidget( + _wrapWithMaterialApp( + client: mockClient, + _ReactionDetailSheetLauncher( + message: message, + initialReactionType: 'love', + ), + ), + ); + + await tester.tap(find.text('Open Sheet')); + await tester.pumpAndSettle(); + + expect(find.text('1 Reaction'), findsOneWidget); + expect(find.byType(StreamUserAvatar), findsOneWidget); + expect(find.text('User 1'), findsOneWidget); + expect(find.text('User 2'), findsNothing); + }); + + testWidgets('pops with SelectReaction when own reaction row is tapped', (tester) async { + MessageAction? action; + + final reaction = Reaction( + type: 'love', + messageId: 'test-message', + userId: 'current-user', + user: User(id: 'current-user', name: 'Current User'), + createdAt: DateTime.now(), + ); + + when( + () => mockClient.queryReactions( + 'test-message', + filter: any(named: 'filter'), + sort: any(named: 'sort'), + pagination: any(named: 'pagination'), + ), + ).thenAnswer( + (_) async => QueryReactionsResponse() + ..reactions = [reaction] + ..next = null, + ); + + final message = _buildMessage( + ownReactions: [reaction], + reactionGroups: { + 'love': ReactionGroup(count: 1, sumScores: 1), + }, + ); + + await tester.pumpWidget( + _wrapWithMaterialApp( + client: mockClient, + _ReactionDetailSheetLauncher( + message: message, + onAction: (value) => action = value, + ), + ), + ); + + await tester.tap(find.text('Open Sheet')); + await tester.pumpAndSettle(); + + expect(find.text('Tap to remove'), findsOneWidget); + + await tester.tap(find.text('Current User')); + await tester.pumpAndSettle(); + + expect(action, isA()); + expect((action! as SelectReaction).reaction.type, 'love'); + }); + + testWidgets('does not pop when non-own reaction row is tapped', (tester) async { + MessageAction? action; + + final otherReaction = Reaction( + type: 'love', + messageId: 'test-message', + userId: 'user-1', + user: User(id: 'user-1', name: 'User 1'), + createdAt: DateTime.now(), + ); + + when( + () => mockClient.queryReactions( + 'test-message', + filter: any(named: 'filter'), + sort: any(named: 'sort'), + pagination: any(named: 'pagination'), + ), + ).thenAnswer( + (_) async => QueryReactionsResponse() + ..reactions = [otherReaction] + ..next = null, + ); + + final message = _buildMessage( + ownReactions: [ + Reaction( + type: 'love', + messageId: 'test-message', + userId: 'current-user', + user: User(id: 'current-user', name: 'Current User'), + createdAt: DateTime.now(), + ), + ], + reactionGroups: { + 'love': ReactionGroup(count: 1, sumScores: 1), + }, + ); + + await tester.pumpWidget( + _wrapWithMaterialApp( + client: mockClient, + _ReactionDetailSheetLauncher( + message: message, + onAction: (value) => action = value, + ), + ), + ); + + await tester.tap(find.text('Open Sheet')); + await tester.pumpAndSettle(); + + expect(find.text('Tap to remove'), findsNothing); + + await tester.tap(find.text('User 1')); + await tester.pumpAndSettle(); + + expect(action, isNull); + expect(find.byType(ReactionDetailSheet), findsOneWidget); + }); + + group('ReactionDetailSheet Golden Tests', () { + final reactions = [ + Reaction( + type: 'love', + messageId: 'test-message', + userId: 'user-1', + user: User(id: 'user-1', name: 'User 1'), + createdAt: DateTime(2026, 1, 1, 10, 0), + ), + Reaction( + type: 'like', + messageId: 'test-message', + userId: 'user-2', + user: User(id: 'user-2', name: 'User 2'), + createdAt: DateTime(2026, 1, 1, 10, 1), + ), + ]; + + final message = _buildMessage( + reactionGroups: { + 'love': ReactionGroup(count: 1, sumScores: 1), + 'like': ReactionGroup(count: 1, sumScores: 1), + }, + ); + + for (final brightness in Brightness.values) { + final theme = brightness.name; + + goldenTest( + 'ReactionDetailSheet in $theme theme', + fileName: 'reaction_detail_sheet_$theme', + constraints: const BoxConstraints(maxWidth: 400, maxHeight: 700), + pumpBeforeTest: (tester) async { + when( + () => mockClient.queryReactions( + 'test-message', + filter: any(named: 'filter'), + sort: any(named: 'sort'), + pagination: any(named: 'pagination'), + ), + ).thenAnswer( + (_) async => QueryReactionsResponse() + ..reactions = reactions + ..next = null, + ); + // Pump once to trigger post-frame modal opening, then settle animation. + await tester.pump(); + await tester.pumpAndSettle(const Duration(seconds: 1)); + }, + builder: () => _wrapWithMaterialApp( + client: mockClient, + brightness: brightness, + _ReactionDetailSheetGoldenHost(message: message), + ), + ); + + goldenTest( + 'ReactionDetailSheet filtered in $theme theme', + fileName: 'reaction_detail_sheet_filtered_$theme', + constraints: const BoxConstraints(maxWidth: 400, maxHeight: 700), + pumpBeforeTest: (tester) async { + when( + () => mockClient.queryReactions( + 'test-message', + filter: any(named: 'filter'), + sort: any(named: 'sort'), + pagination: any(named: 'pagination'), + ), + ).thenAnswer( + (_) async => QueryReactionsResponse() + ..reactions = reactions.where((r) => r.type == 'love').toList() + ..next = null, + ); + // Pump once to trigger post-frame modal opening, then settle animation. + await tester.pump(); + await tester.pumpAndSettle(const Duration(seconds: 1)); + }, + builder: () => _wrapWithMaterialApp( + client: mockClient, + brightness: brightness, + _ReactionDetailSheetGoldenHost( + message: message, + initialReactionType: 'love', + ), + ), + ); + } + }); +} + +class _ReactionDetailSheetLauncher extends StatelessWidget { + const _ReactionDetailSheetLauncher({ + required this.message, + this.initialReactionType, + this.onAction, + }); + + final Message message; + final String? initialReactionType; + final ValueChanged? onAction; + + @override + Widget build(BuildContext context) { + return Center( + child: TextButton( + onPressed: () async { + final action = await ReactionDetailSheet.show( + context: context, + message: message, + initialReactionType: initialReactionType, + ); + + onAction?.call(action); + }, + child: const Text('Open Sheet'), + ), + ); + } +} + +class _ReactionDetailSheetGoldenHost extends StatefulWidget { + const _ReactionDetailSheetGoldenHost({ + required this.message, + this.initialReactionType, + }); + + final Message message; + final String? initialReactionType; + + @override + State<_ReactionDetailSheetGoldenHost> createState() => _ReactionDetailSheetGoldenHostState(); +} + +class _ReactionDetailSheetGoldenHostState extends State<_ReactionDetailSheetGoldenHost> { + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + ReactionDetailSheet.show( + context: context, + message: widget.message, + initialReactionType: widget.initialReactionType, + ); + }); + } + + @override + Widget build(BuildContext context) { + return const SizedBox.expand(); + } +} + +Message _buildMessage({ + List? latestReactions, + List? ownReactions, + Map? reactionGroups, +}) { + return Message( + id: 'test-message', + text: 'This is a test message', + createdAt: DateTime.now(), + user: User(id: 'test-user', name: 'Test User'), + latestReactions: latestReactions, + ownReactions: ownReactions, + reactionGroups: reactionGroups, + ); +} + +Widget _wrapWithMaterialApp( + Widget child, { + required StreamChatClient client, + Brightness? brightness, +}) { + return MaterialApp( + debugShowCheckedModeBanner: false, + theme: ThemeData(brightness: brightness), + builder: (context, child) => StreamChat( + client: client, + // Mock the connectivity stream to always return wifi. + connectivityStream: Stream.value([ConnectivityResult.wifi]), + streamChatThemeData: StreamChatThemeData(brightness: brightness), + child: child ?? const SizedBox.shrink(), + ), + home: Builder( + builder: (context) { + final theme = StreamChatTheme.of(context); + return Scaffold( + backgroundColor: theme.colorTheme.appBg, + body: ColoredBox( + color: theme.colorTheme.overlay, + child: Padding( + padding: const EdgeInsets.all(8), + child: child, + ), + ), + ); + }, + ), + ); +} diff --git a/packages/stream_chat_flutter/test/src/reactions/picker/goldens/ci/reaction_icon_button_selected_dark.png b/packages/stream_chat_flutter/test/src/reactions/picker/goldens/ci/reaction_icon_button_selected_dark.png new file mode 100644 index 0000000000..b23c628a3b Binary files /dev/null and b/packages/stream_chat_flutter/test/src/reactions/picker/goldens/ci/reaction_icon_button_selected_dark.png differ diff --git a/packages/stream_chat_flutter/test/src/reactions/picker/goldens/ci/reaction_icon_button_selected_light.png b/packages/stream_chat_flutter/test/src/reactions/picker/goldens/ci/reaction_icon_button_selected_light.png new file mode 100644 index 0000000000..c47907a1c0 Binary files /dev/null and b/packages/stream_chat_flutter/test/src/reactions/picker/goldens/ci/reaction_icon_button_selected_light.png differ diff --git a/packages/stream_chat_flutter/test/src/reactions/picker/goldens/ci/reaction_icon_button_unselected_dark.png b/packages/stream_chat_flutter/test/src/reactions/picker/goldens/ci/reaction_icon_button_unselected_dark.png new file mode 100644 index 0000000000..e7cac1e1a0 Binary files /dev/null and b/packages/stream_chat_flutter/test/src/reactions/picker/goldens/ci/reaction_icon_button_unselected_dark.png differ diff --git a/packages/stream_chat_flutter/test/src/reactions/picker/goldens/ci/reaction_icon_button_unselected_light.png b/packages/stream_chat_flutter/test/src/reactions/picker/goldens/ci/reaction_icon_button_unselected_light.png new file mode 100644 index 0000000000..a8d37e94bb Binary files /dev/null and b/packages/stream_chat_flutter/test/src/reactions/picker/goldens/ci/reaction_icon_button_unselected_light.png differ diff --git a/packages/stream_chat_flutter/test/src/reactions/picker/goldens/ci/reaction_picker_icon_list_dark.png b/packages/stream_chat_flutter/test/src/reactions/picker/goldens/ci/reaction_picker_icon_list_dark.png new file mode 100644 index 0000000000..3ea44c3993 Binary files /dev/null and b/packages/stream_chat_flutter/test/src/reactions/picker/goldens/ci/reaction_picker_icon_list_dark.png differ diff --git a/packages/stream_chat_flutter/test/src/reactions/picker/goldens/ci/reaction_picker_icon_list_light.png b/packages/stream_chat_flutter/test/src/reactions/picker/goldens/ci/reaction_picker_icon_list_light.png new file mode 100644 index 0000000000..dea0b18230 Binary files /dev/null and b/packages/stream_chat_flutter/test/src/reactions/picker/goldens/ci/reaction_picker_icon_list_light.png differ diff --git a/packages/stream_chat_flutter/test/src/reactions/picker/goldens/ci/reaction_picker_icon_list_selected_dark.png b/packages/stream_chat_flutter/test/src/reactions/picker/goldens/ci/reaction_picker_icon_list_selected_dark.png new file mode 100644 index 0000000000..3de2c605ae Binary files /dev/null and b/packages/stream_chat_flutter/test/src/reactions/picker/goldens/ci/reaction_picker_icon_list_selected_dark.png differ diff --git a/packages/stream_chat_flutter/test/src/reactions/picker/goldens/ci/reaction_picker_icon_list_selected_light.png b/packages/stream_chat_flutter/test/src/reactions/picker/goldens/ci/reaction_picker_icon_list_selected_light.png new file mode 100644 index 0000000000..6a1b705330 Binary files /dev/null and b/packages/stream_chat_flutter/test/src/reactions/picker/goldens/ci/reaction_picker_icon_list_selected_light.png differ diff --git a/packages/stream_chat_flutter/test/src/reactions/picker/goldens/ci/stream_reaction_picker_dark.png b/packages/stream_chat_flutter/test/src/reactions/picker/goldens/ci/stream_reaction_picker_dark.png new file mode 100644 index 0000000000..3ba1e274a9 Binary files /dev/null and b/packages/stream_chat_flutter/test/src/reactions/picker/goldens/ci/stream_reaction_picker_dark.png differ diff --git a/packages/stream_chat_flutter/test/src/reactions/picker/goldens/ci/stream_reaction_picker_light.png b/packages/stream_chat_flutter/test/src/reactions/picker/goldens/ci/stream_reaction_picker_light.png new file mode 100644 index 0000000000..942405a5ed Binary files /dev/null and b/packages/stream_chat_flutter/test/src/reactions/picker/goldens/ci/stream_reaction_picker_light.png differ diff --git a/packages/stream_chat_flutter/test/src/reactions/picker/goldens/ci/stream_reaction_picker_selected_dark.png b/packages/stream_chat_flutter/test/src/reactions/picker/goldens/ci/stream_reaction_picker_selected_dark.png new file mode 100644 index 0000000000..1a779cb39f Binary files /dev/null and b/packages/stream_chat_flutter/test/src/reactions/picker/goldens/ci/stream_reaction_picker_selected_dark.png differ diff --git a/packages/stream_chat_flutter/test/src/reactions/picker/goldens/ci/stream_reaction_picker_selected_light.png b/packages/stream_chat_flutter/test/src/reactions/picker/goldens/ci/stream_reaction_picker_selected_light.png new file mode 100644 index 0000000000..e7fc9188c8 Binary files /dev/null and b/packages/stream_chat_flutter/test/src/reactions/picker/goldens/ci/stream_reaction_picker_selected_light.png differ diff --git a/packages/stream_chat_flutter/test/src/reactions/picker/goldens/ci/stream_reaction_picker_subset_dark.png b/packages/stream_chat_flutter/test/src/reactions/picker/goldens/ci/stream_reaction_picker_subset_dark.png new file mode 100644 index 0000000000..b9b5f2f76e Binary files /dev/null and b/packages/stream_chat_flutter/test/src/reactions/picker/goldens/ci/stream_reaction_picker_subset_dark.png differ diff --git a/packages/stream_chat_flutter/test/src/reactions/picker/goldens/ci/stream_reaction_picker_subset_light.png b/packages/stream_chat_flutter/test/src/reactions/picker/goldens/ci/stream_reaction_picker_subset_light.png new file mode 100644 index 0000000000..01cd62f57f Binary files /dev/null and b/packages/stream_chat_flutter/test/src/reactions/picker/goldens/ci/stream_reaction_picker_subset_light.png differ diff --git a/packages/stream_chat_flutter/test/src/reactions/picker/reaction_picker_test.dart b/packages/stream_chat_flutter/test/src/reactions/picker/reaction_picker_test.dart new file mode 100644 index 0000000000..9d60675718 --- /dev/null +++ b/packages/stream_chat_flutter/test/src/reactions/picker/reaction_picker_test.dart @@ -0,0 +1,482 @@ +import 'package:alchemist/alchemist.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:stream_chat_flutter/src/misc/staggered_scale_transition.dart'; +import 'package:stream_chat_flutter/stream_chat_flutter.dart'; + +void main() { + const resolver = _TestReactionIconResolver(); + + testWidgets( + 'renders with correct message and reaction buttons', + (WidgetTester tester) async { + final message = Message( + id: 'test-message', + text: 'Hello world', + user: User(id: 'test-user'), + ); + + await tester.pumpWidget( + _wrapWithMaterialApp( + StreamMessageReactionPicker( + message: message, + onReactionPicked: (_) {}, + ), + reactionIconResolver: resolver, + ), + ); + + await tester.pumpAndSettle(const Duration(seconds: 1)); + + // Verify the widget renders with correct structure. + expect(find.byType(StreamMessageReactionPicker), findsOneWidget); + // Verify the correct number of reaction buttons. + expect( + find.byType(StreamEmojiButton), + findsNWidgets(resolver.defaultReactions.length), + ); + expect(find.byKey(const Key('add_reaction')), findsOneWidget); + }, + ); + + testWidgets( + 'calls onReactionPicked when a reaction is selected', + (WidgetTester tester) async { + final message = Message( + id: 'test-message', + text: 'Hello world', + user: User(id: 'test-user'), + ); + + Reaction? pickedReaction; + + await tester.pumpWidget( + _wrapWithMaterialApp( + StreamMessageReactionPicker( + message: message, + onReactionPicked: (reaction) { + pickedReaction = reaction; + }, + ), + reactionIconResolver: resolver, + ), + ); + + // Wait for animations to complete. + await tester.pumpAndSettle(const Duration(seconds: 1)); + + // Tap the first reaction button. + await tester.tap(find.byKey(const Key('love'))); + await tester.pump(); + + // Verify the callback was called with the correct reaction. + expect(pickedReaction, isNotNull); + expect(pickedReaction!.type, 'love'); + expect(pickedReaction!.emojiCode, resolver.emojiCode('love')); + }, + ); + + testWidgets( + 'reuses own reaction when selected', + (WidgetTester tester) async { + final existingReaction = Reaction( + type: 'love', + messageId: 'test-message', + userId: 'test-user', + ); + + final message = Message( + id: 'test-message', + text: 'Hello world', + user: User(id: 'test-user'), + ownReactions: [existingReaction], + ); + + Reaction? pickedReaction; + + await tester.pumpWidget( + _wrapWithMaterialApp( + StreamMessageReactionPicker( + message: message, + onReactionPicked: (reaction) { + pickedReaction = reaction; + }, + ), + reactionIconResolver: resolver, + ), + ); + + // Wait for animations to complete. + await tester.pumpAndSettle(const Duration(seconds: 1)); + + await tester.tap(find.byKey(const Key('love'))); + await tester.pump(); + + expect(pickedReaction, same(existingReaction)); + }, + ); + + testWidgets( + 'marks own reactions as selected', + (WidgetTester tester) async { + final message = Message( + id: 'test-message', + text: 'Hello world', + user: User(id: 'test-user'), + ownReactions: [ + Reaction( + type: 'love', + messageId: 'test-message', + userId: 'test-user', + ), + ], + ); + + await tester.pumpWidget( + _wrapWithMaterialApp( + StreamMessageReactionPicker( + message: message, + onReactionPicked: (_) {}, + ), + reactionIconResolver: resolver, + ), + ); + + // Wait for animations to complete. + await tester.pumpAndSettle(const Duration(seconds: 1)); + + final selectedButton = tester.widget( + find.byKey(const Key('love')), + ); + final unselectedButton = tester.widget( + find.byKey(const Key('like')), + ); + + expect(selectedButton.props.isSelected, isTrue); + expect(unselectedButton.props.isSelected, isFalse); + }, + ); + + testWidgets( + 'updates reaction buttons when resolver default reactions change', + (WidgetTester tester) async { + final message = Message( + id: 'test-message', + text: 'Hello world', + user: User(id: 'test-user'), + ); + + const compactResolver = _CustomReactionIconResolver({'love', 'like'}); + + await tester.pumpWidget( + _wrapWithMaterialApp( + StreamMessageReactionPicker( + message: message, + onReactionPicked: (_) {}, + ), + reactionIconResolver: compactResolver, + ), + ); + + await tester.pumpAndSettle(const Duration(seconds: 1)); + // Initial resolver exposes two quick reactions. + expect(find.byType(StreamEmojiButton), findsNWidgets(2)); + + await tester.pumpWidget( + _wrapWithMaterialApp( + StreamMessageReactionPicker( + message: message, + onReactionPicked: (_) {}, + ), + reactionIconResolver: resolver, + ), + ); + + await tester.pumpAndSettle(const Duration(seconds: 1)); + // Updated resolver exposes the default quick-reaction set. + expect( + find.byType(StreamEmojiButton), + findsNWidgets(resolver.defaultReactions.length), + ); + }, + ); + + testWidgets( + 'uses only defaultReactions even when supportedReactions contains more types', + (WidgetTester tester) async { + final message = Message( + id: 'test-message', + text: 'Hello world', + user: User(id: 'test-user'), + ); + + const subsetResolver = _SubsetDefaultReactionIconResolver(); + + await tester.pumpWidget( + _wrapWithMaterialApp( + StreamMessageReactionPicker( + message: message, + onReactionPicked: (_) {}, + ), + reactionIconResolver: subsetResolver, + ), + ); + + // Wait for animations to complete. + await tester.pumpAndSettle(const Duration(seconds: 1)); + + // Picker should render only the resolver's quick-reaction defaults. + expect(find.byType(StreamEmojiButton), findsNWidgets(1)); + expect(find.byKey(const Key('love')), findsOneWidget); + expect(find.byKey(const Key('like')), findsNothing); + expect(find.byKey(const Key('wow')), findsNothing); + }, + ); + + testWidgets( + 'uses custom reaction resolver rendering', + (WidgetTester tester) async { + final message = Message( + id: 'test-message', + text: 'Hello world', + user: User(id: 'test-user'), + ); + + await tester.pumpWidget( + _wrapWithMaterialApp( + StreamMessageReactionPicker( + message: message, + onReactionPicked: (_) {}, + ), + reactionIconResolver: const _TypeBasedReactionIconResolver(), + ), + ); + + // Wait for animations to complete. + await tester.pumpAndSettle(const Duration(seconds: 1)); + + // _TypeBasedReactionIconResolver defines one reaction ('customParty') + // that resolves to StreamUnicodeEmoji('❓'). Verify the fallback emoji + // is rendered via a StreamEmoji widget inside the picker. + expect(find.byType(StreamEmoji), findsOneWidget); + expect(find.text('❓'), findsOneWidget); + }, + ); + + testWidgets( + 'renders picker without staggered transition animation', + (WidgetTester tester) async { + final message = Message( + id: 'test-message', + text: 'Hello world', + user: User(id: 'test-user'), + ); + + await tester.pumpWidget( + _wrapWithMaterialApp( + StreamMessageReactionPicker( + message: message, + onReactionPicked: (_) {}, + ), + reactionIconResolver: resolver, + ), + ); + + await tester.pumpAndSettle(const Duration(seconds: 1)); + + expect(find.byType(StaggeredScaleTransition), findsNothing); + }, + ); + + group('Golden tests', () { + for (final brightness in [Brightness.light, Brightness.dark]) { + final theme = brightness.name; + + goldenTest( + 'StreamMessageReactionPicker in $theme theme', + fileName: 'stream_reaction_picker_$theme', + constraints: const BoxConstraints.tightFor(width: 400, height: 100), + builder: () { + final message = Message( + id: 'test-message', + text: 'Hello world', + user: User(id: 'test-user'), + ); + + return _wrapWithMaterialApp( + brightness: brightness, + StreamMessageReactionPicker( + message: message, + onReactionPicked: (_) {}, + ), + reactionIconResolver: resolver, + ); + }, + ); + + goldenTest( + 'StreamMessageReactionPicker with selected reaction in $theme theme', + fileName: 'stream_reaction_picker_selected_$theme', + constraints: const BoxConstraints.tightFor(width: 400, height: 100), + builder: () { + final message = Message( + id: 'test-message', + text: 'Hello world', + user: User(id: 'test-user'), + ownReactions: [ + Reaction( + type: 'love', + messageId: 'test-message', + userId: 'test-user', + ), + ], + ); + + return _wrapWithMaterialApp( + brightness: brightness, + StreamMessageReactionPicker( + message: message, + onReactionPicked: (_) {}, + ), + reactionIconResolver: resolver, + ); + }, + ); + + goldenTest( + 'StreamMessageReactionPicker with subset defaults in $theme theme', + fileName: 'stream_reaction_picker_subset_$theme', + constraints: const BoxConstraints.tightFor(width: 400, height: 100), + builder: () { + final message = Message( + id: 'test-message', + text: 'Hello world', + user: User(id: 'test-user'), + ); + + return _wrapWithMaterialApp( + brightness: brightness, + StreamMessageReactionPicker( + message: message, + onReactionPicked: (_) {}, + ), + reactionIconResolver: const _SubsetDefaultReactionIconResolver(), + ); + }, + ); + } + }); +} + +Widget _wrapWithMaterialApp( + Widget child, { + Brightness? brightness, + ReactionIconResolver? reactionIconResolver, +}) { + return MaterialApp( + debugShowCheckedModeBanner: false, + theme: ThemeData(brightness: brightness), + builder: (context, child) => StreamChatConfiguration( + data: StreamChatConfigurationData( + reactionIconResolver: reactionIconResolver ?? const _TestReactionIconResolver(), + ), + child: StreamChatTheme( + data: StreamChatThemeData(brightness: brightness), + child: child ?? const SizedBox.shrink(), + ), + ), + home: Builder( + builder: (context) { + final theme = StreamChatTheme.of(context); + return Scaffold( + backgroundColor: theme.colorTheme.overlay, + body: Center( + child: Padding( + padding: const EdgeInsets.all(8), + child: child, + ), + ), + ); + }, + ), + ); +} + +class _TestReactionIconResolver extends ReactionIconResolver { + const _TestReactionIconResolver(); + + static const _reactionTypes = {'like', 'haha', 'love', 'wow', 'sad'}; + + @override + Set get defaultReactions => _reactionTypes; + + @override + Set get supportedReactions => _reactionTypes; + + @override + String? emojiCode(String type) => streamSupportedEmojis[type]?.emoji; + + @override + StreamEmojiContent resolve(String type) { + if (emojiCode(type) case final emoji?) return StreamUnicodeEmoji(emoji); + return const StreamUnicodeEmoji('❓'); + } +} + +class _CustomReactionIconResolver extends ReactionIconResolver { + const _CustomReactionIconResolver(this._types); + + final Set _types; + + @override + Set get defaultReactions => _types; + + @override + Set get supportedReactions => _types; + + @override + String? emojiCode(String type) => streamSupportedEmojis[type]?.emoji; + + @override + StreamEmojiContent resolve(String type) { + if (emojiCode(type) case final emoji?) return StreamUnicodeEmoji(emoji); + return const StreamUnicodeEmoji('❓'); + } +} + +class _TypeBasedReactionIconResolver extends ReactionIconResolver { + const _TypeBasedReactionIconResolver(); + + @override + Set get defaultReactions => const {'customParty'}; + + @override + Set get supportedReactions => const {'customParty'}; + + @override + String? emojiCode(String type) => null; + + @override + StreamEmojiContent resolve(String type) { + return const StreamUnicodeEmoji('❓'); + } +} + +class _SubsetDefaultReactionIconResolver extends ReactionIconResolver { + const _SubsetDefaultReactionIconResolver(); + + @override + Set get defaultReactions => const {'love'}; + + @override + Set get supportedReactions => const {'love', 'like', 'wow'}; + + @override + String? emojiCode(String type) => streamSupportedEmojis[type]?.emoji; + + @override + StreamEmojiContent resolve(String type) { + if (emojiCode(type) case final emoji?) return StreamUnicodeEmoji(emoji); + return const StreamUnicodeEmoji('❓'); + } +} diff --git a/packages/stream_chat_flutter/test/src/scroll_view/draft_scroll_view/goldens/ci/stream_draft_list_tile_dark.png b/packages/stream_chat_flutter/test/src/scroll_view/draft_scroll_view/goldens/ci/stream_draft_list_tile_dark.png deleted file mode 100644 index 98ed897a61..0000000000 Binary files a/packages/stream_chat_flutter/test/src/scroll_view/draft_scroll_view/goldens/ci/stream_draft_list_tile_dark.png and /dev/null differ diff --git a/packages/stream_chat_flutter/test/src/scroll_view/draft_scroll_view/goldens/ci/stream_draft_list_tile_light.png b/packages/stream_chat_flutter/test/src/scroll_view/draft_scroll_view/goldens/ci/stream_draft_list_tile_light.png deleted file mode 100644 index a4388618b2..0000000000 Binary files a/packages/stream_chat_flutter/test/src/scroll_view/draft_scroll_view/goldens/ci/stream_draft_list_tile_light.png and /dev/null differ diff --git a/packages/stream_chat_flutter/test/src/scroll_view/draft_scroll_view/stream_draft_list_tile_test.dart b/packages/stream_chat_flutter/test/src/scroll_view/draft_scroll_view/stream_draft_list_tile_test.dart deleted file mode 100644 index 9d77767bbb..0000000000 --- a/packages/stream_chat_flutter/test/src/scroll_view/draft_scroll_view/stream_draft_list_tile_test.dart +++ /dev/null @@ -1,125 +0,0 @@ -import 'package:alchemist/alchemist.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:mocktail/mocktail.dart'; -import 'package:stream_chat_flutter/stream_chat_flutter.dart'; - -import '../../mocks.dart'; - -void main() { - final user = User(id: 'uid1', name: 'User 1'); - final createdAt = DateTime.parse('2022-07-20T16:00:00.000Z'); - final draft = Draft( - channelCid: 'messaging:123', - channel: ChannelModel( - cid: 'messaging:123', - extraData: const {'name': 'Group chat'}, - ), - createdAt: createdAt, - message: DraftMessage( - text: 'This is a draft message that I want to save for later', - ), - ); - - for (final brightness in Brightness.values) { - goldenTest( - '[${brightness.name}] -> StreamDraftListTile looks fine', - fileName: 'stream_draft_list_tile_${brightness.name}', - constraints: const BoxConstraints.tightFor(width: 600, height: 120), - builder: () => _wrapWithMaterialApp( - brightness: brightness, - StreamDraftListTile(draft: draft, currentUser: user), - ), - ); - } - - group('Formatter Tests', () { - testWidgets( - 'StreamDraftListTile displays custom formatted timestamp', - (tester) async { - await tester.pumpWidget( - _wrapWithMaterialApp( - StreamDraftListTileTheme( - data: StreamDraftListTileThemeData( - draftTimestampFormatter: (context, timestamp) { - return 'CUSTOM_FORMAT_20_07_2022'; - }, - ), - child: StreamDraftListTile(draft: draft, currentUser: user), - ), - ), - ); - - await tester.pumpAndSettle(); - - // Verify the custom formatted text is visible - expect(find.text('CUSTOM_FORMAT_20_07_2022'), findsOneWidget); - }, - ); - - testWidgets( - 'StreamDraftListTile inner theme overrides outer theme', - (tester) async { - await tester.pumpWidget( - _wrapWithMaterialApp( - StreamDraftListTileTheme( - data: StreamDraftListTileThemeData( - draftTimestampFormatter: (context, timestamp) { - return 'OUTER_FORMATTER'; - }, - ), - child: StreamDraftListTileTheme( - data: StreamDraftListTileThemeData( - draftTimestampFormatter: (context, timestamp) { - return 'INNER_FORMATTER'; - }, - ), - child: StreamDraftListTile(draft: draft, currentUser: user), - ), - ), - ), - ); - - await tester.pumpAndSettle(); - - // Inner formatter should be used - expect(find.text('INNER_FORMATTER'), findsOneWidget); - expect(find.text('OUTER_FORMATTER'), findsNothing); - }, - ); - }); -} - -Widget _wrapWithMaterialApp( - Widget widget, { - Brightness? brightness, -}) { - final client = MockClient(); - final clientState = MockClientState(); - final currentUser = OwnUser(id: 'current-user-id', name: 'Current User'); - - when(() => client.state).thenReturn(clientState); - when(() => clientState.currentUser).thenReturn(currentUser); - - return MaterialApp( - theme: ThemeData( - useMaterial3: true, - brightness: brightness ?? Brightness.light, - ), - home: StreamChat( - client: client, - streamChatConfigData: StreamChatConfigurationData(), - connectivityStream: Stream.value([ConnectivityResult.wifi]), - streamChatThemeData: StreamChatThemeData(brightness: brightness), - child: Builder( - builder: (context) { - final theme = StreamChatTheme.of(context); - return Scaffold( - backgroundColor: theme.colorTheme.appBg, - body: Center(child: widget), - ); - }, - ), - ), - ); -} diff --git a/packages/stream_chat_flutter/test/src/scroll_view/member_scroll_view/stream_member_list_view_test.dart b/packages/stream_chat_flutter/test/src/scroll_view/member_scroll_view/stream_member_list_view_test.dart index b5275a9790..13fb4f59a3 100644 --- a/packages/stream_chat_flutter/test/src/scroll_view/member_scroll_view/stream_member_list_view_test.dart +++ b/packages/stream_chat_flutter/test/src/scroll_view/member_scroll_view/stream_member_list_view_test.dart @@ -16,17 +16,13 @@ void main() { clientState = MockClientState(); when(() => client.state).thenAnswer((_) => clientState); when(() => clientState.currentUser).thenReturn(OwnUser(id: 'testid')); - when(() => clientState.currentUserStream) - .thenAnswer((_) => Stream.value(OwnUser(id: 'testid'))); + when(() => clientState.currentUserStream).thenAnswer((_) => Stream.value(OwnUser(id: 'testid'))); channel = MockChannel(); - when(() => channel.on(any(), any(), any(), any())) - .thenAnswer((_) => const Stream.empty()); channelClientState = MockChannelState(); when(() => channel.client).thenReturn(client); when(() => channel.state).thenReturn(channelClientState); - when(() => channelClientState.membersStream) - .thenAnswer((_) => const Stream.empty()); + when(() => channelClientState.membersStream).thenAnswer((_) => const Stream.empty()); when(() => channelClientState.members).thenReturn([]); }); diff --git a/packages/stream_chat_flutter/test/src/scroll_view/thread_scroll_view/goldens/ci/stream_thread_list_tile_dark.png b/packages/stream_chat_flutter/test/src/scroll_view/thread_scroll_view/goldens/ci/stream_thread_list_tile_dark.png index dfab79b8fb..89b449e71c 100644 Binary files a/packages/stream_chat_flutter/test/src/scroll_view/thread_scroll_view/goldens/ci/stream_thread_list_tile_dark.png and b/packages/stream_chat_flutter/test/src/scroll_view/thread_scroll_view/goldens/ci/stream_thread_list_tile_dark.png differ diff --git a/packages/stream_chat_flutter/test/src/scroll_view/thread_scroll_view/goldens/ci/stream_thread_list_tile_light.png b/packages/stream_chat_flutter/test/src/scroll_view/thread_scroll_view/goldens/ci/stream_thread_list_tile_light.png index 9bcf92fd9f..f531ec7135 100644 Binary files a/packages/stream_chat_flutter/test/src/scroll_view/thread_scroll_view/goldens/ci/stream_thread_list_tile_light.png and b/packages/stream_chat_flutter/test/src/scroll_view/thread_scroll_view/goldens/ci/stream_thread_list_tile_light.png differ diff --git a/packages/stream_chat_flutter/test/src/scroll_view/thread_scroll_view/goldens/ci/stream_unread_threads_banner_dark.png b/packages/stream_chat_flutter/test/src/scroll_view/thread_scroll_view/goldens/ci/stream_unread_threads_banner_dark.png index bc3b26348a..e8bd3164ae 100644 Binary files a/packages/stream_chat_flutter/test/src/scroll_view/thread_scroll_view/goldens/ci/stream_unread_threads_banner_dark.png and b/packages/stream_chat_flutter/test/src/scroll_view/thread_scroll_view/goldens/ci/stream_unread_threads_banner_dark.png differ diff --git a/packages/stream_chat_flutter/test/src/scroll_view/thread_scroll_view/goldens/ci/stream_unread_threads_banner_light.png b/packages/stream_chat_flutter/test/src/scroll_view/thread_scroll_view/goldens/ci/stream_unread_threads_banner_light.png index 0dccaf0600..3224ca6103 100644 Binary files a/packages/stream_chat_flutter/test/src/scroll_view/thread_scroll_view/goldens/ci/stream_unread_threads_banner_light.png and b/packages/stream_chat_flutter/test/src/scroll_view/thread_scroll_view/goldens/ci/stream_unread_threads_banner_light.png differ diff --git a/packages/stream_chat_flutter/test/src/scroll_view/thread_scroll_view/stream_thread_list_view_test.dart b/packages/stream_chat_flutter/test/src/scroll_view/thread_scroll_view/stream_thread_list_view_test.dart new file mode 100644 index 0000000000..bccb897337 --- /dev/null +++ b/packages/stream_chat_flutter/test/src/scroll_view/thread_scroll_view/stream_thread_list_view_test.dart @@ -0,0 +1,115 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:stream_chat_flutter/stream_chat_flutter.dart'; + +import '../../mocks.dart'; + +class MockStreamThreadListController extends Mock implements StreamThreadListController { + @override + PagedValue value = const PagedValue.loading(); + + final _unseenThreadIds = ValueNotifier>(const {}); + + @override + ValueListenable> get unseenThreadIds => _unseenThreadIds; +} + +void main() { + late StreamChatClient client; + late ClientState clientState; + late MockStreamThreadListController controller; + late Thread thread; + + setUp(() { + client = MockClient(); + clientState = MockClientState(); + when(() => client.state).thenReturn(clientState); + when(() => clientState.currentUser).thenReturn(OwnUser(id: 'current-user-id')); + + thread = Thread( + channelCid: 'messaging:general', + parentMessageId: 'parent-message-id', + parentMessage: Message( + id: 'parent-message-id', + text: 'Parent message from thread list', + user: User(id: 'other-user-id'), + createdAt: DateTime.now().toUtc(), + ), + createdByUserId: 'other-user-id', + replyCount: 2, + participantCount: 1, + ); + + controller = MockStreamThreadListController(); + when(controller.doInitialLoad).thenAnswer((_) async { + controller.value = PagedValue(items: [thread]); + }); + }); + + tearDown(() { + controller.dispose(); + }); + + testWidgets('renders parent message row with thread indicator', (tester) async { + await tester.pumpWidget( + _wrapWithMaterialApp( + client: client, + child: StreamThreadListView(controller: controller), + ), + ); + await tester.pumpAndSettle(); + + expect(find.text('Parent message from thread list'), findsOneWidget); + expect(find.text('2 replies'), findsOneWidget); + }); + + testWidgets('honors per-instance messageBuilder override', (tester) async { + await tester.pumpWidget( + _wrapWithMaterialApp( + client: client, + child: StreamThreadListView( + controller: controller, + itemBuilder: (_, __, ___, ____) => const Text('custom-thread-row'), + ), + ), + ); + await tester.pumpAndSettle(); + + expect(find.text('custom-thread-row'), findsOneWidget); + }); + + testWidgets('honors global threadListItem component builder override', (tester) async { + await tester.pumpWidget( + _wrapWithMaterialApp( + client: client, + componentBuilders: StreamComponentBuilders( + extensions: streamChatComponentBuilders( + threadListItem: (_, __) => const Text('global-thread-item'), + ), + ), + child: StreamThreadListView(controller: controller), + ), + ); + await tester.pumpAndSettle(); + + expect(find.text('global-thread-item'), findsOneWidget); + }); +} + +Widget _wrapWithMaterialApp({ + required StreamChatClient client, + required Widget child, + StreamComponentBuilders? componentBuilders, +}) { + return MaterialApp( + home: StreamChat( + client: client, + componentBuilders: componentBuilders, + streamChatConfigData: StreamChatConfigurationData(), + connectivityStream: Stream.value([ConnectivityResult.wifi]), + child: Scaffold(body: child), + ), + ); +} diff --git a/packages/stream_chat_flutter/test/src/scroll_view/thread_scroll_view/stream_unread_threads_banner_test.dart b/packages/stream_chat_flutter/test/src/scroll_view/thread_scroll_view/stream_unread_threads_banner_test.dart index 897d140aed..120bb8c4d9 100644 --- a/packages/stream_chat_flutter/test/src/scroll_view/thread_scroll_view/stream_unread_threads_banner_test.dart +++ b/packages/stream_chat_flutter/test/src/scroll_view/thread_scroll_view/stream_unread_threads_banner_test.dart @@ -10,7 +10,10 @@ void main() { constraints: const BoxConstraints.tightFor(width: 400, height: 100), builder: () => _wrapWithMaterialApp( brightness: brightness, - const StreamUnreadThreadsBanner(unreadThreads: {'id1', 'id2', 'id3'}), + const StreamUnreadThreadsBanner( + enabled: true, + unreadThreads: {'id1', 'id2', 'id3'}, + ), ), ); } @@ -23,13 +26,15 @@ Widget _wrapWithMaterialApp( return MaterialApp( home: StreamChatTheme( data: StreamChatThemeData(brightness: brightness), - child: Builder(builder: (context) { - final theme = StreamChatTheme.of(context); - return Scaffold( - backgroundColor: theme.colorTheme.appBg, - body: Center(child: widget), - ); - }), + child: Builder( + builder: (context) { + final theme = StreamChatTheme.of(context); + return Scaffold( + backgroundColor: theme.colorTheme.appBg, + body: Center(child: widget), + ); + }, + ), ), ); } diff --git a/packages/stream_chat_flutter/test/src/stream_chat_configuration_test.dart b/packages/stream_chat_flutter/test/src/stream_chat_configuration_test.dart index d9db650ab2..dcd94aba13 100644 --- a/packages/stream_chat_flutter/test/src/stream_chat_configuration_test.dart +++ b/packages/stream_chat_flutter/test/src/stream_chat_configuration_test.dart @@ -9,15 +9,17 @@ void main() { (t) async { final configuration = StreamChatConfigurationData(); late final StreamChatConfigurationData configurationFromProvider; - await t.pumpWidget(StreamChatConfiguration( - data: configuration, - child: Builder( - builder: (context) { - configurationFromProvider = StreamChatConfiguration.of(context); - return const SizedBox(); - }, + await t.pumpWidget( + StreamChatConfiguration( + data: configuration, + child: Builder( + builder: (context) { + configurationFromProvider = StreamChatConfiguration.of(context); + return const SizedBox(); + }, + ), ), - )); + ); expect(configuration, configurationFromProvider); }, @@ -30,15 +32,17 @@ void main() { enforceUniqueReactions: false, ); late final StreamChatConfigurationData configurationFromProvider; - await t.pumpWidget(StreamChatConfiguration( - data: configuration, - child: Builder( - builder: (context) { - configurationFromProvider = StreamChatConfiguration.of(context); - return const SizedBox(); - }, + await t.pumpWidget( + StreamChatConfiguration( + data: configuration, + child: Builder( + builder: (context) { + configurationFromProvider = StreamChatConfiguration.of(context); + return const SizedBox(); + }, + ), ), - )); + ); expect(configuration, configurationFromProvider); }, diff --git a/packages/stream_chat_flutter/test/src/theme/avatar_theme_test.dart b/packages/stream_chat_flutter/test/src/theme/avatar_theme_test.dart deleted file mode 100644 index 44f6f85113..0000000000 --- a/packages/stream_chat_flutter/test/src/theme/avatar_theme_test.dart +++ /dev/null @@ -1,58 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:stream_chat_flutter/stream_chat_flutter.dart'; - -void main() { - test('AvatarThemeData copyWith, ==, hashCode basics', () { - expect(const StreamAvatarThemeData(), - const StreamAvatarThemeData().copyWith()); - expect(const StreamAvatarThemeData().hashCode, - const StreamAvatarThemeData().copyWith().hashCode); - }); - - group('AvatarThemeData lerps correctly', () { - test('Lerp completely', () { - expect( - const StreamAvatarThemeData() - .lerp(_avatarThemeDataControl1, _avatarThemeDataControl2, 1), - _avatarThemeDataControl2); - }); - - test('Lerp halfway', () { - expect( - const StreamAvatarThemeData().lerp( - _avatarThemeDataControl1, - _avatarThemeDataControl2, - 0.5, - ), - _avatarThemeDataControlMidLerp, - // TODO: Remove skip, once we drop support for flutter v3.24.0 - skip: true, - reason: 'Currently failing in flutter v3.27.0 due to new color alpha', - ); - }); - }); - - test('Merging two AvatarThemeData results in the latter', () { - expect(_avatarThemeDataControl1.merge(_avatarThemeDataControl2), - _avatarThemeDataControl2); - }); -} - -const _avatarThemeDataControl1 = StreamAvatarThemeData(); - -final _avatarThemeDataControlMidLerp = StreamAvatarThemeData( - borderRadius: BorderRadius.circular(16), - constraints: const BoxConstraints.tightFor( - height: 33, - width: 33, - ), -); - -final _avatarThemeDataControl2 = StreamAvatarThemeData( - borderRadius: BorderRadius.circular(12), - constraints: const BoxConstraints.tightFor( - height: 34, - width: 34, - ), -); diff --git a/packages/stream_chat_flutter/test/src/theme/channel_header_theme_test.dart b/packages/stream_chat_flutter/test/src/theme/channel_header_theme_test.dart deleted file mode 100644 index 3f5a546923..0000000000 --- a/packages/stream_chat_flutter/test/src/theme/channel_header_theme_test.dart +++ /dev/null @@ -1,104 +0,0 @@ -import 'package:flutter/material.dart' hide TextTheme; -import 'package:flutter_test/flutter_test.dart'; -import 'package:stream_chat_flutter/stream_chat_flutter.dart'; - -void main() { - test('ChannelHeaderThemeData copyWith, ==, hashCode basics', () { - expect(const StreamChannelHeaderThemeData(), - const StreamChannelHeaderThemeData().copyWith()); - expect(const StreamChannelHeaderThemeData().hashCode, - const StreamChannelHeaderThemeData().copyWith().hashCode); - }); - - group('ChannelHeaderThemeData lerps', () { - test( - '''Light ChannelHeaderThemeData lerps completely to dark ChannelHeaderThemeData''', - () { - expect( - const StreamChannelHeaderThemeData() - .lerp(_channelThemeControl, _channelThemeControlDark, 1), - _channelThemeControlDark); - }); - - test( - '''Light ChannelHeaderThemeData lerps halfway to dark ChannelHeaderThemeData''', - () { - expect( - const StreamChannelHeaderThemeData().lerp( - _channelThemeControl, - _channelThemeControlDark, - 0.5, - ), - _channelThemeControlMidLerp, - // TODO: Remove skip, once we drop support for flutter v3.24.0 - skip: true, - reason: 'Currently failing in flutter v3.27.0 due to new color alpha', - ); - }); - - test( - '''Dark ChannelHeaderThemeData lerps completely to light ChannelHeaderThemeData''', - () { - expect( - const StreamChannelHeaderThemeData() - .lerp(_channelThemeControlDark, _channelThemeControl, 1), - _channelThemeControl); - }); - }); - - test('Merging dark and light themes results in a dark theme', () { - expect(_channelThemeControl.merge(_channelThemeControlDark), - _channelThemeControlDark); - }); -} - -final _channelThemeControl = StreamChannelHeaderThemeData( - avatarTheme: StreamAvatarThemeData( - borderRadius: BorderRadius.circular(20), - constraints: const BoxConstraints.tightFor( - height: 40, - width: 40, - ), - ), - color: const Color(0xff101418), - titleStyle: StreamTextTheme.light().headlineBold.copyWith( - color: const Color(0xffffffff), - ), - subtitleStyle: StreamTextTheme.light().footnote.copyWith( - color: const Color(0xff7a7a7a), - ), -); - -final _channelThemeControlMidLerp = StreamChannelHeaderThemeData( - avatarTheme: StreamAvatarThemeData( - borderRadius: BorderRadius.circular(20), - constraints: const BoxConstraints.tightFor( - height: 40, - width: 40, - ), - ), - color: const Color(0xff111417), - titleStyle: const TextStyle( - color: Color(0xffffffff), - fontWeight: FontWeight.w500, - fontSize: 16, - ), - subtitleStyle: StreamTextTheme.light().footnote.copyWith( - color: const Color(0xff7a7a7a), - ), -); - -final _channelThemeControlDark = StreamChannelHeaderThemeData( - avatarTheme: StreamAvatarThemeData( - borderRadius: BorderRadius.circular(20), - constraints: const BoxConstraints.tightFor( - height: 40, - width: 40, - ), - ), - color: StreamColorTheme.dark().barsBg, - titleStyle: StreamTextTheme.dark().headlineBold, - subtitleStyle: StreamTextTheme.dark().footnote.copyWith( - color: const Color(0xff7A7A7A), - ), -); diff --git a/packages/stream_chat_flutter/test/src/theme/channel_list_header_theme_test.dart b/packages/stream_chat_flutter/test/src/theme/channel_list_header_theme_test.dart deleted file mode 100644 index b9bd1f8c04..0000000000 --- a/packages/stream_chat_flutter/test/src/theme/channel_list_header_theme_test.dart +++ /dev/null @@ -1,99 +0,0 @@ -import 'package:flutter/material.dart' hide TextTheme; -import 'package:flutter_test/flutter_test.dart'; -import 'package:stream_chat_flutter/stream_chat_flutter.dart'; - -void main() { - test('ChannelListHeaderThemeData copyWith, ==, hashCode basics', () { - expect(const StreamChannelListHeaderThemeData(), - const StreamChannelListHeaderThemeData().copyWith()); - expect(const StreamChannelListHeaderThemeData().hashCode, - const StreamChannelListHeaderThemeData().copyWith().hashCode); - }); - - group('ChannelListHeaderThemeData lerps', () { - test( - '''Light ChannelListHeaderThemeData lerps completely to dark ChannelListHeaderThemeData''', - () { - expect( - const StreamChannelListHeaderThemeData().lerp( - _channelListHeaderThemeControl, - _channelListHeaderThemeControlDark, - 1), - _channelListHeaderThemeControlDark); - }); - - test( - '''Light ChannelListHeaderThemeData lerps halfway to dark ChannelListHeaderThemeData''', - () { - expect( - const StreamChannelListHeaderThemeData().lerp( - _channelListHeaderThemeControl, - _channelListHeaderThemeControlDark, - 0.5, - ), - _channelListHeaderThemeControlMidLerp, - // TODO: Remove skip, once we drop support for flutter v3.24.0 - skip: true, - reason: 'Currently failing in flutter v3.27.0 due to new color alpha', - ); - }); - - test( - '''Dark ChannelListHeaderThemeData lerps completely to light ChannelListHeaderThemeData''', - () { - expect( - const StreamChannelListHeaderThemeData().lerp( - _channelListHeaderThemeControlDark, - _channelListHeaderThemeControl, - 1), - _channelListHeaderThemeControl); - }); - }); - - test('Merging dark and light themes results in a dark theme', () { - expect( - _channelListHeaderThemeControl - .merge(_channelListHeaderThemeControlDark), - _channelListHeaderThemeControlDark); - }); -} - -final _channelListHeaderThemeControl = StreamChannelListHeaderThemeData( - avatarTheme: StreamAvatarThemeData( - borderRadius: BorderRadius.circular(20), - constraints: const BoxConstraints.tightFor( - height: 40, - width: 40, - ), - ), - color: StreamColorTheme.light().barsBg, - titleStyle: StreamTextTheme.light().headlineBold, -); - -final _channelListHeaderThemeControlMidLerp = StreamChannelListHeaderThemeData( - avatarTheme: StreamAvatarThemeData( - borderRadius: BorderRadius.circular(20), - constraints: const BoxConstraints.tightFor( - height: 40, - width: 40, - ), - ), - color: const Color(0xff88898a), - titleStyle: const TextStyle( - color: Color(0xff7f7f7f), - fontSize: 16, - fontWeight: FontWeight.w500, - ), -); - -final _channelListHeaderThemeControlDark = StreamChannelListHeaderThemeData( - avatarTheme: StreamAvatarThemeData( - borderRadius: BorderRadius.circular(20), - constraints: const BoxConstraints.tightFor( - height: 40, - width: 40, - ), - ), - color: StreamColorTheme.dark().barsBg, - titleStyle: StreamTextTheme.dark().headlineBold, -); diff --git a/packages/stream_chat_flutter/test/src/theme/channel_preview_theme_test.dart b/packages/stream_chat_flutter/test/src/theme/channel_preview_theme_test.dart deleted file mode 100644 index 4f04d916d2..0000000000 --- a/packages/stream_chat_flutter/test/src/theme/channel_preview_theme_test.dart +++ /dev/null @@ -1,124 +0,0 @@ -import 'package:flutter/material.dart' hide TextTheme; -import 'package:flutter_test/flutter_test.dart'; -import 'package:stream_chat_flutter/stream_chat_flutter.dart'; - -String _dummyFormatter(BuildContext context, DateTime date) => 'formatted'; - -void main() { - test('ChannelPreviewThemeData copyWith, ==, hashCode basics', () { - expect(const StreamChannelPreviewThemeData(), - const StreamChannelPreviewThemeData().copyWith()); - expect(const StreamChannelPreviewThemeData().hashCode, - const StreamChannelPreviewThemeData().copyWith().hashCode); - }); - - group('ChannelPreviewThemeData lerps', () { - test( - '''Light ChannelPreviewThemeData lerps completely to dark ChannelPreviewThemeData''', - () { - expect( - const StreamChannelPreviewThemeData().lerp( - _channelPreviewThemeControl, _channelPreviewThemeControlDark, 1), - _channelPreviewThemeControlDark); - }); - - test( - '''Light ChannelPreviewThemeData lerps halfway to dark ChannelPreviewThemeData''', - () { - expect( - const StreamChannelPreviewThemeData().lerp( - _channelPreviewThemeControl, - _channelPreviewThemeControlDark, - 0.5, - ), - _channelPreviewThemeControlMidLerp, - // TODO: Remove skip, once we drop support for flutter v3.24.0 - skip: true, - reason: 'Currently failing in flutter v3.27.0 due to new color alpha', - ); - }); - - test( - '''Dark ChannelPreviewThemeData lerps completely to light ChannelPreviewThemeData''', - () { - expect( - const StreamChannelPreviewThemeData().lerp( - _channelPreviewThemeControlDark, _channelPreviewThemeControl, 1), - _channelPreviewThemeControl); - }); - }); - - test('Merging dark and light themes results in a dark theme', () { - expect(_channelPreviewThemeControl.merge(_channelPreviewThemeControlDark), - _channelPreviewThemeControlDark); - }); -} - -final _channelPreviewThemeControl = StreamChannelPreviewThemeData( - unreadCounterColor: StreamColorTheme.light().accentError, - avatarTheme: StreamAvatarThemeData( - borderRadius: BorderRadius.circular(20), - constraints: const BoxConstraints.tightFor( - height: 40, - width: 40, - ), - ), - titleStyle: StreamTextTheme.light().bodyBold, - subtitleStyle: StreamTextTheme.light().footnote.copyWith( - color: const Color(0xff7A7A7A), - ), - lastMessageAtStyle: StreamTextTheme.light().footnote.copyWith( - // ignore: deprecated_member_use - color: StreamColorTheme.light().textHighEmphasis.withOpacity(0.5), - ), - lastMessageAtFormatter: _dummyFormatter, - indicatorIconSize: 16, -); - -final _channelPreviewThemeControlMidLerp = StreamChannelPreviewThemeData( - unreadCounterColor: const Color(0xffff3742), - avatarTheme: StreamAvatarThemeData( - borderRadius: BorderRadius.circular(20), - constraints: const BoxConstraints.tightFor( - height: 40, - width: 40, - ), - ), - titleStyle: const TextStyle( - color: Color(0xff7f7f7f), - fontSize: 14, - fontWeight: FontWeight.w500, - ), - subtitleStyle: const TextStyle( - color: Color(0xff7a7a7a), - fontSize: 12, - fontWeight: FontWeight.w400, - ), - lastMessageAtStyle: StreamTextTheme.light().footnote.copyWith( - // ignore: deprecated_member_use - color: const Color(0x807f7f7f).withOpacity(0.5), - ), - lastMessageAtFormatter: _dummyFormatter, - indicatorIconSize: 16, -); - -final _channelPreviewThemeControlDark = StreamChannelPreviewThemeData( - unreadCounterColor: StreamColorTheme.dark().accentError, - avatarTheme: StreamAvatarThemeData( - borderRadius: BorderRadius.circular(20), - constraints: const BoxConstraints.tightFor( - height: 40, - width: 40, - ), - ), - titleStyle: StreamTextTheme.dark().bodyBold, - subtitleStyle: StreamTextTheme.dark().footnote.copyWith( - color: const Color(0xff7A7A7A), - ), - lastMessageAtStyle: StreamTextTheme.dark().footnote.copyWith( - // ignore: deprecated_member_use - color: StreamColorTheme.dark().textHighEmphasis.withOpacity(0.5), - ), - lastMessageAtFormatter: _dummyFormatter, - indicatorIconSize: 16, -); diff --git a/packages/stream_chat_flutter/test/src/theme/draft_list_tile_theme_test.dart b/packages/stream_chat_flutter/test/src/theme/draft_list_tile_theme_test.dart deleted file mode 100644 index e7f91b0fba..0000000000 --- a/packages/stream_chat_flutter/test/src/theme/draft_list_tile_theme_test.dart +++ /dev/null @@ -1,177 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:stream_chat_flutter/stream_chat_flutter.dart'; - -String _dummyFormatter(BuildContext context, DateTime date) => 'formatted'; - -void main() { - testWidgets('StreamDraftListTileTheme merges with ancestor theme', - (tester) async { - const backgroundColor = Colors.blue; - const childBackgroundColor = Colors.red; - - late BuildContext capturedContext; - - await tester.pumpWidget( - MaterialApp( - home: StreamChatTheme( - data: StreamChatThemeData( - draftListTileTheme: const StreamDraftListTileThemeData( - backgroundColor: backgroundColor, - ), - ), - child: Builder( - builder: (context) { - return StreamDraftListTileTheme( - data: const StreamDraftListTileThemeData( - backgroundColor: childBackgroundColor, - ), - child: Builder( - builder: (context) { - capturedContext = context; - return const SizedBox(); - }, - ), - ); - }, - ), - ), - ), - ); - - // Verify that the theme data is correctly merged - final theme = StreamDraftListTileTheme.of(capturedContext); - expect(theme.backgroundColor, childBackgroundColor); - }); - - test('StreamDraftListTileThemeData equality', () { - const themeData1 = StreamDraftListTileThemeData( - backgroundColor: Colors.red, - padding: EdgeInsets.all(8), - draftChannelNameStyle: TextStyle(fontSize: 16), - draftMessageStyle: TextStyle(fontSize: 14), - draftTimestampStyle: TextStyle(fontSize: 12), - draftTimestampFormatter: _dummyFormatter, - ); - - const themeData2 = StreamDraftListTileThemeData( - backgroundColor: Colors.red, - padding: EdgeInsets.all(8), - draftChannelNameStyle: TextStyle(fontSize: 16), - draftMessageStyle: TextStyle(fontSize: 14), - draftTimestampStyle: TextStyle(fontSize: 12), - draftTimestampFormatter: _dummyFormatter, - ); - - const themeData3 = StreamDraftListTileThemeData( - backgroundColor: Colors.blue, // Different color - padding: EdgeInsets.all(8), - draftChannelNameStyle: TextStyle(fontSize: 16), - draftMessageStyle: TextStyle(fontSize: 14), - draftTimestampStyle: TextStyle(fontSize: 12), - draftTimestampFormatter: _dummyFormatter, - ); - - // Same properties should be equal - expect(themeData1, themeData2); - // Different properties should not be equal - expect(themeData1, isNot(themeData3)); - - // Hash codes should match for equal objects - expect(themeData1.hashCode, themeData2.hashCode); - }); - - test('StreamDraftListTileThemeData copyWith', () { - const original = StreamDraftListTileThemeData( - backgroundColor: Colors.red, - padding: EdgeInsets.all(8), - draftChannelNameStyle: TextStyle(fontSize: 16), - draftMessageStyle: TextStyle(fontSize: 14), - draftTimestampStyle: TextStyle(fontSize: 12), - draftTimestampFormatter: _dummyFormatter, - ); - - const newBackgroundColor = Colors.blue; - const newPadding = EdgeInsets.all(16); - - final copied = original.copyWith( - backgroundColor: newBackgroundColor, - padding: newPadding, - ); - - // Verify copied properties - expect(copied.backgroundColor, newBackgroundColor); - expect(copied.padding, newPadding); - // Unchanged properties should remain the same - expect(copied.draftChannelNameStyle, original.draftChannelNameStyle); - expect(copied.draftMessageStyle, original.draftMessageStyle); - expect(copied.draftTimestampStyle, original.draftTimestampStyle); - expect(copied.draftTimestampFormatter, original.draftTimestampFormatter); - }); - - test('StreamDraftListTileThemeData merge', () { - const original = StreamDraftListTileThemeData( - backgroundColor: Colors.red, - padding: EdgeInsets.all(8), - draftChannelNameStyle: TextStyle(fontSize: 16), - draftMessageStyle: TextStyle(fontSize: 14), - draftTimestampStyle: TextStyle(fontSize: 12), - draftTimestampFormatter: _dummyFormatter, - ); - - const other = StreamDraftListTileThemeData( - backgroundColor: Colors.blue, - padding: EdgeInsets.all(16), - // Other properties are null - ); - - final merged = original.merge(other); - - // Properties from 'other' should override 'original' - expect(merged.backgroundColor, other.backgroundColor); - expect(merged.padding, other.padding); - // Null properties in 'other' should not override 'original' - expect(merged.draftChannelNameStyle, original.draftChannelNameStyle); - expect(merged.draftMessageStyle, original.draftMessageStyle); - expect(merged.draftTimestampStyle, original.draftTimestampStyle); - expect(merged.draftTimestampFormatter, original.draftTimestampFormatter); - - // Merging with null should return original - final mergedWithNull = original.merge(null); - expect(mergedWithNull, original); - }); - - test('StreamDraftListTileThemeData lerp', () { - const data1 = StreamDraftListTileThemeData( - backgroundColor: Colors.black, - padding: EdgeInsets.all(8), - ); - - const data2 = StreamDraftListTileThemeData( - backgroundColor: Colors.white, - padding: EdgeInsets.all(16), - ); - - // t = 0 should return data1 - final lerpedAt0 = data1.lerp(data1, data2, 0); - expect(lerpedAt0.backgroundColor, data1.backgroundColor); - expect(lerpedAt0.padding, data1.padding); - - // t = 1 should return data2 - final lerpedAt1 = data1.lerp(data1, data2, 1); - expect(lerpedAt1.backgroundColor, data2.backgroundColor); - expect(lerpedAt1.padding, data2.padding); - - // t = 0.5 should return something in between - final lerpedAt05 = data1.lerp(data1, data2, 0.5); - expect(lerpedAt05.backgroundColor, - Color.lerp(Colors.black, Colors.white, 0.5)); - expect( - lerpedAt05.padding, - EdgeInsetsGeometry.lerp( - const EdgeInsets.all(8), - const EdgeInsets.all(16), - 0.5, - )); - }); -} diff --git a/packages/stream_chat_flutter/test/src/theme/gallery_footer_theme_test.dart b/packages/stream_chat_flutter/test/src/theme/gallery_footer_theme_test.dart deleted file mode 100644 index e06e0ac157..0000000000 --- a/packages/stream_chat_flutter/test/src/theme/gallery_footer_theme_test.dart +++ /dev/null @@ -1,188 +0,0 @@ -import 'package:flutter/material.dart' hide TextTheme; -import 'package:flutter_test/flutter_test.dart'; -import 'package:mocktail/mocktail.dart'; -import 'package:stream_chat_flutter/stream_chat_flutter.dart'; - -class MockStreamChatClient extends Mock implements StreamChatClient {} - -void main() { - test('GalleryFooterThemeData copyWith, ==, hashCode basics', () { - expect(const StreamGalleryFooterThemeData(), - const StreamGalleryFooterThemeData().copyWith()); - expect(const StreamGalleryFooterThemeData().hashCode, - const StreamGalleryFooterThemeData().copyWith().hashCode); - }); - - test( - '''Light GalleryFooterThemeData lerps completely to dark GalleryFooterThemeData''', - () { - expect( - const StreamGalleryFooterThemeData().lerp( - _galleryFooterThemeDataControl, - _galleryFooterThemeDataControlDark, - 1), - _galleryFooterThemeDataControlDark); - }); - - test( - '''Light GalleryFooterThemeData lerps halfway to dark GalleryFooterThemeData''', - () { - expect( - const StreamGalleryFooterThemeData().lerp( - _galleryFooterThemeDataControl, - _galleryFooterThemeDataControlDark, - 0.5, - ), - _galleryFooterThemeDataControlMidLerp, - // TODO: Remove skip, once we drop support for flutter v3.24.0 - skip: true, - reason: 'Currently failing in flutter v3.27.0 due to new color alpha', - ); - }); - - test( - '''Dark GalleryFooterThemeData lerps completely to light GalleryFooterThemeData''', - () { - expect( - const StreamGalleryFooterThemeData().lerp( - _galleryFooterThemeDataControlDark, - _galleryFooterThemeDataControl, - 1), - _galleryFooterThemeDataControl); - }); - - test('Merging dark and light themes results in a dark theme', () { - expect( - _galleryFooterThemeDataControl - .merge(_galleryFooterThemeDataControlDark), - _galleryFooterThemeDataControlDark); - }); - - test('Merging dark and light themes results in a dark theme', () { - expect( - _galleryFooterThemeDataControlDark - .merge(_galleryFooterThemeDataControl), - _galleryFooterThemeDataControl); - }); - - testWidgets( - 'Passing no GalleryFooterThemeData returns default light theme values', - (WidgetTester tester) async { - late BuildContext _context; - await tester.pumpWidget( - MaterialApp( - builder: (context, child) => StreamChat( - client: MockStreamChatClient(), - child: child, - ), - home: Builder( - builder: (context) { - _context = context; - return const SizedBox.shrink(); - }, - ), - ), - ); - - final imageFooterTheme = StreamGalleryFooterTheme.of(_context); - expect(imageFooterTheme.backgroundColor, - _galleryFooterThemeDataControl.backgroundColor); - expect(imageFooterTheme.shareIconColor, - _galleryFooterThemeDataControl.shareIconColor); - expect(imageFooterTheme.titleTextStyle, - _galleryFooterThemeDataControl.titleTextStyle); - expect(imageFooterTheme.gridIconButtonColor, - _galleryFooterThemeDataControl.gridIconButtonColor); - expect(imageFooterTheme.bottomSheetBarrierColor, - _galleryFooterThemeDataControl.bottomSheetBarrierColor); - expect(imageFooterTheme.bottomSheetBackgroundColor, - _galleryFooterThemeDataControl.bottomSheetBackgroundColor); - expect(imageFooterTheme.bottomSheetCloseIconColor, - _galleryFooterThemeDataControl.bottomSheetCloseIconColor); - expect(imageFooterTheme.bottomSheetPhotosTextStyle, - _galleryFooterThemeDataControl.bottomSheetPhotosTextStyle); - }); - - testWidgets( - 'Passing no GalleryFooterThemeData returns default dark theme values', - (WidgetTester tester) async { - late BuildContext _context; - await tester.pumpWidget( - MaterialApp( - builder: (context, child) => StreamChat( - client: MockStreamChatClient(), - streamChatThemeData: StreamChatThemeData.dark(), - child: child, - ), - home: Builder( - builder: (context) { - _context = context; - return const SizedBox.shrink(); - }, - ), - ), - ); - - final imageFooterTheme = StreamGalleryFooterTheme.of(_context); - expect(imageFooterTheme.backgroundColor, - _galleryFooterThemeDataControlDark.backgroundColor); - expect(imageFooterTheme.shareIconColor, - _galleryFooterThemeDataControlDark.shareIconColor); - expect(imageFooterTheme.titleTextStyle, - _galleryFooterThemeDataControlDark.titleTextStyle); - expect(imageFooterTheme.gridIconButtonColor, - _galleryFooterThemeDataControlDark.gridIconButtonColor); - expect(imageFooterTheme.bottomSheetBarrierColor, - _galleryFooterThemeDataControlDark.bottomSheetBarrierColor); - expect(imageFooterTheme.bottomSheetBackgroundColor, - _galleryFooterThemeDataControlDark.bottomSheetBackgroundColor); - expect(imageFooterTheme.bottomSheetCloseIconColor, - _galleryFooterThemeDataControlDark.bottomSheetCloseIconColor); - expect(imageFooterTheme.bottomSheetPhotosTextStyle, - _galleryFooterThemeDataControlDark.bottomSheetPhotosTextStyle); - }); -} - -// Light theme control -final _galleryFooterThemeDataControl = StreamGalleryFooterThemeData( - backgroundColor: StreamColorTheme.light().barsBg, - shareIconColor: StreamColorTheme.light().textHighEmphasis, - titleTextStyle: StreamTextTheme.light().headlineBold, - gridIconButtonColor: StreamColorTheme.light().textHighEmphasis, - bottomSheetBackgroundColor: StreamColorTheme.light().barsBg, - bottomSheetBarrierColor: StreamColorTheme.light().overlay, - bottomSheetCloseIconColor: StreamColorTheme.light().textHighEmphasis, - bottomSheetPhotosTextStyle: StreamTextTheme.light().headlineBold, -); - -// Mid-lerp theme control -const _galleryFooterThemeDataControlMidLerp = StreamGalleryFooterThemeData( - backgroundColor: Color(0xff88898a), - shareIconColor: Color(0xff7f7f7f), - titleTextStyle: TextStyle( - color: Color(0xff7f7f7f), - fontSize: 16, - fontWeight: FontWeight.w500, - ), - gridIconButtonColor: Color(0xff7f7f7f), - bottomSheetBarrierColor: Color(0x4c000000), - bottomSheetBackgroundColor: Color(0xff88898a), - bottomSheetPhotosTextStyle: TextStyle( - color: Color(0xff7f7f7f), - fontSize: 16, - fontWeight: FontWeight.w500, - ), - bottomSheetCloseIconColor: Color(0xff7f7f7f), -); - -// Dark theme control -final _galleryFooterThemeDataControlDark = StreamGalleryFooterThemeData( - backgroundColor: StreamColorTheme.dark().barsBg, - shareIconColor: StreamColorTheme.dark().textHighEmphasis, - titleTextStyle: StreamTextTheme.dark().headlineBold, - gridIconButtonColor: StreamColorTheme.dark().textHighEmphasis, - bottomSheetBackgroundColor: StreamColorTheme.dark().barsBg, - bottomSheetBarrierColor: StreamColorTheme.dark().overlay, - bottomSheetCloseIconColor: StreamColorTheme.dark().textHighEmphasis, - bottomSheetPhotosTextStyle: StreamTextTheme.dark().headlineBold, -); diff --git a/packages/stream_chat_flutter/test/src/theme/gallery_header_theme_test.dart b/packages/stream_chat_flutter/test/src/theme/gallery_header_theme_test.dart deleted file mode 100644 index 6c080193ed..0000000000 --- a/packages/stream_chat_flutter/test/src/theme/gallery_header_theme_test.dart +++ /dev/null @@ -1,189 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:mocktail/mocktail.dart'; -import 'package:stream_chat_flutter/stream_chat_flutter.dart'; - -class MockStreamChatClient extends Mock implements StreamChatClient {} - -void main() { - test('GalleryHeaderThemeData copyWith, ==, hashCode basics', () { - expect(const StreamGalleryHeaderThemeData(), - const StreamGalleryHeaderThemeData().copyWith()); - expect(const StreamGalleryHeaderThemeData().hashCode, - const StreamGalleryHeaderThemeData().copyWith().hashCode); - }); - - test( - '''Light GalleryHeaderThemeData lerps completely to dark GalleryHeaderThemeData''', - () { - expect( - const StreamGalleryHeaderThemeData().lerp( - _galleryHeaderThemeDataControl, - _galleryHeaderThemeDataDarkControl, - 1), - _galleryHeaderThemeDataDarkControl); - }); - - test( - '''Light GalleryHeaderThemeData lerps halfway to dark GalleryHeaderThemeData''', - () { - expect( - const StreamGalleryHeaderThemeData().lerp( - _galleryHeaderThemeDataControl, - _galleryHeaderThemeDataDarkControl, - 0.5, - ), - _galleryHeaderThemeDataHalfLerpControl, - // TODO: Remove skip, once we drop support for flutter v3.24.0 - skip: true, - reason: 'Currently failing in flutter v3.27.0 due to new color alpha', - ); - }); - - test( - '''Dark GalleryHeaderThemeData lerps completely to light GalleryHeaderThemeData''', - () { - expect( - const StreamGalleryHeaderThemeData().lerp( - _galleryHeaderThemeDataDarkControl, - _galleryHeaderThemeDataControl, - 1), - _galleryHeaderThemeDataControl); - }); - - test('Merging dark and light themes results in a dark theme', () { - expect( - _galleryHeaderThemeDataControl - .merge(_galleryHeaderThemeDataDarkControl), - _galleryHeaderThemeDataDarkControl); - }); - - testWidgets( - 'Passing no GalleryHeaderThemeData returns default light theme values', - (WidgetTester tester) async { - late BuildContext _context; - await tester.pumpWidget( - MaterialApp( - builder: (context, child) => StreamChat( - client: MockStreamChatClient(), - child: child, - ), - home: Builder( - builder: (context) { - _context = context; - return const SizedBox.shrink(); - }, - ), - ), - ); - - final imageHeaderTheme = StreamGalleryHeaderTheme.of(_context); - expect(imageHeaderTheme.closeButtonColor, - _galleryHeaderThemeDataControl.closeButtonColor); - expect(imageHeaderTheme.backgroundColor, - _galleryHeaderThemeDataControl.backgroundColor); - expect(imageHeaderTheme.iconMenuPointColor, - _galleryHeaderThemeDataControl.iconMenuPointColor); - expect(imageHeaderTheme.titleTextStyle, - _galleryHeaderThemeDataControl.titleTextStyle); - expect(imageHeaderTheme.subtitleTextStyle, - _galleryHeaderThemeDataControl.subtitleTextStyle); - expect(imageHeaderTheme.bottomSheetBarrierColor, - _galleryHeaderThemeDataControl.bottomSheetBarrierColor); - }); - - testWidgets( - 'Passing no GalleryHeaderThemeData returns default dark theme values', - (WidgetTester tester) async { - late BuildContext _context; - await tester.pumpWidget( - MaterialApp( - builder: (context, child) => StreamChat( - client: MockStreamChatClient(), - streamChatThemeData: StreamChatThemeData.dark(), - child: child, - ), - home: Builder( - builder: (context) { - _context = context; - return const SizedBox.shrink(); - }, - ), - ), - ); - - final imageHeaderTheme = StreamGalleryHeaderTheme.of(_context); - expect(imageHeaderTheme.closeButtonColor, - _galleryHeaderThemeDataDarkControl.closeButtonColor); - expect(imageHeaderTheme.backgroundColor, - _galleryHeaderThemeDataDarkControl.backgroundColor); - expect(imageHeaderTheme.iconMenuPointColor, - _galleryHeaderThemeDataDarkControl.iconMenuPointColor); - expect(imageHeaderTheme.titleTextStyle, - _galleryHeaderThemeDataDarkControl.titleTextStyle); - expect(imageHeaderTheme.subtitleTextStyle, - _galleryHeaderThemeDataDarkControl.subtitleTextStyle); - expect(imageHeaderTheme.bottomSheetBarrierColor, - _galleryHeaderThemeDataDarkControl.bottomSheetBarrierColor); - }); -} - -// Light theme test control. -final _galleryHeaderThemeDataControl = StreamGalleryHeaderThemeData( - closeButtonColor: const Color(0xff000000), - backgroundColor: const Color(0xffffffff), - iconMenuPointColor: const Color(0xff000000), - titleTextStyle: const TextStyle( - fontSize: 16, - fontWeight: FontWeight.w500, - color: Colors.black, - ), - subtitleTextStyle: const TextStyle( - fontSize: 12, - color: Colors.black, - fontWeight: FontWeight.w400, - ).copyWith( - color: const Color(0xff7A7A7A), - ), - bottomSheetBarrierColor: const Color.fromRGBO(0, 0, 0, 0.2), -); - -// Light theme test control. -final _galleryHeaderThemeDataHalfLerpControl = StreamGalleryHeaderThemeData( - closeButtonColor: const Color(0xff7f7f7f), - backgroundColor: const Color(0xff88898a), - iconMenuPointColor: const Color(0xff7f7f7f), - titleTextStyle: const TextStyle( - fontSize: 16, - fontWeight: FontWeight.w500, - color: Color(0xff7f7f7f), - ), - subtitleTextStyle: const TextStyle( - fontSize: 12, - color: Color(0xff7a7a7a), - fontWeight: FontWeight.w400, - ).copyWith( - color: const Color(0xff7A7A7A), - ), - bottomSheetBarrierColor: const Color(0x4c000000), -); - -// Dark theme test control. -final _galleryHeaderThemeDataDarkControl = StreamGalleryHeaderThemeData( - closeButtonColor: const Color(0xffffffff), - backgroundColor: const Color(0xff121416), - iconMenuPointColor: const Color(0xffffffff), - titleTextStyle: const TextStyle( - fontSize: 16, - fontWeight: FontWeight.w500, - color: Colors.white, - ), - subtitleTextStyle: const TextStyle( - fontSize: 12, - color: Colors.white, - fontWeight: FontWeight.w400, - ).copyWith( - color: const Color(0xff7A7A7A), - ), - bottomSheetBarrierColor: const Color.fromRGBO(0, 0, 0, 0.4), -); diff --git a/packages/stream_chat_flutter/test/src/theme/message_input_theme_test.dart b/packages/stream_chat_flutter/test/src/theme/message_input_theme_test.dart deleted file mode 100644 index 425fb5c151..0000000000 --- a/packages/stream_chat_flutter/test/src/theme/message_input_theme_test.dart +++ /dev/null @@ -1,129 +0,0 @@ -import 'package:flutter/material.dart' hide TextTheme; -import 'package:flutter_test/flutter_test.dart'; -import 'package:stream_chat_flutter/stream_chat_flutter.dart'; - -void main() { - test('MessageInputThemeData copyWith, ==, hashCode basics', () { - expect(const StreamMessageInputThemeData(), - const StreamMessageInputThemeData().copyWith()); - expect(const StreamMessageInputThemeData().hashCode, - const StreamMessageInputThemeData().copyWith().hashCode); - }); - - group('MessageInputThemeData lerps correctly', () { - test('Lerp completely from light to dark', () { - expect( - const StreamMessageInputThemeData().lerp( - _messageInputThemeControl, _messageInputThemeControlDark, 1), - _messageInputThemeControlDark); - }); - - test('Lerp halfway from light to dark', () { - expect( - const StreamMessageInputThemeData().lerp( - _messageInputThemeControl, - _messageInputThemeControlDark, - 0.5, - ), - _messageInputThemeControlMidLerp, - // TODO: Remove skip, once we drop support for flutter v3.24.0 - skip: true, - reason: 'Currently failing in flutter v3.27.0 due to new color alpha', - ); - }); - - test('Lerp completely from dark to light', () { - expect( - const StreamMessageInputThemeData().lerp( - _messageInputThemeControlDark, _messageInputThemeControl, 1), - _messageInputThemeControl); - }); - }); - - test('Merging two MessageInputThemeData results in the latter', () { - expect(_messageInputThemeControl.merge(_messageInputThemeControlDark), - _messageInputThemeControlDark); - }); -} - -final _messageInputThemeControl = StreamMessageInputThemeData( - borderRadius: BorderRadius.circular(20), - sendAnimationDuration: const Duration(milliseconds: 300), - actionButtonColor: StreamColorTheme.light().accentPrimary, - actionButtonIdleColor: StreamColorTheme.light().textLowEmphasis, - expandButtonColor: StreamColorTheme.light().accentPrimary, - sendButtonColor: StreamColorTheme.light().accentPrimary, - sendButtonIdleColor: StreamColorTheme.light().disabled, - inputBackgroundColor: StreamColorTheme.light().barsBg, - inputTextStyle: StreamTextTheme.light().body, - idleBorderGradient: LinearGradient( - stops: const [0.0, 1.0], - colors: [ - StreamColorTheme.light().disabled, - StreamColorTheme.light().disabled, - ], - ), - activeBorderGradient: LinearGradient( - stops: const [0.0, 1.0], - colors: [ - StreamColorTheme.light().disabled, - StreamColorTheme.light().disabled, - ], - ), -); - -final _messageInputThemeControlMidLerp = StreamMessageInputThemeData( - borderRadius: BorderRadius.circular(20), - sendAnimationDuration: const Duration(milliseconds: 300), - inputBackgroundColor: const Color(0xff88898a), - actionButtonColor: const Color(0xff196eff), - actionButtonIdleColor: const Color(0xff7a7a7a), - sendButtonColor: const Color(0xff196eff), - sendButtonIdleColor: const Color(0xff848585), - expandButtonColor: const Color(0xff196eff), - inputTextStyle: const TextStyle( - color: Color(0xff7f7f7f), - fontSize: 14, - fontWeight: FontWeight.w400, - ), - idleBorderGradient: const LinearGradient( - stops: [0.0, 1.0], - colors: [ - Color(0xff848585), - Color(0xff848585), - ], - ), - activeBorderGradient: const LinearGradient( - stops: [0.0, 1.0], - colors: [ - Color(0xff848585), - Color(0xff848585), - ], - ), -); - -final _messageInputThemeControlDark = StreamMessageInputThemeData( - borderRadius: BorderRadius.circular(20), - sendAnimationDuration: const Duration(milliseconds: 300), - actionButtonColor: StreamColorTheme.dark().accentPrimary, - actionButtonIdleColor: StreamColorTheme.dark().textLowEmphasis, - expandButtonColor: StreamColorTheme.dark().accentPrimary, - sendButtonColor: StreamColorTheme.dark().accentPrimary, - sendButtonIdleColor: StreamColorTheme.dark().disabled, - inputBackgroundColor: StreamColorTheme.dark().barsBg, - inputTextStyle: StreamTextTheme.dark().body, - idleBorderGradient: LinearGradient( - stops: const [0.0, 1.0], - colors: [ - StreamColorTheme.dark().disabled, - StreamColorTheme.dark().disabled, - ], - ), - activeBorderGradient: LinearGradient( - stops: const [0.0, 1.0], - colors: [ - StreamColorTheme.dark().disabled, - StreamColorTheme.dark().disabled, - ], - ), -); diff --git a/packages/stream_chat_flutter/test/src/theme/message_list_view_theme_test.dart b/packages/stream_chat_flutter/test/src/theme/message_list_view_theme_test.dart index ebcc8fbccb..b1e8f4842b 100644 --- a/packages/stream_chat_flutter/test/src/theme/message_list_view_theme_test.dart +++ b/packages/stream_chat_flutter/test/src/theme/message_list_view_theme_test.dart @@ -9,26 +9,22 @@ class MockStreamChatClient extends Mock implements StreamChatClient {} void main() { test('MessageListViewThemeData copyWith, ==, hashCode basics', () { - expect(const StreamMessageListViewThemeData(), - const StreamMessageListViewThemeData().copyWith()); - expect(const StreamMessageListViewThemeData().hashCode, - const StreamMessageListViewThemeData().copyWith().hashCode); + expect(const StreamMessageListViewThemeData(), const StreamMessageListViewThemeData().copyWith()); + expect(const StreamMessageListViewThemeData().hashCode, const StreamMessageListViewThemeData().copyWith().hashCode); }); - test( - '''Light MessageListViewThemeData lerps completely to dark MessageListViewThemeData''', - () { + test('''Light MessageListViewThemeData lerps completely to dark MessageListViewThemeData''', () { expect( - const StreamMessageListViewThemeData().lerp( - _messageListViewThemeDataControl, - _messageListViewThemeDataControlDark, - 1), - _messageListViewThemeDataControlDark); + const StreamMessageListViewThemeData().lerp( + _messageListViewThemeDataControl, + _messageListViewThemeDataControlDark, + 1, + ), + _messageListViewThemeDataControlDark, + ); }); - test( - '''Light MessageListViewThemeData lerps halfway to dark MessageListViewThemeData''', - () { + test('''Light MessageListViewThemeData lerps halfway to dark MessageListViewThemeData''', () { expect( const StreamMessageListViewThemeData().lerp( _messageListViewThemeDataControl, @@ -42,27 +38,25 @@ void main() { ); }); - test( - '''Dark MessageListViewThemeData lerps completely to light MessageListViewThemeData''', - () { + test('''Dark MessageListViewThemeData lerps completely to light MessageListViewThemeData''', () { expect( - const StreamMessageListViewThemeData().lerp( - _messageListViewThemeDataControlDark, - _messageListViewThemeDataControl, - 1), - _messageListViewThemeDataControl); + const StreamMessageListViewThemeData().lerp( + _messageListViewThemeDataControlDark, + _messageListViewThemeDataControl, + 1, + ), + _messageListViewThemeDataControl, + ); }); test('Merging dark and light themes results in a dark theme', () { expect( - _messageListViewThemeDataControl - .merge(_messageListViewThemeDataControlDark), - _messageListViewThemeDataControlDark); + _messageListViewThemeDataControl.merge(_messageListViewThemeDataControlDark), + _messageListViewThemeDataControlDark, + ); }); - testWidgets( - 'Passing no MessageListViewThemeData returns default light theme values', - (WidgetTester tester) async { + testWidgets('Passing no MessageListViewThemeData returns default light theme values', (WidgetTester tester) async { late BuildContext _context; await tester.pumpWidget( MaterialApp( @@ -80,13 +74,10 @@ void main() { ); final messageListViewTheme = StreamMessageListViewTheme.of(_context); - expect(messageListViewTheme.backgroundColor, - _messageListViewThemeDataControl.backgroundColor); + expect(messageListViewTheme.backgroundColor, _messageListViewThemeDataControl.backgroundColor); }); - testWidgets( - 'Passing no MessageListViewThemeData returns default dark theme values', - (WidgetTester tester) async { + testWidgets('Passing no MessageListViewThemeData returns default dark theme values', (WidgetTester tester) async { late BuildContext _context; await tester.pumpWidget( MaterialApp( @@ -105,20 +96,18 @@ void main() { ); final messageListViewTheme = StreamMessageListViewTheme.of(_context); - expect(messageListViewTheme.backgroundColor, - _messageListViewThemeDataControlDark.backgroundColor); + expect(messageListViewTheme.backgroundColor, _messageListViewThemeDataControlDark.backgroundColor); }); - testWidgets( - 'Pass backgroundImage to MessageListViewThemeData return backgroundImage', - (WidgetTester tester) async { + testWidgets('Pass backgroundImage to MessageListViewThemeData return backgroundImage', (WidgetTester tester) async { late BuildContext _context; await tester.pumpWidget( MaterialApp( builder: (context, child) => StreamChat( client: MockStreamChatClient(), - streamChatThemeData: StreamChatThemeData.light() - .copyWith(messageListViewTheme: _messageListViewThemeDataImage), + streamChatThemeData: StreamChatThemeData.light().copyWith( + messageListViewTheme: _messageListViewThemeDataImage, + ), child: child, ), home: Builder( @@ -136,13 +125,12 @@ void main() { ); final messageListViewTheme = StreamMessageListViewTheme.of(_context); - expect(messageListViewTheme.backgroundImage, - _messageListViewThemeDataImage.backgroundImage); + expect(messageListViewTheme.backgroundImage, _messageListViewThemeDataImage.backgroundImage); }); } final _messageListViewThemeDataControl = StreamMessageListViewThemeData( - backgroundColor: StreamColorTheme.light().barsBg, + backgroundColor: const StreamColorTheme.light().appBg, ); const _messageListViewThemeDataControlHalfLerp = StreamMessageListViewThemeData( @@ -150,7 +138,7 @@ const _messageListViewThemeDataControlHalfLerp = StreamMessageListViewThemeData( ); final _messageListViewThemeDataControlDark = StreamMessageListViewThemeData( - backgroundColor: StreamColorTheme.dark().barsBg, + backgroundColor: const StreamColorTheme.dark().appBg, ); const _messageListViewThemeDataImage = StreamMessageListViewThemeData( diff --git a/packages/stream_chat_flutter/test/src/theme/message_theme_test.dart b/packages/stream_chat_flutter/test/src/theme/message_theme_test.dart deleted file mode 100644 index b60cdc259b..0000000000 --- a/packages/stream_chat_flutter/test/src/theme/message_theme_test.dart +++ /dev/null @@ -1,97 +0,0 @@ -import 'package:flutter/material.dart' hide TextTheme; -import 'package:flutter_test/flutter_test.dart'; -import 'package:stream_chat_flutter/stream_chat_flutter.dart'; - -String _dummyFormatter(BuildContext context, DateTime date) => 'formatted'; - -void main() { - test('MessageThemeData copyWith, ==, hashCode basics', () { - expect(const StreamMessageThemeData(), - const StreamMessageThemeData().copyWith()); - expect(const StreamMessageThemeData().hashCode, - const StreamMessageThemeData().copyWith().hashCode); - }); - - group('MessageThemeData lerps', () { - test('''Light MessageThemeData lerps completely to dark MessageThemeData''', - () { - expect( - const StreamMessageThemeData() - .lerp(_messageThemeControl, _messageThemeControlDark, 1), - _messageThemeControlDark); - }); - - test('''Dark MessageThemeData lerps completely to light MessageThemeData''', - () { - expect( - const StreamMessageThemeData() - .lerp(_messageThemeControlDark, _messageThemeControl, 1), - _messageThemeControl); - }); - }); - - test('Merging dark and light themes results in a dark theme', () { - expect(_messageThemeControl.merge(_messageThemeControlDark), - _messageThemeControlDark); - }); -} - -final _messageThemeControl = StreamMessageThemeData( - messageAuthorStyle: StreamTextTheme.light().footnote.copyWith( - color: StreamColorTheme.light().textLowEmphasis, - ), - messageTextStyle: StreamTextTheme.light().body, - createdAtStyle: StreamTextTheme.light().footnote.copyWith( - color: StreamColorTheme.light().textLowEmphasis, - ), - createdAtFormatter: _dummyFormatter, - repliesStyle: StreamTextTheme.light().footnoteBold.copyWith( - color: StreamColorTheme.light().accentPrimary, - ), - messageBackgroundColor: StreamColorTheme.light().disabled, - reactionsBackgroundColor: StreamColorTheme.light().barsBg, - reactionsBorderColor: StreamColorTheme.light().borders, - reactionsMaskColor: StreamColorTheme.light().appBg, - messageBorderColor: StreamColorTheme.light().disabled, - avatarTheme: StreamAvatarThemeData( - borderRadius: BorderRadius.circular(20), - constraints: const BoxConstraints.tightFor( - height: 32, - width: 32, - ), - ), - messageLinksStyle: TextStyle( - color: StreamColorTheme.light().accentPrimary, - ), - urlAttachmentBackgroundColor: StreamColorTheme.light().linkBg, -); - -final _messageThemeControlDark = StreamMessageThemeData( - messageAuthorStyle: StreamTextTheme.dark().footnote.copyWith( - color: StreamColorTheme.dark().textLowEmphasis, - ), - messageTextStyle: StreamTextTheme.dark().body, - createdAtStyle: StreamTextTheme.dark().footnote.copyWith( - color: StreamColorTheme.dark().textLowEmphasis, - ), - createdAtFormatter: _dummyFormatter, - repliesStyle: StreamTextTheme.dark().footnoteBold.copyWith( - color: StreamColorTheme.dark().accentPrimary, - ), - messageBackgroundColor: StreamColorTheme.dark().disabled, - reactionsBackgroundColor: StreamColorTheme.dark().barsBg, - reactionsBorderColor: StreamColorTheme.dark().borders, - reactionsMaskColor: StreamColorTheme.dark().appBg, - messageBorderColor: StreamColorTheme.dark().disabled, - avatarTheme: StreamAvatarThemeData( - borderRadius: BorderRadius.circular(20), - constraints: const BoxConstraints.tightFor( - height: 32, - width: 32, - ), - ), - messageLinksStyle: TextStyle( - color: StreamColorTheme.dark().accentPrimary, - ), - urlAttachmentBackgroundColor: StreamColorTheme.dark().linkBg, -); diff --git a/packages/stream_chat_flutter/test/src/theme/poll_comments_sheet_theme_test.dart b/packages/stream_chat_flutter/test/src/theme/poll_comments_sheet_theme_test.dart new file mode 100644 index 0000000000..f6bba57a28 --- /dev/null +++ b/packages/stream_chat_flutter/test/src/theme/poll_comments_sheet_theme_test.dart @@ -0,0 +1,153 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:stream_chat_flutter/stream_chat_flutter.dart'; + +void main() { + group('StreamPollCommentsSheetTheme', () { + testWidgets('merges local theme with ancestor global theme', (tester) async { + const globalBg = Color(0xFF111111); + const localItemSpacing = 42.0; + const localCommentCardColor = Color(0xFF222222); + + late BuildContext capturedContext; + + await tester.pumpWidget( + MaterialApp( + home: StreamChatTheme( + data: StreamChatThemeData.light().copyWith( + pollCommentsSheetTheme: const StreamPollCommentsSheetThemeData( + backgroundColor: globalBg, + ), + ), + child: Builder( + builder: (context) { + return StreamPollCommentsSheetTheme( + data: const StreamPollCommentsSheetThemeData( + itemSpacing: localItemSpacing, + commentStyle: StreamPollOptionVotesStyle( + cardStyle: StreamPollCardStyle( + backgroundColor: localCommentCardColor, + ), + ), + ), + child: Builder( + builder: (context) { + capturedContext = context; + return const SizedBox(); + }, + ), + ); + }, + ), + ), + ), + ); + + final theme = StreamPollCommentsSheetTheme.of(capturedContext); + // Inherited from the global theme. + expect(theme.backgroundColor, globalBg); + // Overridden locally. + expect(theme.itemSpacing, localItemSpacing); + expect(theme.commentStyle?.cardStyle?.backgroundColor, localCommentCardColor); + }); + }); + + group('StreamPollCommentsSheetThemeData', () { + const full = StreamPollCommentsSheetThemeData( + backgroundColor: Colors.red, + contentPadding: EdgeInsets.all(8), + itemSpacing: 16, + commentStyle: StreamPollOptionVotesStyle( + cardStyle: StreamPollCardStyle( + backgroundColor: Colors.white, + padding: EdgeInsets.all(4), + ), + footerDividerColor: Colors.pink, + footerButtonStyle: StreamButtonThemeStyle( + backgroundColor: WidgetStatePropertyAll(Colors.black), + ), + ), + ); + + test('equality + hashCode', () { + const identical = StreamPollCommentsSheetThemeData( + backgroundColor: Colors.red, + contentPadding: EdgeInsets.all(8), + itemSpacing: 16, + commentStyle: StreamPollOptionVotesStyle( + cardStyle: StreamPollCardStyle( + backgroundColor: Colors.white, + padding: EdgeInsets.all(4), + ), + footerDividerColor: Colors.pink, + footerButtonStyle: StreamButtonThemeStyle( + backgroundColor: WidgetStatePropertyAll(Colors.black), + ), + ), + ); + + const different = StreamPollCommentsSheetThemeData( + backgroundColor: Colors.blue, + ); + + expect(full, identical); + expect(full, isNot(different)); + expect(full.hashCode, identical.hashCode); + }); + + test('copyWith overrides only specified fields', () { + final copied = full.copyWith( + backgroundColor: Colors.blue, + itemSpacing: 99, + ); + + expect(copied.backgroundColor, Colors.blue); + expect(copied.itemSpacing, 99); + // Untouched fields remain. + expect(copied.contentPadding, full.contentPadding); + expect(copied.commentStyle, full.commentStyle); + }); + + test('merge prefers non-null fields from other', () { + const other = StreamPollCommentsSheetThemeData( + backgroundColor: Colors.green, + itemSpacing: 99, + ); + + final merged = full.merge(other); + + // Fields present on `other` win. + expect(merged.backgroundColor, Colors.green); + expect(merged.itemSpacing, 99); + // Fields null on `other` preserve `full`. + expect(merged.contentPadding, full.contentPadding); + expect(merged.commentStyle, full.commentStyle); + + // Merging with null returns `this`. + expect(full.merge(null), full); + }); + + test('lerp at boundaries returns endpoints; at 0.5 interpolates', () { + const a = StreamPollCommentsSheetThemeData( + backgroundColor: Colors.black, + itemSpacing: 0, + ); + const b = StreamPollCommentsSheetThemeData( + backgroundColor: Colors.white, + itemSpacing: 10, + ); + + final at0 = StreamPollCommentsSheetThemeData.lerp(a, b, 0)!; + expect(at0.backgroundColor, a.backgroundColor); + expect(at0.itemSpacing, a.itemSpacing); + + final at1 = StreamPollCommentsSheetThemeData.lerp(a, b, 1)!; + expect(at1.backgroundColor, b.backgroundColor); + expect(at1.itemSpacing, b.itemSpacing); + + final mid = StreamPollCommentsSheetThemeData.lerp(a, b, 0.5)!; + expect(mid.backgroundColor, Color.lerp(Colors.black, Colors.white, 0.5)); + expect(mid.itemSpacing, 5); + }); + }); +} diff --git a/packages/stream_chat_flutter/test/src/theme/poll_option_votes_style_test.dart b/packages/stream_chat_flutter/test/src/theme/poll_option_votes_style_test.dart new file mode 100644 index 0000000000..f519ccf370 --- /dev/null +++ b/packages/stream_chat_flutter/test/src/theme/poll_option_votes_style_test.dart @@ -0,0 +1,90 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:stream_chat_flutter/stream_chat_flutter.dart'; + +void main() { + group('StreamPollOptionVotesStyle', () { + const full = StreamPollOptionVotesStyle( + cardStyle: StreamPollCardStyle( + backgroundColor: Colors.grey, + padding: EdgeInsets.all(6), + ), + numberTextStyle: TextStyle(fontSize: 10), + textStyle: TextStyle(fontSize: 14), + voteCountTextStyle: TextStyle(fontSize: 11), + winnerIconColor: Colors.amber, + winnerIconSize: 24, + footerDividerColor: Colors.pink, + footerButtonStyle: StreamButtonThemeStyle( + backgroundColor: WidgetStatePropertyAll(Colors.black), + ), + ); + + test('equality + hashCode', () { + const identical = StreamPollOptionVotesStyle( + cardStyle: StreamPollCardStyle( + backgroundColor: Colors.grey, + padding: EdgeInsets.all(6), + ), + numberTextStyle: TextStyle(fontSize: 10), + textStyle: TextStyle(fontSize: 14), + voteCountTextStyle: TextStyle(fontSize: 11), + winnerIconColor: Colors.amber, + winnerIconSize: 24, + footerDividerColor: Colors.pink, + footerButtonStyle: StreamButtonThemeStyle( + backgroundColor: WidgetStatePropertyAll(Colors.black), + ), + ); + const different = StreamPollOptionVotesStyle(winnerIconSize: 99); + + expect(full, identical); + expect(full, isNot(different)); + expect(full.hashCode, identical.hashCode); + }); + + test('copyWith overrides only specified fields', () { + final copied = full.copyWith(winnerIconSize: 99); + expect(copied.winnerIconSize, 99); + expect(copied.cardStyle, full.cardStyle); + expect(copied.numberTextStyle, full.numberTextStyle); + expect(copied.textStyle, full.textStyle); + expect(copied.voteCountTextStyle, full.voteCountTextStyle); + expect(copied.winnerIconColor, full.winnerIconColor); + expect(copied.footerDividerColor, full.footerDividerColor); + expect(copied.footerButtonStyle, full.footerButtonStyle); + }); + + test('merge prefers non-null fields from other', () { + const other = StreamPollOptionVotesStyle( + winnerIconSize: 99, + winnerIconColor: Colors.red, + footerDividerColor: Colors.green, + ); + final merged = full.merge(other); + + expect(merged.winnerIconSize, 99); + expect(merged.winnerIconColor, Colors.red); + expect(merged.footerDividerColor, Colors.green); + expect(merged.cardStyle, full.cardStyle); + expect(merged.numberTextStyle, full.numberTextStyle); + expect(merged.textStyle, full.textStyle); + + expect(full.merge(null), full); + }); + + test('lerp interpolates common fields', () { + const a = StreamPollOptionVotesStyle(winnerIconSize: 10); + const b = StreamPollOptionVotesStyle(winnerIconSize: 20); + + final at0 = StreamPollOptionVotesStyle.lerp(a, b, 0)!; + expect(at0.winnerIconSize, a.winnerIconSize); + + final at1 = StreamPollOptionVotesStyle.lerp(a, b, 1)!; + expect(at1.winnerIconSize, b.winnerIconSize); + + final mid = StreamPollOptionVotesStyle.lerp(a, b, 0.5)!; + expect(mid.winnerIconSize, 15); + }); + }); +} diff --git a/packages/stream_chat_flutter/test/src/theme/poll_options_sheet_theme_test.dart b/packages/stream_chat_flutter/test/src/theme/poll_options_sheet_theme_test.dart new file mode 100644 index 0000000000..41100d8eac --- /dev/null +++ b/packages/stream_chat_flutter/test/src/theme/poll_options_sheet_theme_test.dart @@ -0,0 +1,287 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:stream_chat_flutter/stream_chat_flutter.dart'; + +void main() { + group('StreamPollOptionsSheetTheme', () { + testWidgets('merges local theme with ancestor global theme', (tester) async { + const globalBg = Color(0xFF111111); + const localSectionSpacing = 42.0; + const localOptionStyleTextStyle = TextStyle(fontSize: 19); + + late BuildContext capturedContext; + + await tester.pumpWidget( + MaterialApp( + home: StreamChatTheme( + data: StreamChatThemeData.light().copyWith( + pollOptionsSheetTheme: const StreamPollOptionsSheetThemeData( + backgroundColor: globalBg, + ), + ), + child: Builder( + builder: (context) { + return StreamPollOptionsSheetTheme( + data: const StreamPollOptionsSheetThemeData( + sectionSpacing: localSectionSpacing, + optionStyle: StreamPollOptionStyle( + textStyle: localOptionStyleTextStyle, + ), + ), + child: Builder( + builder: (context) { + capturedContext = context; + return const SizedBox(); + }, + ), + ); + }, + ), + ), + ), + ); + + final theme = StreamPollOptionsSheetTheme.of(capturedContext); + // Inherited from the global theme. + expect(theme.backgroundColor, globalBg); + // Overridden locally. + expect(theme.sectionSpacing, localSectionSpacing); + expect(theme.optionStyle?.textStyle, localOptionStyleTextStyle); + }); + }); + + group('StreamPollOptionsSheetThemeData', () { + const full = StreamPollOptionsSheetThemeData( + backgroundColor: Colors.red, + contentPadding: EdgeInsets.all(8), + sectionSpacing: 16, + questionStyle: StreamPollQuestionStyle( + cardStyle: StreamPollCardStyle( + backgroundColor: Colors.white, + padding: EdgeInsets.all(4), + ), + headerTextStyle: TextStyle(fontSize: 12), + textStyle: TextStyle(fontSize: 18), + ), + optionsCardStyle: StreamPollCardStyle( + backgroundColor: Colors.grey, + padding: EdgeInsets.all(6), + ), + optionsItemSpacing: 4, + optionStyle: StreamPollOptionStyle( + textStyle: TextStyle(fontSize: 14), + ), + ); + + test('equality + hashCode', () { + const identical = StreamPollOptionsSheetThemeData( + backgroundColor: Colors.red, + contentPadding: EdgeInsets.all(8), + sectionSpacing: 16, + questionStyle: StreamPollQuestionStyle( + cardStyle: StreamPollCardStyle( + backgroundColor: Colors.white, + padding: EdgeInsets.all(4), + ), + headerTextStyle: TextStyle(fontSize: 12), + textStyle: TextStyle(fontSize: 18), + ), + optionsCardStyle: StreamPollCardStyle( + backgroundColor: Colors.grey, + padding: EdgeInsets.all(6), + ), + optionsItemSpacing: 4, + optionStyle: StreamPollOptionStyle( + textStyle: TextStyle(fontSize: 14), + ), + ); + + const different = StreamPollOptionsSheetThemeData( + backgroundColor: Colors.blue, // changed + ); + + expect(full, identical); + expect(full, isNot(different)); + expect(full.hashCode, identical.hashCode); + }); + + test('copyWith overrides only specified fields', () { + final copied = full.copyWith( + backgroundColor: Colors.blue, + sectionSpacing: 99, + ); + + expect(copied.backgroundColor, Colors.blue); + expect(copied.sectionSpacing, 99); + // Untouched fields remain. + expect(copied.contentPadding, full.contentPadding); + expect(copied.questionStyle, full.questionStyle); + expect(copied.optionsCardStyle, full.optionsCardStyle); + expect(copied.optionsItemSpacing, full.optionsItemSpacing); + expect(copied.optionStyle, full.optionStyle); + }); + + test('merge prefers non-null fields from other', () { + const other = StreamPollOptionsSheetThemeData( + backgroundColor: Colors.green, + optionsItemSpacing: 99, + ); + + final merged = full.merge(other); + + // Fields present on `other` win. + expect(merged.backgroundColor, Colors.green); + expect(merged.optionsItemSpacing, 99); + // Fields null on `other` preserve `full`. + expect(merged.sectionSpacing, full.sectionSpacing); + expect(merged.questionStyle, full.questionStyle); + expect(merged.optionStyle, full.optionStyle); + + // Merging with null returns `this`. + expect(full.merge(null), full); + }); + + test('lerp at boundaries returns endpoints; at 0.5 interpolates', () { + const a = StreamPollOptionsSheetThemeData( + backgroundColor: Colors.black, + sectionSpacing: 0, + ); + const b = StreamPollOptionsSheetThemeData( + backgroundColor: Colors.white, + sectionSpacing: 10, + ); + + final at0 = StreamPollOptionsSheetThemeData.lerp(a, b, 0)!; + expect(at0.backgroundColor, a.backgroundColor); + expect(at0.sectionSpacing, a.sectionSpacing); + + final at1 = StreamPollOptionsSheetThemeData.lerp(a, b, 1)!; + expect(at1.backgroundColor, b.backgroundColor); + expect(at1.sectionSpacing, b.sectionSpacing); + + final mid = StreamPollOptionsSheetThemeData.lerp(a, b, 0.5)!; + expect(mid.backgroundColor, Color.lerp(Colors.black, Colors.white, 0.5)); + expect(mid.sectionSpacing, 5); + }); + }); + + group('StreamPollQuestionStyle', () { + const full = StreamPollQuestionStyle( + cardStyle: StreamPollCardStyle( + backgroundColor: Colors.white, + padding: EdgeInsets.all(4), + ), + headerTextStyle: TextStyle(fontSize: 12), + textStyle: TextStyle(fontSize: 18), + ); + + test('equality + hashCode', () { + const identical = StreamPollQuestionStyle( + cardStyle: StreamPollCardStyle( + backgroundColor: Colors.white, + padding: EdgeInsets.all(4), + ), + headerTextStyle: TextStyle(fontSize: 12), + textStyle: TextStyle(fontSize: 18), + ); + const different = StreamPollQuestionStyle( + headerTextStyle: TextStyle(fontSize: 99), + ); + + expect(full, identical); + expect(full, isNot(different)); + expect(full.hashCode, identical.hashCode); + }); + + test('copyWith overrides only specified fields', () { + final copied = full.copyWith( + headerTextStyle: const TextStyle(fontSize: 30), + ); + expect(copied.headerTextStyle, const TextStyle(fontSize: 30)); + expect(copied.cardStyle, full.cardStyle); + expect(copied.textStyle, full.textStyle); + }); + + test('merge prefers non-null fields from other', () { + const other = StreamPollQuestionStyle( + textStyle: TextStyle(fontSize: 30), + ); + final merged = full.merge(other); + + expect(merged.textStyle, const TextStyle(fontSize: 18).merge(const TextStyle(fontSize: 30))); + expect(merged.cardStyle, full.cardStyle); + expect(merged.headerTextStyle, full.headerTextStyle); + + expect(full.merge(null), full); + }); + + test('lerp interpolates common fields', () { + const a = StreamPollQuestionStyle( + headerTextStyle: TextStyle(fontSize: 10), + ); + const b = StreamPollQuestionStyle( + headerTextStyle: TextStyle(fontSize: 20), + ); + + final at0 = StreamPollQuestionStyle.lerp(a, b, 0)!; + expect(at0.headerTextStyle, a.headerTextStyle); + + final at1 = StreamPollQuestionStyle.lerp(a, b, 1)!; + expect(at1.headerTextStyle, b.headerTextStyle); + + final mid = StreamPollQuestionStyle.lerp(a, b, 0.5)!; + expect(mid.headerTextStyle?.fontSize, 15); + }); + }); + + group('StreamPollCardStyle', () { + const full = StreamPollCardStyle( + backgroundColor: Colors.red, + borderRadius: BorderRadius.all(Radius.circular(12)), + padding: EdgeInsets.all(8), + ); + + test('equality', () { + const identical = StreamPollCardStyle( + backgroundColor: Colors.red, + borderRadius: BorderRadius.all(Radius.circular(12)), + padding: EdgeInsets.all(8), + ); + const different = StreamPollCardStyle(backgroundColor: Colors.blue); + + expect(full, identical); + expect(full, isNot(different)); + }); + + test('copyWith overrides only specified fields', () { + final copied = full.copyWith(backgroundColor: Colors.blue); + expect(copied.backgroundColor, Colors.blue); + expect(copied.borderRadius, full.borderRadius); + expect(copied.padding, full.padding); + }); + + test('merge prefers non-null fields from other', () { + const other = StreamPollCardStyle(backgroundColor: Colors.green); + final merged = full.merge(other); + expect(merged.backgroundColor, Colors.green); + expect(merged.borderRadius, full.borderRadius); + expect(merged.padding, full.padding); + + expect(full.merge(null), full); + }); + + test('lerp interpolates common fields', () { + const a = StreamPollCardStyle(backgroundColor: Colors.black); + const b = StreamPollCardStyle(backgroundColor: Colors.white); + + final at0 = StreamPollCardStyle.lerp(a, b, 0)!; + expect(at0.backgroundColor, a.backgroundColor); + + final at1 = StreamPollCardStyle.lerp(a, b, 1)!; + expect(at1.backgroundColor, b.backgroundColor); + + final mid = StreamPollCardStyle.lerp(a, b, 0.5)!; + expect(mid.backgroundColor, Color.lerp(Colors.black, Colors.white, 0.5)); + }); + }); +} diff --git a/packages/stream_chat_flutter/test/src/theme/poll_results_sheet_theme_test.dart b/packages/stream_chat_flutter/test/src/theme/poll_results_sheet_theme_test.dart new file mode 100644 index 0000000000..b40d43664c --- /dev/null +++ b/packages/stream_chat_flutter/test/src/theme/poll_results_sheet_theme_test.dart @@ -0,0 +1,182 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:stream_chat_flutter/stream_chat_flutter.dart'; + +void main() { + group('StreamPollResultsSheetTheme', () { + testWidgets('merges local theme with ancestor global theme', (tester) async { + const globalBg = Color(0xFF111111); + const globalTotalVoteCountTextStyle = TextStyle(fontSize: 17); + const localSectionSpacing = 42.0; + const localOptionTextStyle = TextStyle(fontSize: 19); + + late BuildContext capturedContext; + + await tester.pumpWidget( + MaterialApp( + home: StreamChatTheme( + data: StreamChatThemeData.light().copyWith( + pollResultsSheetTheme: const StreamPollResultsSheetThemeData( + backgroundColor: globalBg, + totalVoteCountTextStyle: globalTotalVoteCountTextStyle, + ), + ), + child: Builder( + builder: (context) { + return StreamPollResultsSheetTheme( + data: const StreamPollResultsSheetThemeData( + sectionSpacing: localSectionSpacing, + optionStyle: StreamPollOptionVotesStyle( + textStyle: localOptionTextStyle, + ), + ), + child: Builder( + builder: (context) { + capturedContext = context; + return const SizedBox(); + }, + ), + ); + }, + ), + ), + ), + ); + + final theme = StreamPollResultsSheetTheme.of(capturedContext); + // Inherited from the global theme. + expect(theme.backgroundColor, globalBg); + expect(theme.totalVoteCountTextStyle, globalTotalVoteCountTextStyle); + // Overridden locally. + expect(theme.sectionSpacing, localSectionSpacing); + expect(theme.optionStyle?.textStyle, localOptionTextStyle); + }); + }); + + group('StreamPollResultsSheetThemeData', () { + const full = StreamPollResultsSheetThemeData( + backgroundColor: Colors.red, + contentPadding: EdgeInsets.all(8), + sectionSpacing: 16, + questionStyle: StreamPollQuestionStyle( + cardStyle: StreamPollCardStyle( + backgroundColor: Colors.white, + padding: EdgeInsets.all(4), + ), + headerTextStyle: TextStyle(fontSize: 12), + textStyle: TextStyle(fontSize: 18), + ), + optionsItemSpacing: 6, + optionStyle: StreamPollOptionVotesStyle( + cardStyle: StreamPollCardStyle( + backgroundColor: Colors.grey, + padding: EdgeInsets.all(6), + ), + numberTextStyle: TextStyle(fontSize: 10), + textStyle: TextStyle(fontSize: 14), + voteCountTextStyle: TextStyle(fontSize: 11), + winnerIconColor: Colors.amber, + winnerIconSize: 24, + ), + totalVoteCountTextStyle: TextStyle(fontSize: 13), + ); + + test('equality + hashCode', () { + const identical = StreamPollResultsSheetThemeData( + backgroundColor: Colors.red, + contentPadding: EdgeInsets.all(8), + sectionSpacing: 16, + questionStyle: StreamPollQuestionStyle( + cardStyle: StreamPollCardStyle( + backgroundColor: Colors.white, + padding: EdgeInsets.all(4), + ), + headerTextStyle: TextStyle(fontSize: 12), + textStyle: TextStyle(fontSize: 18), + ), + optionsItemSpacing: 6, + optionStyle: StreamPollOptionVotesStyle( + cardStyle: StreamPollCardStyle( + backgroundColor: Colors.grey, + padding: EdgeInsets.all(6), + ), + numberTextStyle: TextStyle(fontSize: 10), + textStyle: TextStyle(fontSize: 14), + voteCountTextStyle: TextStyle(fontSize: 11), + winnerIconColor: Colors.amber, + winnerIconSize: 24, + ), + totalVoteCountTextStyle: TextStyle(fontSize: 13), + ); + + const different = StreamPollResultsSheetThemeData( + backgroundColor: Colors.blue, // changed + ); + + expect(full, identical); + expect(full, isNot(different)); + expect(full.hashCode, identical.hashCode); + }); + + test('copyWith overrides only specified fields', () { + final copied = full.copyWith( + backgroundColor: Colors.blue, + sectionSpacing: 99, + ); + + expect(copied.backgroundColor, Colors.blue); + expect(copied.sectionSpacing, 99); + // Untouched fields remain. + expect(copied.contentPadding, full.contentPadding); + expect(copied.questionStyle, full.questionStyle); + expect(copied.optionsItemSpacing, full.optionsItemSpacing); + expect(copied.optionStyle, full.optionStyle); + expect(copied.totalVoteCountTextStyle, full.totalVoteCountTextStyle); + }); + + test('merge prefers non-null fields from other', () { + const other = StreamPollResultsSheetThemeData( + backgroundColor: Colors.green, + optionsItemSpacing: 99, + totalVoteCountTextStyle: TextStyle(fontSize: 99), + ); + + final merged = full.merge(other); + + // Fields present on `other` win. + expect(merged.backgroundColor, Colors.green); + expect(merged.optionsItemSpacing, 99); + expect(merged.totalVoteCountTextStyle, const TextStyle(fontSize: 99)); + // Fields null on `other` preserve `full`. + expect(merged.sectionSpacing, full.sectionSpacing); + expect(merged.questionStyle, full.questionStyle); + expect(merged.optionStyle, full.optionStyle); + + // Merging with null returns `this`. + expect(full.merge(null), full); + }); + + test('lerp at boundaries returns endpoints; at 0.5 interpolates', () { + const a = StreamPollResultsSheetThemeData( + backgroundColor: Colors.black, + sectionSpacing: 0, + ); + const b = StreamPollResultsSheetThemeData( + backgroundColor: Colors.white, + sectionSpacing: 10, + ); + + final at0 = StreamPollResultsSheetThemeData.lerp(a, b, 0)!; + expect(at0.backgroundColor, a.backgroundColor); + expect(at0.sectionSpacing, a.sectionSpacing); + + final at1 = StreamPollResultsSheetThemeData.lerp(a, b, 1)!; + expect(at1.backgroundColor, b.backgroundColor); + expect(at1.sectionSpacing, b.sectionSpacing); + + final mid = StreamPollResultsSheetThemeData.lerp(a, b, 0.5)!; + expect(mid.backgroundColor, Color.lerp(Colors.black, Colors.white, 0.5)); + expect(mid.sectionSpacing, 5); + }); + }); +} diff --git a/packages/stream_chat_flutter/test/src/theme/thread_list_tile_theme_test.dart b/packages/stream_chat_flutter/test/src/theme/thread_list_tile_theme_test.dart index dd857151f6..d4c38dece2 100644 --- a/packages/stream_chat_flutter/test/src/theme/thread_list_tile_theme_test.dart +++ b/packages/stream_chat_flutter/test/src/theme/thread_list_tile_theme_test.dart @@ -5,8 +5,7 @@ import 'package:stream_chat_flutter/stream_chat_flutter.dart'; String _dummyFormatter(BuildContext context, DateTime date) => 'formatted'; void main() { - testWidgets('StreamThreadListTileTheme merges with ancestor theme', - (tester) async { + testWidgets('StreamThreadListTileTheme merges with ancestor theme', (tester) async { const backgroundColor = Colors.blue; const childBackgroundColor = Colors.red; @@ -116,12 +115,9 @@ void main() { expect(copied.padding, newPadding); // Unchanged properties should remain the same expect(copied.threadChannelNameStyle, original.threadChannelNameStyle); - expect( - copied.threadReplyToMessageStyle, original.threadReplyToMessageStyle); - expect(copied.threadLatestReplyTimestampStyle, - original.threadLatestReplyTimestampStyle); - expect(copied.threadLatestReplyTimestampFormatter, - original.threadLatestReplyTimestampFormatter); + expect(copied.threadReplyToMessageStyle, original.threadReplyToMessageStyle); + expect(copied.threadLatestReplyTimestampStyle, original.threadLatestReplyTimestampStyle); + expect(copied.threadLatestReplyTimestampFormatter, original.threadLatestReplyTimestampFormatter); }); test('StreamThreadListTileThemeData merge', () { @@ -147,12 +143,9 @@ void main() { expect(merged.padding, other.padding); // Null properties in 'other' should not override 'original' expect(merged.threadChannelNameStyle, original.threadChannelNameStyle); - expect( - merged.threadReplyToMessageStyle, original.threadReplyToMessageStyle); - expect(merged.threadLatestReplyTimestampStyle, - original.threadLatestReplyTimestampStyle); - expect(merged.threadLatestReplyTimestampFormatter, - original.threadLatestReplyTimestampFormatter); + expect(merged.threadReplyToMessageStyle, original.threadReplyToMessageStyle); + expect(merged.threadLatestReplyTimestampStyle, original.threadLatestReplyTimestampStyle); + expect(merged.threadLatestReplyTimestampFormatter, original.threadLatestReplyTimestampFormatter); // Merging with null should return original final mergedWithNull = original.merge(null); @@ -176,29 +169,26 @@ void main() { final lerpedAt0 = data1.lerp(data1, data2, 0); expect(lerpedAt0.backgroundColor, data1.backgroundColor); expect(lerpedAt0.padding, data1.padding); - expect(lerpedAt0.threadLatestReplyTimestampFormatter, - data1.threadLatestReplyTimestampFormatter); + expect(lerpedAt0.threadLatestReplyTimestampFormatter, data1.threadLatestReplyTimestampFormatter); // t = 1 should return data2 final lerpedAt1 = data1.lerp(data1, data2, 1); expect(lerpedAt1.backgroundColor, data2.backgroundColor); expect(lerpedAt1.padding, data2.padding); - expect(lerpedAt1.threadLatestReplyTimestampFormatter, - data2.threadLatestReplyTimestampFormatter); + expect(lerpedAt1.threadLatestReplyTimestampFormatter, data2.threadLatestReplyTimestampFormatter); // t = 0.5 should return something in between final lerpedAt05 = data1.lerp(data1, data2, 0.5); - expect(lerpedAt05.backgroundColor, - Color.lerp(Colors.black, Colors.white, 0.5)); + expect(lerpedAt05.backgroundColor, Color.lerp(Colors.black, Colors.white, 0.5)); expect( - lerpedAt05.padding, - EdgeInsetsGeometry.lerp( - const EdgeInsets.all(8), - const EdgeInsets.all(16), - 0.5, - )); + lerpedAt05.padding, + EdgeInsetsGeometry.lerp( + const EdgeInsets.all(8), + const EdgeInsets.all(16), + 0.5, + ), + ); // For t < 0.5, should use data1's formatter - expect(lerpedAt05.threadLatestReplyTimestampFormatter, - data1.threadLatestReplyTimestampFormatter); + expect(lerpedAt05.threadLatestReplyTimestampFormatter, data1.threadLatestReplyTimestampFormatter); }); } diff --git a/packages/stream_chat_flutter/test/src/utils/date_formatter_test.dart b/packages/stream_chat_flutter/test/src/utils/date_formatter_test.dart new file mode 100644 index 0000000000..876ce6620a --- /dev/null +++ b/packages/stream_chat_flutter/test/src/utils/date_formatter_test.dart @@ -0,0 +1,127 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:stream_chat_flutter/stream_chat_flutter.dart'; + +import '../mocks.dart'; + +void main() { + group('formatRecentDateTime', () { + final referenceDate = DateTime(2026, 4, 7, 10, 0); + + testWidgets('formats dates within a minute as Just now', (tester) async { + await tester.pumpWidget( + _wrapWithStreamChat( + Builder( + builder: (context) { + return Text( + formatRecentDateTime( + context, + DateTime(2026, 4, 7, 9, 59, 30), + referenceDate: referenceDate, + ), + ); + }, + ), + ), + ); + + expect(find.text('Just now'), findsOneWidget); + }); + + testWidgets('formats same-day dates as Today at H:mm', (tester) async { + await tester.pumpWidget( + _wrapWithStreamChat( + Builder( + builder: (context) { + return Text( + formatRecentDateTime( + context, + DateTime(2026, 4, 7, 9, 41), + referenceDate: referenceDate, + ), + ); + }, + ), + ), + ); + + expect(find.text('Today at 9:41'), findsOneWidget); + }); + + testWidgets('formats previous-day dates as Yesterday at H:mm', (tester) async { + await tester.pumpWidget( + _wrapWithStreamChat( + Builder( + builder: (context) { + return Text( + formatRecentDateTime( + context, + DateTime(2026, 4, 6, 9, 41), + referenceDate: referenceDate, + ), + ); + }, + ), + ), + ); + + expect(find.text('Yesterday at 9:41'), findsOneWidget); + }); + + testWidgets('formats recent dates within a week as Weekday at H:mm', (tester) async { + await tester.pumpWidget( + _wrapWithStreamChat( + Builder( + builder: (context) { + return Text( + formatRecentDateTime( + context, + DateTime(2026, 4, 4, 9, 41), + referenceDate: referenceDate, + ), + ); + }, + ), + ), + ); + + expect(find.text('Saturday at 9:41'), findsOneWidget); + }); + + testWidgets('formats older dates as MMM do at H:mm', (tester) async { + await tester.pumpWidget( + _wrapWithStreamChat( + Builder( + builder: (context) { + return Text( + formatRecentDateTime( + context, + DateTime(2026, 1, 1, 9, 41), + referenceDate: referenceDate, + ), + ); + }, + ), + ), + ); + + expect(find.text('Jan 1st at 9:41'), findsOneWidget); + }); + }); +} + +Widget _wrapWithStreamChat(Widget child) { + final client = MockClient(); + final clientState = MockClientState(); + + when(() => client.state).thenReturn(clientState); + when(() => clientState.currentUser).thenReturn(OwnUser(id: 'current-user-id', name: 'Current User')); + + return MaterialApp( + home: StreamChat( + client: client, + child: Scaffold(body: child), + ), + ); +} diff --git a/packages/stream_chat_flutter/test/src/utils/extension_test.dart b/packages/stream_chat_flutter/test/src/utils/extension_test.dart index ce99d0786a..9f3b7bdb76 100644 --- a/packages/stream_chat_flutter/test/src/utils/extension_test.dart +++ b/packages/stream_chat_flutter/test/src/utils/extension_test.dart @@ -163,8 +163,8 @@ void main() { final modifiedMessage = message.replaceMentions(); - expect(modifiedMessage.text, contains('[@Alice](user1)')); - expect(modifiedMessage.text, contains('[@Bob](user2)')); + expect(modifiedMessage.text, contains('[@Alice](mention:user1)')); + expect(modifiedMessage.text, contains('[@Bob](mention:user2)')); }); test('replaceMentions without linkify should not add links', () { @@ -190,7 +190,7 @@ void main() { final modifiedMessage = message.replaceMentions(); - expect(modifiedMessage.text, contains('[@Alice](user1)')); + expect(modifiedMessage.text, contains('[@Alice](mention:user1)')); }); test( @@ -222,8 +222,8 @@ void main() { final modifiedMessage = message.replaceMentions(); - expect(modifiedMessage.text, contains('[@Alice](user1)')); - expect(modifiedMessage.text, contains('[@Bob](user2)')); + expect(modifiedMessage.text, contains('[@Alice](mention:user1)')); + expect(modifiedMessage.text, contains('[@Bob](mention:user2)')); }, ); @@ -238,7 +238,7 @@ void main() { final modifiedMessage = message.replaceMentions(); - expect(modifiedMessage.text, equals('Hello, [@Tester (X)](user1)!')); + expect(modifiedMessage.text, equals('Hello, [@Tester (X)](mention:user1)!')); }); test('should handle usernames with square brackets', () { @@ -251,7 +251,7 @@ void main() { final modifiedMessage = message.replaceMentions(); - expect(modifiedMessage.text, equals('Hello, [@User[123]](user1)!')); + expect(modifiedMessage.text, equals('Hello, [@User[123]](mention:user1)!')); }); test('should handle usernames with dots and asterisks', () { @@ -264,7 +264,7 @@ void main() { final modifiedMessage = message.replaceMentions(); - expect(modifiedMessage.text, equals('Hello, [@user.name*](user1)!')); + expect(modifiedMessage.text, equals('Hello, [@user.name*](mention:user1)!')); }); test('should handle usernames with plus and question marks', () { @@ -277,7 +277,7 @@ void main() { final modifiedMessage = message.replaceMentions(); - expect(modifiedMessage.text, equals('Hello, [@test+user?](user1)!')); + expect(modifiedMessage.text, equals('Hello, [@test+user?](mention:user1)!')); }); test('should handle usernames without linkify', () { @@ -305,7 +305,7 @@ void main() { expect( modifiedMessage.text, - equals('Hello, [@Test (X)](user1) and @Test (Y)!'), + equals('Hello, [@Test (X)](mention:user1) and @Test (Y)!'), ); }); @@ -321,12 +321,11 @@ void main() { expect( modifiedMessage.text, - equals('Hello, [@TestUser](user.id+123)!'), + equals('Hello, [@TestUser](mention:user.id+123)!'), ); }); - test('should handle both userId and userName with special characters', - () { + test('should handle both userId and userName with special characters', () { final user = User(id: 'user[123]', name: 'Test (X)'); final message = Message( @@ -338,7 +337,7 @@ void main() { expect( modifiedMessage.text, - equals('Hello, [@Test (X)](user[123]) and [@Test (X)](user[123])!'), + equals('Hello, [@Test (X)](mention:user[123]) and [@Test (X)](mention:user[123])!'), ); }); }); @@ -388,124 +387,4 @@ void main() { expect(modifiedMessage.text, isNot(contains('@Alice'))); }); }); - - group('Message List Extension Tests', () { - group('lastUnreadMessage', () { - test('should return null when list is empty', () { - final messages = []; - final userRead = Read( - lastRead: DateTime.now(), - user: User(id: 'user1'), - ); - expect(messages.lastUnreadMessage(userRead), isNull); - }); - - test('should return null when userRead is null', () { - final messages = [ - Message(id: '1'), - Message(id: '2'), - ]; - expect(messages.lastUnreadMessage(null), isNull); - }); - - test('should return null when all messages are read', () { - final lastRead = DateTime.now(); - final messages = [ - Message( - id: '1', - createdAt: lastRead.subtract(const Duration(seconds: 1))), - Message(id: '2', createdAt: lastRead), - ]; - final userRead = Read( - lastRead: lastRead, - user: User(id: 'user1'), - ); - expect(messages.lastUnreadMessage(userRead), isNull); - }); - - test('should return null when all messages are mine', () { - final lastRead = DateTime.now(); - final userRead = Read( - lastRead: lastRead, - user: User(id: 'user1'), - ); - final messages = [ - Message( - id: '1', - user: userRead.user, - createdAt: lastRead.add(const Duration(seconds: 1))), - Message(id: '2', user: userRead.user, createdAt: lastRead), - ]; - expect(messages.lastUnreadMessage(userRead), isNull); - }); - - test('should return the message', () { - final lastRead = DateTime.now(); - final otherUser = User(id: 'user2'); - final userRead = Read( - lastRead: lastRead, - user: User(id: 'user1'), - ); - - final messages = [ - Message( - id: '1', - user: otherUser, - createdAt: lastRead.add(const Duration(seconds: 2)), - ), - Message( - id: '2', - user: otherUser, - createdAt: lastRead.add(const Duration(seconds: 1)), - ), - Message( - id: '3', - user: otherUser, - createdAt: lastRead.subtract(const Duration(seconds: 1)), - ), - ]; - - final lastUnreadMessage = messages.lastUnreadMessage(userRead); - expect(lastUnreadMessage, isNotNull); - expect(lastUnreadMessage!.id, '2'); - }); - - test('should not return the last message read', () { - final lastRead = DateTime.timestamp(); - final otherUser = User(id: 'user2'); - final userRead = Read( - lastRead: lastRead, - user: User(id: 'user1'), - lastReadMessageId: '3', - ); - - final messages = [ - Message( - id: '1', - user: otherUser, - createdAt: lastRead.add(const Duration(seconds: 2)), - ), - Message( - id: '2', - user: otherUser, - createdAt: lastRead.add(const Duration(milliseconds: 1)), - ), - Message( - id: '3', - user: otherUser, - createdAt: lastRead.add(const Duration(microseconds: 1)), - ), - Message( - id: '4', - user: otherUser, - createdAt: lastRead.subtract(const Duration(seconds: 1)), - ), - ]; - - final lastUnreadMessage = messages.lastUnreadMessage(userRead); - expect(lastUnreadMessage, isNotNull); - expect(lastUnreadMessage!.id, '2'); - }); - }); - }); } diff --git a/packages/stream_chat_flutter/test/src/utils/stream_image_cdn_test.dart b/packages/stream_chat_flutter/test/src/utils/stream_image_cdn_test.dart new file mode 100644 index 0000000000..967ba5a9ed --- /dev/null +++ b/packages/stream_chat_flutter/test/src/utils/stream_image_cdn_test.dart @@ -0,0 +1,237 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:stream_chat_flutter/src/utils/stream_image_cdn.dart'; + +void main() { + const cdn = StreamImageCDN(); + + group('StreamImageCDN.resolveUrl', () { + group('Stream CDN URLs', () { + test('returns unchanged URL when resize is null', () { + const url = + 'https://us-east.stream-io-cdn.com/102400/images/photo.jpg' + '?Policy=abc&Signature=xyz&Key-Pair-Id=123'; + + expect(cdn.resolveUrl(url), equals(url)); + }); + + test('adds resize params when none exist', () { + const url = 'https://us-east.stream-io-cdn.com/102400/images/photo.jpg'; + const resize = ImageResize(width: 200, height: 300); + + final result = cdn.resolveUrl(url, resize: resize); + + expect(result, contains('w=200')); + expect(result, contains('h=300')); + expect(result, contains('resize=clip')); + expect(result, contains('ro=0')); + expect(result, isNot(contains('crop='))); + }); + + test('includes crop param only when mode is crop', () { + const url = 'https://us-east.stream-io-cdn.com/102400/images/photo.jpg'; + const resize = ImageResize( + width: 400, + height: 400, + mode: ResizeMode.crop, + crop: CropMode.top, + ); + + final result = cdn.resolveUrl(url, resize: resize); + + expect(result, contains('resize=crop')); + expect(result, contains('crop=top')); + expect(result, contains('ro=0')); + }); + + test('does not include crop param when mode is not crop', () { + const url = 'https://us-east.stream-io-cdn.com/102400/images/photo.jpg'; + + for (final mode in [ + ResizeMode.clip, + ResizeMode.scale, + ResizeMode.fill, + ]) { + final result = cdn.resolveUrl( + url, + resize: ImageResize(width: 200, height: 200, mode: mode), + ); + + expect( + result, + isNot(contains('crop=')), + reason: 'crop should not be present for mode ${mode.value}', + ); + } + }); + + test('always overrides existing resize params', () { + const url = + 'https://us-east.stream-io-cdn.com/102400/images/photo.jpg' + '?w=100&h=100&resize=fill'; + const resize = ImageResize( + width: 200, + height: 300, + mode: ResizeMode.crop, + crop: CropMode.left, + ); + + final result = cdn.resolveUrl(url, resize: resize); + + expect(result, contains('w=200')); + expect(result, contains('h=300')); + expect(result, contains('resize=crop')); + expect(result, contains('crop=left')); + }); + + test('preserves existing non-resize query parameters', () { + const url = + 'https://us-east.stream-io-cdn.com/102400/images/photo.jpg' + '?Policy=abc&Signature=xyz&Key-Pair-Id=123'; + const resize = ImageResize(width: 200, height: 300); + + final result = cdn.resolveUrl(url, resize: resize); + + expect(result, contains('Policy=abc')); + expect(result, contains('Signature=xyz')); + expect(result, contains('Key-Pair-Id=123')); + expect(result, contains('w=200')); + }); + + test('floors fractional dimensions', () { + const url = 'https://us-east.stream-io-cdn.com/102400/images/photo.jpg'; + const resize = ImageResize(width: 199.7, height: 300.3); + + final result = cdn.resolveUrl(url, resize: resize); + + expect(result, contains('w=199')); + expect(result, contains('h=300')); + }); + + test('uses wildcard for zero dimensions', () { + const url = 'https://us-east.stream-io-cdn.com/102400/images/photo.jpg'; + const resize = ImageResize(width: 0, height: 300); + + final result = cdn.resolveUrl(url, resize: resize); + + expect(result, contains('w=%2A')); + expect(result, contains('h=300')); + }); + }); + + group('non-Stream URLs', () { + test('returns URL unchanged regardless of resize', () { + const url = 'https://example.com/photo.jpg'; + const resize = ImageResize(width: 200, height: 300); + + expect(cdn.resolveUrl(url, resize: resize), equals(url)); + }); + + test('returns URL unchanged when resize is null', () { + const url = 'https://example.com/photo.jpg?token=abc'; + + expect(cdn.resolveUrl(url), equals(url)); + }); + }); + }); + + group('StreamImageCDN.cacheKey', () { + group('Stream CDN URLs', () { + test('strips signing parameters', () { + const url = + 'https://us-east.stream-io-cdn.com/102400/images/photo.jpg' + '?Key-Pair-Id=APKAIHG&Policy=eyJTdGF0&Signature=OeMK5' + '&w=200&h=300&resize=clip&crop=center'; + + final key = cdn.cacheKey(url); + + expect(key, contains('w=200')); + expect(key, contains('h=300')); + expect(key, contains('resize=clip')); + expect(key, contains('crop=center')); + expect(key, isNot(contains('Key-Pair-Id'))); + expect(key, isNot(contains('Policy'))); + expect(key, isNot(contains('Signature'))); + }); + + test('returns URL path only when no resize params exist', () { + const url = + 'https://us-east.stream-io-cdn.com/102400/images/photo.jpg' + '?Key-Pair-Id=APKAIHG&Policy=eyJTdGF0&Signature=OeMK5'; + + final key = cdn.cacheKey(url); + + expect(key, isNot(contains('Key-Pair-Id'))); + expect(key, isNot(contains('Policy'))); + expect(key, isNot(contains('Signature'))); + expect( + key, + 'https://us-east.stream-io-cdn.com/102400/images/photo.jpg?', + ); + }); + + test('produces same key for same image with different signatures', () { + const url1 = + 'https://us-east.stream-io-cdn.com/102400/images/photo.jpg' + '?Key-Pair-Id=APKAIHG&Policy=policy1&Signature=sig1' + '&w=200&h=300'; + const url2 = + 'https://us-east.stream-io-cdn.com/102400/images/photo.jpg' + '?Key-Pair-Id=APKAIHG&Policy=policy2&Signature=sig2' + '&w=200&h=300'; + + expect(cdn.cacheKey(url1), equals(cdn.cacheKey(url2))); + }); + + test('produces different keys for different resize dimensions', () { + const url1 = + 'https://us-east.stream-io-cdn.com/102400/images/photo.jpg' + '?w=200&h=300'; + const url2 = + 'https://us-east.stream-io-cdn.com/102400/images/photo.jpg' + '?w=400&h=600'; + + expect(cdn.cacheKey(url1), isNot(equals(cdn.cacheKey(url2)))); + }); + + test('strips oh and ow parameters', () { + const url = + 'https://us-east.stream-io-cdn.com/102400/images/photo.jpg' + '?oh=4032&ow=3024&w=200&h=300'; + + final key = cdn.cacheKey(url); + + expect(key, isNot(contains('oh='))); + expect(key, isNot(contains('ow='))); + expect(key, contains('w=200')); + expect(key, contains('h=300')); + }); + }); + + group('non-Stream URLs', () { + test('returns full URL string unchanged', () { + const url = 'https://example.com/photo.jpg?token=abc'; + + expect(cdn.cacheKey(url), equals(url)); + }); + }); + }); + + group('ResizeMode', () { + test('all modes have correct string values', () { + expect(ResizeMode.clip.value, 'clip'); + expect(ResizeMode.crop.value, 'crop'); + expect(ResizeMode.scale.value, 'scale'); + expect(ResizeMode.fill.value, 'fill'); + }); + }); + + group('CropMode', () { + test('all modes have correct string values', () { + expect(CropMode.center.value, 'center'); + expect(CropMode.top.value, 'top'); + expect(CropMode.bottom.value, 'bottom'); + expect(CropMode.left.value, 'left'); + expect(CropMode.right.value, 'right'); + }); + }); +} diff --git a/packages/stream_chat_flutter/test/test_utils/data_generator.dart b/packages/stream_chat_flutter/test/test_utils/data_generator.dart index 5759136ba6..94f32285e3 100644 --- a/packages/stream_chat_flutter/test/test_utils/data_generator.dart +++ b/packages/stream_chat_flutter/test/test_utils/data_generator.dart @@ -8,9 +8,10 @@ List generateConversation( int unreadCount = 0, }) { assert( - users == null || noOfUsers == null, - 'Only one of users or noOfUsers ' - 'should be provided'); + users == null || noOfUsers == null, + 'Only one of users or noOfUsers ' + 'should be provided', + ); assert(count > 0, 'Count should be greater than 0'); assert(count > unreadCount, 'Count should be greater than unreadCount'); @@ -38,8 +39,7 @@ List generateConversation( id: faker.datatype.uuid(), text: faker.lorem.sentence(), user: user, - createdAt: - DateTime.now().subtract(Duration(minutes: i + count - unreadCount)), + createdAt: DateTime.now().subtract(Duration(minutes: i + count - unreadCount)), ), ); } diff --git a/packages/stream_chat_flutter_core/CHANGELOG.md b/packages/stream_chat_flutter_core/CHANGELOG.md index 67cf23e867..07c87bec32 100644 --- a/packages/stream_chat_flutter_core/CHANGELOG.md +++ b/packages/stream_chat_flutter_core/CHANGELOG.md @@ -1,15 +1,69 @@ +## Upcoming + +🛑️ Breaking + +- Renamed `StreamMessageInputController` → `StreamMessageComposerController`. +- Renamed `StreamRestorableMessageInputController` → `StreamRestorableMessageComposerController`. +- Renamed `StreamMessageComposerController.editingOriginalMessage` → `messageBeingEdited`. +- `StreamMessageComposerController` constructor no longer accepts non-initial messages; + use `editMessage()` to enter edit mode. +- `StreamMessageComposerController.cancelEditMessage()` is now a no-op when no edit is active. +- `StreamMessageComposerController.clear()` no longer exits edit mode; + use `cancelEditMessage()` instead. + +✅ Added + +- Added `StreamMessageComposerController.isEditing` getter. +- Added `StreamMessageComposerController.clearCommand()`; setting `command = null` is + now an alias for it. +- `StreamMessageComposerController.editMessage()` and the `command` setter are now + re-entrant — repeated calls preserve the original restore snapshot. + +🔄 Changed + +- `StreamChatCore` now sets `client.recoverStateOnReconnect = false` on mount; refreshes on `connectionRecovered` are driven by the list controllers in this package, avoiding a duplicate `queryChannels` round-trip and the historical event-replay flicker on reactions, polls, and quoted messages. +- Apps watching a `Channel` outside any list controller (e.g. a deep link into a single channel screen) should subscribe to `client.on(EventType.connectionRecovered)` and call `channel.watch()` themselves to refresh state on reconnect. +- Changed the default `backgroundKeepAlive` from 1 minute to 15 seconds — covers quick app-switches and notification-shade checks while closing cleanly before the server's 35-second read timeout. Still configurable. + +🐞 Fixed + +- Fixed `StreamChatCore` disconnecting the WebSocket immediately on background when no `onBackgroundEventReceived` handler was provided; the keep-alive timer now fires before the connection closes regardless of whether a handler is set. +- Fixed `StreamMessageComposerController.cancelEditMessage` losing the pre-edit draft when a remote update arrived for the message being edited. + +## 10.0.0-beta.13 + +🛑️ Breaking +- SDK Redesign Changes. For more details, please refer to the [migration guide](https://github.com/GetStream/stream-chat-flutter/blob/210ff93f955be3f85c62e860309bd9aa240a5446/migrations). + The SDK redesign introduces a fresher default UI, but also better APIs for customization of the components. + +## 10.0.0-beta.12 + +- Included the changes from version [`9.23.0`](https://pub.dev/packages/stream_chat_flutter_core/changelog). + ## 9.23.0 - Updated `stream_chat` dependency to [`9.23.0`](https://pub.dev/packages/stream_chat/changelog). +## 10.0.0-beta.11 + +- Included the changes from version [`9.22.0`](https://pub.dev/packages/stream_chat_flutter_core/changelog). + ## 9.22.0 - Updated `stream_chat` dependency to [`9.22.0`](https://pub.dev/packages/stream_chat/changelog). +## 10.0.0-beta.10 + +- Included the changes from version [`9.21.0`](https://pub.dev/packages/stream_chat_flutter_core/changelog). + ## 9.21.0 - Updated `stream_chat` dependency to [`9.21.0`](https://pub.dev/packages/stream_chat/changelog). +## 10.0.0-beta.9 + +- Included the changes from version [`9.20.0`](https://pub.dev/packages/stream_chat_flutter_core/changelog). + ## 9.20.0 🐞 Fixed @@ -17,18 +71,34 @@ - Fixed race condition where `connectUser` could be blocked when connectivity monitoring triggers during initial connection. [[#2409]](https://github.com/GetStream/stream-chat-flutter/issues/2409) +## 10.0.0-beta.8 + +- Included the changes from version [`9.19.0`](https://pub.dev/packages/stream_chat_flutter_core/changelog). + ## 9.19.0 - Updated `stream_chat` dependency to [`9.19.0`](https://pub.dev/packages/stream_chat/changelog). +## 10.0.0-beta.7 + +- Included the changes from version [`9.18.0`](https://pub.dev/packages/stream_chat_flutter_core/changelog). + ## 9.18.0 - Updated `stream_chat` dependency to [`9.18.0`](https://pub.dev/packages/stream_chat/changelog). +## 10.0.0-beta.6 + +- Included the changes from version [`9.17.0`](https://pub.dev/packages/stream_chat_flutter_core/changelog). + ## 9.17.0 - Updated `stream_chat` dependency to [`9.17.0`](https://pub.dev/packages/stream_chat/changelog). +## 10.0.0-beta.5 + +- Included the changes from version [`9.16.0`](https://pub.dev/packages/stream_chat_flutter_core/changelog). + ## 9.16.0 🐞 Fixed @@ -39,6 +109,10 @@ - Added methods for paginating thread replies in `StreamChannel`. +## 10.0.0-beta.4 + +- Included the changes from version [`9.15.0`](https://pub.dev/packages/stream_chat_flutter_core/changelog). + ## 9.15.0 ✅ Added @@ -53,6 +127,10 @@ - Ensure `StreamChannel` future builder completes after channel initialization. [[#2323]](https://github.com/GetStream/stream-chat-flutter/issues/2323) +## 10.0.0-beta.3 + +- Included the changes from version [`9.14.0`](https://pub.dev/packages/stream_chat_flutter_core/changelog). + ## 9.14.0 🐞 Fixed @@ -60,6 +138,10 @@ - Fixed cached messages are cleared from channels with unread messages when accessed offline. [[#2083]](https://github.com/GetStream/stream-chat-flutter/issues/2083) +## 10.0.0-beta.2 + +- Included the changes from version [`9.13.0`](https://pub.dev/packages/stream_chat_flutter_core/changelog). + ## 9.13.0 🐞 Fixed @@ -67,6 +149,10 @@ - Fixed pagination end detection logic to properly determine when the top or bottom of the message list has been reached. +## 10.0.0-beta.1 + +- Updated `stream_chat` dependency to [`10.0.0-beta.1`](https://pub.dev/packages/stream_chat/changelog). + ## 9.12.0 ✅ Added diff --git a/packages/stream_chat_flutter_core/README.md b/packages/stream_chat_flutter_core/README.md index 695cd9cdf0..82e82fdaf8 100644 --- a/packages/stream_chat_flutter_core/README.md +++ b/packages/stream_chat_flutter_core/README.md @@ -75,7 +75,7 @@ Controllers are used to handle the business logic of the chat. You can use them * StreamChannelListController * StreamUserListController * StreamMessageSearchListController -* StreamMessageInputController +* StreamMessageComposerController * LazyLoadScrollView * PagedValueListenableBuilder diff --git a/packages/stream_chat_flutter_core/example/lib/main.dart b/packages/stream_chat_flutter_core/example/lib/main.dart index 0a523f81ac..15173a0bad 100644 --- a/packages/stream_chat_flutter_core/example/lib/main.dart +++ b/packages/stream_chat_flutter_core/example/lib/main.dart @@ -19,11 +19,7 @@ Future main() async { '''eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ1c2VyX2lkIjoiY29vbC1zaGFkb3ctNyJ9.gkOlCRb1qgy4joHPaxFwPOdXcGvSPvp6QY0S4mpRkVo''', ); - runApp( - StreamExample( - client: client, - ), - ); + runApp(StreamExample(client: client)); } /// Example application using Stream Chat core widgets. @@ -37,10 +33,7 @@ class StreamExample extends StatelessWidget { /// /// If you'd prefer using pre-made UI widgets for your app, please see our /// other package, `stream_chat_flutter`. - const StreamExample({ - Key? key, - required this.client, - }) : super(key: key); + const StreamExample({Key? key, required this.client}) : super(key: key); /// Instance of Stream Client. /// Stream's [StreamChatClient] can be used to connect to our servers and @@ -50,13 +43,10 @@ class StreamExample extends StatelessWidget { @override Widget build(BuildContext context) => MaterialApp( - title: 'Stream Chat Core Example', - home: HomeScreen(), - builder: (context, child) => StreamChatCore( - client: client, - child: child!, - ), - ); + title: 'Stream Chat Core Example', + home: HomeScreen(), + builder: (context, child) => StreamChatCore(client: client, child: child!), + ); } /// Basic layout displaying a list of [Channel]s the user is a part of. @@ -80,12 +70,7 @@ class _HomeScreenState extends State { client: StreamChatCore.of(context).client, filter: Filter.and([ Filter.equal('type', 'messaging'), - Filter.in_( - 'members', - [ - StreamChatCore.of(context).currentUser!.id, - ], - ), + Filter.in_('members', [StreamChatCore.of(context).currentUser!.id]), ]), ); @@ -103,91 +88,89 @@ class _HomeScreenState extends State { @override Widget build(BuildContext context) => Scaffold( - appBar: AppBar( - title: const Text('Channels'), - ), - body: PagedValueListenableBuilder( - valueListenable: channelListController, - builder: (context, value, child) { - return value.when( - (channels, nextPageKey, error) => LazyLoadScrollView( - onEndOfPage: () async { - if (nextPageKey != null) { - channelListController.loadMore(nextPageKey); + appBar: AppBar(title: const Text('Channels')), + body: PagedValueListenableBuilder( + valueListenable: channelListController, + builder: (context, value, child) { + return value.when( + (channels, nextPageKey, error) => LazyLoadScrollView( + onEndOfPage: () async { + if (nextPageKey != null) { + channelListController.loadMore(nextPageKey); + } + }, + child: ListView.builder( + /// We're using the channels length when there are no more + /// pages to load and there are no errors with pagination. + /// In case we need to show a loading indicator or and error + /// tile we're increasing the count by 1. + itemCount: (nextPageKey != null || error != null) + ? channels.length + 1 + : channels.length, + itemBuilder: (BuildContext context, int index) { + if (index == channels.length) { + if (error != null) { + return TextButton( + onPressed: () { + channelListController.retry(); + }, + child: Text(error.message), + ); } - }, - child: ListView.builder( - /// We're using the channels length when there are no more - /// pages to load and there are no errors with pagination. - /// In case we need to show a loading indicator or and error - /// tile we're increasing the count by 1. - itemCount: (nextPageKey != null || error != null) - ? channels.length + 1 - : channels.length, - itemBuilder: (BuildContext context, int index) { - if (index == channels.length) { - if (error != null) { - return TextButton( - onPressed: () { - channelListController.retry(); - }, - child: Text(error.message), - ); - } - return CircularProgressIndicator(); - } + return CircularProgressIndicator(); + } - final _item = channels[index]; - return ListTile( - title: Text(_item.name ?? ''), - subtitle: StreamBuilder( - stream: _item.state!.lastMessageStream, - initialData: _item.state!.lastMessage, - builder: (context, snapshot) { - if (snapshot.hasData) { - return Text(snapshot.data!.text!); - } + final _item = channels[index]; + return ListTile( + title: Text(_item.name ?? ''), + subtitle: StreamBuilder( + stream: _item.state!.lastMessageStream, + initialData: _item.state!.lastMessage, + builder: (context, snapshot) { + if (snapshot.hasData) { + return Text(snapshot.data!.text!); + } - return const SizedBox(); - }, + return const SizedBox(); + }, + ), + onTap: () { + /// Display a list of messages when the user taps on + /// an item. We can use [StreamChannel] to wrap our + /// [MessageScreen] screen with the selected channel. + /// + /// This allows us to use a built-in inherited widget + /// for accessing our `channel` later on. + Navigator.of(context).push( + MaterialPageRoute( + builder: (context) => StreamChannel( + channel: _item, + child: const MessageScreen(), + ), ), - onTap: () { - /// Display a list of messages when the user taps on - /// an item. We can use [StreamChannel] to wrap our - /// [MessageScreen] screen with the selected channel. - /// - /// This allows us to use a built-in inherited widget - /// for accessing our `channel` later on. - Navigator.of(context).push( - MaterialPageRoute( - builder: (context) => StreamChannel( - channel: _item, - child: const MessageScreen(), - ), - ), - ); - }, ); }, - ), - ), - loading: () => const Center( - child: SizedBox( - height: 100, - width: 100, - child: CircularProgressIndicator(), - ), - ), - error: (e) => Center( - child: Text( - 'Oh no, something went wrong. ' - 'Please check your config. $e', - ), - ), - ); - }, - ), - ); + ); + }, + ), + ), + loading: () => const Center( + child: SizedBox( + height: 100, + width: 100, + child: CircularProgressIndicator(), + ), + ), + error: (e) => Center( + child: Text( + 'Oh no, something went wrong. ' + 'Please check your config. $e', + ), + ), + ); + }, + ), + ); } /// A list of messages sent in the current channel. @@ -205,8 +188,8 @@ class MessageScreen extends StatefulWidget { } class _MessageScreenState extends State { - final StreamMessageInputController messageInputController = - StreamMessageInputController(); + final StreamMessageComposerController messageComposerController = + StreamMessageComposerController(); late final ScrollController _scrollController; final messageListController = MessageListController(); @@ -218,7 +201,7 @@ class _MessageScreenState extends State { @override void dispose() { - messageInputController.dispose(); + messageComposerController.dispose(); _scrollController.dispose(); super.dispose(); } @@ -259,9 +242,8 @@ class _MessageScreenState extends State { }, child: MessageListCore( messageListController: messageListController, - emptyBuilder: (BuildContext context) => const Center( - child: Text('Nothing here yet'), - ), + emptyBuilder: (BuildContext context) => + const Center(child: Text('Nothing here yet')), loadingBuilder: (BuildContext context) => const Center( child: SizedBox( height: 100, @@ -269,44 +251,43 @@ class _MessageScreenState extends State { child: CircularProgressIndicator(), ), ), - messageListBuilder: ( - BuildContext context, - List messages, - ) => - ListView.builder( - controller: _scrollController, - itemCount: messages.length, - reverse: true, - itemBuilder: (BuildContext context, int index) { - final item = messages[index]; - final client = StreamChatCore.of(context).client; - if (item.user!.id == client.uid) { - return Align( - alignment: Alignment.centerRight, - child: Padding( - padding: const EdgeInsets.all(8), - child: Text(item.text!), + messageListBuilder: + (BuildContext context, List messages) => + ListView.builder( + controller: _scrollController, + itemCount: messages.length, + reverse: true, + itemBuilder: (BuildContext context, int index) { + final item = messages[index]; + final client = StreamChatCore.of(context).client; + if (item.user!.id == client.uid) { + return Align( + alignment: Alignment.centerRight, + child: Padding( + padding: const EdgeInsets.all(8), + child: Text(item.text!), + ), + ); + } else { + return Align( + alignment: Alignment.centerLeft, + child: Padding( + padding: const EdgeInsets.all(8), + child: Text(item.text!), + ), + ); + } + }, ), - ); - } else { - return Align( - alignment: Alignment.centerLeft, - child: Padding( - padding: const EdgeInsets.all(8), - child: Text(item.text!), - ), - ); - } - }, - ), errorBuilder: (BuildContext context, error) { print(error.toString()); return const Center( child: SizedBox( height: 100, width: 100, - child: - Text('Oh no, an error occured. Please see logs.'), + child: Text( + 'Oh no, an error occured. Please see logs.', + ), ), ); }, @@ -319,7 +300,7 @@ class _MessageScreenState extends State { children: [ Expanded( child: TextField( - controller: messageInputController.textFieldController, + controller: messageComposerController.textFieldController, decoration: const InputDecoration( hintText: 'Enter your message', ), @@ -331,11 +312,11 @@ class _MessageScreenState extends State { clipBehavior: Clip.hardEdge, child: InkWell( onTap: () async { - if (messageInputController.text.isNotEmpty) { + if (messageComposerController.text.isNotEmpty) { await channel.sendMessage( - messageInputController.message, + messageComposerController.message, ); - messageInputController.clear(); + messageComposerController.clear(); if (mounted) { _updateList(); } @@ -344,10 +325,7 @@ class _MessageScreenState extends State { child: const Padding( padding: EdgeInsets.all(8), child: Center( - child: Icon( - Icons.send, - color: Colors.white, - ), + child: Icon(Icons.send, color: Colors.white), ), ), ), diff --git a/packages/stream_chat_flutter_core/example/pubspec.yaml b/packages/stream_chat_flutter_core/example/pubspec.yaml index a2268fe974..bc19ead3aa 100644 --- a/packages/stream_chat_flutter_core/example/pubspec.yaml +++ b/packages/stream_chat_flutter_core/example/pubspec.yaml @@ -16,14 +16,14 @@ version: 1.0.0+1 # 2. Add it to the melos.yaml file for future updates. environment: - sdk: ^3.6.2 - flutter: ">=3.27.4" + sdk: ^3.10.0 + flutter: ">=3.38.1" dependencies: cupertino_icons: ^1.0.3 flutter: sdk: flutter - stream_chat_flutter_core: ^9.23.0 + stream_chat_flutter_core: ^10.0.0-beta.13 flutter: uses-material-design: true diff --git a/packages/stream_chat_flutter_core/lib/src/better_stream_builder.dart b/packages/stream_chat_flutter_core/lib/src/better_stream_builder.dart index 6305224b61..22344c81ef 100644 --- a/packages/stream_chat_flutter_core/lib/src/better_stream_builder.dart +++ b/packages/stream_chat_flutter_core/lib/src/better_stream_builder.dart @@ -40,8 +40,7 @@ class BetterStreamBuilder extends StatefulWidget { _BetterStreamBuilderState createState() => _BetterStreamBuilderState(); } -class _BetterStreamBuilderState - extends State> { +class _BetterStreamBuilderState extends State> { T? _lastEvent; StreamSubscription? _subscription; Object? _lastError; @@ -102,8 +101,7 @@ class _BetterStreamBuilderState void _onEvent(T? event) { _lastError = null; - final isEqual = - widget.comparator?.call(_lastEvent, event) ?? event == _lastEvent; + final isEqual = widget.comparator?.call(_lastEvent, event) ?? event == _lastEvent; if (!isEqual) { _lastEvent = event; if (mounted) { diff --git a/packages/stream_chat_flutter_core/lib/src/lazy_load_scroll_view.dart b/packages/stream_chat_flutter_core/lib/src/lazy_load_scroll_view.dart index d56d5f239a..1495a94c9b 100644 --- a/packages/stream_chat_flutter_core/lib/src/lazy_load_scroll_view.dart +++ b/packages/stream_chat_flutter_core/lib/src/lazy_load_scroll_view.dart @@ -53,11 +53,10 @@ class _LazyLoadScrollViewState extends State { double _scrollPosition = 0; @override - Widget build(BuildContext context) => - NotificationListener( - onNotification: _onNotification, - child: widget.child, - ); + Widget build(BuildContext context) => NotificationListener( + onNotification: _onNotification, + child: widget.child, + ); bool _onNotification(ScrollNotification notification) { if (notification is ScrollStartNotification) { @@ -78,8 +77,7 @@ class _LazyLoadScrollViewState extends State { final minScrollExtent = notification.metrics.minScrollExtent; final scrollOffset = widget.scrollOffset; - if (pixels > (minScrollExtent + scrollOffset) && - pixels < (maxScrollExtent - scrollOffset)) { + if (pixels > (minScrollExtent + scrollOffset) && pixels < (maxScrollExtent - scrollOffset)) { if (widget.onInBetweenOfPage != null) { widget.onInBetweenOfPage!(); return !widget.allowNotificationBubbling; diff --git a/packages/stream_chat_flutter_core/lib/src/message_list_core.dart b/packages/stream_chat_flutter_core/lib/src/message_list_core.dart index 44b3c2619c..7a9b3d6c0e 100644 --- a/packages/stream_chat_flutter_core/lib/src/message_list_core.dart +++ b/packages/stream_chat_flutter_core/lib/src/message_list_core.dart @@ -9,12 +9,11 @@ import 'package:stream_chat_flutter_core/src/stream_channel.dart'; import 'package:stream_chat_flutter_core/src/typedef.dart'; /// Default filter for the message list -bool Function(Message) defaultMessageFilter(String currentUserId) => - (Message m) { - final isMyMessage = m.user?.id == currentUserId; - if (m.shadowed && !isMyMessage) return false; - return true; - }; +bool Function(Message) defaultMessageFilter(String currentUserId) => (Message m) { + final isMyMessage = m.user?.id == currentUserId; + if (m.shadowed && !isMyMessage) return false; + return true; +}; /// [MessageListCore] is a simplified class that allows fetching a list of /// messages while exposing UI builders. @@ -131,8 +130,8 @@ class MessageListCoreState extends State { Widget build(BuildContext context) { final messagesStream = _isThreadConversation ? _streamChannel!.channel.state?.threadsStream - .where((threads) => threads.containsKey(widget.parentMessage!.id)) - .map((threads) => threads[widget.parentMessage!.id]) + .where((threads) => threads.containsKey(widget.parentMessage!.id)) + .map((threads) => threads[widget.parentMessage!.id]) : _streamChannel!.channel.state?.messagesStream; final initialData = _isThreadConversation diff --git a/packages/stream_chat_flutter_core/lib/src/message_text_field_controller.dart b/packages/stream_chat_flutter_core/lib/src/message_text_field_controller.dart index ae730c06b8..f485b75c5a 100644 --- a/packages/stream_chat_flutter_core/lib/src/message_text_field_controller.dart +++ b/packages/stream_chat_flutter_core/lib/src/message_text_field_controller.dart @@ -1,10 +1,11 @@ import 'package:flutter/material.dart'; /// A function that takes a [BuildContext] and returns a [TextStyle]. -typedef TextStyleBuilder = TextStyle? Function( - BuildContext context, - String text, -); +typedef TextStyleBuilder = + TextStyle? Function( + BuildContext context, + String text, + ); /// Controller for the [StreamTextField] widget. class MessageTextFieldController extends TextEditingController { diff --git a/packages/stream_chat_flutter_core/lib/src/paged_value_notifier.dart b/packages/stream_chat_flutter_core/lib/src/paged_value_notifier.dart index 83643a64d0..38bfa98756 100644 --- a/packages/stream_chat_flutter_core/lib/src/paged_value_notifier.dart +++ b/packages/stream_chat_flutter_core/lib/src/paged_value_notifier.dart @@ -8,8 +8,7 @@ part 'paged_value_notifier.freezed.dart'; const defaultInitialPagedLimitMultiplier = 3; /// Value listenable for paged data. -typedef PagedValueListenableBuilder - = ValueListenableBuilder>; +typedef PagedValueListenableBuilder = ValueListenableBuilder>; /// A [PagedValueNotifier] that uses a [PagedListenable] to load data. /// @@ -19,8 +18,7 @@ typedef PagedValueListenableBuilder /// /// [PagedValueNotifier] is a [ValueNotifier] that emits a [PagedValue] /// whenever the data is loaded or an error occurs. -abstract class PagedValueNotifier - extends ValueNotifier> { +abstract class PagedValueNotifier extends ValueNotifier> { /// Creates a [PagedValueNotifier] PagedValueNotifier(this._initialValue) : super(_initialValue); @@ -148,17 +146,18 @@ extension PagedValuePatternMatching on PagedValue { List items, Key? nextPageKey, StreamChatError? error, - ) success, { + ) + success, { required TResult Function() loading, required TResult Function(StreamChatError error) error, }) { final pagedValue = this; return switch (pagedValue) { Success() => success( - pagedValue.items, - pagedValue.nextPageKey, - pagedValue.error, - ), + pagedValue.items, + pagedValue.nextPageKey, + pagedValue.error, + ), Loading() => loading(), Error() => error(pagedValue.error), }; @@ -171,17 +170,18 @@ extension PagedValuePatternMatching on PagedValue { List items, Key? nextPageKey, StreamChatError? error, - )? success, { + )? + success, { TResult? Function()? loading, TResult? Function(StreamChatError error)? error, }) { final pagedValue = this; return switch (pagedValue) { Success() => success?.call( - pagedValue.items, - pagedValue.nextPageKey, - pagedValue.error, - ), + pagedValue.items, + pagedValue.nextPageKey, + pagedValue.error, + ), Loading() => loading?.call(), Error() => error?.call(pagedValue.error), }; @@ -194,7 +194,8 @@ extension PagedValuePatternMatching on PagedValue { List items, Key? nextPageKey, StreamChatError? error, - )? success, { + )? + success, { TResult Function()? loading, TResult Function(StreamChatError error)? error, required TResult orElse(), @@ -202,10 +203,10 @@ extension PagedValuePatternMatching on PagedValue { final pagedValue = this; final result = switch (pagedValue) { Success() => success?.call( - pagedValue.items, - pagedValue.nextPageKey, - pagedValue.error, - ), + pagedValue.items, + pagedValue.nextPageKey, + pagedValue.error, + ), Loading() => loading?.call(), Error() => error?.call(pagedValue.error), }; diff --git a/packages/stream_chat_flutter_core/lib/src/paged_value_scroll_view.dart b/packages/stream_chat_flutter_core/lib/src/paged_value_scroll_view.dart index a078f471c7..ce60e68bb0 100644 --- a/packages/stream_chat_flutter_core/lib/src/paged_value_scroll_view.dart +++ b/packages/stream_chat_flutter_core/lib/src/paged_value_scroll_view.dart @@ -5,18 +5,20 @@ import 'package:stream_chat_flutter_core/src/paged_value_notifier.dart'; /// Signature for a function that creates a widget for a given index, e.g., in a /// [PagedValueListView] and [PagedValueGridView]. -typedef PagedValueScrollViewIndexedWidgetBuilder = Widget Function( - BuildContext context, - List values, - int index, -); +typedef PagedValueScrollViewIndexedWidgetBuilder = + Widget Function( + BuildContext context, + List values, + int index, + ); /// Signature for the item builder that creates the children of the /// [PagedValueListView] and [PagedValueGridView]. -typedef PagedValueScrollViewLoadMoreErrorBuilder = Widget Function( - BuildContext context, - StreamChatError error, -); +typedef PagedValueScrollViewLoadMoreErrorBuilder = + Widget Function( + BuildContext context, + StreamChatError error, + ); /// A [ListView] that loads more pages when the user scrolls to the end of the /// list. @@ -260,8 +262,7 @@ class PagedValueListView extends StatefulWidget { final Clip clipBehavior; @override - State> createState() => - _PagedValueListViewState(); + State> createState() => _PagedValueListViewState(); } class _PagedValueListViewState extends State> { @@ -288,65 +289,62 @@ class _PagedValueListViewState extends State> { @override Widget build(BuildContext context) => PagedValueListenableBuilder( - valueListenable: _controller, - builder: (context, value, _) => value.when( - (items, nextPageKey, error) { - if (items.isEmpty) { - return widget.emptyBuilder(context); - } - - return ListView.separated( - scrollDirection: widget.scrollDirection, - padding: widget.padding, - physics: widget.physics, - reverse: widget.reverse, - controller: widget.scrollController, - primary: widget.primary, - shrinkWrap: widget.shrinkWrap, - addAutomaticKeepAlives: widget.addAutomaticKeepAlives, - addRepaintBoundaries: widget.addRepaintBoundaries, - addSemanticIndexes: widget.addSemanticIndexes, - keyboardDismissBehavior: widget.keyboardDismissBehavior, - restorationId: widget.restorationId, - dragStartBehavior: widget.dragStartBehavior, - cacheExtent: widget.cacheExtent, - clipBehavior: widget.clipBehavior, - itemCount: value.itemCount, - separatorBuilder: (context, index) => - widget.separatorBuilder(context, items, index), - itemBuilder: (context, index) { - if (!_hasRequestedNextPage) { - final newPageRequestTriggerIndex = - items.length - widget.loadMoreTriggerIndex; - final isBuildingTriggerIndexItem = - index == newPageRequestTriggerIndex; - if (nextPageKey != null && isBuildingTriggerIndexItem) { - // Schedules the request for the end of this frame. - WidgetsBinding.instance.addPostFrameCallback((_) async { - if (error == null) { - await _controller.loadMore(nextPageKey); - } - _hasRequestedNextPage = false; - }); - _hasRequestedNextPage = true; + valueListenable: _controller, + builder: (context, value, _) => value.when( + (items, nextPageKey, error) { + if (items.isEmpty) { + return widget.emptyBuilder(context); + } + + return ListView.separated( + scrollDirection: widget.scrollDirection, + padding: widget.padding, + physics: widget.physics, + reverse: widget.reverse, + controller: widget.scrollController, + primary: widget.primary, + shrinkWrap: widget.shrinkWrap, + addAutomaticKeepAlives: widget.addAutomaticKeepAlives, + addRepaintBoundaries: widget.addRepaintBoundaries, + addSemanticIndexes: widget.addSemanticIndexes, + keyboardDismissBehavior: widget.keyboardDismissBehavior, + restorationId: widget.restorationId, + dragStartBehavior: widget.dragStartBehavior, + cacheExtent: widget.cacheExtent, + clipBehavior: widget.clipBehavior, + itemCount: value.itemCount, + separatorBuilder: (context, index) => widget.separatorBuilder(context, items, index), + itemBuilder: (context, index) { + if (!_hasRequestedNextPage) { + final newPageRequestTriggerIndex = items.length - widget.loadMoreTriggerIndex; + final isBuildingTriggerIndexItem = index == newPageRequestTriggerIndex; + if (nextPageKey != null && isBuildingTriggerIndexItem) { + // Schedules the request for the end of this frame. + WidgetsBinding.instance.addPostFrameCallback((_) async { + if (error == null) { + await _controller.loadMore(nextPageKey); } - } + _hasRequestedNextPage = false; + }); + _hasRequestedNextPage = true; + } + } - if (index == items.length) { - if (error != null) { - return widget.loadMoreErrorBuilder(context, error); - } - return widget.loadMoreIndicatorBuilder(context); - } + if (index == items.length) { + if (error != null) { + return widget.loadMoreErrorBuilder(context, error); + } + return widget.loadMoreIndicatorBuilder(context); + } - return widget.itemBuilder(context, items, index); - }, - ); + return widget.itemBuilder(context, items, index); }, - loading: () => widget.loadingBuilder(context), - error: (error) => widget.errorBuilder(context, error), - ), - ); + ); + }, + loading: () => widget.loadingBuilder(context), + error: (error) => widget.errorBuilder(context, error), + ), + ); } /// A [GridView] that loads more pages when the user scrolls to the end of the @@ -367,6 +365,7 @@ class PagedValueGridView extends StatefulWidget { required this.loadingBuilder, required this.errorBuilder, this.loadMoreTriggerIndex = 3, + this.leadingItemBuilder, this.scrollDirection = Axis.vertical, this.reverse = false, this.scrollController, @@ -415,6 +414,13 @@ class PagedValueGridView extends StatefulWidget { /// The index to take into account when triggering [controller.loadMore]. final int loadMoreTriggerIndex; + /// An optional builder for a single item prepended before the paged items. + /// + /// When provided, [itemBuilder] still receives regular item indices starting + /// at 0 — the leading item is handled separately, similar to + /// [loadMoreIndicatorBuilder]. + final WidgetBuilder? leadingItemBuilder; + /// {@template flutter.widgets.scroll_view.scrollDirection} /// The axis along which the scroll view scrolls. /// @@ -616,8 +622,7 @@ class PagedValueGridView extends StatefulWidget { final Clip clipBehavior; @override - State> createState() => - _PagedValueGridViewState(); + State> createState() => _PagedValueGridViewState(); } class _PagedValueGridViewState extends State> { @@ -644,63 +649,66 @@ class _PagedValueGridViewState extends State> { @override Widget build(BuildContext context) => PagedValueListenableBuilder( - valueListenable: _controller, - builder: (context, value, _) => value.when( - (items, nextPageKey, error) { - if (items.isEmpty) { - return widget.emptyBuilder(context); + valueListenable: _controller, + builder: (context, value, _) => value.when( + (items, nextPageKey, error) { + if (items.isEmpty) { + return widget.emptyBuilder(context); + } + + return GridView.builder( + scrollDirection: widget.scrollDirection, + reverse: widget.reverse, + controller: widget.scrollController, + primary: widget.primary, + physics: widget.physics, + shrinkWrap: widget.shrinkWrap, + padding: widget.padding, + addAutomaticKeepAlives: widget.addAutomaticKeepAlives, + addRepaintBoundaries: widget.addRepaintBoundaries, + addSemanticIndexes: widget.addSemanticIndexes, + cacheExtent: widget.cacheExtent, + semanticChildCount: widget.semanticChildCount, + dragStartBehavior: widget.dragStartBehavior, + keyboardDismissBehavior: widget.keyboardDismissBehavior, + restorationId: widget.restorationId, + clipBehavior: widget.clipBehavior, + itemCount: value.itemCount + (widget.leadingItemBuilder != null ? 1 : 0), + gridDelegate: widget.gridDelegate, + itemBuilder: (context, index) { + var adjustedIndex = index; + if (widget.leadingItemBuilder != null) { + if (index == 0) return widget.leadingItemBuilder!(context); + adjustedIndex = index - 1; } - return GridView.builder( - scrollDirection: widget.scrollDirection, - reverse: widget.reverse, - controller: widget.scrollController, - primary: widget.primary, - physics: widget.physics, - shrinkWrap: widget.shrinkWrap, - padding: widget.padding, - addAutomaticKeepAlives: widget.addAutomaticKeepAlives, - addRepaintBoundaries: widget.addRepaintBoundaries, - addSemanticIndexes: widget.addSemanticIndexes, - cacheExtent: widget.cacheExtent, - semanticChildCount: widget.semanticChildCount, - dragStartBehavior: widget.dragStartBehavior, - keyboardDismissBehavior: widget.keyboardDismissBehavior, - restorationId: widget.restorationId, - clipBehavior: widget.clipBehavior, - itemCount: value.itemCount, - gridDelegate: widget.gridDelegate, - itemBuilder: (context, index) { - if (!_hasRequestedNextPage) { - final newPageRequestTriggerIndex = - items.length - widget.loadMoreTriggerIndex; - final isBuildingTriggerIndexItem = - index == newPageRequestTriggerIndex; - if (nextPageKey != null && isBuildingTriggerIndexItem) { - // Schedules the request for the end of this frame. - WidgetsBinding.instance.addPostFrameCallback((_) async { - if (error == null) { - await _controller.loadMore(nextPageKey); - } - _hasRequestedNextPage = false; - }); - _hasRequestedNextPage = true; + if (!_hasRequestedNextPage) { + final newPageRequestTriggerIndex = items.length - widget.loadMoreTriggerIndex; + if (nextPageKey != null && adjustedIndex == newPageRequestTriggerIndex) { + // Schedules the request for the end of this frame. + WidgetsBinding.instance.addPostFrameCallback((_) async { + if (error == null) { + await _controller.loadMore(nextPageKey); } - } + _hasRequestedNextPage = false; + }); + _hasRequestedNextPage = true; + } + } - if (index == items.length) { - if (error != null) { - return widget.loadMoreErrorBuilder(context, error); - } - return widget.loadMoreIndicatorBuilder(context); - } + if (adjustedIndex == items.length) { + if (error != null) { + return widget.loadMoreErrorBuilder(context, error); + } + return widget.loadMoreIndicatorBuilder(context); + } - return widget.itemBuilder(context, items, index); - }, - ); + return widget.itemBuilder(context, items, adjustedIndex); }, - loading: () => widget.loadingBuilder(context), - error: (error) => widget.errorBuilder(context, error), - ), - ); + ); + }, + loading: () => widget.loadingBuilder(context), + error: (error) => widget.errorBuilder(context, error), + ), + ); } diff --git a/packages/stream_chat_flutter_core/lib/src/stream_channel.dart b/packages/stream_chat_flutter_core/lib/src/stream_channel.dart index 77ef984bc9..ee6edd0c2b 100644 --- a/packages/stream_chat_flutter_core/lib/src/stream_channel.dart +++ b/packages/stream_chat_flutter_core/lib/src/stream_channel.dart @@ -17,11 +17,12 @@ enum QueryDirection { /// Signature used by [StreamChannel.errorBuilder] to create a replacement /// widget for an error that occurs while asynchronously building the channel. // TODO: Remove once ErrorBuilder supports passing stacktrace. -typedef ErrorWidgetBuilder = Widget Function( - BuildContext context, - Object error, - StackTrace? stackTrace, -); +typedef ErrorWidgetBuilder = + Widget Function( + BuildContext context, + Object error, + StackTrace? stackTrace, + ); Color _getDefaultBackgroundColor(BuildContext context) { final brightness = Theme.of(context).brightness; @@ -95,8 +96,7 @@ class StreamChannel extends StatefulWidget { color: backgroundColor, child: Center( child: switch (exception) { - DioException(type: DioExceptionType.badResponse) => - Text(exception.message ?? 'Bad response'), + DioException(type: DioExceptionType.badResponse) => Text(exception.message ?? 'Bad response'), DioException() => const Text('Check your connection and retry'), _ => Text(exception.toString()), }, @@ -180,8 +180,7 @@ class StreamChannelState extends State { String? get initialMessageId => widget.initialMessageId; /// Current channel state stream - Stream? get channelStateStream => - widget.channel.state?.channelStateStream; + Stream? get channelStateStream => widget.channel.state?.channelStateStream; final _queryTopMessagesController = BehaviorSubject.seeded(false); final _queryBottomMessagesController = BehaviorSubject.seeded(false); @@ -427,31 +426,30 @@ class StreamChannelState extends State { String? messageId, { int limit = 30, bool preferOffline = false, - }) => - _queryAtMessage( - messageId: messageId, - limit: limit, - preferOffline: preferOffline, - ); + }) => _queryAtMessage( + messageId: messageId, + limit: limit, + preferOffline: preferOffline, + ); /// Loads channel at specific message Future loadChannelAtTimestamp( DateTime timestamp, { int limit = 30, bool preferOffline = false, - }) => - _queryAtTimestamp( - timestamp: timestamp, - limit: limit, - preferOffline: preferOffline, - ); + }) => _queryAtTimestamp( + timestamp: timestamp, + limit: limit, + preferOffline: preferOffline, + ); // If we are jumping to a message we can determine if we loaded the oldest // page or the newest page, depending on where the aroundMessageId is located. ({ bool endOfPrependReached, bool endOfAppendReached, - }) _inferBoundariesFromAnchorId( + }) + _inferBoundariesFromAnchorId( String anchorId, List loadedMessages, ) { @@ -539,7 +537,8 @@ class StreamChannelState extends State { ({ bool endOfPrependReached, bool endOfAppendReached, - }) _inferBoundariesFromAnchorTimestamp( + }) + _inferBoundariesFromAnchorTimestamp( DateTime anchorTimestamp, List loadedMessages, ) { @@ -564,9 +563,11 @@ class StreamChannelState extends State { DateTime anchorTimestamp, List loadedMessages, ) { - final messageTimestamps = loadedMessages.map((it) { - return it.createdAt.millisecondsSinceEpoch; - }).toList(growable: false); + final messageTimestamps = loadedMessages + .map((it) { + return it.createdAt.millisecondsSinceEpoch; + }) + .toList(growable: false); return messageTimestamps.lowerBoundBy( anchorTimestamp.millisecondsSinceEpoch, @@ -820,8 +821,7 @@ class StreamChannelState extends State { @override void didUpdateWidget(StreamChannel oldWidget) { super.didUpdateWidget(oldWidget); - if (oldWidget.channel.cid != widget.channel.cid || - oldWidget.initialMessageId != widget.initialMessageId) { + if (oldWidget.channel.cid != widget.channel.cid || oldWidget.initialMessageId != widget.initialMessageId) { // Re-initialize channel if the channel CID or initial message ID changes. _channelInitFuture = [_maybeInitChannel(), channel.initialized].wait; } diff --git a/packages/stream_chat_flutter_core/lib/src/stream_channel_list_controller.dart b/packages/stream_chat_flutter_core/lib/src/stream_channel_list_controller.dart index db2ff2c4c6..bd671a0bcc 100644 --- a/packages/stream_chat_flutter_core/lib/src/stream_channel_list_controller.dart +++ b/packages/stream_chat_flutter_core/lib/src/stream_channel_list_controller.dart @@ -55,8 +55,8 @@ class StreamChannelListController extends PagedValueNotifier { this.limit = defaultChannelPagedLimit, this.messageLimit, this.memberLimit, - }) : _eventHandler = eventHandler ?? StreamChannelListEventHandler(), - super(const PagedValue.loading()); + }) : _eventHandler = eventHandler ?? StreamChannelListEventHandler(), + super(const PagedValue.loading()); /// Creates a [StreamChannelListController] from the passed [value]. StreamChannelListController.fromValue( @@ -113,14 +113,14 @@ class StreamChannelListController extends PagedValueNotifier { super.value = switch (channelStateSort) { null => newValue, final channelSort => newValue.maybeMap( - orElse: () => newValue, - (success) => success.copyWith( - items: success.items.sortedByCompare( - (it) => it.state!.channelState, - channelSort.compare, - ), + orElse: () => newValue, + (success) => success.copyWith( + items: success.items.sortedByCompare( + (it) => it.state!.channelState, + channelSort.compare, ), ), + ), }; } @@ -269,8 +269,7 @@ class StreamChannelListController extends PagedValueNotifier { _eventHandler.onNotificationMessageNew(event, this); } else if (eventType == EventType.notificationRemovedFromChannel) { _eventHandler.onNotificationRemovedFromChannel(event, this); - } else if (eventType == 'user.presence.changed' || - eventType == EventType.userUpdated) { + } else if (eventType == 'user.presence.changed' || eventType == EventType.userUpdated) { _eventHandler.onUserPresenceChanged(event, this); } else if (eventType == EventType.memberUpdated) { _eventHandler.onMemberUpdated(event, this); diff --git a/packages/stream_chat_flutter_core/lib/src/stream_channel_list_event_handler.dart b/packages/stream_chat_flutter_core/lib/src/stream_channel_list_event_handler.dart index b59d16e67c..59a21f13c4 100644 --- a/packages/stream_chat_flutter_core/lib/src/stream_channel_list_event_handler.dart +++ b/packages/stream_chat_flutter_core/lib/src/stream_channel_list_event_handler.dart @@ -171,8 +171,7 @@ mixin class StreamChannelListEventHandler { StreamChannelListController controller, ) { final channels = [...controller.currentItems]; - final updatedChannels = - channels.where((it) => it.cid != event.channel?.cid); + final updatedChannels = channels.where((it) => it.cid != event.channel?.cid); final listChanged = channels.length != updatedChannels.length; if (!listChanged) return; diff --git a/packages/stream_chat_flutter_core/lib/src/stream_chat_core.dart b/packages/stream_chat_flutter_core/lib/src/stream_chat_core.dart index 252ffee341..726ffb60fa 100644 --- a/packages/stream_chat_flutter_core/lib/src/stream_chat_core.dart +++ b/packages/stream_chat_flutter_core/lib/src/stream_chat_core.dart @@ -44,7 +44,7 @@ class StreamChatCore extends StatefulWidget { required this.client, required this.child, this.onBackgroundEventReceived, - this.backgroundKeepAlive = const Duration(minutes: 1), + this.backgroundKeepAlive = const Duration(seconds: 15), this.connectivityStream, }); @@ -137,8 +137,7 @@ class StreamChatCore extends StatefulWidget { } /// State class associated with [StreamChatCore]. -class StreamChatCoreState extends State - with WidgetsBindingObserver { +class StreamChatCoreState extends State with WidgetsBindingObserver { /// The current user User? get currentUser => client.state.currentUser; @@ -168,15 +167,13 @@ class StreamChatCoreState extends State case PlatformType.macOS: final info = await DeviceInfoPlugin().macOsInfo; - osVersion = [info.majorVersion, info.minorVersion, info.patchVersion] - .join('.'); + osVersion = [info.majorVersion, info.minorVersion, info.patchVersion].join('.'); deviceModel = info.model; break; case PlatformType.windows: final info = await DeviceInfoPlugin().windowsInfo; - osVersion = [info.majorVersion, info.minorVersion, info.buildNumber] - .join('.'); + osVersion = [info.majorVersion, info.minorVersion, info.buildNumber].join('.'); deviceModel = null; break; @@ -226,6 +223,9 @@ class StreamChatCoreState extends State WidgetsBinding.instance.addObserver(this); _subscribeToConnectivityChange(widget.connectivityStream); + // Disable the client-level state recovery on reconnect since we handle it via the list controllers. + client.recoverStateOnReconnect = false; + // Update the client system environment. unawaited(_getSystemEnvironment.then(client.updateSystemEnvironment)); } @@ -256,6 +256,9 @@ class StreamChatCoreState extends State @override void didUpdateWidget(StreamChatCore oldWidget) { super.didUpdateWidget(oldWidget); + if (widget.client != oldWidget.client) { + widget.client.recoverStateOnReconnect = false; + } final connectivityStream = widget.connectivityStream; if (connectivityStream != oldWidget.connectivityStream) { _unsubscribeFromConnectivityChange(); @@ -284,7 +287,7 @@ final class _ChatLifecycleManager { _ChatLifecycleManager({ required this.client, this.onBackgroundEvent, - this.backgroundKeepAlive = const Duration(minutes: 1), + this.backgroundKeepAlive = const Duration(seconds: 15), }); final StreamChatClient client; @@ -314,21 +317,16 @@ final class _ChatLifecycleManager { return client.maybeReconnect().ignore(); } - void _onBackground() { - _cancelBackgroundTimer(); - - final handler = onBackgroundEvent; - if (handler == null) return client.maybeDisconnect(); - - return _startBackgroundEventListening(handler); - } - Timer? _backgroundTimer; StreamSubscription? _eventSubscription; - void _startBackgroundEventListening(EventHandler handler) { + void _onBackground() { + _cancelBackgroundTimer(); _cancelEventSubscription(); - _eventSubscription = client.on().listen(handler); + + if (onBackgroundEvent case final handler?) { + _eventSubscription = client.on().listen(handler); + } _backgroundTimer = Timer(backgroundKeepAlive, () { _cancelEventSubscription(); diff --git a/packages/stream_chat_flutter_core/lib/src/stream_draft_list_controller.dart b/packages/stream_chat_flutter_core/lib/src/stream_draft_list_controller.dart index 8845ec93f1..cebe6c965a 100644 --- a/packages/stream_chat_flutter_core/lib/src/stream_draft_list_controller.dart +++ b/packages/stream_chat_flutter_core/lib/src/stream_draft_list_controller.dart @@ -34,10 +34,10 @@ class StreamDraftListController extends PagedValueNotifier { this.filter, this.sort = defaultDraftListSort, this.limit = defaultDraftPagedLimit, - }) : _activeFilter = filter, - _activeSort = sort, - _eventHandler = eventHandler ?? StreamDraftListEventHandler(), - super(const PagedValue.loading()); + }) : _activeFilter = filter, + _activeSort = sort, + _eventHandler = eventHandler ?? StreamDraftListEventHandler(), + super(const PagedValue.loading()); /// Creates a [StreamThreadListController] from the passed [value]. StreamDraftListController.fromValue( @@ -47,9 +47,9 @@ class StreamDraftListController extends PagedValueNotifier { this.filter, this.sort = defaultDraftListSort, this.limit = defaultDraftPagedLimit, - }) : _activeFilter = filter, - _activeSort = sort, - _eventHandler = eventHandler ?? StreamDraftListEventHandler(); + }) : _activeFilter = filter, + _activeSort = sort, + _eventHandler = eventHandler ?? StreamDraftListEventHandler(); /// The Stream client used to perform the queries. final StreamChatClient client; @@ -94,11 +94,11 @@ class StreamDraftListController extends PagedValueNotifier { super.value = switch (_activeSort) { null => newValue, final draftSort => newValue.maybeMap( - orElse: () => newValue, - (success) => success.copyWith( - items: success.items.sorted(draftSort.compare), - ), + orElse: () => newValue, + (success) => success.copyWith( + items: success.items.sorted(draftSort.compare), ), + ), }; } diff --git a/packages/stream_chat_flutter_core/lib/src/stream_member_list_controller.dart b/packages/stream_chat_flutter_core/lib/src/stream_member_list_controller.dart index 16185685d9..0bc07a4c43 100644 --- a/packages/stream_chat_flutter_core/lib/src/stream_member_list_controller.dart +++ b/packages/stream_chat_flutter_core/lib/src/stream_member_list_controller.dart @@ -36,9 +36,9 @@ class StreamMemberListController extends PagedValueNotifier { this.filter, this.sort = defaultMemberListSort, this.limit = defaultMemberPagedLimit, - }) : _activeFilter = filter, - _activeSort = sort, - super(const PagedValue.loading()); + }) : _activeFilter = filter, + _activeSort = sort, + super(const PagedValue.loading()); /// Creates a [StreamMemberListController] from the passed [value]. StreamMemberListController.fromValue( @@ -47,8 +47,8 @@ class StreamMemberListController extends PagedValueNotifier { this.filter, this.sort = defaultMemberListSort, this.limit = defaultMemberPagedLimit, - }) : _activeFilter = filter, - _activeSort = sort; + }) : _activeFilter = filter, + _activeSort = sort; /// The client to use for the channels list. final Channel channel; @@ -97,11 +97,11 @@ class StreamMemberListController extends PagedValueNotifier { super.value = switch (_activeSort) { null => newValue, final memberSort => newValue.maybeMap( - orElse: () => newValue, - (success) => success.copyWith( - items: success.items.sorted(memberSort.compare), - ), + orElse: () => newValue, + (success) => success.copyWith( + items: success.items.sorted(memberSort.compare), ), + ), }; } diff --git a/packages/stream_chat_flutter_core/lib/src/stream_message_input_controller.dart b/packages/stream_chat_flutter_core/lib/src/stream_message_composer_controller.dart similarity index 57% rename from packages/stream_chat_flutter_core/lib/src/stream_message_input_controller.dart rename to packages/stream_chat_flutter_core/lib/src/stream_message_composer_controller.dart index 478fdacce7..015b4a5738 100644 --- a/packages/stream_chat_flutter_core/lib/src/stream_message_input_controller.dart +++ b/packages/stream_chat_flutter_core/lib/src/stream_message_composer_controller.dart @@ -9,56 +9,58 @@ import 'package:stream_chat_flutter_core/src/message_text_field_controller.dart' /// A value listenable builder related to a [Message]. /// -/// Pass in a [StreamMessageInputController] as the `valueListenable`. +/// Pass in a [StreamMessageComposerController] as the `valueListenable`. typedef StreamMessageValueListenableBuilder = ValueListenableBuilder; -/// {@template stream_chat_flutter.StreamMessageInputController} +/// {@template stream_chat_flutter.StreamMessageComposerController} /// Controller for storing and mutating a [Message] value. /// {@endtemplate} -class StreamMessageInputController extends ValueNotifier { +class StreamMessageComposerController extends ValueNotifier { /// Creates a controller for an editable text field. /// /// This constructor treats a null [message] argument as if it were the empty /// message. - factory StreamMessageInputController({ + factory StreamMessageComposerController({ Message? message, Map? textPatternStyle, - }) => - StreamMessageInputController._( - initialMessage: message ?? Message(), - textPatternStyle: textPatternStyle, - ); + }) => StreamMessageComposerController._( + initialMessage: message ?? Message(), + textPatternStyle: textPatternStyle, + ); /// Creates a controller for an editable text field from an initial [text]. - factory StreamMessageInputController.fromText( + factory StreamMessageComposerController.fromText( String? text, { Map? textPatternStyle, - }) => - StreamMessageInputController._( - initialMessage: Message(text: text), - textPatternStyle: textPatternStyle, - ); + }) => StreamMessageComposerController._( + initialMessage: Message(text: text), + textPatternStyle: textPatternStyle, + ); /// Creates a controller for an editable text field from initial /// [attachments]. - factory StreamMessageInputController.fromAttachments( + factory StreamMessageComposerController.fromAttachments( List attachments, { Map? textPatternStyle, - }) => - StreamMessageInputController._( - initialMessage: Message(attachments: attachments), - textPatternStyle: textPatternStyle, - ); + }) => StreamMessageComposerController._( + initialMessage: Message(attachments: attachments), + textPatternStyle: textPatternStyle, + ); - StreamMessageInputController._({ + StreamMessageComposerController._({ required Message initialMessage, Map? textPatternStyle, - }) : _initialMessage = initialMessage, - _textFieldController = MessageTextFieldController.fromValue( - _textEditingValueFromMessage(initialMessage), - textPatternStyle: textPatternStyle, - ), - super(initialMessage) { + }) : assert( + initialMessage.state.isInitial, + 'Controllers must be created with an initial (draft) message. ' + 'Call editMessage() to enter edit mode on an existing message.', + ), + _initialMessage = initialMessage, + _textFieldController = MessageTextFieldController.fromValue( + _textEditingValueFromMessage(initialMessage), + textPatternStyle: textPatternStyle, + ), + super(initialMessage) { _textFieldController.addListener(_textFieldListener); } @@ -184,9 +186,23 @@ class StreamMessageInputController extends ValueNotifier { ); } - /// Sets a command for the message. - set command(String command) { - // Setting the command should also clear the text and attachments. + // Snapshot of the composer message taken when [command] is first set, so + // [clearCommand] can restore the user's content. + Message? _messageBeforeCommand; + + /// Sets a command on the message. + /// + /// Replaces the composer's content with an empty message tagged with + /// [command] so the UI can reflect command mode. Call [clearCommand] to + /// exit command mode and restore the composer to the content it had + /// before. Passing `null` is equivalent to calling [clearCommand]. + /// + /// Safe to call repeatedly during an active command; [clearCommand] still + /// restores the content that was in the composer before the first call. + set command(String? command) { + if (command == null) return clearCommand(); + _messageBeforeCommand ??= message; + message = message.copyWith( text: '', attachments: [], @@ -194,6 +210,63 @@ class StreamMessageInputController extends ValueNotifier { ); } + /// Clears the active command and restores the composer to the content it + /// had before [command] was set. + /// + /// No-op if there is no active command. + void clearCommand() { + if (_messageBeforeCommand case final message?) { + this.message = message; + _messageBeforeCommand = null; + } + } + + /// Whether the controller is currently in edit mode. + /// + /// Equivalent to `messageBeingEdited != null`. + bool get isEditing => _messageBeingEdited != null; + + /// The message currently being edited, unmodified by the user's changes. + /// + /// Set by [editMessage] and cleared by [cancelEditMessage]. Use this to + /// display a stable preview of the original message while the user is + /// typing their edits. + Message? get messageBeingEdited => _messageBeingEdited; + Message? _messageBeingEdited; + + // Snapshot of the composer message taken when [editMessage] is first called, + // so [cancelEditMessage] can restore the user's draft. + Message? _messageBeforeEdit; + + /// Switches the controller to edit mode for the given [message]. + /// + /// Replaces the composer's content with [message] and exposes it via + /// [messageBeingEdited] so the UI can show a preview of the message being + /// edited. Call [cancelEditMessage] to exit edit mode and restore the + /// composer to the content it had before. + /// + /// Safe to call repeatedly during an active edit (e.g. when a newer + /// version of the same message arrives); [cancelEditMessage] still + /// restores the content that was in the composer before the first call. + void editMessage(Message message) { + _messageBeforeEdit ??= this.message; + _messageBeingEdited = message; + + this.message = message.copyWith(state: MessageState.updating); + } + + /// Cancels the active edit and restores the composer to the content it + /// had before [editMessage] was called. + /// + /// No-op if there is no active edit. + void cancelEditMessage() { + _messageBeingEdited = null; + if (_messageBeforeEdit case final message?) { + this.message = message; + _messageBeforeEdit = null; + } + } + /// Sets the [showInChannel] flag of the message. set showInChannel(bool newValue) { message = message.copyWith(showInChannel: newValue); @@ -307,19 +380,29 @@ class StreamMessageInputController extends ValueNotifier { /// Sets the [message], to empty. /// /// After calling this function, [text], [attachments] and [mentionedUsers] - /// will all be empty. + /// will all be empty, and any active command is dropped. Any active edit + /// session is preserved — use [cancelEditMessage] to exit edit mode. /// /// Calling this will notify all the listeners of this - /// [StreamMessageInputController] that they need to update + /// [StreamMessageComposerController] that they need to update /// (calls [notifyListeners]). For this reason, /// this method should only be called between frames, e.g. in response to user /// actions, not during the build, layout, or paint phases. void clear() { + // Clear the command state, if any. + _messageBeforeCommand = null; message = Message(); } /// Sets the [message] to the initial [Message] value. void reset({bool resetId = true}) { + // Reset the edit state, if any. + _messageBeingEdited = null; + _messageBeforeEdit = null; + + // Reset the command state, if any. + _messageBeforeCommand = null; + if (resetId) { final newId = const Uuid().v4(); _initialMessage = _initialMessage.copyWith(id: newId); @@ -340,41 +423,75 @@ class StreamMessageInputController extends ValueNotifier { } /// A [RestorableProperty] that knows how to store and restore a -/// [StreamMessageInputController]. +/// [StreamMessageComposerController]. /// -/// The [StreamMessageInputController] is accessible via the [value] getter. +/// The [StreamMessageComposerController] is accessible via the [value] getter. /// During state restoration, -/// the property will restore [StreamMessageInputController.message] +/// the property will restore [StreamMessageComposerController.message] /// to the value it had when the restoration data it is getting restored from /// was collected. -class StreamRestorableMessageInputController - extends RestorableChangeNotifier { - /// Creates a [StreamRestorableMessageInputController]. +class StreamRestorableMessageComposerController extends RestorableChangeNotifier { + /// Creates a [StreamRestorableMessageComposerController]. /// /// This constructor creates a default [Message] when no `message` argument /// is supplied. - StreamRestorableMessageInputController({Message? message}) - : _initialValue = message ?? Message(); + StreamRestorableMessageComposerController({Message? message}) : _initialValue = message ?? Message(); - /// Creates a [StreamRestorableMessageInputController] from an initial + /// Creates a [StreamRestorableMessageComposerController] from an initial /// [text] value. - factory StreamRestorableMessageInputController.fromText(String? text) => - StreamRestorableMessageInputController(message: Message(text: text)); + factory StreamRestorableMessageComposerController.fromText(String? text) => + StreamRestorableMessageComposerController(message: Message(text: text)); final Message _initialValue; @override - StreamMessageInputController createDefaultValue() => - StreamMessageInputController(message: _initialValue); + StreamMessageComposerController createDefaultValue() => StreamMessageComposerController(message: _initialValue); @override - StreamMessageInputController fromPrimitives(Object? data) { - final message = Message.fromJson(json.decode(data! as String)); - return StreamMessageInputController(message: message); + StreamMessageComposerController fromPrimitives(Object? data) { + final restoredData = json.decode(data! as String) as Map; + + final currentMessage = Message.fromJson(restoredData['message'] as Map); + + final messageBeingEditedJson = restoredData['message_being_edited']; + if (messageBeingEditedJson != null) { + // Restore edit mode: construct the controller with the pre-edit draft, + // call editMessage() to re-enter edit mode, then overlay the current + // (possibly user-modified) composed text. + final messageBeingEdited = Message.fromJson(messageBeingEditedJson as Map); + final messageBeforeEdit = Message.fromJson( + restoredData['message_before_edit'] as Map, + ); + + final controller = + StreamMessageComposerController( + message: messageBeforeEdit.copyWith(state: const MessageState.initial()), + ) + ..editMessage(messageBeingEdited) + // Restore any edits the user made while in edit mode. + ..message = currentMessage; + return controller; + } + + // Normal draft: reset state to initial (the controller invariant requires + // it; any non-initial state serialized here is unrecoverable anyway). + return StreamMessageComposerController( + message: currentMessage.copyWith(state: const MessageState.initial()), + ); } @override - String toPrimitives() => json.encode(value.message); + String toPrimitives() { + final controller = value; + final data = { + 'message': controller.message.toJson(), + }; + if (controller.isEditing) { + data['message_being_edited'] = controller.messageBeingEdited!.toJson(); + data['message_before_edit'] = controller._messageBeforeEdit!.toJson(); + } + return json.encode(data); + } } Timer _setPeriodicTimer( diff --git a/packages/stream_chat_flutter_core/lib/src/stream_message_reminder_list_controller.dart b/packages/stream_chat_flutter_core/lib/src/stream_message_reminder_list_controller.dart index a2fa4f156e..242bd9c4b1 100644 --- a/packages/stream_chat_flutter_core/lib/src/stream_message_reminder_list_controller.dart +++ b/packages/stream_chat_flutter_core/lib/src/stream_message_reminder_list_controller.dart @@ -29,8 +29,7 @@ const _kDefaultBackendPaginationLimit = 30; /// This controller is typically used in conjunction with UI components /// to display and interact with a list of message reminders. /// {@endtemplate} -class StreamMessageReminderListController - extends PagedValueNotifier { +class StreamMessageReminderListController extends PagedValueNotifier { /// {@macro streamMessageReminderListController} StreamMessageReminderListController({ required this.client, @@ -38,10 +37,10 @@ class StreamMessageReminderListController this.filter, this.sort = defaultMessageReminderListSort, this.limit = defaultMessageReminderPagedLimit, - }) : _activeFilter = filter, - _activeSort = sort, - _eventHandler = eventHandler ?? StreamMessageReminderListEventHandler(), - super(const PagedValue.loading()); + }) : _activeFilter = filter, + _activeSort = sort, + _eventHandler = eventHandler ?? StreamMessageReminderListEventHandler(), + super(const PagedValue.loading()); /// Creates a [StreamMessageReminderListController] from the passed [value]. StreamMessageReminderListController.fromValue( @@ -51,9 +50,9 @@ class StreamMessageReminderListController this.filter, this.sort = defaultMessageReminderListSort, this.limit = defaultMessageReminderPagedLimit, - }) : _activeFilter = filter, - _activeSort = sort, - _eventHandler = eventHandler ?? StreamMessageReminderListEventHandler(); + }) : _activeFilter = filter, + _activeSort = sort, + _eventHandler = eventHandler ?? StreamMessageReminderListEventHandler(); /// The Stream client used to perform the queries. final StreamChatClient client; @@ -98,11 +97,11 @@ class StreamMessageReminderListController super.value = switch (_activeSort) { null => newValue, final reminderSort => newValue.maybeMap( - orElse: () => newValue, - (success) => success.copyWith( - items: success.items.sorted(reminderSort.compare), - ), + orElse: () => newValue, + (success) => success.copyWith( + items: success.items.sorted(reminderSort.compare), ), + ), }; } diff --git a/packages/stream_chat_flutter_core/lib/src/stream_message_search_list_controller.dart b/packages/stream_chat_flutter_core/lib/src/stream_message_search_list_controller.dart index 5f8b369c70..3666f7d0f4 100644 --- a/packages/stream_chat_flutter_core/lib/src/stream_message_search_list_controller.dart +++ b/packages/stream_chat_flutter_core/lib/src/stream_message_search_list_controller.dart @@ -15,8 +15,7 @@ const _kDefaultBackendPaginationLimit = 30; /// * Load initial data. /// * Load more data using [loadMore]. /// * Replace the previously loaded users. -class StreamMessageSearchListController - extends PagedValueNotifier { +class StreamMessageSearchListController extends PagedValueNotifier { /// Creates a Stream user list controller. /// /// * `client` is the Stream chat client to use for the channels list. @@ -36,19 +35,19 @@ class StreamMessageSearchListController this.searchQuery, this.sort, this.limit = defaultMessageSearchPagedLimit, - }) : assert( - messageFilter != null || searchQuery != null, - 'Either messageFilter or searchQuery must be provided', - ), - assert( - messageFilter == null || searchQuery == null, - 'Only one of messageFilter or searchQuery can be provided', - ), - _activeFilter = filter, - _activeMessageFilter = messageFilter, - _activeSearchQuery = searchQuery, - _activeSort = sort, - super(const PagedValue.loading()); + }) : assert( + messageFilter != null || searchQuery != null, + 'Either messageFilter or searchQuery must be provided', + ), + assert( + messageFilter == null || searchQuery == null, + 'Only one of messageFilter or searchQuery can be provided', + ), + _activeFilter = filter, + _activeMessageFilter = messageFilter, + _activeSearchQuery = searchQuery, + _activeSort = sort, + super(const PagedValue.loading()); /// Creates a [StreamUserListController] from the passed [value]. StreamMessageSearchListController.fromValue( @@ -59,18 +58,18 @@ class StreamMessageSearchListController this.searchQuery, this.sort, this.limit = defaultMessageSearchPagedLimit, - }) : assert( - messageFilter != null || searchQuery != null, - 'Either messageFilter or searchQuery must be provided', - ), - assert( - messageFilter == null || searchQuery == null, - 'Only one of messageFilter or searchQuery can be provided', - ), - _activeFilter = filter, - _activeMessageFilter = messageFilter, - _activeSearchQuery = searchQuery, - _activeSort = sort; + }) : assert( + messageFilter != null || searchQuery != null, + 'Either messageFilter or searchQuery must be provided', + ), + assert( + messageFilter == null || searchQuery == null, + 'Only one of messageFilter or searchQuery can be provided', + ), + _activeFilter = filter, + _activeMessageFilter = messageFilter, + _activeSearchQuery = searchQuery, + _activeSort = sort; /// The client to use for the channels list. final StreamChatClient client; diff --git a/packages/stream_chat_flutter_core/lib/src/stream_poll_controller.dart b/packages/stream_chat_flutter_core/lib/src/stream_poll_controller.dart index 749fb77e0e..216ae0d3f0 100644 --- a/packages/stream_chat_flutter_core/lib/src/stream_poll_controller.dart +++ b/packages/stream_chat_flutter_core/lib/src/stream_poll_controller.dart @@ -16,7 +16,7 @@ class PollConfig { /// {@macro pollConfig} const PollConfig({ this.nameRange = const (min: 1, max: 80), - this.optionsRange = const (min: 1, max: 10), + this.optionsRange = const (min: 1, max: null), this.allowDuplicateOptions = false, this.allowedVotesRange = const (min: 2, max: 10), }); @@ -30,7 +30,7 @@ class PollConfig { /// The minimum and maximum length of the poll options. /// if `null`, there is no limit to the length of the options. /// - /// Defaults to `2` and `10`. + /// Defaults to `1` and `null`. final Range? optionsRange; /// Whether the poll allows duplicate options. @@ -53,11 +53,14 @@ class StreamPollController extends ValueNotifier { factory StreamPollController({ Poll? poll, PollConfig? config, - }) => - StreamPollController._( - config ?? const PollConfig(), - poll ?? Poll(name: '', options: const [PollOption(text: '')]), - ); + }) => StreamPollController._( + config ?? const PollConfig(), + poll ?? + Poll( + name: '', + options: const [PollOption(text: '')], + ), + ); StreamPollController._(this.config, super.poll) : _initialValue = poll; @@ -84,7 +87,7 @@ class StreamPollController extends ValueNotifier { // Remove the id from the new added options. return option.copyWith(id: null); - }) + }), ], ); } @@ -122,8 +125,7 @@ class StreamPollController extends ValueNotifier { final name = value.name; final (:min, :max) = nameRange; - if (min != null && name.length < min || - max != null && name.length > max) { + if (min != null && name.length < min || max != null && name.length > max) { invalidErrors.add( PollValidationError.nameRange(name, range: nameRange), ); @@ -147,8 +149,7 @@ class StreamPollController extends ValueNotifier { final nonEmptyOptions = [...options.where((it) => it.text.isNotEmpty)]; final (:min, :max) = optionsRange; - if (min != null && nonEmptyOptions.length < min || - max != null && nonEmptyOptions.length > max) { + if (min != null && nonEmptyOptions.length < min || max != null && nonEmptyOptions.length > max) { invalidErrors.add( PollValidationError.optionsRange(options, range: optionsRange), ); @@ -161,8 +162,7 @@ class StreamPollController extends ValueNotifier { if (config.allowedVotesRange case final allowedVotesRange?) { final (:min, :max) = allowedVotesRange; - if (min != null && maxVotesAllowed < min || - max != null && maxVotesAllowed > max) { + if (min != null && maxVotesAllowed < min || max != null && maxVotesAllowed > max) { invalidErrors.add( PollValidationError.maxVotesAllowed( maxVotesAllowed, @@ -292,19 +292,15 @@ extension PollValidationErrorPatternMatching on PollValidationError { TResult when({ required TResult Function(List options) duplicateOptions, required TResult Function(String name, Range range) nameRange, - required TResult Function(List options, Range range) - optionsRange, - required TResult Function(int maxVotesAllowed, Range range) - maxVotesAllowed, + required TResult Function(List options, Range range) optionsRange, + required TResult Function(int maxVotesAllowed, Range range) maxVotesAllowed, }) { final error = this; return switch (error) { _PollValidationErrorDuplicateOptions() => duplicateOptions(error.options), _PollValidationErrorNameRange() => nameRange(error.name, error.range), - _PollValidationErrorOptionsRange() => - optionsRange(error.options, error.range), - _PollValidationErrorMaxVotesAllowed() => - maxVotesAllowed(error.maxVotesAllowed, error.range), + _PollValidationErrorOptionsRange() => optionsRange(error.options, error.range), + _PollValidationErrorMaxVotesAllowed() => maxVotesAllowed(error.maxVotesAllowed, error.range), }; } @@ -318,14 +314,10 @@ extension PollValidationErrorPatternMatching on PollValidationError { }) { final error = this; return switch (error) { - _PollValidationErrorDuplicateOptions() => - duplicateOptions?.call(error.options), - _PollValidationErrorNameRange() => - nameRange?.call(error.name, error.range), - _PollValidationErrorOptionsRange() => - optionsRange?.call(error.options, error.range), - _PollValidationErrorMaxVotesAllowed() => - maxVotesAllowed?.call(error.maxVotesAllowed, error.range), + _PollValidationErrorDuplicateOptions() => duplicateOptions?.call(error.options), + _PollValidationErrorNameRange() => nameRange?.call(error.name, error.range), + _PollValidationErrorOptionsRange() => optionsRange?.call(error.options, error.range), + _PollValidationErrorMaxVotesAllowed() => maxVotesAllowed?.call(error.maxVotesAllowed, error.range), }; } @@ -340,14 +332,10 @@ extension PollValidationErrorPatternMatching on PollValidationError { }) { final error = this; final result = switch (error) { - _PollValidationErrorDuplicateOptions() => - duplicateOptions?.call(error.options), - _PollValidationErrorNameRange() => - nameRange?.call(error.name, error.range), - _PollValidationErrorOptionsRange() => - optionsRange?.call(error.options, error.range), - _PollValidationErrorMaxVotesAllowed() => - maxVotesAllowed?.call(error.maxVotesAllowed, error.range), + _PollValidationErrorDuplicateOptions() => duplicateOptions?.call(error.options), + _PollValidationErrorNameRange() => nameRange?.call(error.name, error.range), + _PollValidationErrorOptionsRange() => optionsRange?.call(error.options, error.range), + _PollValidationErrorMaxVotesAllowed() => maxVotesAllowed?.call(error.maxVotesAllowed, error.range), }; return result ?? orElse(); @@ -356,13 +344,10 @@ extension PollValidationErrorPatternMatching on PollValidationError { /// @nodoc @optionalTypeArgs TResult map({ - required TResult Function(_PollValidationErrorDuplicateOptions value) - duplicateOptions, + required TResult Function(_PollValidationErrorDuplicateOptions value) duplicateOptions, required TResult Function(_PollValidationErrorNameRange value) nameRange, - required TResult Function(_PollValidationErrorOptionsRange value) - optionsRange, - required TResult Function(_PollValidationErrorMaxVotesAllowed value) - maxVotesAllowed, + required TResult Function(_PollValidationErrorOptionsRange value) optionsRange, + required TResult Function(_PollValidationErrorMaxVotesAllowed value) maxVotesAllowed, }) { final error = this; return switch (error) { @@ -376,12 +361,10 @@ extension PollValidationErrorPatternMatching on PollValidationError { /// @nodoc @optionalTypeArgs TResult? mapOrNull({ - TResult? Function(_PollValidationErrorDuplicateOptions value)? - duplicateOptions, + TResult? Function(_PollValidationErrorDuplicateOptions value)? duplicateOptions, TResult? Function(_PollValidationErrorNameRange value)? nameRange, TResult? Function(_PollValidationErrorOptionsRange value)? optionsRange, - TResult? Function(_PollValidationErrorMaxVotesAllowed value)? - maxVotesAllowed, + TResult? Function(_PollValidationErrorMaxVotesAllowed value)? maxVotesAllowed, }) { final error = this; return switch (error) { @@ -395,12 +378,10 @@ extension PollValidationErrorPatternMatching on PollValidationError { /// @nodoc @optionalTypeArgs TResult maybeMap({ - TResult Function(_PollValidationErrorDuplicateOptions value)? - duplicateOptions, + TResult Function(_PollValidationErrorDuplicateOptions value)? duplicateOptions, TResult Function(_PollValidationErrorNameRange value)? nameRange, TResult Function(_PollValidationErrorOptionsRange value)? optionsRange, - TResult Function(_PollValidationErrorMaxVotesAllowed value)? - maxVotesAllowed, + TResult Function(_PollValidationErrorMaxVotesAllowed value)? maxVotesAllowed, required TResult orElse(), }) { final error = this; diff --git a/packages/stream_chat_flutter_core/lib/src/stream_poll_vote_list_controller.dart b/packages/stream_chat_flutter_core/lib/src/stream_poll_vote_list_controller.dart index 3b29d687dc..a55d48fbd5 100644 --- a/packages/stream_chat_flutter_core/lib/src/stream_poll_vote_list_controller.dart +++ b/packages/stream_chat_flutter_core/lib/src/stream_poll_vote_list_controller.dart @@ -21,8 +21,7 @@ const _kDefaultBackendPaginationLimit = 30; /// * Load initial data. /// * Load more data using [loadMore]. /// * Replace the previously loaded poll votes. -class StreamPollVoteListController - extends PagedValueNotifier { +class StreamPollVoteListController extends PagedValueNotifier { /// Creates a Stream poll vote list controller. /// * `channel` is the Stream chat channel to use for the poll votes list. /// * `pollId` is the poll id to use for the poll votes list. @@ -36,10 +35,10 @@ class StreamPollVoteListController this.filter, this.sort = defaultPollVoteListSort, this.limit = defaultPollVotePagedLimit, - }) : _eventHandler = eventHandler ?? StreamPollVoteEventHandler(), - _activeFilter = filter, - _activeSort = sort, - super(const PagedValue.loading()); + }) : _eventHandler = eventHandler ?? StreamPollVoteEventHandler(), + _activeFilter = filter, + _activeSort = sort, + super(const PagedValue.loading()); /// Creates a [StreamPollVoteListController] from the passed [value]. StreamPollVoteListController.fromValue( @@ -50,9 +49,9 @@ class StreamPollVoteListController this.filter, this.sort = defaultPollVoteListSort, this.limit = defaultPollVotePagedLimit, - }) : _eventHandler = eventHandler ?? StreamPollVoteEventHandler(), - _activeFilter = filter, - _activeSort = sort; + }) : _eventHandler = eventHandler ?? StreamPollVoteEventHandler(), + _activeFilter = filter, + _activeSort = sort; /// The channel to use for the poll votes list. final Channel channel; @@ -106,11 +105,11 @@ class StreamPollVoteListController super.value = switch (_activeSort) { null => newValue, final pollVoteSort => newValue.maybeMap( - orElse: () => newValue, - (success) => success.copyWith( - items: success.items.sorted(pollVoteSort.compare), - ), + orElse: () => newValue, + (success) => success.copyWith( + items: success.items.sorted(pollVoteSort.compare), ), + ), }; } @@ -216,13 +215,11 @@ class StreamPollVoteListController if (eventListener?.call(event) ?? false) return; final eventType = event.type; - if (eventType == EventType.pollVoteCasted || - eventType == EventType.pollAnswerCasted) { + if (eventType == EventType.pollVoteCasted || eventType == EventType.pollAnswerCasted) { _eventHandler.onPollVoteCasted(event, this); } else if (eventType == EventType.pollVoteChanged) { _eventHandler.onPollVoteChanged(event, this); - } else if (eventType == EventType.pollVoteRemoved || - eventType == EventType.pollAnswerRemoved) { + } else if (eventType == EventType.pollVoteRemoved || eventType == EventType.pollAnswerRemoved) { _eventHandler.onPollVoteRemoved(event, this); } }); diff --git a/packages/stream_chat_flutter_core/lib/src/stream_reaction_list_controller.dart b/packages/stream_chat_flutter_core/lib/src/stream_reaction_list_controller.dart new file mode 100644 index 0000000000..2e7dc0dda6 --- /dev/null +++ b/packages/stream_chat_flutter_core/lib/src/stream_reaction_list_controller.dart @@ -0,0 +1,182 @@ +import 'dart:async'; +import 'dart:math'; + +import 'package:collection/collection.dart'; +import 'package:stream_chat/stream_chat.dart' hide Success; +import 'package:stream_chat_flutter_core/src/paged_value_notifier.dart'; + +/// The default reaction list page limit to load. +const defaultReactionPagedLimit = 25; + +const _kDefaultBackendPaginationLimit = 30; + +/// {@template streamReactionListController} +/// A controller for managing and displaying a paginated list of reactions. +/// +/// The `StreamReactionListController` extends [PagedValueNotifier] to handle +/// paginated data for reactions. It provides functionality for querying +/// reactions and managing filters and sorting. +/// +/// This controller uses cursor-based pagination via the `queryReactions` API, +/// which supports filtering by reaction type, user ID, or creation date. +/// +/// This controller is typically used in conjunction with UI components +/// to display and interact with a list of reactions for a message. +/// {@endtemplate} +class StreamReactionListController extends PagedValueNotifier { + /// {@macro streamReactionListController} + StreamReactionListController({ + required this.client, + required this.messageId, + this.filter, + this.sort, + this.limit = defaultReactionPagedLimit, + }) : _activeFilter = filter, + _activeSort = sort, + super(const PagedValue.loading()); + + /// Creates a [StreamReactionListController] from the passed [value]. + StreamReactionListController.fromValue( + super.value, { + required this.client, + required this.messageId, + this.filter, + this.sort, + this.limit = defaultReactionPagedLimit, + }) : _activeFilter = filter, + _activeSort = sort; + + /// The Stream chat client used to query reactions. + final StreamChatClient client; + + /// The ID of the message whose reactions are being listed. + final String messageId; + + /// The query filters to use. + /// + /// Supported filter fields: `type`, `user_id`, `created_at`. + final Filter? filter; + Filter? _activeFilter; + + /// The sorting used for the reactions matching the filters. + /// + /// Sorting is based on field and direction. The only backend-supported sort + /// field is `created_at` (see [ReactionSortKey]). + /// + /// Direction can be ascending or descending. + final SortOrder? sort; + SortOrder? _activeSort; + + /// The limit to apply to the reaction list. + /// + /// The default is set to [defaultReactionPagedLimit]. + final int limit; + + /// Allows for the change of filters used for reaction queries. + /// + /// Use this if you need to support runtime filter changes, + /// such as switching between reaction type tabs. + /// + /// Note: This will not trigger a new query. Make sure to call + /// [doInitialLoad] or [refresh] after setting a new filter. + set filter(Filter? value) => _activeFilter = value; + + /// Allows for the change of the query sort used for reaction queries. + /// + /// Use this if you need to support runtime sort changes, + /// through custom sort UI. + /// + /// Note: This will not trigger a new query. Make sure to call + /// [doInitialLoad] or [refresh] after setting a new sort. + set sort(SortOrder? value) => _activeSort = value; + + @override + set value(PagedValue newValue) { + super.value = switch (_activeSort) { + null => newValue, + final reactionSort => newValue.maybeMap( + orElse: () => newValue, + (success) => success.copyWith( + items: success.items.sorted(reactionSort.compare), + ), + ), + }; + } + + @override + Future doInitialLoad() async { + final limit = min( + this.limit * defaultInitialPagedLimitMultiplier, + _kDefaultBackendPaginationLimit, + ); + try { + final response = await client.queryReactions( + messageId, + filter: _activeFilter, + sort: _activeSort, + pagination: PaginationParams(limit: limit), + ); + + final reactions = response.reactions; + final next = response.next; + final nextKey = next != null && next.isNotEmpty ? next : null; + value = PagedValue( + items: reactions, + nextPageKey: nextKey, + ); + } on StreamChatError catch (error) { + value = PagedValue.error(error); + } catch (error) { + final chatError = StreamChatError(error.toString()); + value = PagedValue.error(chatError); + } + } + + @override + Future loadMore(String? nextPageKey) async { + final previousValue = value.asSuccess; + + try { + final response = await client.queryReactions( + messageId, + filter: _activeFilter, + sort: _activeSort, + pagination: PaginationParams(limit: limit, next: nextPageKey), + ); + + final reactions = response.reactions; + final previousItems = previousValue.items; + final newItems = previousItems + reactions; + final next = response.next; + final nextKey = next != null && next.isNotEmpty ? next : null; + value = PagedValue( + items: newItems, + nextPageKey: nextKey, + ); + } on StreamChatError catch (error) { + value = previousValue.copyWith(error: error); + } catch (error) { + final chatError = StreamChatError(error.toString()); + value = previousValue.copyWith(error: chatError); + } + } + + @override + Future refresh({bool resetValue = true}) { + if (resetValue) { + _activeFilter = filter; + _activeSort = sort; + } + return super.refresh(resetValue: resetValue); + } + + /// Replaces the previously loaded reactions with [reactions]. + set reactions(List reactions) { + if (value.isSuccess) { + final currentValue = value.asSuccess; + value = currentValue.copyWith(items: reactions); + } else { + value = PagedValue(items: reactions); + } + } +} diff --git a/packages/stream_chat_flutter_core/lib/src/stream_thread_list_controller.dart b/packages/stream_chat_flutter_core/lib/src/stream_thread_list_controller.dart index fe30762ed7..691c4e7f2b 100644 --- a/packages/stream_chat_flutter_core/lib/src/stream_thread_list_controller.dart +++ b/packages/stream_chat_flutter_core/lib/src/stream_thread_list_controller.dart @@ -29,11 +29,11 @@ class StreamThreadListController extends PagedValueNotifier { this.sort, this.options = const ThreadOptions(), this.limit = defaultThreadsPagedLimit, - }) : _activeFilter = filter, - _activeSort = sort, - _activeOptions = options, - _eventHandler = eventHandler ?? StreamThreadListEventHandler(), - super(const PagedValue.loading()); + }) : _activeFilter = filter, + _activeSort = sort, + _activeOptions = options, + _eventHandler = eventHandler ?? StreamThreadListEventHandler(), + super(const PagedValue.loading()); /// Creates a [StreamThreadListController] from the passed [value]. StreamThreadListController.fromValue( @@ -44,10 +44,10 @@ class StreamThreadListController extends PagedValueNotifier { this.sort, this.options = const ThreadOptions(), this.limit = defaultThreadsPagedLimit, - }) : _activeFilter = filter, - _activeSort = sort, - _activeOptions = options, - _eventHandler = eventHandler ?? StreamThreadListEventHandler(); + }) : _activeFilter = filter, + _activeSort = sort, + _activeOptions = options, + _eventHandler = eventHandler ?? StreamThreadListEventHandler(); /// The Stream client used to perform the queries. final StreamChatClient client; @@ -129,11 +129,11 @@ class StreamThreadListController extends PagedValueNotifier { super.value = switch (_activeSort) { null => newValue, final threadSort => newValue.maybeMap( - orElse: () => newValue, - (success) => success.copyWith( - items: success.items.sorted(threadSort.compare), - ), + orElse: () => newValue, + (success) => success.copyWith( + items: success.items.sorted(threadSort.compare), ), + ), }; } @@ -338,11 +338,9 @@ class StreamThreadListController extends PagedValueNotifier { final handlerFunc = switch (event.type) { EventType.threadUpdated => _eventHandler.onThreadUpdated, EventType.connectionRecovered => _eventHandler.onConnectionRecovered, - EventType.notificationThreadMessageNew => - _eventHandler.onNotificationThreadMessageNew, + EventType.notificationThreadMessageNew => _eventHandler.onNotificationThreadMessageNew, EventType.messageRead => _eventHandler.onMessageRead, - EventType.notificationMarkUnread => - _eventHandler.onNotificationMarkUnread, + EventType.notificationMarkUnread => _eventHandler.onNotificationMarkUnread, EventType.channelDeleted => _eventHandler.onChannelDeleted, EventType.channelTruncated => _eventHandler.onChannelTruncated, EventType.messageNew => _eventHandler.onMessageNew, diff --git a/packages/stream_chat_flutter_core/lib/src/stream_user_list_controller.dart b/packages/stream_chat_flutter_core/lib/src/stream_user_list_controller.dart index 9dc25635eb..fa22bc49a1 100644 --- a/packages/stream_chat_flutter_core/lib/src/stream_user_list_controller.dart +++ b/packages/stream_chat_flutter_core/lib/src/stream_user_list_controller.dart @@ -40,9 +40,9 @@ class StreamUserListController extends PagedValueNotifier { this.sort = defaultUserListSort, this.presence = true, this.limit = defaultUserPagedLimit, - }) : _activeFilter = filter, - _activeSort = sort, - super(const PagedValue.loading()); + }) : _activeFilter = filter, + _activeSort = sort, + super(const PagedValue.loading()); /// Creates a [StreamUserListController] from the passed [value]. StreamUserListController.fromValue( @@ -52,8 +52,8 @@ class StreamUserListController extends PagedValueNotifier { this.sort = defaultUserListSort, this.presence = true, this.limit = defaultUserPagedLimit, - }) : _activeFilter = filter, - _activeSort = sort; + }) : _activeFilter = filter, + _activeSort = sort; /// The client to use for the channels list. final StreamChatClient client; @@ -105,11 +105,11 @@ class StreamUserListController extends PagedValueNotifier { super.value = switch (_activeSort) { null => newValue, final userSort => newValue.maybeMap( - orElse: () => newValue, - (success) => success.copyWith( - items: success.items.sorted(userSort.compare), - ), + orElse: () => newValue, + (success) => success.copyWith( + items: success.items.sorted(userSort.compare), ), + ), }; } diff --git a/packages/stream_chat_flutter_core/lib/stream_chat_flutter_core.dart b/packages/stream_chat_flutter_core/lib/stream_chat_flutter_core.dart index 444362bbb7..105a9ad77d 100644 --- a/packages/stream_chat_flutter_core/lib/stream_chat_flutter_core.dart +++ b/packages/stream_chat_flutter_core/lib/stream_chat_flutter_core.dart @@ -8,11 +8,7 @@ export 'src/lazy_load_scroll_view.dart'; export 'src/message_list_core.dart' hide MessageListCoreState; export 'src/message_text_field_controller.dart'; export 'src/paged_value_notifier.dart' - show - PagedValueListenableBuilder, - PagedValue, - PagedValueNotifier, - PagedValuePatternMatching; + show PagedValueListenableBuilder, PagedValue, PagedValueNotifier, PagedValuePatternMatching; export 'src/paged_value_scroll_view.dart'; export 'src/stream_channel.dart'; export 'src/stream_channel_list_controller.dart'; @@ -21,12 +17,13 @@ export 'src/stream_chat_core.dart'; export 'src/stream_draft_list_controller.dart'; export 'src/stream_draft_list_event_handler.dart'; export 'src/stream_member_list_controller.dart'; -export 'src/stream_message_input_controller.dart'; +export 'src/stream_message_composer_controller.dart'; export 'src/stream_message_reminder_list_controller.dart'; export 'src/stream_message_reminder_list_event_handler.dart'; export 'src/stream_message_search_list_controller.dart'; export 'src/stream_poll_controller.dart'; export 'src/stream_poll_vote_list_controller.dart'; +export 'src/stream_reaction_list_controller.dart'; export 'src/stream_thread_list_controller.dart'; export 'src/stream_thread_list_event_handler.dart'; export 'src/stream_user_list_controller.dart'; diff --git a/packages/stream_chat_flutter_core/pubspec.yaml b/packages/stream_chat_flutter_core/pubspec.yaml index fc538ec6cb..e73c6c3537 100644 --- a/packages/stream_chat_flutter_core/pubspec.yaml +++ b/packages/stream_chat_flutter_core/pubspec.yaml @@ -1,7 +1,7 @@ name: stream_chat_flutter_core homepage: https://github.com/GetStream/stream-chat-flutter description: Stream Chat official Flutter SDK Core. Build your own chat experience using Dart and Flutter. -version: 9.23.0 +version: 10.0.0-beta.13 repository: https://github.com/GetStream/stream-chat-flutter issue_tracker: https://github.com/GetStream/stream-chat-flutter/issues @@ -18,8 +18,8 @@ issue_tracker: https://github.com/GetStream/stream-chat-flutter/issues # 2. Add it to the melos.yaml file for future updates. environment: - sdk: ^3.6.2 - flutter: ">=3.27.4" + sdk: ^3.10.0 + flutter: ">=3.38.1" dependencies: collection: ^1.17.2 @@ -31,7 +31,7 @@ dependencies: meta: ^1.9.1 package_info_plus: ">=8.3.0 <10.0.0" rxdart: ^0.28.0 - stream_chat: ^9.23.0 + stream_chat: ^10.0.0-beta.13 dev_dependencies: build_runner: ^2.4.9 diff --git a/packages/stream_chat_flutter_core/test/message_list_core_test.dart b/packages/stream_chat_flutter_core/test/message_list_core_test.dart index d49a31e8f5..5392f7db16 100644 --- a/packages/stream_chat_flutter_core/test/message_list_core_test.dart +++ b/packages/stream_chat_flutter_core/test/message_list_core_test.dart @@ -14,14 +14,14 @@ void main() { int offset = 0, bool threads = false, }) { - final users = List.generate(count, (index) { - index = count + offset; + final users = List.generate(count, (i) { + final index = i + offset; return User(id: 'testUserId$index'); }); final messages = List.generate( count, - (index) { - index = index + offset; + (i) { + final index = i + offset; return Message( id: 'testMessageId$index', type: 'testType', @@ -39,8 +39,8 @@ void main() { ); final threadMessages = List.generate( count, - (index) { - index = index + offset; + (i) { + final index = i + offset; return Message( id: 'testThreadMessageId$index', type: 'testType', @@ -94,8 +94,7 @@ void main() { final mockChannel = MockChannel(); when(() => mockChannel.state.unreadCount).thenReturn(0); when(() => mockChannel.state.isUpToDate).thenReturn(true); - when(() => mockChannel.state.messagesStream) - .thenAnswer((_) => Stream.value([])); + when(() => mockChannel.state.messagesStream).thenAnswer((_) => Stream.value([])); when(() => mockChannel.state.messages).thenReturn([]); await tester.pumpWidget( @@ -131,8 +130,7 @@ void main() { when(() => mockChannel.state.isUpToDate).thenReturn(true); when(() => mockChannel.state.unreadCount).thenReturn(0); - when(() => mockChannel.state.messagesStream) - .thenAnswer((_) => Stream.value([])); + when(() => mockChannel.state.messagesStream).thenAnswer((_) => Stream.value([])); when(() => mockChannel.state.messages).thenReturn([]); await tester.pumpWidget( @@ -173,8 +171,7 @@ void main() { when(() => mockChannel.state.unreadCount).thenReturn(0); final messages = _generateMessages(); when(() => mockChannel.state.messages).thenReturn(messages); - when(() => mockChannel.state.messagesStream) - .thenAnswer((_) => Stream.value(messages)); + when(() => mockChannel.state.messagesStream).thenAnswer((_) => Stream.value(messages)); when(() => mockChannel.state.messages).thenReturn(messages); await tester.pumpWidget( @@ -193,13 +190,15 @@ void main() { await coreState.paginateData(); - verify(() => mockChannel.query( - messagesPagination: any( - named: 'messagesPagination', - that: wrapMatcher((it) => it.limit == paginationLimit), - ), - preferOffline: any(named: 'preferOffline'), - )).called(1); + verify( + () => mockChannel.query( + messagesPagination: any( + named: 'messagesPagination', + that: wrapMatcher((it) => it.limit == paginationLimit), + ), + preferOffline: any(named: 'preferOffline'), + ), + ).called(1); }, ); @@ -223,8 +222,7 @@ void main() { when(() => mockChannel.state.isUpToDate).thenReturn(true); const error = 'Error! Error! Error!'; - when(() => mockChannel.state.messagesStream) - .thenAnswer((_) => Stream.error(error)); + when(() => mockChannel.state.messagesStream).thenAnswer((_) => Stream.error(error)); when(() => mockChannel.state.messages).thenReturn([]); when(() => mockChannel.state.unreadCount).thenReturn(0); @@ -254,8 +252,7 @@ void main() { key: messageListCoreKey, messageListBuilder: (_, __) => const Offstage(), loadingBuilder: (BuildContext context) => const Offstage(), - emptyBuilder: (BuildContext context) => - const Offstage(key: emptyWidgetKey), + emptyBuilder: (BuildContext context) => const Offstage(key: emptyWidgetKey), errorBuilder: (BuildContext context, Object error) => const Offstage(), ); @@ -264,8 +261,7 @@ void main() { when(() => mockChannel.state.isUpToDate).thenReturn(true); const messages = []; - when(() => mockChannel.state.messagesStream) - .thenAnswer((_) => Stream.value(messages)); + when(() => mockChannel.state.messagesStream).thenAnswer((_) => Stream.value(messages)); when(() => mockChannel.state.messages).thenReturn(messages); when(() => mockChannel.state.unreadCount).thenReturn(0); @@ -302,19 +298,20 @@ void main() { final mockChannel = MockChannel(); when(() => mockChannel.state.isUpToDate).thenReturn(false); - when(() => mockChannel.query( - state: any(named: 'state'), - watch: any(named: 'watch'), - presence: any(named: 'presence'), - membersPagination: any(named: 'membersPagination'), - messagesPagination: any(named: 'messagesPagination'), - preferOffline: any(named: 'preferOffline'), - watchersPagination: any(named: 'watchersPagination'), - )).thenAnswer((_) async => const ChannelState()); + when( + () => mockChannel.query( + state: any(named: 'state'), + watch: any(named: 'watch'), + presence: any(named: 'presence'), + membersPagination: any(named: 'membersPagination'), + messagesPagination: any(named: 'messagesPagination'), + preferOffline: any(named: 'preferOffline'), + watchersPagination: any(named: 'watchersPagination'), + ), + ).thenAnswer((_) async => const ChannelState()); const messages = []; - when(() => mockChannel.state.messagesStream) - .thenAnswer((_) => Stream.value(messages)); + when(() => mockChannel.state.messagesStream).thenAnswer((_) => Stream.value(messages)); when(() => mockChannel.state.messages).thenReturn(messages); when(() => mockChannel.state.unreadCount).thenReturn(0); @@ -358,8 +355,7 @@ void main() { when(() => mockChannel.state.isUpToDate).thenReturn(true); final messages = _generateMessages(); - when(() => mockChannel.state.messagesStream) - .thenAnswer((_) => Stream.value(messages)); + when(() => mockChannel.state.messagesStream).thenAnswer((_) => Stream.value(messages)); when(() => mockChannel.state.messages).thenReturn(messages); when(() => mockChannel.state.unreadCount).thenReturn(0); @@ -408,8 +404,7 @@ void main() { final threads = {parentMessage.id: messages}; when(() => mockChannel.state.threads).thenReturn(threads); - when(() => mockChannel.state.threadsStream) - .thenAnswer((_) => Stream.value(threads)); + when(() => mockChannel.state.threadsStream).thenAnswer((_) => Stream.value(threads)); when(() => mockChannel.state.unreadCount).thenReturn(0); when( diff --git a/packages/stream_chat_flutter_core/test/mocks.dart b/packages/stream_chat_flutter_core/test/mocks.dart index 7235202250..cc705f9838 100644 --- a/packages/stream_chat_flutter_core/test/mocks.dart +++ b/packages/stream_chat_flutter_core/test/mocks.dart @@ -22,11 +22,11 @@ class MockClientState extends Mock implements ClientState { @override OwnUser get currentUser => _currentUser ??= OwnUser( - id: 'testUserId', - role: 'admin', - createdAt: DateTime.now(), - updatedAt: DateTime.now(), - ); + id: 'testUserId', + role: 'admin', + createdAt: DateTime.now(), + updatedAt: DateTime.now(), + ); } class NonInitializedMockChannel extends Mock implements Channel { diff --git a/packages/stream_chat_flutter_core/test/paged_value_grid_view_test.dart b/packages/stream_chat_flutter_core/test/paged_value_grid_view_test.dart new file mode 100644 index 0000000000..c2877cdaf5 --- /dev/null +++ b/packages/stream_chat_flutter_core/test/paged_value_grid_view_test.dart @@ -0,0 +1,147 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:stream_chat_flutter_core/stream_chat_flutter_core.dart'; + +class _TestController extends PagedValueNotifier { + _TestController(List items, {int? nextPageKey}) : super(PagedValue(items: items, nextPageKey: nextPageKey)); + + @override + Future doInitialLoad() async {} + + @override + Future loadMore(int nextPageKey) async {} +} + +Widget _wrap(Widget child) => MaterialApp(home: Scaffold(body: child)); + +PagedValueGridView _buildGrid( + _TestController controller, { + WidgetBuilder? leadingItemBuilder, + required List builtIndices, +}) { + return PagedValueGridView( + controller: controller, + gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(crossAxisCount: 3), + leadingItemBuilder: leadingItemBuilder, + itemBuilder: (context, items, index) { + builtIndices.add(index); + return Text('item-$index'); + }, + emptyBuilder: (_) => const Text('empty'), + loadMoreErrorBuilder: (_, __) => const Text('load-more-error'), + loadMoreIndicatorBuilder: (_) => const Text('load-more-indicator'), + loadingBuilder: (_) => const Text('loading'), + errorBuilder: (_, __) => const Text('error'), + ); +} + +void main() { + group('PagedValueGridView without leadingItemBuilder', () { + testWidgets('renders items starting at index 0', (tester) async { + final controller = _TestController(['a', 'b', 'c']); + final builtIndices = []; + + await tester.pumpWidget(_wrap(_buildGrid(controller, builtIndices: builtIndices))); + await tester.pump(); + + expect(find.text('item-0'), findsOneWidget); + expect(find.text('item-1'), findsOneWidget); + expect(find.text('item-2'), findsOneWidget); + expect(builtIndices, [0, 1, 2]); + }); + + testWidgets('does not render a leading item', (tester) async { + final controller = _TestController(['a']); + final builtIndices = []; + + await tester.pumpWidget(_wrap(_buildGrid(controller, builtIndices: builtIndices))); + await tester.pump(); + + expect(find.text('leading'), findsNothing); + expect(builtIndices, [0]); + }); + }); + + group('PagedValueGridView with leadingItemBuilder', () { + testWidgets('renders the leading item before the paged items', (tester) async { + final controller = _TestController(['a', 'b', 'c']); + final builtIndices = []; + + await tester.pumpWidget( + _wrap( + _buildGrid( + controller, + leadingItemBuilder: (_) => const Text('leading'), + builtIndices: builtIndices, + ), + ), + ); + await tester.pump(); + + expect(find.text('leading'), findsOneWidget); + expect(find.text('item-0'), findsOneWidget); + expect(find.text('item-1'), findsOneWidget); + expect(find.text('item-2'), findsOneWidget); + }); + + testWidgets('itemBuilder receives item indices starting at 0, not offset', (tester) async { + final controller = _TestController(['a', 'b', 'c']); + final builtIndices = []; + + await tester.pumpWidget( + _wrap( + _buildGrid( + controller, + leadingItemBuilder: (_) => const Text('leading'), + builtIndices: builtIndices, + ), + ), + ); + await tester.pump(); + + expect(builtIndices, [0, 1, 2]); + }); + + testWidgets('renders leading item even with a single paged item', (tester) async { + final controller = _TestController(['a']); + final builtIndices = []; + + await tester.pumpWidget( + _wrap( + _buildGrid( + controller, + leadingItemBuilder: (_) => const Text('leading'), + builtIndices: builtIndices, + ), + ), + ); + await tester.pump(); + + expect(find.text('leading'), findsOneWidget); + expect(find.text('item-0'), findsOneWidget); + expect(builtIndices, [0]); + }); + + testWidgets('renders load-more indicator at correct position with leading item', (tester) async { + final controller = _TestController(['item-0', 'item-1'], nextPageKey: 1); + final builtIndices = []; + + await tester.pumpWidget( + _wrap( + _buildGrid( + controller, + leadingItemBuilder: (_) => const Text('leading'), + builtIndices: builtIndices, + ), + ), + ); + await tester.pump(); + + expect(find.text('leading'), findsOneWidget); + expect(find.text('item-0'), findsOneWidget); + expect(find.text('item-1'), findsOneWidget); + expect(find.text('load-more-indicator'), findsOneWidget); + expect(builtIndices, [0, 1]); + }); + }); +} diff --git a/packages/stream_chat_flutter_core/test/stream_chat_core_test.dart b/packages/stream_chat_flutter_core/test/stream_chat_core_test.dart index 8f56ce1c1d..730f0e6973 100644 --- a/packages/stream_chat_flutter_core/test/stream_chat_core_test.dart +++ b/packages/stream_chat_flutter_core/test/stream_chat_core_test.dart @@ -128,6 +128,52 @@ void main() { }, ); + group('StreamChatCore client configuration', () { + testWidgets( + 'should disable client recoverStateOnReconnect on initState', + (tester) async { + final mockClient = MockClient(); + + await tester.pumpWidget( + MaterialApp( + home: StreamChatCore( + client: mockClient, + child: const SizedBox(), + ), + ), + ); + + verify(() => mockClient.recoverStateOnReconnect = false).called(1); + }, + ); + + testWidgets( + 'should disable recoverStateOnReconnect on the new client when it is swapped', + (tester) async { + final firstClient = MockClient(); + final secondClient = MockClient(); + + Widget buildWith(MockClient client) => MaterialApp( + home: StreamChatCore( + client: client, + child: const SizedBox(), + ), + ); + + await tester.pumpWidget(buildWith(firstClient)); + + // Sanity: only the initial client was configured so far. + verify(() => firstClient.recoverStateOnReconnect = false).called(1); + verifyNever(() => secondClient.recoverStateOnReconnect = false); + + // Swap to a different client. + await tester.pumpWidget(buildWith(secondClient)); + + verify(() => secondClient.recoverStateOnReconnect = false).called(1); + }, + ); + }); + group('StreamChatCore lifecycle behavior', () { late MockClient mockClient; late MockOnBackgroundEventReceived mockOnBackgroundEventReceived; @@ -145,8 +191,7 @@ void main() { when( mockClient.openConnection, ).thenAnswer((_) async => OwnUser(id: 'test-user')); - when(() => mockClient.wsConnectionStatus) - .thenReturn(ConnectionStatus.connected); + when(() => mockClient.wsConnectionStatus).thenReturn(ConnectionStatus.connected); }); tearDown(() { @@ -156,7 +201,7 @@ void main() { Future pumpStreamChatCore( WidgetTester tester, { void Function(Event)? onBackgroundEventReceived, - Duration backgroundKeepAlive = const Duration(minutes: 1), + Duration backgroundKeepAlive = const Duration(seconds: 15), }) async { await tester.pumpWidget( MaterialApp( @@ -172,7 +217,7 @@ void main() { } testWidgets( - 'should close connection when app goes to background without handler', + 'should not listen for events when app goes to background without handler', (tester) async { // Arrange await pumpStreamChatCore(tester); @@ -181,8 +226,9 @@ void main() { tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.paused); await tester.pumpAndSettle(); - // Assert - verify(mockClient.closeConnection).called(1); + // Assert — the timer-expiry path is covered by a separate test; + // here we only verify that no event subscription is started. + verifyNever(mockClient.on); }, ); @@ -202,8 +248,7 @@ void main() { ).thenReturn(ConnectionStatus.disconnected); // Act - bring app to foreground - tester.binding - .handleAppLifecycleStateChanged(AppLifecycleState.resumed); + tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.resumed); await tester.pumpAndSettle(); // Assert @@ -251,8 +296,7 @@ void main() { ); // Act - tester.binding - .handleAppLifecycleStateChanged(AppLifecycleState.paused); + tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.paused); await tester.pumpAndSettle(); // Wait for timer to expire diff --git a/packages/stream_chat_flutter_core/test/stream_draft_list_controller_test.dart b/packages/stream_chat_flutter_core/test/stream_draft_list_controller_test.dart index ff07096332..87aa10cfa8 100644 --- a/packages/stream_chat_flutter_core/test/stream_draft_list_controller_test.dart +++ b/packages/stream_chat_flutter_core/test/stream_draft_list_controller_test.dart @@ -36,9 +36,7 @@ List generateDrafts({ final baseId = startId ?? 123; return List.generate(count, (index) { - final text = texts != null && index < texts.length - ? texts[index] - : 'Draft ${index + 1}'; + final text = texts != null && index < texts.length ? texts[index] : 'Draft ${index + 1}'; return generateDraft( channelCid: 'messaging:${baseId + index}', @@ -86,22 +84,26 @@ void main() { ..drafts = drafts ..next = ''; - when(() => client.queryDrafts( - filter: any(named: 'filter'), - sort: any(named: 'sort'), - pagination: any(named: 'pagination'), - )).thenAnswer((_) async => response); + when( + () => client.queryDrafts( + filter: any(named: 'filter'), + sort: any(named: 'sort'), + pagination: any(named: 'pagination'), + ), + ).thenAnswer((_) async => response); final controller = StreamDraftListController(client: client); await controller.doInitialLoad(); await pumpEventQueue(); - verify(() => client.queryDrafts( - filter: any(named: 'filter'), - sort: any(named: 'sort'), - pagination: any(named: 'pagination'), - )).called(1); + verify( + () => client.queryDrafts( + filter: any(named: 'filter'), + sort: any(named: 'sort'), + pagination: any(named: 'pagination'), + ), + ).called(1); expect(controller.value, isA>()); expect(controller.value.asSuccess.items, equals(drafts)); @@ -109,11 +111,13 @@ void main() { test('handles API exceptions by transitioning to error state', () async { final exception = Exception('API unavailable'); - when(() => client.queryDrafts( - filter: any(named: 'filter'), - sort: any(named: 'sort'), - pagination: any(named: 'pagination'), - )).thenThrow(exception); + when( + () => client.queryDrafts( + filter: any(named: 'filter'), + sort: any(named: 'sort'), + pagination: any(named: 'pagination'), + ), + ).thenThrow(exception); final controller = StreamDraftListController(client: client); @@ -142,11 +146,13 @@ void main() { ..drafts = additionalDrafts ..next = ''; - when(() => client.queryDrafts( - filter: any(named: 'filter'), - sort: any(named: 'sort'), - pagination: any(named: 'pagination'), - )).thenAnswer((_) async => response); + when( + () => client.queryDrafts( + filter: any(named: 'filter'), + sort: any(named: 'sort'), + pagination: any(named: 'pagination'), + ), + ).thenAnswer((_) async => response); final controller = StreamDraftListController.fromValue( PagedValue( @@ -171,9 +177,9 @@ void main() { for (final draft in mergedDrafts) { expect( - controller.value.asSuccess.items.any((d) => - d.channelCid == draft.channelCid && - d.message.text == draft.message.text), + controller.value.asSuccess.items.any( + (d) => d.channelCid == draft.channelCid && d.message.text == draft.message.text, + ), isTrue, ); } @@ -181,17 +187,18 @@ void main() { expect(controller.value.asSuccess.nextPageKey, isNull); }); - test('loadMore preserves existing items when API throws exception', - () async { + test('loadMore preserves existing items when API throws exception', () async { const nextKey = 'next_page_token'; final existingDrafts = generateDrafts(); final exception = Exception('Network error'); - when(() => client.queryDrafts( - filter: any(named: 'filter'), - sort: any(named: 'sort'), - pagination: any(named: 'pagination'), - )).thenThrow(exception); + when( + () => client.queryDrafts( + filter: any(named: 'filter'), + sort: any(named: 'sort'), + pagination: any(named: 'pagination'), + ), + ).thenThrow(exception); final controller = StreamDraftListController.fromValue( PagedValue( @@ -331,9 +338,7 @@ void main() { equals(allDrafts.length - 1), ); - final remainingDraftTexts = [ - ...controller.value.asSuccess.items.map((d) => d.message.text) - ]; + final remainingDraftTexts = [...controller.value.asSuccess.items.map((d) => d.message.text)]; expect(remainingDraftTexts, contains('Thread Draft 2')); expect(remainingDraftTexts, isNot(contains('Thread Draft 1'))); @@ -393,11 +398,13 @@ void main() { ..drafts = drafts ..next = ''; - when(() => client.queryDrafts( - filter: any(named: 'filter'), - sort: any(named: 'sort'), - pagination: any(named: 'pagination'), - )).thenAnswer((_) async => queryResponse); + when( + () => client.queryDrafts( + filter: any(named: 'filter'), + sort: any(named: 'sort'), + pagination: any(named: 'pagination'), + ), + ).thenAnswer((_) async => queryResponse); final controller = StreamDraftListController.fromValue( PagedValue(items: drafts), @@ -432,11 +439,13 @@ void main() { ..drafts = drafts ..next = ''; - when(() => client.queryDrafts( - filter: any(named: 'filter'), - sort: any(named: 'sort'), - pagination: any(named: 'pagination'), - )).thenAnswer((_) async => queryResponse); + when( + () => client.queryDrafts( + filter: any(named: 'filter'), + sort: any(named: 'sort'), + pagination: any(named: 'pagination'), + ), + ).thenAnswer((_) async => queryResponse); final controller = StreamDraftListController.fromValue( PagedValue(items: drafts), @@ -473,11 +482,13 @@ void main() { final drafts = generateDrafts(); var queryCallCount = 0; - when(() => client.queryDrafts( - filter: any(named: 'filter'), - sort: any(named: 'sort'), - pagination: any(named: 'pagination'), - )).thenAnswer((_) async { + when( + () => client.queryDrafts( + filter: any(named: 'filter'), + sort: any(named: 'sort'), + pagination: any(named: 'pagination'), + ), + ).thenAnswer((_) async { queryCallCount++; return QueryDraftsResponse() ..drafts = drafts @@ -512,11 +523,13 @@ void main() { ..drafts = drafts ..next = ''; - when(() => client.queryDrafts( - filter: any(named: 'filter'), - sort: any(named: 'sort'), - pagination: any(named: 'pagination'), - )).thenAnswer((_) async => queryResponse); + when( + () => client.queryDrafts( + filter: any(named: 'filter'), + sort: any(named: 'sort'), + pagination: any(named: 'pagination'), + ), + ).thenAnswer((_) async => queryResponse); final controller = StreamDraftListController.fromValue( PagedValue(items: drafts), @@ -569,11 +582,13 @@ void main() { ..drafts = drafts ..next = ''; - when(() => client.queryDrafts( - filter: any(named: 'filter'), - sort: any(named: 'sort'), - pagination: any(named: 'pagination'), - )).thenAnswer((_) async => response); + when( + () => client.queryDrafts( + filter: any(named: 'filter'), + sort: any(named: 'sort'), + pagination: any(named: 'pagination'), + ), + ).thenAnswer((_) async => response); final controller = StreamDraftListController.fromValue( PagedValue(items: drafts), @@ -599,11 +614,13 @@ void main() { final apiCalls = >[]; - when(() => client.queryDrafts( - filter: any(named: 'filter'), - sort: any(named: 'sort'), - pagination: any(named: 'pagination'), - )).thenAnswer((invocation) async { + when( + () => client.queryDrafts( + filter: any(named: 'filter'), + sort: any(named: 'sort'), + pagination: any(named: 'pagination'), + ), + ).thenAnswer((invocation) async { apiCalls.add({ 'filter': invocation.namedArguments[const Symbol('filter')], 'sort': invocation.namedArguments[const Symbol('sort')], @@ -647,18 +664,22 @@ void main() { final apiCalls = >[]; - when(() => client.queryDrafts( - filter: any(named: 'filter'), - sort: any(named: 'sort'), - pagination: any(named: 'pagination'), - )).thenAnswer((invocation) { + when( + () => client.queryDrafts( + filter: any(named: 'filter'), + sort: any(named: 'sort'), + pagination: any(named: 'pagination'), + ), + ).thenAnswer((invocation) { apiCalls.add({ 'filter': invocation.namedArguments[const Symbol('filter')], 'sort': invocation.namedArguments[const Symbol('sort')], }); - return Future.value(QueryDraftsResponse() - ..drafts = drafts - ..next = ''); + return Future.value( + QueryDraftsResponse() + ..drafts = drafts + ..next = '', + ); }); final controller = StreamDraftListController( @@ -688,8 +709,7 @@ void main() { group('Disposal', () { test('dispose cancels subscriptions without errors', () { - final controller = StreamDraftListController(client: client) - ..doInitialLoad(); + final controller = StreamDraftListController(client: client)..doInitialLoad(); expect(controller.dispose, returnsNormally); }); diff --git a/packages/stream_chat_flutter_core/test/stream_message_input_controller_test.dart b/packages/stream_chat_flutter_core/test/stream_message_composer_controller_test.dart similarity index 53% rename from packages/stream_chat_flutter_core/test/stream_message_input_controller_test.dart rename to packages/stream_chat_flutter_core/test/stream_message_composer_controller_test.dart index ba1eadfa9f..7a4826e61b 100644 --- a/packages/stream_chat_flutter_core/test/stream_message_input_controller_test.dart +++ b/packages/stream_chat_flutter_core/test/stream_message_composer_controller_test.dart @@ -1,21 +1,23 @@ // ignore_for_file: cascade_invocations +import 'dart:convert'; + import 'package:fake_async/fake_async.dart'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:mocktail/mocktail.dart'; import 'package:stream_chat/stream_chat.dart'; -import 'package:stream_chat_flutter_core/src/stream_message_input_controller.dart'; +import 'package:stream_chat_flutter_core/src/stream_message_composer_controller.dart'; class ValueNotifierListenerMock extends Mock { void call(); } void main() { - late StreamMessageInputController controller; + late StreamMessageComposerController controller; setUp(() { - controller = StreamMessageInputController(); + controller = StreamMessageComposerController(); }); tearDown(() { @@ -31,7 +33,7 @@ void main() { }); test('fromText constructor initializes with proper text', () { - final textController = StreamMessageInputController.fromText('Hello'); + final textController = StreamMessageComposerController.fromText('Hello'); expect(textController.text, 'Hello'); textController.dispose(); }); @@ -41,7 +43,7 @@ void main() { Attachment(type: 'image', title: 'test'), ]; - final controller = StreamMessageInputController.fromAttachments( + final controller = StreamMessageComposerController.fromAttachments( attachments, ); @@ -56,7 +58,7 @@ void main() { }, }; - final controller = StreamMessageInputController( + final controller = StreamMessageComposerController( textPatternStyle: patterns, ); @@ -124,6 +126,75 @@ void main() { expect(controller.text, isEmpty); expect(controller.attachments, isEmpty); }); + + test('clearCommand restores the content that was in the composer before', () { + controller.text = 'Draft text'; + controller.addAttachment(Attachment(type: 'image')); + + controller.command = 'giphy'; + controller.text = 'giphy search'; + + controller.clearCommand(); + + expect(controller.message.command, isNull); + expect(controller.text, 'Draft text'); + expect(controller.attachments, hasLength(1)); + }); + + test('clearCommand without an active command is a no-op', () { + controller.text = 'Draft text'; + + controller.clearCommand(); + + expect(controller.text, 'Draft text'); + expect(controller.message.command, isNull); + }); + + test('setting command again during an active command keeps the original snapshot', () { + controller.text = 'Draft text'; + + controller.command = 'giphy'; + controller.text = 'mid-command typing'; + controller.command = 'ban'; + + controller.clearCommand(); + + expect(controller.text, 'Draft text'); + expect(controller.message.command, isNull); + }); + + test('setting command to null is equivalent to clearCommand', () { + controller.text = 'Draft text'; + + controller.command = 'giphy'; + controller.command = null; + + expect(controller.text, 'Draft text'); + expect(controller.message.command, isNull); + }); + + test('reset clears active command tracking', () { + controller.text = 'Draft text'; + controller.command = 'giphy'; + + controller.reset(); + + controller.clearCommand(); + + expect(controller.text, isNot('Draft text')); + }); + + test('clear clears active command tracking', () { + controller.text = 'Draft text'; + controller.command = 'giphy'; + + controller.clear(); + + controller.clearCommand(); + + expect(controller.text, isEmpty); + expect(controller.message.command, isNull); + }); }); group('Show In Channel', () { @@ -241,12 +312,10 @@ void main() { }); test('setOGAttachment replaces existing OG attachment', () { - final oldOGAttachment = - Attachment(ogScrapeUrl: 'https://old.example.com'); + final oldOGAttachment = Attachment(ogScrapeUrl: 'https://old.example.com'); controller.addAttachment(oldOGAttachment); - final newOGAttachment = - Attachment(ogScrapeUrl: 'https://new.example.com'); + final newOGAttachment = Attachment(ogScrapeUrl: 'https://new.example.com'); controller.setOGAttachment(newOGAttachment); expect(controller.attachments.length, 1); @@ -382,6 +451,119 @@ void main() { }); }); + group('Edit Message', () { + test('constructing with a non-initial message throws an assertion', () { + final existingMessage = Message( + id: 'msg-1', + text: 'Existing text', + state: MessageState.updating, + ); + + expect( + () => StreamMessageComposerController(message: existingMessage), + throwsA(isA()), + ); + }); + + test('constructing with a fresh message does not enter edit mode', () { + final editController = StreamMessageComposerController.fromText('Some draft'); + addTearDown(editController.dispose); + + expect(editController.messageBeingEdited, isNull); + }); + + test('editMessage sets state to MessageState.updating', () { + final existingMessage = Message(id: 'msg-1', text: 'Original text'); + controller.editMessage(existingMessage); + + expect(controller.message.state.isInitial, isFalse); + expect(controller.message.state.isUpdating, isTrue); + }); + + test('editMessage preserves the message id and text', () { + final existingMessage = Message(id: 'msg-1', text: 'Original text'); + controller.editMessage(existingMessage); + + expect(controller.message.id, 'msg-1'); + expect(controller.message.text, 'Original text'); + }); + + test('editMessage stores original in messageBeingEdited', () { + final existingMessage = Message(id: 'msg-1', text: 'Original text'); + controller.editMessage(existingMessage); + + expect(controller.messageBeingEdited, isNotNull); + expect(controller.messageBeingEdited!.id, 'msg-1'); + expect(controller.messageBeingEdited!.text, 'Original text'); + }); + + test('messageBeingEdited text is not affected by subsequent typing', () { + final existingMessage = Message(id: 'msg-1', text: 'Original text'); + controller.editMessage(existingMessage); + + controller.text = 'Edited text'; + + expect(controller.messageBeingEdited!.text, 'Original text'); + expect(controller.message.text, 'Edited text'); + }); + + test('cancelEditMessage clears messageBeingEdited', () { + final existingMessage = Message(id: 'msg-1', text: 'Original text'); + controller.editMessage(existingMessage); + + controller.cancelEditMessage(); + + expect(controller.messageBeingEdited, isNull); + }); + + test('cancelEditMessage resets controller to empty state, not the edited message', () { + final existingMessage = Message(id: 'msg-1', text: 'Original text'); + controller.editMessage(existingMessage); + controller.text = 'Edited text'; + + controller.cancelEditMessage(); + + expect(controller.text, isEmpty); + expect(controller.message.state.isInitial, isTrue); + }); + + test('cancelEditMessage restores the draft that was in the composer before edit', () { + final draftController = StreamMessageComposerController.fromText('Draft text'); + addTearDown(draftController.dispose); + + draftController.editMessage(Message(id: 'msg-1', text: 'Original text')); + draftController.text = 'Edited text'; + draftController.cancelEditMessage(); + + expect(draftController.text, 'Draft text'); + expect(draftController.messageBeingEdited, isNull); + }); + + test('editMessage called again during an edit keeps the original pre-edit draft', () { + final draftController = StreamMessageComposerController.fromText('Draft text'); + addTearDown(draftController.dispose); + + draftController.editMessage(Message(id: 'msg-1', text: 'Original text')); + draftController.text = 'Edited text'; + // Simulates a remote update arriving for the message being edited. + draftController.editMessage(Message(id: 'msg-1', text: 'Remote update')); + draftController.cancelEditMessage(); + + expect(draftController.text, 'Draft text'); + expect(draftController.messageBeingEdited, isNull); + }); + + test('cancelEditMessage without an active edit is a no-op', () { + final draftController = StreamMessageComposerController.fromText('Draft text'); + addTearDown(draftController.dispose); + + draftController.cancelEditMessage(); + + expect(draftController.text, 'Draft text'); + expect(draftController.messageBeingEdited, isNull); + }); + }); + group('Reset and Clear', () { test('clear resets the message to empty state', () { controller.text = 'Some text'; @@ -396,7 +578,7 @@ void main() { }); test('reset restores the initial message', () { - final initialController = StreamMessageInputController( + final initialController = StreamMessageComposerController( message: Message(text: 'Initial text'), ); @@ -409,7 +591,7 @@ void main() { test('reset with resetId=false keeps the same message ID', () { final message = Message(id: 'message-id', text: 'Initial text'); - final initialController = StreamMessageInputController(message: message); + final initialController = StreamMessageComposerController(message: message); initialController.text = 'Updated text'; initialController.reset(resetId: false); @@ -417,6 +599,26 @@ void main() { expect(initialController.message.id, 'message-id'); initialController.dispose(); }); + + test('reset clears active edit tracking', () { + controller.editMessage(Message(id: 'msg-1', text: 'Original text')); + expect(controller.messageBeingEdited, isNotNull); + + controller.reset(); + + expect(controller.messageBeingEdited, isNull); + }); + + test('clear preserves the active edit session', () { + controller.editMessage(Message(id: 'msg-1', text: 'Original text')); + controller.text = 'Edited text'; + + controller.clear(); + + expect(controller.text, isEmpty); + expect(controller.messageBeingEdited, isNotNull); + expect(controller.messageBeingEdited!.id, 'msg-1'); + }); }); group('Listener Notifications', () { @@ -449,7 +651,7 @@ void main() { }); }); - group('RestorableMessageInputController', () { + group('RestorableMessageComposerController', () { testWidgets( 'restores old state correctly', (tester) async { @@ -491,6 +693,122 @@ void main() { expect(find.text(text), findsOneWidget); }, ); + + testWidgets( + 'edit mode survives restartAndRestore', + (tester) async { + final stateKey = GlobalKey<_RestorableEditWidgetState>(); + + await tester.pumpWidget( + MaterialApp( + restorationScopeId: 'app', + home: _RestorableEditWidget(key: stateKey), + ), + ); + + await tester.pumpAndSettle(); + + stateKey.currentState!.controller.value + ..editMessage(Message(id: 'msg-1', text: 'Original message')) + ..text = 'My edits'; + await tester.pumpAndSettle(); + + // Verify edit mode is active and rendering before the restart. + expect(find.text('editing:msg-1:My edits'), findsOneWidget); + + await tester.restartAndRestore(); + + // After restoration both the edit context and the composed text must + // still be present. + expect(find.text('editing:msg-1:My edits'), findsOneWidget); + }, + ); + + group('fromPrimitives', () { + // fromPrimitives is a pure factory: it does not access `this.value`, so + // it can be called directly without going through the Flutter restoration + // machinery. + late StreamRestorableMessageComposerController restorable; + + setUp(() => restorable = StreamRestorableMessageComposerController()); + tearDown(() => restorable.dispose()); + + StreamMessageComposerController restore(Map data) { + final controller = restorable.fromPrimitives(jsonEncode(data)); + addTearDown(controller.dispose); + return controller; + } + + test('restores text and attachments from a normal draft', () { + final controller = restore({ + 'message': Message( + text: 'Hello!', + // Attachment.id is a client-side tracking field that is not + // serialised by the SDK, so verify the type survives instead. + attachments: [Attachment(type: 'image')], + ).toJson(), + }); + + expect(controller.text, 'Hello!'); + expect(controller.attachments, hasLength(1)); + expect(controller.attachments.first.type, 'image'); + expect(controller.message.state.isInitial, isTrue); + expect(controller.isEditing, isFalse); + }); + + test('always resets message state to initial, even for old serialized non-initial state', () { + // Old format stored a message_state key alongside the message. New code + // ignores it and always constructs with MessageState.initial(). + final controller = restore({ + 'message': Message(text: 'Hi').toJson(), + 'message_state': MessageState.sending.toJson(), + }); + + expect(controller.message.state.isInitial, isTrue); + expect(controller.isEditing, isFalse); + }); + + test('restores isEditing, messageBeingEdited, and current composed text', () { + final messageBeingEdited = Message(id: 'msg-1', text: 'Original'); + + final controller = restore({ + 'message': messageBeingEdited + .copyWith( + text: 'Edited text', + state: MessageState.updating, + ) + .toJson(), + 'message_being_edited': messageBeingEdited.toJson(), + 'message_before_edit': Message(text: 'Draft before edit').toJson(), + }); + + expect(controller.isEditing, isTrue); + expect(controller.messageBeingEdited?.id, 'msg-1'); + expect(controller.messageBeingEdited?.text, 'Original'); + expect(controller.text, 'Edited text'); + }); + + test('cancelEditMessage after restore returns to the pre-edit draft', () { + final messageBeingEdited = Message(id: 'msg-1', text: 'Original'); + + final controller = restore({ + 'message': messageBeingEdited + .copyWith( + text: 'Edited text', + state: MessageState.updating, + ) + .toJson(), + 'message_being_edited': messageBeingEdited.toJson(), + 'message_before_edit': Message(text: 'Draft before edit').toJson(), + }); + + controller.cancelEditMessage(); + + expect(controller.isEditing, isFalse); + expect(controller.text, 'Draft before edit'); + expect(controller.message.state.isInitial, isTrue); + }); + }); }); } @@ -501,9 +819,8 @@ class _RestorableWidget extends StatefulWidget { State<_RestorableWidget> createState() => _RestorableWidgetState(); } -class _RestorableWidgetState extends State<_RestorableWidget> - with RestorationMixin { - final controller = StreamRestorableMessageInputController(); +class _RestorableWidgetState extends State<_RestorableWidget> with RestorationMixin { + final controller = StreamRestorableMessageComposerController(); @override String get restorationId => 'widget'; @@ -534,3 +851,45 @@ class _RestorableWidgetState extends State<_RestorableWidget> ); } } + +class _RestorableEditWidget extends StatefulWidget { + const _RestorableEditWidget({super.key}); + + @override + State<_RestorableEditWidget> createState() => _RestorableEditWidgetState(); +} + +class _RestorableEditWidgetState extends State<_RestorableEditWidget> with RestorationMixin { + final controller = StreamRestorableMessageComposerController(); + + @override + String get restorationId => 'edit_widget'; + + @override + void restoreState(RestorationBucket? oldBucket, bool initialRestore) { + registerForRestoration(controller, 'controller'); + } + + @override + void dispose() { + controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + // ListenableBuilder ensures setState is called when the controller changes, + // which causes RestorationMixin to flush the latest primitives to the + // bucket before restartAndRestore captures the state. + return ListenableBuilder( + listenable: controller, + builder: (context, _) { + final value = controller.value; + // Encode the relevant state into a single text node so tests can + // use find.text() to assert on the restored state. + final label = value.isEditing ? 'editing:${value.messageBeingEdited?.id}:${value.text}' : 'draft:${value.text}'; + return Text(label, textDirection: TextDirection.ltr); + }, + ); + } +} diff --git a/packages/stream_chat_flutter_core/test/stream_message_reminder_list_controller_test.dart b/packages/stream_chat_flutter_core/test/stream_message_reminder_list_controller_test.dart index 86d6767df1..cbbd5924b6 100644 --- a/packages/stream_chat_flutter_core/test/stream_message_reminder_list_controller_test.dart +++ b/packages/stream_chat_flutter_core/test/stream_message_reminder_list_controller_test.dart @@ -48,15 +48,9 @@ List generateMessageReminders({ final channelCid = channelCids != null && index < channelCids.length ? channelCids[index] : 'messaging:${baseId + index}'; - final messageId = messageIds != null && index < messageIds.length - ? messageIds[index] - : 'message_${baseId + index}'; - final userId = userIds != null && index < userIds.length - ? userIds[index] - : 'user_${baseId + index}'; - final text = texts != null && index < texts.length - ? texts[index] - : 'Reminder ${index + 1}'; + final messageId = messageIds != null && index < messageIds.length ? messageIds[index] : 'message_${baseId + index}'; + final userId = userIds != null && index < userIds.length ? userIds[index] : 'user_${baseId + index}'; + final text = texts != null && index < texts.length ? texts[index] : 'Reminder ${index + 1}'; return generateMessageReminder( channelCid: channelCid, @@ -110,22 +104,26 @@ void main() { ..reminders = reminders ..next = null; - when(() => client.queryReminders( - filter: any(named: 'filter'), - sort: any(named: 'sort'), - pagination: any(named: 'pagination'), - )).thenAnswer((_) async => response); + when( + () => client.queryReminders( + filter: any(named: 'filter'), + sort: any(named: 'sort'), + pagination: any(named: 'pagination'), + ), + ).thenAnswer((_) async => response); final controller = StreamMessageReminderListController(client: client); await controller.doInitialLoad(); await pumpEventQueue(); - verify(() => client.queryReminders( - filter: any(named: 'filter'), - sort: any(named: 'sort'), - pagination: any(named: 'pagination'), - )).called(1); + verify( + () => client.queryReminders( + filter: any(named: 'filter'), + sort: any(named: 'sort'), + pagination: any(named: 'pagination'), + ), + ).called(1); expect(controller.value, isA>()); expect(controller.value.asSuccess.items, equals(reminders)); @@ -133,11 +131,13 @@ void main() { test('handles StreamChatError exceptions properly', () async { const chatError = StreamChatError('Network error'); - when(() => client.queryReminders( - filter: any(named: 'filter'), - sort: any(named: 'sort'), - pagination: any(named: 'pagination'), - )).thenThrow(chatError); + when( + () => client.queryReminders( + filter: any(named: 'filter'), + sort: any(named: 'sort'), + pagination: any(named: 'pagination'), + ), + ).thenThrow(chatError); final controller = StreamMessageReminderListController(client: client); @@ -166,11 +166,13 @@ void main() { ..reminders = additionalReminders ..next = null; - when(() => client.queryReminders( - filter: any(named: 'filter'), - sort: any(named: 'sort'), - pagination: any(named: 'pagination'), - )).thenAnswer((_) async => response); + when( + () => client.queryReminders( + filter: any(named: 'filter'), + sort: any(named: 'sort'), + pagination: any(named: 'pagination'), + ), + ).thenAnswer((_) async => response); final controller = StreamMessageReminderListController.fromValue( PagedValue( @@ -198,11 +200,13 @@ void main() { final existingReminders = generateMessageReminders(); const chatError = StreamChatError('Network error'); - when(() => client.queryReminders( - filter: any(named: 'filter'), - sort: any(named: 'sort'), - pagination: any(named: 'pagination'), - )).thenThrow(chatError); + when( + () => client.queryReminders( + filter: any(named: 'filter'), + sort: any(named: 'sort'), + pagination: any(named: 'pagination'), + ), + ).thenThrow(chatError); final controller = StreamMessageReminderListController.fromValue( PagedValue( @@ -275,9 +279,7 @@ void main() { ); }); - test( - 'deleteReminder removes reminder and returns true when reminder exists', - () { + test('deleteReminder removes reminder and returns true when reminder exists', () { final reminders = generateMessageReminders(); final controller = StreamMessageReminderListController.fromValue( PagedValue(items: reminders), @@ -292,9 +294,9 @@ void main() { equals(reminders.length - 1), ); expect( - controller.value.asSuccess.items.any((r) => - r.messageId == reminders[0].messageId && - r.userId == reminders[0].userId), + controller.value.asSuccess.items.any( + (r) => r.messageId == reminders[0].messageId && r.userId == reminders[0].userId, + ), isFalse, ); }); @@ -438,9 +440,7 @@ void main() { expect( controller.value.asSuccess.items.any( - (r) => - r.messageId == initialReminders[0].messageId && - r.userId == initialReminders[0].userId, + (r) => r.messageId == initialReminders[0].messageId && r.userId == initialReminders[0].userId, ), isFalse, ); diff --git a/packages/stream_chat_flutter_core/test/stream_poll_controller_test.dart b/packages/stream_chat_flutter_core/test/stream_poll_controller_test.dart index ed5282eb63..408b4c9c29 100644 --- a/packages/stream_chat_flutter_core/test/stream_poll_controller_test.dart +++ b/packages/stream_chat_flutter_core/test/stream_poll_controller_test.dart @@ -11,10 +11,13 @@ void main() { }); test('Initialization with Custom Poll and Config', () { - final poll = Poll(name: 'Initial Poll', options: const [ - PollOption(text: 'Option 1'), - PollOption(text: 'Option 2'), - ]); + final poll = Poll( + name: 'Initial Poll', + options: const [ + PollOption(text: 'Option 1'), + PollOption(text: 'Option 2'), + ], + ); const config = PollConfig(nameRange: (min: 2, max: 50)); final pollController = StreamPollController(poll: poll, config: config); @@ -101,10 +104,7 @@ void main() { final errors = pollController.validateGranularly(); expect(errors.isEmpty, isFalse); - final containsNameRangeError = errors - .map((e) => e.mapOrNull(nameRange: (e) => e)) - .nonNulls - .isNotEmpty; + final containsNameRangeError = errors.map((e) => e.mapOrNull(nameRange: (e) => e)).nonNulls.isNotEmpty; expect(containsNameRangeError, isTrue); }); @@ -117,10 +117,7 @@ void main() { final errors = pollController.validateGranularly(); expect(errors.isEmpty, isFalse); - final containsDuplicateOptions = errors - .map((e) => e.mapOrNull(duplicateOptions: (e) => e)) - .nonNulls - .isNotEmpty; + final containsDuplicateOptions = errors.map((e) => e.mapOrNull(duplicateOptions: (e) => e)).nonNulls.isNotEmpty; expect(containsDuplicateOptions, isTrue); }); @@ -130,10 +127,7 @@ void main() { final errors = pollController.validateGranularly(); expect(errors.isEmpty, isFalse); - final containsOptionsRangeError = errors - .map((e) => e.mapOrNull(optionsRange: (e) => e)) - .nonNulls - .isNotEmpty; + final containsOptionsRangeError = errors.map((e) => e.mapOrNull(optionsRange: (e) => e)).nonNulls.isNotEmpty; expect(containsOptionsRangeError, isTrue); }); @@ -238,10 +232,7 @@ void main() { )..question = 'A' * 200; final errors = pollController.validateGranularly(); - final containsNameRangeError = errors - .map((e) => e.mapOrNull(nameRange: (e) => e)) - .nonNulls - .isNotEmpty; + final containsNameRangeError = errors.map((e) => e.mapOrNull(nameRange: (e) => e)).nonNulls.isNotEmpty; expect(containsNameRangeError, isFalse); }); @@ -256,10 +247,7 @@ void main() { } final errors = pollController.validateGranularly(); - final containsOptionsRangeError = errors - .map((e) => e.mapOrNull(optionsRange: (e) => e)) - .nonNulls - .isNotEmpty; + final containsOptionsRangeError = errors.map((e) => e.mapOrNull(optionsRange: (e) => e)).nonNulls.isNotEmpty; expect(containsOptionsRangeError, isFalse); }); diff --git a/packages/stream_chat_flutter_core/test/stream_reaction_list_controller_test.dart b/packages/stream_chat_flutter_core/test/stream_reaction_list_controller_test.dart new file mode 100644 index 0000000000..983d82b09b --- /dev/null +++ b/packages/stream_chat_flutter_core/test/stream_reaction_list_controller_test.dart @@ -0,0 +1,579 @@ +// ignore_for_file: avoid_redundant_argument_values + +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:stream_chat/stream_chat.dart' hide Success; +import 'package:stream_chat_flutter_core/src/paged_value_notifier.dart'; +import 'package:stream_chat_flutter_core/src/stream_reaction_list_controller.dart'; + +import 'mocks.dart'; + +Reaction generateReaction({ + String? messageId, + String? type, + String? userId, + DateTime? createdAt, +}) { + return Reaction( + messageId: messageId ?? 'message_123', + type: type ?? 'like', + userId: userId ?? 'user_123', + createdAt: createdAt ?? DateTime.now(), + ); +} + +List generateReactions({ + int count = 2, + String? messageId, + List? types, + List? userIds, + int? startId, +}) { + final now = DateTime.now(); + final baseId = startId ?? 1; + + return List.generate(count, (index) { + final type = types != null && index < types.length ? types[index] : 'like'; + final userId = userIds != null && index < userIds.length ? userIds[index] : 'user_${baseId + index}'; + + return generateReaction( + messageId: messageId ?? 'message_123', + type: type, + userId: userId, + createdAt: now.subtract(Duration(minutes: index)), + ); + }); +} + +void main() { + const messageId = 'message_123'; + + final client = MockClient(); + + setUpAll(() { + registerFallbackValue(const PaginationParams()); + registerFallbackValue(Filter.equal('type', 'like')); + }); + + setUp(() { + when(client.on).thenAnswer((_) => const Stream.empty()); + }); + + tearDown(() { + reset(client); + }); + + group('Initialization', () { + test('should start in loading state when created with client', () { + final controller = StreamReactionListController( + client: client, + messageId: messageId, + ); + expect(controller.value, isA()); + }); + + test('should preserve provided value when created with fromValue', () { + final reactions = generateReactions(); + final value = PagedValue(items: reactions); + final controller = StreamReactionListController.fromValue( + value, + client: client, + messageId: messageId, + ); + + expect(controller.value, same(value)); + expect(controller.value.asSuccess.items, equals(reactions)); + }); + }); + + group('Initial loading', () { + test('successfully loads reactions from API', () async { + final reactions = generateReactions(); + final response = QueryReactionsResponse() + ..reactions = reactions + ..next = null; + + when( + () => client.queryReactions( + any(), + filter: any(named: 'filter'), + sort: any(named: 'sort'), + pagination: any(named: 'pagination'), + ), + ).thenAnswer((_) async => response); + + final controller = StreamReactionListController( + client: client, + messageId: messageId, + ); + + await controller.doInitialLoad(); + await pumpEventQueue(); + + verify( + () => client.queryReactions( + messageId, + filter: any(named: 'filter'), + sort: any(named: 'sort'), + pagination: any(named: 'pagination'), + ), + ).called(1); + + expect(controller.value, isA>()); + expect(controller.value.asSuccess.items, equals(reactions)); + }); + + test('sets next page key when API returns next cursor', () async { + const nextCursor = 'next_cursor_token'; + final reactions = generateReactions(); + final response = QueryReactionsResponse() + ..reactions = reactions + ..next = nextCursor; + + when( + () => client.queryReactions( + any(), + filter: any(named: 'filter'), + sort: any(named: 'sort'), + pagination: any(named: 'pagination'), + ), + ).thenAnswer((_) async => response); + + final controller = StreamReactionListController( + client: client, + messageId: messageId, + ); + + await controller.doInitialLoad(); + await pumpEventQueue(); + + expect(controller.value.asSuccess.nextPageKey, equals(nextCursor)); + }); + + test('sets null next page key when API returns empty next cursor', () async { + final reactions = generateReactions(); + final response = QueryReactionsResponse() + ..reactions = reactions + ..next = ''; + + when( + () => client.queryReactions( + any(), + filter: any(named: 'filter'), + sort: any(named: 'sort'), + pagination: any(named: 'pagination'), + ), + ).thenAnswer((_) async => response); + + final controller = StreamReactionListController( + client: client, + messageId: messageId, + ); + + await controller.doInitialLoad(); + await pumpEventQueue(); + + expect(controller.value.asSuccess.nextPageKey, isNull); + }); + + test('handles StreamChatError by transitioning to error state', () async { + const chatError = StreamChatError('Network error'); + + when( + () => client.queryReactions( + any(), + filter: any(named: 'filter'), + sort: any(named: 'sort'), + pagination: any(named: 'pagination'), + ), + ).thenThrow(chatError); + + final controller = StreamReactionListController( + client: client, + messageId: messageId, + ); + + await controller.doInitialLoad(); + await pumpEventQueue(); + + expect(controller.value, isA()); + expect((controller.value as Error).error, equals(chatError)); + }); + + test('wraps generic exceptions in StreamChatError', () async { + final exception = Exception('API unavailable'); + + when( + () => client.queryReactions( + any(), + filter: any(named: 'filter'), + sort: any(named: 'sort'), + pagination: any(named: 'pagination'), + ), + ).thenThrow(exception); + + final controller = StreamReactionListController( + client: client, + messageId: messageId, + ); + + await controller.doInitialLoad(); + await pumpEventQueue(); + + expect(controller.value, isA()); + expect( + (controller.value as Error).error.message, + contains('API unavailable'), + ); + }); + }); + + group('Pagination', () { + test('loadMore appends new reactions to existing items', () async { + const nextKey = 'next_page_token'; + final existingReactions = generateReactions(); + final additionalReactions = generateReactions( + count: 1, + userIds: ['user_999'], + startId: 999, + ); + + final response = QueryReactionsResponse() + ..reactions = additionalReactions + ..next = null; + + when( + () => client.queryReactions( + any(), + filter: any(named: 'filter'), + sort: any(named: 'sort'), + pagination: any(named: 'pagination'), + ), + ).thenAnswer((_) async => response); + + final controller = StreamReactionListController.fromValue( + PagedValue( + items: existingReactions, + nextPageKey: nextKey, + ), + client: client, + messageId: messageId, + ); + + await controller.loadMore(nextKey); + await pumpEventQueue(); + + final mergedReactions = [...existingReactions, ...additionalReactions]; + + expect( + controller.value.asSuccess.items.length, + equals(mergedReactions.length), + ); + expect(controller.value.asSuccess.nextPageKey, isNull); + }); + + test('loadMore passes next cursor to API', () async { + const nextKey = 'cursor_page_2'; + final existingReactions = generateReactions(); + + final response = QueryReactionsResponse() + ..reactions = [] + ..next = null; + + PaginationParams? capturedPagination; + when( + () => client.queryReactions( + any(), + filter: any(named: 'filter'), + sort: any(named: 'sort'), + pagination: any(named: 'pagination'), + ), + ).thenAnswer((invocation) async { + capturedPagination = invocation.namedArguments[const Symbol('pagination')] as PaginationParams?; + return response; + }); + + final controller = StreamReactionListController.fromValue( + PagedValue( + items: existingReactions, + nextPageKey: nextKey, + ), + client: client, + messageId: messageId, + ); + + await controller.loadMore(nextKey); + await pumpEventQueue(); + + expect(capturedPagination?.next, equals(nextKey)); + }); + + test('loadMore preserves existing items on StreamChatError', () async { + const nextKey = 'next_page_token'; + final existingReactions = generateReactions(); + const chatError = StreamChatError('Network error'); + + when( + () => client.queryReactions( + any(), + filter: any(named: 'filter'), + sort: any(named: 'sort'), + pagination: any(named: 'pagination'), + ), + ).thenThrow(chatError); + + final controller = StreamReactionListController.fromValue( + PagedValue( + items: existingReactions, + nextPageKey: nextKey, + ), + client: client, + messageId: messageId, + ); + + await controller.loadMore(nextKey); + await pumpEventQueue(); + + expect(controller.value.isSuccess, isTrue); + expect(controller.value.asSuccess.items, equals(existingReactions)); + expect(controller.value.asSuccess.error, equals(chatError)); + }); + + test('loadMore preserves existing items on generic error', () async { + const nextKey = 'next_page_token'; + final existingReactions = generateReactions(); + final exception = Exception('Network error'); + + when( + () => client.queryReactions( + any(), + filter: any(named: 'filter'), + sort: any(named: 'sort'), + pagination: any(named: 'pagination'), + ), + ).thenThrow(exception); + + final controller = StreamReactionListController.fromValue( + PagedValue( + items: existingReactions, + nextPageKey: nextKey, + ), + client: client, + messageId: messageId, + ); + + await controller.loadMore(nextKey); + await pumpEventQueue(); + + expect(controller.value.isSuccess, isTrue); + expect(controller.value.asSuccess.items, equals(existingReactions)); + expect(controller.value.asSuccess.error, isNotNull); + expect( + controller.value.asSuccess.error!.message, + contains('Network error'), + ); + }); + }); + + group('reactions setter', () { + test('replaces reactions when in success state', () { + final initial = generateReactions(count: 3); + final replacement = generateReactions(count: 1, userIds: ['user_new']); + + final controller = StreamReactionListController.fromValue( + PagedValue(items: initial), + client: client, + messageId: messageId, + ); + + expect(controller.value.isSuccess, isTrue); + expect(controller.value.asSuccess.items, equals(initial)); + + controller.reactions = replacement; + + expect(controller.value.asSuccess.items, equals(replacement)); + expect(controller.value.asSuccess.items.length, equals(1)); + }); + + test('creates new success value when not in success state', () { + final reactions = generateReactions(); + final controller = StreamReactionListController( + client: client, + messageId: messageId, + ); + + // Controller is in loading state + expect(controller.value.isNotSuccess, isTrue); + + controller.reactions = reactions; + + expect(controller.value.isSuccess, isTrue); + expect(controller.value.asSuccess.items, equals(reactions)); + }); + + test('preserves nextPageKey when replacing reactions', () { + const nextKey = 'next_cursor'; + final initial = generateReactions(); + final replacement = generateReactions(count: 1, userIds: ['user_new']); + + final controller = StreamReactionListController.fromValue( + PagedValue(items: initial, nextPageKey: nextKey), + client: client, + messageId: messageId, + ); + + expect(controller.value.isSuccess, isTrue); + expect(controller.value.asSuccess.items, equals(initial)); + expect(controller.value.asSuccess.nextPageKey, equals(nextKey)); + + controller.reactions = replacement; + + expect(controller.value.asSuccess.items, equals(replacement)); + expect(controller.value.asSuccess.nextPageKey, equals(nextKey)); + }); + }); + + group('Filtering and sorting', () { + test('refresh resets filter and sort to initial values', () async { + final reactions = generateReactions(); + final initialFilter = Filter.equal('type', 'like'); + final sort = [const SortOption.desc(ReactionSortKey.createdAt)]; + + final apiCalls = >[]; + + when( + () => client.queryReactions( + any(), + filter: any(named: 'filter'), + sort: any(named: 'sort'), + pagination: any(named: 'pagination'), + ), + ).thenAnswer((invocation) async { + apiCalls.add({ + 'filter': invocation.namedArguments[const Symbol('filter')], + 'sort': invocation.namedArguments[const Symbol('sort')], + }); + return QueryReactionsResponse() + ..reactions = reactions + ..next = null; + }); + + final controller = StreamReactionListController( + client: client, + messageId: messageId, + filter: initialFilter, + sort: sort, + ); + + await controller.doInitialLoad(); + await pumpEventQueue(); + + // Change filter and sort at runtime + controller + ..filter = Filter.equal('type', 'love') + ..sort = [const SortOption.asc(ReactionSortKey.createdAt)]; + + await controller.refresh(); + await pumpEventQueue(); + + expect(apiCalls.length, equals(2)); + + final refreshCall = apiCalls.last; + expect(refreshCall['filter'], equals(initialFilter)); + expect(refreshCall['sort'], equals(sort)); + }); + + test('refresh with resetValue=false preserves current filter and sort', () async { + final reactions = generateReactions(); + final initialFilter = Filter.equal('type', 'like'); + final initialSort = [const SortOption.desc(ReactionSortKey.createdAt)]; + final newFilter = Filter.equal('type', 'love'); + final newSort = [const SortOption.asc(ReactionSortKey.createdAt)]; + + final apiCalls = >[]; + + when( + () => client.queryReactions( + any(), + filter: any(named: 'filter'), + sort: any(named: 'sort'), + pagination: any(named: 'pagination'), + ), + ).thenAnswer((invocation) async { + apiCalls.add({ + 'filter': invocation.namedArguments[const Symbol('filter')], + 'sort': invocation.namedArguments[const Symbol('sort')], + }); + return QueryReactionsResponse() + ..reactions = reactions + ..next = null; + }); + + final controller = StreamReactionListController( + client: client, + messageId: messageId, + filter: initialFilter, + sort: initialSort, + ); + + await controller.doInitialLoad(); + await pumpEventQueue(); + + controller + ..filter = newFilter + ..sort = newSort; + + await controller.refresh(resetValue: false); + await pumpEventQueue(); + + expect(apiCalls.length, equals(2)); + + final refreshCall = apiCalls.last; + expect(refreshCall['filter'], equals(newFilter)); + expect(refreshCall['sort'], equals(newSort)); + }); + + test('value setter sorts items when sort is provided', () async { + final now = DateTime.now(); + final older = generateReaction(userId: 'user_1', createdAt: now.subtract(const Duration(hours: 1))); + final newer = generateReaction(userId: 'user_2', createdAt: now); + + final response = QueryReactionsResponse() + ..reactions = [older, newer] + ..next = null; + + when( + () => client.queryReactions( + any(), + filter: any(named: 'filter'), + sort: any(named: 'sort'), + pagination: any(named: 'pagination'), + ), + ).thenAnswer((_) async => response); + + final controller = StreamReactionListController( + client: client, + messageId: messageId, + sort: [const SortOption.desc(ReactionSortKey.createdAt)], + ); + + await controller.doInitialLoad(); + await pumpEventQueue(); + + // desc order: newer first + expect(controller.value.asSuccess.items.first.userId, equals('user_2')); + expect(controller.value.asSuccess.items.last.userId, equals('user_1')); + }); + }); + + group('Disposal', () { + test('dispose completes without errors', () { + final controller = StreamReactionListController( + client: client, + messageId: messageId, + )..doInitialLoad(); + + expect(controller.dispose, returnsNormally); + }); + }); +} diff --git a/packages/stream_chat_flutter_core/test/stream_thread_list_event_handler_test.dart b/packages/stream_chat_flutter_core/test/stream_thread_list_event_handler_test.dart index 2dd163d6df..f4608b16f1 100644 --- a/packages/stream_chat_flutter_core/test/stream_thread_list_event_handler_test.dart +++ b/packages/stream_chat_flutter_core/test/stream_thread_list_event_handler_test.dart @@ -5,8 +5,7 @@ import 'package:stream_chat_flutter_core/src/stream_thread_list_controller.dart' import 'package:stream_chat_flutter_core/src/stream_thread_list_event_handler.dart'; // Mock classes -class MockStreamThreadListController extends Mock - implements StreamThreadListController {} +class MockStreamThreadListController extends Mock implements StreamThreadListController {} class MockEvent extends Mock implements Event {} @@ -83,8 +82,7 @@ void main() { () { when(() => mockEvent.message).thenReturn(mockMessage); when(() => mockMessage.parentId).thenReturn('parent-id'); - when(() => mockController.getThread( - parentMessageId: any(named: 'parentMessageId'))).thenReturn(null); + when(() => mockController.getThread(parentMessageId: any(named: 'parentMessageId'))).thenReturn(null); handler.onNotificationThreadMessageNew(mockEvent, mockController); verify(() => mockController.addUnseenThreadId('parent-id')); @@ -108,12 +106,10 @@ void main() { when(() => mockMessage.parentId).thenReturn(null); when(() => mockEvent.message).thenReturn(mockMessage); when(() => mockEvent.hardDelete).thenReturn(true); - when(() => mockController.deleteThread(parentMessageId: 'message-id')) - .thenReturn(true); + when(() => mockController.deleteThread(parentMessageId: 'message-id')).thenReturn(true); handler.onMessageDeleted(mockEvent, mockController); - verify( - () => mockController.deleteThread(parentMessageId: 'message-id')); + verify(() => mockController.deleteThread(parentMessageId: 'message-id')); verifyNever(() => mockController.deleteReply(any())); }, ); @@ -178,27 +174,23 @@ void main() { when(() => mockEvent.cid).thenReturn('channel-cid'); handler.onChannelDeleted(mockEvent, mockController); - verify(() => - mockController.deleteThreadByChannelCid(channelCid: 'channel-cid')); + verify(() => mockController.deleteThreadByChannelCid(channelCid: 'channel-cid')); }); test('onChannelTruncated deletes threads by channel cid', () { when(() => mockEvent.cid).thenReturn('channel-cid'); handler.onChannelTruncated(mockEvent, mockController); - verify(() => - mockController.deleteThreadByChannelCid(channelCid: 'channel-cid')); + verify(() => mockController.deleteThreadByChannelCid(channelCid: 'channel-cid')); }); test('onMessageRead marks thread as read', () { - when(() => mockThread.copyWith(read: any(named: 'read'))) - .thenReturn(mockThread); + when(() => mockThread.copyWith(read: any(named: 'read'))).thenReturn(mockThread); when(() => mockThread.parentMessageId).thenReturn('parent-id'); when(() => mockEvent.thread).thenReturn(mockThread); when(() => mockEvent.user).thenReturn(mockUser); when(() => mockEvent.createdAt).thenReturn(DateTime.now()); - when(() => mockController.getThread(parentMessageId: 'parent-id')) - .thenReturn(mockThread); + when(() => mockController.getThread(parentMessageId: 'parent-id')).thenReturn(mockThread); when(() => mockController.updateThread(mockThread)).thenReturn(true); handler.onMessageRead(mockEvent, mockController); @@ -208,14 +200,12 @@ void main() { }); test('onNotificationMarkUnread marks thread as unread', () { - when(() => mockThread.copyWith(read: any(named: 'read'))) - .thenReturn(mockThread); + when(() => mockThread.copyWith(read: any(named: 'read'))).thenReturn(mockThread); when(() => mockThread.parentMessageId).thenReturn('parent-id'); when(() => mockEvent.thread).thenReturn(mockThread); when(() => mockEvent.user).thenReturn(mockUser); when(() => mockEvent.createdAt).thenReturn(DateTime.now()); - when(() => mockController.getThread(parentMessageId: 'parent-id')) - .thenReturn(mockThread); + when(() => mockController.getThread(parentMessageId: 'parent-id')).thenReturn(mockThread); when(() => mockController.updateThread(mockThread)).thenReturn(true); handler.onNotificationMarkUnread(mockEvent, mockController); diff --git a/packages/stream_chat_localizations/CHANGELOG.md b/packages/stream_chat_localizations/CHANGELOG.md index 025eebebff..dc17364220 100644 --- a/packages/stream_chat_localizations/CHANGELOG.md +++ b/packages/stream_chat_localizations/CHANGELOG.md @@ -1,48 +1,151 @@ +## Upcoming + +🛑️ Breaking + +- Renamed `attachmentsUploadProgressText` parameter `remaining` → `completed` + and reworded the translation across all locales to report completed count + instead of remaining. +- Renamed `questionsLabel` getter → `questionLabel({bool isPlural = false})` + method across all supported locales. +- Renamed `endVoteConfirmationText` → `endVoteConfirmationTitle` across all + supported locales; English default changed to `'End This Poll?'`. + +✅ Added + +- Added `commandUsernameLabel` translation (default `@username`) for all supported + locales. Used by the message composer placeholder when user-target commands + (`/mute`, `/unmute`, `/ban`, `/unban`) are active. +- Added `unsupportedAttachmentLabel` translation for all supported locales. +- Added `linkAttachmentText` translation for all supported locales (used by + `MessagePreviewFormatter` for link-preview attachments). +- Added `confirmLabel`, `emptyReactionsText`, `loadingReactionsError`, and + `tapToRemoveReactionLabel` translations for all supported locales. +- Added `justNowLabel`, `replyToUserLabel`, `multipleAnswersDescription`, + `maximumVotesPerPersonDescription`, `anonymousPollDescription`, + `suggestAnOptionDescription`, and `addACommentDescription` translations for + all supported locales. +- Added `totalVoteCountLabel({int? count})` translation for all supported + locales. +- Added `viewAllLabel` translation for all supported locales. +- Added `pollVotesLabel` translation for all supported locales. +- Added `endVoteConfirmationMessage` translation for all supported locales. +- Added `reactionsCountText(int count)` translation for all supported locales. +- Added `photosAndVideosLabel` translation (default `Photos & Videos`) for all + supported locales. Used by the new media gallery preview footer's thumbnail- + grid sheet header. + +🔄 Changed + +- Reworded `emptyMessagesText` across all locales to the shorter "No messages + yet" style (English default: `'There are no messages currently'` → + `'No messages yet'`) to match the redesigned empty state copy. +- Reworded `writeAMessageLabel` across all locales to use a "Send a message" + style (English default: `'Write a message'` → `'Send a message'`) to match + the redesigned message composer placeholder. +- Reworded `endVoteLabel` English override from `'End Vote'` to `'End Poll'`. +- Reworded `flagLabel`, `cancelLabel` and `deleteLabel` defaults from uppercase to sentence case across all supported locales (e.g. English: `'FLAG'` → `'Flag'`, `'CANCEL'` → `'Cancel'`, `'DELETE'` → `'Delete'`) so dialog buttons render in the same case as the rest of the system. + +## 10.0.0-beta.13 + +🛑️ Breaking +- SDK Redesign Changes. For more details, please refer to the [migration guide](https://github.com/GetStream/stream-chat-flutter/blob/210ff93f955be3f85c62e860309bd9aa240a5446/migrations). + The SDK redesign introduces a fresher default UI, but also better APIs for customization of the components. + +## 10.0.0-beta.12 + +- Included the changes from version [`9.23.0`](https://pub.dev/packages/stream_chat_localizations/changelog). + ## 9.23.0 - Fixed Italian translation for `unreadMessagesSeparatorText` (was incorrectly showing French text "Nouveaux messages" instead of Italian "Nuovi messaggi"). +## 10.0.0-beta.11 + +- Included the changes from version [`9.22.0`](https://pub.dev/packages/stream_chat_localizations/changelog). + ## 9.22.0 - Added translations for new `deletePollOptionLabel` label. - Added translations for new `deletePollOptionQuestion` text. +## 10.0.0-beta.10 + +- Included the changes from version [`9.21.0`](https://pub.dev/packages/stream_chat_localizations/changelog). + ## 9.21.0 - Updated `stream_chat_flutter` dependency to [`9.21.0`](https://pub.dev/packages/stream_chat_flutter/changelog). +## 10.0.0-beta.9 + +- Included the changes from version [`9.20.0`](https://pub.dev/packages/stream_chat_localizations/changelog). + ## 9.20.0 - Updated `stream_chat_flutter` dependency to [`9.20.0`](https://pub.dev/packages/stream_chat_flutter/changelog). +## 10.0.0-beta.8 + +- Included the changes from version [`9.19.0`](https://pub.dev/packages/stream_chat_localizations/changelog). + ## 9.19.0 - Updated `stream_chat_flutter` dependency to [`9.19.0`](https://pub.dev/packages/stream_chat_flutter/changelog). +## 10.0.0-beta.7 + +- Included the changes from version [`9.18.0`](https://pub.dev/packages/stream_chat_localizations/changelog). + ## 9.18.0 - Updated `stream_chat_flutter` dependency to [`9.18.0`](https://pub.dev/packages/stream_chat_flutter/changelog). +## 10.0.0-beta.6 + +- Included the changes from version [`9.17.0`](https://pub.dev/packages/stream_chat_localizations/changelog). + ## 9.17.0 - Updated `stream_chat_flutter` dependency to [`9.17.0`](https://pub.dev/packages/stream_chat_flutter/changelog). +## 10.0.0-beta.5 + +- Included the changes from version [`9.16.0`](https://pub.dev/packages/stream_chat_localizations/changelog). + ## 9.16.0 - Updated `stream_chat_flutter` dependency to [`9.16.0`](https://pub.dev/packages/stream_chat_flutter/changelog). +## 10.0.0-beta.4 + +- Added translations for new `locationLabel` label. + +- Included the changes from version [`9.15.0`](https://pub.dev/packages/stream_chat_localizations/changelog). + ## 9.15.0 - Updated `stream_chat_flutter` dependency to [`9.15.0`](https://pub.dev/packages/stream_chat_flutter/changelog). +## 10.0.0-beta.3 + +- Included the changes from version [`9.14.0`](https://pub.dev/packages/stream_chat_localizations/changelog). + ## 9.14.0 - Updated `stream_chat_flutter` dependency to [`9.14.0`](https://pub.dev/packages/stream_chat_flutter/changelog). +## 10.0.0-beta.2 + +- Included the changes from version [`9.13.0`](https://pub.dev/packages/stream_chat_localizations/changelog). + ## 9.13.0 - Updated `stream_chat_flutter` dependency to [`9.13.0`](https://pub.dev/packages/stream_chat_flutter/changelog). +## 10.0.0-beta.1 + +- Updated `stream_chat_flutter` dependency to [`10.0.0-beta.1`](https://pub.dev/packages/stream_chat_flutter/changelog). + ## 9.12.0 - Updated `stream_chat_flutter` dependency to [`9.12.0`](https://pub.dev/packages/stream_chat_flutter/changelog). diff --git a/packages/stream_chat_localizations/example/lib/add_new_lang.dart b/packages/stream_chat_localizations/example/lib/add_new_lang.dart index ce38cf422e..1b9bf4264a 100644 --- a/packages/stream_chat_localizations/example/lib/add_new_lang.dart +++ b/packages/stream_chat_localizations/example/lib/add_new_lang.dart @@ -3,16 +3,14 @@ import 'package:flutter/material.dart'; import 'package:stream_chat_flutter/stream_chat_flutter.dart'; import 'package:stream_chat_localizations/stream_chat_localizations.dart'; -class _NnStreamChatLocalizationsDelegate - extends LocalizationsDelegate { +class _NnStreamChatLocalizationsDelegate extends LocalizationsDelegate { const _NnStreamChatLocalizationsDelegate(); @override bool isSupported(Locale locale) => locale.languageCode == 'nn'; @override - Future load(Locale locale) => - SynchronousFuture(const NnStreamChatLocalizations()); + Future load(Locale locale) => SynchronousFuture(const NnStreamChatLocalizations()); @override bool shouldReload(_NnStreamChatLocalizationsDelegate old) => false; @@ -71,10 +69,9 @@ class NnStreamChatLocalizations extends GlobalStreamChatLocalizations { @override String attachmentsUploadProgressText({ - required int remaining, + required int completed, required int total, - }) => - 'Uploading $remaining/$total ...'; + }) => 'Uploaded $completed of $total ...'; @override String pinnedByUserText({ @@ -87,11 +84,10 @@ class NnStreamChatLocalizations extends GlobalStreamChatLocalizations { } @override - String get sendMessagePermissionError => - "You don't have permission to send messages"; + String get sendMessagePermissionError => "You don't have permission to send messages"; @override - String get emptyMessagesText => 'There are no messages currently'; + String get emptyMessagesText => 'No messages yet'; @override String get genericErrorText => 'Something went wrong'; @@ -122,8 +118,8 @@ class NnStreamChatLocalizations extends GlobalStreamChatLocalizations { @override String threadSeparatorText(int replyCount) { - if (replyCount == 1) return '1 Reply'; - return '$replyCount Replies'; + if (replyCount == 1) return '1 reply'; + return '$replyCount replies'; } @override @@ -136,7 +132,7 @@ class NnStreamChatLocalizations extends GlobalStreamChatLocalizations { String get reconnectingLabel => 'Reconnecting...'; @override - String get alsoSendAsDirectMessageLabel => 'Also send as direct message'; + String get alsoSendAsDirectMessageLabel => 'Also send in Channel'; @override String get addACommentOrSendLabel => 'Add a comment or send'; @@ -145,7 +141,7 @@ class NnStreamChatLocalizations extends GlobalStreamChatLocalizations { String get searchGifLabel => 'Search GIFs'; @override - String get writeAMessageLabel => 'Write a message'; + String get writeAMessageLabel => 'Send a message'; @override String get instantCommandsLabel => 'Instant Commands'; @@ -161,8 +157,7 @@ class NnStreamChatLocalizations extends GlobalStreamChatLocalizations { 'The file is too large to upload. The file size limit is $limitInMB MB.'; @override - String get couldNotReadBytesFromFileError => - 'Could not read bytes from file.'; + String get couldNotReadBytesFromFileError => 'Could not read bytes from file.'; @override String get addAFileLabel => 'Add a file'; @@ -189,7 +184,7 @@ class NnStreamChatLocalizations extends GlobalStreamChatLocalizations { String get somethingWentWrongError => 'Something went wrong'; @override - String get addMoreFilesLabel => 'Add more files'; + String get addMoreFilesLabel => 'Add more'; @override String get enablePhotoAndVideoAccessMessage => @@ -217,8 +212,7 @@ class NnStreamChatLocalizations extends GlobalStreamChatLocalizations { String get flagMessageSuccessfulLabel => 'Message flagged'; @override - String get flagMessageSuccessfulText => - 'The message has been reported to a moderator.'; + String get flagMessageSuccessfulText => 'The message has been reported to a moderator.'; @override String get deleteLabel => 'DELETE'; @@ -227,12 +221,10 @@ class NnStreamChatLocalizations extends GlobalStreamChatLocalizations { String get deleteMessageLabel => 'Delete Message'; @override - String get deleteMessageQuestion => - 'Are you sure you want to permanently delete this\nmessage?'; + String get deleteMessageQuestion => 'Are you sure you want to permanently delete this\nmessage?'; @override - String get operationCouldNotBeCompletedText => - "The operation couldn't be completed."; + String get operationCouldNotBeCompletedText => "The operation couldn't be completed."; @override String get replyLabel => 'Reply'; @@ -264,6 +256,9 @@ class NnStreamChatLocalizations extends GlobalStreamChatLocalizations { @override String get photosLabel => 'Photos'; + @override + String get photosAndVideosLabel => 'Photos & Videos'; + String _getDay(DateTime dateTime) { final now = DateTime.now(); final today = DateTime(now.year, now.month, now.day); @@ -302,8 +297,7 @@ class NnStreamChatLocalizations extends GlobalStreamChatLocalizations { String get letsStartChattingLabel => 'Let’s start chatting!'; @override - String get sendingFirstMessageLabel => - 'How about sending your first message to a friend?'; + String get sendingFirstMessageLabel => 'How about sending your first message to a friend?'; @override String get startAChatLabel => 'Start a chat'; @@ -315,8 +309,7 @@ class NnStreamChatLocalizations extends GlobalStreamChatLocalizations { String get deleteConversationLabel => 'Delete Conversation'; @override - String get deleteConversationQuestion => - 'Are you sure you want to delete this conversation?'; + String get deleteConversationQuestion => 'Are you sure you want to delete this conversation?'; @override String get streamChatLabel => 'Stream Chat'; @@ -342,6 +335,16 @@ class NnStreamChatLocalizations extends GlobalStreamChatLocalizations { return '$count Online'; } + @override + String membersCountWithOnlineText({ + required int memberCount, + required int onlineCount, + }) { + final members = membersCountText(memberCount); + if (onlineCount <= 0) return members; + return '$members, ${watchersCountText(onlineCount)}'; + } + @override String get viewInfoLabel => 'View Info'; @@ -355,8 +358,7 @@ class NnStreamChatLocalizations extends GlobalStreamChatLocalizations { String get leaveConversationLabel => 'Leave conversation'; @override - String get leaveConversationQuestion => - 'Are you sure you want to leave this conversation?'; + String get leaveConversationQuestion => 'Are you sure you want to leave this conversation?'; @override String get showInChatLabel => 'Show in Chat'; @@ -392,8 +394,7 @@ class NnStreamChatLocalizations extends GlobalStreamChatLocalizations { String galleryPaginationText({ required int currentPage, required int totalPages, - }) => - '$currentPage of $totalPages'; + }) => '$currentPage of $totalPages'; @override String get fileText => 'File'; @@ -402,12 +403,14 @@ class NnStreamChatLocalizations extends GlobalStreamChatLocalizations { String get replyToMessageLabel => 'Reply to Message'; @override - String attachmentLimitExceedError(int limit) => - 'Attachment limit exceeded, limit: $limit'; + String attachmentLimitExceedError(int limit) => 'Attachment limit exceeded, limit: $limit'; @override String get slowModeOnLabel => 'Slow mode ON'; + @override + String get commandUsernameLabel => '@username'; + @override String get downloadLabel => 'Download'; @@ -457,8 +460,7 @@ class NnStreamChatLocalizations extends GlobalStreamChatLocalizations { } @override - String get linkDisabledDetails => - 'Sending links is not allowed in this conversation.'; + String get linkDisabledDetails => 'Sending links is not allowed in this conversation.'; @override String get linkDisabledError => 'Links are disabled'; @@ -495,7 +497,10 @@ class NnStreamChatLocalizations extends GlobalStreamChatLocalizations { } @override - String get questionsLabel => 'Questions'; + String questionLabel({bool isPlural = false}) { + if (isPlural) return 'Questions'; + return 'Question'; + } @override String get askAQuestionLabel => 'Ask a question'; @@ -578,15 +583,17 @@ class NnStreamChatLocalizations extends GlobalStreamChatLocalizations { String get enterYourCommentLabel => 'Enter your comment'; @override - String get endVoteConfirmationText => - 'Are you sure you want to end the poll?'; + String get endVoteConfirmationTitle => 'End This Poll?'; + + @override + String get endVoteConfirmationMessage => + 'Do you want to end this poll now? Nobody will be able to vote in this poll anymore.'; @override String get deletePollOptionLabel => 'Delete Option'; @override - String get deletePollOptionQuestion => - 'Are you sure you want to delete this option?'; + String get deletePollOptionQuestion => 'Are you sure you want to delete this option?'; @override String get createLabel => 'Create'; @@ -617,23 +624,36 @@ class NnStreamChatLocalizations extends GlobalStreamChatLocalizations { String get viewResultsLabel => 'View Results'; @override - String get endVoteLabel => 'End Vote'; + String get endVoteLabel => 'End Poll'; @override String get pollResultsLabel => 'Poll Results'; + @override + String get pollVotesLabel => 'Votes'; + @override String showAllVotesLabel({int? count}) { if (count == null) return 'Show all votes'; return 'Show all $count votes'; } + @override + String get viewAllLabel => 'View all'; + @override String voteCountLabel({int? count}) => switch (count) { - null || < 1 => '0 votes', - 1 => '1 vote', - _ => '$count votes', - }; + null || < 1 => '0 votes', + 1 => '1 vote', + _ => '$count votes', + }; + + @override + String totalVoteCountLabel({int? count}) => switch (count) { + null || < 1 => '0 votes total', + 1 => '1 vote total', + _ => '$count votes total', + }; @override String get noPollVotesLabel => 'There are no poll votes currently'; @@ -650,6 +670,9 @@ class NnStreamChatLocalizations extends GlobalStreamChatLocalizations { return '$count new threads'; } + @override + String get loadingLabel => 'Loading...'; + @override String get slideToCancelLabel => 'Slide to cancel'; @@ -660,8 +683,7 @@ class NnStreamChatLocalizations extends GlobalStreamChatLocalizations { String get sendAnywayLabel => 'Send Anyway'; @override - String get moderatedMessageBlockedText => - 'Message was blocked by moderation policies'; + String get moderatedMessageBlockedText => 'Message was blocked by moderation policies'; @override String get moderationReviewModalTitle => 'Are you sure?'; @@ -699,6 +721,120 @@ class NnStreamChatLocalizations extends GlobalStreamChatLocalizations { @override String get draftLabel => 'Draft'; + + @override + String locationLabel({bool isLive = false}) { + if (isLive) return 'Live Location'; + return 'Location'; + } + + @override + String get fileAttachmentText => 'File'; + + @override + String get linkAttachmentText => 'Link'; + + @override + String filesAttachmentCountText(int count) { + return count == 1 ? 'File' : '$count files'; + } + + @override + String photosAttachmentCountText(int count) { + return count == 1 ? 'Photo' : '$count photos'; + } + + @override + String videosAttachmentCountText(int count) { + return count == 1 ? 'Video' : '$count videos'; + } + + @override + String get noConversationsYetText => 'No conversations yet'; + + @override + String get replyToStartThreadText => 'Reply to a message to start a thread'; + + @override + String get sendMessageToStartConversationText => 'Send a message to start the conversation'; + + @override + String get savedForLaterLabel => 'Saved for later'; + + @override + String get repliedToThreadAnnotationLabel => 'Replied to a thread'; + + @override + String get alsoSentInChannelAnnotationLabel => 'Also sent in channel'; + + @override + String get viewLabel => 'View'; + + @override + String get reminderSetLabel => 'Reminder set'; + + @override + String reminderAtText(String time) => 'Today at $time'; + + @override + String get createPollPromptLabel => 'Create a poll and let everyone vote!'; + + @override + String get takePhotoAndShareLabel => 'Take a photo and share'; + + @override + String get takeVideoAndShareLabel => 'Take a video and share'; + + @override + String get openCameraLabel => 'Open camera'; + + @override + String get selectFilesToShareLabel => 'Select files to share'; + + @override + String get openFilesLabel => 'Open files'; + + @override + String get unsupportedAttachmentLabel => 'Unsupported Attachment'; + + @override + String get confirmLabel => 'CONFIRM'; + + @override + String get emptyReactionsText => 'No reactions yet'; + + @override + String get loadingReactionsError => 'Error loading reactions'; + + @override + String get tapToRemoveReactionLabel => 'Tap to remove'; + + @override + String reactionsCountText(int count) => count == 1 ? '1 Reaction' : '$count Reactions'; + + @override + String get justNowLabel => 'Just now'; + + @override + String replyToUserLabel(String userName) => 'Reply to $userName'; + + @override + String get multipleAnswersDescription => 'Select more than one option'; + + @override + String maximumVotesPerPersonDescription([Range? range]) { + final (:min, :max) = range ?? (min: 2, max: 10); + return 'Choose between $min\u2013$max options'; + } + + @override + String get anonymousPollDescription => 'Hide who voted'; + + @override + String get suggestAnOptionDescription => 'Let others add options'; + + @override + String get addACommentDescription => 'Allow others to add comments'; } void main() async { @@ -810,14 +946,14 @@ class ChannelPage extends StatelessWidget { @override Widget build(BuildContext context) { - return const Scaffold( - appBar: StreamChannelHeader(), + return Scaffold( + appBar: const StreamChannelHeader(), body: Column( children: [ - Expanded( + const Expanded( child: StreamMessageListView(), ), - StreamMessageInput(), + StreamMessageComposer(), ], ), ); diff --git a/packages/stream_chat_localizations/example/lib/main.dart b/packages/stream_chat_localizations/example/lib/main.dart index 22abb65fab..d10937249e 100644 --- a/packages/stream_chat_localizations/example/lib/main.dart +++ b/packages/stream_chat_localizations/example/lib/main.dart @@ -64,32 +64,32 @@ class MyApp extends StatelessWidget { @override Widget build(BuildContext context) => MaterialApp( - theme: ThemeData.light(), - darkTheme: ThemeData.dark(), - // Add all the supported locales - supportedLocales: const [ - Locale('en'), - Locale('hi'), - Locale('fr'), - Locale('it'), - Locale('es'), - Locale('ja'), - Locale('ko'), - Locale('pt'), - ], - // Add GlobalStreamChatLocalizations.delegates - localizationsDelegates: GlobalStreamChatLocalizations.delegates, - // Programatically set the locale (this is a global change) - locale: const Locale('fr'), - builder: (context, widget) => StreamChat( - client: client, - child: widget, - ), - home: StreamChannel( - channel: channel, - child: const ChannelPage(), - ), - ); + theme: ThemeData.light(), + darkTheme: ThemeData.dark(), + // Add all the supported locales + supportedLocales: const [ + Locale('en'), + Locale('hi'), + Locale('fr'), + Locale('it'), + Locale('es'), + Locale('ja'), + Locale('ko'), + Locale('pt'), + ], + // Add GlobalStreamChatLocalizations.delegates + localizationsDelegates: GlobalStreamChatLocalizations.delegates, + // Programatically set the locale (this is a global change) + locale: const Locale('fr'), + builder: (context, widget) => StreamChat( + client: client, + child: widget, + ), + home: StreamChannel( + channel: channel, + child: const ChannelPage(), + ), + ); } /// A list of messages sent in the current channel. @@ -106,14 +106,14 @@ class ChannelPage extends StatelessWidget { @override Widget build(BuildContext context) { - return const Scaffold( - appBar: StreamChannelHeader(), + return Scaffold( + appBar: const StreamChannelHeader(), body: Column( children: [ - Expanded( + const Expanded( child: StreamMessageListView(), ), - StreamMessageInput(), + StreamMessageComposer(), ], ), ); diff --git a/packages/stream_chat_localizations/example/lib/override_lang.dart b/packages/stream_chat_localizations/example/lib/override_lang.dart index 1dc05f9100..453966a361 100644 --- a/packages/stream_chat_localizations/example/lib/override_lang.dart +++ b/packages/stream_chat_localizations/example/lib/override_lang.dart @@ -3,16 +3,14 @@ import 'package:flutter/material.dart'; import 'package:stream_chat_flutter/stream_chat_flutter.dart'; import 'package:stream_chat_localizations/stream_chat_localizations.dart'; -class _CustomStreamChatLocalizationsDelegate - extends LocalizationsDelegate { +class _CustomStreamChatLocalizationsDelegate extends LocalizationsDelegate { const _CustomStreamChatLocalizationsDelegate(); @override bool isSupported(Locale locale) => locale.languageCode == 'en'; @override - Future load(Locale locale) => - SynchronousFuture(CustomStreamChatLocalizationsEn()); + Future load(Locale locale) => SynchronousFuture(CustomStreamChatLocalizationsEn()); @override bool shouldReload(_CustomStreamChatLocalizationsDelegate old) => false; @@ -89,34 +87,34 @@ class MyApp extends StatelessWidget { @override Widget build(BuildContext context) => MaterialApp( - theme: ThemeData.light(), - darkTheme: ThemeData.dark(), - // Add all the supported locales - supportedLocales: const [ - Locale('en'), - Locale('hi'), - Locale('fr'), - Locale('it'), - Locale('es'), - Locale('ja'), - Locale('ko'), - Locale('pt'), - ], - // Add overridden "CustomStreamChatLocalizationsEn.delegate" along with - // "GlobalStreamChatLocalizations.delegates" - localizationsDelegates: const [ - CustomStreamChatLocalizationsEn.delegate, - ...GlobalStreamChatLocalizations.delegates, - ], - builder: (context, widget) => StreamChat( - client: client, - child: widget, - ), - home: StreamChannel( - channel: channel, - child: const ChannelPage(), - ), - ); + theme: ThemeData.light(), + darkTheme: ThemeData.dark(), + // Add all the supported locales + supportedLocales: const [ + Locale('en'), + Locale('hi'), + Locale('fr'), + Locale('it'), + Locale('es'), + Locale('ja'), + Locale('ko'), + Locale('pt'), + ], + // Add overridden "CustomStreamChatLocalizationsEn.delegate" along with + // "GlobalStreamChatLocalizations.delegates" + localizationsDelegates: const [ + CustomStreamChatLocalizationsEn.delegate, + ...GlobalStreamChatLocalizations.delegates, + ], + builder: (context, widget) => StreamChat( + client: client, + child: widget, + ), + home: StreamChannel( + channel: channel, + child: const ChannelPage(), + ), + ); } /// A list of messages sent in the current channel. @@ -133,14 +131,14 @@ class ChannelPage extends StatelessWidget { @override Widget build(BuildContext context) { - return const Scaffold( - appBar: StreamChannelHeader(), + return Scaffold( + appBar: const StreamChannelHeader(), body: Column( children: [ - Expanded( + const Expanded( child: StreamMessageListView(), ), - StreamMessageInput(), + StreamMessageComposer(), ], ), ); diff --git a/packages/stream_chat_localizations/example/pubspec.yaml b/packages/stream_chat_localizations/example/pubspec.yaml index f7a62ec748..053eb15726 100644 --- a/packages/stream_chat_localizations/example/pubspec.yaml +++ b/packages/stream_chat_localizations/example/pubspec.yaml @@ -17,15 +17,15 @@ version: 1.0.0+1 # 2. Add it to the melos.yaml file for future updates. environment: - sdk: ^3.6.2 - flutter: ">=3.27.4" + sdk: ^3.10.0 + flutter: ">=3.38.1" dependencies: cupertino_icons: ^1.0.3 flutter: sdk: flutter - stream_chat_flutter: ^9.23.0 - stream_chat_localizations: ^9.23.0 + stream_chat_flutter: ^10.0.0-beta.13 + stream_chat_localizations: ^10.0.0-beta.13 flutter: uses-material-design: true \ No newline at end of file diff --git a/packages/stream_chat_localizations/lib/src/stream_chat_localizations.dart b/packages/stream_chat_localizations/lib/src/stream_chat_localizations.dart index 62248df818..956b906cb4 100644 --- a/packages/stream_chat_localizations/lib/src/stream_chat_localizations.dart +++ b/packages/stream_chat_localizations/lib/src/stream_chat_localizations.dart @@ -112,8 +112,7 @@ GlobalStreamChatLocalizations? getStreamChatTranslation(Locale locale) { /// ) /// ``` /// -abstract class GlobalStreamChatLocalizations - implements StreamChatLocalizations { +abstract class GlobalStreamChatLocalizations implements StreamChatLocalizations { /// Initializes an object that defines the StreamChat widget's localized /// strings for the given `localeName`. const GlobalStreamChatLocalizations({ @@ -129,8 +128,7 @@ abstract class GlobalStreamChatLocalizations /// [GlobalStreamChatLocalizations.delegates] as the value of /// [MaterialApp.localizationsDelegates] to include the localizations for both /// the flutter and stream chat widget libraries. - static const LocalizationsDelegate delegate = - _StreamChatLocalizationsDelegate(); + static const LocalizationsDelegate delegate = _StreamChatLocalizationsDelegate(); /// A value for [MaterialApp.localizationsDelegates] that's typically used by /// internationalized apps. @@ -160,16 +158,13 @@ abstract class GlobalStreamChatLocalizations ]; } -class _StreamChatLocalizationsDelegate - extends LocalizationsDelegate { +class _StreamChatLocalizationsDelegate extends LocalizationsDelegate { const _StreamChatLocalizationsDelegate(); @override - bool isSupported(Locale locale) => - kStreamChatSupportedLanguages.contains(locale.languageCode); + bool isSupported(Locale locale) => kStreamChatSupportedLanguages.contains(locale.languageCode); - static final _loadedTranslations = - >{}; + static final _loadedTranslations = >{}; @override Future load(Locale locale) { @@ -186,6 +181,7 @@ class _StreamChatLocalizationsDelegate bool shouldReload(_StreamChatLocalizationsDelegate old) => false; @override - String toString() => 'GlobalStreamChatLocalizations.delegate(' + String toString() => + 'GlobalStreamChatLocalizations.delegate(' '${kStreamChatSupportedLanguages.length} locales)'; } diff --git a/packages/stream_chat_localizations/lib/src/stream_chat_localizations_ca.dart b/packages/stream_chat_localizations/lib/src/stream_chat_localizations_ca.dart index a71ec593a2..3b8b467d41 100644 --- a/packages/stream_chat_localizations/lib/src/stream_chat_localizations_ca.dart +++ b/packages/stream_chat_localizations/lib/src/stream_chat_localizations_ca.dart @@ -1,3 +1,5 @@ +// ignore_for_file: lines_longer_than_80_chars + part of 'stream_chat_localizations.dart'; /// The translations for Catalan (`ca`). @@ -47,10 +49,9 @@ class StreamChatLocalizationsCa extends GlobalStreamChatLocalizations { @override String attachmentsUploadProgressText({ - required int remaining, + required int completed, required int total, - }) => - 'Transferència en curs $remaining/$total ...'; + }) => 'Pujats $completed de $total ...'; @override String pinnedByUserText({ @@ -63,18 +64,16 @@ class StreamChatLocalizationsCa extends GlobalStreamChatLocalizations { } @override - String get sendMessagePermissionError => - 'No tens permís per enviar missatges'; + String get sendMessagePermissionError => 'No tens permís per enviar missatges'; @override - String get emptyMessagesText => 'Actualment no hi ha missatges'; + String get emptyMessagesText => 'Encara no hi ha missatges'; @override String get genericErrorText => 'Hi ha hagut un problema'; @override - String get loadingMessagesError => - 'Hi ha hagut un error mentre carregava el missatge'; + String get loadingMessagesError => 'Hi ha hagut un error mentre carregava el missatge'; @override String resultCountText(int count) => '$count resultats'; @@ -113,8 +112,7 @@ class StreamChatLocalizationsCa extends GlobalStreamChatLocalizations { String get reconnectingLabel => 'Reconnectant...'; @override - String get alsoSendAsDirectMessageLabel => - 'Enviar també com a missatge directe'; + String get alsoSendAsDirectMessageLabel => 'Enviar també com a missatge directe'; @override String get addACommentOrSendLabel => 'Afegir un comentari o enviar'; @@ -123,7 +121,7 @@ class StreamChatLocalizationsCa extends GlobalStreamChatLocalizations { String get searchGifLabel => 'Cerca de GIFs'; @override - String get writeAMessageLabel => 'Escriure un missatge'; + String get writeAMessageLabel => 'Enviar un missatge'; @override String get instantCommandsLabel => 'Commandes instantànies'; @@ -140,8 +138,7 @@ class StreamChatLocalizationsCa extends GlobalStreamChatLocalizations { 'La mida màxima del fitxer és de $limitInMB MB.'; @override - String get couldNotReadBytesFromFileError => - "No s'han pogut llegir els bytes del fitxer."; + String get couldNotReadBytesFromFileError => "No s'han pogut llegir els bytes del fitxer."; @override String get addAFileLabel => 'Afegeix un fitxer'; @@ -168,12 +165,11 @@ class StreamChatLocalizationsCa extends GlobalStreamChatLocalizations { String get somethingWentWrongError => 'Alguna cosa ha anat malament'; @override - String get addMoreFilesLabel => 'Afegir més fitxers'; + String get addMoreFilesLabel => 'Afegir més'; @override String get enablePhotoAndVideoAccessMessage => - "Si us plau, permet l'accés a les teves fotos" - '\ni vídeos per a que puguis compartir-los'; + "Si us plau, permet l'accés a les teves fotos i vídeos per a que puguis compartir-los"; @override String get allowGalleryAccessMessage => "Permet l'accés a la galeria"; @@ -183,35 +179,31 @@ class StreamChatLocalizationsCa extends GlobalStreamChatLocalizations { @override String get flagMessageQuestion => - "Vols enviar una còpia d'aquest missatge a un" - '\nmoderador per una major investigació?'; + "Vols enviar una còpia d'aquest missatge a un moderador per una major investigació?"; @override - String get flagLabel => 'REPORTA'; + String get flagLabel => 'Reporta'; @override - String get cancelLabel => 'CANCEL·LA'; + String get cancelLabel => 'Cancel·la'; @override String get flagMessageSuccessfulLabel => 'Missatge reportat'; @override - String get flagMessageSuccessfulText => - 'Aquest missatge ha estat reportat a un moderador'; + String get flagMessageSuccessfulText => 'Aquest missatge ha estat reportat a un moderador'; @override - String get deleteLabel => 'ESBORRA'; + String get deleteLabel => 'Esborra'; @override String get deleteMessageLabel => 'Esborra el missatge'; @override - String get deleteMessageQuestion => - 'Estàs segur que vols esborrar aquest\nmissatge de forma permanent?'; + String get deleteMessageQuestion => 'Estàs segur que vols esborrar aquest missatge de forma permanent?'; @override - String get operationCouldNotBeCompletedText => - "L'operació no s'ha pogut completar"; + String get operationCouldNotBeCompletedText => "L'operació no s'ha pogut completar"; @override String get replyLabel => 'Respondre'; @@ -243,6 +235,9 @@ class StreamChatLocalizationsCa extends GlobalStreamChatLocalizations { @override String get photosLabel => 'Fotos'; + @override + String get photosAndVideosLabel => 'Fotos i vídeos'; + String _getDay(DateTime dateTime) { final now = DateTime.now(); final today = DateTime(now.year, now.month, now.day); @@ -281,8 +276,7 @@ class StreamChatLocalizationsCa extends GlobalStreamChatLocalizations { String get letsStartChattingLabel => 'Comencem a parlar!'; @override - String get sendingFirstMessageLabel => - 'Què et sembla enviar el teu primer missatge?'; + String get sendingFirstMessageLabel => 'Què et sembla enviar el teu primer missatge?'; @override String get startAChatLabel => 'Inicia una conversa'; @@ -294,11 +288,10 @@ class StreamChatLocalizationsCa extends GlobalStreamChatLocalizations { String get deleteConversationLabel => 'Esborra la conversa'; @override - String get deleteConversationQuestion => - 'Estàs segur que vols esborrar aquesta conversa?'; + String get deleteConversationQuestion => 'Estàs segur que vols esborrar aquesta conversa?'; @override - String get streamChatLabel => 'Stream Chat'; + String get streamChatLabel => 'Converses'; @override String get searchingForNetworkText => 'Cercant xarxa'; @@ -321,6 +314,16 @@ class StreamChatLocalizationsCa extends GlobalStreamChatLocalizations { return '$count En línia'; } + @override + String membersCountWithOnlineText({ + required int memberCount, + required int onlineCount, + }) { + final members = membersCountText(memberCount); + if (onlineCount <= 0) return members; + return '$members, ${watchersCountText(onlineCount)}'; + } + @override String get viewInfoLabel => 'Veure informació'; @@ -334,8 +337,7 @@ class StreamChatLocalizationsCa extends GlobalStreamChatLocalizations { String get leaveConversationLabel => 'Surt de la conversa'; @override - String get leaveConversationQuestion => - "Estàs segur que vols sortir d'aquesta conversa?"; + String get leaveConversationQuestion => "Estàs segur que vols sortir d'aquesta conversa?"; @override String get showInChatLabel => 'Mostra al xat'; @@ -371,8 +373,7 @@ class StreamChatLocalizationsCa extends GlobalStreamChatLocalizations { String galleryPaginationText({ required int currentPage, required int totalPages, - }) => - '${currentPage + 1} de $totalPages'; + }) => '${currentPage + 1} de $totalPages'; @override String get fileText => 'Fitxer'; @@ -381,8 +382,7 @@ class StreamChatLocalizationsCa extends GlobalStreamChatLocalizations { String get replyToMessageLabel => 'Respondre al missatge'; @override - String attachmentLimitExceedError(int limit) => - 'No és possible afegir més de $limit fitxers adjunts'; + String attachmentLimitExceedError(int limit) => 'No és possible afegir més de $limit fitxers adjunts'; @override String get viewLibrary => 'Veure llibreria'; @@ -390,6 +390,9 @@ class StreamChatLocalizationsCa extends GlobalStreamChatLocalizations { @override String get slowModeOnLabel => 'Mode lent activat'; + @override + String get commandUsernameLabel => '@username'; + @override String get downloadLabel => 'Descarrega'; @@ -439,8 +442,7 @@ class StreamChatLocalizationsCa extends GlobalStreamChatLocalizations { } @override - String get linkDisabledDetails => - 'No es permet enviar enllaços a aquesta conversa'; + String get linkDisabledDetails => 'No es permet enviar enllaços a aquesta conversa'; @override String get linkDisabledError => 'Els enllaços estan deshabilitats'; @@ -449,8 +451,7 @@ class StreamChatLocalizationsCa extends GlobalStreamChatLocalizations { String unreadMessagesSeparatorText() => 'Missatges nous'; @override - String get enableFileAccessMessage => "Habilita l'accés als fitxers" - '\nper poder compartir-los amb amics'; + String get enableFileAccessMessage => "Habilita l'accés als fitxers per poder compartir-los amb amics"; @override String get allowFileAccessMessage => "Permet l'accés als fitxers"; @@ -476,7 +477,10 @@ class StreamChatLocalizationsCa extends GlobalStreamChatLocalizations { } @override - String get questionsLabel => 'Preguntes'; + String questionLabel({bool isPlural = false}) { + if (isPlural) return 'Preguntes'; + return 'Pregunta'; + } @override String get askAQuestionLabel => 'Fes una pregunta'; @@ -559,15 +563,17 @@ class StreamChatLocalizationsCa extends GlobalStreamChatLocalizations { String get enterYourCommentLabel => 'Introdueix el teu comentari'; @override - String get endVoteConfirmationText => - 'Estàs segur que vols finalitzar la votació?'; + String get endVoteConfirmationTitle => 'Estàs segur que vols finalitzar la votació?'; + + @override + String get endVoteConfirmationMessage => + 'Vols finalitzar aquesta enquesta ara? Ningú no podrà votar més en aquesta enquesta.'; @override String get deletePollOptionLabel => 'Eliminar opció'; @override - String get deletePollOptionQuestion => - 'Estàs segur que vols eliminar aquesta opció?'; + String get deletePollOptionQuestion => 'Estàs segur que vols eliminar aquesta opció?'; @override String get createLabel => 'Crear'; @@ -603,18 +609,31 @@ class StreamChatLocalizationsCa extends GlobalStreamChatLocalizations { @override String get pollResultsLabel => 'Resultats de la votació'; + @override + String get pollVotesLabel => 'Vots'; + @override String showAllVotesLabel({int? count}) { if (count == null) return 'Mostrar tots els vots'; return 'Mostrar tots els $count vots'; } + @override + String get viewAllLabel => 'Veure-ho tot'; + @override String voteCountLabel({int? count}) => switch (count) { - null || < 1 => '0 vots', - 1 => '1 vot', - _ => '$count vots', - }; + null || < 1 => '0 vots', + 1 => '1 vot', + _ => '$count vots', + }; + + @override + String totalVoteCountLabel({int? count}) => switch (count) { + null || < 1 => '0 vots en total', + 1 => '1 vot en total', + _ => '$count vots en total', + }; @override String get noPollVotesLabel => 'No hi ha vots en aquest moment'; @@ -631,19 +650,20 @@ class StreamChatLocalizationsCa extends GlobalStreamChatLocalizations { return '$count fils nous'; } + @override + String get loadingLabel => 'Carregant...'; + @override String get slideToCancelLabel => 'Llisca per cancel·lar'; @override - String get holdToRecordLabel => - 'Mantén premut per gravar, deixa anar per enviar'; + String get holdToRecordLabel => 'Mantén premut per gravar, deixa anar per enviar'; @override String get sendAnywayLabel => 'Enviar igualment'; @override - String get moderatedMessageBlockedText => - 'Missatge bloquejat per les polítiques de moderació'; + String get moderatedMessageBlockedText => 'Missatge bloquejat per les polítiques de moderació'; @override String get moderationReviewModalTitle => 'Estàs segur?'; @@ -667,6 +687,21 @@ class StreamChatLocalizationsCa extends GlobalStreamChatLocalizations { @override String get videoAttachmentText => 'Vídeo'; + @override + String get fileAttachmentText => 'Fitxer'; + + @override + String get linkAttachmentText => 'Enllaç'; + + @override + String filesAttachmentCountText(int count) => count == 1 ? 'Fitxer' : '$count fitxers'; + + @override + String photosAttachmentCountText(int count) => count == 1 ? 'Foto' : '$count fotos'; + + @override + String videosAttachmentCountText(int count) => count == 1 ? 'Vídeo' : '$count vídeos'; + @override String get pollYouVotedText => 'Has votat'; @@ -681,4 +716,97 @@ class StreamChatLocalizationsCa extends GlobalStreamChatLocalizations { @override String get draftLabel => 'Esborrany'; + + @override + String locationLabel({bool isLive = false}) { + if (isLive) return 'Ubicació en directe'; + return 'Ubicació'; + } + + @override + String get noConversationsYetText => 'Encara no hi ha converses'; + + @override + String get replyToStartThreadText => 'Respon a un missatge per iniciar un fil'; + + @override + String get sendMessageToStartConversationText => 'Envia un missatge per iniciar la conversa'; + + @override + String get savedForLaterLabel => 'Desat per a més tard'; + + @override + String get repliedToThreadAnnotationLabel => 'Ha respost a un fil'; + + @override + String get alsoSentInChannelAnnotationLabel => 'També enviat al canal'; + + @override + String get viewLabel => 'Veure'; + + @override + String get reminderSetLabel => 'Recordatori establert'; + + @override + String reminderAtText(String time) => 'Avui a les $time'; + + @override + String get createPollPromptLabel => 'Crea una enquesta i deixa que tothom voti!'; + + @override + String get takePhotoAndShareLabel => 'Fes una foto i comparteix'; + + @override + String get takeVideoAndShareLabel => 'Grava un vídeo i comparteix'; + + @override + String get openCameraLabel => 'Obrir càmera'; + + @override + String get selectFilesToShareLabel => 'Seleccioneu fitxers per compartir'; + + @override + String get openFilesLabel => 'Obrir fitxers'; + + @override + String get unsupportedAttachmentLabel => 'Adjunt no compatible'; + + @override + String get confirmLabel => 'CONFIRMAR'; + + @override + String get emptyReactionsText => 'Encara no hi ha reaccions'; + + @override + String get loadingReactionsError => 'Error en carregar les reaccions'; + + @override + String get tapToRemoveReactionLabel => 'Toca per eliminar'; + + @override + String reactionsCountText(int count) => '$count reaccions'; + + @override + String get justNowLabel => 'Ara mateix'; + + @override + String replyToUserLabel(String userName) => 'Respon a $userName'; + + @override + String get multipleAnswersDescription => "Selecciona més d'una opció"; + + @override + String maximumVotesPerPersonDescription([Range? range]) { + final (:min, :max) = range ?? (min: 2, max: 10); + return 'Tria entre $min\u2013$max opcions'; + } + + @override + String get anonymousPollDescription => 'Amaga qui ha votat'; + + @override + String get suggestAnOptionDescription => 'Permet que altres afegeixin opcions'; + + @override + String get addACommentDescription => 'Permet que altres afegeixin comentaris'; } diff --git a/packages/stream_chat_localizations/lib/src/stream_chat_localizations_de.dart b/packages/stream_chat_localizations/lib/src/stream_chat_localizations_de.dart index 6ca4153625..0761fb9743 100644 --- a/packages/stream_chat_localizations/lib/src/stream_chat_localizations_de.dart +++ b/packages/stream_chat_localizations/lib/src/stream_chat_localizations_de.dart @@ -1,3 +1,5 @@ +// ignore_for_file: lines_longer_than_80_chars + part of 'stream_chat_localizations.dart'; /// The translations for German (`de`). @@ -47,10 +49,9 @@ class StreamChatLocalizationsDe extends GlobalStreamChatLocalizations { @override String attachmentsUploadProgressText({ - required int remaining, + required int completed, required int total, - }) => - 'Hochladen $remaining/$total ...'; + }) => 'Hochgeladen $completed von $total ...'; @override String pinnedByUserText({ @@ -63,7 +64,7 @@ class StreamChatLocalizationsDe extends GlobalStreamChatLocalizations { } @override - String get emptyMessagesText => 'Derzeit sind keine Nachrichten vorhanden'; + String get emptyMessagesText => 'Noch keine Nachrichten'; @override String get genericErrorText => 'Etwas ist schief gelaufen'; @@ -117,7 +118,7 @@ class StreamChatLocalizationsDe extends GlobalStreamChatLocalizations { String get searchGifLabel => 'GIFs suchen'; @override - String get writeAMessageLabel => 'Nachricht schreiben'; + String get writeAMessageLabel => 'Nachricht senden'; @override String get instantCommandsLabel => 'Sofort-Befehle'; @@ -158,12 +159,11 @@ class StreamChatLocalizationsDe extends GlobalStreamChatLocalizations { String get somethingWentWrongError => 'Etwas ist schief gelaufen'; @override - String get addMoreFilesLabel => 'Weitere Dateien hinzufügen'; + String get addMoreFilesLabel => 'Mehr hinzufügen'; @override String get enablePhotoAndVideoAccessMessage => - 'Bitte aktivieren Sie den Zugriff auf Ihre Fotos' - '\nund Videos, damit Sie sie mit Freunden teilen können.'; + 'Bitte aktivieren Sie den Zugriff auf Ihre Fotos und Videos, damit Sie sie mit Freunden teilen können.'; @override String get allowGalleryAccessMessage => 'Zugang zu Ihrer Galerie gewähren'; @@ -173,35 +173,31 @@ class StreamChatLocalizationsDe extends GlobalStreamChatLocalizations { @override String get flagMessageQuestion => - 'Möchten Sie eine Kopie dieser Nachricht an einen' - '\nModerator für weitere Untersuchungen senden?'; + 'Möchten Sie eine Kopie dieser Nachricht an einen Moderator für weitere Untersuchungen senden?'; @override - String get flagLabel => 'MELDEN'; + String get flagLabel => 'Melden'; @override - String get cancelLabel => 'ABBRECHEN'; + String get cancelLabel => 'Abbrechen'; @override String get flagMessageSuccessfulLabel => 'Nachricht gemeldet'; @override - String get flagMessageSuccessfulText => - 'Die Nachricht wurde an einen Moderator weitergeleitet.'; + String get flagMessageSuccessfulText => 'Die Nachricht wurde an einen Moderator weitergeleitet.'; @override - String get deleteLabel => 'LÖSCHEN'; + String get deleteLabel => 'Löschen'; @override String get deleteMessageLabel => 'Nachricht löschen'; @override - String get deleteMessageQuestion => - 'Sind Sie sicher, dass Sie diese Nachricht endgültig löschen wollen?'; + String get deleteMessageQuestion => 'Sind Sie sicher, dass Sie diese Nachricht endgültig löschen wollen?'; @override - String get operationCouldNotBeCompletedText => - 'Die Operation konnte nicht abgeschlossen werden.'; + String get operationCouldNotBeCompletedText => 'Die Operation konnte nicht abgeschlossen werden.'; @override String get replyLabel => 'Antwort'; @@ -233,6 +229,9 @@ class StreamChatLocalizationsDe extends GlobalStreamChatLocalizations { @override String get photosLabel => 'Fotos'; + @override + String get photosAndVideosLabel => 'Fotos & Videos'; + String _getDay(DateTime dateTime) { final now = DateTime.now(); final today = DateTime(now.year, now.month, now.day); @@ -271,7 +270,8 @@ class StreamChatLocalizationsDe extends GlobalStreamChatLocalizations { String get letsStartChattingLabel => 'Lass uns anfangen zu chatten!'; @override - String get sendingFirstMessageLabel => 'Wie wäre es, wenn Sie Ihre erste ' + String get sendingFirstMessageLabel => + 'Wie wäre es, wenn Sie Ihre erste ' 'Nachricht an einen Freund senden würden?'; @override @@ -284,11 +284,10 @@ class StreamChatLocalizationsDe extends GlobalStreamChatLocalizations { String get deleteConversationLabel => 'Unterhaltung löschen'; @override - String get deleteConversationQuestion => - 'Sind Sie sicher, dass Sie diese Unterhaltung löschen wollen?'; + String get deleteConversationQuestion => 'Sind Sie sicher, dass Sie diese Unterhaltung löschen wollen?'; @override - String get streamChatLabel => 'Stream Chat'; + String get streamChatLabel => 'Unterhaltungen'; @override String get searchingForNetworkText => 'Netzwerk wird gesucht'; @@ -310,6 +309,16 @@ class StreamChatLocalizationsDe extends GlobalStreamChatLocalizations { return '$count Online'; } + @override + String membersCountWithOnlineText({ + required int memberCount, + required int onlineCount, + }) { + final members = membersCountText(memberCount); + if (onlineCount <= 0) return members; + return '$members, ${watchersCountText(onlineCount)}'; + } + @override String get viewInfoLabel => 'Infos anzeigen'; @@ -323,8 +332,7 @@ class StreamChatLocalizationsDe extends GlobalStreamChatLocalizations { String get leaveConversationLabel => 'Unterhaltung verlassen'; @override - String get leaveConversationQuestion => - 'Sind Sie sicher, dass Sie diese Unterhaltung verlassen wollen?'; + String get leaveConversationQuestion => 'Sind Sie sicher, dass Sie diese Unterhaltung verlassen wollen?'; @override String get showInChatLabel => 'Im Chat anzeigen'; @@ -360,8 +368,7 @@ class StreamChatLocalizationsDe extends GlobalStreamChatLocalizations { String galleryPaginationText({ required int currentPage, required int totalPages, - }) => - '${currentPage + 1} von $totalPages'; + }) => '${currentPage + 1} von $totalPages'; @override String get fileText => 'Datei'; @@ -370,26 +377,25 @@ class StreamChatLocalizationsDe extends GlobalStreamChatLocalizations { String get replyToMessageLabel => 'Auf Nachricht antworten'; @override - String attachmentLimitExceedError(int limit) => - 'Dateigröße überschritten, Grenze: $limit'; + String attachmentLimitExceedError(int limit) => 'Dateigröße überschritten, Grenze: $limit'; @override String get slowModeOnLabel => 'Langsamer Modus: EIN'; @override - String get linkDisabledDetails => - 'Das Senden von Links ist in dieser Konversation nicht erlaubt.'; + String get commandUsernameLabel => '@username'; + + @override + String get linkDisabledDetails => 'Das Senden von Links ist in dieser Konversation nicht erlaubt.'; @override String get linkDisabledError => 'Verknüpfungen sind deaktiviert'; @override - String get sendMessagePermissionError => - 'Sie sind nicht berechtigt Nachrichten zu senden'; + String get sendMessagePermissionError => 'Sie sind nicht berechtigt Nachrichten zu senden'; @override - String get couldNotReadBytesFromFileError => - 'Kan bytes niet uit bestand lezen.'; + String get couldNotReadBytesFromFileError => 'Kan bytes niet uit bestand lezen.'; @override String get downloadLabel => 'Downloaden'; @@ -443,8 +449,7 @@ class StreamChatLocalizationsDe extends GlobalStreamChatLocalizations { @override String get enableFileAccessMessage => - 'Bitte aktivieren Sie den Zugriff auf Dateien,' - '\ndamit Sie sie mit Freunden teilen können.'; + 'Bitte aktivieren Sie den Zugriff auf Dateien, damit Sie sie mit Freunden teilen können.'; @override String get allowFileAccessMessage => 'Zugriff auf Dateien zulassen'; @@ -470,7 +475,10 @@ class StreamChatLocalizationsDe extends GlobalStreamChatLocalizations { } @override - String get questionsLabel => 'Fragen'; + String questionLabel({bool isPlural = false}) { + if (isPlural) return 'Fragen'; + return 'Frage'; + } @override String get askAQuestionLabel => 'Stellen Sie eine Frage'; @@ -553,15 +561,17 @@ class StreamChatLocalizationsDe extends GlobalStreamChatLocalizations { String get enterYourCommentLabel => 'Geben Sie Ihren Kommentar ein'; @override - String get endVoteConfirmationText => - 'Sind Sie sicher, dass Sie die Abstimmung beenden möchten?'; + String get endVoteConfirmationTitle => 'Sind Sie sicher, dass Sie die Abstimmung beenden möchten?'; + + @override + String get endVoteConfirmationMessage => + 'Möchten Sie diese Umfrage jetzt beenden? Danach kann niemand mehr in dieser Umfrage abstimmen.'; @override String get deletePollOptionLabel => 'Option löschen'; @override - String get deletePollOptionQuestion => - 'Sind Sie sicher, dass Sie diese Option löschen möchten?'; + String get deletePollOptionQuestion => 'Sind Sie sicher, dass Sie diese Option löschen möchten?'; @override String get createLabel => 'Erstellen'; @@ -597,18 +607,31 @@ class StreamChatLocalizationsDe extends GlobalStreamChatLocalizations { @override String get pollResultsLabel => 'Umfrage-Ergebnisse'; + @override + String get pollVotesLabel => 'Stimmen'; + @override String showAllVotesLabel({int? count}) { if (count == null) return 'Alle Stimmen anzeigen'; return 'Alle $count Stimmen anzeigen'; } + @override + String get viewAllLabel => 'Alle anzeigen'; + @override String voteCountLabel({int? count}) => switch (count) { - null || < 1 => '0 Stimmen', - 1 => '1 Stimme', - _ => '$count Stimmen', - }; + null || < 1 => '0 Stimmen', + 1 => '1 Stimme', + _ => '$count Stimmen', + }; + + @override + String totalVoteCountLabel({int? count}) => switch (count) { + null || < 1 => '0 Stimmen insgesamt', + 1 => '1 Stimme insgesamt', + _ => '$count Stimmen insgesamt', + }; @override String get noPollVotesLabel => 'Derzeit keine Umfrage-Stimmen'; @@ -625,6 +648,9 @@ class StreamChatLocalizationsDe extends GlobalStreamChatLocalizations { return '$count neue Threads'; } + @override + String get loadingLabel => 'Wird geladen...'; + @override String get slideToCancelLabel => 'Zum Abbrechen schieben'; @@ -635,8 +661,7 @@ class StreamChatLocalizationsDe extends GlobalStreamChatLocalizations { String get sendAnywayLabel => 'Trotzdem senden'; @override - String get moderatedMessageBlockedText => - 'Nachricht wurde durch Moderationsrichtlinien blockiert'; + String get moderatedMessageBlockedText => 'Nachricht wurde durch Moderationsrichtlinien blockiert'; @override String get moderationReviewModalTitle => 'Bist du sicher?'; @@ -660,6 +685,21 @@ class StreamChatLocalizationsDe extends GlobalStreamChatLocalizations { @override String get videoAttachmentText => 'Video'; + @override + String get fileAttachmentText => 'Datei'; + + @override + String get linkAttachmentText => 'Link'; + + @override + String filesAttachmentCountText(int count) => count == 1 ? 'Datei' : '$count Dateien'; + + @override + String photosAttachmentCountText(int count) => count == 1 ? 'Foto' : '$count Fotos'; + + @override + String videosAttachmentCountText(int count) => count == 1 ? 'Video' : '$count Videos'; + @override String get pollYouVotedText => 'Du hast abgestimmt'; @@ -674,4 +714,97 @@ class StreamChatLocalizationsDe extends GlobalStreamChatLocalizations { @override String get draftLabel => 'Entwurf'; + + @override + String locationLabel({bool isLive = false}) { + if (isLive) return 'Live-Standort'; + return 'Standort'; + } + + @override + String get noConversationsYetText => 'Noch keine Unterhaltungen'; + + @override + String get replyToStartThreadText => 'Antworten Sie auf eine Nachricht, um einen Thread zu starten'; + + @override + String get sendMessageToStartConversationText => 'Senden Sie eine Nachricht, um die Unterhaltung zu starten'; + + @override + String get savedForLaterLabel => 'Für später gespeichert'; + + @override + String get repliedToThreadAnnotationLabel => 'Auf einen Thread geantwortet'; + + @override + String get alsoSentInChannelAnnotationLabel => 'Auch im Kanal gesendet'; + + @override + String get viewLabel => 'Anzeigen'; + + @override + String get reminderSetLabel => 'Erinnerung gesetzt'; + + @override + String reminderAtText(String time) => 'Heute um $time'; + + @override + String get createPollPromptLabel => 'Erstelle eine Umfrage und lass alle abstimmen!'; + + @override + String get takePhotoAndShareLabel => 'Foto aufnehmen und teilen'; + + @override + String get takeVideoAndShareLabel => 'Video aufnehmen und teilen'; + + @override + String get openCameraLabel => 'Kamera öffnen'; + + @override + String get selectFilesToShareLabel => 'Dateien zum Teilen auswählen'; + + @override + String get openFilesLabel => 'Dateien öffnen'; + + @override + String get unsupportedAttachmentLabel => 'Nicht unterstützter Anhang'; + + @override + String get confirmLabel => 'BESTÄTIGEN'; + + @override + String get emptyReactionsText => 'Noch keine Reaktionen'; + + @override + String get loadingReactionsError => 'Fehler beim Laden der Reaktionen'; + + @override + String get tapToRemoveReactionLabel => 'Zum Entfernen tippen'; + + @override + String reactionsCountText(int count) => '$count Reaktionen'; + + @override + String get justNowLabel => 'Gerade eben'; + + @override + String replyToUserLabel(String userName) => 'Antwort an $userName'; + + @override + String get multipleAnswersDescription => 'Mehr als eine Option auswählen'; + + @override + String maximumVotesPerPersonDescription([Range? range]) { + final (:min, :max) = range ?? (min: 2, max: 10); + return 'Wähle zwischen $min\u2013$max Optionen'; + } + + @override + String get anonymousPollDescription => 'Verbergen, wer abgestimmt hat'; + + @override + String get suggestAnOptionDescription => 'Anderen erlauben, Optionen hinzuzufügen'; + + @override + String get addACommentDescription => 'Anderen erlauben, Kommentare hinzuzufügen'; } diff --git a/packages/stream_chat_localizations/lib/src/stream_chat_localizations_en.dart b/packages/stream_chat_localizations/lib/src/stream_chat_localizations_en.dart index f26fd20a4e..bb53b34fab 100644 --- a/packages/stream_chat_localizations/lib/src/stream_chat_localizations_en.dart +++ b/packages/stream_chat_localizations/lib/src/stream_chat_localizations_en.dart @@ -1,3 +1,5 @@ +// ignore_for_file: lines_longer_than_80_chars + part of 'stream_chat_localizations.dart'; /// The translations for English (`en`). @@ -37,20 +39,19 @@ class StreamChatLocalizationsEn extends GlobalStreamChatLocalizations { } @override - String get threadReplyLabel => 'Thread Reply'; + String get threadReplyLabel => 'Thread'; @override String get onlyVisibleToYouText => 'Only visible to you'; @override - String threadReplyCountText(int count) => '$count Thread Replies'; + String threadReplyCountText(int count) => count == 1 ? '1 reply' : '$count replies'; @override String attachmentsUploadProgressText({ - required int remaining, + required int completed, required int total, - }) => - 'Uploading $remaining/$total ...'; + }) => 'Uploaded $completed of $total ...'; @override String pinnedByUserText({ @@ -63,11 +64,10 @@ class StreamChatLocalizationsEn extends GlobalStreamChatLocalizations { } @override - String get sendMessagePermissionError => - "You don't have permission to send messages"; + String get sendMessagePermissionError => "You don't have permission to send messages"; @override - String get emptyMessagesText => 'There are no messages currently'; + String get emptyMessagesText => 'No messages yet'; @override String get genericErrorText => 'Something went wrong'; @@ -98,8 +98,8 @@ class StreamChatLocalizationsEn extends GlobalStreamChatLocalizations { @override String threadSeparatorText(int replyCount) { - if (replyCount == 1) return '1 Reply'; - return '$replyCount Replies'; + if (replyCount == 1) return '1 reply'; + return '$replyCount replies'; } @override @@ -112,7 +112,7 @@ class StreamChatLocalizationsEn extends GlobalStreamChatLocalizations { String get reconnectingLabel => 'Reconnecting...'; @override - String get alsoSendAsDirectMessageLabel => 'Also send as direct message'; + String get alsoSendAsDirectMessageLabel => 'Also send in Channel'; @override String get addACommentOrSendLabel => 'Add a comment or send'; @@ -121,7 +121,7 @@ class StreamChatLocalizationsEn extends GlobalStreamChatLocalizations { String get searchGifLabel => 'Search GIFs'; @override - String get writeAMessageLabel => 'Write a message'; + String get writeAMessageLabel => 'Send a message'; @override String get instantCommandsLabel => 'Instant Commands'; @@ -137,8 +137,7 @@ class StreamChatLocalizationsEn extends GlobalStreamChatLocalizations { 'The file is too large to upload. The file size limit is $limitInMB MB.'; @override - String get couldNotReadBytesFromFileError => - 'Could not read bytes from file.'; + String get couldNotReadBytesFromFileError => 'Could not read bytes from file.'; @override String get addAFileLabel => 'Add a file'; @@ -165,12 +164,11 @@ class StreamChatLocalizationsEn extends GlobalStreamChatLocalizations { String get somethingWentWrongError => 'Something went wrong'; @override - String get addMoreFilesLabel => 'Add more files'; + String get addMoreFilesLabel => 'Add more'; @override String get enablePhotoAndVideoAccessMessage => - 'Please enable access to your photos' - '\nand videos so you can share them with friends.'; + 'Please enable access to your photos and videos so you can share them with friends.'; @override String get allowGalleryAccessMessage => 'Allow access to your gallery'; @@ -180,35 +178,31 @@ class StreamChatLocalizationsEn extends GlobalStreamChatLocalizations { @override String get flagMessageQuestion => - 'Do you want to send a copy of this message to a' - '\nmoderator for further investigation?'; + 'Do you want to send a copy of this message to a moderator for further investigation?'; @override - String get flagLabel => 'FLAG'; + String get flagLabel => 'Flag'; @override - String get cancelLabel => 'CANCEL'; + String get cancelLabel => 'Cancel'; @override String get flagMessageSuccessfulLabel => 'Message flagged'; @override - String get flagMessageSuccessfulText => - 'The message has been reported to a moderator.'; + String get flagMessageSuccessfulText => 'The message has been reported to a moderator.'; @override - String get deleteLabel => 'DELETE'; + String get deleteLabel => 'Delete'; @override String get deleteMessageLabel => 'Delete Message'; @override - String get deleteMessageQuestion => - 'Are you sure you want to permanently delete this\nmessage?'; + String get deleteMessageQuestion => 'Are you sure you want to permanently delete this message?'; @override - String get operationCouldNotBeCompletedText => - "The operation couldn't be completed."; + String get operationCouldNotBeCompletedText => "The operation couldn't be completed."; @override String get replyLabel => 'Reply'; @@ -240,6 +234,9 @@ class StreamChatLocalizationsEn extends GlobalStreamChatLocalizations { @override String get photosLabel => 'Photos'; + @override + String get photosAndVideosLabel => 'Photos & Videos'; + String _getDay(DateTime dateTime) { final now = DateTime.now(); final today = DateTime(now.year, now.month, now.day); @@ -278,8 +275,7 @@ class StreamChatLocalizationsEn extends GlobalStreamChatLocalizations { String get letsStartChattingLabel => 'Let’s start chatting!'; @override - String get sendingFirstMessageLabel => - 'How about sending your first message to a friend?'; + String get sendingFirstMessageLabel => 'How about sending your first message to a friend?'; @override String get startAChatLabel => 'Start a chat'; @@ -291,11 +287,10 @@ class StreamChatLocalizationsEn extends GlobalStreamChatLocalizations { String get deleteConversationLabel => 'Delete Conversation'; @override - String get deleteConversationQuestion => - 'Are you sure you want to delete this conversation?'; + String get deleteConversationQuestion => 'Are you sure you want to delete this conversation?'; @override - String get streamChatLabel => 'Stream Chat'; + String get streamChatLabel => 'Chats'; @override String get searchingForNetworkText => 'Searching for Network'; @@ -318,6 +313,16 @@ class StreamChatLocalizationsEn extends GlobalStreamChatLocalizations { return '$count Online'; } + @override + String membersCountWithOnlineText({ + required int memberCount, + required int onlineCount, + }) { + final members = membersCountText(memberCount); + if (onlineCount <= 0) return members; + return '$members, ${watchersCountText(onlineCount)}'; + } + @override String get viewInfoLabel => 'View Info'; @@ -331,8 +336,7 @@ class StreamChatLocalizationsEn extends GlobalStreamChatLocalizations { String get leaveConversationLabel => 'Leave conversation'; @override - String get leaveConversationQuestion => - 'Are you sure you want to leave this conversation?'; + String get leaveConversationQuestion => 'Are you sure you want to leave this conversation?'; @override String get showInChatLabel => 'Show in Chat'; @@ -368,8 +372,7 @@ class StreamChatLocalizationsEn extends GlobalStreamChatLocalizations { String galleryPaginationText({ required int currentPage, required int totalPages, - }) => - '${currentPage + 1} of $totalPages'; + }) => '${currentPage + 1} of $totalPages'; @override String get fileText => 'File'; @@ -378,12 +381,14 @@ class StreamChatLocalizationsEn extends GlobalStreamChatLocalizations { String get replyToMessageLabel => 'Reply to Message'; @override - String attachmentLimitExceedError(int limit) => - 'Attachment limit exceeded, limit: $limit'; + String attachmentLimitExceedError(int limit) => 'Attachment limit exceeded, limit: $limit'; @override String get slowModeOnLabel => 'Slow mode ON'; + @override + String get commandUsernameLabel => '@username'; + @override String get downloadLabel => 'Download'; @@ -433,8 +438,7 @@ class StreamChatLocalizationsEn extends GlobalStreamChatLocalizations { } @override - String get linkDisabledDetails => - 'Sending links is not allowed in this conversation.'; + String get linkDisabledDetails => 'Sending links is not allowed in this conversation.'; @override String get linkDisabledError => 'Links are disabled'; @@ -446,8 +450,7 @@ class StreamChatLocalizationsEn extends GlobalStreamChatLocalizations { String unreadMessagesSeparatorText() => 'New messages'; @override - String get enableFileAccessMessage => 'Please enable access to files' - '\nso you can share them with friends.'; + String get enableFileAccessMessage => 'Please enable access to files so you can share them with friends.'; @override String get allowFileAccessMessage => 'Allow access to files'; @@ -472,7 +475,10 @@ class StreamChatLocalizationsEn extends GlobalStreamChatLocalizations { } @override - String get questionsLabel => 'Questions'; + String questionLabel({bool isPlural = false}) { + if (isPlural) return 'Questions'; + return 'Question'; + } @override String get askAQuestionLabel => 'Ask a question'; @@ -555,15 +561,17 @@ class StreamChatLocalizationsEn extends GlobalStreamChatLocalizations { String get enterYourCommentLabel => 'Enter your comment'; @override - String get endVoteConfirmationText => - 'Are you sure you want to end the vote?'; + String get endVoteConfirmationTitle => 'End This Poll?'; + + @override + String get endVoteConfirmationMessage => + 'Do you want to end this poll now? Nobody will be able to vote in this poll anymore.'; @override String get deletePollOptionLabel => 'Delete Option'; @override - String get deletePollOptionQuestion => - 'Are you sure you want to delete this option?'; + String get deletePollOptionQuestion => 'Are you sure you want to delete this option?'; @override String get createLabel => 'Create'; @@ -594,23 +602,36 @@ class StreamChatLocalizationsEn extends GlobalStreamChatLocalizations { String get viewResultsLabel => 'View Results'; @override - String get endVoteLabel => 'End Vote'; + String get endVoteLabel => 'End Poll'; @override String get pollResultsLabel => 'Poll Results'; + @override + String get pollVotesLabel => 'Votes'; + @override String showAllVotesLabel({int? count}) { if (count == null) return 'Show all votes'; return 'Show all $count votes'; } + @override + String get viewAllLabel => 'View all'; + @override String voteCountLabel({int? count}) => switch (count) { - null || < 1 => '0 votes', - 1 => '1 vote', - _ => '$count votes', - }; + null || < 1 => '0 votes', + 1 => '1 vote', + _ => '$count votes', + }; + + @override + String totalVoteCountLabel({int? count}) => switch (count) { + null || < 1 => '0 votes total', + 1 => '1 vote total', + _ => '$count votes total', + }; @override String get noPollVotesLabel => 'There are no poll votes currently'; @@ -627,18 +648,20 @@ class StreamChatLocalizationsEn extends GlobalStreamChatLocalizations { return '$count new threads'; } + @override + String get loadingLabel => 'Loading...'; + @override String get slideToCancelLabel => 'Slide to cancel'; @override - String get holdToRecordLabel => 'Hold to record, release to send.'; + String get holdToRecordLabel => 'Hold to record. Release to save.'; @override String get sendAnywayLabel => 'Send Anyway'; @override - String get moderatedMessageBlockedText => - 'Message was blocked by moderation policies'; + String get moderatedMessageBlockedText => 'Message was blocked by moderation policies'; @override String get moderationReviewModalTitle => 'Are you sure?'; @@ -662,6 +685,21 @@ class StreamChatLocalizationsEn extends GlobalStreamChatLocalizations { @override String get videoAttachmentText => 'Video'; + @override + String get fileAttachmentText => 'File'; + + @override + String get linkAttachmentText => 'Link'; + + @override + String filesAttachmentCountText(int count) => count == 1 ? 'File' : '$count files'; + + @override + String photosAttachmentCountText(int count) => count == 1 ? 'Photo' : '$count photos'; + + @override + String videosAttachmentCountText(int count) => count == 1 ? 'Video' : '$count videos'; + @override String get pollYouVotedText => 'You voted'; @@ -676,4 +714,97 @@ class StreamChatLocalizationsEn extends GlobalStreamChatLocalizations { @override String get draftLabel => 'Draft'; + + @override + String locationLabel({bool isLive = false}) { + if (isLive) return 'Live Location'; + return 'Location'; + } + + @override + String get noConversationsYetText => 'No conversations yet'; + + @override + String get replyToStartThreadText => 'Reply to a message to start a thread'; + + @override + String get sendMessageToStartConversationText => 'Send a message to start the conversation'; + + @override + String get savedForLaterLabel => 'Saved for later'; + + @override + String get repliedToThreadAnnotationLabel => 'Replied to a thread'; + + @override + String get alsoSentInChannelAnnotationLabel => 'Also sent in channel'; + + @override + String get viewLabel => 'View'; + + @override + String get reminderSetLabel => 'Reminder set'; + + @override + String reminderAtText(String time) => 'Today at $time'; + + @override + String get createPollPromptLabel => 'Create a poll and let everyone vote!'; + + @override + String get takePhotoAndShareLabel => 'Take a photo and share'; + + @override + String get takeVideoAndShareLabel => 'Take a video and share'; + + @override + String get openCameraLabel => 'Open camera'; + + @override + String get selectFilesToShareLabel => 'Select files to share'; + + @override + String get openFilesLabel => 'Open files'; + + @override + String get unsupportedAttachmentLabel => 'Unsupported Attachment'; + + @override + String get confirmLabel => 'CONFIRM'; + + @override + String get emptyReactionsText => 'No reactions yet'; + + @override + String get loadingReactionsError => 'Error loading reactions'; + + @override + String get tapToRemoveReactionLabel => 'Tap to remove'; + + @override + String reactionsCountText(int count) => count == 1 ? '1 Reaction' : '$count Reactions'; + + @override + String get justNowLabel => 'Just now'; + + @override + String replyToUserLabel(String userName) => 'Reply to $userName'; + + @override + String get multipleAnswersDescription => 'Select more than one option'; + + @override + String maximumVotesPerPersonDescription([Range? range]) { + final (:min, :max) = range ?? (min: 2, max: 10); + return 'Choose between $min\u2013$max options'; + } + + @override + String get anonymousPollDescription => 'Hide who voted'; + + @override + String get suggestAnOptionDescription => 'Let others add options'; + + @override + String get addACommentDescription => 'Allow others to add comments'; } diff --git a/packages/stream_chat_localizations/lib/src/stream_chat_localizations_es.dart b/packages/stream_chat_localizations/lib/src/stream_chat_localizations_es.dart index e1587597c7..2d971b1a2e 100644 --- a/packages/stream_chat_localizations/lib/src/stream_chat_localizations_es.dart +++ b/packages/stream_chat_localizations/lib/src/stream_chat_localizations_es.dart @@ -1,3 +1,5 @@ +// ignore_for_file: lines_longer_than_80_chars + part of 'stream_chat_localizations.dart'; /// The translations for Spanish (`es`). @@ -43,15 +45,13 @@ class StreamChatLocalizationsEs extends GlobalStreamChatLocalizations { String get onlyVisibleToYouText => 'Sólo visible para usted'; @override - String threadReplyCountText(int count) => - '$count respuestas al hilo de discusión'; + String threadReplyCountText(int count) => '$count respuestas al hilo de discusión'; @override String attachmentsUploadProgressText({ - required int remaining, + required int completed, required int total, - }) => - 'Transferencia en curso $remaining/$total ...'; + }) => 'Subidos $completed de $total ...'; @override String pinnedByUserText({ @@ -64,18 +64,16 @@ class StreamChatLocalizationsEs extends GlobalStreamChatLocalizations { } @override - String get sendMessagePermissionError => - 'No tienes permiso para enviar mensajes'; + String get sendMessagePermissionError => 'No tienes permiso para enviar mensajes'; @override - String get emptyMessagesText => 'Actualmente no hay mensajes'; + String get emptyMessagesText => 'Aún no hay mensajes'; @override String get genericErrorText => 'Hubo un problema'; @override - String get loadingMessagesError => - 'Hubo un error mientras se cargaba el mensaje'; + String get loadingMessagesError => 'Hubo un error mientras se cargaba el mensaje'; @override String resultCountText(int count) => '$count resultados'; @@ -114,8 +112,7 @@ class StreamChatLocalizationsEs extends GlobalStreamChatLocalizations { String get reconnectingLabel => 'Reconectando...'; @override - String get alsoSendAsDirectMessageLabel => - 'Enviar también como mensaje directo'; + String get alsoSendAsDirectMessageLabel => 'Enviar también como mensaje directo'; @override String get addACommentOrSendLabel => 'Añadir un comentario o enviar'; @@ -124,7 +121,7 @@ class StreamChatLocalizationsEs extends GlobalStreamChatLocalizations { String get searchGifLabel => 'Búsqueda de GIFs'; @override - String get writeAMessageLabel => 'Escribir un mensaje'; + String get writeAMessageLabel => 'Enviar un mensaje'; @override String get instantCommandsLabel => 'Comandos instantáneos'; @@ -141,8 +138,7 @@ class StreamChatLocalizationsEs extends GlobalStreamChatLocalizations { 'El límite de tamaño de los archivos es de $limitInMB MB.'; @override - String get couldNotReadBytesFromFileError => - 'No se pudieron leer los bytes del archivo.'; + String get couldNotReadBytesFromFileError => 'No se pudieron leer los bytes del archivo.'; @override String get addAFileLabel => 'Añadir un archivo'; @@ -169,12 +165,11 @@ class StreamChatLocalizationsEs extends GlobalStreamChatLocalizations { String get somethingWentWrongError => 'Algo ha salido mal'; @override - String get addMoreFilesLabel => 'Añadir más archivos'; + String get addMoreFilesLabel => 'Añadir más'; @override String get enablePhotoAndVideoAccessMessage => - 'Por favor, permita el acceso a sus fotos' - '\ny vídeos para que pueda compartirlos con sus amigos.'; + 'Por favor, permita el acceso a sus fotos y vídeos para que pueda compartirlos con sus amigos.'; @override String get allowGalleryAccessMessage => 'Permitir el acceso a su galería'; @@ -184,35 +179,31 @@ class StreamChatLocalizationsEs extends GlobalStreamChatLocalizations { @override String get flagMessageQuestion => - '¿Quiere enviar una copia de este mensaje a un' - '\nmoderador para una mayor investigación?'; + '¿Quiere enviar una copia de este mensaje a un moderador para una mayor investigación?'; @override - String get flagLabel => 'REPORTAR'; + String get flagLabel => 'Reportar'; @override - String get cancelLabel => 'CANCELAR'; + String get cancelLabel => 'Cancelar'; @override String get flagMessageSuccessfulLabel => 'Mensaje reportado'; @override - String get flagMessageSuccessfulText => - 'Este mensaje ha sido reportado a un moderador.'; + String get flagMessageSuccessfulText => 'Este mensaje ha sido reportado a un moderador.'; @override - String get deleteLabel => 'BORRAR'; + String get deleteLabel => 'Borrar'; @override String get deleteMessageLabel => 'Borrar el mensaje'; @override - String get deleteMessageQuestion => - '¿Estás seguro de que quieres borrar este\nmensaje de forma permanente?'; + String get deleteMessageQuestion => '¿Estás seguro de que quieres borrar este mensaje de forma permanente?'; @override - String get operationCouldNotBeCompletedText => - 'La operación no pudo completarse.'; + String get operationCouldNotBeCompletedText => 'La operación no pudo completarse.'; @override String get replyLabel => 'Responder'; @@ -244,6 +235,9 @@ class StreamChatLocalizationsEs extends GlobalStreamChatLocalizations { @override String get photosLabel => 'Fotos'; + @override + String get photosAndVideosLabel => 'Fotos y vídeos'; + String _getDay(DateTime dateTime) { final now = DateTime.now(); final today = DateTime(now.year, now.month, now.day); @@ -282,8 +276,7 @@ class StreamChatLocalizationsEs extends GlobalStreamChatLocalizations { String get letsStartChattingLabel => '¡Empecemos a charlar!'; @override - String get sendingFirstMessageLabel => - '¿Qué le parece enviar su primer mensaje a un amigo?'; + String get sendingFirstMessageLabel => '¿Qué le parece enviar su primer mensaje a un amigo?'; @override String get startAChatLabel => 'Iniciar una conversación'; @@ -295,11 +288,10 @@ class StreamChatLocalizationsEs extends GlobalStreamChatLocalizations { String get deleteConversationLabel => 'Borrar la conversación'; @override - String get deleteConversationQuestion => - '¿Estás seguro de que quieres borrar esta conversación?'; + String get deleteConversationQuestion => '¿Estás seguro de que quieres borrar esta conversación?'; @override - String get streamChatLabel => 'Stream Chat'; + String get streamChatLabel => 'Conversaciones'; @override String get searchingForNetworkText => 'Buscando red'; @@ -322,6 +314,16 @@ class StreamChatLocalizationsEs extends GlobalStreamChatLocalizations { return '$count En línea'; } + @override + String membersCountWithOnlineText({ + required int memberCount, + required int onlineCount, + }) { + final members = membersCountText(memberCount); + if (onlineCount <= 0) return members; + return '$members, ${watchersCountText(onlineCount)}'; + } + @override String get viewInfoLabel => 'Ver información'; @@ -335,8 +337,7 @@ class StreamChatLocalizationsEs extends GlobalStreamChatLocalizations { String get leaveConversationLabel => 'Salir de la conversación'; @override - String get leaveConversationQuestion => - '¿Estás seguro de que quiere salir de esta conversación?'; + String get leaveConversationQuestion => '¿Estás seguro de que quiere salir de esta conversación?'; @override String get showInChatLabel => 'Mostrar en el chat'; @@ -372,8 +373,7 @@ class StreamChatLocalizationsEs extends GlobalStreamChatLocalizations { String galleryPaginationText({ required int currentPage, required int totalPages, - }) => - '${currentPage + 1} de $totalPages'; + }) => '${currentPage + 1} de $totalPages'; @override String get fileText => 'Archivo'; @@ -382,7 +382,8 @@ class StreamChatLocalizationsEs extends GlobalStreamChatLocalizations { String get replyToMessageLabel => 'Responder al Mensaje'; @override - String attachmentLimitExceedError(int limit) => ''' + String attachmentLimitExceedError(int limit) => + ''' No es posible añadir más de $limit archivos adjuntos '''; @@ -392,6 +393,9 @@ No es posible añadir más de $limit archivos adjuntos @override String get slowModeOnLabel => 'Modo lento activado'; + @override + String get commandUsernameLabel => '@username'; + @override String get downloadLabel => 'Descargar'; @@ -441,8 +445,7 @@ No es posible añadir más de $limit archivos adjuntos } @override - String get linkDisabledDetails => - 'No se permite enviar enlaces en esta conversación.'; + String get linkDisabledDetails => 'No se permite enviar enlaces en esta conversación.'; @override String get linkDisabledError => 'Los enlaces están deshabilitados'; @@ -451,8 +454,7 @@ No es posible añadir más de $limit archivos adjuntos String unreadMessagesSeparatorText() => 'Nuevos mensajes'; @override - String get enableFileAccessMessage => 'Habilite el acceso a los archivos' - '\npara poder compartirlos con amigos.'; + String get enableFileAccessMessage => 'Habilite el acceso a los archivos para poder compartirlos con amigos.'; @override String get allowFileAccessMessage => 'Permitir el acceso a los archivos'; @@ -477,7 +479,10 @@ No es posible añadir más de $limit archivos adjuntos } @override - String get questionsLabel => 'Preguntas'; + String questionLabel({bool isPlural = false}) { + if (isPlural) return 'Preguntas'; + return 'Pregunta'; + } @override String get askAQuestionLabel => 'Hacer una pregunta'; @@ -560,15 +565,17 @@ No es posible añadir más de $limit archivos adjuntos String get enterYourCommentLabel => 'Ingresa tu comentario'; @override - String get endVoteConfirmationText => - '¿Estás seguro de que quieres finalizar la votación?'; + String get endVoteConfirmationTitle => '¿Estás seguro de que quieres finalizar la votación?'; + + @override + String get endVoteConfirmationMessage => + '¿Quieres finalizar esta encuesta ahora? Nadie podrá votar en esta encuesta.'; @override String get deletePollOptionLabel => 'Eliminar opción'; @override - String get deletePollOptionQuestion => - '¿Estás seguro de que quieres eliminar esta opción?'; + String get deletePollOptionQuestion => '¿Estás seguro de que quieres eliminar esta opción?'; @override String get createLabel => 'Crear'; @@ -604,25 +611,37 @@ No es posible añadir más de $limit archivos adjuntos @override String get pollResultsLabel => 'Resultados de la encuesta'; + @override + String get pollVotesLabel => 'Votos'; + @override String showAllVotesLabel({int? count}) { if (count == null) return 'Mostrar todos los votos'; return 'Mostrar todos los $count votos'; } + @override + String get viewAllLabel => 'Ver todo'; + @override String voteCountLabel({int? count}) => switch (count) { - null || < 1 => '0 votos', - 1 => '1 voto', - _ => '$count votos', - }; + null || < 1 => '0 votos', + 1 => '1 voto', + _ => '$count votos', + }; + + @override + String totalVoteCountLabel({int? count}) => switch (count) { + null || < 1 => '0 votos en total', + 1 => '1 voto en total', + _ => '$count votos en total', + }; @override String get noPollVotesLabel => 'No hay votos en la encuesta actualmente'; @override - String get loadingPollVotesError => - 'Error al cargar los votos de la encuesta'; + String get loadingPollVotesError => 'Error al cargar los votos de la encuesta'; @override String get repliedToLabel => 'respondido a:'; @@ -633,19 +652,20 @@ No es posible añadir más de $limit archivos adjuntos return '$count nuevos hilos'; } + @override + String get loadingLabel => 'Cargando...'; + @override String get slideToCancelLabel => 'Desliza para cancelar'; @override - String get holdToRecordLabel => - 'Mantén pulsado para grabar, suelta para enviar'; + String get holdToRecordLabel => 'Mantén pulsado para grabar, suelta para enviar'; @override String get sendAnywayLabel => 'Enviar de todos modos'; @override - String get moderatedMessageBlockedText => - 'Mensaje bloqueado por políticas de moderación'; + String get moderatedMessageBlockedText => 'Mensaje bloqueado por políticas de moderación'; @override String get moderationReviewModalTitle => '¿Estás seguro?'; @@ -669,6 +689,21 @@ No es posible añadir más de $limit archivos adjuntos @override String get videoAttachmentText => 'Video'; + @override + String get fileAttachmentText => 'Archivo'; + + @override + String get linkAttachmentText => 'Enlace'; + + @override + String filesAttachmentCountText(int count) => count == 1 ? 'Archivo' : '$count archivos'; + + @override + String photosAttachmentCountText(int count) => count == 1 ? 'Foto' : '$count fotos'; + + @override + String videosAttachmentCountText(int count) => count == 1 ? 'Vídeo' : '$count vídeos'; + @override String get pollYouVotedText => 'Has votado'; @@ -683,4 +718,97 @@ No es posible añadir más de $limit archivos adjuntos @override String get draftLabel => 'Borrador'; + + @override + String locationLabel({bool isLive = false}) { + if (isLive) return 'Ubicación en vivo'; + return 'Ubicación'; + } + + @override + String get noConversationsYetText => 'Aún no hay conversaciones'; + + @override + String get replyToStartThreadText => 'Responde a un mensaje para iniciar un hilo'; + + @override + String get sendMessageToStartConversationText => 'Envía un mensaje para iniciar la conversación'; + + @override + String get savedForLaterLabel => 'Guardado para después'; + + @override + String get repliedToThreadAnnotationLabel => 'Respondió a un hilo'; + + @override + String get alsoSentInChannelAnnotationLabel => 'También enviado en el canal'; + + @override + String get viewLabel => 'Ver'; + + @override + String get reminderSetLabel => 'Recordatorio establecido'; + + @override + String reminderAtText(String time) => 'Hoy a las $time'; + + @override + String get createPollPromptLabel => '¡Crea una encuesta y deja que todos voten!'; + + @override + String get takePhotoAndShareLabel => 'Toma una foto y comparte'; + + @override + String get takeVideoAndShareLabel => 'Graba un video y comparte'; + + @override + String get openCameraLabel => 'Abrir cámara'; + + @override + String get selectFilesToShareLabel => 'Selecciona archivos para compartir'; + + @override + String get openFilesLabel => 'Abrir archivos'; + + @override + String get unsupportedAttachmentLabel => 'Archivo adjunto no compatible'; + + @override + String get confirmLabel => 'CONFIRMAR'; + + @override + String get emptyReactionsText => 'Aún no hay reacciones'; + + @override + String get loadingReactionsError => 'Error al cargar las reacciones'; + + @override + String get tapToRemoveReactionLabel => 'Toca para eliminar'; + + @override + String reactionsCountText(int count) => '$count reacciones'; + + @override + String get justNowLabel => 'Ahora mismo'; + + @override + String replyToUserLabel(String userName) => 'Responder a $userName'; + + @override + String get multipleAnswersDescription => 'Seleccionar más de una opción'; + + @override + String maximumVotesPerPersonDescription([Range? range]) { + final (:min, :max) = range ?? (min: 2, max: 10); + return 'Elige entre $min\u2013$max opciones'; + } + + @override + String get anonymousPollDescription => 'Ocultar quién votó'; + + @override + String get suggestAnOptionDescription => 'Permitir que otros añadan opciones'; + + @override + String get addACommentDescription => 'Permitir que otros añadan comentarios'; } diff --git a/packages/stream_chat_localizations/lib/src/stream_chat_localizations_fr.dart b/packages/stream_chat_localizations/lib/src/stream_chat_localizations_fr.dart index 7d210044ed..b6ace6fdac 100644 --- a/packages/stream_chat_localizations/lib/src/stream_chat_localizations_fr.dart +++ b/packages/stream_chat_localizations/lib/src/stream_chat_localizations_fr.dart @@ -1,3 +1,5 @@ +// ignore_for_file: lines_longer_than_80_chars + part of 'stream_chat_localizations.dart'; /// The translations for French (`fr`). @@ -43,15 +45,13 @@ class StreamChatLocalizationsFr extends GlobalStreamChatLocalizations { String get onlyVisibleToYouText => 'Seulement visible par vous'; @override - String threadReplyCountText(int count) => - '$count Réponses au fil de discussion'; + String threadReplyCountText(int count) => '$count Réponses au fil de discussion'; @override String attachmentsUploadProgressText({ - required int remaining, + required int completed, required int total, - }) => - 'Transfert en cours $remaining/$total ...'; + }) => 'Téléversés $completed sur $total ...'; @override String pinnedByUserText({ @@ -64,11 +64,10 @@ class StreamChatLocalizationsFr extends GlobalStreamChatLocalizations { } @override - String get sendMessagePermissionError => - "Vous n'êtes pas autorisé à envoyer des messages"; + String get sendMessagePermissionError => "Vous n'êtes pas autorisé à envoyer des messages"; @override - String get emptyMessagesText => "Il n'y a pas de messages actuellement"; + String get emptyMessagesText => 'Aucun message pour le moment'; @override String get genericErrorText => 'Il y a eu un problème'; @@ -113,8 +112,7 @@ class StreamChatLocalizationsFr extends GlobalStreamChatLocalizations { String get reconnectingLabel => 'Reconnexion...'; @override - String get alsoSendAsDirectMessageLabel => - 'Envoyer aussi comme message direct'; + String get alsoSendAsDirectMessageLabel => 'Envoyer aussi comme message direct'; @override String get addACommentOrSendLabel => 'Ajouter un commentaire ou envoyer'; @@ -123,7 +121,7 @@ class StreamChatLocalizationsFr extends GlobalStreamChatLocalizations { String get searchGifLabel => 'Recherche de GIFs'; @override - String get writeAMessageLabel => 'Écrire un message'; + String get writeAMessageLabel => 'Envoyer un message'; @override String get instantCommandsLabel => 'Commandes instantanées'; @@ -140,8 +138,7 @@ class StreamChatLocalizationsFr extends GlobalStreamChatLocalizations { 'La taille limite du fichier est de $limitInMB Mo.'; @override - String get couldNotReadBytesFromFileError => - 'Impossible de lire les octets du fichier.'; + String get couldNotReadBytesFromFileError => 'Impossible de lire les octets du fichier.'; @override String get addAFileLabel => 'Ajouter un fichier'; @@ -168,12 +165,11 @@ class StreamChatLocalizationsFr extends GlobalStreamChatLocalizations { String get somethingWentWrongError => 'Quelque chose a mal tourné'; @override - String get addMoreFilesLabel => "Ajouter d'autres fichiers"; + String get addMoreFilesLabel => 'Ajouter plus'; @override String get enablePhotoAndVideoAccessMessage => - "Veuillez autoriser l'accès à vos photos" - '\net vidéos afin de pouvoir les partager avec vos amis.'; + "Veuillez autoriser l'accès à vos photos et vidéos afin de pouvoir les partager avec vos amis."; @override String get allowGalleryAccessMessage => "Autoriser l'accès à votre galerie"; @@ -183,35 +179,31 @@ class StreamChatLocalizationsFr extends GlobalStreamChatLocalizations { @override String get flagMessageQuestion => - 'Voulez-vous envoyer une copie de ce message à un' - '\nmodérateur pour une enquête plus approfondie ?'; + 'Voulez-vous envoyer une copie de ce message à un modérateur pour une enquête plus approfondie ?'; @override - String get flagLabel => 'SIGNALER'; + String get flagLabel => 'Signaler'; @override - String get cancelLabel => 'ANNULER'; + String get cancelLabel => 'Annuler'; @override String get flagMessageSuccessfulLabel => 'Message signalé'; @override - String get flagMessageSuccessfulText => - 'Ce message a été signalé à un modérateur.'; + String get flagMessageSuccessfulText => 'Ce message a été signalé à un modérateur.'; @override - String get deleteLabel => 'SUPPRIMER'; + String get deleteLabel => 'Supprimer'; @override String get deleteMessageLabel => 'Supprimer le message'; @override - String get deleteMessageQuestion => - 'Êtes-vous sûr de vouloir supprimer définitivement ce\nmessage ?'; + String get deleteMessageQuestion => 'Êtes-vous sûr de vouloir supprimer définitivement ce message ?'; @override - String get operationCouldNotBeCompletedText => - "L'opération n'a pas pu être terminée."; + String get operationCouldNotBeCompletedText => "L'opération n'a pas pu être terminée."; @override String get replyLabel => 'Répondre'; @@ -243,6 +235,9 @@ class StreamChatLocalizationsFr extends GlobalStreamChatLocalizations { @override String get photosLabel => 'Photos'; + @override + String get photosAndVideosLabel => 'Photos et vidéos'; + String _getDay(DateTime dateTime) { final now = DateTime.now(); final today = DateTime(now.year, now.month, now.day); @@ -281,8 +276,7 @@ class StreamChatLocalizationsFr extends GlobalStreamChatLocalizations { String get letsStartChattingLabel => 'Commençons à discuter !'; @override - String get sendingFirstMessageLabel => - "Que diriez-vous d'envoyer votre premier message à un ami ?"; + String get sendingFirstMessageLabel => "Que diriez-vous d'envoyer votre premier message à un ami ?"; @override String get startAChatLabel => 'Commencer une discussion'; @@ -294,11 +288,10 @@ class StreamChatLocalizationsFr extends GlobalStreamChatLocalizations { String get deleteConversationLabel => 'Supprimer la conversation'; @override - String get deleteConversationQuestion => - 'Vous êtes sûr de vouloir supprimer cette conversation ?'; + String get deleteConversationQuestion => 'Vous êtes sûr de vouloir supprimer cette conversation ?'; @override - String get streamChatLabel => 'Stream Chat'; + String get streamChatLabel => 'Conversations'; @override String get searchingForNetworkText => 'Recherche de réseau'; @@ -321,6 +314,16 @@ class StreamChatLocalizationsFr extends GlobalStreamChatLocalizations { return '$count En ligne'; } + @override + String membersCountWithOnlineText({ + required int memberCount, + required int onlineCount, + }) { + final members = membersCountText(memberCount); + if (onlineCount <= 0) return members; + return '$members, ${watchersCountText(onlineCount)}'; + } + @override String get viewInfoLabel => 'Voir les informations'; @@ -334,8 +337,7 @@ class StreamChatLocalizationsFr extends GlobalStreamChatLocalizations { String get leaveConversationLabel => 'Quitter la conversation'; @override - String get leaveConversationQuestion => - 'Etes-vous sûr de vouloir quitter cette conversation ?'; + String get leaveConversationQuestion => 'Etes-vous sûr de vouloir quitter cette conversation ?'; @override String get showInChatLabel => 'Montrer dans la Discussion'; @@ -371,8 +373,7 @@ class StreamChatLocalizationsFr extends GlobalStreamChatLocalizations { String galleryPaginationText({ required int currentPage, required int totalPages, - }) => - '${currentPage + 1} de $totalPages'; + }) => '${currentPage + 1} de $totalPages'; @override String get fileText => 'Fichier'; @@ -381,7 +382,8 @@ class StreamChatLocalizationsFr extends GlobalStreamChatLocalizations { String get replyToMessageLabel => 'Répondre au Message'; @override - String attachmentLimitExceedError(int limit) => ''' + String attachmentLimitExceedError(int limit) => + ''' Limite de pièces jointes dépassée : il n'est pas possible d'ajouter plus de $limit pièces jointes '''; @@ -391,6 +393,9 @@ Limite de pièces jointes dépassée : il n'est pas possible d'ajouter plus de $ @override String get slowModeOnLabel => 'Mode lent activé'; + @override + String get commandUsernameLabel => '@username'; + @override String get downloadLabel => 'Télécharger'; @@ -440,8 +445,7 @@ Limite de pièces jointes dépassée : il n'est pas possible d'ajouter plus de $ } @override - String get linkDisabledDetails => - "L'envoi de liens n'est pas autorisé dans cette conversation."; + String get linkDisabledDetails => "L'envoi de liens n'est pas autorisé dans cette conversation."; @override String get linkDisabledError => 'Les liens sont désactivés'; @@ -451,8 +455,7 @@ Limite de pièces jointes dépassée : il n'est pas possible d'ajouter plus de $ @override String get enableFileAccessMessage => - "Veuillez autoriser l'accès aux fichiers" - '\nafin de pouvoir les partager avec des amis.'; + "Veuillez autoriser l'accès aux fichiers afin de pouvoir les partager avec des amis."; @override String get allowFileAccessMessage => "Autoriser l'accès aux fichiers"; @@ -478,7 +481,10 @@ Limite de pièces jointes dépassée : il n'est pas possible d'ajouter plus de $ } @override - String get questionsLabel => 'Questions'; + String questionLabel({bool isPlural = false}) { + if (isPlural) return 'Questions'; + return 'Question'; + } @override String get askAQuestionLabel => 'Poser une question'; @@ -519,8 +525,7 @@ Limite de pièces jointes dépassée : il n'est pas possible d'ajouter plus de $ String get multipleAnswersLabel => 'Réponses multiples'; @override - String get maximumVotesPerPersonLabel => - 'Nombre maximum de votes par personne'; + String get maximumVotesPerPersonLabel => 'Nombre maximum de votes par personne'; @override String? maxVotesPerPersonValidationError(int votes, Range range) { @@ -562,15 +567,17 @@ Limite de pièces jointes dépassée : il n'est pas possible d'ajouter plus de $ String get enterYourCommentLabel => 'Entrez votre commentaire'; @override - String get endVoteConfirmationText => - 'Êtes-vous sûr de vouloir terminer le vote?'; + String get endVoteConfirmationTitle => 'Êtes-vous sûr de vouloir terminer le vote?'; + + @override + String get endVoteConfirmationMessage => + 'Voulez-vous terminer ce sondage maintenant ? Plus personne ne pourra voter dans ce sondage.'; @override String get deletePollOptionLabel => "Supprimer l'option"; @override - String get deletePollOptionQuestion => - 'Êtes-vous sûr de vouloir supprimer cette option ?'; + String get deletePollOptionQuestion => 'Êtes-vous sûr de vouloir supprimer cette option ?'; @override String get createLabel => 'Créer'; @@ -606,26 +613,37 @@ Limite de pièces jointes dépassée : il n'est pas possible d'ajouter plus de $ @override String get pollResultsLabel => 'Résultats du sondage'; + @override + String get pollVotesLabel => 'Votes'; + @override String showAllVotesLabel({int? count}) { if (count == null) return 'Afficher tous les votes'; return 'Afficher tous les $count votes'; } + @override + String get viewAllLabel => 'Voir tout'; + @override String voteCountLabel({int? count}) => switch (count) { - null || < 1 => '0 vote', - 1 => '1 vote', - _ => '$count votes', - }; + null || < 1 => '0 vote', + 1 => '1 vote', + _ => '$count votes', + }; + + @override + String totalVoteCountLabel({int? count}) => switch (count) { + null || < 1 => '0 vote au total', + 1 => '1 vote au total', + _ => '$count votes au total', + }; @override - String get noPollVotesLabel => - "Il n'y a pas de votes de sondage actuellement"; + String get noPollVotesLabel => "Il n'y a pas de votes de sondage actuellement"; @override - String get loadingPollVotesError => - 'Erreur de chargement des votes du sondage'; + String get loadingPollVotesError => 'Erreur de chargement des votes du sondage'; @override String get repliedToLabel => 'répondu à:'; @@ -636,19 +654,20 @@ Limite de pièces jointes dépassée : il n'est pas possible d'ajouter plus de $ return '$count Nouveaux fils'; } + @override + String get loadingLabel => 'Chargement...'; + @override String get slideToCancelLabel => 'Glissez pour annuler'; @override - String get holdToRecordLabel => - 'Maintenez pour enregistrer, relâchez pour envoyer'; + String get holdToRecordLabel => 'Maintenez pour enregistrer, relâchez pour envoyer'; @override String get sendAnywayLabel => 'Envoyer quand même'; @override - String get moderatedMessageBlockedText => - 'Message bloqué par les politiques de modération'; + String get moderatedMessageBlockedText => 'Message bloqué par les politiques de modération'; @override String get moderationReviewModalTitle => 'Êtes-vous sûr ?'; @@ -672,6 +691,21 @@ Limite de pièces jointes dépassée : il n'est pas possible d'ajouter plus de $ @override String get videoAttachmentText => 'Vidéo'; + @override + String get fileAttachmentText => 'Fichier'; + + @override + String get linkAttachmentText => 'Lien'; + + @override + String filesAttachmentCountText(int count) => count == 1 ? 'Fichier' : '$count fichiers'; + + @override + String photosAttachmentCountText(int count) => count == 1 ? 'Photo' : '$count photos'; + + @override + String videosAttachmentCountText(int count) => count == 1 ? 'Vidéo' : '$count vidéos'; + @override String get pollYouVotedText => 'Vous avez voté'; @@ -686,4 +720,97 @@ Limite de pièces jointes dépassée : il n'est pas possible d'ajouter plus de $ @override String get draftLabel => 'Brouillon'; + + @override + String locationLabel({bool isLive = false}) { + if (isLive) return 'Position en direct'; + return 'Position'; + } + + @override + String get noConversationsYetText => 'Pas encore de conversations'; + + @override + String get replyToStartThreadText => 'Répondez à un message pour démarrer un fil'; + + @override + String get sendMessageToStartConversationText => 'Envoyez un message pour démarrer la conversation'; + + @override + String get savedForLaterLabel => 'Enregistré pour plus tard'; + + @override + String get repliedToThreadAnnotationLabel => 'A répondu à un fil'; + + @override + String get alsoSentInChannelAnnotationLabel => 'Également envoyé dans le canal'; + + @override + String get viewLabel => 'Voir'; + + @override + String get reminderSetLabel => 'Rappel défini'; + + @override + String reminderAtText(String time) => "Aujourd'hui à $time"; + + @override + String get createPollPromptLabel => 'Créez un sondage et laissez tout le monde voter !'; + + @override + String get takePhotoAndShareLabel => 'Prendre une photo et partager'; + + @override + String get takeVideoAndShareLabel => 'Prendre une vidéo et partager'; + + @override + String get openCameraLabel => 'Ouvrir la caméra'; + + @override + String get selectFilesToShareLabel => 'Sélectionnez des fichiers à partager'; + + @override + String get openFilesLabel => 'Ouvrir des fichiers'; + + @override + String get unsupportedAttachmentLabel => 'Pièce jointe non prise en charge'; + + @override + String get confirmLabel => 'CONFIRMER'; + + @override + String get emptyReactionsText => 'Pas encore de réactions'; + + @override + String get loadingReactionsError => 'Erreur lors du chargement des réactions'; + + @override + String get tapToRemoveReactionLabel => 'Appuyer pour supprimer'; + + @override + String reactionsCountText(int count) => '$count Réactions'; + + @override + String get justNowLabel => "À l'instant"; + + @override + String replyToUserLabel(String userName) => 'Répondre à $userName'; + + @override + String get multipleAnswersDescription => "Sélectionner plus d'une option"; + + @override + String maximumVotesPerPersonDescription([Range? range]) { + final (:min, :max) = range ?? (min: 2, max: 10); + return 'Choisir entre $min et $max options'; + } + + @override + String get anonymousPollDescription => 'Masquer qui a voté'; + + @override + String get suggestAnOptionDescription => 'Laisser les autres ajouter des options'; + + @override + String get addACommentDescription => "Permettre aux autres d'ajouter des commentaires"; } diff --git a/packages/stream_chat_localizations/lib/src/stream_chat_localizations_hi.dart b/packages/stream_chat_localizations/lib/src/stream_chat_localizations_hi.dart index e4120b3782..199faf2bd1 100644 --- a/packages/stream_chat_localizations/lib/src/stream_chat_localizations_hi.dart +++ b/packages/stream_chat_localizations/lib/src/stream_chat_localizations_hi.dart @@ -1,3 +1,5 @@ +// ignore_for_file: lines_longer_than_80_chars + part of 'stream_chat_localizations.dart'; /// The translations for Hindi (`hi`). @@ -47,10 +49,9 @@ class StreamChatLocalizationsHi extends GlobalStreamChatLocalizations { @override String attachmentsUploadProgressText({ - required int remaining, + required int completed, required int total, - }) => - 'अपलोडिंग $remaining/$total ...'; + }) => '$total में से $completed अपलोड किए ...'; @override String pinnedByUserText({ @@ -66,7 +67,7 @@ class StreamChatLocalizationsHi extends GlobalStreamChatLocalizations { String get sendMessagePermissionError => 'आपको संदेश भेजने की अनुमति नहीं है'; @override - String get emptyMessagesText => 'वर्तमान में कोई संदेश नहीं है'; + String get emptyMessagesText => 'अभी तक कोई संदेश नहीं'; @override String get genericErrorText => 'कुछ समस्या हो गई'; @@ -120,7 +121,7 @@ class StreamChatLocalizationsHi extends GlobalStreamChatLocalizations { String get searchGifLabel => 'जीआईएफ खोजें'; @override - String get writeAMessageLabel => 'एक सन्देश लिखिए'; + String get writeAMessageLabel => 'संदेश भेजें'; @override String get instantCommandsLabel => 'तत्काल आदेश'; @@ -163,12 +164,11 @@ class StreamChatLocalizationsHi extends GlobalStreamChatLocalizations { String get somethingWentWrongError => 'लोड करने में समस्या'; @override - String get addMoreFilesLabel => 'और फ़ाइलें जोड़ें'; + String get addMoreFilesLabel => 'और जोड़ें'; @override String get enablePhotoAndVideoAccessMessage => - 'कृपया अपने फ़ोटो और वीडियो तक पहुंच सक्षम करें' - '\nताकि आप उन्हें मित्रों के साथ साझा कर सकें।'; + 'कृपया अपने फ़ोटो और वीडियो तक पहुंच सक्षम करे ताकि आप उन्हें मित्रों के साथ साझा कर सकें।'; @override String get allowGalleryAccessMessage => 'अपनी गैलरी तक पहुंच की अनुमति दें'; @@ -177,8 +177,7 @@ class StreamChatLocalizationsHi extends GlobalStreamChatLocalizations { String get flagMessageLabel => 'फ्लैग संदेश'; @override - String get flagMessageQuestion => 'क्या आप आगे की जांच के लिए इस संदेश की' - '\nएक प्रति मॉडरेटर को भेजना चाहते हैं?'; + String get flagMessageQuestion => 'क्या आप आगे की जांच के लिए इस संदेश की एक प्रति मॉडरेटर को भेजना चाहते हैं?'; @override String get flagLabel => 'फ्लैग'; @@ -190,8 +189,7 @@ class StreamChatLocalizationsHi extends GlobalStreamChatLocalizations { String get flagMessageSuccessfulLabel => 'संदेश फ्लैग हो गया'; @override - String get flagMessageSuccessfulText => - 'संदेश की रिपोर्ट एक मॉडरेटर को कर दी गई है।'; + String get flagMessageSuccessfulText => 'संदेश की रिपोर्ट एक मॉडरेटर को कर दी गई है।'; @override String get deleteLabel => 'हटाएँ'; @@ -200,12 +198,10 @@ class StreamChatLocalizationsHi extends GlobalStreamChatLocalizations { String get deleteMessageLabel => 'संदेश हटाएं'; @override - String get deleteMessageQuestion => - 'क्या आप वाकई इस संदेश को स्थायी रूप से\nहटाना चाहते हैं?'; + String get deleteMessageQuestion => 'क्या आप वाकई इस संदेश को स्थायी रूप से हटाना चाहते हैं?'; @override - String get operationCouldNotBeCompletedText => - 'कार्रवाई पूरी नहीं की जा सकी.'; + String get operationCouldNotBeCompletedText => 'कार्रवाई पूरी नहीं की जा सकी.'; @override String get replyLabel => 'जवाब दें'; @@ -237,6 +233,9 @@ class StreamChatLocalizationsHi extends GlobalStreamChatLocalizations { @override String get photosLabel => 'फ़ोटोज'; + @override + String get photosAndVideosLabel => 'फ़ोटोज़ और वीडियो'; + String _getDay(DateTime dateTime) { final now = DateTime.now(); final today = DateTime(now.year, now.month, now.day); @@ -275,8 +274,7 @@ class StreamChatLocalizationsHi extends GlobalStreamChatLocalizations { String get letsStartChattingLabel => 'चलो चैट करना शुरू करें!'; @override - String get sendingFirstMessageLabel => - 'किसी मित्र को अपना पहला संदेश भेजने के बारे में क्या विचार है?'; + String get sendingFirstMessageLabel => 'किसी मित्र को अपना पहला संदेश भेजने के बारे में क्या विचार है?'; @override String get startAChatLabel => 'चैट शुरू करें'; @@ -288,11 +286,10 @@ class StreamChatLocalizationsHi extends GlobalStreamChatLocalizations { String get deleteConversationLabel => 'वार्तालाप हटाए'; @override - String get deleteConversationQuestion => - 'क्या आप वाकई इस वार्तालाप को हटाना चाहते हैं?'; + String get deleteConversationQuestion => 'क्या आप वाकई इस वार्तालाप को हटाना चाहते हैं?'; @override - String get streamChatLabel => 'स्ट्रीम चैट'; + String get streamChatLabel => 'चैट'; @override String get searchingForNetworkText => 'नेटवर्क खोज रहे हैं'; @@ -315,6 +312,16 @@ class StreamChatLocalizationsHi extends GlobalStreamChatLocalizations { return '$count ऑनलाइन'; } + @override + String membersCountWithOnlineText({ + required int memberCount, + required int onlineCount, + }) { + final members = membersCountText(memberCount); + if (onlineCount <= 0) return members; + return '$members, ${watchersCountText(onlineCount)}'; + } + @override String get viewInfoLabel => 'जानकारी देखें'; @@ -328,8 +335,7 @@ class StreamChatLocalizationsHi extends GlobalStreamChatLocalizations { String get leaveConversationLabel => 'वार्तालाप छोड़े'; @override - String get leaveConversationQuestion => - 'क्या आप वाकई इस बातचीत को छोड़ना चाहते हैं?'; + String get leaveConversationQuestion => 'क्या आप वाकई इस बातचीत को छोड़ना चाहते हैं?'; @override String get showInChatLabel => 'चैट में दिखाएं'; @@ -365,8 +371,7 @@ class StreamChatLocalizationsHi extends GlobalStreamChatLocalizations { String galleryPaginationText({ required int currentPage, required int totalPages, - }) => - '${currentPage + 1} ऑफ़ $totalPages'; + }) => '${currentPage + 1} ऑफ़ $totalPages'; @override String get fileText => 'फ़ाइल'; @@ -375,7 +380,8 @@ class StreamChatLocalizationsHi extends GlobalStreamChatLocalizations { String get replyToMessageLabel => 'संदेश का जवाब'; @override - String attachmentLimitExceedError(int limit) => ''' + String attachmentLimitExceedError(int limit) => + ''' अटैचमेंट लिमिट: $limit अटैचमेंट से अधिक जोड़ना संभव नहीं है '''; @@ -385,6 +391,9 @@ class StreamChatLocalizationsHi extends GlobalStreamChatLocalizations { @override String get slowModeOnLabel => 'स्लो मोड चालू'; + @override + String get commandUsernameLabel => '@username'; + @override String get downloadLabel => 'डाउनलोड'; @@ -434,8 +443,7 @@ class StreamChatLocalizationsHi extends GlobalStreamChatLocalizations { } @override - String get linkDisabledDetails => - 'इस बातचीत में लिंक भेजने की अनुमति नहीं है.'; + String get linkDisabledDetails => 'इस बातचीत में लिंक भेजने की अनुमति नहीं है.'; @override String get linkDisabledError => 'लिंक भेजना प्रतिबंधित'; @@ -444,8 +452,7 @@ class StreamChatLocalizationsHi extends GlobalStreamChatLocalizations { String unreadMessagesSeparatorText() => 'नए संदेश।'; @override - String get enableFileAccessMessage => 'कृपया फ़ाइलों तक पहुंच सक्षम करें ताकि' - '\nआप उन्हें मित्रों के साथ साझा कर सकें।'; + String get enableFileAccessMessage => 'कृपया फ़ाइलों तक पहुंच सक्षम करें ताकि आप उन्हें मित्रों के साथ साझा कर सकें।'; @override String get allowFileAccessMessage => 'फाइलों तक पहुंच की अनुमति दें'; @@ -470,7 +477,7 @@ class StreamChatLocalizationsHi extends GlobalStreamChatLocalizations { } @override - String get questionsLabel => 'प्रश्न'; + String questionLabel({bool isPlural = false}) => 'प्रश्न'; @override String get askAQuestionLabel => 'प्रश्न पूछें'; @@ -547,15 +554,17 @@ class StreamChatLocalizationsHi extends GlobalStreamChatLocalizations { String get enterYourCommentLabel => 'अपनी टिप्पणी दर्ज करें'; @override - String get endVoteConfirmationText => - 'क्या आप वाकई मतदान समाप्त करना चाहते हैं?'; + String get endVoteConfirmationTitle => 'क्या आप वाकई मतदान समाप्त करना चाहते हैं?'; + + @override + String get endVoteConfirmationMessage => + 'क्या आप अभी इस पोल को समाप्त करना चाहते हैं? इसके बाद कोई भी इस पोल में वोट नहीं कर सकेगा।'; @override String get deletePollOptionLabel => 'विकल्प हटाएं'; @override - String get deletePollOptionQuestion => - 'क्या आप वाकई इस विकल्प को हटाना चाहते हैं?'; + String get deletePollOptionQuestion => 'क्या आप वाकई इस विकल्प को हटाना चाहते हैं?'; @override String get endLabel => 'समाप्त'; @@ -575,6 +584,9 @@ class StreamChatLocalizationsHi extends GlobalStreamChatLocalizations { @override String get pollResultsLabel => 'पोल परिणाम'; + @override + String get pollVotesLabel => 'वोट'; + @override String pollVotingModeLabel(PollVotingMode votingMode) { return votingMode.when( @@ -601,6 +613,9 @@ class StreamChatLocalizationsHi extends GlobalStreamChatLocalizations { return 'सभी वोट दिखाएं'; } + @override + String get viewAllLabel => 'सभी देखें'; + @override String get updateYourCommentLabel => 'अपनी टिप्पणी अपडेट करें'; @@ -618,6 +633,14 @@ class StreamChatLocalizationsHi extends GlobalStreamChatLocalizations { return 'वोट'; } + @override + String totalVoteCountLabel({int? count}) { + if (count != null) { + return 'कुल $count वोट'; + } + return 'कुल वोट'; + } + @override String get repliedToLabel => 'जवाब दिया:'; @@ -627,19 +650,20 @@ class StreamChatLocalizationsHi extends GlobalStreamChatLocalizations { return '$count नए थ्रेड्स'; } + @override + String get loadingLabel => 'लोड हो रहा है...'; + @override String get slideToCancelLabel => 'रद्द करने के लिए स्लाइड करें'; @override - String get holdToRecordLabel => - 'रिकॉर्ड करने के लिए दबाए रखें, भेजने के लिए छोड़ें'; + String get holdToRecordLabel => 'रिकॉर्ड करने के लिए दबाए रखें, भेजने के लिए छोड़ें'; @override String get sendAnywayLabel => 'फिर भी भेजें'; @override - String get moderatedMessageBlockedText => - 'मॉडरेशन नीतियों द्वारा संदेश अवरुद्ध किया गया'; + String get moderatedMessageBlockedText => 'मॉडरेशन नीतियों द्वारा संदेश अवरुद्ध किया गया'; @override String get moderationReviewModalTitle => 'क्या आप निश्चित हैं?'; @@ -663,6 +687,21 @@ class StreamChatLocalizationsHi extends GlobalStreamChatLocalizations { @override String get videoAttachmentText => 'वीडियो'; + @override + String get fileAttachmentText => 'फ़ाइल'; + + @override + String get linkAttachmentText => 'लिंक'; + + @override + String filesAttachmentCountText(int count) => count == 1 ? 'फ़ाइल' : '$count फ़ाइलें'; + + @override + String photosAttachmentCountText(int count) => count == 1 ? 'फ़ोटो' : '$count फ़ोटो'; + + @override + String videosAttachmentCountText(int count) => count == 1 ? 'वीडियो' : '$count वीडियो'; + @override String get pollYouVotedText => 'आपने वोट दिया'; @@ -677,4 +716,97 @@ class StreamChatLocalizationsHi extends GlobalStreamChatLocalizations { @override String get draftLabel => 'ड्राफ्ट'; + + @override + String locationLabel({bool isLive = false}) { + if (isLive) return 'लाइव लोकेशन'; + return 'लोकेशन'; + } + + @override + String get noConversationsYetText => 'अभी तक कोई बातचीत नहीं'; + + @override + String get replyToStartThreadText => 'थ्रेड शुरू करने के लिए किसी संदेश का जवाब दें'; + + @override + String get sendMessageToStartConversationText => 'बातचीत शुरू करने के लिए एक संदेश भेजें'; + + @override + String get savedForLaterLabel => 'बाद के लिए सहेजा गया'; + + @override + String get repliedToThreadAnnotationLabel => 'एक थ्रेड का जवाब दिया'; + + @override + String get alsoSentInChannelAnnotationLabel => 'चैनल में भी भेजा गया'; + + @override + String get viewLabel => 'देखें'; + + @override + String get reminderSetLabel => 'रिमाइंडर सेट'; + + @override + String reminderAtText(String time) => 'आज $time पर'; + + @override + String get createPollPromptLabel => 'पोल बनाएं और सबको वोट करने दें!'; + + @override + String get takePhotoAndShareLabel => 'फ़ोटो लें और साझा करें'; + + @override + String get takeVideoAndShareLabel => 'वीडियो लें और साझा करें'; + + @override + String get openCameraLabel => 'कैमरा खोलें'; + + @override + String get selectFilesToShareLabel => 'साझा करने के लिए फ़ाइलें चुनें'; + + @override + String get openFilesLabel => 'फ़ाइलें खोलें'; + + @override + String get unsupportedAttachmentLabel => 'असमर्थित अटैचमेंट'; + + @override + String get confirmLabel => 'पुष्टि करें'; + + @override + String get emptyReactionsText => 'अभी तक कोई प्रतिक्रिया नहीं'; + + @override + String get loadingReactionsError => 'प्रतिक्रियाएँ लोड करने में त्रुटि'; + + @override + String get tapToRemoveReactionLabel => 'हटाने के लिए टैप करें'; + + @override + String reactionsCountText(int count) => '$count प्रतिक्रियाएँ'; + + @override + String get justNowLabel => 'अभी अभी'; + + @override + String replyToUserLabel(String userName) => '$userName को जवाब दें'; + + @override + String get multipleAnswersDescription => 'एक से अधिक विकल्प चुनें'; + + @override + String maximumVotesPerPersonDescription([Range? range]) { + final (:min, :max) = range ?? (min: 2, max: 10); + return '$min\u2013$max विकल्पों में से चुनें'; + } + + @override + String get anonymousPollDescription => 'छिपाएँ कि किसने वोट दिया'; + + @override + String get suggestAnOptionDescription => 'दूसरों को विकल्प जोड़ने दें'; + + @override + String get addACommentDescription => 'दूसरों को टिप्पणियाँ जोड़ने दें'; } diff --git a/packages/stream_chat_localizations/lib/src/stream_chat_localizations_it.dart b/packages/stream_chat_localizations/lib/src/stream_chat_localizations_it.dart index 878a2a6b8a..f5f98a969b 100644 --- a/packages/stream_chat_localizations/lib/src/stream_chat_localizations_it.dart +++ b/packages/stream_chat_localizations/lib/src/stream_chat_localizations_it.dart @@ -1,3 +1,5 @@ +// ignore_for_file: lines_longer_than_80_chars + part of 'stream_chat_localizations.dart'; /// The translations for Italian (`it`). @@ -52,10 +54,9 @@ class StreamChatLocalizationsIt extends GlobalStreamChatLocalizations { @override String attachmentsUploadProgressText({ - required int remaining, + required int completed, required int total, - }) => - 'Caricamento $remaining/$total ...'; + }) => 'Caricati $completed di $total ...'; @override String pinnedByUserText({ @@ -68,18 +69,16 @@ class StreamChatLocalizationsIt extends GlobalStreamChatLocalizations { } @override - String get sendMessagePermissionError => - "Non hai l'autorizzazione per inviare messaggi"; + String get sendMessagePermissionError => "Non hai l'autorizzazione per inviare messaggi"; @override - String get emptyMessagesText => "Non c'é nessun messaggio al momento"; + String get emptyMessagesText => 'Nessun messaggio ancora'; @override String get genericErrorText => 'Qualcosa è andato storto'; @override - String get loadingMessagesError => - 'Errore durante il caricamento dei messaggi'; + String get loadingMessagesError => 'Errore durante il caricamento dei messaggi'; @override String resultCountText(int count) => '$count risultati'; @@ -118,8 +117,7 @@ class StreamChatLocalizationsIt extends GlobalStreamChatLocalizations { String get reconnectingLabel => 'Riconnessione in corso...'; @override - String get alsoSendAsDirectMessageLabel => - 'Manda anche come messaggio diretto'; + String get alsoSendAsDirectMessageLabel => 'Manda anche come messaggio diretto'; @override String get addACommentOrSendLabel => 'Aggiungi un commento o invia'; @@ -128,7 +126,7 @@ class StreamChatLocalizationsIt extends GlobalStreamChatLocalizations { String get searchGifLabel => 'Cerca una GIF'; @override - String get writeAMessageLabel => 'Scrivi un messaggio'; + String get writeAMessageLabel => 'Invia un messaggio'; @override String get instantCommandsLabel => 'Commandi istantanei'; @@ -144,8 +142,7 @@ class StreamChatLocalizationsIt extends GlobalStreamChatLocalizations { Il file è troppo grande per essere caricato. Il limite è di $limitInMB MB.'''; @override - String get couldNotReadBytesFromFileError => - 'Impossibile leggere i byte dal file.'; + String get couldNotReadBytesFromFileError => 'Impossibile leggere i byte dal file.'; @override String get addAFileLabel => 'Aggiungi un file'; @@ -172,12 +169,11 @@ Il file è troppo grande per essere caricato. Il limite è di $limitInMB MB.'''; String get somethingWentWrongError => 'Qualcosa è andato storto'; @override - String get addMoreFilesLabel => 'Aggiungi altri file'; + String get addMoreFilesLabel => 'Aggiungi altri'; @override String get enablePhotoAndVideoAccessMessage => - "Per favore attiva l'accesso alle foto" - '\ne ai video cosí potrai condividerli con i tuoi amici.'; + "Per favore attiva l'accesso alle foto e ai video cosí potrai condividerli con i tuoi amici."; @override String get allowGalleryAccessMessage => "Permetti l'accesso alla galleria"; @@ -186,35 +182,31 @@ Il file è troppo grande per essere caricato. Il limite è di $limitInMB MB.'''; String get flagMessageLabel => 'Segnala messaggio'; @override - String get flagMessageQuestion => 'Vuoi mandare una copia di questo messaggio' - '\nad un moderatore?'; + String get flagMessageQuestion => 'Vuoi mandare una copia di questo messaggio ad un moderatore?'; @override - String get flagLabel => 'SEGNALA'; + String get flagLabel => 'Segnala'; @override - String get cancelLabel => 'ANNULLA'; + String get cancelLabel => 'Annulla'; @override String get flagMessageSuccessfulLabel => 'Messaggio segnalato'; @override - String get flagMessageSuccessfulText => - 'Questo messaggio è stato segnalato ad un moderatore.'; + String get flagMessageSuccessfulText => 'Questo messaggio è stato segnalato ad un moderatore.'; @override - String get deleteLabel => 'CANCELLA'; + String get deleteLabel => 'Cancella'; @override String get deleteMessageLabel => 'Cancella messaggio'; @override - String get deleteMessageQuestion => - 'Sei sicuro di voler definitivamente cancellare questo\nmessaggio?'; + String get deleteMessageQuestion => 'Sei sicuro di voler definitivamente cancellare questo messaggio?'; @override - String get operationCouldNotBeCompletedText => - 'Non è stato possibile completare questa operazione.'; + String get operationCouldNotBeCompletedText => 'Non è stato possibile completare questa operazione.'; @override String get replyLabel => 'Rispondi'; @@ -246,6 +238,9 @@ Il file è troppo grande per essere caricato. Il limite è di $limitInMB MB.'''; @override String get photosLabel => 'Foto'; + @override + String get photosAndVideosLabel => 'Foto e video'; + String _getDay(DateTime dateTime) { final now = DateTime.now(); final today = DateTime(now.year, now.month, now.day); @@ -284,8 +279,7 @@ Il file è troppo grande per essere caricato. Il limite è di $limitInMB MB.'''; String get letsStartChattingLabel => 'Inizia una conversazione!'; @override - String get sendingFirstMessageLabel => - 'Che ne dici di mandare il tuo primo messaggio ad un amico?'; + String get sendingFirstMessageLabel => 'Che ne dici di mandare il tuo primo messaggio ad un amico?'; @override String get startAChatLabel => 'Inizia una conversazione'; @@ -297,11 +291,10 @@ Il file è troppo grande per essere caricato. Il limite è di $limitInMB MB.'''; String get deleteConversationLabel => 'Elimina conversazione'; @override - String get deleteConversationQuestion => - 'Sei sicuro di voler eliminare questa conversazione?'; + String get deleteConversationQuestion => 'Sei sicuro di voler eliminare questa conversazione?'; @override - String get streamChatLabel => 'Stream Chat'; + String get streamChatLabel => 'Conversazioni'; @override String get searchingForNetworkText => 'Cercando una connessione'; @@ -324,6 +317,16 @@ Il file è troppo grande per essere caricato. Il limite è di $limitInMB MB.'''; return '$count Online'; } + @override + String membersCountWithOnlineText({ + required int memberCount, + required int onlineCount, + }) { + final members = membersCountText(memberCount); + if (onlineCount <= 0) return members; + return '$members, ${watchersCountText(onlineCount)}'; + } + @override String get viewInfoLabel => 'Vedi info'; @@ -337,8 +340,7 @@ Il file è troppo grande per essere caricato. Il limite è di $limitInMB MB.'''; String get leaveConversationLabel => 'Esci dalla conversazione'; @override - String get leaveConversationQuestion => - 'Sei sicuro di voler lasciare questa conversazione?'; + String get leaveConversationQuestion => 'Sei sicuro di voler lasciare questa conversazione?'; @override String get showInChatLabel => 'Mostra nella chat'; @@ -374,8 +376,7 @@ Il file è troppo grande per essere caricato. Il limite è di $limitInMB MB.'''; String galleryPaginationText({ required int currentPage, required int totalPages, - }) => - '${currentPage + 1} di $totalPages'; + }) => '${currentPage + 1} di $totalPages'; @override String get fileText => 'file'; @@ -384,7 +385,8 @@ Il file è troppo grande per essere caricato. Il limite è di $limitInMB MB.'''; String get replyToMessageLabel => 'Rispondi al messaggio'; @override - String attachmentLimitExceedError(int limit) => ''' + String attachmentLimitExceedError(int limit) => + ''' Attenzione: il limite massimo di $limit file è stato superato. '''; @@ -394,6 +396,9 @@ Attenzione: il limite massimo di $limit file è stato superato. @override String get slowModeOnLabel => 'Slowmode attiva'; + @override + String get commandUsernameLabel => '@username'; + @override String get downloadLabel => 'Scaricamento'; @@ -443,8 +448,7 @@ Attenzione: il limite massimo di $limit file è stato superato. } @override - String get linkDisabledDetails => - 'Non è permesso condividere link in questa convesazione.'; + String get linkDisabledDetails => 'Non è permesso condividere link in questa convesazione.'; @override String get linkDisabledError => 'I links sono disattivati'; @@ -453,8 +457,8 @@ Attenzione: il limite massimo di $limit file è stato superato. String unreadMessagesSeparatorText() => 'Nuovi messaggi'; @override - String get enableFileAccessMessage => "Per favore attiva l'accesso ai file" - '\ncosí potrai condividerli con i tuoi amici.'; + String get enableFileAccessMessage => + "Per favore attiva l'accesso ai file cosí potrai condividerli con i tuoi amici."; @override String get allowFileAccessMessage => "Consenti l'accesso ai file"; @@ -480,7 +484,10 @@ Attenzione: il limite massimo di $limit file è stato superato. } @override - String get questionsLabel => 'Domande'; + String questionLabel({bool isPlural = false}) { + if (isPlural) return 'Domande'; + return 'Domanda'; + } @override String get askAQuestionLabel => 'Fai una domanda'; @@ -563,15 +570,17 @@ Attenzione: il limite massimo di $limit file è stato superato. String get enterYourCommentLabel => 'Inserisci il tuo commento'; @override - String get endVoteConfirmationText => - 'Sei sicuro di voler terminare il voto?'; + String get endVoteConfirmationTitle => 'Sei sicuro di voler terminare il voto?'; + + @override + String get endVoteConfirmationMessage => + 'Vuoi terminare questo sondaggio adesso? Nessuno potrà più votare in questo sondaggio.'; @override String get deletePollOptionLabel => "Elimina l'opzione"; @override - String get deletePollOptionQuestion => - 'Sei sicuro di voler eliminare questa opzione?'; + String get deletePollOptionQuestion => 'Sei sicuro di voler eliminare questa opzione?'; @override String get createLabel => 'Crea'; @@ -607,25 +616,37 @@ Attenzione: il limite massimo di $limit file è stato superato. @override String get pollResultsLabel => 'Risultati del sondaggio'; + @override + String get pollVotesLabel => 'Voti'; + @override String showAllVotesLabel({int? count}) { if (count == null) return 'Mostra tutti i voti'; return 'Mostra tutti i $count voti'; } + @override + String get viewAllLabel => 'Vedi tutto'; + @override String voteCountLabel({int? count}) => switch (count) { - null || < 1 => '0 voti', - 1 => '1 voto', - _ => '$count voti', - }; + null || < 1 => '0 voti', + 1 => '1 voto', + _ => '$count voti', + }; + + @override + String totalVoteCountLabel({int? count}) => switch (count) { + null || < 1 => '0 voti in totale', + 1 => '1 voto in totale', + _ => '$count voti in totale', + }; @override String get noPollVotesLabel => 'Attualmente non ci sono voti nel sondaggio'; @override - String get loadingPollVotesError => - 'Errore durante il caricamento dei voti del sondaggio'; + String get loadingPollVotesError => 'Errore durante il caricamento dei voti del sondaggio'; @override String get repliedToLabel => 'risposto a:'; @@ -636,19 +657,20 @@ Attenzione: il limite massimo di $limit file è stato superato. return '$count nuovi thread'; } + @override + String get loadingLabel => 'Caricamento...'; + @override String get slideToCancelLabel => 'Scorri per annullare'; @override - String get holdToRecordLabel => - 'Tieni premuto per registrare, rilascia per inviare'; + String get holdToRecordLabel => 'Tieni premuto per registrare, rilascia per inviare'; @override String get sendAnywayLabel => 'Invia comunque'; @override - String get moderatedMessageBlockedText => - 'Messaggio bloccato dalle politiche di moderazione'; + String get moderatedMessageBlockedText => 'Messaggio bloccato dalle politiche di moderazione'; @override String get moderationReviewModalTitle => 'Sei sicuro?'; @@ -672,6 +694,21 @@ Attenzione: il limite massimo di $limit file è stato superato. @override String get videoAttachmentText => 'Video'; + @override + String get fileAttachmentText => 'File'; + + @override + String get linkAttachmentText => 'Link'; + + @override + String filesAttachmentCountText(int count) => count == 1 ? 'File' : '$count file'; + + @override + String photosAttachmentCountText(int count) => count == 1 ? 'Foto' : '$count foto'; + + @override + String videosAttachmentCountText(int count) => count == 1 ? 'Video' : '$count video'; + @override String get pollYouVotedText => 'Hai votato'; @@ -686,4 +723,102 @@ Attenzione: il limite massimo di $limit file è stato superato. @override String get draftLabel => 'Bozza'; + + @override + String locationLabel({bool isLive = false}) { + if (isLive) return 'Posizione dal vivo'; + return 'Posizione'; + } + + @override + String get noConversationsYetText => 'Ancora nessuna conversazione'; + + @override + String get replyToStartThreadText => 'Rispondi a un messaggio per avviare un thread'; + + @override + String get sendMessageToStartConversationText => 'Invia un messaggio per iniziare la conversazione'; + + @override + String get savedForLaterLabel => 'Salvato per dopo'; + + @override + String get repliedToThreadAnnotationLabel => 'Ha risposto a un thread'; + + @override + String get alsoSentInChannelAnnotationLabel => 'Inviato anche nel canale'; + + @override + String get viewLabel => 'Visualizza'; + + @override + String get reminderSetLabel => 'Promemoria impostato'; + + @override + String reminderAtText(String time) => 'Oggi alle $time'; + + @override + String get createPollPromptLabel => 'Crea un sondaggio e fai votare tutti!'; + + @override + String get takePhotoAndShareLabel => 'Scatta una foto e condividi'; + + @override + String get takeVideoAndShareLabel => 'Registra un video e condividi'; + + @override + String get openCameraLabel => 'Apri fotocamera'; + + @override + String get selectFilesToShareLabel => 'Seleziona i file da condividere'; + + @override + String get openFilesLabel => 'Apri file'; + + @override + String get unsupportedAttachmentLabel => 'Allegato non supportato'; + + @override + String get confirmLabel => 'CONFERMA'; + + @override + String get emptyReactionsText => 'Ancora nessuna reazione'; + + @override + String get loadingReactionsError => 'Errore durante il caricamento delle reazioni'; + + @override + String get tapToRemoveReactionLabel => 'Tocca per rimuovere'; + + @override + String reactionsCountText(int count) { + if (count == 1) { + return '1 Reazione'; + } + return '$count Reazioni'; + } + + @override + String get justNowLabel => 'Proprio ora'; + + @override + String replyToUserLabel(String userName) => 'Rispondi a $userName'; + + @override + String get multipleAnswersDescription => "Seleziona più di un'opzione"; + + @override + String maximumVotesPerPersonDescription([Range? range]) { + final (:min, :max) = range ?? (min: 2, max: 10); + return 'Scegli tra $min\u2013$max opzioni'; + } + + @override + String get anonymousPollDescription => 'Nascondi chi ha votato'; + + @override + String get suggestAnOptionDescription => 'Permetti agli altri di aggiungere opzioni'; + + @override + String get addACommentDescription => 'Permetti agli altri di aggiungere commenti'; } diff --git a/packages/stream_chat_localizations/lib/src/stream_chat_localizations_ja.dart b/packages/stream_chat_localizations/lib/src/stream_chat_localizations_ja.dart index fea76688a1..817cc2ea0a 100644 --- a/packages/stream_chat_localizations/lib/src/stream_chat_localizations_ja.dart +++ b/packages/stream_chat_localizations/lib/src/stream_chat_localizations_ja.dart @@ -47,10 +47,9 @@ class StreamChatLocalizationsJa extends GlobalStreamChatLocalizations { @override String attachmentsUploadProgressText({ - required int remaining, + required int completed, required int total, - }) => - '$remaining/${total}mbのアップロード中…'; + }) => '$total 件中 $completed 件アップロード済み…'; @override String pinnedByUserText({ @@ -66,7 +65,7 @@ class StreamChatLocalizationsJa extends GlobalStreamChatLocalizations { String get sendMessagePermissionError => 'メッセージを送信する権限がありません'; @override - String get emptyMessagesText => '現在、メッセージはありません。'; + String get emptyMessagesText => 'メッセージはまだありません'; @override String get genericErrorText => 'エラーが発生しました'; @@ -117,7 +116,7 @@ class StreamChatLocalizationsJa extends GlobalStreamChatLocalizations { String get searchGifLabel => 'GIFの検索'; @override - String get writeAMessageLabel => 'メッセージを書く'; + String get writeAMessageLabel => 'メッセージを送る'; @override String get instantCommandsLabel => 'インスタントコマンド'; @@ -129,8 +128,7 @@ class StreamChatLocalizationsJa extends GlobalStreamChatLocalizations { '圧縮を試しましたがサイズをオーバーしました'; @override - String fileTooLargeError(double limitInMB) => - 'ファイルが大きすぎてアップロードできません。ファイルサイズの制限は${limitInMB}MBです。'; + String fileTooLargeError(double limitInMB) => 'ファイルが大きすぎてアップロードできません。ファイルサイズの制限は${limitInMB}MBです。'; @override String get couldNotReadBytesFromFileError => 'ファイルからバイトを読み取れませんでした'; @@ -160,11 +158,10 @@ class StreamChatLocalizationsJa extends GlobalStreamChatLocalizations { String get somethingWentWrongError => 'エラーが発生しました'; @override - String get addMoreFilesLabel => 'ファイルの追加'; + String get addMoreFilesLabel => 'さらに追加'; @override - String get enablePhotoAndVideoAccessMessage => 'お友達と共有できるように、写真' - '\nやビデオへのアクセスを有効にしてください。'; + String get enablePhotoAndVideoAccessMessage => 'お友達と共有できるように、写真やビデオへのアクセスを有効にしてください。'; @override String get allowGalleryAccessMessage => 'ギャラリーへのアクセスを許可する'; @@ -172,8 +169,7 @@ class StreamChatLocalizationsJa extends GlobalStreamChatLocalizations { String get flagMessageLabel => 'メッセージをフラグする'; @override - String get flagMessageQuestion => 'このメッセージのコピーを' - '\nモデレーターに送って、さらに調査してもらいますか?'; + String get flagMessageQuestion => 'このメッセージのコピーをモデレーターに送って、さらに調査してもらいますか?'; @override String get flagLabel => 'フラグする'; @@ -194,8 +190,7 @@ class StreamChatLocalizationsJa extends GlobalStreamChatLocalizations { String get deleteMessageLabel => 'メッセージを削除する'; @override - String get deleteMessageQuestion => 'このメッセージ' - '\nを完全に削除してもよろしいですか?'; + String get deleteMessageQuestion => 'このメッセージを完全に削除してもよろしいですか?'; @override String get operationCouldNotBeCompletedText => '操作を完了できませんでした。'; @@ -230,6 +225,9 @@ class StreamChatLocalizationsJa extends GlobalStreamChatLocalizations { @override String get photosLabel => '写真'; + @override + String get photosAndVideosLabel => '写真と動画'; + String _getDay(DateTime dateTime) { final now = DateTime.now(); final today = DateTime(now.year, now.month, now.day); @@ -283,7 +281,7 @@ class StreamChatLocalizationsJa extends GlobalStreamChatLocalizations { String get deleteConversationQuestion => '本当に会話を削除しますか?'; @override - String get streamChatLabel => 'ストリームチャット'; + String get streamChatLabel => 'チャット'; @override String get searchingForNetworkText => 'ネットワークを検索中'; @@ -300,6 +298,16 @@ class StreamChatLocalizationsJa extends GlobalStreamChatLocalizations { @override String watchersCountText(int count) => '$count人がオンライン'; + @override + String membersCountWithOnlineText({ + required int memberCount, + required int onlineCount, + }) { + final members = membersCountText(memberCount); + if (onlineCount <= 0) return members; + return '$members、${watchersCountText(onlineCount)}'; + } + @override String get viewInfoLabel => '情報を見る'; @@ -351,8 +359,7 @@ class StreamChatLocalizationsJa extends GlobalStreamChatLocalizations { String galleryPaginationText({ required int currentPage, required int totalPages, - }) => - '${currentPage + 1} / $totalPages'; + }) => '${currentPage + 1} / $totalPages'; @override String get fileText => 'ファイル'; @@ -363,11 +370,15 @@ class StreamChatLocalizationsJa extends GlobalStreamChatLocalizations { @override String get slowModeOnLabel => 'スローモードオン'; + @override + String get commandUsernameLabel => '@username'; + @override String get viewLibrary => 'ライブラリを表示'; @override - String attachmentLimitExceedError(int limit) => ''' + String attachmentLimitExceedError(int limit) => + ''' 添付ファイルの制限を超えました:$limit個のファイル以上を添付することはできません '''; @@ -429,8 +440,7 @@ class StreamChatLocalizationsJa extends GlobalStreamChatLocalizations { String unreadMessagesSeparatorText() => '新しいメッセージ。'; @override - String get enableFileAccessMessage => - '友達と共有できるように、' '\nファイルへのアクセスを有効にしてください。'; + String get enableFileAccessMessage => '友達と共有できるように、ファイルへのアクセスを有効にしてください。'; @override String get allowFileAccessMessage => 'ファイルへのアクセスを許可する'; @@ -444,8 +454,7 @@ class StreamChatLocalizationsJa extends GlobalStreamChatLocalizations { } @override - String get markUnreadError => - 'メッセージを未読にする際にエラーが発生しました。最新の100件のチャンネルメッセージより古い未読メッセージはマークできません。'; + String get markUnreadError => 'メッセージを未読にする際にエラーが発生しました。最新の100件のチャンネルメッセージより古い未読メッセージはマークできません。'; @override String createPollLabel({bool isNew = false}) { @@ -454,7 +463,7 @@ class StreamChatLocalizationsJa extends GlobalStreamChatLocalizations { } @override - String get questionsLabel => '問'; + String questionLabel({bool isPlural = false}) => '問'; @override String get askAQuestionLabel => '質問する'; @@ -537,7 +546,10 @@ class StreamChatLocalizationsJa extends GlobalStreamChatLocalizations { String get enterYourCommentLabel => 'コメントを入力'; @override - String get endVoteConfirmationText => '投票を終了してもよろしいですか?'; + String get endVoteConfirmationTitle => '投票を終了してもよろしいですか?'; + + @override + String get endVoteConfirmationMessage => 'この投票を今すぐ終了しますか?終了後は誰も投票できなくなります。'; @override String get deletePollOptionLabel => 'オプションを削除する'; @@ -579,18 +591,31 @@ class StreamChatLocalizationsJa extends GlobalStreamChatLocalizations { @override String get pollResultsLabel => '投票結果'; + @override + String get pollVotesLabel => '投票'; + @override String showAllVotesLabel({int? count}) { if (count == null) return 'すべての投票を表示'; return 'すべての $count 投票を表示'; } + @override + String get viewAllLabel => 'すべて表示'; + @override String voteCountLabel({int? count}) => switch (count) { - null || < 1 => '0 票', - 1 => '1 票', - _ => '$count 票', - }; + null || < 1 => '0 票', + 1 => '1 票', + _ => '$count 票', + }; + + @override + String totalVoteCountLabel({int? count}) => switch (count) { + null || < 1 => '合計 0 票', + 1 => '合計 1 票', + _ => '合計 $count 票', + }; @override String get noPollVotesLabel => '現在投票はありません'; @@ -606,6 +631,9 @@ class StreamChatLocalizationsJa extends GlobalStreamChatLocalizations { return '$count 件の新しいスレッド'; } + @override + String get loadingLabel => '読み込み中...'; + @override String get slideToCancelLabel => 'スライドでキャンセル'; @@ -622,8 +650,7 @@ class StreamChatLocalizationsJa extends GlobalStreamChatLocalizations { String get moderationReviewModalTitle => 'よろしいですか?'; @override - String get moderationReviewModalDescription => - '''あなたのコメントが他の人にどのような影響を与えるかを考え、コミュニティガイドラインに従ってください。'''; + String get moderationReviewModalDescription => '''あなたのコメントが他の人にどのような影響を与えるかを考え、コミュニティガイドラインに従ってください。'''; @override String get emptyMessagePreviewText => ''; @@ -640,6 +667,21 @@ class StreamChatLocalizationsJa extends GlobalStreamChatLocalizations { @override String get videoAttachmentText => '動画'; + @override + String get fileAttachmentText => 'ファイル'; + + @override + String get linkAttachmentText => 'リンク'; + + @override + String filesAttachmentCountText(int count) => count == 1 ? 'ファイル' : '$count件のファイル'; + + @override + String photosAttachmentCountText(int count) => count == 1 ? '写真' : '$count枚の写真'; + + @override + String videosAttachmentCountText(int count) => count == 1 ? '動画' : '$count本の動画'; + @override String get pollYouVotedText => '投票しました'; @@ -654,4 +696,97 @@ class StreamChatLocalizationsJa extends GlobalStreamChatLocalizations { @override String get draftLabel => '下書き'; + + @override + String locationLabel({bool isLive = false}) { + if (isLive) return 'ライブ位置情報'; + return '位置情報'; + } + + @override + String get noConversationsYetText => 'まだ会話がありません'; + + @override + String get replyToStartThreadText => 'スレッドを開始するにはメッセージに返信してください'; + + @override + String get sendMessageToStartConversationText => '会話を始めるにはメッセージを送信してください'; + + @override + String get savedForLaterLabel => '後で確認'; + + @override + String get repliedToThreadAnnotationLabel => 'スレッドに返信しました'; + + @override + String get alsoSentInChannelAnnotationLabel => 'チャンネルにも送信されました'; + + @override + String get viewLabel => '表示'; + + @override + String get reminderSetLabel => 'リマインダー設定済み'; + + @override + String reminderAtText(String time) => '今日 $time'; + + @override + String get createPollPromptLabel => '投票を作成してみんなに投票してもらおう!'; + + @override + String get takePhotoAndShareLabel => '写真を撮って共有'; + + @override + String get takeVideoAndShareLabel => '動画を撮って共有'; + + @override + String get openCameraLabel => 'カメラを開く'; + + @override + String get selectFilesToShareLabel => '共有するファイルを選択'; + + @override + String get openFilesLabel => 'ファイルを開く'; + + @override + String get unsupportedAttachmentLabel => 'サポートされていない添付ファイル'; + + @override + String get confirmLabel => '確認'; + + @override + String get emptyReactionsText => 'まだリアクションはありません'; + + @override + String get loadingReactionsError => 'リアクションの読み込み中にエラーが発生しました'; + + @override + String get tapToRemoveReactionLabel => 'タップして削除'; + + @override + String reactionsCountText(int count) => '$count件のリアクション'; + + @override + String get justNowLabel => 'たった今'; + + @override + String replyToUserLabel(String userName) => '$userNameに返信'; + + @override + String get multipleAnswersDescription => '複数の選択肢を選ぶ'; + + @override + String maximumVotesPerPersonDescription([Range? range]) { + final (:min, :max) = range ?? (min: 2, max: 10); + return '$min〜$max個の選択肢から選ぶ'; + } + + @override + String get anonymousPollDescription => '投票者を非表示'; + + @override + String get suggestAnOptionDescription => '他のユーザーに選択肢の追加を許可'; + + @override + String get addACommentDescription => '他のユーザーにコメントの追加を許可'; } diff --git a/packages/stream_chat_localizations/lib/src/stream_chat_localizations_ko.dart b/packages/stream_chat_localizations/lib/src/stream_chat_localizations_ko.dart index 9d4156d2d8..197ce29a64 100644 --- a/packages/stream_chat_localizations/lib/src/stream_chat_localizations_ko.dart +++ b/packages/stream_chat_localizations/lib/src/stream_chat_localizations_ko.dart @@ -47,10 +47,9 @@ class StreamChatLocalizationsKo extends GlobalStreamChatLocalizations { @override String attachmentsUploadProgressText({ - required int remaining, + required int completed, required int total, - }) => - '$remaining/${total}mb를 업로드중...'; + }) => '$total개 중 $completed개 업로드됨...'; @override String pinnedByUserText({ @@ -66,7 +65,7 @@ class StreamChatLocalizationsKo extends GlobalStreamChatLocalizations { String get sendMessagePermissionError => '메시지를 보낼 수 있는 권한이 없습니다'; @override - String get emptyMessagesText => '현재 메시지가 없습니다'; + String get emptyMessagesText => '아직 메시지가 없습니다'; @override String get genericErrorText => '뭔가 잘못됐습니다'; @@ -117,7 +116,7 @@ class StreamChatLocalizationsKo extends GlobalStreamChatLocalizations { String get searchGifLabel => 'GIF 검색'; @override - String get writeAMessageLabel => '메시지 쓰기'; + String get writeAMessageLabel => '메시지 보내기'; @override String get instantCommandsLabel => '인스턴트 커맨즈'; @@ -129,8 +128,7 @@ class StreamChatLocalizationsKo extends GlobalStreamChatLocalizations { '우리는 압축해 보았지만 충분하지 않았습니다.'; @override - String fileTooLargeError(double limitInMB) => - '파일이 너무 커서 업로드할 수 없습니다. 파일 크기 제한은 ${limitInMB}MB입니다.'; + String fileTooLargeError(double limitInMB) => '파일이 너무 커서 업로드할 수 없습니다. 파일 크기 제한은 ${limitInMB}MB입니다.'; @override String get couldNotReadBytesFromFileError => '파일에서 바이트를 읽을 수 없습니다.'; @@ -160,11 +158,10 @@ class StreamChatLocalizationsKo extends GlobalStreamChatLocalizations { String get somethingWentWrongError => '뭔가 잘못됐습느다'; @override - String get addMoreFilesLabel => '파일을 추가함'; + String get addMoreFilesLabel => '더 추가'; @override - String get enablePhotoAndVideoAccessMessage => '친구와 공유할 수 있도록 사진과' - '\n동영상에 액세스할 수 있도록 설정하십시오.'; + String get enablePhotoAndVideoAccessMessage => '친구와 공유할 수 있도록 사진과 동영상에 액세스할 수 있도록 설정하십시오.'; @override String get allowGalleryAccessMessage => '갤러리에 대한 액세스를 허용합니다'; @@ -229,6 +226,9 @@ class StreamChatLocalizationsKo extends GlobalStreamChatLocalizations { @override String get photosLabel => '사진'; + @override + String get photosAndVideosLabel => '사진 및 동영상'; + String _getDay(DateTime dateTime) { final now = DateTime.now(); final today = DateTime(now.year, now.month, now.day); @@ -282,7 +282,7 @@ class StreamChatLocalizationsKo extends GlobalStreamChatLocalizations { String get deleteConversationQuestion => '대화를 삭제하시겠습니까?'; @override - String get streamChatLabel => '스트림 채팅'; + String get streamChatLabel => '채팅'; @override String get searchingForNetworkText => '네트워크를 검색하는 중입니다.'; @@ -299,6 +299,16 @@ class StreamChatLocalizationsKo extends GlobalStreamChatLocalizations { @override String watchersCountText(int count) => '$count명이 온라인'; + @override + String membersCountWithOnlineText({ + required int memberCount, + required int onlineCount, + }) { + final members = membersCountText(memberCount); + if (onlineCount <= 0) return members; + return '$members, ${watchersCountText(onlineCount)}'; + } + @override String get viewInfoLabel => '정보를 보기'; @@ -350,8 +360,7 @@ class StreamChatLocalizationsKo extends GlobalStreamChatLocalizations { String galleryPaginationText({ required int currentPage, required int totalPages, - }) => - '${currentPage + 1} / $totalPages'; + }) => '${currentPage + 1} / $totalPages'; //3 / 11 @@ -364,13 +373,15 @@ class StreamChatLocalizationsKo extends GlobalStreamChatLocalizations { @override String get slowModeOnLabel => '슬로모드 켜짐'; + @override + String get commandUsernameLabel => '@username'; + @override @override String get viewLibrary => '라이브러리 보기'; @override - String attachmentLimitExceedError(int limit) => - '첨부 파일 제한 초과: $limit 이상의 첨부 파일을 추가할 수 없습니다'; + String attachmentLimitExceedError(int limit) => '첨부 파일 제한 초과: $limit 이상의 첨부 파일을 추가할 수 없습니다'; @override String get downloadLabel => '다운로드'; @@ -455,7 +466,7 @@ class StreamChatLocalizationsKo extends GlobalStreamChatLocalizations { } @override - String get questionsLabel => '질문'; + String questionLabel({bool isPlural = false}) => '질문'; @override String get askAQuestionLabel => '질문하기'; @@ -538,7 +549,10 @@ class StreamChatLocalizationsKo extends GlobalStreamChatLocalizations { String get enterYourCommentLabel => '댓글 입력'; @override - String get endVoteConfirmationText => '투표를 종료하시겠습니까?'; + String get endVoteConfirmationTitle => '투표를 종료하시겠습니까?'; + + @override + String get endVoteConfirmationMessage => '지금 이 투표를 종료하시겠습니까? 종료하면 더 이상 아무도 이 투표에 참여할 수 없습니다.'; @override String get deletePollOptionLabel => '옵션을 삭제합니다.'; @@ -580,18 +594,31 @@ class StreamChatLocalizationsKo extends GlobalStreamChatLocalizations { @override String get pollResultsLabel => '투표 결과'; + @override + String get pollVotesLabel => '투표'; + @override String showAllVotesLabel({int? count}) { if (count == null) return '모든 투표 보기'; return '모든 $count 투표 보기'; } + @override + String get viewAllLabel => '모두 보기'; + @override String voteCountLabel({int? count}) => switch (count) { - null || < 1 => '0 표', - 1 => '1 표', - _ => '$count 표', - }; + null || < 1 => '0 표', + 1 => '1 표', + _ => '$count 표', + }; + + @override + String totalVoteCountLabel({int? count}) => switch (count) { + null || < 1 => '총 0 표', + 1 => '총 1 표', + _ => '총 $count 표', + }; @override String get noPollVotesLabel => '현재 투표가 없습니다'; @@ -607,6 +634,9 @@ class StreamChatLocalizationsKo extends GlobalStreamChatLocalizations { return '$count개의 새 스레드'; } + @override + String get loadingLabel => '로딩 중...'; + @override String get slideToCancelLabel => '슬라이드하여 취소'; @@ -623,8 +653,7 @@ class StreamChatLocalizationsKo extends GlobalStreamChatLocalizations { String get moderationReviewModalTitle => '확실합니까?'; @override - String get moderationReviewModalDescription => - '''귀하의 댓글이 다른 사람들에게 어떤 영향을 미칠 수 있는지 고려하고 커뮤니티 가이드라인을 준수하세요.'''; + String get moderationReviewModalDescription => '''귀하의 댓글이 다른 사람들에게 어떤 영향을 미칠 수 있는지 고려하고 커뮤니티 가이드라인을 준수하세요.'''; @override String get emptyMessagePreviewText => ''; @@ -641,6 +670,21 @@ class StreamChatLocalizationsKo extends GlobalStreamChatLocalizations { @override String get videoAttachmentText => '비디오'; + @override + String get fileAttachmentText => '파일'; + + @override + String get linkAttachmentText => '링크'; + + @override + String filesAttachmentCountText(int count) => count == 1 ? '파일' : '파일 $count개'; + + @override + String photosAttachmentCountText(int count) => count == 1 ? '사진' : '사진 $count장'; + + @override + String videosAttachmentCountText(int count) => count == 1 ? '동영상' : '동영상 $count개'; + @override String get pollYouVotedText => '투표했습니다'; @@ -655,4 +699,97 @@ class StreamChatLocalizationsKo extends GlobalStreamChatLocalizations { @override String get draftLabel => '임시글'; + + @override + String locationLabel({bool isLive = false}) { + if (isLive) return '실시간 위치'; + return '위치'; + } + + @override + String get noConversationsYetText => '아직 대화가 없습니다'; + + @override + String get replyToStartThreadText => '스레드를 시작하려면 메시지에 답장하세요'; + + @override + String get sendMessageToStartConversationText => '대화를 시작하려면 메시지를 보내세요'; + + @override + String get savedForLaterLabel => '나중을 위해 저장됨'; + + @override + String get repliedToThreadAnnotationLabel => '스레드에 답장함'; + + @override + String get alsoSentInChannelAnnotationLabel => '채널에도 전송됨'; + + @override + String get viewLabel => '보기'; + + @override + String get reminderSetLabel => '리마인더 설정됨'; + + @override + String reminderAtText(String time) => '오늘 $time'; + + @override + String get createPollPromptLabel => '투표를 만들고 모두에게 투표하게 하세요!'; + + @override + String get takePhotoAndShareLabel => '사진을 찍고 공유'; + + @override + String get takeVideoAndShareLabel => '동영상을 찍고 공유'; + + @override + String get openCameraLabel => '카메라 열기'; + + @override + String get selectFilesToShareLabel => '공유할 파일 선택'; + + @override + String get openFilesLabel => '파일 열기'; + + @override + String get unsupportedAttachmentLabel => '지원되지 않는 첨부파일'; + + @override + String get confirmLabel => '확인'; + + @override + String get emptyReactionsText => '아직 반응이 없습니다'; + + @override + String get loadingReactionsError => '반응을 불러오는 중 오류가 발생했습니다'; + + @override + String get tapToRemoveReactionLabel => '탭하여 제거'; + + @override + String reactionsCountText(int count) => '반응 $count개'; + + @override + String get justNowLabel => '방금'; + + @override + String replyToUserLabel(String userName) => '$userName님에게 답장'; + + @override + String get multipleAnswersDescription => '여러 옵션 선택'; + + @override + String maximumVotesPerPersonDescription([Range? range]) { + final (:min, :max) = range ?? (min: 2, max: 10); + return '$min\u2013$max개의 옵션 중에서 선택'; + } + + @override + String get anonymousPollDescription => '투표자 숨기기'; + + @override + String get suggestAnOptionDescription => '다른 사람이 옵션을 추가하도록 허용'; + + @override + String get addACommentDescription => '다른 사람이 댓글을 추가하도록 허용'; } diff --git a/packages/stream_chat_localizations/lib/src/stream_chat_localizations_no.dart b/packages/stream_chat_localizations/lib/src/stream_chat_localizations_no.dart index 814867f8d8..87ccc9a320 100644 --- a/packages/stream_chat_localizations/lib/src/stream_chat_localizations_no.dart +++ b/packages/stream_chat_localizations/lib/src/stream_chat_localizations_no.dart @@ -1,3 +1,5 @@ +// ignore_for_file: lines_longer_than_80_chars + part of 'stream_chat_localizations.dart'; /// The translations for Norwegian (`no`). @@ -47,10 +49,9 @@ class StreamChatLocalizationsNo extends GlobalStreamChatLocalizations { @override String attachmentsUploadProgressText({ - required int remaining, + required int completed, required int total, - }) => - 'Laster opp $remaining/$total ...'; + }) => 'Lastet opp $completed av $total ...'; @override String pinnedByUserText({ @@ -63,11 +64,10 @@ class StreamChatLocalizationsNo extends GlobalStreamChatLocalizations { } @override - String get sendMessagePermissionError => - 'Du har ikke tillatelse til å sende meldinger'; + String get sendMessagePermissionError => 'Du har ikke tillatelse til å sende meldinger'; @override - String get emptyMessagesText => 'Det er ingen meldinger akkurat nå'; + String get emptyMessagesText => 'Ingen meldinger ennå'; @override String get genericErrorText => 'Noe gikk galt'; @@ -121,7 +121,7 @@ class StreamChatLocalizationsNo extends GlobalStreamChatLocalizations { String get searchGifLabel => 'Søk GIFs'; @override - String get writeAMessageLabel => 'Skriv en melding'; + String get writeAMessageLabel => 'Send en melding'; @override String get instantCommandsLabel => 'Direkte kommandoer'; @@ -133,8 +133,7 @@ class StreamChatLocalizationsNo extends GlobalStreamChatLocalizations { 'Vi prøvde å komprimere den, men det hjalp ikke.'; @override - String fileTooLargeError(double limitInMB) => - 'Filen er for stor til å laste opp. Filgrense er $limitInMB MB.'; + String fileTooLargeError(double limitInMB) => 'Filen er for stor til å laste opp. Filgrense er $limitInMB MB.'; @override String get addAFileLabel => 'Legg til en fil'; @@ -161,12 +160,11 @@ class StreamChatLocalizationsNo extends GlobalStreamChatLocalizations { String get somethingWentWrongError => 'Noe gikk galt'; @override - String get addMoreFilesLabel => 'Legg til flere filer'; + String get addMoreFilesLabel => 'Legg til flere'; @override String get enablePhotoAndVideoAccessMessage => - 'Vennligst gi tillatelse til dine bilder' - '\nog videoer så du kan dele de med dine venner.'; + 'Vennligst gi tillatelse til dine bilder og videoer så du kan dele de med dine venner.'; @override String get allowGalleryAccessMessage => 'Tillat tilgang til galleri'; @@ -176,35 +174,31 @@ class StreamChatLocalizationsNo extends GlobalStreamChatLocalizations { @override String get flagMessageQuestion => - 'Ønsker du å sende en kopi av denne meldingen til en' - '\nmoderator for videre undersøkelser'; + 'Ønsker du å sende en kopi av denne meldingen til en moderator for videre undersøkelser'; @override - String get flagLabel => 'RAPPORTER'; + String get flagLabel => 'Rapporter'; @override - String get cancelLabel => 'AVBRYT'; + String get cancelLabel => 'Avbryt'; @override String get flagMessageSuccessfulLabel => 'Melding rapportert'; @override - String get flagMessageSuccessfulText => - 'Meldingen har blitt rapportert til en moderator.'; + String get flagMessageSuccessfulText => 'Meldingen har blitt rapportert til en moderator.'; @override - String get deleteLabel => 'SLETT'; + String get deleteLabel => 'Slett'; @override String get deleteMessageLabel => 'Slett melding'; @override - String get deleteMessageQuestion => - 'Er du sikker på at du ønsker å slette denne meldingen permanent?'; + String get deleteMessageQuestion => 'Er du sikker på at du ønsker å slette denne meldingen permanent?'; @override - String get operationCouldNotBeCompletedText => - 'Denne handlingen kunne ikke bli gjennomført.'; + String get operationCouldNotBeCompletedText => 'Denne handlingen kunne ikke bli gjennomført.'; @override String get replyLabel => 'Svar'; @@ -236,6 +230,9 @@ class StreamChatLocalizationsNo extends GlobalStreamChatLocalizations { @override String get photosLabel => 'Foto'; + @override + String get photosAndVideosLabel => 'Foto og video'; + String _getDay(DateTime dateTime) { final now = DateTime.now(); final today = DateTime(now.year, now.month, now.day); @@ -274,8 +271,7 @@ class StreamChatLocalizationsNo extends GlobalStreamChatLocalizations { String get letsStartChattingLabel => 'La oss starte å chatte!'; @override - String get sendingFirstMessageLabel => - 'Hva med å sende din første melding til en venn?'; + String get sendingFirstMessageLabel => 'Hva med å sende din første melding til en venn?'; @override String get startAChatLabel => 'Start en chat'; @@ -287,11 +283,10 @@ class StreamChatLocalizationsNo extends GlobalStreamChatLocalizations { String get deleteConversationLabel => 'Slett samtale'; @override - String get deleteConversationQuestion => - 'Er du sikker på at du ønsker å slette denne samtalen?'; + String get deleteConversationQuestion => 'Er du sikker på at du ønsker å slette denne samtalen?'; @override - String get streamChatLabel => 'Stream Chat'; + String get streamChatLabel => 'Samtaler'; @override String get searchingForNetworkText => 'Søker etter nettverk'; @@ -314,6 +309,16 @@ class StreamChatLocalizationsNo extends GlobalStreamChatLocalizations { return '$count pålogget'; } + @override + String membersCountWithOnlineText({ + required int memberCount, + required int onlineCount, + }) { + final members = membersCountText(memberCount); + if (onlineCount <= 0) return members; + return '$members, ${watchersCountText(onlineCount)}'; + } + @override String get viewInfoLabel => 'Se info'; @@ -327,8 +332,7 @@ class StreamChatLocalizationsNo extends GlobalStreamChatLocalizations { String get leaveConversationLabel => 'Forlat samtale'; @override - String get leaveConversationQuestion => - 'Er du sikker på at du ønsker å forlate denne samtalen?'; + String get leaveConversationQuestion => 'Er du sikker på at du ønsker å forlate denne samtalen?'; @override String get showInChatLabel => 'Se i chat'; @@ -364,8 +368,7 @@ class StreamChatLocalizationsNo extends GlobalStreamChatLocalizations { String galleryPaginationText({ required int currentPage, required int totalPages, - }) => - '${currentPage + 1} of $totalPages'; + }) => '${currentPage + 1} of $totalPages'; @override String get fileText => 'Fil'; @@ -374,15 +377,16 @@ class StreamChatLocalizationsNo extends GlobalStreamChatLocalizations { String get replyToMessageLabel => 'Svar på melding'; @override - String attachmentLimitExceedError(int limit) => - 'Antall vedlegg oversteget, maks antall: $limit'; + String attachmentLimitExceedError(int limit) => 'Antall vedlegg oversteget, maks antall: $limit'; @override String get slowModeOnLabel => 'Sakte modus PÅ'; @override - String get linkDisabledDetails => - 'Sende lenker er ikke lov i denne samtalen.'; + String get commandUsernameLabel => '@username'; + + @override + String get linkDisabledDetails => 'Sende lenker er ikke lov i denne samtalen.'; @override String get linkDisabledError => 'Lenker er deaktivert'; @@ -394,8 +398,7 @@ class StreamChatLocalizationsNo extends GlobalStreamChatLocalizations { String unreadMessagesSeparatorText() => 'Nye meldinger.'; @override - String get couldNotReadBytesFromFileError => - 'Kunne ikke lese bytes fra filen.'; + String get couldNotReadBytesFromFileError => 'Kunne ikke lese bytes fra filen.'; @override String get downloadLabel => 'Nedlasting'; @@ -423,7 +426,6 @@ class StreamChatLocalizationsNo extends GlobalStreamChatLocalizations { @override String toggleMuteUnmuteUserQuestion({required bool isMuted}) { if (isMuted) { - // ignore: lines_longer_than_80_chars return 'Er du sikker på at du vil oppheve ignoreringen av denne brukeren?'; } return 'Er du sikker på at du vil ignorere denne brukeren?'; @@ -436,8 +438,7 @@ class StreamChatLocalizationsNo extends GlobalStreamChatLocalizations { } @override - String get enableFileAccessMessage => - 'Aktiver tilgang til filer slik' '\nat du kan dele dem med venner.'; + String get enableFileAccessMessage => 'Aktiver tilgang til filer slik at du kan dele dem med venner.'; @override String get allowFileAccessMessage => 'Gi tilgang til filer'; @@ -462,7 +463,7 @@ class StreamChatLocalizationsNo extends GlobalStreamChatLocalizations { } @override - String get questionsLabel => 'Spørsmål'; + String questionLabel({bool isPlural = false}) => 'Spørsmål'; @override String get askAQuestionLabel => 'Still et spørsmål'; @@ -503,8 +504,7 @@ class StreamChatLocalizationsNo extends GlobalStreamChatLocalizations { String get multipleAnswersLabel => 'Flere svar'; @override - String get maximumVotesPerPersonLabel => - 'Maksimalt antall stemmer per person'; + String get maximumVotesPerPersonLabel => 'Maksimalt antall stemmer per person'; @override String? maxVotesPerPersonValidationError(int votes, Range range) { @@ -546,15 +546,17 @@ class StreamChatLocalizationsNo extends GlobalStreamChatLocalizations { String get enterYourCommentLabel => 'Skriv inn kommentaren din'; @override - String get endVoteConfirmationText => - 'Er du sikker på at du vil avslutte avstemningen?'; + String get endVoteConfirmationTitle => 'Er du sikker på at du vil avslutte avstemningen?'; + + @override + String get endVoteConfirmationMessage => + 'Vil du avslutte denne avstemningen nå? Ingen vil kunne stemme i denne avstemningen lenger.'; @override String get deletePollOptionLabel => 'Slett alternativ'; @override - String get deletePollOptionQuestion => - 'Er du sikker på at du vil slette dette alternativet?'; + String get deletePollOptionQuestion => 'Er du sikker på at du vil slette dette alternativet?'; @override String get createLabel => 'Opprett'; @@ -590,18 +592,31 @@ class StreamChatLocalizationsNo extends GlobalStreamChatLocalizations { @override String get pollResultsLabel => 'Resultater for avstemningen'; + @override + String get pollVotesLabel => 'Stemmer'; + @override String showAllVotesLabel({int? count}) { if (count == null) return 'Vis alle stemmer'; return 'Vis alle $count stemmer'; } + @override + String get viewAllLabel => 'Vis alle'; + @override String voteCountLabel({int? count}) => switch (count) { - null || < 1 => '0 stemmer', - 1 => '1 stemme', - _ => '$count stemmer', - }; + null || < 1 => '0 stemmer', + 1 => '1 stemme', + _ => '$count stemmer', + }; + + @override + String totalVoteCountLabel({int? count}) => switch (count) { + null || < 1 => '0 stemmer totalt', + 1 => '1 stemme totalt', + _ => '$count stemmer totalt', + }; @override String get noPollVotesLabel => 'Det er ingen stemmer for øyeblikket'; @@ -618,6 +633,9 @@ class StreamChatLocalizationsNo extends GlobalStreamChatLocalizations { return '$count nye tråder'; } + @override + String get loadingLabel => 'Laster...'; + @override String get slideToCancelLabel => 'Gli for å avbryte'; @@ -628,8 +646,7 @@ class StreamChatLocalizationsNo extends GlobalStreamChatLocalizations { String get sendAnywayLabel => 'Send likevel'; @override - String get moderatedMessageBlockedText => - 'Meldingen ble blokkert av modereringsregler'; + String get moderatedMessageBlockedText => 'Meldingen ble blokkert av modereringsregler'; @override String get moderationReviewModalTitle => 'Er du sikker?'; @@ -653,6 +670,21 @@ class StreamChatLocalizationsNo extends GlobalStreamChatLocalizations { @override String get videoAttachmentText => 'Video'; + @override + String get fileAttachmentText => 'Fil'; + + @override + String get linkAttachmentText => 'Lenke'; + + @override + String filesAttachmentCountText(int count) => count == 1 ? 'Fil' : '$count filer'; + + @override + String photosAttachmentCountText(int count) => count == 1 ? 'Bilde' : '$count bilder'; + + @override + String videosAttachmentCountText(int count) => count == 1 ? 'Video' : '$count videoer'; + @override String get pollYouVotedText => 'Du stemte'; @@ -667,4 +699,97 @@ class StreamChatLocalizationsNo extends GlobalStreamChatLocalizations { @override String get draftLabel => 'Utkast'; + + @override + String locationLabel({bool isLive = false}) { + if (isLive) return 'Direkte posisjon'; + return 'Posisjon'; + } + + @override + String get noConversationsYetText => 'Ingen samtaler ennå'; + + @override + String get replyToStartThreadText => 'Svar på en melding for å starte en tråd'; + + @override + String get sendMessageToStartConversationText => 'Send en melding for å starte samtalen'; + + @override + String get savedForLaterLabel => 'Lagret til senere'; + + @override + String get repliedToThreadAnnotationLabel => 'Svarte i en tråd'; + + @override + String get alsoSentInChannelAnnotationLabel => 'Også sendt i kanalen'; + + @override + String get viewLabel => 'Vis'; + + @override + String get reminderSetLabel => 'Påminnelse satt'; + + @override + String reminderAtText(String time) => 'I dag kl. $time'; + + @override + String get createPollPromptLabel => 'Lag en avstemning og la alle stemme!'; + + @override + String get takePhotoAndShareLabel => 'Ta et bilde og del'; + + @override + String get takeVideoAndShareLabel => 'Ta en video og del'; + + @override + String get openCameraLabel => 'Åpne kamera'; + + @override + String get selectFilesToShareLabel => 'Velg filer å dele'; + + @override + String get openFilesLabel => 'Åpne filer'; + + @override + String get unsupportedAttachmentLabel => 'Vedlegg støttes ikke'; + + @override + String get confirmLabel => 'BEKREFT'; + + @override + String get emptyReactionsText => 'Ingen reaksjoner ennå'; + + @override + String get loadingReactionsError => 'Kunne ikke laste reaksjoner'; + + @override + String get tapToRemoveReactionLabel => 'Trykk for å fjerne'; + + @override + String reactionsCountText(int count) => '$count reaksjoner'; + + @override + String get justNowLabel => 'Akkurat nå'; + + @override + String replyToUserLabel(String userName) => 'Svar til $userName'; + + @override + String get multipleAnswersDescription => 'Velg mer enn ett alternativ'; + + @override + String maximumVotesPerPersonDescription([Range? range]) { + final (:min, :max) = range ?? (min: 2, max: 10); + return 'Velg mellom $min\u2013$max alternativer'; + } + + @override + String get anonymousPollDescription => 'Skjul hvem som stemte'; + + @override + String get suggestAnOptionDescription => 'La andre legge til alternativer'; + + @override + String get addACommentDescription => 'La andre legge til kommentarer'; } diff --git a/packages/stream_chat_localizations/lib/src/stream_chat_localizations_pt.dart b/packages/stream_chat_localizations/lib/src/stream_chat_localizations_pt.dart index ddc189736f..1f7f223504 100644 --- a/packages/stream_chat_localizations/lib/src/stream_chat_localizations_pt.dart +++ b/packages/stream_chat_localizations/lib/src/stream_chat_localizations_pt.dart @@ -1,3 +1,5 @@ +// ignore_for_file: lines_longer_than_80_chars + part of 'stream_chat_localizations.dart'; /// The translations for Portuguese (`pt`). @@ -47,10 +49,9 @@ class StreamChatLocalizationsPt extends GlobalStreamChatLocalizations { @override String attachmentsUploadProgressText({ - required int remaining, + required int completed, required int total, - }) => - 'Tranferência em andamento $remaining/$total ...'; + }) => 'Enviados $completed de $total ...'; @override String pinnedByUserText({ @@ -63,14 +64,13 @@ class StreamChatLocalizationsPt extends GlobalStreamChatLocalizations { } @override - String get emptyMessagesText => 'Não há mensagens'; + String get emptyMessagesText => 'Ainda não há mensagens'; @override String get genericErrorText => 'Ocorreu um problema'; @override - String get loadingMessagesError => - 'Ocorreu um problema ao carregar a mensagem'; + String get loadingMessagesError => 'Ocorreu um problema ao carregar a mensagem'; @override String resultCountText(int count) => '$count resultados'; @@ -109,8 +109,7 @@ class StreamChatLocalizationsPt extends GlobalStreamChatLocalizations { String get reconnectingLabel => 'Reconectando...'; @override - String get alsoSendAsDirectMessageLabel => - 'Enviar também como mensagem direta'; + String get alsoSendAsDirectMessageLabel => 'Enviar também como mensagem direta'; @override String get addACommentOrSendLabel => 'Adicionar um comnetário ou enviar'; @@ -119,7 +118,7 @@ class StreamChatLocalizationsPt extends GlobalStreamChatLocalizations { String get searchGifLabel => 'Pesquisar GIFs'; @override - String get writeAMessageLabel => 'Escrever uma mensagem'; + String get writeAMessageLabel => 'Enviar uma mensagem'; @override String get instantCommandsLabel => 'Comandos instantâneos'; @@ -136,8 +135,7 @@ class StreamChatLocalizationsPt extends GlobalStreamChatLocalizations { 'O tamanho máximo dos arquivos é de $limitInMB MB.'; @override - String get couldNotReadBytesFromFileError => - 'Não foi possível ler os bytes do arquivo.'; + String get couldNotReadBytesFromFileError => 'Não foi possível ler os bytes do arquivo.'; @override String get addAFileLabel => 'Adicionar um arquivo'; @@ -164,12 +162,11 @@ class StreamChatLocalizationsPt extends GlobalStreamChatLocalizations { String get somethingWentWrongError => 'Algo deu errado'; @override - String get addMoreFilesLabel => 'Adicionar mais arquivos'; + String get addMoreFilesLabel => 'Adicionar mais'; @override String get enablePhotoAndVideoAccessMessage => - 'Por favor, permita o acesso às suas fotos' - '\ne vídeos para que possa compartilhar com sua rede.'; + 'Por favor, permita o acesso às suas fotos e vídeos para que possa compartilhar com sua rede.'; @override String get allowGalleryAccessMessage => 'Permitir acesso à sua galeria'; @@ -178,35 +175,31 @@ class StreamChatLocalizationsPt extends GlobalStreamChatLocalizations { String get flagMessageLabel => 'Denunciar mensagem'; @override - String get flagMessageQuestion => 'Gostaria de enviar esta mensagem ao' - '\nmoderador para maior investigação?'; + String get flagMessageQuestion => 'Gostaria de enviar esta mensagem ao moderador para maior investigação?'; @override - String get flagLabel => 'DENUNCIAR'; + String get flagLabel => 'Denunciar'; @override - String get cancelLabel => 'CANCELAR'; + String get cancelLabel => 'Cancelar'; @override String get flagMessageSuccessfulLabel => 'Mensagem denunciada'; @override - String get flagMessageSuccessfulText => - 'Esta mensagem foi enviada a um moderador.'; + String get flagMessageSuccessfulText => 'Esta mensagem foi enviada a um moderador.'; @override - String get deleteLabel => 'APAGAR'; + String get deleteLabel => 'Apagar'; @override String get deleteMessageLabel => 'Apagar mensagem'; @override - String get deleteMessageQuestion => - 'Você tem certeza que deseja apagar essa\nmensagem permanentemente?'; + String get deleteMessageQuestion => 'Você tem certeza que deseja apagar essa mensagem permanentemente?'; @override - String get operationCouldNotBeCompletedText => - 'A operação não pode ser completada.'; + String get operationCouldNotBeCompletedText => 'A operação não pode ser completada.'; @override String get replyLabel => 'Resposta'; @@ -238,6 +231,9 @@ class StreamChatLocalizationsPt extends GlobalStreamChatLocalizations { @override String get photosLabel => 'Fotos'; + @override + String get photosAndVideosLabel => 'Fotos e vídeos'; + String _getDay(DateTime dateTime) { final now = DateTime.now(); final today = DateTime(now.year, now.month, now.day); @@ -276,8 +272,7 @@ class StreamChatLocalizationsPt extends GlobalStreamChatLocalizations { String get letsStartChattingLabel => 'Vamos começar a conversar!'; @override - String get sendingFirstMessageLabel => - 'Que tal enviar sua primeira mensagem a um amigo?'; + String get sendingFirstMessageLabel => 'Que tal enviar sua primeira mensagem a um amigo?'; @override String get startAChatLabel => 'Iniciar uma conversa'; @@ -289,11 +284,10 @@ class StreamChatLocalizationsPt extends GlobalStreamChatLocalizations { String get deleteConversationLabel => 'Apagar a conversa'; @override - String get deleteConversationQuestion => - 'Tem certeza que deseja apagar essa conversa?'; + String get deleteConversationQuestion => 'Tem certeza que deseja apagar essa conversa?'; @override - String get streamChatLabel => 'Stream Chat'; + String get streamChatLabel => 'Conversas'; @override String get searchingForNetworkText => 'Pesquisando rede'; @@ -316,6 +310,16 @@ class StreamChatLocalizationsPt extends GlobalStreamChatLocalizations { return '$count online'; } + @override + String membersCountWithOnlineText({ + required int memberCount, + required int onlineCount, + }) { + final members = membersCountText(memberCount); + if (onlineCount <= 0) return members; + return '$members, ${watchersCountText(onlineCount)}'; + } + @override String get viewInfoLabel => 'Ver informação'; @@ -329,8 +333,7 @@ class StreamChatLocalizationsPt extends GlobalStreamChatLocalizations { String get leaveConversationLabel => 'Sair da conversa'; @override - String get leaveConversationQuestion => - 'Tem certeza que deseja sair dessa conversa?'; + String get leaveConversationQuestion => 'Tem certeza que deseja sair dessa conversa?'; @override String get showInChatLabel => 'Mostrar no chat'; @@ -366,8 +369,7 @@ class StreamChatLocalizationsPt extends GlobalStreamChatLocalizations { String galleryPaginationText({ required int currentPage, required int totalPages, - }) => - '${currentPage + 1} de $totalPages'; + }) => '${currentPage + 1} de $totalPages'; @override String get fileText => 'Arquivo'; @@ -376,13 +378,17 @@ class StreamChatLocalizationsPt extends GlobalStreamChatLocalizations { String get replyToMessageLabel => 'Responder à mensagem'; @override - String attachmentLimitExceedError(int limit) => ''' + String attachmentLimitExceedError(int limit) => + ''' Não é possível adicionar mais de $limit arquivos de uma vez '''; @override String get slowModeOnLabel => 'Modo lento ativado'; + @override + String get commandUsernameLabel => '@username'; + @override String get downloadLabel => 'Download'; @@ -432,15 +438,13 @@ Não é possível adicionar mais de $limit arquivos de uma vez } @override - String get linkDisabledDetails => - 'O envio de links não é permitido nesta conversa.'; + String get linkDisabledDetails => 'O envio de links não é permitido nesta conversa.'; @override String get linkDisabledError => 'Os links estão desativados'; @override - String get sendMessagePermissionError => - 'Você não tem permissão para enviar mensagens'; + String get sendMessagePermissionError => 'Você não tem permissão para enviar mensagens'; @override String get viewLibrary => 'Ver biblioteca'; @@ -449,8 +453,7 @@ Não é possível adicionar mais de $limit arquivos de uma vez String unreadMessagesSeparatorText() => 'Novas mensagens'; @override - String get enableFileAccessMessage => - 'Ative o acesso aos arquivos' '\npara poder compartilhá-los com amigos.'; + String get enableFileAccessMessage => 'Ative o acesso aos arquivos para poder compartilhá-los com amigos.'; @override String get allowFileAccessMessage => 'Permitir acesso aos arquivos'; @@ -475,7 +478,10 @@ Não é possível adicionar mais de $limit arquivos de uma vez } @override - String get questionsLabel => 'Perguntas'; + String questionLabel({bool isPlural = false}) { + if (isPlural) return 'Perguntas'; + return 'Pergunta'; + } @override String get askAQuestionLabel => 'Fazer uma pergunta'; @@ -558,15 +564,17 @@ Não é possível adicionar mais de $limit arquivos de uma vez String get enterYourCommentLabel => 'Inserir seu comentário'; @override - String get endVoteConfirmationText => - 'Tem certeza de que deseja encerrar a votação?'; + String get endVoteConfirmationTitle => 'Tem certeza de que deseja encerrar a votação?'; + + @override + String get endVoteConfirmationMessage => + 'Deseja encerrar esta enquete agora? Ninguém mais poderá votar nesta enquete.'; @override String get deletePollOptionLabel => 'Excluir opção'; @override - String get deletePollOptionQuestion => - 'Tem certeza de que deseja excluir esta opção?'; + String get deletePollOptionQuestion => 'Tem certeza de que deseja excluir esta opção?'; @override String get createLabel => 'Criar'; @@ -602,18 +610,31 @@ Não é possível adicionar mais de $limit arquivos de uma vez @override String get pollResultsLabel => 'Resultados da votação'; + @override + String get pollVotesLabel => 'Votos'; + @override String showAllVotesLabel({int? count}) { if (count == null) return 'Mostrar todos os votos'; return 'Mostrar todos os $count votos'; } + @override + String get viewAllLabel => 'Ver tudo'; + @override String voteCountLabel({int? count}) => switch (count) { - null || < 1 => '0 votos', - 1 => '1 voto', - _ => '$count votos', - }; + null || < 1 => '0 votos', + 1 => '1 voto', + _ => '$count votos', + }; + + @override + String totalVoteCountLabel({int? count}) => switch (count) { + null || < 1 => '0 votos no total', + 1 => '1 voto no total', + _ => '$count votos no total', + }; @override String get noPollVotesLabel => 'Não há votos no momento'; @@ -630,19 +651,20 @@ Não é possível adicionar mais de $limit arquivos de uma vez return '$count novos tópicos'; } + @override + String get loadingLabel => 'Carregando...'; + @override String get slideToCancelLabel => 'Deslize para cancelar'; @override - String get holdToRecordLabel => - 'Mantenha pressionado para gravar, solte para enviar'; + String get holdToRecordLabel => 'Mantenha pressionado para gravar, solte para enviar'; @override String get sendAnywayLabel => 'Enviar mesmo assim'; @override - String get moderatedMessageBlockedText => - 'Mensagem bloqueada pelas políticas de moderação'; + String get moderatedMessageBlockedText => 'Mensagem bloqueada pelas políticas de moderação'; @override String get moderationReviewModalTitle => 'Tem certeza?'; @@ -666,6 +688,21 @@ Não é possível adicionar mais de $limit arquivos de uma vez @override String get videoAttachmentText => 'Vídeo'; + @override + String get fileAttachmentText => 'Arquivo'; + + @override + String get linkAttachmentText => 'Link'; + + @override + String filesAttachmentCountText(int count) => count == 1 ? 'Arquivo' : '$count arquivos'; + + @override + String photosAttachmentCountText(int count) => count == 1 ? 'Foto' : '$count fotos'; + + @override + String videosAttachmentCountText(int count) => count == 1 ? 'Vídeo' : '$count vídeos'; + @override String get pollYouVotedText => 'Você votou'; @@ -680,4 +717,97 @@ Não é possível adicionar mais de $limit arquivos de uma vez @override String get draftLabel => 'Rascunho'; + + @override + String locationLabel({bool isLive = false}) { + if (isLive) return 'Localização ao Vivo'; + return 'Localização'; + } + + @override + String get noConversationsYetText => 'Ainda não há conversas'; + + @override + String get replyToStartThreadText => 'Responda a uma mensagem para iniciar uma thread'; + + @override + String get sendMessageToStartConversationText => 'Envie uma mensagem para iniciar a conversa'; + + @override + String get savedForLaterLabel => 'Guardado para depois'; + + @override + String get repliedToThreadAnnotationLabel => 'Respondeu a uma thread'; + + @override + String get alsoSentInChannelAnnotationLabel => 'Também enviado no canal'; + + @override + String get viewLabel => 'Ver'; + + @override + String get reminderSetLabel => 'Lembrete definido'; + + @override + String reminderAtText(String time) => 'Hoje às $time'; + + @override + String get createPollPromptLabel => 'Crie uma enquete e deixe todos votarem!'; + + @override + String get takePhotoAndShareLabel => 'Tire uma foto e compartilhe'; + + @override + String get takeVideoAndShareLabel => 'Grave um vídeo e compartilhe'; + + @override + String get openCameraLabel => 'Abrir câmera'; + + @override + String get selectFilesToShareLabel => 'Selecione arquivos para compartilhar'; + + @override + String get openFilesLabel => 'Abrir arquivos'; + + @override + String get unsupportedAttachmentLabel => 'Anexo não suportado'; + + @override + String get confirmLabel => 'CONFIRMAR'; + + @override + String get emptyReactionsText => 'Ainda não há reações'; + + @override + String get loadingReactionsError => 'Erro ao carregar as reações'; + + @override + String get tapToRemoveReactionLabel => 'Toque para remover'; + + @override + String reactionsCountText(int count) => '$count reações'; + + @override + String get justNowLabel => 'Agora mesmo'; + + @override + String replyToUserLabel(String userName) => 'Responder a $userName'; + + @override + String get multipleAnswersDescription => 'Selecionar mais de uma opção'; + + @override + String maximumVotesPerPersonDescription([Range? range]) { + final (:min, :max) = range ?? (min: 2, max: 10); + return 'Escolha entre $min\u2013$max opções'; + } + + @override + String get anonymousPollDescription => 'Ocultar quem votou'; + + @override + String get suggestAnOptionDescription => 'Permitir que outros adicionem opções'; + + @override + String get addACommentDescription => 'Permitir que outros adicionem comentários'; } diff --git a/packages/stream_chat_localizations/lib/stream_chat_localizations.dart b/packages/stream_chat_localizations/lib/stream_chat_localizations.dart index 27c018806a..852e8e2f54 100644 --- a/packages/stream_chat_localizations/lib/stream_chat_localizations.dart +++ b/packages/stream_chat_localizations/lib/stream_chat_localizations.dart @@ -2,8 +2,5 @@ library stream_chat_localizations; export 'package:flutter_localizations/flutter_localizations.dart' - show - GlobalCupertinoLocalizations, - GlobalMaterialLocalizations, - GlobalWidgetsLocalizations; + show GlobalCupertinoLocalizations, GlobalMaterialLocalizations, GlobalWidgetsLocalizations; export 'src/stream_chat_localizations.dart' hide getStreamChatTranslation; diff --git a/packages/stream_chat_localizations/pubspec.yaml b/packages/stream_chat_localizations/pubspec.yaml index 8ea640d9b4..e95ffeb03f 100644 --- a/packages/stream_chat_localizations/pubspec.yaml +++ b/packages/stream_chat_localizations/pubspec.yaml @@ -1,6 +1,6 @@ name: stream_chat_localizations description: The Official localizations for Stream Chat Flutter, a service for building chat applications -version: 9.23.0 +version: 10.0.0-beta.13 homepage: https://github.com/GetStream/stream-chat-flutter repository: https://github.com/GetStream/stream-chat-flutter issue_tracker: https://github.com/GetStream/stream-chat-flutter/issues @@ -18,15 +18,15 @@ issue_tracker: https://github.com/GetStream/stream-chat-flutter/issues # 2. Add it to the melos.yaml file for future updates. environment: - sdk: ^3.6.2 - flutter: ">=3.27.4" + sdk: ^3.10.0 + flutter: ">=3.38.1" dependencies: flutter: sdk: flutter flutter_localizations: sdk: flutter - stream_chat_flutter: ^9.23.0 + stream_chat_flutter: ^10.0.0-beta.13 dev_dependencies: flutter_test: diff --git a/packages/stream_chat_localizations/test/basics_test.dart b/packages/stream_chat_localizations/test/basics_test.dart index a05877f43c..f973c08d45 100644 --- a/packages/stream_chat_localizations/test/basics_test.dart +++ b/packages/stream_chat_localizations/test/basics_test.dart @@ -6,28 +6,32 @@ import 'package:stream_chat_localizations/stream_chat_localizations.dart'; void main() { testWidgets('Nested Localizations', (WidgetTester tester) async { - await tester.pumpWidget(MaterialApp( - theme: ThemeData( - useMaterial3: false, + await tester.pumpWidget( + MaterialApp( + theme: ThemeData( + useMaterial3: false, + ), + // Creates the outer Localizations widget. + home: ListView( + children: [ + const LocalizationTracker(key: ValueKey('outer')), + Localizations( + locale: const Locale('hi'), + delegates: GlobalStreamChatLocalizations.delegates, + child: const LocalizationTracker(key: ValueKey('inner')), + ), + ], + ), ), - // Creates the outer Localizations widget. - home: ListView( - children: [ - const LocalizationTracker(key: ValueKey('outer')), - Localizations( - locale: const Locale('hi'), - delegates: GlobalStreamChatLocalizations.delegates, - child: const LocalizationTracker(key: ValueKey('inner')), - ), - ], - ), - )); + ); final LocalizationTrackerState outerTracker = tester.state( - find.byKey(const ValueKey('outer'), skipOffstage: false)); + find.byKey(const ValueKey('outer'), skipOffstage: false), + ); expect(outerTracker.captionFontSize, 12.0); final LocalizationTrackerState innerTracker = tester.state( - find.byKey(const ValueKey('inner'), skipOffstage: false)); + find.byKey(const ValueKey('inner'), skipOffstage: false), + ); expect(innerTracker.captionFontSize, 13.0); }); @@ -36,19 +40,21 @@ void main() { 'during didChangeDependencies', (WidgetTester tester) async { // PageView calls ScrollPosition.dispose() during didChangeDependencies. - await tester.pumpWidget(MaterialApp( - supportedLocales: const [ - Locale('en', 'US'), - Locale('hi', 'IN'), - ], - localizationsDelegates: const [ - DummyLocalizations.delegate, - GlobalStreamChatLocalizations.delegate, - GlobalMaterialLocalizations.delegate, - GlobalCupertinoLocalizations.delegate, - ], - home: PageView(), - )); + await tester.pumpWidget( + MaterialApp( + supportedLocales: const [ + Locale('en', 'US'), + Locale('hi', 'IN'), + ], + localizationsDelegates: const [ + DummyLocalizations.delegate, + GlobalStreamChatLocalizations.delegate, + GlobalMaterialLocalizations.delegate, + GlobalCupertinoLocalizations.delegate, + ], + home: PageView(), + ), + ); await tester.binding.setLocale('hi', 'IN'); await tester.pump(); @@ -58,14 +64,16 @@ void main() { testWidgets('Locale without countryCode', (WidgetTester tester) async { // Regression test for https://github.com/flutter/flutter/pull/16782 - await tester.pumpWidget(MaterialApp( - localizationsDelegates: GlobalStreamChatLocalizations.delegates, - supportedLocales: const [ - Locale('en', 'US'), - Locale('hi'), - ], - home: Container(), - )); + await tester.pumpWidget( + MaterialApp( + localizationsDelegates: GlobalStreamChatLocalizations.delegates, + supportedLocales: const [ + Locale('en', 'US'), + Locale('hi'), + ], + home: Container(), + ), + ); await tester.binding.setLocale('hi', ''); await tester.pump(); @@ -76,8 +84,7 @@ void main() { /// A localizations delegate that does not contain any useful data, and is only /// used to trigger didChangeDependencies upon locale change. -class _DummyLocalizationsDelegate - extends LocalizationsDelegate { +class _DummyLocalizationsDelegate extends LocalizationsDelegate { const _DummyLocalizationsDelegate(); @override diff --git a/packages/stream_chat_localizations/test/override_test.dart b/packages/stream_chat_localizations/test/override_test.dart index 6b775de167..7714fa5f5a 100644 --- a/packages/stream_chat_localizations/test/override_test.dart +++ b/packages/stream_chat_localizations/test/override_test.dart @@ -16,8 +16,7 @@ class FooStreamChatLocalizations extends StreamChatLocalizationsEn { final String launchUrlError; } -class FooStreamChatLocalizationsDelegate - extends LocalizationsDelegate { +class FooStreamChatLocalizationsDelegate extends LocalizationsDelegate { const FooStreamChatLocalizationsDelegate({ this.supportedLanguage = 'en', this.launchUrlError = 'foo', @@ -27,15 +26,12 @@ class FooStreamChatLocalizationsDelegate final String launchUrlError; @override - bool isSupported(Locale locale) => - supportedLanguage == 'allLanguages' || - locale.languageCode == supportedLanguage; + bool isSupported(Locale locale) => supportedLanguage == 'allLanguages' || locale.languageCode == supportedLanguage; @override - Future load(Locale locale) => - SynchronousFuture( - FooStreamChatLocalizations(locale, launchUrlError), - ); + Future load(Locale locale) => SynchronousFuture( + FooStreamChatLocalizations(locale, launchUrlError), + ); @override bool shouldReload(FooStreamChatLocalizationsDelegate old) => false; @@ -43,24 +39,22 @@ class FooStreamChatLocalizationsDelegate Widget buildFrame({ Locale? locale, - Iterable delegates = - GlobalStreamChatLocalizations.delegates, + Iterable delegates = GlobalStreamChatLocalizations.delegates, required WidgetBuilder buildContent, LocaleResolutionCallback? localeResolutionCallback, Iterable supportedLocales = const [ Locale('en', 'US'), Locale('hi', 'IN'), ], -}) => - MaterialApp( - color: const Color(0xFFFFFFFF), - locale: locale, - supportedLocales: supportedLocales, - localizationsDelegates: delegates, - localeResolutionCallback: localeResolutionCallback, - onGenerateRoute: (RouteSettings settings) => MaterialPageRoute( - builder: (BuildContext context) => buildContent(context)), - ); +}) => MaterialApp( + color: const Color(0xFFFFFFFF), + locale: locale, + supportedLocales: supportedLocales, + localizationsDelegates: delegates, + localeResolutionCallback: localeResolutionCallback, + onGenerateRoute: (RouteSettings settings) => + MaterialPageRoute(builder: (BuildContext context) => buildContent(context)), +); void main() { testWidgets( @@ -104,23 +98,23 @@ void main() { "Localizations.override widget tracks parent's locale", (WidgetTester tester) async { Widget buildLocaleFrame(Locale locale) => buildFrame( - locale: locale, - supportedLocales: [locale], - buildContent: (BuildContext context) => Localizations.override( - context: context, - child: Builder( - builder: (BuildContext context) { - // No StreamChatLocalizations are defined for the first - // Localizations ancestor, so we should get the values from - // the default one, i.e. the one created by WidgetsApp via - // the LocalizationsDelegate provided by MaterialApp. - return Text( - StreamChatLocalizations.of(context)!.launchUrlError, - ); - }, - ), - ), - ); + locale: locale, + supportedLocales: [locale], + buildContent: (BuildContext context) => Localizations.override( + context: context, + child: Builder( + builder: (BuildContext context) { + // No StreamChatLocalizations are defined for the first + // Localizations ancestor, so we should get the values from + // the default one, i.e. the one created by WidgetsApp via + // the LocalizationsDelegate provided by MaterialApp. + return Text( + StreamChatLocalizations.of(context)!.launchUrlError, + ); + }, + ), + ), + ); await tester.pumpWidget(buildLocaleFrame(const Locale('en', 'US'))); expect(find.text('Cannot launch the url'), findsOneWidget); @@ -130,28 +124,27 @@ void main() { }, ); - testWidgets('Localizations.override widget with hardwired locale', - (WidgetTester tester) async { + testWidgets('Localizations.override widget with hardwired locale', (WidgetTester tester) async { Widget buildLocaleFrame(Locale locale) => buildFrame( - locale: locale, - buildContent: (BuildContext context) { - return Localizations.override( - context: context, - locale: const Locale('en', 'US'), - child: Builder( - builder: (BuildContext context) { - // No StreamChatLocalizations are defined for the first - // Localizations ancestor, so we should get the values from - // the default one, i.e. the one created by WidgetsApp via - // the LocalizationsDelegate provided by MaterialApp. - return Text( - StreamChatLocalizations.of(context)!.launchUrlError, - ); - }, - ), - ); - }, + locale: locale, + buildContent: (BuildContext context) { + return Localizations.override( + context: context, + locale: const Locale('en', 'US'), + child: Builder( + builder: (BuildContext context) { + // No StreamChatLocalizations are defined for the first + // Localizations ancestor, so we should get the values from + // the default one, i.e. the one created by WidgetsApp via + // the LocalizationsDelegate provided by MaterialApp. + return Text( + StreamChatLocalizations.of(context)!.launchUrlError, + ); + }, + ), ); + }, + ); await tester.pumpWidget(buildLocaleFrame(const Locale('en', 'US'))); expect(find.text('Cannot launch the url'), findsOneWidget); @@ -165,30 +158,32 @@ void main() { (WidgetTester tester) async { final Key textKey = UniqueKey(); - await tester.pumpWidget(buildFrame( - delegates: [ - ...GlobalStreamChatLocalizations.delegates, - const FooStreamChatLocalizationsDelegate( - supportedLanguage: 'fr', - launchUrlError: "Impossible de lancer l'url", - ), - const FooStreamChatLocalizationsDelegate( - supportedLanguage: 'uz', - launchUrlError: 'test', + await tester.pumpWidget( + buildFrame( + delegates: [ + ...GlobalStreamChatLocalizations.delegates, + const FooStreamChatLocalizationsDelegate( + supportedLanguage: 'fr', + launchUrlError: "Impossible de lancer l'url", + ), + const FooStreamChatLocalizationsDelegate( + supportedLanguage: 'uz', + launchUrlError: 'test', + ), + ], + supportedLocales: const [ + Locale('en'), + Locale('hi'), + Locale('fr'), + Locale('de'), + Locale('uz'), + ], + buildContent: (BuildContext context) => Text( + StreamChatLocalizations.of(context)!.launchUrlError, + key: textKey, ), - ], - supportedLocales: const [ - Locale('en'), - Locale('hi'), - Locale('fr'), - Locale('de'), - Locale('uz'), - ], - buildContent: (BuildContext context) => Text( - StreamChatLocalizations.of(context)!.launchUrlError, - key: textKey, ), - )); + ); expect( tester.widget(find.byKey(textKey)).data, @@ -214,24 +209,25 @@ void main() { (WidgetTester tester) async { final Key textKey = UniqueKey(); - await tester.pumpWidget(buildFrame( - // Accept whatever locale we're given - localeResolutionCallback: - (Locale? locale, Iterable supportedLocales) => locale, - delegates: [ - const FooStreamChatLocalizationsDelegate( - supportedLanguage: 'allLanguages', - ), - ...GlobalStreamChatLocalizations.delegates, - ], - buildContent: (BuildContext context) { - // Should always be 'foo', no matter what the locale is - return Text( - StreamChatLocalizations.of(context)!.launchUrlError, - key: textKey, - ); - }, - )); + await tester.pumpWidget( + buildFrame( + // Accept whatever locale we're given + localeResolutionCallback: (Locale? locale, Iterable supportedLocales) => locale, + delegates: [ + const FooStreamChatLocalizationsDelegate( + supportedLanguage: 'allLanguages', + ), + ...GlobalStreamChatLocalizations.delegates, + ], + buildContent: (BuildContext context) { + // Should always be 'foo', no matter what the locale is + return Text( + StreamChatLocalizations.of(context)!.launchUrlError, + key: textKey, + ); + }, + ), + ); expect(tester.widget(find.byKey(textKey)).data, 'foo'); @@ -250,16 +246,18 @@ void main() { (WidgetTester tester) async { final Key textKey = UniqueKey(); - await tester.pumpWidget(buildFrame( - delegates: [ - const FooStreamChatLocalizationsDelegate(), - ], - // supportedLocales not specified, so all locales resolve to 'en' - buildContent: (BuildContext context) => Text( - StreamChatLocalizations.of(context)!.launchUrlError, - key: textKey, + await tester.pumpWidget( + buildFrame( + delegates: [ + const FooStreamChatLocalizationsDelegate(), + ], + // supportedLocales not specified, so all locales resolve to 'en' + buildContent: (BuildContext context) => Text( + StreamChatLocalizations.of(context)!.launchUrlError, + key: textKey, + ), ), - )); + ); // Unsupported locale '_' (the widget tester's default) resolves to 'en'. expect(tester.widget(find.byKey(textKey)).data, 'foo'); diff --git a/packages/stream_chat_localizations/test/translations_test.dart b/packages/stream_chat_localizations/test/translations_test.dart index 8c043e3787..3efef36345 100644 --- a/packages/stream_chat_localizations/test/translations_test.dart +++ b/packages/stream_chat_localizations/test/translations_test.dart @@ -7,10 +7,8 @@ void main() { for (final language in kStreamChatSupportedLanguages) { test('translations exist for $language', () async { final locale = Locale(language); - expect( - GlobalStreamChatLocalizations.delegate.isSupported(locale), isTrue); - final localizations = - await GlobalStreamChatLocalizations.delegate.load(locale); + expect(GlobalStreamChatLocalizations.delegate.isSupported(locale), isTrue); + final localizations = await GlobalStreamChatLocalizations.delegate.load(locale); expect(localizations.launchUrlError, isNotNull); expect(localizations.loadingUsersError, isNotNull); expect(localizations.noUsersLabel, isNotNull); @@ -37,7 +35,7 @@ void main() { expect(localizations.editedMessageLabel, isNotNull); expect(localizations.threadReplyCountText(3), isNotNull); expect( - localizations.attachmentsUploadProgressText(remaining: 3, total: 10), + localizations.attachmentsUploadProgressText(completed: 3, total: 10), isNotNull, ); expect( @@ -118,6 +116,7 @@ void main() { isNotNull, ); expect(localizations.photosLabel, isNotNull); + expect(localizations.photosAndVideosLabel, isNotNull); // today expect( localizations.sentAtText( @@ -164,6 +163,16 @@ void main() { expect(localizations.watchersCountText(1), isNotNull); // 3 members expect(localizations.watchersCountText(3), isNotNull); + // no online members + expect( + localizations.membersCountWithOnlineText(memberCount: 5, onlineCount: 0), + isNotNull, + ); + // with online members + expect( + localizations.membersCountWithOnlineText(memberCount: 5, onlineCount: 2), + isNotNull, + ); expect(localizations.viewInfoLabel, isNotNull); expect(localizations.leaveGroupLabel, isNotNull); expect(localizations.leaveLabel, isNotNull); @@ -194,18 +203,16 @@ void main() { expect(localizations.couldNotReadBytesFromFileError, isNotNull); expect(localizations.toggleMuteUnmuteAction(isMuted: false), isNotNull); expect(localizations.downloadLabel, isNotNull); - expect(localizations.toggleMuteUnmuteGroupQuestion(isMuted: true), - isNotNull); + expect(localizations.commandUsernameLabel, isNotNull); + expect(localizations.toggleMuteUnmuteGroupQuestion(isMuted: true), isNotNull); expect(localizations.toggleMuteUnmuteGroupText(isMuted: true), isNotNull); - expect( - localizations.toggleMuteUnmuteUserQuestion(isMuted: true), isNotNull); + expect(localizations.toggleMuteUnmuteUserQuestion(isMuted: true), isNotNull); expect(localizations.toggleMuteUnmuteUserText(isMuted: true), isNotNull); expect(localizations.viewLibrary, isNotNull); expect(localizations.unreadMessagesSeparatorText(), isNotNull); expect(localizations.enableFileAccessMessage, isNotNull); expect(localizations.allowFileAccessMessage, isNotNull); - expect( - localizations.unreadCountIndicatorLabel(unreadCount: 2), isNotNull); + expect(localizations.unreadCountIndicatorLabel(unreadCount: 2), isNotNull); expect(localizations.unreadMessagesSeparatorText(), isNotNull); expect(localizations.markUnreadError, isNotNull); expect(localizations.markAsUnreadLabel, isNotNull); @@ -213,7 +220,8 @@ void main() { expect(localizations.createPollLabel(), isNotNull); // Create a new poll expect(localizations.createPollLabel(isNew: true), isNotNull); - expect(localizations.questionsLabel, isNotNull); + expect(localizations.questionLabel(), isNotNull); + expect(localizations.questionLabel(isPlural: true), isNotNull); expect(localizations.askAQuestionLabel, isNotNull); // Question must be at least 5 characters long expect( @@ -253,7 +261,8 @@ void main() { expect(localizations.anonymousPollLabel, isNotNull); expect(localizations.suggestAnOptionLabel, isNotNull); expect(localizations.addACommentLabel, isNotNull); - expect(localizations.endVoteConfirmationText, isNotNull); + expect(localizations.endVoteConfirmationTitle, isNotNull); + expect(localizations.endVoteConfirmationMessage, isNotNull); expect(localizations.createLabel, isNotNull); expect(localizations.endLabel, isNotNull); expect(localizations.endVoteLabel, isNotNull); @@ -264,6 +273,7 @@ void main() { expect(localizations.pollCommentsLabel, isNotNull); expect(localizations.pollOptionsLabel, isNotNull); expect(localizations.pollResultsLabel, isNotNull); + expect(localizations.pollVotesLabel, isNotNull); // Voting mode expect( localizations.pollVotingModeLabel(const PollVotingMode.disabled()), @@ -287,14 +297,19 @@ void main() { expect(localizations.seeAllOptionsLabel(count: 3), isNotNull); expect(localizations.showAllVotesLabel(), isNotNull); expect(localizations.showAllVotesLabel(count: 3), isNotNull); + expect(localizations.viewAllLabel, isNotNull); expect(localizations.updateYourCommentLabel, isNotNull); expect(localizations.viewCommentsLabel, isNotNull); expect(localizations.viewResultsLabel, isNotNull); // Vote count expect(localizations.voteCountLabel(), isNotNull); expect(localizations.voteCountLabel(count: 3), isNotNull); + // Total vote count + expect(localizations.totalVoteCountLabel(), isNotNull); + expect(localizations.totalVoteCountLabel(count: 3), isNotNull); expect(localizations.repliedToLabel, isNotNull); expect(localizations.newThreadsLabel(count: 3), isNotNull); + expect(localizations.loadingLabel, isNotNull); expect(localizations.slideToCancelLabel, isNotNull); expect(localizations.holdToRecordLabel, isNotNull); expect(localizations.sendAnywayLabel, isNotNull); @@ -306,11 +321,54 @@ void main() { expect(localizations.audioAttachmentText, isNotNull); expect(localizations.imageAttachmentText, isNotNull); expect(localizations.videoAttachmentText, isNotNull); + expect(localizations.fileAttachmentText, isNotNull); + expect(localizations.linkAttachmentText, isNotNull); + expect(localizations.filesAttachmentCountText(3), isNotNull); + expect(localizations.photosAttachmentCountText(3), isNotNull); + expect(localizations.videosAttachmentCountText(3), isNotNull); expect(localizations.pollYouVotedText, isNotNull); expect(localizations.pollSomeoneVotedText('TestUser'), isNotNull); expect(localizations.pollYouCreatedText, isNotNull); expect(localizations.pollSomeoneCreatedText('TestUser'), isNotNull); expect(localizations.systemMessageLabel, isNotNull); + expect(localizations.draftLabel, isNotNull); + expect(localizations.locationLabel(), isNotNull); + expect(localizations.noConversationsYetText, isNotNull); + expect(localizations.replyToStartThreadText, isNotNull); + expect(localizations.sendMessageToStartConversationText, isNotNull); + expect(localizations.savedForLaterLabel, isNotNull); + expect(localizations.repliedToThreadAnnotationLabel, isNotNull); + expect(localizations.alsoSentInChannelAnnotationLabel, isNotNull); + expect(localizations.viewLabel, isNotNull); + expect(localizations.reminderSetLabel, isNotNull); + expect(localizations.reminderAtText('3:00 PM'), isNotNull); + expect(localizations.createPollPromptLabel, isNotNull); + expect(localizations.takePhotoAndShareLabel, isNotNull); + expect(localizations.takeVideoAndShareLabel, isNotNull); + expect(localizations.openCameraLabel, isNotNull); + expect(localizations.selectFilesToShareLabel, isNotNull); + expect(localizations.openFilesLabel, isNotNull); + expect(localizations.unsupportedAttachmentLabel, isNotNull); + expect(localizations.confirmLabel, isNotNull); + expect(localizations.emptyReactionsText, isNotNull); + expect(localizations.loadingReactionsError, isNotNull); + expect(localizations.tapToRemoveReactionLabel, isNotNull); + // singular vs. plural — both branches exercised + expect(localizations.reactionsCountText(1), isNotNull); + expect(localizations.reactionsCountText(5), isNotNull); + expect(localizations.justNowLabel, isNotNull); + expect(localizations.replyToUserLabel('TestUser'), isNotNull); + expect(localizations.multipleAnswersDescription, isNotNull); + // default range + expect(localizations.maximumVotesPerPersonDescription(), isNotNull); + // explicit range + expect( + localizations.maximumVotesPerPersonDescription(const (min: 1, max: 5)), + isNotNull, + ); + expect(localizations.anonymousPollDescription, isNotNull); + expect(localizations.suggestAnOptionDescription, isNotNull); + expect(localizations.addACommentDescription, isNotNull); }); } diff --git a/packages/stream_chat_persistence/CHANGELOG.md b/packages/stream_chat_persistence/CHANGELOG.md index dee30e6112..26e51dd504 100644 --- a/packages/stream_chat_persistence/CHANGELOG.md +++ b/packages/stream_chat_persistence/CHANGELOG.md @@ -1,17 +1,45 @@ +## Upcoming + +🐞 Fixed + +- Fixed channel list re-sorting on refresh or when returning from background. + +## 10.0.0-beta.13 + +🛑️ Breaking +- SDK Redesign Changes. For more details, please refer to the [migration guide](https://github.com/GetStream/stream-chat-flutter/blob/210ff93f955be3f85c62e860309bd9aa240a5446/migrations). + The SDK redesign introduces a fresher default UI, but also better APIs for customization of the components. + +## 10.0.0-beta.12 + +- Included the changes from version [`9.23.0`](https://pub.dev/packages/stream_chat_persistence/changelog). + ## 9.23.0 - Updated `stream_chat` dependency to [`9.23.0`](https://pub.dev/packages/stream_chat/changelog). +## 10.0.0-beta.11 + +- Included the changes from version [`9.22.0`](https://pub.dev/packages/stream_chat_persistence/changelog). + ## 9.22.0 ✅ Added - Added support for `ChannelModel.filterTags` field. +## 10.0.0-beta.10 + +- Included the changes from version [`9.21.0`](https://pub.dev/packages/stream_chat_persistence/changelog). + ## 9.21.0 - Updated `stream_chat` dependency to [`9.21.0`](https://pub.dev/packages/stream_chat/changelog). +## 10.0.0-beta.9 + +- Included the changes from version [`9.20.0`](https://pub.dev/packages/stream_chat_persistence/changelog). + ## 9.20.0 ✅ Added @@ -19,10 +47,28 @@ - Added support for `Read.lastDeliveredAt` and `Read.lastDeliveredMessageId` fields to track message delivery receipts. +## 10.0.0-beta.8 + +✅ Added + +- Added a new `StreamChatPersistenceClient.deleteMessagesFromUser()` method to delete + all messages from a specific user across all channels. +- Added a new `messageLimit` parameter to the `getChannelStates` method + to limit the number of messages fetched per channel. + +- Included the changes from version [`9.19.0`](https://pub.dev/packages/stream_chat_persistence/changelog). + ## 9.19.0 - Updated `stream_chat` dependency to [`9.19.0`](https://pub.dev/packages/stream_chat/changelog). +## 10.0.0-beta.7 + +- Added support for `Messages.deletedForMe`, `PinnedMessages.deletedForMe`, and + `Members.deletedMessages` fields. + +- Included the changes from version [`9.18.0`](https://pub.dev/packages/stream_chat_persistence/changelog). + ## 9.18.0 ✅ Added @@ -31,14 +77,29 @@ - Added support for `client.flush()` method to clear database. - Added support for `Channel.messageCount` field. +## 10.0.0-beta.6 + +- Included the changes from version [`9.17.0`](https://pub.dev/packages/stream_chat_persistence/changelog). + ## 9.17.0 - Updated `stream_chat` dependency to [`9.17.0`](https://pub.dev/packages/stream_chat/changelog). +## 10.0.0-beta.5 + +- Included the changes from version [`9.16.0`](https://pub.dev/packages/stream_chat_persistence/changelog). + ## 9.16.0 - Updated `stream_chat` dependency to [`9.16.0`](https://pub.dev/packages/stream_chat/changelog). +## 10.0.0-beta.4 + +- Added support for `Location` entity in the database. +- Added support for `emojiCode` and `updatedAt` fields in `Reaction` entity. + +- Included the changes from version [`9.15.0`](https://pub.dev/packages/stream_chat_persistence/changelog). + ## 9.15.0 🐞 Fixed @@ -50,14 +111,26 @@ - Added support for `User.avgResponseTime` field. +## 10.0.0-beta.3 + +- Included the changes from version [`9.14.0`](https://pub.dev/packages/stream_chat_persistence/changelog). + ## 9.14.0 - Updated `stream_chat` dependency to [`9.14.0`](https://pub.dev/packages/stream_chat/changelog). +## 10.0.0-beta.2 + +- Included the changes from version [`9.13.0`](https://pub.dev/packages/stream_chat_persistence/changelog). + ## 9.13.0 - Updated `stream_chat` dependency to [`9.13.0`](https://pub.dev/packages/stream_chat/changelog). +## 10.0.0-beta.1 + +- Updated `stream_chat` dependency to [`10.0.0-beta.1`](https://pub.dev/packages/stream_chat/changelog). + ## 9.12.0 - Updated `stream_chat` dependency to [`9.12.0`](https://pub.dev/packages/stream_chat/changelog). diff --git a/packages/stream_chat_persistence/example/lib/main.dart b/packages/stream_chat_persistence/example/lib/main.dart index 6e71a555da..f82143fbbf 100644 --- a/packages/stream_chat_persistence/example/lib/main.dart +++ b/packages/stream_chat_persistence/example/lib/main.dart @@ -22,8 +22,7 @@ Future main() async { await client.connectUser( User( id: 'cool-shadow-7', - image: - 'https://getstream.io/random_png/?id=cool-shadow-7&name=Cool+shadow', + image: 'https://getstream.io/random_png/?id=cool-shadow-7&name=Cool+shadow', ), 'eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ1c2VyX2lkIjoiY29vbC1zaGFkb3ctNyJ9.' 'gkOlCRb1qgy4joHPaxFwPOdXcGvSPvp6QY0S4mpRkVo', @@ -95,31 +94,32 @@ class HomeScreen extends StatelessWidget { body: SafeArea( child: StreamBuilder( stream: messages, - builder: ( - BuildContext context, - AsyncSnapshot snapshot, - ) { - if (snapshot.hasData && snapshot.data != null) { - final _messages = snapshot.data!.messages ?? []; - return MessageView( - messages: _messages.reversed.toList(), - channel: channel, - ); - } else if (snapshot.hasError) { - return const Center( - child: Text( - 'There was an error loading messages. Please see logs.', - ), - ); - } - return const Center( - child: SizedBox( - width: 100, - height: 100, - child: CircularProgressIndicator(), - ), - ); - }, + builder: + ( + BuildContext context, + AsyncSnapshot snapshot, + ) { + if (snapshot.hasData && snapshot.data != null) { + final _messages = snapshot.data!.messages ?? []; + return MessageView( + messages: _messages.reversed.toList(), + channel: channel, + ); + } else if (snapshot.hasError) { + return const Center( + child: Text( + 'There was an error loading messages. Please see logs.', + ), + ); + } + return const Center( + child: SizedBox( + width: 100, + height: 100, + child: CircularProgressIndicator(), + ), + ); + }, ), ), ); diff --git a/packages/stream_chat_persistence/example/linux/flutter/generated_plugins.cmake b/packages/stream_chat_persistence/example/linux/flutter/generated_plugins.cmake index 7ea2a80150..22f82029d5 100644 --- a/packages/stream_chat_persistence/example/linux/flutter/generated_plugins.cmake +++ b/packages/stream_chat_persistence/example/linux/flutter/generated_plugins.cmake @@ -7,6 +7,7 @@ list(APPEND FLUTTER_PLUGIN_LIST ) list(APPEND FLUTTER_FFI_PLUGIN_LIST + jni ) set(PLUGIN_BUNDLED_LIBRARIES) diff --git a/packages/stream_chat_persistence/example/pubspec.yaml b/packages/stream_chat_persistence/example/pubspec.yaml index adee937525..dc5b0577e5 100644 --- a/packages/stream_chat_persistence/example/pubspec.yaml +++ b/packages/stream_chat_persistence/example/pubspec.yaml @@ -16,15 +16,15 @@ version: 1.0.0+1 # 2. Add it to the melos.yaml file for future updates. environment: - sdk: ^3.6.2 - flutter: ">=3.27.4" + sdk: ^3.10.0 + flutter: ">=3.38.1" dependencies: cupertino_icons: ^1.0.3 flutter: sdk: flutter - stream_chat: ^9.23.0 - stream_chat_persistence: ^9.23.0 + stream_chat: ^10.0.0-beta.13 + stream_chat_persistence: ^10.0.0-beta.13 flutter: uses-material-design: true \ No newline at end of file diff --git a/packages/stream_chat_persistence/example/windows/flutter/generated_plugins.cmake b/packages/stream_chat_persistence/example/windows/flutter/generated_plugins.cmake index 8abff9572e..2703737743 100644 --- a/packages/stream_chat_persistence/example/windows/flutter/generated_plugins.cmake +++ b/packages/stream_chat_persistence/example/windows/flutter/generated_plugins.cmake @@ -7,6 +7,7 @@ list(APPEND FLUTTER_PLUGIN_LIST ) list(APPEND FLUTTER_FFI_PLUGIN_LIST + jni ) set(PLUGIN_BUNDLED_LIBRARIES) diff --git a/packages/stream_chat_persistence/lib/src/converter/voting_visibility_converter.dart b/packages/stream_chat_persistence/lib/src/converter/voting_visibility_converter.dart index 2d5786d558..047be71a46 100644 --- a/packages/stream_chat_persistence/lib/src/converter/voting_visibility_converter.dart +++ b/packages/stream_chat_persistence/lib/src/converter/voting_visibility_converter.dart @@ -2,8 +2,7 @@ import 'package:drift/drift.dart'; import 'package:stream_chat/stream_chat.dart'; /// A [TypeConverter] that serializes [VotingVisibility] to a [String] column. -class VotingVisibilityConverter - extends TypeConverter { +class VotingVisibilityConverter extends TypeConverter { /// Constant default constructor. const VotingVisibilityConverter(); diff --git a/packages/stream_chat_persistence/lib/src/dao/channel_dao.dart b/packages/stream_chat_persistence/lib/src/dao/channel_dao.dart index f3a3de415a..88754425c6 100644 --- a/packages/stream_chat_persistence/lib/src/dao/channel_dao.dart +++ b/packages/stream_chat_persistence/lib/src/dao/channel_dao.dart @@ -9,20 +9,21 @@ part 'channel_dao.g.dart'; /// The Data Access Object for operations in [Channels] table. @DriftAccessor(tables: [Channels, Users]) -class ChannelDao extends DatabaseAccessor - with _$ChannelDaoMixin { +class ChannelDao extends DatabaseAccessor with _$ChannelDaoMixin { /// Creates a new channel dao instance ChannelDao(super.db); /// Get channel by cid - Future getChannelByCid(String cid) async => - (select(channels)..where((c) => c.cid.equals(cid))).join([ + Future getChannelByCid(String cid) async => (select(channels)..where((c) => c.cid.equals(cid))) + .join([ leftOuterJoin(users, channels.createdById.equalsExp(users.id)), - ]).map((rows) { + ]) + .map((rows) { final channel = rows.readTable(channels); final createdBy = rows.readTableOrNull(users); return channel.toChannelModel(createdBy: createdBy?.toUser()); - }).getSingleOrNull(); + }) + .getSingleOrNull(); /// Delete all channels by matching cid in [cids] /// @@ -34,17 +35,18 @@ class ChannelDao extends DatabaseAccessor (delete(channels)..where((tbl) => tbl.cid.isIn(cids))).go(); /// Get the channel cids saved in the storage - Future> get cids => (select(channels) - ..orderBy([(c) => OrderingTerm.desc(c.lastMessageAt)]) - ..limit(250)) - .map((c) => c.cid) - .get(); + Future> get cids => + (select(channels) + ..orderBy([(c) => OrderingTerm.desc(c.lastMessageAt)]) + ..limit(250)) + .map((c) => c.cid) + .get(); /// Updates all the channels using the new [channelList] data Future updateChannels(List channelList) => batch( - (it) => it.insertAllOnConflictUpdate( - channels, - channelList.map((c) => c.toEntity()).toList(), - ), - ); + (it) => it.insertAllOnConflictUpdate( + channels, + channelList.map((c) => c.toEntity()).toList(), + ), + ); } diff --git a/packages/stream_chat_persistence/lib/src/dao/channel_query_dao.dart b/packages/stream_chat_persistence/lib/src/dao/channel_query_dao.dart index b0931ee3be..a8f0d922f8 100644 --- a/packages/stream_chat_persistence/lib/src/dao/channel_query_dao.dart +++ b/packages/stream_chat_persistence/lib/src/dao/channel_query_dao.dart @@ -12,8 +12,7 @@ part 'channel_query_dao.g.dart'; /// The Data Access Object for operations in [ChannelQueries] table. @DriftAccessor(tables: [ChannelQueries, Channels, Users]) -class ChannelQueryDao extends DatabaseAccessor - with _$ChannelQueryDaoMixin { +class ChannelQueryDao extends DatabaseAccessor with _$ChannelQueryDaoMixin { /// Creates a new channel query dao instance ChannelQueryDao(super.db); @@ -32,35 +31,29 @@ class ChannelQueryDao extends DatabaseAccessor Filter? filter, List cids, { bool clearQueryCache = false, - }) async => - transaction(() async { - final hash = _computeHash(filter); - if (clearQueryCache) { - await batch((it) { - it.deleteWhere( - channelQueries, - (c) => c.queryHash.equals(hash), - ); - }); - } - - await batch((it) { - it.insertAllOnConflictUpdate( - channelQueries, - cids - .map((cid) => - ChannelQueryEntity(queryHash: hash, channelCid: cid)) - .toList(), - ); - }); + }) async => transaction(() async { + final hash = _computeHash(filter); + if (clearQueryCache) { + await batch((it) { + it.deleteWhere( + channelQueries, + (c) => c.queryHash.equals(hash), + ); }); + } + + await batch((it) { + it.insertAllOnConflictUpdate( + channelQueries, + cids.map((cid) => ChannelQueryEntity(queryHash: hash, channelCid: cid)).toList(), + ); + }); + }); /// Future> getCachedChannelCids(Filter? filter) { final hash = _computeHash(filter); - return (select(channelQueries)..where((c) => c.queryHash.equals(hash))) - .map((c) => c.channelCid) - .get(); + return (select(channelQueries)..where((c) => c.queryHash.equals(hash))).map((c) => c.channelCid).get(); } /// Get list of channels by filter, sort and paginationParams @@ -68,13 +61,16 @@ class ChannelQueryDao extends DatabaseAccessor final cachedChannelCids = await getCachedChannelCids(filter); final query = select(channels)..where((c) => c.cid.isIn(cachedChannelCids)); - final cachedChannels = await query.join([ - leftOuterJoin(users, channels.createdById.equalsExp(users.id)), - ]).map((row) { - final createdByEntity = row.readTableOrNull(users); - final channelEntity = row.readTable(channels); - return channelEntity.toChannelModel(createdBy: createdByEntity?.toUser()); - }).get(); + final cachedChannels = await query + .join([ + leftOuterJoin(users, channels.createdById.equalsExp(users.id)), + ]) + .map((row) { + final createdByEntity = row.readTableOrNull(users); + final channelEntity = row.readTable(channels); + return channelEntity.toChannelModel(createdBy: createdByEntity?.toUser()); + }) + .get(); return cachedChannels; } diff --git a/packages/stream_chat_persistence/lib/src/dao/connection_event_dao.dart b/packages/stream_chat_persistence/lib/src/dao/connection_event_dao.dart index fa7ff9829d..976d5931f0 100644 --- a/packages/stream_chat_persistence/lib/src/dao/connection_event_dao.dart +++ b/packages/stream_chat_persistence/lib/src/dao/connection_event_dao.dart @@ -9,37 +9,32 @@ part 'connection_event_dao.g.dart'; /// The Data Access Object for operations in [ConnectionEvents] table. @DriftAccessor(tables: [ConnectionEvents]) -class ConnectionEventDao extends DatabaseAccessor - with _$ConnectionEventDaoMixin { +class ConnectionEventDao extends DatabaseAccessor with _$ConnectionEventDaoMixin { /// Creates a new connection event dao instance ConnectionEventDao(super.db); /// Get the latest stored connection event - Future get connectionEvent => select(connectionEvents) - .map((eventEntity) => eventEntity.toEvent()) - .getSingleOrNull(); + Future get connectionEvent => + select(connectionEvents).map((eventEntity) => eventEntity.toEvent()).getSingleOrNull(); /// Get the latest stored lastSyncAt - Future get lastSyncAt => - select(connectionEvents).getSingleOrNull().then((r) => r?.lastSyncAt); + Future get lastSyncAt => select(connectionEvents).getSingleOrNull().then((r) => r?.lastSyncAt); /// Update stored connection event with latest data Future updateConnectionEvent(Event event) => transaction(() async { - final connectionInfo = await select(connectionEvents).getSingleOrNull(); - return into(connectionEvents).insertOnConflictUpdate( - ConnectionEventEntity( - id: 1, - type: event.type, - lastSyncAt: connectionInfo?.lastSyncAt, - lastEventAt: event.createdAt, - totalUnreadCount: - event.totalUnreadCount ?? connectionInfo?.totalUnreadCount, - ownUser: event.me?.toJson() ?? connectionInfo?.ownUser, - unreadChannels: - event.unreadChannels ?? connectionInfo?.unreadChannels, - ), - ); - }); + final connectionInfo = await select(connectionEvents).getSingleOrNull(); + return into(connectionEvents).insertOnConflictUpdate( + ConnectionEventEntity( + id: 1, + type: event.type, + lastSyncAt: connectionInfo?.lastSyncAt, + lastEventAt: event.createdAt, + totalUnreadCount: event.totalUnreadCount ?? connectionInfo?.totalUnreadCount, + ownUser: event.me?.toJson() ?? connectionInfo?.ownUser, + unreadChannels: event.unreadChannels ?? connectionInfo?.unreadChannels, + ), + ); + }); /// Update stored lastSyncAt with latest data Future updateLastSyncAt(DateTime lastSyncAt) async => diff --git a/packages/stream_chat_persistence/lib/src/dao/connection_event_dao.g.dart b/packages/stream_chat_persistence/lib/src/dao/connection_event_dao.g.dart index 32541de5d5..10a23aa21a 100644 --- a/packages/stream_chat_persistence/lib/src/dao/connection_event_dao.g.dart +++ b/packages/stream_chat_persistence/lib/src/dao/connection_event_dao.g.dart @@ -4,6 +4,5 @@ part of 'connection_event_dao.dart'; // ignore_for_file: type=lint mixin _$ConnectionEventDaoMixin on DatabaseAccessor { - $ConnectionEventsTable get connectionEvents => - attachedDatabase.connectionEvents; + $ConnectionEventsTable get connectionEvents => attachedDatabase.connectionEvents; } diff --git a/packages/stream_chat_persistence/lib/src/dao/dao.dart b/packages/stream_chat_persistence/lib/src/dao/dao.dart index d0aab7b212..d2f088c399 100644 --- a/packages/stream_chat_persistence/lib/src/dao/dao.dart +++ b/packages/stream_chat_persistence/lib/src/dao/dao.dart @@ -2,6 +2,7 @@ export 'channel_dao.dart'; export 'channel_query_dao.dart'; export 'connection_event_dao.dart'; export 'draft_message_dao.dart'; +export 'location_dao.dart'; export 'member_dao.dart'; export 'message_dao.dart'; export 'pinned_message_dao.dart'; diff --git a/packages/stream_chat_persistence/lib/src/dao/draft_message_dao.dart b/packages/stream_chat_persistence/lib/src/dao/draft_message_dao.dart index b5a5cb6fc9..a0602a0679 100644 --- a/packages/stream_chat_persistence/lib/src/dao/draft_message_dao.dart +++ b/packages/stream_chat_persistence/lib/src/dao/draft_message_dao.dart @@ -10,26 +10,34 @@ part 'draft_message_dao.g.dart'; /// The Data Access Object for operations in [DraftMessages] table. @DriftAccessor(tables: [DraftMessages, Messages]) -class DraftMessageDao extends DatabaseAccessor - with _$DraftMessageDaoMixin { +class DraftMessageDao extends DatabaseAccessor with _$DraftMessageDaoMixin { /// Creates a new draft message dao instance DraftMessageDao(this._db) : super(_db); final DriftChatDatabase _db; Future _draftFromEntity(DraftMessageEntity entity) async { - // We do not want to fetch the draft message of the parent and quoted - // message because it will create a circular dependency and will + // We do not want to fetch the draft and shared location of the parent and + // quoted message because it will create a circular dependency and will // result in infinite loop. const fetchDraft = false; + const fetchSharedLocation = false; final parentMessage = await switch (entity.parentId) { - final id? => _db.messageDao.getMessageById(id, fetchDraft: fetchDraft), + final id? => _db.messageDao.getMessageById( + id, + fetchDraft: fetchDraft, + fetchSharedLocation: fetchSharedLocation, + ), _ => null, }; final quotedMessage = await switch (entity.quotedMessageId) { - final id? => _db.messageDao.getMessageById(id, fetchDraft: fetchDraft), + final id? => _db.messageDao.getMessageById( + id, + fetchDraft: fetchDraft, + fetchSharedLocation: fetchSharedLocation, + ), _ => null, }; diff --git a/packages/stream_chat_persistence/lib/src/dao/location_dao.dart b/packages/stream_chat_persistence/lib/src/dao/location_dao.dart new file mode 100644 index 0000000000..f6d6bb6cfa --- /dev/null +++ b/packages/stream_chat_persistence/lib/src/dao/location_dao.dart @@ -0,0 +1,82 @@ +// ignore_for_file: join_return_with_assignment + +import 'package:drift/drift.dart'; +import 'package:stream_chat/stream_chat.dart'; +import 'package:stream_chat_persistence/src/db/drift_chat_database.dart'; +import 'package:stream_chat_persistence/src/entity/locations.dart'; +import 'package:stream_chat_persistence/src/mapper/mapper.dart'; + +part 'location_dao.g.dart'; + +/// The Data Access Object for operations in [Locations] table. +@DriftAccessor(tables: [Locations]) +class LocationDao extends DatabaseAccessor with _$LocationDaoMixin { + /// Creates a new location dao instance + LocationDao(this._db) : super(_db); + + final DriftChatDatabase _db; + + Future _locationFromEntity(LocationEntity entity) async { + // We do not want to fetch the location of the parent and quoted + // message because it will create a circular dependency and will + // result in infinite loop. + const fetchDraft = false; + const fetchSharedLocation = false; + + final channel = await switch (entity.channelCid) { + final cid? => db.channelDao.getChannelByCid(cid), + _ => null, + }; + + final message = await switch (entity.messageId) { + final id? => _db.messageDao.getMessageById( + id, + fetchDraft: fetchDraft, + fetchSharedLocation: fetchSharedLocation, + ), + _ => null, + }; + + return entity.toLocation( + channel: channel, + message: message, + ); + } + + /// Get all locations for a channel + Future> getLocationsByCid(String cid) async { + final query = select(locations)..where((tbl) => tbl.channelCid.equals(cid)); + + final result = await query.map(_locationFromEntity).get(); + return Future.wait(result); + } + + /// Get location by message ID + Future getLocationByMessageId(String messageId) async { + final query = + select(locations) // + ..where((tbl) => tbl.messageId.equals(messageId)); + + final result = await query.getSingleOrNull(); + if (result == null) return null; + + return _locationFromEntity(result); + } + + /// Update multiple locations + Future updateLocations(List locationList) { + return batch( + (it) => it.insertAllOnConflictUpdate( + locations, + locationList.map((it) => it.toEntity()), + ), + ); + } + + /// Delete locations by channel ID + Future deleteLocationsByCid(String cid) => (delete(locations)..where((tbl) => tbl.channelCid.equals(cid))).go(); + + /// Delete locations by message IDs + Future deleteLocationsByMessageIds(List messageIds) => + (delete(locations)..where((tbl) => tbl.messageId.isIn(messageIds))).go(); +} diff --git a/packages/stream_chat_persistence/lib/src/dao/location_dao.g.dart b/packages/stream_chat_persistence/lib/src/dao/location_dao.g.dart new file mode 100644 index 0000000000..240b3b4b83 --- /dev/null +++ b/packages/stream_chat_persistence/lib/src/dao/location_dao.g.dart @@ -0,0 +1,10 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'location_dao.dart'; + +// ignore_for_file: type=lint +mixin _$LocationDaoMixin on DatabaseAccessor { + $ChannelsTable get channels => attachedDatabase.channels; + $MessagesTable get messages => attachedDatabase.messages; + $LocationsTable get locations => attachedDatabase.locations; +} diff --git a/packages/stream_chat_persistence/lib/src/dao/member_dao.dart b/packages/stream_chat_persistence/lib/src/dao/member_dao.dart index fb345d3bff..7d61ecb4cc 100644 --- a/packages/stream_chat_persistence/lib/src/dao/member_dao.dart +++ b/packages/stream_chat_persistence/lib/src/dao/member_dao.dart @@ -9,38 +9,39 @@ part 'member_dao.g.dart'; /// The Data Access Object for operations in [Members] table. @DriftAccessor(tables: [Members, Users]) -class MemberDao extends DatabaseAccessor - with _$MemberDaoMixin { +class MemberDao extends DatabaseAccessor with _$MemberDaoMixin { /// Creates a new member dao instance MemberDao(super.db); /// Get all members where [Members.channelCid] matches [cid] Future> getMembersByCid(String cid) async => (select(members).join([ - leftOuterJoin(users, members.userId.equalsExp(users.id)), - ]) + leftOuterJoin(users, members.userId.equalsExp(users.id)), + ]) ..where(members.channelCid.equals(cid)) ..orderBy([OrderingTerm.asc(members.createdAt)])) .map((row) { - final userEntity = row.readTable(users); - final memberEntity = row.readTable(members); - return memberEntity.toMember(user: userEntity.toUser()); - }).get(); + final userEntity = row.readTable(users); + final memberEntity = row.readTable(members); + return memberEntity.toMember(user: userEntity.toUser()); + }) + .get(); /// Updates all the members using the new [memberList] data - Future updateMembers(String cid, List memberList) => - bulkUpdateMembers({cid: memberList}); + Future updateMembers(String cid, List memberList) => bulkUpdateMembers({cid: memberList}); /// Bulk updates the members data of multiple channels Future bulkUpdateMembers( Map?> channelWithMembers, ) { final entities = channelWithMembers.entries - .map((entry) => - entry.value?.map( - (member) => member.toEntity(cid: entry.key), - ) ?? - []) + .map( + (entry) => + entry.value?.map( + (member) => member.toEntity(cid: entry.key), + ) ?? + [], + ) .expand((it) => it) .toList(growable: false); return batch( @@ -50,9 +51,9 @@ class MemberDao extends DatabaseAccessor /// Deletes all the members whose [Members.channelCid] is present in [cids] Future deleteMemberByCids(List cids) async => batch((it) { - it.deleteWhere( - members, - (m) => m.channelCid.isIn(cids), - ); - }); + it.deleteWhere( + members, + (m) => m.channelCid.isIn(cids), + ); + }); } diff --git a/packages/stream_chat_persistence/lib/src/dao/message_dao.dart b/packages/stream_chat_persistence/lib/src/dao/message_dao.dart index 77d84dff07..86b8f7275c 100644 --- a/packages/stream_chat_persistence/lib/src/dao/message_dao.dart +++ b/packages/stream_chat_persistence/lib/src/dao/message_dao.dart @@ -1,3 +1,4 @@ +import 'dart:convert'; import 'dart:math'; import 'package:drift/drift.dart'; @@ -11,8 +12,7 @@ part 'message_dao.g.dart'; /// The Data Access Object for operations in [Messages] table. @DriftAccessor(tables: [Messages, Users]) -class MessageDao extends DatabaseAccessor - with _$MessageDaoMixin { +class MessageDao extends DatabaseAccessor with _$MessageDaoMixin { /// Creates a new message dao instance MessageDao(this._db) : super(_db); @@ -39,6 +39,7 @@ class MessageDao extends DatabaseAccessor Future _messageFromJoinRow( TypedResult rows, { bool fetchDraft = false, + bool fetchSharedLocation = false, }) async { final userEntity = rows.readTableOrNull(_users); final pinnedByEntity = rows.readTableOrNull(_pinnedByUsers); @@ -61,9 +62,14 @@ class MessageDao extends DatabaseAccessor final draft = await switch (fetchDraft) { true => _db.draftMessageDao.getDraftMessageByCid( - msgEntity.channelCid, - parentId: msgEntity.id, - ), + msgEntity.channelCid, + parentId: msgEntity.id, + ), + _ => null, + }; + + final sharedLocation = await switch (fetchSharedLocation) { + true => _db.locationDao.getLocationByMessageId(msgEntity.id), _ => null, }; @@ -75,6 +81,7 @@ class MessageDao extends DatabaseAccessor quotedMessage: quotedMessage, poll: poll, draft: draft, + sharedLocation: sharedLocation, ); } @@ -85,6 +92,7 @@ class MessageDao extends DatabaseAccessor Future getMessageById( String id, { bool fetchDraft = true, + bool fetchSharedLocation = true, }) async { final query = select(messages).join([ leftOuterJoin(_users, messages.userId.equalsExp(_users.id)), @@ -92,8 +100,7 @@ class MessageDao extends DatabaseAccessor _pinnedByUsers, messages.pinnedByUserId.equalsExp(_pinnedByUsers.id), ), - ]) - ..where(messages.id.equals(id)); + ])..where(messages.id.equals(id)); final result = await query.getSingleOrNull(); if (result == null) return null; @@ -101,24 +108,26 @@ class MessageDao extends DatabaseAccessor return _messageFromJoinRow( result, fetchDraft: fetchDraft, + fetchSharedLocation: fetchSharedLocation, ); } /// Returns all the messages of a particular thread by matching /// [Messages.channelCid] with [cid] - Future> getThreadMessages(String cid) async => - Future.wait(await (select(messages).join([ - leftOuterJoin(_users, messages.userId.equalsExp(_users.id)), - leftOuterJoin( - _pinnedByUsers, - messages.pinnedByUserId.equalsExp(_pinnedByUsers.id), - ), - ]) - ..where(messages.channelCid.equals(cid)) - ..where(messages.parentId.isNotNull()) - ..orderBy([OrderingTerm.asc(messages.createdAt)])) - .map(_messageFromJoinRow) - .get()); + Future> getThreadMessages(String cid) async => Future.wait( + await (select(messages).join([ + leftOuterJoin(_users, messages.userId.equalsExp(_users.id)), + leftOuterJoin( + _pinnedByUsers, + messages.pinnedByUserId.equalsExp(_pinnedByUsers.id), + ), + ]) + ..where(messages.channelCid.equals(cid)) + ..where(messages.parentId.isNotNull()) + ..orderBy([OrderingTerm.asc(messages.createdAt)])) + .map(_messageFromJoinRow) + .get(), + ); /// Returns all the messages of a particular thread by matching /// [Messages.parentId] with [parentId] @@ -126,18 +135,20 @@ class MessageDao extends DatabaseAccessor String parentId, { PaginationParams? options, }) async { - final msgList = await Future.wait(await (select(messages).join([ - leftOuterJoin(_users, messages.userId.equalsExp(_users.id)), - leftOuterJoin( - _pinnedByUsers, - messages.pinnedByUserId.equalsExp(_pinnedByUsers.id), - ), - ]) - ..where(messages.parentId.isNotNull()) - ..where(messages.parentId.equals(parentId)) - ..orderBy([OrderingTerm.asc(messages.createdAt)])) - .map(_messageFromJoinRow) - .get()); + final msgList = await Future.wait( + await (select(messages).join([ + leftOuterJoin(_users, messages.userId.equalsExp(_users.id)), + leftOuterJoin( + _pinnedByUsers, + messages.pinnedByUserId.equalsExp(_pinnedByUsers.id), + ), + ]) + ..where(messages.parentId.isNotNull()) + ..where(messages.parentId.equals(parentId)) + ..orderBy([OrderingTerm.asc(messages.createdAt)])) + .map(_messageFromJoinRow) + .get(), + ); if (msgList.isNotEmpty) { if (options?.lessThan != null) { @@ -169,18 +180,20 @@ class MessageDao extends DatabaseAccessor Future> getMessagesByCid( String cid, { bool fetchDraft = true, + bool fetchSharedLocation = true, PaginationParams? messagePagination, }) async { - final query = select(messages).join([ - leftOuterJoin(_users, messages.userId.equalsExp(_users.id)), - leftOuterJoin( - _pinnedByUsers, - messages.pinnedByUserId.equalsExp(_pinnedByUsers.id), - ), - ]) - ..where(messages.channelCid.equals(cid)) - ..where(messages.parentId.isNull() | messages.showInChannel.equals(true)) - ..orderBy([OrderingTerm.asc(messages.createdAt)]); + final query = + select(messages).join([ + leftOuterJoin(_users, messages.userId.equalsExp(_users.id)), + leftOuterJoin( + _pinnedByUsers, + messages.pinnedByUserId.equalsExp(_pinnedByUsers.id), + ), + ]) + ..where(messages.channelCid.equals(cid)) + ..where(messages.parentId.isNull() | messages.showInChannel.equals(true)) + ..orderBy([OrderingTerm.asc(messages.createdAt)]); final result = await query.get(); if (result.isEmpty) return []; @@ -190,6 +203,7 @@ class MessageDao extends DatabaseAccessor (row) => _messageFromJoinRow( row, fetchDraft: fetchDraft, + fetchSharedLocation: fetchSharedLocation, ), ), ); @@ -212,29 +226,74 @@ class MessageDao extends DatabaseAccessor } } if (messagePagination?.limit != null) { - return msgList - .skip(max(0, msgList.length - messagePagination!.limit)) - .toList(); + return msgList.skip(max(0, msgList.length - messagePagination!.limit)).toList(); } } return msgList; } + /// Deletes all messages sent by a user with the given [userId]. + /// + /// If [hardDelete] is `true`, permanently removes messages from the database. + /// Otherwise, soft-deletes them by updating their type, deletion timestamp, + /// and state. + /// + /// If [cid] is provided, only deletes messages in that channel. Otherwise, + /// deletes messages across all channels. + /// + /// The [deletedAt] timestamp is used for soft deletes. Defaults to the + /// current time if not provided. + /// + /// Returns the number of rows affected. + Future deleteMessagesByUser({ + String? cid, + required String userId, + bool hardDelete = false, + DateTime? deletedAt, + }) async { + if (hardDelete) { + // Hard delete: remove from database + final deleteQuery = delete(messages)..where((tbl) => tbl.userId.equals(userId)); + + if (cid != null) { + deleteQuery.where((tbl) => tbl.channelCid.equals(cid)); + } + + return deleteQuery.go(); + } + + // Soft delete: update messages to mark as deleted + final updateQuery = update(messages)..where((tbl) => tbl.userId.equals(userId)); + + if (cid != null) { + updateQuery.where((tbl) => tbl.channelCid.equals(cid)); + } + + return updateQuery.write( + MessagesCompanion( + type: const Value('deleted'), + remoteDeletedAt: Value(deletedAt ?? DateTime.now()), + state: Value(jsonEncode(MessageState.softDeleted)), + ), + ); + } + /// Updates the message data of a particular channel with /// the new [messageList] data - Future updateMessages(String cid, List messageList) => - bulkUpdateMessages({cid: messageList}); + Future updateMessages(String cid, List messageList) => bulkUpdateMessages({cid: messageList}); /// Bulk updates the message data of multiple channels Future bulkUpdateMessages( Map?> channelWithMessages, ) { final entities = channelWithMessages.entries - .map((entry) => - entry.value?.map( - (message) => message.toEntity(cid: entry.key), - ) ?? - []) + .map( + (entry) => + entry.value?.map( + (message) => message.toEntity(cid: entry.key), + ) ?? + [], + ) .expand((it) => it) .toList(growable: false); return batch( diff --git a/packages/stream_chat_persistence/lib/src/dao/pinned_message_dao.dart b/packages/stream_chat_persistence/lib/src/dao/pinned_message_dao.dart index 7992accf5d..413c7164f4 100644 --- a/packages/stream_chat_persistence/lib/src/dao/pinned_message_dao.dart +++ b/packages/stream_chat_persistence/lib/src/dao/pinned_message_dao.dart @@ -1,3 +1,5 @@ +import 'dart:convert'; + import 'package:drift/drift.dart'; import 'package:stream_chat/stream_chat.dart'; import 'package:stream_chat_persistence/src/db/drift_chat_database.dart'; @@ -10,8 +12,7 @@ part 'pinned_message_dao.g.dart'; /// The Data Access Object for operations in [Messages] table. @DriftAccessor(tables: [PinnedMessages, Users]) -class PinnedMessageDao extends DatabaseAccessor - with _$PinnedMessageDaoMixin { +class PinnedMessageDao extends DatabaseAccessor with _$PinnedMessageDaoMixin { /// Creates a new message dao instance PinnedMessageDao(this._db) : super(_db); @@ -38,14 +39,13 @@ class PinnedMessageDao extends DatabaseAccessor Future _messageFromJoinRow( TypedResult rows, { bool fetchDraft = false, + bool fetchSharedLocation = false, }) async { final userEntity = rows.readTableOrNull(_users); final pinnedByEntity = rows.readTableOrNull(_pinnedByUsers); final msgEntity = rows.readTable(pinnedMessages); - final latestReactions = - await _db.pinnedMessageReactionDao.getReactions(msgEntity.id); - final ownReactions = - await _db.pinnedMessageReactionDao.getReactionsByUserId( + final latestReactions = await _db.pinnedMessageReactionDao.getReactions(msgEntity.id); + final ownReactions = await _db.pinnedMessageReactionDao.getReactionsByUserId( msgEntity.id, _db.userId, ); @@ -62,9 +62,14 @@ class PinnedMessageDao extends DatabaseAccessor final draft = await switch (fetchDraft) { true => _db.draftMessageDao.getDraftMessageByCid( - msgEntity.channelCid, - parentId: msgEntity.id, - ), + msgEntity.channelCid, + parentId: msgEntity.id, + ), + _ => null, + }; + + final sharedLocation = await switch (fetchSharedLocation) { + true => _db.locationDao.getLocationByMessageId(msgEntity.id), _ => null, }; @@ -76,6 +81,7 @@ class PinnedMessageDao extends DatabaseAccessor quotedMessage: quotedMessage, poll: poll, draft: draft, + sharedLocation: sharedLocation, ); } @@ -83,6 +89,7 @@ class PinnedMessageDao extends DatabaseAccessor Future getMessageById( String id, { bool fetchDraft = true, + bool fetchSharedLocation = true, }) async { final query = select(pinnedMessages).join([ leftOuterJoin(_users, pinnedMessages.userId.equalsExp(_users.id)), @@ -90,8 +97,7 @@ class PinnedMessageDao extends DatabaseAccessor _pinnedByUsers, pinnedMessages.pinnedByUserId.equalsExp(_pinnedByUsers.id), ), - ]) - ..where(pinnedMessages.id.equals(id)); + ])..where(pinnedMessages.id.equals(id)); final result = await query.getSingleOrNull(); if (result == null) return null; @@ -99,24 +105,26 @@ class PinnedMessageDao extends DatabaseAccessor return _messageFromJoinRow( result, fetchDraft: fetchDraft, + fetchSharedLocation: fetchSharedLocation, ); } /// Returns all the messages of a particular thread by matching /// [PinnedMessages.channelCid] with [cid] - Future> getThreadMessages(String cid) async => - Future.wait(await (select(pinnedMessages).join([ - leftOuterJoin(_users, pinnedMessages.userId.equalsExp(_users.id)), - leftOuterJoin( - _pinnedByUsers, - pinnedMessages.pinnedByUserId.equalsExp(_pinnedByUsers.id), - ), - ]) - ..where(pinnedMessages.channelCid.equals(cid)) - ..where(pinnedMessages.parentId.isNotNull()) - ..orderBy([OrderingTerm.asc(pinnedMessages.createdAt)])) - .map(_messageFromJoinRow) - .get()); + Future> getThreadMessages(String cid) async => Future.wait( + await (select(pinnedMessages).join([ + leftOuterJoin(_users, pinnedMessages.userId.equalsExp(_users.id)), + leftOuterJoin( + _pinnedByUsers, + pinnedMessages.pinnedByUserId.equalsExp(_pinnedByUsers.id), + ), + ]) + ..where(pinnedMessages.channelCid.equals(cid)) + ..where(pinnedMessages.parentId.isNotNull()) + ..orderBy([OrderingTerm.asc(pinnedMessages.createdAt)])) + .map(_messageFromJoinRow) + .get(), + ); /// Returns all the messages of a particular thread by matching /// [PinnedMessages.parentId] with [parentId] @@ -124,18 +132,20 @@ class PinnedMessageDao extends DatabaseAccessor String parentId, { PaginationParams? options, }) async { - final msgList = await Future.wait(await (select(pinnedMessages).join([ - leftOuterJoin(_users, pinnedMessages.userId.equalsExp(_users.id)), - leftOuterJoin( - _pinnedByUsers, - pinnedMessages.pinnedByUserId.equalsExp(_pinnedByUsers.id), - ), - ]) - ..where(pinnedMessages.parentId.isNotNull()) - ..where(pinnedMessages.parentId.equals(parentId)) - ..orderBy([OrderingTerm.asc(pinnedMessages.createdAt)])) - .map(_messageFromJoinRow) - .get()); + final msgList = await Future.wait( + await (select(pinnedMessages).join([ + leftOuterJoin(_users, pinnedMessages.userId.equalsExp(_users.id)), + leftOuterJoin( + _pinnedByUsers, + pinnedMessages.pinnedByUserId.equalsExp(_pinnedByUsers.id), + ), + ]) + ..where(pinnedMessages.parentId.isNotNull()) + ..where(pinnedMessages.parentId.equals(parentId)) + ..orderBy([OrderingTerm.asc(pinnedMessages.createdAt)])) + .map(_messageFromJoinRow) + .get(), + ); if (msgList.isNotEmpty) { if (options?.lessThan != null) { @@ -166,19 +176,20 @@ class PinnedMessageDao extends DatabaseAccessor Future> getMessagesByCid( String cid, { bool fetchDraft = true, + bool fetchSharedLocation = true, PaginationParams? messagePagination, }) async { - final query = select(pinnedMessages).join([ - leftOuterJoin(_users, pinnedMessages.userId.equalsExp(_users.id)), - leftOuterJoin( - _pinnedByUsers, - pinnedMessages.pinnedByUserId.equalsExp(_pinnedByUsers.id), - ), - ]) - ..where(pinnedMessages.channelCid.equals(cid)) - ..where(pinnedMessages.parentId.isNull() | - pinnedMessages.showInChannel.equals(true)) - ..orderBy([OrderingTerm.asc(pinnedMessages.createdAt)]); + final query = + select(pinnedMessages).join([ + leftOuterJoin(_users, pinnedMessages.userId.equalsExp(_users.id)), + leftOuterJoin( + _pinnedByUsers, + pinnedMessages.pinnedByUserId.equalsExp(_pinnedByUsers.id), + ), + ]) + ..where(pinnedMessages.channelCid.equals(cid)) + ..where(pinnedMessages.parentId.isNull() | pinnedMessages.showInChannel.equals(true)) + ..orderBy([OrderingTerm.asc(pinnedMessages.createdAt)]); final result = await query.get(); if (result.isEmpty) return []; @@ -188,6 +199,7 @@ class PinnedMessageDao extends DatabaseAccessor (row) => _messageFromJoinRow( row, fetchDraft: fetchDraft, + fetchSharedLocation: fetchSharedLocation, ), ), ); @@ -216,21 +228,68 @@ class PinnedMessageDao extends DatabaseAccessor return msgList; } + /// Deletes all pinned messages sent by a user with the given [userId]. + /// + /// If [hardDelete] is `true`, permanently removes pinned messages from the + /// database. Otherwise, soft-deletes them by updating their type, deletion + /// timestamp, and state. + /// + /// If [cid] is provided, only deletes pinned messages in that channel. + /// Otherwise, deletes pinned messages across all channels. + /// + /// The [deletedAt] timestamp is used for soft deletes. Defaults to the + /// current time if not provided. + /// + /// Returns the number of rows affected. + Future deleteMessagesByUser({ + String? cid, + required String userId, + bool hardDelete = false, + DateTime? deletedAt, + }) async { + if (hardDelete) { + // Hard delete: remove from database + final deleteQuery = delete(pinnedMessages)..where((tbl) => tbl.userId.equals(userId)); + + if (cid != null) { + deleteQuery.where((tbl) => tbl.channelCid.equals(cid)); + } + + return deleteQuery.go(); + } + + // Soft delete: update messages to mark as deleted + final updateQuery = update(pinnedMessages)..where((tbl) => tbl.userId.equals(userId)); + + if (cid != null) { + updateQuery.where((tbl) => tbl.channelCid.equals(cid)); + } + + return updateQuery.write( + PinnedMessagesCompanion( + type: const Value('deleted'), + remoteDeletedAt: Value(deletedAt ?? DateTime.now()), + state: Value(jsonEncode(MessageState.softDeleted)), + ), + ); + } + /// Updates the message data of a particular channel with /// the new [messageList] data - Future updateMessages(String cid, List messageList) => - bulkUpdateMessages({cid: messageList}); + Future updateMessages(String cid, List messageList) => bulkUpdateMessages({cid: messageList}); /// Bulk updates the message data of multiple channels Future bulkUpdateMessages( Map?> channelWithMessages, ) { final entities = channelWithMessages.entries - .map((entry) => - entry.value?.map( - (message) => message.toPinnedEntity(cid: entry.key), - ) ?? - []) + .map( + (entry) => + entry.value?.map( + (message) => message.toPinnedEntity(cid: entry.key), + ) ?? + [], + ) .expand((it) => it) .toList(growable: false); return batch( diff --git a/packages/stream_chat_persistence/lib/src/dao/pinned_message_reaction_dao.dart b/packages/stream_chat_persistence/lib/src/dao/pinned_message_reaction_dao.dart index c5c5a6d456..9a81c6e46e 100644 --- a/packages/stream_chat_persistence/lib/src/dao/pinned_message_reaction_dao.dart +++ b/packages/stream_chat_persistence/lib/src/dao/pinned_message_reaction_dao.dart @@ -9,8 +9,7 @@ part 'pinned_message_reaction_dao.g.dart'; /// The Data Access Object for operations in [PinnedMessageReactions] table. @DriftAccessor(tables: [PinnedMessageReactions, Users]) -class PinnedMessageReactionDao extends DatabaseAccessor - with _$PinnedMessageReactionDaoMixin { +class PinnedMessageReactionDao extends DatabaseAccessor with _$PinnedMessageReactionDaoMixin { /// Creates a new reaction dao instance PinnedMessageReactionDao(super.db); @@ -18,15 +17,16 @@ class PinnedMessageReactionDao extends DatabaseAccessor /// [Reactions.messageId] with [messageId] Future> getReactions(String messageId) => (select(pinnedMessageReactions).join([ - leftOuterJoin(users, pinnedMessageReactions.userId.equalsExp(users.id)), - ]) + leftOuterJoin(users, pinnedMessageReactions.userId.equalsExp(users.id)), + ]) ..where(pinnedMessageReactions.messageId.equals(messageId)) ..orderBy([OrderingTerm.asc(pinnedMessageReactions.createdAt)])) .map((rows) { - final userEntity = rows.readTableOrNull(users); - final reactionEntity = rows.readTable(pinnedMessageReactions); - return reactionEntity.toReaction(user: userEntity?.toUser()); - }).get(); + final userEntity = rows.readTableOrNull(users); + final reactionEntity = rows.readTable(pinnedMessageReactions); + return reactionEntity.toReaction(user: userEntity?.toUser()); + }) + .get(); /// Returns all the reactions of a particular message /// added by a particular user by matching @@ -42,19 +42,18 @@ class PinnedMessageReactionDao extends DatabaseAccessor /// Updates the reactions data with the new [reactionList] data Future updateReactions(List reactionList) => batch((it) { - it.insertAllOnConflictUpdate( - pinnedMessageReactions, - reactionList.map((r) => r.toPinnedEntity()).toList(), - ); - }); + it.insertAllOnConflictUpdate( + pinnedMessageReactions, + reactionList.map((r) => r.toPinnedEntity()).toList(), + ); + }); /// Deletes all the reactions whose [Reactions.messageId] is /// present in [messageIds] - Future deleteReactionsByMessageIds(List messageIds) => - batch((it) { - it.deleteWhere( - pinnedMessageReactions, - (r) => r.messageId.isIn(messageIds), - ); - }); + Future deleteReactionsByMessageIds(List messageIds) => batch((it) { + it.deleteWhere( + pinnedMessageReactions, + (r) => r.messageId.isIn(messageIds), + ); + }); } diff --git a/packages/stream_chat_persistence/lib/src/dao/pinned_message_reaction_dao.g.dart b/packages/stream_chat_persistence/lib/src/dao/pinned_message_reaction_dao.g.dart index d25c2f4512..0b7393b68b 100644 --- a/packages/stream_chat_persistence/lib/src/dao/pinned_message_reaction_dao.g.dart +++ b/packages/stream_chat_persistence/lib/src/dao/pinned_message_reaction_dao.g.dart @@ -5,7 +5,6 @@ part of 'pinned_message_reaction_dao.dart'; // ignore_for_file: type=lint mixin _$PinnedMessageReactionDaoMixin on DatabaseAccessor { $PinnedMessagesTable get pinnedMessages => attachedDatabase.pinnedMessages; - $PinnedMessageReactionsTable get pinnedMessageReactions => - attachedDatabase.pinnedMessageReactions; + $PinnedMessageReactionsTable get pinnedMessageReactions => attachedDatabase.pinnedMessageReactions; $UsersTable get users => attachedDatabase.users; } diff --git a/packages/stream_chat_persistence/lib/src/dao/poll_dao.dart b/packages/stream_chat_persistence/lib/src/dao/poll_dao.dart index 10a10a024d..43924285e7 100644 --- a/packages/stream_chat_persistence/lib/src/dao/poll_dao.dart +++ b/packages/stream_chat_persistence/lib/src/dao/poll_dao.dart @@ -45,28 +45,27 @@ class PollDao extends DatabaseAccessor with _$PollDaoMixin { } /// Returns the poll by matching [Polls.id] with [pollId] - Future getPollById(String pollId) async => - await (select(polls)..where((it) => it.id.equals(pollId))) - .join([leftOuterJoin(users, polls.createdById.equalsExp(users.id))]) - .map(_pollFromJoinRow) - .getSingleOrNull(); + Future getPollById(String pollId) async => await (select(polls)..where((it) => it.id.equals(pollId))) + .join([leftOuterJoin(users, polls.createdById.equalsExp(users.id))]) + .map(_pollFromJoinRow) + .getSingleOrNull(); /// Updates all the polls using the new [pollList] data Future updatePolls(List pollList) => batch( - (it) => it.insertAllOnConflictUpdate( - polls, - pollList.map((it) => it.toEntity()), - ), - ); + (it) => it.insertAllOnConflictUpdate( + polls, + pollList.map((it) => it.toEntity()), + ), + ); /// Returns the list of all the polls stored in db - Future> getPolls() async => Future.wait(await (select(polls) - ..orderBy([(it) => OrderingTerm.desc(it.createdAt)])) - .join([leftOuterJoin(users, polls.createdById.equalsExp(users.id))]) - .map(_pollFromJoinRow) - .get()); + Future> getPolls() async => Future.wait( + await (select(polls)..orderBy([(it) => OrderingTerm.desc(it.createdAt)])) + .join([leftOuterJoin(users, polls.createdById.equalsExp(users.id))]) + .map(_pollFromJoinRow) + .get(), + ); /// Deletes all the polls whose [Polls.id] is present in [pollIds] - Future deletePollsByIds(List pollIds) => - (delete(polls)..where((tbl) => tbl.id.isIn(pollIds))).go(); + Future deletePollsByIds(List pollIds) => (delete(polls)..where((tbl) => tbl.id.isIn(pollIds))).go(); } diff --git a/packages/stream_chat_persistence/lib/src/dao/poll_vote_dao.dart b/packages/stream_chat_persistence/lib/src/dao/poll_vote_dao.dart index 55884f4a70..6fb765d8e3 100644 --- a/packages/stream_chat_persistence/lib/src/dao/poll_vote_dao.dart +++ b/packages/stream_chat_persistence/lib/src/dao/poll_vote_dao.dart @@ -11,8 +11,7 @@ part 'poll_vote_dao.g.dart'; /// The Data Access Object for operations in [Polls] table. @DriftAccessor(tables: [PollVotes, Users]) -class PollVoteDao extends DatabaseAccessor - with _$PollVoteDaoMixin { +class PollVoteDao extends DatabaseAccessor with _$PollVoteDaoMixin { /// Creates a new poll vote dao instance PollVoteDao(super.db); @@ -20,23 +19,24 @@ class PollVoteDao extends DatabaseAccessor /// [Reactions.messageId] with [messageId] Future> getPollVotes(String pollId) => (select(pollVotes).join([ - leftOuterJoin(users, pollVotes.userId.equalsExp(users.id)), - ]) + leftOuterJoin(users, pollVotes.userId.equalsExp(users.id)), + ]) ..where(pollVotes.pollId.equals(pollId)) ..orderBy([OrderingTerm.asc(pollVotes.createdAt)])) .map((rows) { - final userEntity = rows.readTableOrNull(users); - final pollVoteEntity = rows.readTable(pollVotes); - return pollVoteEntity.toPollVote(user: userEntity?.toUser()); - }).get(); + final userEntity = rows.readTableOrNull(users); + final pollVoteEntity = rows.readTable(pollVotes); + return pollVoteEntity.toPollVote(user: userEntity?.toUser()); + }) + .get(); /// Updates the poll votes data with the new [pollVoteList] data Future updatePollVotes(List pollVoteList) => batch( - (it) => it.insertAllOnConflictUpdate( - pollVotes, - pollVoteList.map((it) => it.toEntity()), - ), - ); + (it) => it.insertAllOnConflictUpdate( + pollVotes, + pollVoteList.map((it) => it.toEntity()), + ), + ); /// Deletes all the poll votes whose [PollVote.pollId] is /// present in [pollIds] diff --git a/packages/stream_chat_persistence/lib/src/dao/reaction_dao.dart b/packages/stream_chat_persistence/lib/src/dao/reaction_dao.dart index d6dae9bd99..2a68626159 100644 --- a/packages/stream_chat_persistence/lib/src/dao/reaction_dao.dart +++ b/packages/stream_chat_persistence/lib/src/dao/reaction_dao.dart @@ -9,8 +9,7 @@ part 'reaction_dao.g.dart'; /// The Data Access Object for operations in [Reactions] table. @DriftAccessor(tables: [Reactions, Users]) -class ReactionDao extends DatabaseAccessor - with _$ReactionDaoMixin { +class ReactionDao extends DatabaseAccessor with _$ReactionDaoMixin { /// Creates a new reaction dao instance ReactionDao(super.db); @@ -18,15 +17,16 @@ class ReactionDao extends DatabaseAccessor /// [Reactions.messageId] with [messageId] Future> getReactions(String messageId) => (select(reactions).join([ - leftOuterJoin(users, reactions.userId.equalsExp(users.id)), - ]) + leftOuterJoin(users, reactions.userId.equalsExp(users.id)), + ]) ..where(reactions.messageId.equals(messageId)) ..orderBy([OrderingTerm.asc(reactions.createdAt)])) .map((rows) { - final userEntity = rows.readTableOrNull(users); - final reactionEntity = rows.readTable(reactions); - return reactionEntity.toReaction(user: userEntity?.toUser()); - }).get(); + final userEntity = rows.readTableOrNull(users); + final reactionEntity = rows.readTable(reactions); + return reactionEntity.toReaction(user: userEntity?.toUser()); + }) + .get(); /// Returns all the reactions of a particular message /// added by a particular user by matching @@ -42,19 +42,18 @@ class ReactionDao extends DatabaseAccessor /// Updates the reactions data with the new [reactionList] data Future updateReactions(List reactionList) => batch((it) { - it.insertAllOnConflictUpdate( - reactions, - reactionList.map((r) => r.toEntity()).toList(), - ); - }); + it.insertAllOnConflictUpdate( + reactions, + reactionList.map((r) => r.toEntity()).toList(), + ); + }); /// Deletes all the reactions whose [Reactions.messageId] is /// present in [messageIds] - Future deleteReactionsByMessageIds(List messageIds) => - batch((it) { - it.deleteWhere( - reactions, - (r) => r.messageId.isIn(messageIds), - ); - }); + Future deleteReactionsByMessageIds(List messageIds) => batch((it) { + it.deleteWhere( + reactions, + (r) => r.messageId.isIn(messageIds), + ); + }); } diff --git a/packages/stream_chat_persistence/lib/src/dao/read_dao.dart b/packages/stream_chat_persistence/lib/src/dao/read_dao.dart index 4e2024f1ff..c6beec0f9f 100644 --- a/packages/stream_chat_persistence/lib/src/dao/read_dao.dart +++ b/packages/stream_chat_persistence/lib/src/dao/read_dao.dart @@ -14,32 +14,35 @@ class ReadDao extends DatabaseAccessor with _$ReadDaoMixin { ReadDao(super.db); /// Get all reads where [Reads.channelCid] matches [cid] - Future> getReadsByCid(String cid) async => (select(reads).join([ - leftOuterJoin(users, reads.userId.equalsExp(users.id)), - ]) + Future> getReadsByCid(String cid) async => + (select(reads).join([ + leftOuterJoin(users, reads.userId.equalsExp(users.id)), + ]) ..where(reads.channelCid.equals(cid)) ..orderBy([ OrderingTerm.asc(reads.lastRead), ])) .map((row) { - final userEntity = row.readTable(users); - final readEntity = row.readTable(reads); - return readEntity.toRead(user: userEntity.toUser()); - }).get(); + final userEntity = row.readTable(users); + final readEntity = row.readTable(reads); + return readEntity.toRead(user: userEntity.toUser()); + }) + .get(); /// Updates the read data of a particular channel with /// the new [readList] data - Future updateReads(String cid, List readList) => - bulkUpdateReads({cid: readList}); + Future updateReads(String cid, List readList) => bulkUpdateReads({cid: readList}); /// Bulk updates the reads data of multiple channels Future bulkUpdateReads(Map?> channelWithReads) { final entities = channelWithReads.entries - .map((entry) => - entry.value?.map( - (read) => read.toEntity(cid: entry.key), - ) ?? - []) + .map( + (entry) => + entry.value?.map( + (read) => read.toEntity(cid: entry.key), + ) ?? + [], + ) .expand((it) => it) .toList(growable: false); return batch((batch) => batch.insertAllOnConflictUpdate(reads, entities)); diff --git a/packages/stream_chat_persistence/lib/src/dao/user_dao.dart b/packages/stream_chat_persistence/lib/src/dao/user_dao.dart index 1366dd2541..214425d882 100644 --- a/packages/stream_chat_persistence/lib/src/dao/user_dao.dart +++ b/packages/stream_chat_persistence/lib/src/dao/user_dao.dart @@ -14,15 +14,13 @@ class UserDao extends DatabaseAccessor with _$UserDaoMixin { /// Updates the users data with the new [userList] data Future updateUsers(List userList) => batch( - (it) => it.insertAllOnConflictUpdate( - users, - userList.map((u) => u.toEntity()).toList(), - ), - ); + (it) => it.insertAllOnConflictUpdate( + users, + userList.map((u) => u.toEntity()).toList(), + ), + ); /// Returns the list of all the users stored in db Future> getUsers() => - (select(users)..orderBy([(u) => OrderingTerm.desc(u.createdAt)])) - .map((it) => it.toUser()) - .get(); + (select(users)..orderBy([(u) => OrderingTerm.desc(u.createdAt)])).map((it) => it.toUser()).get(); } diff --git a/packages/stream_chat_persistence/lib/src/db/drift_chat_database.dart b/packages/stream_chat_persistence/lib/src/db/drift_chat_database.dart index a412abb546..c8f2843d7e 100644 --- a/packages/stream_chat_persistence/lib/src/db/drift_chat_database.dart +++ b/packages/stream_chat_persistence/lib/src/db/drift_chat_database.dart @@ -13,6 +13,7 @@ part 'drift_chat_database.g.dart'; tables: [ Channels, DraftMessages, + Locations, Messages, PinnedMessages, Polls, @@ -30,6 +31,7 @@ part 'drift_chat_database.g.dart'; ChannelDao, MessageDao, DraftMessageDao, + LocationDao, PinnedMessageDao, PinnedMessageReactionDao, MemberDao, @@ -55,22 +57,26 @@ class DriftChatDatabase extends _$DriftChatDatabase { // you should bump this number whenever you change or add a table definition. @override - int get schemaVersion => 27; + int get schemaVersion => 1000 + 30; + + // Store DateTime as ISO-8601 text to preserve sub-second precision. + @override + DriftDatabaseOptions get options => const DriftDatabaseOptions(storeDateTimeAsText: true); @override MigrationStrategy get migration => MigrationStrategy( - beforeOpen: (details) async { - await customStatement('PRAGMA foreign_keys = ON'); - }, - onUpgrade: (migrator, from, to) async { - if (from != to) { - for (final table in allTables) { - await migrator.deleteTable(table.actualTableName); - } - await migrator.createAll(); - } - }, - ); + beforeOpen: (details) async { + await customStatement('PRAGMA foreign_keys = ON'); + }, + onUpgrade: (migrator, from, to) async { + if (from != to) { + for (final table in allTables) { + await migrator.deleteTable(table.actualTableName); + } + await migrator.createAll(); + } + }, + ); /// Deletes all the tables Future flush() async { diff --git a/packages/stream_chat_persistence/lib/src/db/drift_chat_database.g.dart b/packages/stream_chat_persistence/lib/src/db/drift_chat_database.g.dart index 4fc780bd4b..899609385e 100644 --- a/packages/stream_chat_persistence/lib/src/db/drift_chat_database.g.dart +++ b/packages/stream_chat_persistence/lib/src/db/drift_chat_database.g.dart @@ -3,8 +3,7 @@ part of 'drift_chat_database.dart'; // ignore_for_file: type=lint -class $ChannelsTable extends Channels - with TableInfo<$ChannelsTable, ChannelEntity> { +class $ChannelsTable extends Channels with TableInfo<$ChannelsTable, ChannelEntity> { @override final GeneratedDatabase attachedDatabase; final String? _alias; @@ -12,131 +11,164 @@ class $ChannelsTable extends Channels static const VerificationMeta _idMeta = const VerificationMeta('id'); @override late final GeneratedColumn id = GeneratedColumn( - 'id', aliasedName, false, - type: DriftSqlType.string, requiredDuringInsert: true); + 'id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); static const VerificationMeta _typeMeta = const VerificationMeta('type'); @override late final GeneratedColumn type = GeneratedColumn( - 'type', aliasedName, false, - type: DriftSqlType.string, requiredDuringInsert: true); + 'type', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); static const VerificationMeta _cidMeta = const VerificationMeta('cid'); @override late final GeneratedColumn cid = GeneratedColumn( - 'cid', aliasedName, false, - type: DriftSqlType.string, requiredDuringInsert: true); - static const VerificationMeta _ownCapabilitiesMeta = - const VerificationMeta('ownCapabilities'); - @override - late final GeneratedColumnWithTypeConverter?, String> - ownCapabilities = GeneratedColumn( - 'own_capabilities', aliasedName, true, - type: DriftSqlType.string, requiredDuringInsert: false) - .withConverter?>( - $ChannelsTable.$converterownCapabilitiesn); - static const VerificationMeta _configMeta = const VerificationMeta('config'); - @override - late final GeneratedColumnWithTypeConverter, String> - config = GeneratedColumn('config', aliasedName, false, - type: DriftSqlType.string, requiredDuringInsert: true) - .withConverter>($ChannelsTable.$converterconfig); + 'cid', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + @override + late final GeneratedColumnWithTypeConverter?, String> ownCapabilities = GeneratedColumn( + 'own_capabilities', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ).withConverter?>($ChannelsTable.$converterownCapabilitiesn); + @override + late final GeneratedColumnWithTypeConverter, String> config = GeneratedColumn( + 'config', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ).withConverter>($ChannelsTable.$converterconfig); static const VerificationMeta _frozenMeta = const VerificationMeta('frozen'); @override late final GeneratedColumn frozen = GeneratedColumn( - 'frozen', aliasedName, false, - type: DriftSqlType.bool, - requiredDuringInsert: false, - defaultConstraints: - GeneratedColumn.constraintIsAlways('CHECK ("frozen" IN (0, 1))'), - defaultValue: const Constant(false)); - static const VerificationMeta _lastMessageAtMeta = - const VerificationMeta('lastMessageAt'); - @override - late final GeneratedColumn lastMessageAt = - GeneratedColumn('last_message_at', aliasedName, true, - type: DriftSqlType.dateTime, requiredDuringInsert: false); - static const VerificationMeta _createdAtMeta = - const VerificationMeta('createdAt'); + 'frozen', + aliasedName, + false, + type: DriftSqlType.bool, + requiredDuringInsert: false, + defaultConstraints: GeneratedColumn.constraintIsAlways('CHECK ("frozen" IN (0, 1))'), + defaultValue: const Constant(false), + ); + static const VerificationMeta _lastMessageAtMeta = const VerificationMeta('lastMessageAt'); + @override + late final GeneratedColumn lastMessageAt = GeneratedColumn( + 'last_message_at', + aliasedName, + true, + type: DriftSqlType.dateTime, + requiredDuringInsert: false, + ); + static const VerificationMeta _createdAtMeta = const VerificationMeta('createdAt'); @override late final GeneratedColumn createdAt = GeneratedColumn( - 'created_at', aliasedName, false, - type: DriftSqlType.dateTime, - requiredDuringInsert: false, - defaultValue: currentDateAndTime); - static const VerificationMeta _updatedAtMeta = - const VerificationMeta('updatedAt'); + 'created_at', + aliasedName, + false, + type: DriftSqlType.dateTime, + requiredDuringInsert: false, + defaultValue: currentDateAndTime, + ); + static const VerificationMeta _updatedAtMeta = const VerificationMeta('updatedAt'); @override late final GeneratedColumn updatedAt = GeneratedColumn( - 'updated_at', aliasedName, false, - type: DriftSqlType.dateTime, - requiredDuringInsert: false, - defaultValue: currentDateAndTime); - static const VerificationMeta _deletedAtMeta = - const VerificationMeta('deletedAt'); + 'updated_at', + aliasedName, + false, + type: DriftSqlType.dateTime, + requiredDuringInsert: false, + defaultValue: currentDateAndTime, + ); + static const VerificationMeta _deletedAtMeta = const VerificationMeta('deletedAt'); @override late final GeneratedColumn deletedAt = GeneratedColumn( - 'deleted_at', aliasedName, true, - type: DriftSqlType.dateTime, requiredDuringInsert: false); - static const VerificationMeta _memberCountMeta = - const VerificationMeta('memberCount'); + 'deleted_at', + aliasedName, + true, + type: DriftSqlType.dateTime, + requiredDuringInsert: false, + ); + static const VerificationMeta _memberCountMeta = const VerificationMeta('memberCount'); @override late final GeneratedColumn memberCount = GeneratedColumn( - 'member_count', aliasedName, false, - type: DriftSqlType.int, - requiredDuringInsert: false, - defaultValue: const Constant(0)); - static const VerificationMeta _messageCountMeta = - const VerificationMeta('messageCount'); + 'member_count', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: false, + defaultValue: const Constant(0), + ); + static const VerificationMeta _messageCountMeta = const VerificationMeta('messageCount'); @override late final GeneratedColumn messageCount = GeneratedColumn( - 'message_count', aliasedName, true, - type: DriftSqlType.int, requiredDuringInsert: false); - static const VerificationMeta _createdByIdMeta = - const VerificationMeta('createdById'); + 'message_count', + aliasedName, + true, + type: DriftSqlType.int, + requiredDuringInsert: false, + ); + static const VerificationMeta _createdByIdMeta = const VerificationMeta('createdById'); @override late final GeneratedColumn createdById = GeneratedColumn( - 'created_by_id', aliasedName, true, - type: DriftSqlType.string, requiredDuringInsert: false); - static const VerificationMeta _filterTagsMeta = - const VerificationMeta('filterTags'); - @override - late final GeneratedColumnWithTypeConverter?, String> - filterTags = GeneratedColumn('filter_tags', aliasedName, true, - type: DriftSqlType.string, requiredDuringInsert: false) - .withConverter?>($ChannelsTable.$converterfilterTagsn); - static const VerificationMeta _extraDataMeta = - const VerificationMeta('extraData'); - @override - late final GeneratedColumnWithTypeConverter?, String> - extraData = GeneratedColumn('extra_data', aliasedName, true, - type: DriftSqlType.string, requiredDuringInsert: false) - .withConverter?>( - $ChannelsTable.$converterextraDatan); + 'created_by_id', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); + @override + late final GeneratedColumnWithTypeConverter?, String> filterTags = GeneratedColumn( + 'filter_tags', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ).withConverter?>($ChannelsTable.$converterfilterTagsn); + @override + late final GeneratedColumnWithTypeConverter?, String> extraData = GeneratedColumn( + 'extra_data', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ).withConverter?>($ChannelsTable.$converterextraDatan); @override List get $columns => [ - id, - type, - cid, - ownCapabilities, - config, - frozen, - lastMessageAt, - createdAt, - updatedAt, - deletedAt, - memberCount, - messageCount, - createdById, - filterTags, - extraData - ]; + id, + type, + cid, + ownCapabilities, + config, + frozen, + lastMessageAt, + createdAt, + updatedAt, + deletedAt, + memberCount, + messageCount, + createdById, + filterTags, + extraData, + ]; @override String get aliasedName => _alias ?? actualTableName; @override String get actualTableName => $name; static const String $name = 'channels'; @override - VerificationContext validateIntegrity(Insertable instance, - {bool isInserting = false}) { + VerificationContext validateIntegrity(Insertable instance, {bool isInserting = false}) { final context = VerificationContext(); final data = instance.toColumns(true); if (data.containsKey('id')) { @@ -145,61 +177,42 @@ class $ChannelsTable extends Channels context.missing(_idMeta); } if (data.containsKey('type')) { - context.handle( - _typeMeta, type.isAcceptableOrUnknown(data['type']!, _typeMeta)); + context.handle(_typeMeta, type.isAcceptableOrUnknown(data['type']!, _typeMeta)); } else if (isInserting) { context.missing(_typeMeta); } if (data.containsKey('cid')) { - context.handle( - _cidMeta, cid.isAcceptableOrUnknown(data['cid']!, _cidMeta)); + context.handle(_cidMeta, cid.isAcceptableOrUnknown(data['cid']!, _cidMeta)); } else if (isInserting) { context.missing(_cidMeta); } - context.handle(_ownCapabilitiesMeta, const VerificationResult.success()); - context.handle(_configMeta, const VerificationResult.success()); if (data.containsKey('frozen')) { - context.handle(_frozenMeta, - frozen.isAcceptableOrUnknown(data['frozen']!, _frozenMeta)); + context.handle(_frozenMeta, frozen.isAcceptableOrUnknown(data['frozen']!, _frozenMeta)); } if (data.containsKey('last_message_at')) { context.handle( - _lastMessageAtMeta, - lastMessageAt.isAcceptableOrUnknown( - data['last_message_at']!, _lastMessageAtMeta)); + _lastMessageAtMeta, + lastMessageAt.isAcceptableOrUnknown(data['last_message_at']!, _lastMessageAtMeta), + ); } if (data.containsKey('created_at')) { - context.handle(_createdAtMeta, - createdAt.isAcceptableOrUnknown(data['created_at']!, _createdAtMeta)); + context.handle(_createdAtMeta, createdAt.isAcceptableOrUnknown(data['created_at']!, _createdAtMeta)); } if (data.containsKey('updated_at')) { - context.handle(_updatedAtMeta, - updatedAt.isAcceptableOrUnknown(data['updated_at']!, _updatedAtMeta)); + context.handle(_updatedAtMeta, updatedAt.isAcceptableOrUnknown(data['updated_at']!, _updatedAtMeta)); } if (data.containsKey('deleted_at')) { - context.handle(_deletedAtMeta, - deletedAt.isAcceptableOrUnknown(data['deleted_at']!, _deletedAtMeta)); + context.handle(_deletedAtMeta, deletedAt.isAcceptableOrUnknown(data['deleted_at']!, _deletedAtMeta)); } if (data.containsKey('member_count')) { - context.handle( - _memberCountMeta, - memberCount.isAcceptableOrUnknown( - data['member_count']!, _memberCountMeta)); + context.handle(_memberCountMeta, memberCount.isAcceptableOrUnknown(data['member_count']!, _memberCountMeta)); } if (data.containsKey('message_count')) { - context.handle( - _messageCountMeta, - messageCount.isAcceptableOrUnknown( - data['message_count']!, _messageCountMeta)); + context.handle(_messageCountMeta, messageCount.isAcceptableOrUnknown(data['message_count']!, _messageCountMeta)); } if (data.containsKey('created_by_id')) { - context.handle( - _createdByIdMeta, - createdById.isAcceptableOrUnknown( - data['created_by_id']!, _createdByIdMeta)); + context.handle(_createdByIdMeta, createdById.isAcceptableOrUnknown(data['created_by_id']!, _createdByIdMeta)); } - context.handle(_filterTagsMeta, const VerificationResult.success()); - context.handle(_extraDataMeta, const VerificationResult.success()); return context; } @@ -209,40 +222,32 @@ class $ChannelsTable extends Channels ChannelEntity map(Map data, {String? tablePrefix}) { final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; return ChannelEntity( - id: attachedDatabase.typeMapping - .read(DriftSqlType.string, data['${effectivePrefix}id'])!, - type: attachedDatabase.typeMapping - .read(DriftSqlType.string, data['${effectivePrefix}type'])!, - cid: attachedDatabase.typeMapping - .read(DriftSqlType.string, data['${effectivePrefix}cid'])!, + id: attachedDatabase.typeMapping.read(DriftSqlType.string, data['${effectivePrefix}id'])!, + type: attachedDatabase.typeMapping.read(DriftSqlType.string, data['${effectivePrefix}type'])!, + cid: attachedDatabase.typeMapping.read(DriftSqlType.string, data['${effectivePrefix}cid'])!, ownCapabilities: $ChannelsTable.$converterownCapabilitiesn.fromSql( - attachedDatabase.typeMapping.read( - DriftSqlType.string, data['${effectivePrefix}own_capabilities'])), - config: $ChannelsTable.$converterconfig.fromSql(attachedDatabase - .typeMapping - .read(DriftSqlType.string, data['${effectivePrefix}config'])!), - frozen: attachedDatabase.typeMapping - .read(DriftSqlType.bool, data['${effectivePrefix}frozen'])!, + attachedDatabase.typeMapping.read(DriftSqlType.string, data['${effectivePrefix}own_capabilities']), + ), + config: $ChannelsTable.$converterconfig.fromSql( + attachedDatabase.typeMapping.read(DriftSqlType.string, data['${effectivePrefix}config'])!, + ), + frozen: attachedDatabase.typeMapping.read(DriftSqlType.bool, data['${effectivePrefix}frozen'])!, lastMessageAt: attachedDatabase.typeMapping.read( - DriftSqlType.dateTime, data['${effectivePrefix}last_message_at']), - createdAt: attachedDatabase.typeMapping - .read(DriftSqlType.dateTime, data['${effectivePrefix}created_at'])!, - updatedAt: attachedDatabase.typeMapping - .read(DriftSqlType.dateTime, data['${effectivePrefix}updated_at'])!, - deletedAt: attachedDatabase.typeMapping - .read(DriftSqlType.dateTime, data['${effectivePrefix}deleted_at']), - memberCount: attachedDatabase.typeMapping - .read(DriftSqlType.int, data['${effectivePrefix}member_count'])!, - messageCount: attachedDatabase.typeMapping - .read(DriftSqlType.int, data['${effectivePrefix}message_count']), - createdById: attachedDatabase.typeMapping - .read(DriftSqlType.string, data['${effectivePrefix}created_by_id']), - filterTags: $ChannelsTable.$converterfilterTagsn.fromSql(attachedDatabase - .typeMapping - .read(DriftSqlType.string, data['${effectivePrefix}filter_tags'])), - extraData: $ChannelsTable.$converterextraDatan.fromSql(attachedDatabase - .typeMapping - .read(DriftSqlType.string, data['${effectivePrefix}extra_data'])), + DriftSqlType.dateTime, + data['${effectivePrefix}last_message_at'], + ), + createdAt: attachedDatabase.typeMapping.read(DriftSqlType.dateTime, data['${effectivePrefix}created_at'])!, + updatedAt: attachedDatabase.typeMapping.read(DriftSqlType.dateTime, data['${effectivePrefix}updated_at'])!, + deletedAt: attachedDatabase.typeMapping.read(DriftSqlType.dateTime, data['${effectivePrefix}deleted_at']), + memberCount: attachedDatabase.typeMapping.read(DriftSqlType.int, data['${effectivePrefix}member_count'])!, + messageCount: attachedDatabase.typeMapping.read(DriftSqlType.int, data['${effectivePrefix}message_count']), + createdById: attachedDatabase.typeMapping.read(DriftSqlType.string, data['${effectivePrefix}created_by_id']), + filterTags: $ChannelsTable.$converterfilterTagsn.fromSql( + attachedDatabase.typeMapping.read(DriftSqlType.string, data['${effectivePrefix}filter_tags']), + ), + extraData: $ChannelsTable.$converterextraDatan.fromSql( + attachedDatabase.typeMapping.read(DriftSqlType.string, data['${effectivePrefix}extra_data']), + ), ); } @@ -251,20 +256,19 @@ class $ChannelsTable extends Channels return $ChannelsTable(attachedDatabase, alias); } - static TypeConverter, String> $converterownCapabilities = - ListConverter(); - static TypeConverter?, String?> $converterownCapabilitiesn = - NullAwareTypeConverter.wrap($converterownCapabilities); - static TypeConverter, String> $converterconfig = - MapConverter(); - static TypeConverter, String> $converterfilterTags = - ListConverter(); - static TypeConverter?, String?> $converterfilterTagsn = - NullAwareTypeConverter.wrap($converterfilterTags); - static TypeConverter, String> $converterextraData = - MapConverter(); - static TypeConverter?, String?> $converterextraDatan = - NullAwareTypeConverter.wrap($converterextraData); + static TypeConverter, String> $converterownCapabilities = ListConverter(); + static TypeConverter?, String?> $converterownCapabilitiesn = NullAwareTypeConverter.wrap( + $converterownCapabilities, + ); + static TypeConverter, String> $converterconfig = MapConverter(); + static TypeConverter, String> $converterfilterTags = ListConverter(); + static TypeConverter?, String?> $converterfilterTagsn = NullAwareTypeConverter.wrap( + $converterfilterTags, + ); + static TypeConverter, String> $converterextraData = MapConverter(); + static TypeConverter?, String?> $converterextraDatan = NullAwareTypeConverter.wrap( + $converterextraData, + ); } class ChannelEntity extends DataClass implements Insertable { @@ -312,22 +316,23 @@ class ChannelEntity extends DataClass implements Insertable { /// Map of custom channel extraData final Map? extraData; - const ChannelEntity( - {required this.id, - required this.type, - required this.cid, - this.ownCapabilities, - required this.config, - required this.frozen, - this.lastMessageAt, - required this.createdAt, - required this.updatedAt, - this.deletedAt, - required this.memberCount, - this.messageCount, - this.createdById, - this.filterTags, - this.extraData}); + const ChannelEntity({ + required this.id, + required this.type, + required this.cid, + this.ownCapabilities, + required this.config, + required this.frozen, + this.lastMessageAt, + required this.createdAt, + required this.updatedAt, + this.deletedAt, + required this.memberCount, + this.messageCount, + this.createdById, + this.filterTags, + this.extraData, + }); @override Map toColumns(bool nullToAbsent) { final map = {}; @@ -335,12 +340,10 @@ class ChannelEntity extends DataClass implements Insertable { map['type'] = Variable(type); map['cid'] = Variable(cid); if (!nullToAbsent || ownCapabilities != null) { - map['own_capabilities'] = Variable( - $ChannelsTable.$converterownCapabilitiesn.toSql(ownCapabilities)); + map['own_capabilities'] = Variable($ChannelsTable.$converterownCapabilitiesn.toSql(ownCapabilities)); } { - map['config'] = - Variable($ChannelsTable.$converterconfig.toSql(config)); + map['config'] = Variable($ChannelsTable.$converterconfig.toSql(config)); } map['frozen'] = Variable(frozen); if (!nullToAbsent || lastMessageAt != null) { @@ -359,25 +362,21 @@ class ChannelEntity extends DataClass implements Insertable { map['created_by_id'] = Variable(createdById); } if (!nullToAbsent || filterTags != null) { - map['filter_tags'] = Variable( - $ChannelsTable.$converterfilterTagsn.toSql(filterTags)); + map['filter_tags'] = Variable($ChannelsTable.$converterfilterTagsn.toSql(filterTags)); } if (!nullToAbsent || extraData != null) { - map['extra_data'] = Variable( - $ChannelsTable.$converterextraDatan.toSql(extraData)); + map['extra_data'] = Variable($ChannelsTable.$converterextraDatan.toSql(extraData)); } return map; } - factory ChannelEntity.fromJson(Map json, - {ValueSerializer? serializer}) { + factory ChannelEntity.fromJson(Map json, {ValueSerializer? serializer}) { serializer ??= driftRuntimeOptions.defaultSerializer; return ChannelEntity( id: serializer.fromJson(json['id']), type: serializer.fromJson(json['type']), cid: serializer.fromJson(json['cid']), - ownCapabilities: - serializer.fromJson?>(json['ownCapabilities']), + ownCapabilities: serializer.fromJson?>(json['ownCapabilities']), config: serializer.fromJson>(json['config']), frozen: serializer.fromJson(json['frozen']), lastMessageAt: serializer.fromJson(json['lastMessageAt']), @@ -413,68 +412,55 @@ class ChannelEntity extends DataClass implements Insertable { }; } - ChannelEntity copyWith( - {String? id, - String? type, - String? cid, - Value?> ownCapabilities = const Value.absent(), - Map? config, - bool? frozen, - Value lastMessageAt = const Value.absent(), - DateTime? createdAt, - DateTime? updatedAt, - Value deletedAt = const Value.absent(), - int? memberCount, - Value messageCount = const Value.absent(), - Value createdById = const Value.absent(), - Value?> filterTags = const Value.absent(), - Value?> extraData = const Value.absent()}) => - ChannelEntity( - id: id ?? this.id, - type: type ?? this.type, - cid: cid ?? this.cid, - ownCapabilities: ownCapabilities.present - ? ownCapabilities.value - : this.ownCapabilities, - config: config ?? this.config, - frozen: frozen ?? this.frozen, - lastMessageAt: - lastMessageAt.present ? lastMessageAt.value : this.lastMessageAt, - createdAt: createdAt ?? this.createdAt, - updatedAt: updatedAt ?? this.updatedAt, - deletedAt: deletedAt.present ? deletedAt.value : this.deletedAt, - memberCount: memberCount ?? this.memberCount, - messageCount: - messageCount.present ? messageCount.value : this.messageCount, - createdById: createdById.present ? createdById.value : this.createdById, - filterTags: filterTags.present ? filterTags.value : this.filterTags, - extraData: extraData.present ? extraData.value : this.extraData, - ); + ChannelEntity copyWith({ + String? id, + String? type, + String? cid, + Value?> ownCapabilities = const Value.absent(), + Map? config, + bool? frozen, + Value lastMessageAt = const Value.absent(), + DateTime? createdAt, + DateTime? updatedAt, + Value deletedAt = const Value.absent(), + int? memberCount, + Value messageCount = const Value.absent(), + Value createdById = const Value.absent(), + Value?> filterTags = const Value.absent(), + Value?> extraData = const Value.absent(), + }) => ChannelEntity( + id: id ?? this.id, + type: type ?? this.type, + cid: cid ?? this.cid, + ownCapabilities: ownCapabilities.present ? ownCapabilities.value : this.ownCapabilities, + config: config ?? this.config, + frozen: frozen ?? this.frozen, + lastMessageAt: lastMessageAt.present ? lastMessageAt.value : this.lastMessageAt, + createdAt: createdAt ?? this.createdAt, + updatedAt: updatedAt ?? this.updatedAt, + deletedAt: deletedAt.present ? deletedAt.value : this.deletedAt, + memberCount: memberCount ?? this.memberCount, + messageCount: messageCount.present ? messageCount.value : this.messageCount, + createdById: createdById.present ? createdById.value : this.createdById, + filterTags: filterTags.present ? filterTags.value : this.filterTags, + extraData: extraData.present ? extraData.value : this.extraData, + ); ChannelEntity copyWithCompanion(ChannelsCompanion data) { return ChannelEntity( id: data.id.present ? data.id.value : this.id, type: data.type.present ? data.type.value : this.type, cid: data.cid.present ? data.cid.value : this.cid, - ownCapabilities: data.ownCapabilities.present - ? data.ownCapabilities.value - : this.ownCapabilities, + ownCapabilities: data.ownCapabilities.present ? data.ownCapabilities.value : this.ownCapabilities, config: data.config.present ? data.config.value : this.config, frozen: data.frozen.present ? data.frozen.value : this.frozen, - lastMessageAt: data.lastMessageAt.present - ? data.lastMessageAt.value - : this.lastMessageAt, + lastMessageAt: data.lastMessageAt.present ? data.lastMessageAt.value : this.lastMessageAt, createdAt: data.createdAt.present ? data.createdAt.value : this.createdAt, updatedAt: data.updatedAt.present ? data.updatedAt.value : this.updatedAt, deletedAt: data.deletedAt.present ? data.deletedAt.value : this.deletedAt, - memberCount: - data.memberCount.present ? data.memberCount.value : this.memberCount, - messageCount: data.messageCount.present - ? data.messageCount.value - : this.messageCount, - createdById: - data.createdById.present ? data.createdById.value : this.createdById, - filterTags: - data.filterTags.present ? data.filterTags.value : this.filterTags, + memberCount: data.memberCount.present ? data.memberCount.value : this.memberCount, + messageCount: data.messageCount.present ? data.messageCount.value : this.messageCount, + createdById: data.createdById.present ? data.createdById.value : this.createdById, + filterTags: data.filterTags.present ? data.filterTags.value : this.filterTags, extraData: data.extraData.present ? data.extraData.value : this.extraData, ); } @@ -503,21 +489,22 @@ class ChannelEntity extends DataClass implements Insertable { @override int get hashCode => Object.hash( - id, - type, - cid, - ownCapabilities, - config, - frozen, - lastMessageAt, - createdAt, - updatedAt, - deletedAt, - memberCount, - messageCount, - createdById, - filterTags, - extraData); + id, + type, + cid, + ownCapabilities, + config, + frozen, + lastMessageAt, + createdAt, + updatedAt, + deletedAt, + memberCount, + messageCount, + createdById, + filterTags, + extraData, + ); @override bool operator ==(Object other) => identical(this, other) || @@ -591,10 +578,10 @@ class ChannelsCompanion extends UpdateCompanion { this.filterTags = const Value.absent(), this.extraData = const Value.absent(), this.rowid = const Value.absent(), - }) : id = Value(id), - type = Value(type), - cid = Value(cid), - config = Value(config); + }) : id = Value(id), + type = Value(type), + cid = Value(cid), + config = Value(config); static Insertable custom({ Expression? id, Expression? type, @@ -633,23 +620,24 @@ class ChannelsCompanion extends UpdateCompanion { }); } - ChannelsCompanion copyWith( - {Value? id, - Value? type, - Value? cid, - Value?>? ownCapabilities, - Value>? config, - Value? frozen, - Value? lastMessageAt, - Value? createdAt, - Value? updatedAt, - Value? deletedAt, - Value? memberCount, - Value? messageCount, - Value? createdById, - Value?>? filterTags, - Value?>? extraData, - Value? rowid}) { + ChannelsCompanion copyWith({ + Value? id, + Value? type, + Value? cid, + Value?>? ownCapabilities, + Value>? config, + Value? frozen, + Value? lastMessageAt, + Value? createdAt, + Value? updatedAt, + Value? deletedAt, + Value? memberCount, + Value? messageCount, + Value? createdById, + Value?>? filterTags, + Value?>? extraData, + Value? rowid, + }) { return ChannelsCompanion( id: id ?? this.id, type: type ?? this.type, @@ -683,13 +671,12 @@ class ChannelsCompanion extends UpdateCompanion { map['cid'] = Variable(cid.value); } if (ownCapabilities.present) { - map['own_capabilities'] = Variable($ChannelsTable - .$converterownCapabilitiesn - .toSql(ownCapabilities.value)); + map['own_capabilities'] = Variable( + $ChannelsTable.$converterownCapabilitiesn.toSql(ownCapabilities.value), + ); } if (config.present) { - map['config'] = - Variable($ChannelsTable.$converterconfig.toSql(config.value)); + map['config'] = Variable($ChannelsTable.$converterconfig.toSql(config.value)); } if (frozen.present) { map['frozen'] = Variable(frozen.value); @@ -716,12 +703,10 @@ class ChannelsCompanion extends UpdateCompanion { map['created_by_id'] = Variable(createdById.value); } if (filterTags.present) { - map['filter_tags'] = Variable( - $ChannelsTable.$converterfilterTagsn.toSql(filterTags.value)); + map['filter_tags'] = Variable($ChannelsTable.$converterfilterTagsn.toSql(filterTags.value)); } if (extraData.present) { - map['extra_data'] = Variable( - $ChannelsTable.$converterextraDatan.toSql(extraData.value)); + map['extra_data'] = Variable($ChannelsTable.$converterextraDatan.toSql(extraData.value)); } if (rowid.present) { map['rowid'] = Variable(rowid.value); @@ -753,8 +738,7 @@ class ChannelsCompanion extends UpdateCompanion { } } -class $MessagesTable extends Messages - with TableInfo<$MessagesTable, MessageEntity> { +class $MessagesTable extends Messages with TableInfo<$MessagesTable, MessageEntity> { @override final GeneratedDatabase attachedDatabase; final String? _alias; @@ -762,252 +746,336 @@ class $MessagesTable extends Messages static const VerificationMeta _idMeta = const VerificationMeta('id'); @override late final GeneratedColumn id = GeneratedColumn( - 'id', aliasedName, false, - type: DriftSqlType.string, requiredDuringInsert: true); - static const VerificationMeta _messageTextMeta = - const VerificationMeta('messageText'); + 'id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + static const VerificationMeta _messageTextMeta = const VerificationMeta('messageText'); @override late final GeneratedColumn messageText = GeneratedColumn( - 'message_text', aliasedName, true, - type: DriftSqlType.string, requiredDuringInsert: false); - static const VerificationMeta _attachmentsMeta = - const VerificationMeta('attachments'); - @override - late final GeneratedColumnWithTypeConverter, String> - attachments = GeneratedColumn('attachments', aliasedName, false, - type: DriftSqlType.string, requiredDuringInsert: true) - .withConverter>($MessagesTable.$converterattachments); + 'message_text', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); + @override + late final GeneratedColumnWithTypeConverter, String> attachments = GeneratedColumn( + 'attachments', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ).withConverter>($MessagesTable.$converterattachments); static const VerificationMeta _stateMeta = const VerificationMeta('state'); @override late final GeneratedColumn state = GeneratedColumn( - 'state', aliasedName, false, - type: DriftSqlType.string, requiredDuringInsert: true); + 'state', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); static const VerificationMeta _typeMeta = const VerificationMeta('type'); @override late final GeneratedColumn type = GeneratedColumn( - 'type', aliasedName, false, - type: DriftSqlType.string, - requiredDuringInsert: false, - defaultValue: const Constant('regular')); - static const VerificationMeta _mentionedUsersMeta = - const VerificationMeta('mentionedUsers'); - @override - late final GeneratedColumnWithTypeConverter, String> - mentionedUsers = GeneratedColumn( - 'mentioned_users', aliasedName, false, - type: DriftSqlType.string, requiredDuringInsert: true) - .withConverter>($MessagesTable.$convertermentionedUsers); - static const VerificationMeta _reactionGroupsMeta = - const VerificationMeta('reactionGroups'); - @override - late final GeneratedColumnWithTypeConverter?, - String> reactionGroups = GeneratedColumn( - 'reaction_groups', aliasedName, true, - type: DriftSqlType.string, requiredDuringInsert: false) - .withConverter?>( - $MessagesTable.$converterreactionGroupsn); - static const VerificationMeta _parentIdMeta = - const VerificationMeta('parentId'); + 'type', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: false, + defaultValue: const Constant('regular'), + ); + @override + late final GeneratedColumnWithTypeConverter, String> mentionedUsers = GeneratedColumn( + 'mentioned_users', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ).withConverter>($MessagesTable.$convertermentionedUsers); + @override + late final GeneratedColumnWithTypeConverter?, String> reactionGroups = + GeneratedColumn( + 'reaction_groups', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ).withConverter?>($MessagesTable.$converterreactionGroupsn); + static const VerificationMeta _parentIdMeta = const VerificationMeta('parentId'); @override late final GeneratedColumn parentId = GeneratedColumn( - 'parent_id', aliasedName, true, - type: DriftSqlType.string, requiredDuringInsert: false); - static const VerificationMeta _quotedMessageIdMeta = - const VerificationMeta('quotedMessageId'); + 'parent_id', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); + static const VerificationMeta _quotedMessageIdMeta = const VerificationMeta('quotedMessageId'); @override late final GeneratedColumn quotedMessageId = GeneratedColumn( - 'quoted_message_id', aliasedName, true, - type: DriftSqlType.string, requiredDuringInsert: false); + 'quoted_message_id', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); static const VerificationMeta _pollIdMeta = const VerificationMeta('pollId'); @override late final GeneratedColumn pollId = GeneratedColumn( - 'poll_id', aliasedName, true, - type: DriftSqlType.string, requiredDuringInsert: false); - static const VerificationMeta _replyCountMeta = - const VerificationMeta('replyCount'); + 'poll_id', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); + static const VerificationMeta _replyCountMeta = const VerificationMeta('replyCount'); @override late final GeneratedColumn replyCount = GeneratedColumn( - 'reply_count', aliasedName, true, - type: DriftSqlType.int, requiredDuringInsert: false); - static const VerificationMeta _showInChannelMeta = - const VerificationMeta('showInChannel'); + 'reply_count', + aliasedName, + true, + type: DriftSqlType.int, + requiredDuringInsert: false, + ); + static const VerificationMeta _showInChannelMeta = const VerificationMeta('showInChannel'); @override late final GeneratedColumn showInChannel = GeneratedColumn( - 'show_in_channel', aliasedName, true, - type: DriftSqlType.bool, - requiredDuringInsert: false, - defaultConstraints: GeneratedColumn.constraintIsAlways( - 'CHECK ("show_in_channel" IN (0, 1))')); - static const VerificationMeta _shadowedMeta = - const VerificationMeta('shadowed'); + 'show_in_channel', + aliasedName, + true, + type: DriftSqlType.bool, + requiredDuringInsert: false, + defaultConstraints: GeneratedColumn.constraintIsAlways('CHECK ("show_in_channel" IN (0, 1))'), + ); + static const VerificationMeta _shadowedMeta = const VerificationMeta('shadowed'); @override late final GeneratedColumn shadowed = GeneratedColumn( - 'shadowed', aliasedName, false, - type: DriftSqlType.bool, - requiredDuringInsert: false, - defaultConstraints: - GeneratedColumn.constraintIsAlways('CHECK ("shadowed" IN (0, 1))'), - defaultValue: const Constant(false)); - static const VerificationMeta _commandMeta = - const VerificationMeta('command'); + 'shadowed', + aliasedName, + false, + type: DriftSqlType.bool, + requiredDuringInsert: false, + defaultConstraints: GeneratedColumn.constraintIsAlways('CHECK ("shadowed" IN (0, 1))'), + defaultValue: const Constant(false), + ); + static const VerificationMeta _commandMeta = const VerificationMeta('command'); @override late final GeneratedColumn command = GeneratedColumn( - 'command', aliasedName, true, - type: DriftSqlType.string, requiredDuringInsert: false); - static const VerificationMeta _localCreatedAtMeta = - const VerificationMeta('localCreatedAt'); - @override - late final GeneratedColumn localCreatedAt = - GeneratedColumn('local_created_at', aliasedName, true, - type: DriftSqlType.dateTime, requiredDuringInsert: false); - static const VerificationMeta _remoteCreatedAtMeta = - const VerificationMeta('remoteCreatedAt'); - @override - late final GeneratedColumn remoteCreatedAt = - GeneratedColumn('remote_created_at', aliasedName, true, - type: DriftSqlType.dateTime, requiredDuringInsert: false); - static const VerificationMeta _localUpdatedAtMeta = - const VerificationMeta('localUpdatedAt'); - @override - late final GeneratedColumn localUpdatedAt = - GeneratedColumn('local_updated_at', aliasedName, true, - type: DriftSqlType.dateTime, requiredDuringInsert: false); - static const VerificationMeta _remoteUpdatedAtMeta = - const VerificationMeta('remoteUpdatedAt'); - @override - late final GeneratedColumn remoteUpdatedAt = - GeneratedColumn('remote_updated_at', aliasedName, true, - type: DriftSqlType.dateTime, requiredDuringInsert: false); - static const VerificationMeta _localDeletedAtMeta = - const VerificationMeta('localDeletedAt'); - @override - late final GeneratedColumn localDeletedAt = - GeneratedColumn('local_deleted_at', aliasedName, true, - type: DriftSqlType.dateTime, requiredDuringInsert: false); - static const VerificationMeta _remoteDeletedAtMeta = - const VerificationMeta('remoteDeletedAt'); - @override - late final GeneratedColumn remoteDeletedAt = - GeneratedColumn('remote_deleted_at', aliasedName, true, - type: DriftSqlType.dateTime, requiredDuringInsert: false); - static const VerificationMeta _messageTextUpdatedAtMeta = - const VerificationMeta('messageTextUpdatedAt'); - @override - late final GeneratedColumn messageTextUpdatedAt = - GeneratedColumn('message_text_updated_at', aliasedName, true, - type: DriftSqlType.dateTime, requiredDuringInsert: false); + 'command', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); + static const VerificationMeta _localCreatedAtMeta = const VerificationMeta('localCreatedAt'); + @override + late final GeneratedColumn localCreatedAt = GeneratedColumn( + 'local_created_at', + aliasedName, + true, + type: DriftSqlType.dateTime, + requiredDuringInsert: false, + ); + static const VerificationMeta _remoteCreatedAtMeta = const VerificationMeta('remoteCreatedAt'); + @override + late final GeneratedColumn remoteCreatedAt = GeneratedColumn( + 'remote_created_at', + aliasedName, + true, + type: DriftSqlType.dateTime, + requiredDuringInsert: false, + ); + static const VerificationMeta _localUpdatedAtMeta = const VerificationMeta('localUpdatedAt'); + @override + late final GeneratedColumn localUpdatedAt = GeneratedColumn( + 'local_updated_at', + aliasedName, + true, + type: DriftSqlType.dateTime, + requiredDuringInsert: false, + ); + static const VerificationMeta _remoteUpdatedAtMeta = const VerificationMeta('remoteUpdatedAt'); + @override + late final GeneratedColumn remoteUpdatedAt = GeneratedColumn( + 'remote_updated_at', + aliasedName, + true, + type: DriftSqlType.dateTime, + requiredDuringInsert: false, + ); + static const VerificationMeta _localDeletedAtMeta = const VerificationMeta('localDeletedAt'); + @override + late final GeneratedColumn localDeletedAt = GeneratedColumn( + 'local_deleted_at', + aliasedName, + true, + type: DriftSqlType.dateTime, + requiredDuringInsert: false, + ); + static const VerificationMeta _remoteDeletedAtMeta = const VerificationMeta('remoteDeletedAt'); + @override + late final GeneratedColumn remoteDeletedAt = GeneratedColumn( + 'remote_deleted_at', + aliasedName, + true, + type: DriftSqlType.dateTime, + requiredDuringInsert: false, + ); + static const VerificationMeta _deletedForMeMeta = const VerificationMeta('deletedForMe'); + @override + late final GeneratedColumn deletedForMe = GeneratedColumn( + 'deleted_for_me', + aliasedName, + true, + type: DriftSqlType.bool, + requiredDuringInsert: false, + defaultConstraints: GeneratedColumn.constraintIsAlways('CHECK ("deleted_for_me" IN (0, 1))'), + ); + static const VerificationMeta _messageTextUpdatedAtMeta = const VerificationMeta('messageTextUpdatedAt'); + @override + late final GeneratedColumn messageTextUpdatedAt = GeneratedColumn( + 'message_text_updated_at', + aliasedName, + true, + type: DriftSqlType.dateTime, + requiredDuringInsert: false, + ); static const VerificationMeta _userIdMeta = const VerificationMeta('userId'); @override late final GeneratedColumn userId = GeneratedColumn( - 'user_id', aliasedName, true, - type: DriftSqlType.string, requiredDuringInsert: false); - static const VerificationMeta _channelRoleMeta = - const VerificationMeta('channelRole'); + 'user_id', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); + static const VerificationMeta _channelRoleMeta = const VerificationMeta('channelRole'); @override late final GeneratedColumn channelRole = GeneratedColumn( - 'channel_role', aliasedName, true, - type: DriftSqlType.string, requiredDuringInsert: false); + 'channel_role', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); static const VerificationMeta _pinnedMeta = const VerificationMeta('pinned'); @override late final GeneratedColumn pinned = GeneratedColumn( - 'pinned', aliasedName, false, - type: DriftSqlType.bool, - requiredDuringInsert: false, - defaultConstraints: - GeneratedColumn.constraintIsAlways('CHECK ("pinned" IN (0, 1))'), - defaultValue: const Constant(false)); - static const VerificationMeta _pinnedAtMeta = - const VerificationMeta('pinnedAt'); + 'pinned', + aliasedName, + false, + type: DriftSqlType.bool, + requiredDuringInsert: false, + defaultConstraints: GeneratedColumn.constraintIsAlways('CHECK ("pinned" IN (0, 1))'), + defaultValue: const Constant(false), + ); + static const VerificationMeta _pinnedAtMeta = const VerificationMeta('pinnedAt'); @override late final GeneratedColumn pinnedAt = GeneratedColumn( - 'pinned_at', aliasedName, true, - type: DriftSqlType.dateTime, requiredDuringInsert: false); - static const VerificationMeta _pinExpiresMeta = - const VerificationMeta('pinExpires'); + 'pinned_at', + aliasedName, + true, + type: DriftSqlType.dateTime, + requiredDuringInsert: false, + ); + static const VerificationMeta _pinExpiresMeta = const VerificationMeta('pinExpires'); @override late final GeneratedColumn pinExpires = GeneratedColumn( - 'pin_expires', aliasedName, true, - type: DriftSqlType.dateTime, requiredDuringInsert: false); - static const VerificationMeta _pinnedByUserIdMeta = - const VerificationMeta('pinnedByUserId'); + 'pin_expires', + aliasedName, + true, + type: DriftSqlType.dateTime, + requiredDuringInsert: false, + ); + static const VerificationMeta _pinnedByUserIdMeta = const VerificationMeta('pinnedByUserId'); @override late final GeneratedColumn pinnedByUserId = GeneratedColumn( - 'pinned_by_user_id', aliasedName, true, - type: DriftSqlType.string, requiredDuringInsert: false); - static const VerificationMeta _channelCidMeta = - const VerificationMeta('channelCid'); + 'pinned_by_user_id', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); + static const VerificationMeta _channelCidMeta = const VerificationMeta('channelCid'); @override late final GeneratedColumn channelCid = GeneratedColumn( - 'channel_cid', aliasedName, false, - type: DriftSqlType.string, - requiredDuringInsert: true, - defaultConstraints: GeneratedColumn.constraintIsAlways( - 'REFERENCES channels (cid) ON DELETE CASCADE')); - static const VerificationMeta _i18nMeta = const VerificationMeta('i18n'); - @override - late final GeneratedColumnWithTypeConverter?, String> - i18n = GeneratedColumn('i18n', aliasedName, true, - type: DriftSqlType.string, requiredDuringInsert: false) - .withConverter?>($MessagesTable.$converteri18n); - static const VerificationMeta _restrictedVisibilityMeta = - const VerificationMeta('restrictedVisibility'); - @override - late final GeneratedColumnWithTypeConverter?, String> - restrictedVisibility = GeneratedColumn( - 'restricted_visibility', aliasedName, true, - type: DriftSqlType.string, requiredDuringInsert: false) - .withConverter?>( - $MessagesTable.$converterrestrictedVisibilityn); - static const VerificationMeta _extraDataMeta = - const VerificationMeta('extraData'); - @override - late final GeneratedColumnWithTypeConverter?, String> - extraData = GeneratedColumn('extra_data', aliasedName, true, - type: DriftSqlType.string, requiredDuringInsert: false) - .withConverter?>( - $MessagesTable.$converterextraDatan); + 'channel_cid', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + defaultConstraints: GeneratedColumn.constraintIsAlways('REFERENCES channels (cid) ON DELETE CASCADE'), + ); + @override + late final GeneratedColumnWithTypeConverter?, String> i18n = GeneratedColumn( + 'i18n', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ).withConverter?>($MessagesTable.$converteri18n); + @override + late final GeneratedColumnWithTypeConverter?, String> restrictedVisibility = GeneratedColumn( + 'restricted_visibility', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ).withConverter?>($MessagesTable.$converterrestrictedVisibilityn); + @override + late final GeneratedColumnWithTypeConverter?, String> extraData = GeneratedColumn( + 'extra_data', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ).withConverter?>($MessagesTable.$converterextraDatan); @override List get $columns => [ - id, - messageText, - attachments, - state, - type, - mentionedUsers, - reactionGroups, - parentId, - quotedMessageId, - pollId, - replyCount, - showInChannel, - shadowed, - command, - localCreatedAt, - remoteCreatedAt, - localUpdatedAt, - remoteUpdatedAt, - localDeletedAt, - remoteDeletedAt, - messageTextUpdatedAt, - userId, - channelRole, - pinned, - pinnedAt, - pinExpires, - pinnedByUserId, - channelCid, - i18n, - restrictedVisibility, - extraData - ]; + id, + messageText, + attachments, + state, + type, + mentionedUsers, + reactionGroups, + parentId, + quotedMessageId, + pollId, + replyCount, + showInChannel, + shadowed, + command, + localCreatedAt, + remoteCreatedAt, + localUpdatedAt, + remoteUpdatedAt, + localDeletedAt, + remoteDeletedAt, + deletedForMe, + messageTextUpdatedAt, + userId, + channelRole, + pinned, + pinnedAt, + pinExpires, + pinnedByUserId, + channelCid, + i18n, + restrictedVisibility, + extraData, + ]; @override String get aliasedName => _alias ?? actualTableName; @override String get actualTableName => $name; static const String $name = 'messages'; @override - VerificationContext validateIntegrity(Insertable instance, - {bool isInserting = false}) { + VerificationContext validateIntegrity(Insertable instance, {bool isInserting = false}) { final context = VerificationContext(); final data = instance.toColumns(true); if (data.containsKey('id')) { @@ -1016,142 +1084,114 @@ class $MessagesTable extends Messages context.missing(_idMeta); } if (data.containsKey('message_text')) { - context.handle( - _messageTextMeta, - messageText.isAcceptableOrUnknown( - data['message_text']!, _messageTextMeta)); + context.handle(_messageTextMeta, messageText.isAcceptableOrUnknown(data['message_text']!, _messageTextMeta)); } - context.handle(_attachmentsMeta, const VerificationResult.success()); if (data.containsKey('state')) { - context.handle( - _stateMeta, state.isAcceptableOrUnknown(data['state']!, _stateMeta)); + context.handle(_stateMeta, state.isAcceptableOrUnknown(data['state']!, _stateMeta)); } else if (isInserting) { context.missing(_stateMeta); } if (data.containsKey('type')) { - context.handle( - _typeMeta, type.isAcceptableOrUnknown(data['type']!, _typeMeta)); + context.handle(_typeMeta, type.isAcceptableOrUnknown(data['type']!, _typeMeta)); } - context.handle(_mentionedUsersMeta, const VerificationResult.success()); - context.handle(_reactionGroupsMeta, const VerificationResult.success()); if (data.containsKey('parent_id')) { - context.handle(_parentIdMeta, - parentId.isAcceptableOrUnknown(data['parent_id']!, _parentIdMeta)); + context.handle(_parentIdMeta, parentId.isAcceptableOrUnknown(data['parent_id']!, _parentIdMeta)); } if (data.containsKey('quoted_message_id')) { context.handle( - _quotedMessageIdMeta, - quotedMessageId.isAcceptableOrUnknown( - data['quoted_message_id']!, _quotedMessageIdMeta)); + _quotedMessageIdMeta, + quotedMessageId.isAcceptableOrUnknown(data['quoted_message_id']!, _quotedMessageIdMeta), + ); } if (data.containsKey('poll_id')) { - context.handle(_pollIdMeta, - pollId.isAcceptableOrUnknown(data['poll_id']!, _pollIdMeta)); + context.handle(_pollIdMeta, pollId.isAcceptableOrUnknown(data['poll_id']!, _pollIdMeta)); } if (data.containsKey('reply_count')) { - context.handle( - _replyCountMeta, - replyCount.isAcceptableOrUnknown( - data['reply_count']!, _replyCountMeta)); + context.handle(_replyCountMeta, replyCount.isAcceptableOrUnknown(data['reply_count']!, _replyCountMeta)); } if (data.containsKey('show_in_channel')) { context.handle( - _showInChannelMeta, - showInChannel.isAcceptableOrUnknown( - data['show_in_channel']!, _showInChannelMeta)); + _showInChannelMeta, + showInChannel.isAcceptableOrUnknown(data['show_in_channel']!, _showInChannelMeta), + ); } if (data.containsKey('shadowed')) { - context.handle(_shadowedMeta, - shadowed.isAcceptableOrUnknown(data['shadowed']!, _shadowedMeta)); + context.handle(_shadowedMeta, shadowed.isAcceptableOrUnknown(data['shadowed']!, _shadowedMeta)); } if (data.containsKey('command')) { - context.handle(_commandMeta, - command.isAcceptableOrUnknown(data['command']!, _commandMeta)); + context.handle(_commandMeta, command.isAcceptableOrUnknown(data['command']!, _commandMeta)); } if (data.containsKey('local_created_at')) { context.handle( - _localCreatedAtMeta, - localCreatedAt.isAcceptableOrUnknown( - data['local_created_at']!, _localCreatedAtMeta)); + _localCreatedAtMeta, + localCreatedAt.isAcceptableOrUnknown(data['local_created_at']!, _localCreatedAtMeta), + ); } if (data.containsKey('remote_created_at')) { context.handle( - _remoteCreatedAtMeta, - remoteCreatedAt.isAcceptableOrUnknown( - data['remote_created_at']!, _remoteCreatedAtMeta)); + _remoteCreatedAtMeta, + remoteCreatedAt.isAcceptableOrUnknown(data['remote_created_at']!, _remoteCreatedAtMeta), + ); } if (data.containsKey('local_updated_at')) { context.handle( - _localUpdatedAtMeta, - localUpdatedAt.isAcceptableOrUnknown( - data['local_updated_at']!, _localUpdatedAtMeta)); + _localUpdatedAtMeta, + localUpdatedAt.isAcceptableOrUnknown(data['local_updated_at']!, _localUpdatedAtMeta), + ); } if (data.containsKey('remote_updated_at')) { context.handle( - _remoteUpdatedAtMeta, - remoteUpdatedAt.isAcceptableOrUnknown( - data['remote_updated_at']!, _remoteUpdatedAtMeta)); + _remoteUpdatedAtMeta, + remoteUpdatedAt.isAcceptableOrUnknown(data['remote_updated_at']!, _remoteUpdatedAtMeta), + ); } if (data.containsKey('local_deleted_at')) { context.handle( - _localDeletedAtMeta, - localDeletedAt.isAcceptableOrUnknown( - data['local_deleted_at']!, _localDeletedAtMeta)); + _localDeletedAtMeta, + localDeletedAt.isAcceptableOrUnknown(data['local_deleted_at']!, _localDeletedAtMeta), + ); } if (data.containsKey('remote_deleted_at')) { context.handle( - _remoteDeletedAtMeta, - remoteDeletedAt.isAcceptableOrUnknown( - data['remote_deleted_at']!, _remoteDeletedAtMeta)); + _remoteDeletedAtMeta, + remoteDeletedAt.isAcceptableOrUnknown(data['remote_deleted_at']!, _remoteDeletedAtMeta), + ); + } + if (data.containsKey('deleted_for_me')) { + context.handle(_deletedForMeMeta, deletedForMe.isAcceptableOrUnknown(data['deleted_for_me']!, _deletedForMeMeta)); } if (data.containsKey('message_text_updated_at')) { context.handle( - _messageTextUpdatedAtMeta, - messageTextUpdatedAt.isAcceptableOrUnknown( - data['message_text_updated_at']!, _messageTextUpdatedAtMeta)); + _messageTextUpdatedAtMeta, + messageTextUpdatedAt.isAcceptableOrUnknown(data['message_text_updated_at']!, _messageTextUpdatedAtMeta), + ); } if (data.containsKey('user_id')) { - context.handle(_userIdMeta, - userId.isAcceptableOrUnknown(data['user_id']!, _userIdMeta)); + context.handle(_userIdMeta, userId.isAcceptableOrUnknown(data['user_id']!, _userIdMeta)); } if (data.containsKey('channel_role')) { - context.handle( - _channelRoleMeta, - channelRole.isAcceptableOrUnknown( - data['channel_role']!, _channelRoleMeta)); + context.handle(_channelRoleMeta, channelRole.isAcceptableOrUnknown(data['channel_role']!, _channelRoleMeta)); } if (data.containsKey('pinned')) { - context.handle(_pinnedMeta, - pinned.isAcceptableOrUnknown(data['pinned']!, _pinnedMeta)); + context.handle(_pinnedMeta, pinned.isAcceptableOrUnknown(data['pinned']!, _pinnedMeta)); } if (data.containsKey('pinned_at')) { - context.handle(_pinnedAtMeta, - pinnedAt.isAcceptableOrUnknown(data['pinned_at']!, _pinnedAtMeta)); + context.handle(_pinnedAtMeta, pinnedAt.isAcceptableOrUnknown(data['pinned_at']!, _pinnedAtMeta)); } if (data.containsKey('pin_expires')) { - context.handle( - _pinExpiresMeta, - pinExpires.isAcceptableOrUnknown( - data['pin_expires']!, _pinExpiresMeta)); + context.handle(_pinExpiresMeta, pinExpires.isAcceptableOrUnknown(data['pin_expires']!, _pinExpiresMeta)); } if (data.containsKey('pinned_by_user_id')) { context.handle( - _pinnedByUserIdMeta, - pinnedByUserId.isAcceptableOrUnknown( - data['pinned_by_user_id']!, _pinnedByUserIdMeta)); + _pinnedByUserIdMeta, + pinnedByUserId.isAcceptableOrUnknown(data['pinned_by_user_id']!, _pinnedByUserIdMeta), + ); } if (data.containsKey('channel_cid')) { - context.handle( - _channelCidMeta, - channelCid.isAcceptableOrUnknown( - data['channel_cid']!, _channelCidMeta)); + context.handle(_channelCidMeta, channelCid.isAcceptableOrUnknown(data['channel_cid']!, _channelCidMeta)); } else if (isInserting) { context.missing(_channelCidMeta); } - context.handle(_i18nMeta, const VerificationResult.success()); - context.handle( - _restrictedVisibilityMeta, const VerificationResult.success()); - context.handle(_extraDataMeta, const VerificationResult.success()); return context; } @@ -1161,74 +1201,77 @@ class $MessagesTable extends Messages MessageEntity map(Map data, {String? tablePrefix}) { final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; return MessageEntity( - id: attachedDatabase.typeMapping - .read(DriftSqlType.string, data['${effectivePrefix}id'])!, - messageText: attachedDatabase.typeMapping - .read(DriftSqlType.string, data['${effectivePrefix}message_text']), - attachments: $MessagesTable.$converterattachments.fromSql(attachedDatabase - .typeMapping - .read(DriftSqlType.string, data['${effectivePrefix}attachments'])!), - state: attachedDatabase.typeMapping - .read(DriftSqlType.string, data['${effectivePrefix}state'])!, - type: attachedDatabase.typeMapping - .read(DriftSqlType.string, data['${effectivePrefix}type'])!, + id: attachedDatabase.typeMapping.read(DriftSqlType.string, data['${effectivePrefix}id'])!, + messageText: attachedDatabase.typeMapping.read(DriftSqlType.string, data['${effectivePrefix}message_text']), + attachments: $MessagesTable.$converterattachments.fromSql( + attachedDatabase.typeMapping.read(DriftSqlType.string, data['${effectivePrefix}attachments'])!, + ), + state: attachedDatabase.typeMapping.read(DriftSqlType.string, data['${effectivePrefix}state'])!, + type: attachedDatabase.typeMapping.read(DriftSqlType.string, data['${effectivePrefix}type'])!, mentionedUsers: $MessagesTable.$convertermentionedUsers.fromSql( - attachedDatabase.typeMapping.read( - DriftSqlType.string, data['${effectivePrefix}mentioned_users'])!), + attachedDatabase.typeMapping.read(DriftSqlType.string, data['${effectivePrefix}mentioned_users'])!, + ), reactionGroups: $MessagesTable.$converterreactionGroupsn.fromSql( - attachedDatabase.typeMapping.read( - DriftSqlType.string, data['${effectivePrefix}reaction_groups'])), - parentId: attachedDatabase.typeMapping - .read(DriftSqlType.string, data['${effectivePrefix}parent_id']), + attachedDatabase.typeMapping.read(DriftSqlType.string, data['${effectivePrefix}reaction_groups']), + ), + parentId: attachedDatabase.typeMapping.read(DriftSqlType.string, data['${effectivePrefix}parent_id']), quotedMessageId: attachedDatabase.typeMapping.read( - DriftSqlType.string, data['${effectivePrefix}quoted_message_id']), - pollId: attachedDatabase.typeMapping - .read(DriftSqlType.string, data['${effectivePrefix}poll_id']), - replyCount: attachedDatabase.typeMapping - .read(DriftSqlType.int, data['${effectivePrefix}reply_count']), - showInChannel: attachedDatabase.typeMapping - .read(DriftSqlType.bool, data['${effectivePrefix}show_in_channel']), - shadowed: attachedDatabase.typeMapping - .read(DriftSqlType.bool, data['${effectivePrefix}shadowed'])!, - command: attachedDatabase.typeMapping - .read(DriftSqlType.string, data['${effectivePrefix}command']), + DriftSqlType.string, + data['${effectivePrefix}quoted_message_id'], + ), + pollId: attachedDatabase.typeMapping.read(DriftSqlType.string, data['${effectivePrefix}poll_id']), + replyCount: attachedDatabase.typeMapping.read(DriftSqlType.int, data['${effectivePrefix}reply_count']), + showInChannel: attachedDatabase.typeMapping.read(DriftSqlType.bool, data['${effectivePrefix}show_in_channel']), + shadowed: attachedDatabase.typeMapping.read(DriftSqlType.bool, data['${effectivePrefix}shadowed'])!, + command: attachedDatabase.typeMapping.read(DriftSqlType.string, data['${effectivePrefix}command']), localCreatedAt: attachedDatabase.typeMapping.read( - DriftSqlType.dateTime, data['${effectivePrefix}local_created_at']), + DriftSqlType.dateTime, + data['${effectivePrefix}local_created_at'], + ), remoteCreatedAt: attachedDatabase.typeMapping.read( - DriftSqlType.dateTime, data['${effectivePrefix}remote_created_at']), + DriftSqlType.dateTime, + data['${effectivePrefix}remote_created_at'], + ), localUpdatedAt: attachedDatabase.typeMapping.read( - DriftSqlType.dateTime, data['${effectivePrefix}local_updated_at']), + DriftSqlType.dateTime, + data['${effectivePrefix}local_updated_at'], + ), remoteUpdatedAt: attachedDatabase.typeMapping.read( - DriftSqlType.dateTime, data['${effectivePrefix}remote_updated_at']), + DriftSqlType.dateTime, + data['${effectivePrefix}remote_updated_at'], + ), localDeletedAt: attachedDatabase.typeMapping.read( - DriftSqlType.dateTime, data['${effectivePrefix}local_deleted_at']), + DriftSqlType.dateTime, + data['${effectivePrefix}local_deleted_at'], + ), remoteDeletedAt: attachedDatabase.typeMapping.read( - DriftSqlType.dateTime, data['${effectivePrefix}remote_deleted_at']), + DriftSqlType.dateTime, + data['${effectivePrefix}remote_deleted_at'], + ), + deletedForMe: attachedDatabase.typeMapping.read(DriftSqlType.bool, data['${effectivePrefix}deleted_for_me']), messageTextUpdatedAt: attachedDatabase.typeMapping.read( - DriftSqlType.dateTime, - data['${effectivePrefix}message_text_updated_at']), - userId: attachedDatabase.typeMapping - .read(DriftSqlType.string, data['${effectivePrefix}user_id']), - channelRole: attachedDatabase.typeMapping - .read(DriftSqlType.string, data['${effectivePrefix}channel_role']), - pinned: attachedDatabase.typeMapping - .read(DriftSqlType.bool, data['${effectivePrefix}pinned'])!, - pinnedAt: attachedDatabase.typeMapping - .read(DriftSqlType.dateTime, data['${effectivePrefix}pinned_at']), - pinExpires: attachedDatabase.typeMapping - .read(DriftSqlType.dateTime, data['${effectivePrefix}pin_expires']), + DriftSqlType.dateTime, + data['${effectivePrefix}message_text_updated_at'], + ), + userId: attachedDatabase.typeMapping.read(DriftSqlType.string, data['${effectivePrefix}user_id']), + channelRole: attachedDatabase.typeMapping.read(DriftSqlType.string, data['${effectivePrefix}channel_role']), + pinned: attachedDatabase.typeMapping.read(DriftSqlType.bool, data['${effectivePrefix}pinned'])!, + pinnedAt: attachedDatabase.typeMapping.read(DriftSqlType.dateTime, data['${effectivePrefix}pinned_at']), + pinExpires: attachedDatabase.typeMapping.read(DriftSqlType.dateTime, data['${effectivePrefix}pin_expires']), pinnedByUserId: attachedDatabase.typeMapping.read( - DriftSqlType.string, data['${effectivePrefix}pinned_by_user_id']), - channelCid: attachedDatabase.typeMapping - .read(DriftSqlType.string, data['${effectivePrefix}channel_cid'])!, - i18n: $MessagesTable.$converteri18n.fromSql(attachedDatabase.typeMapping - .read(DriftSqlType.string, data['${effectivePrefix}i18n'])), - restrictedVisibility: $MessagesTable.$converterrestrictedVisibilityn - .fromSql(attachedDatabase.typeMapping.read(DriftSqlType.string, - data['${effectivePrefix}restricted_visibility'])), - extraData: $MessagesTable.$converterextraDatan.fromSql(attachedDatabase - .typeMapping - .read(DriftSqlType.string, data['${effectivePrefix}extra_data'])), + DriftSqlType.string, + data['${effectivePrefix}pinned_by_user_id'], + ), + channelCid: attachedDatabase.typeMapping.read(DriftSqlType.string, data['${effectivePrefix}channel_cid'])!, + i18n: $MessagesTable.$converteri18n.fromSql( + attachedDatabase.typeMapping.read(DriftSqlType.string, data['${effectivePrefix}i18n']), + ), + restrictedVisibility: $MessagesTable.$converterrestrictedVisibilityn.fromSql( + attachedDatabase.typeMapping.read(DriftSqlType.string, data['${effectivePrefix}restricted_visibility']), + ), + extraData: $MessagesTable.$converterextraDatan.fromSql( + attachedDatabase.typeMapping.read(DriftSqlType.string, data['${effectivePrefix}extra_data']), + ), ); } @@ -1237,25 +1280,21 @@ class $MessagesTable extends Messages return $MessagesTable(attachedDatabase, alias); } - static TypeConverter, String> $converterattachments = - ListConverter(); - static TypeConverter, String> $convertermentionedUsers = - ListConverter(); - static TypeConverter, String> - $converterreactionGroups = ReactionGroupsConverter(); - static TypeConverter?, String?> - $converterreactionGroupsn = - NullAwareTypeConverter.wrap($converterreactionGroups); - static TypeConverter?, String?> $converteri18n = - NullableMapConverter(); - static TypeConverter, String> $converterrestrictedVisibility = - ListConverter(); - static TypeConverter?, String?> $converterrestrictedVisibilityn = - NullAwareTypeConverter.wrap($converterrestrictedVisibility); - static TypeConverter, String> $converterextraData = - MapConverter(); - static TypeConverter?, String?> $converterextraDatan = - NullAwareTypeConverter.wrap($converterextraData); + static TypeConverter, String> $converterattachments = ListConverter(); + static TypeConverter, String> $convertermentionedUsers = ListConverter(); + static TypeConverter, String> $converterreactionGroups = ReactionGroupsConverter(); + static TypeConverter?, String?> $converterreactionGroupsn = NullAwareTypeConverter.wrap( + $converterreactionGroups, + ); + static TypeConverter?, String?> $converteri18n = NullableMapConverter(); + static TypeConverter, String> $converterrestrictedVisibility = ListConverter(); + static TypeConverter?, String?> $converterrestrictedVisibilityn = NullAwareTypeConverter.wrap( + $converterrestrictedVisibility, + ); + static TypeConverter, String> $converterextraData = MapConverter(); + static TypeConverter?, String?> $converterextraDatan = NullAwareTypeConverter.wrap( + $converterextraData, + ); } class MessageEntity extends DataClass implements Insertable { @@ -1320,6 +1359,9 @@ class MessageEntity extends DataClass implements Insertable { /// The DateTime on which the message was deleted on the server. final DateTime? remoteDeletedAt; + /// Whether the message was deleted only for the current user. + final bool? deletedForMe; + /// The DateTime at which the message text was edited final DateTime? messageTextUpdatedAt; @@ -1352,38 +1394,40 @@ class MessageEntity extends DataClass implements Insertable { /// Message custom extraData final Map? extraData; - const MessageEntity( - {required this.id, - this.messageText, - required this.attachments, - required this.state, - required this.type, - required this.mentionedUsers, - this.reactionGroups, - this.parentId, - this.quotedMessageId, - this.pollId, - this.replyCount, - this.showInChannel, - required this.shadowed, - this.command, - this.localCreatedAt, - this.remoteCreatedAt, - this.localUpdatedAt, - this.remoteUpdatedAt, - this.localDeletedAt, - this.remoteDeletedAt, - this.messageTextUpdatedAt, - this.userId, - this.channelRole, - required this.pinned, - this.pinnedAt, - this.pinExpires, - this.pinnedByUserId, - required this.channelCid, - this.i18n, - this.restrictedVisibility, - this.extraData}); + const MessageEntity({ + required this.id, + this.messageText, + required this.attachments, + required this.state, + required this.type, + required this.mentionedUsers, + this.reactionGroups, + this.parentId, + this.quotedMessageId, + this.pollId, + this.replyCount, + this.showInChannel, + required this.shadowed, + this.command, + this.localCreatedAt, + this.remoteCreatedAt, + this.localUpdatedAt, + this.remoteUpdatedAt, + this.localDeletedAt, + this.remoteDeletedAt, + this.deletedForMe, + this.messageTextUpdatedAt, + this.userId, + this.channelRole, + required this.pinned, + this.pinnedAt, + this.pinExpires, + this.pinnedByUserId, + required this.channelCid, + this.i18n, + this.restrictedVisibility, + this.extraData, + }); @override Map toColumns(bool nullToAbsent) { final map = {}; @@ -1392,18 +1436,15 @@ class MessageEntity extends DataClass implements Insertable { map['message_text'] = Variable(messageText); } { - map['attachments'] = Variable( - $MessagesTable.$converterattachments.toSql(attachments)); + map['attachments'] = Variable($MessagesTable.$converterattachments.toSql(attachments)); } map['state'] = Variable(state); map['type'] = Variable(type); { - map['mentioned_users'] = Variable( - $MessagesTable.$convertermentionedUsers.toSql(mentionedUsers)); + map['mentioned_users'] = Variable($MessagesTable.$convertermentionedUsers.toSql(mentionedUsers)); } if (!nullToAbsent || reactionGroups != null) { - map['reaction_groups'] = Variable( - $MessagesTable.$converterreactionGroupsn.toSql(reactionGroups)); + map['reaction_groups'] = Variable($MessagesTable.$converterreactionGroupsn.toSql(reactionGroups)); } if (!nullToAbsent || parentId != null) { map['parent_id'] = Variable(parentId); @@ -1442,6 +1483,9 @@ class MessageEntity extends DataClass implements Insertable { if (!nullToAbsent || remoteDeletedAt != null) { map['remote_deleted_at'] = Variable(remoteDeletedAt); } + if (!nullToAbsent || deletedForMe != null) { + map['deleted_for_me'] = Variable(deletedForMe); + } if (!nullToAbsent || messageTextUpdatedAt != null) { map['message_text_updated_at'] = Variable(messageTextUpdatedAt); } @@ -1466,19 +1510,17 @@ class MessageEntity extends DataClass implements Insertable { map['i18n'] = Variable($MessagesTable.$converteri18n.toSql(i18n)); } if (!nullToAbsent || restrictedVisibility != null) { - map['restricted_visibility'] = Variable($MessagesTable - .$converterrestrictedVisibilityn - .toSql(restrictedVisibility)); + map['restricted_visibility'] = Variable( + $MessagesTable.$converterrestrictedVisibilityn.toSql(restrictedVisibility), + ); } if (!nullToAbsent || extraData != null) { - map['extra_data'] = Variable( - $MessagesTable.$converterextraDatan.toSql(extraData)); + map['extra_data'] = Variable($MessagesTable.$converterextraDatan.toSql(extraData)); } return map; } - factory MessageEntity.fromJson(Map json, - {ValueSerializer? serializer}) { + factory MessageEntity.fromJson(Map json, {ValueSerializer? serializer}) { serializer ??= driftRuntimeOptions.defaultSerializer; return MessageEntity( id: serializer.fromJson(json['id']), @@ -1487,8 +1529,7 @@ class MessageEntity extends DataClass implements Insertable { state: serializer.fromJson(json['state']), type: serializer.fromJson(json['type']), mentionedUsers: serializer.fromJson>(json['mentionedUsers']), - reactionGroups: serializer - .fromJson?>(json['reactionGroups']), + reactionGroups: serializer.fromJson?>(json['reactionGroups']), parentId: serializer.fromJson(json['parentId']), quotedMessageId: serializer.fromJson(json['quotedMessageId']), pollId: serializer.fromJson(json['pollId']), @@ -1502,8 +1543,8 @@ class MessageEntity extends DataClass implements Insertable { remoteUpdatedAt: serializer.fromJson(json['remoteUpdatedAt']), localDeletedAt: serializer.fromJson(json['localDeletedAt']), remoteDeletedAt: serializer.fromJson(json['remoteDeletedAt']), - messageTextUpdatedAt: - serializer.fromJson(json['messageTextUpdatedAt']), + deletedForMe: serializer.fromJson(json['deletedForMe']), + messageTextUpdatedAt: serializer.fromJson(json['messageTextUpdatedAt']), userId: serializer.fromJson(json['userId']), channelRole: serializer.fromJson(json['channelRole']), pinned: serializer.fromJson(json['pinned']), @@ -1512,8 +1553,7 @@ class MessageEntity extends DataClass implements Insertable { pinnedByUserId: serializer.fromJson(json['pinnedByUserId']), channelCid: serializer.fromJson(json['channelCid']), i18n: serializer.fromJson?>(json['i18n']), - restrictedVisibility: - serializer.fromJson?>(json['restrictedVisibility']), + restrictedVisibility: serializer.fromJson?>(json['restrictedVisibility']), extraData: serializer.fromJson?>(json['extraData']), ); } @@ -1527,8 +1567,7 @@ class MessageEntity extends DataClass implements Insertable { 'state': serializer.toJson(state), 'type': serializer.toJson(type), 'mentionedUsers': serializer.toJson>(mentionedUsers), - 'reactionGroups': - serializer.toJson?>(reactionGroups), + 'reactionGroups': serializer.toJson?>(reactionGroups), 'parentId': serializer.toJson(parentId), 'quotedMessageId': serializer.toJson(quotedMessageId), 'pollId': serializer.toJson(pollId), @@ -1542,8 +1581,8 @@ class MessageEntity extends DataClass implements Insertable { 'remoteUpdatedAt': serializer.toJson(remoteUpdatedAt), 'localDeletedAt': serializer.toJson(localDeletedAt), 'remoteDeletedAt': serializer.toJson(remoteDeletedAt), - 'messageTextUpdatedAt': - serializer.toJson(messageTextUpdatedAt), + 'deletedForMe': serializer.toJson(deletedForMe), + 'messageTextUpdatedAt': serializer.toJson(messageTextUpdatedAt), 'userId': serializer.toJson(userId), 'channelRole': serializer.toJson(channelRole), 'pinned': serializer.toJson(pinned), @@ -1552,156 +1591,111 @@ class MessageEntity extends DataClass implements Insertable { 'pinnedByUserId': serializer.toJson(pinnedByUserId), 'channelCid': serializer.toJson(channelCid), 'i18n': serializer.toJson?>(i18n), - 'restrictedVisibility': - serializer.toJson?>(restrictedVisibility), + 'restrictedVisibility': serializer.toJson?>(restrictedVisibility), 'extraData': serializer.toJson?>(extraData), }; } - MessageEntity copyWith( - {String? id, - Value messageText = const Value.absent(), - List? attachments, - String? state, - String? type, - List? mentionedUsers, - Value?> reactionGroups = - const Value.absent(), - Value parentId = const Value.absent(), - Value quotedMessageId = const Value.absent(), - Value pollId = const Value.absent(), - Value replyCount = const Value.absent(), - Value showInChannel = const Value.absent(), - bool? shadowed, - Value command = const Value.absent(), - Value localCreatedAt = const Value.absent(), - Value remoteCreatedAt = const Value.absent(), - Value localUpdatedAt = const Value.absent(), - Value remoteUpdatedAt = const Value.absent(), - Value localDeletedAt = const Value.absent(), - Value remoteDeletedAt = const Value.absent(), - Value messageTextUpdatedAt = const Value.absent(), - Value userId = const Value.absent(), - Value channelRole = const Value.absent(), - bool? pinned, - Value pinnedAt = const Value.absent(), - Value pinExpires = const Value.absent(), - Value pinnedByUserId = const Value.absent(), - String? channelCid, - Value?> i18n = const Value.absent(), - Value?> restrictedVisibility = const Value.absent(), - Value?> extraData = const Value.absent()}) => - MessageEntity( - id: id ?? this.id, - messageText: messageText.present ? messageText.value : this.messageText, - attachments: attachments ?? this.attachments, - state: state ?? this.state, - type: type ?? this.type, - mentionedUsers: mentionedUsers ?? this.mentionedUsers, - reactionGroups: - reactionGroups.present ? reactionGroups.value : this.reactionGroups, - parentId: parentId.present ? parentId.value : this.parentId, - quotedMessageId: quotedMessageId.present - ? quotedMessageId.value - : this.quotedMessageId, - pollId: pollId.present ? pollId.value : this.pollId, - replyCount: replyCount.present ? replyCount.value : this.replyCount, - showInChannel: - showInChannel.present ? showInChannel.value : this.showInChannel, - shadowed: shadowed ?? this.shadowed, - command: command.present ? command.value : this.command, - localCreatedAt: - localCreatedAt.present ? localCreatedAt.value : this.localCreatedAt, - remoteCreatedAt: remoteCreatedAt.present - ? remoteCreatedAt.value - : this.remoteCreatedAt, - localUpdatedAt: - localUpdatedAt.present ? localUpdatedAt.value : this.localUpdatedAt, - remoteUpdatedAt: remoteUpdatedAt.present - ? remoteUpdatedAt.value - : this.remoteUpdatedAt, - localDeletedAt: - localDeletedAt.present ? localDeletedAt.value : this.localDeletedAt, - remoteDeletedAt: remoteDeletedAt.present - ? remoteDeletedAt.value - : this.remoteDeletedAt, - messageTextUpdatedAt: messageTextUpdatedAt.present - ? messageTextUpdatedAt.value - : this.messageTextUpdatedAt, - userId: userId.present ? userId.value : this.userId, - channelRole: channelRole.present ? channelRole.value : this.channelRole, - pinned: pinned ?? this.pinned, - pinnedAt: pinnedAt.present ? pinnedAt.value : this.pinnedAt, - pinExpires: pinExpires.present ? pinExpires.value : this.pinExpires, - pinnedByUserId: - pinnedByUserId.present ? pinnedByUserId.value : this.pinnedByUserId, - channelCid: channelCid ?? this.channelCid, - i18n: i18n.present ? i18n.value : this.i18n, - restrictedVisibility: restrictedVisibility.present - ? restrictedVisibility.value - : this.restrictedVisibility, - extraData: extraData.present ? extraData.value : this.extraData, - ); + MessageEntity copyWith({ + String? id, + Value messageText = const Value.absent(), + List? attachments, + String? state, + String? type, + List? mentionedUsers, + Value?> reactionGroups = const Value.absent(), + Value parentId = const Value.absent(), + Value quotedMessageId = const Value.absent(), + Value pollId = const Value.absent(), + Value replyCount = const Value.absent(), + Value showInChannel = const Value.absent(), + bool? shadowed, + Value command = const Value.absent(), + Value localCreatedAt = const Value.absent(), + Value remoteCreatedAt = const Value.absent(), + Value localUpdatedAt = const Value.absent(), + Value remoteUpdatedAt = const Value.absent(), + Value localDeletedAt = const Value.absent(), + Value remoteDeletedAt = const Value.absent(), + Value deletedForMe = const Value.absent(), + Value messageTextUpdatedAt = const Value.absent(), + Value userId = const Value.absent(), + Value channelRole = const Value.absent(), + bool? pinned, + Value pinnedAt = const Value.absent(), + Value pinExpires = const Value.absent(), + Value pinnedByUserId = const Value.absent(), + String? channelCid, + Value?> i18n = const Value.absent(), + Value?> restrictedVisibility = const Value.absent(), + Value?> extraData = const Value.absent(), + }) => MessageEntity( + id: id ?? this.id, + messageText: messageText.present ? messageText.value : this.messageText, + attachments: attachments ?? this.attachments, + state: state ?? this.state, + type: type ?? this.type, + mentionedUsers: mentionedUsers ?? this.mentionedUsers, + reactionGroups: reactionGroups.present ? reactionGroups.value : this.reactionGroups, + parentId: parentId.present ? parentId.value : this.parentId, + quotedMessageId: quotedMessageId.present ? quotedMessageId.value : this.quotedMessageId, + pollId: pollId.present ? pollId.value : this.pollId, + replyCount: replyCount.present ? replyCount.value : this.replyCount, + showInChannel: showInChannel.present ? showInChannel.value : this.showInChannel, + shadowed: shadowed ?? this.shadowed, + command: command.present ? command.value : this.command, + localCreatedAt: localCreatedAt.present ? localCreatedAt.value : this.localCreatedAt, + remoteCreatedAt: remoteCreatedAt.present ? remoteCreatedAt.value : this.remoteCreatedAt, + localUpdatedAt: localUpdatedAt.present ? localUpdatedAt.value : this.localUpdatedAt, + remoteUpdatedAt: remoteUpdatedAt.present ? remoteUpdatedAt.value : this.remoteUpdatedAt, + localDeletedAt: localDeletedAt.present ? localDeletedAt.value : this.localDeletedAt, + remoteDeletedAt: remoteDeletedAt.present ? remoteDeletedAt.value : this.remoteDeletedAt, + deletedForMe: deletedForMe.present ? deletedForMe.value : this.deletedForMe, + messageTextUpdatedAt: messageTextUpdatedAt.present ? messageTextUpdatedAt.value : this.messageTextUpdatedAt, + userId: userId.present ? userId.value : this.userId, + channelRole: channelRole.present ? channelRole.value : this.channelRole, + pinned: pinned ?? this.pinned, + pinnedAt: pinnedAt.present ? pinnedAt.value : this.pinnedAt, + pinExpires: pinExpires.present ? pinExpires.value : this.pinExpires, + pinnedByUserId: pinnedByUserId.present ? pinnedByUserId.value : this.pinnedByUserId, + channelCid: channelCid ?? this.channelCid, + i18n: i18n.present ? i18n.value : this.i18n, + restrictedVisibility: restrictedVisibility.present ? restrictedVisibility.value : this.restrictedVisibility, + extraData: extraData.present ? extraData.value : this.extraData, + ); MessageEntity copyWithCompanion(MessagesCompanion data) { return MessageEntity( id: data.id.present ? data.id.value : this.id, - messageText: - data.messageText.present ? data.messageText.value : this.messageText, - attachments: - data.attachments.present ? data.attachments.value : this.attachments, + messageText: data.messageText.present ? data.messageText.value : this.messageText, + attachments: data.attachments.present ? data.attachments.value : this.attachments, state: data.state.present ? data.state.value : this.state, type: data.type.present ? data.type.value : this.type, - mentionedUsers: data.mentionedUsers.present - ? data.mentionedUsers.value - : this.mentionedUsers, - reactionGroups: data.reactionGroups.present - ? data.reactionGroups.value - : this.reactionGroups, + mentionedUsers: data.mentionedUsers.present ? data.mentionedUsers.value : this.mentionedUsers, + reactionGroups: data.reactionGroups.present ? data.reactionGroups.value : this.reactionGroups, parentId: data.parentId.present ? data.parentId.value : this.parentId, - quotedMessageId: data.quotedMessageId.present - ? data.quotedMessageId.value - : this.quotedMessageId, + quotedMessageId: data.quotedMessageId.present ? data.quotedMessageId.value : this.quotedMessageId, pollId: data.pollId.present ? data.pollId.value : this.pollId, - replyCount: - data.replyCount.present ? data.replyCount.value : this.replyCount, - showInChannel: data.showInChannel.present - ? data.showInChannel.value - : this.showInChannel, + replyCount: data.replyCount.present ? data.replyCount.value : this.replyCount, + showInChannel: data.showInChannel.present ? data.showInChannel.value : this.showInChannel, shadowed: data.shadowed.present ? data.shadowed.value : this.shadowed, command: data.command.present ? data.command.value : this.command, - localCreatedAt: data.localCreatedAt.present - ? data.localCreatedAt.value - : this.localCreatedAt, - remoteCreatedAt: data.remoteCreatedAt.present - ? data.remoteCreatedAt.value - : this.remoteCreatedAt, - localUpdatedAt: data.localUpdatedAt.present - ? data.localUpdatedAt.value - : this.localUpdatedAt, - remoteUpdatedAt: data.remoteUpdatedAt.present - ? data.remoteUpdatedAt.value - : this.remoteUpdatedAt, - localDeletedAt: data.localDeletedAt.present - ? data.localDeletedAt.value - : this.localDeletedAt, - remoteDeletedAt: data.remoteDeletedAt.present - ? data.remoteDeletedAt.value - : this.remoteDeletedAt, + localCreatedAt: data.localCreatedAt.present ? data.localCreatedAt.value : this.localCreatedAt, + remoteCreatedAt: data.remoteCreatedAt.present ? data.remoteCreatedAt.value : this.remoteCreatedAt, + localUpdatedAt: data.localUpdatedAt.present ? data.localUpdatedAt.value : this.localUpdatedAt, + remoteUpdatedAt: data.remoteUpdatedAt.present ? data.remoteUpdatedAt.value : this.remoteUpdatedAt, + localDeletedAt: data.localDeletedAt.present ? data.localDeletedAt.value : this.localDeletedAt, + remoteDeletedAt: data.remoteDeletedAt.present ? data.remoteDeletedAt.value : this.remoteDeletedAt, + deletedForMe: data.deletedForMe.present ? data.deletedForMe.value : this.deletedForMe, messageTextUpdatedAt: data.messageTextUpdatedAt.present ? data.messageTextUpdatedAt.value : this.messageTextUpdatedAt, userId: data.userId.present ? data.userId.value : this.userId, - channelRole: - data.channelRole.present ? data.channelRole.value : this.channelRole, + channelRole: data.channelRole.present ? data.channelRole.value : this.channelRole, pinned: data.pinned.present ? data.pinned.value : this.pinned, pinnedAt: data.pinnedAt.present ? data.pinnedAt.value : this.pinnedAt, - pinExpires: - data.pinExpires.present ? data.pinExpires.value : this.pinExpires, - pinnedByUserId: data.pinnedByUserId.present - ? data.pinnedByUserId.value - : this.pinnedByUserId, - channelCid: - data.channelCid.present ? data.channelCid.value : this.channelCid, + pinExpires: data.pinExpires.present ? data.pinExpires.value : this.pinExpires, + pinnedByUserId: data.pinnedByUserId.present ? data.pinnedByUserId.value : this.pinnedByUserId, + channelCid: data.channelCid.present ? data.channelCid.value : this.channelCid, i18n: data.i18n.present ? data.i18n.value : this.i18n, restrictedVisibility: data.restrictedVisibility.present ? data.restrictedVisibility.value @@ -1733,6 +1727,7 @@ class MessageEntity extends DataClass implements Insertable { ..write('remoteUpdatedAt: $remoteUpdatedAt, ') ..write('localDeletedAt: $localDeletedAt, ') ..write('remoteDeletedAt: $remoteDeletedAt, ') + ..write('deletedForMe: $deletedForMe, ') ..write('messageTextUpdatedAt: $messageTextUpdatedAt, ') ..write('userId: $userId, ') ..write('channelRole: $channelRole, ') @@ -1750,38 +1745,39 @@ class MessageEntity extends DataClass implements Insertable { @override int get hashCode => Object.hashAll([ - id, - messageText, - attachments, - state, - type, - mentionedUsers, - reactionGroups, - parentId, - quotedMessageId, - pollId, - replyCount, - showInChannel, - shadowed, - command, - localCreatedAt, - remoteCreatedAt, - localUpdatedAt, - remoteUpdatedAt, - localDeletedAt, - remoteDeletedAt, - messageTextUpdatedAt, - userId, - channelRole, - pinned, - pinnedAt, - pinExpires, - pinnedByUserId, - channelCid, - i18n, - restrictedVisibility, - extraData - ]); + id, + messageText, + attachments, + state, + type, + mentionedUsers, + reactionGroups, + parentId, + quotedMessageId, + pollId, + replyCount, + showInChannel, + shadowed, + command, + localCreatedAt, + remoteCreatedAt, + localUpdatedAt, + remoteUpdatedAt, + localDeletedAt, + remoteDeletedAt, + deletedForMe, + messageTextUpdatedAt, + userId, + channelRole, + pinned, + pinnedAt, + pinExpires, + pinnedByUserId, + channelCid, + i18n, + restrictedVisibility, + extraData, + ]); @override bool operator ==(Object other) => identical(this, other) || @@ -1806,6 +1802,7 @@ class MessageEntity extends DataClass implements Insertable { other.remoteUpdatedAt == this.remoteUpdatedAt && other.localDeletedAt == this.localDeletedAt && other.remoteDeletedAt == this.remoteDeletedAt && + other.deletedForMe == this.deletedForMe && other.messageTextUpdatedAt == this.messageTextUpdatedAt && other.userId == this.userId && other.channelRole == this.channelRole && @@ -1840,6 +1837,7 @@ class MessagesCompanion extends UpdateCompanion { final Value remoteUpdatedAt; final Value localDeletedAt; final Value remoteDeletedAt; + final Value deletedForMe; final Value messageTextUpdatedAt; final Value userId; final Value channelRole; @@ -1873,6 +1871,7 @@ class MessagesCompanion extends UpdateCompanion { this.remoteUpdatedAt = const Value.absent(), this.localDeletedAt = const Value.absent(), this.remoteDeletedAt = const Value.absent(), + this.deletedForMe = const Value.absent(), this.messageTextUpdatedAt = const Value.absent(), this.userId = const Value.absent(), this.channelRole = const Value.absent(), @@ -1907,6 +1906,7 @@ class MessagesCompanion extends UpdateCompanion { this.remoteUpdatedAt = const Value.absent(), this.localDeletedAt = const Value.absent(), this.remoteDeletedAt = const Value.absent(), + this.deletedForMe = const Value.absent(), this.messageTextUpdatedAt = const Value.absent(), this.userId = const Value.absent(), this.channelRole = const Value.absent(), @@ -1919,11 +1919,11 @@ class MessagesCompanion extends UpdateCompanion { this.restrictedVisibility = const Value.absent(), this.extraData = const Value.absent(), this.rowid = const Value.absent(), - }) : id = Value(id), - attachments = Value(attachments), - state = Value(state), - mentionedUsers = Value(mentionedUsers), - channelCid = Value(channelCid); + }) : id = Value(id), + attachments = Value(attachments), + state = Value(state), + mentionedUsers = Value(mentionedUsers), + channelCid = Value(channelCid); static Insertable custom({ Expression? id, Expression? messageText, @@ -1945,6 +1945,7 @@ class MessagesCompanion extends UpdateCompanion { Expression? remoteUpdatedAt, Expression? localDeletedAt, Expression? remoteDeletedAt, + Expression? deletedForMe, Expression? messageTextUpdatedAt, Expression? userId, Expression? channelRole, @@ -1979,8 +1980,8 @@ class MessagesCompanion extends UpdateCompanion { if (remoteUpdatedAt != null) 'remote_updated_at': remoteUpdatedAt, if (localDeletedAt != null) 'local_deleted_at': localDeletedAt, if (remoteDeletedAt != null) 'remote_deleted_at': remoteDeletedAt, - if (messageTextUpdatedAt != null) - 'message_text_updated_at': messageTextUpdatedAt, + if (deletedForMe != null) 'deleted_for_me': deletedForMe, + if (messageTextUpdatedAt != null) 'message_text_updated_at': messageTextUpdatedAt, if (userId != null) 'user_id': userId, if (channelRole != null) 'channel_role': channelRole, if (pinned != null) 'pinned': pinned, @@ -1989,46 +1990,47 @@ class MessagesCompanion extends UpdateCompanion { if (pinnedByUserId != null) 'pinned_by_user_id': pinnedByUserId, if (channelCid != null) 'channel_cid': channelCid, if (i18n != null) 'i18n': i18n, - if (restrictedVisibility != null) - 'restricted_visibility': restrictedVisibility, + if (restrictedVisibility != null) 'restricted_visibility': restrictedVisibility, if (extraData != null) 'extra_data': extraData, if (rowid != null) 'rowid': rowid, }); } - MessagesCompanion copyWith( - {Value? id, - Value? messageText, - Value>? attachments, - Value? state, - Value? type, - Value>? mentionedUsers, - Value?>? reactionGroups, - Value? parentId, - Value? quotedMessageId, - Value? pollId, - Value? replyCount, - Value? showInChannel, - Value? shadowed, - Value? command, - Value? localCreatedAt, - Value? remoteCreatedAt, - Value? localUpdatedAt, - Value? remoteUpdatedAt, - Value? localDeletedAt, - Value? remoteDeletedAt, - Value? messageTextUpdatedAt, - Value? userId, - Value? channelRole, - Value? pinned, - Value? pinnedAt, - Value? pinExpires, - Value? pinnedByUserId, - Value? channelCid, - Value?>? i18n, - Value?>? restrictedVisibility, - Value?>? extraData, - Value? rowid}) { + MessagesCompanion copyWith({ + Value? id, + Value? messageText, + Value>? attachments, + Value? state, + Value? type, + Value>? mentionedUsers, + Value?>? reactionGroups, + Value? parentId, + Value? quotedMessageId, + Value? pollId, + Value? replyCount, + Value? showInChannel, + Value? shadowed, + Value? command, + Value? localCreatedAt, + Value? remoteCreatedAt, + Value? localUpdatedAt, + Value? remoteUpdatedAt, + Value? localDeletedAt, + Value? remoteDeletedAt, + Value? deletedForMe, + Value? messageTextUpdatedAt, + Value? userId, + Value? channelRole, + Value? pinned, + Value? pinnedAt, + Value? pinExpires, + Value? pinnedByUserId, + Value? channelCid, + Value?>? i18n, + Value?>? restrictedVisibility, + Value?>? extraData, + Value? rowid, + }) { return MessagesCompanion( id: id ?? this.id, messageText: messageText ?? this.messageText, @@ -2050,6 +2052,7 @@ class MessagesCompanion extends UpdateCompanion { remoteUpdatedAt: remoteUpdatedAt ?? this.remoteUpdatedAt, localDeletedAt: localDeletedAt ?? this.localDeletedAt, remoteDeletedAt: remoteDeletedAt ?? this.remoteDeletedAt, + deletedForMe: deletedForMe ?? this.deletedForMe, messageTextUpdatedAt: messageTextUpdatedAt ?? this.messageTextUpdatedAt, userId: userId ?? this.userId, channelRole: channelRole ?? this.channelRole, @@ -2075,8 +2078,7 @@ class MessagesCompanion extends UpdateCompanion { map['message_text'] = Variable(messageText.value); } if (attachments.present) { - map['attachments'] = Variable( - $MessagesTable.$converterattachments.toSql(attachments.value)); + map['attachments'] = Variable($MessagesTable.$converterattachments.toSql(attachments.value)); } if (state.present) { map['state'] = Variable(state.value); @@ -2085,12 +2087,10 @@ class MessagesCompanion extends UpdateCompanion { map['type'] = Variable(type.value); } if (mentionedUsers.present) { - map['mentioned_users'] = Variable( - $MessagesTable.$convertermentionedUsers.toSql(mentionedUsers.value)); + map['mentioned_users'] = Variable($MessagesTable.$convertermentionedUsers.toSql(mentionedUsers.value)); } if (reactionGroups.present) { - map['reaction_groups'] = Variable( - $MessagesTable.$converterreactionGroupsn.toSql(reactionGroups.value)); + map['reaction_groups'] = Variable($MessagesTable.$converterreactionGroupsn.toSql(reactionGroups.value)); } if (parentId.present) { map['parent_id'] = Variable(parentId.value); @@ -2131,9 +2131,11 @@ class MessagesCompanion extends UpdateCompanion { if (remoteDeletedAt.present) { map['remote_deleted_at'] = Variable(remoteDeletedAt.value); } + if (deletedForMe.present) { + map['deleted_for_me'] = Variable(deletedForMe.value); + } if (messageTextUpdatedAt.present) { - map['message_text_updated_at'] = - Variable(messageTextUpdatedAt.value); + map['message_text_updated_at'] = Variable(messageTextUpdatedAt.value); } if (userId.present) { map['user_id'] = Variable(userId.value); @@ -2157,17 +2159,15 @@ class MessagesCompanion extends UpdateCompanion { map['channel_cid'] = Variable(channelCid.value); } if (i18n.present) { - map['i18n'] = - Variable($MessagesTable.$converteri18n.toSql(i18n.value)); + map['i18n'] = Variable($MessagesTable.$converteri18n.toSql(i18n.value)); } if (restrictedVisibility.present) { - map['restricted_visibility'] = Variable($MessagesTable - .$converterrestrictedVisibilityn - .toSql(restrictedVisibility.value)); + map['restricted_visibility'] = Variable( + $MessagesTable.$converterrestrictedVisibilityn.toSql(restrictedVisibility.value), + ); } if (extraData.present) { - map['extra_data'] = Variable( - $MessagesTable.$converterextraDatan.toSql(extraData.value)); + map['extra_data'] = Variable($MessagesTable.$converterextraDatan.toSql(extraData.value)); } if (rowid.present) { map['rowid'] = Variable(rowid.value); @@ -2198,6 +2198,7 @@ class MessagesCompanion extends UpdateCompanion { ..write('remoteUpdatedAt: $remoteUpdatedAt, ') ..write('localDeletedAt: $localDeletedAt, ') ..write('remoteDeletedAt: $remoteDeletedAt, ') + ..write('deletedForMe: $deletedForMe, ') ..write('messageTextUpdatedAt: $messageTextUpdatedAt, ') ..write('userId: $userId, ') ..write('channelRole: $channelRole, ') @@ -2215,8 +2216,7 @@ class MessagesCompanion extends UpdateCompanion { } } -class $DraftMessagesTable extends DraftMessages - with TableInfo<$DraftMessagesTable, DraftMessageEntity> { +class $DraftMessagesTable extends DraftMessages with TableInfo<$DraftMessagesTable, DraftMessageEntity> { @override final GeneratedDatabase attachedDatabase; final String? _alias; @@ -2224,132 +2224,157 @@ class $DraftMessagesTable extends DraftMessages static const VerificationMeta _idMeta = const VerificationMeta('id'); @override late final GeneratedColumn id = GeneratedColumn( - 'id', aliasedName, false, - type: DriftSqlType.string, requiredDuringInsert: true); - static const VerificationMeta _messageTextMeta = - const VerificationMeta('messageText'); + 'id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + static const VerificationMeta _messageTextMeta = const VerificationMeta('messageText'); @override late final GeneratedColumn messageText = GeneratedColumn( - 'message_text', aliasedName, true, - type: DriftSqlType.string, requiredDuringInsert: false); - static const VerificationMeta _attachmentsMeta = - const VerificationMeta('attachments'); - @override - late final GeneratedColumnWithTypeConverter, String> - attachments = GeneratedColumn('attachments', aliasedName, false, - type: DriftSqlType.string, requiredDuringInsert: true) - .withConverter>( - $DraftMessagesTable.$converterattachments); + 'message_text', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); + @override + late final GeneratedColumnWithTypeConverter, String> attachments = GeneratedColumn( + 'attachments', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ).withConverter>($DraftMessagesTable.$converterattachments); static const VerificationMeta _typeMeta = const VerificationMeta('type'); @override late final GeneratedColumn type = GeneratedColumn( - 'type', aliasedName, false, - type: DriftSqlType.string, - requiredDuringInsert: false, - defaultValue: const Constant('regular')); - static const VerificationMeta _mentionedUsersMeta = - const VerificationMeta('mentionedUsers'); - @override - late final GeneratedColumnWithTypeConverter, String> - mentionedUsers = GeneratedColumn( - 'mentioned_users', aliasedName, false, - type: DriftSqlType.string, requiredDuringInsert: true) - .withConverter>( - $DraftMessagesTable.$convertermentionedUsers); - static const VerificationMeta _parentIdMeta = - const VerificationMeta('parentId'); + 'type', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: false, + defaultValue: const Constant('regular'), + ); + @override + late final GeneratedColumnWithTypeConverter, String> mentionedUsers = GeneratedColumn( + 'mentioned_users', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ).withConverter>($DraftMessagesTable.$convertermentionedUsers); + static const VerificationMeta _parentIdMeta = const VerificationMeta('parentId'); @override late final GeneratedColumn parentId = GeneratedColumn( - 'parent_id', aliasedName, true, - type: DriftSqlType.string, - requiredDuringInsert: false, - defaultConstraints: GeneratedColumn.constraintIsAlways( - 'REFERENCES messages (id) ON DELETE CASCADE')); - static const VerificationMeta _quotedMessageIdMeta = - const VerificationMeta('quotedMessageId'); + 'parent_id', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + defaultConstraints: GeneratedColumn.constraintIsAlways('REFERENCES messages (id) ON DELETE CASCADE'), + ); + static const VerificationMeta _quotedMessageIdMeta = const VerificationMeta('quotedMessageId'); @override late final GeneratedColumn quotedMessageId = GeneratedColumn( - 'quoted_message_id', aliasedName, true, - type: DriftSqlType.string, requiredDuringInsert: false); + 'quoted_message_id', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); static const VerificationMeta _pollIdMeta = const VerificationMeta('pollId'); @override late final GeneratedColumn pollId = GeneratedColumn( - 'poll_id', aliasedName, true, - type: DriftSqlType.string, requiredDuringInsert: false); - static const VerificationMeta _showInChannelMeta = - const VerificationMeta('showInChannel'); + 'poll_id', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); + static const VerificationMeta _showInChannelMeta = const VerificationMeta('showInChannel'); @override late final GeneratedColumn showInChannel = GeneratedColumn( - 'show_in_channel', aliasedName, true, - type: DriftSqlType.bool, - requiredDuringInsert: false, - defaultConstraints: GeneratedColumn.constraintIsAlways( - 'CHECK ("show_in_channel" IN (0, 1))')); - static const VerificationMeta _commandMeta = - const VerificationMeta('command'); + 'show_in_channel', + aliasedName, + true, + type: DriftSqlType.bool, + requiredDuringInsert: false, + defaultConstraints: GeneratedColumn.constraintIsAlways('CHECK ("show_in_channel" IN (0, 1))'), + ); + static const VerificationMeta _commandMeta = const VerificationMeta('command'); @override late final GeneratedColumn command = GeneratedColumn( - 'command', aliasedName, true, - type: DriftSqlType.string, requiredDuringInsert: false); + 'command', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); static const VerificationMeta _silentMeta = const VerificationMeta('silent'); @override late final GeneratedColumn silent = GeneratedColumn( - 'silent', aliasedName, false, - type: DriftSqlType.bool, - requiredDuringInsert: false, - defaultConstraints: - GeneratedColumn.constraintIsAlways('CHECK ("silent" IN (0, 1))'), - defaultValue: const Constant(false)); - static const VerificationMeta _createdAtMeta = - const VerificationMeta('createdAt'); + 'silent', + aliasedName, + false, + type: DriftSqlType.bool, + requiredDuringInsert: false, + defaultConstraints: GeneratedColumn.constraintIsAlways('CHECK ("silent" IN (0, 1))'), + defaultValue: const Constant(false), + ); + static const VerificationMeta _createdAtMeta = const VerificationMeta('createdAt'); @override late final GeneratedColumn createdAt = GeneratedColumn( - 'created_at', aliasedName, false, - type: DriftSqlType.dateTime, - requiredDuringInsert: false, - defaultValue: currentDateAndTime); - static const VerificationMeta _channelCidMeta = - const VerificationMeta('channelCid'); + 'created_at', + aliasedName, + false, + type: DriftSqlType.dateTime, + requiredDuringInsert: false, + defaultValue: currentDateAndTime, + ); + static const VerificationMeta _channelCidMeta = const VerificationMeta('channelCid'); @override late final GeneratedColumn channelCid = GeneratedColumn( - 'channel_cid', aliasedName, false, - type: DriftSqlType.string, - requiredDuringInsert: true, - defaultConstraints: GeneratedColumn.constraintIsAlways( - 'REFERENCES channels (cid) ON DELETE CASCADE')); - static const VerificationMeta _extraDataMeta = - const VerificationMeta('extraData'); - @override - late final GeneratedColumnWithTypeConverter?, String> - extraData = GeneratedColumn('extra_data', aliasedName, true, - type: DriftSqlType.string, requiredDuringInsert: false) - .withConverter?>( - $DraftMessagesTable.$converterextraDatan); + 'channel_cid', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + defaultConstraints: GeneratedColumn.constraintIsAlways('REFERENCES channels (cid) ON DELETE CASCADE'), + ); + @override + late final GeneratedColumnWithTypeConverter?, String> extraData = GeneratedColumn( + 'extra_data', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ).withConverter?>($DraftMessagesTable.$converterextraDatan); @override List get $columns => [ - id, - messageText, - attachments, - type, - mentionedUsers, - parentId, - quotedMessageId, - pollId, - showInChannel, - command, - silent, - createdAt, - channelCid, - extraData - ]; + id, + messageText, + attachments, + type, + mentionedUsers, + parentId, + quotedMessageId, + pollId, + showInChannel, + command, + silent, + createdAt, + channelCid, + extraData, + ]; @override String get aliasedName => _alias ?? actualTableName; @override String get actualTableName => $name; static const String $name = 'draft_messages'; @override - VerificationContext validateIntegrity(Insertable instance, - {bool isInserting = false}) { + VerificationContext validateIntegrity(Insertable instance, {bool isInserting = false}) { final context = VerificationContext(); final data = instance.toColumns(true); if (data.containsKey('id')) { @@ -2358,58 +2383,43 @@ class $DraftMessagesTable extends DraftMessages context.missing(_idMeta); } if (data.containsKey('message_text')) { - context.handle( - _messageTextMeta, - messageText.isAcceptableOrUnknown( - data['message_text']!, _messageTextMeta)); + context.handle(_messageTextMeta, messageText.isAcceptableOrUnknown(data['message_text']!, _messageTextMeta)); } - context.handle(_attachmentsMeta, const VerificationResult.success()); if (data.containsKey('type')) { - context.handle( - _typeMeta, type.isAcceptableOrUnknown(data['type']!, _typeMeta)); + context.handle(_typeMeta, type.isAcceptableOrUnknown(data['type']!, _typeMeta)); } - context.handle(_mentionedUsersMeta, const VerificationResult.success()); if (data.containsKey('parent_id')) { - context.handle(_parentIdMeta, - parentId.isAcceptableOrUnknown(data['parent_id']!, _parentIdMeta)); + context.handle(_parentIdMeta, parentId.isAcceptableOrUnknown(data['parent_id']!, _parentIdMeta)); } if (data.containsKey('quoted_message_id')) { context.handle( - _quotedMessageIdMeta, - quotedMessageId.isAcceptableOrUnknown( - data['quoted_message_id']!, _quotedMessageIdMeta)); + _quotedMessageIdMeta, + quotedMessageId.isAcceptableOrUnknown(data['quoted_message_id']!, _quotedMessageIdMeta), + ); } if (data.containsKey('poll_id')) { - context.handle(_pollIdMeta, - pollId.isAcceptableOrUnknown(data['poll_id']!, _pollIdMeta)); + context.handle(_pollIdMeta, pollId.isAcceptableOrUnknown(data['poll_id']!, _pollIdMeta)); } if (data.containsKey('show_in_channel')) { context.handle( - _showInChannelMeta, - showInChannel.isAcceptableOrUnknown( - data['show_in_channel']!, _showInChannelMeta)); + _showInChannelMeta, + showInChannel.isAcceptableOrUnknown(data['show_in_channel']!, _showInChannelMeta), + ); } if (data.containsKey('command')) { - context.handle(_commandMeta, - command.isAcceptableOrUnknown(data['command']!, _commandMeta)); + context.handle(_commandMeta, command.isAcceptableOrUnknown(data['command']!, _commandMeta)); } if (data.containsKey('silent')) { - context.handle(_silentMeta, - silent.isAcceptableOrUnknown(data['silent']!, _silentMeta)); + context.handle(_silentMeta, silent.isAcceptableOrUnknown(data['silent']!, _silentMeta)); } if (data.containsKey('created_at')) { - context.handle(_createdAtMeta, - createdAt.isAcceptableOrUnknown(data['created_at']!, _createdAtMeta)); + context.handle(_createdAtMeta, createdAt.isAcceptableOrUnknown(data['created_at']!, _createdAtMeta)); } if (data.containsKey('channel_cid')) { - context.handle( - _channelCidMeta, - channelCid.isAcceptableOrUnknown( - data['channel_cid']!, _channelCidMeta)); + context.handle(_channelCidMeta, channelCid.isAcceptableOrUnknown(data['channel_cid']!, _channelCidMeta)); } else if (isInserting) { context.missing(_channelCidMeta); } - context.handle(_extraDataMeta, const VerificationResult.success()); return context; } @@ -2419,37 +2429,29 @@ class $DraftMessagesTable extends DraftMessages DraftMessageEntity map(Map data, {String? tablePrefix}) { final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; return DraftMessageEntity( - id: attachedDatabase.typeMapping - .read(DriftSqlType.string, data['${effectivePrefix}id'])!, - messageText: attachedDatabase.typeMapping - .read(DriftSqlType.string, data['${effectivePrefix}message_text']), + id: attachedDatabase.typeMapping.read(DriftSqlType.string, data['${effectivePrefix}id'])!, + messageText: attachedDatabase.typeMapping.read(DriftSqlType.string, data['${effectivePrefix}message_text']), attachments: $DraftMessagesTable.$converterattachments.fromSql( - attachedDatabase.typeMapping.read( - DriftSqlType.string, data['${effectivePrefix}attachments'])!), - type: attachedDatabase.typeMapping - .read(DriftSqlType.string, data['${effectivePrefix}type'])!, + attachedDatabase.typeMapping.read(DriftSqlType.string, data['${effectivePrefix}attachments'])!, + ), + type: attachedDatabase.typeMapping.read(DriftSqlType.string, data['${effectivePrefix}type'])!, mentionedUsers: $DraftMessagesTable.$convertermentionedUsers.fromSql( - attachedDatabase.typeMapping.read( - DriftSqlType.string, data['${effectivePrefix}mentioned_users'])!), - parentId: attachedDatabase.typeMapping - .read(DriftSqlType.string, data['${effectivePrefix}parent_id']), + attachedDatabase.typeMapping.read(DriftSqlType.string, data['${effectivePrefix}mentioned_users'])!, + ), + parentId: attachedDatabase.typeMapping.read(DriftSqlType.string, data['${effectivePrefix}parent_id']), quotedMessageId: attachedDatabase.typeMapping.read( - DriftSqlType.string, data['${effectivePrefix}quoted_message_id']), - pollId: attachedDatabase.typeMapping - .read(DriftSqlType.string, data['${effectivePrefix}poll_id']), - showInChannel: attachedDatabase.typeMapping - .read(DriftSqlType.bool, data['${effectivePrefix}show_in_channel']), - command: attachedDatabase.typeMapping - .read(DriftSqlType.string, data['${effectivePrefix}command']), - silent: attachedDatabase.typeMapping - .read(DriftSqlType.bool, data['${effectivePrefix}silent'])!, - createdAt: attachedDatabase.typeMapping - .read(DriftSqlType.dateTime, data['${effectivePrefix}created_at'])!, - channelCid: attachedDatabase.typeMapping - .read(DriftSqlType.string, data['${effectivePrefix}channel_cid'])!, + DriftSqlType.string, + data['${effectivePrefix}quoted_message_id'], + ), + pollId: attachedDatabase.typeMapping.read(DriftSqlType.string, data['${effectivePrefix}poll_id']), + showInChannel: attachedDatabase.typeMapping.read(DriftSqlType.bool, data['${effectivePrefix}show_in_channel']), + command: attachedDatabase.typeMapping.read(DriftSqlType.string, data['${effectivePrefix}command']), + silent: attachedDatabase.typeMapping.read(DriftSqlType.bool, data['${effectivePrefix}silent'])!, + createdAt: attachedDatabase.typeMapping.read(DriftSqlType.dateTime, data['${effectivePrefix}created_at'])!, + channelCid: attachedDatabase.typeMapping.read(DriftSqlType.string, data['${effectivePrefix}channel_cid'])!, extraData: $DraftMessagesTable.$converterextraDatan.fromSql( - attachedDatabase.typeMapping - .read(DriftSqlType.string, data['${effectivePrefix}extra_data'])), + attachedDatabase.typeMapping.read(DriftSqlType.string, data['${effectivePrefix}extra_data']), + ), ); } @@ -2458,18 +2460,15 @@ class $DraftMessagesTable extends DraftMessages return $DraftMessagesTable(attachedDatabase, alias); } - static TypeConverter, String> $converterattachments = - ListConverter(); - static TypeConverter, String> $convertermentionedUsers = - ListConverter(); - static TypeConverter, String> $converterextraData = - MapConverter(); - static TypeConverter?, String?> $converterextraDatan = - NullAwareTypeConverter.wrap($converterextraData); + static TypeConverter, String> $converterattachments = ListConverter(); + static TypeConverter, String> $convertermentionedUsers = ListConverter(); + static TypeConverter, String> $converterextraData = MapConverter(); + static TypeConverter?, String?> $converterextraDatan = NullAwareTypeConverter.wrap( + $converterextraData, + ); } -class DraftMessageEntity extends DataClass - implements Insertable { +class DraftMessageEntity extends DataClass implements Insertable { /// The message id final String id; @@ -2512,21 +2511,22 @@ class DraftMessageEntity extends DataClass /// Message custom extraData final Map? extraData; - const DraftMessageEntity( - {required this.id, - this.messageText, - required this.attachments, - required this.type, - required this.mentionedUsers, - this.parentId, - this.quotedMessageId, - this.pollId, - this.showInChannel, - this.command, - required this.silent, - required this.createdAt, - required this.channelCid, - this.extraData}); + const DraftMessageEntity({ + required this.id, + this.messageText, + required this.attachments, + required this.type, + required this.mentionedUsers, + this.parentId, + this.quotedMessageId, + this.pollId, + this.showInChannel, + this.command, + required this.silent, + required this.createdAt, + required this.channelCid, + this.extraData, + }); @override Map toColumns(bool nullToAbsent) { final map = {}; @@ -2535,13 +2535,11 @@ class DraftMessageEntity extends DataClass map['message_text'] = Variable(messageText); } { - map['attachments'] = Variable( - $DraftMessagesTable.$converterattachments.toSql(attachments)); + map['attachments'] = Variable($DraftMessagesTable.$converterattachments.toSql(attachments)); } map['type'] = Variable(type); { - map['mentioned_users'] = Variable( - $DraftMessagesTable.$convertermentionedUsers.toSql(mentionedUsers)); + map['mentioned_users'] = Variable($DraftMessagesTable.$convertermentionedUsers.toSql(mentionedUsers)); } if (!nullToAbsent || parentId != null) { map['parent_id'] = Variable(parentId); @@ -2562,14 +2560,12 @@ class DraftMessageEntity extends DataClass map['created_at'] = Variable(createdAt); map['channel_cid'] = Variable(channelCid); if (!nullToAbsent || extraData != null) { - map['extra_data'] = Variable( - $DraftMessagesTable.$converterextraDatan.toSql(extraData)); + map['extra_data'] = Variable($DraftMessagesTable.$converterextraDatan.toSql(extraData)); } return map; } - factory DraftMessageEntity.fromJson(Map json, - {ValueSerializer? serializer}) { + factory DraftMessageEntity.fromJson(Map json, {ValueSerializer? serializer}) { serializer ??= driftRuntimeOptions.defaultSerializer; return DraftMessageEntity( id: serializer.fromJson(json['id']), @@ -2609,64 +2605,52 @@ class DraftMessageEntity extends DataClass }; } - DraftMessageEntity copyWith( - {String? id, - Value messageText = const Value.absent(), - List? attachments, - String? type, - List? mentionedUsers, - Value parentId = const Value.absent(), - Value quotedMessageId = const Value.absent(), - Value pollId = const Value.absent(), - Value showInChannel = const Value.absent(), - Value command = const Value.absent(), - bool? silent, - DateTime? createdAt, - String? channelCid, - Value?> extraData = const Value.absent()}) => - DraftMessageEntity( - id: id ?? this.id, - messageText: messageText.present ? messageText.value : this.messageText, - attachments: attachments ?? this.attachments, - type: type ?? this.type, - mentionedUsers: mentionedUsers ?? this.mentionedUsers, - parentId: parentId.present ? parentId.value : this.parentId, - quotedMessageId: quotedMessageId.present - ? quotedMessageId.value - : this.quotedMessageId, - pollId: pollId.present ? pollId.value : this.pollId, - showInChannel: - showInChannel.present ? showInChannel.value : this.showInChannel, - command: command.present ? command.value : this.command, - silent: silent ?? this.silent, - createdAt: createdAt ?? this.createdAt, - channelCid: channelCid ?? this.channelCid, - extraData: extraData.present ? extraData.value : this.extraData, - ); + DraftMessageEntity copyWith({ + String? id, + Value messageText = const Value.absent(), + List? attachments, + String? type, + List? mentionedUsers, + Value parentId = const Value.absent(), + Value quotedMessageId = const Value.absent(), + Value pollId = const Value.absent(), + Value showInChannel = const Value.absent(), + Value command = const Value.absent(), + bool? silent, + DateTime? createdAt, + String? channelCid, + Value?> extraData = const Value.absent(), + }) => DraftMessageEntity( + id: id ?? this.id, + messageText: messageText.present ? messageText.value : this.messageText, + attachments: attachments ?? this.attachments, + type: type ?? this.type, + mentionedUsers: mentionedUsers ?? this.mentionedUsers, + parentId: parentId.present ? parentId.value : this.parentId, + quotedMessageId: quotedMessageId.present ? quotedMessageId.value : this.quotedMessageId, + pollId: pollId.present ? pollId.value : this.pollId, + showInChannel: showInChannel.present ? showInChannel.value : this.showInChannel, + command: command.present ? command.value : this.command, + silent: silent ?? this.silent, + createdAt: createdAt ?? this.createdAt, + channelCid: channelCid ?? this.channelCid, + extraData: extraData.present ? extraData.value : this.extraData, + ); DraftMessageEntity copyWithCompanion(DraftMessagesCompanion data) { return DraftMessageEntity( id: data.id.present ? data.id.value : this.id, - messageText: - data.messageText.present ? data.messageText.value : this.messageText, - attachments: - data.attachments.present ? data.attachments.value : this.attachments, + messageText: data.messageText.present ? data.messageText.value : this.messageText, + attachments: data.attachments.present ? data.attachments.value : this.attachments, type: data.type.present ? data.type.value : this.type, - mentionedUsers: data.mentionedUsers.present - ? data.mentionedUsers.value - : this.mentionedUsers, + mentionedUsers: data.mentionedUsers.present ? data.mentionedUsers.value : this.mentionedUsers, parentId: data.parentId.present ? data.parentId.value : this.parentId, - quotedMessageId: data.quotedMessageId.present - ? data.quotedMessageId.value - : this.quotedMessageId, + quotedMessageId: data.quotedMessageId.present ? data.quotedMessageId.value : this.quotedMessageId, pollId: data.pollId.present ? data.pollId.value : this.pollId, - showInChannel: data.showInChannel.present - ? data.showInChannel.value - : this.showInChannel, + showInChannel: data.showInChannel.present ? data.showInChannel.value : this.showInChannel, command: data.command.present ? data.command.value : this.command, silent: data.silent.present ? data.silent.value : this.silent, createdAt: data.createdAt.present ? data.createdAt.value : this.createdAt, - channelCid: - data.channelCid.present ? data.channelCid.value : this.channelCid, + channelCid: data.channelCid.present ? data.channelCid.value : this.channelCid, extraData: data.extraData.present ? data.extraData.value : this.extraData, ); } @@ -2694,20 +2678,21 @@ class DraftMessageEntity extends DataClass @override int get hashCode => Object.hash( - id, - messageText, - attachments, - type, - mentionedUsers, - parentId, - quotedMessageId, - pollId, - showInChannel, - command, - silent, - createdAt, - channelCid, - extraData); + id, + messageText, + attachments, + type, + mentionedUsers, + parentId, + quotedMessageId, + pollId, + showInChannel, + command, + silent, + createdAt, + channelCid, + extraData, + ); @override bool operator ==(Object other) => identical(this, other) || @@ -2777,10 +2762,10 @@ class DraftMessagesCompanion extends UpdateCompanion { required String channelCid, this.extraData = const Value.absent(), this.rowid = const Value.absent(), - }) : id = Value(id), - attachments = Value(attachments), - mentionedUsers = Value(mentionedUsers), - channelCid = Value(channelCid); + }) : id = Value(id), + attachments = Value(attachments), + mentionedUsers = Value(mentionedUsers), + channelCid = Value(channelCid); static Insertable custom({ Expression? id, Expression? messageText, @@ -2817,22 +2802,23 @@ class DraftMessagesCompanion extends UpdateCompanion { }); } - DraftMessagesCompanion copyWith( - {Value? id, - Value? messageText, - Value>? attachments, - Value? type, - Value>? mentionedUsers, - Value? parentId, - Value? quotedMessageId, - Value? pollId, - Value? showInChannel, - Value? command, - Value? silent, - Value? createdAt, - Value? channelCid, - Value?>? extraData, - Value? rowid}) { + DraftMessagesCompanion copyWith({ + Value? id, + Value? messageText, + Value>? attachments, + Value? type, + Value>? mentionedUsers, + Value? parentId, + Value? quotedMessageId, + Value? pollId, + Value? showInChannel, + Value? command, + Value? silent, + Value? createdAt, + Value? channelCid, + Value?>? extraData, + Value? rowid, + }) { return DraftMessagesCompanion( id: id ?? this.id, messageText: messageText ?? this.messageText, @@ -2862,16 +2848,15 @@ class DraftMessagesCompanion extends UpdateCompanion { map['message_text'] = Variable(messageText.value); } if (attachments.present) { - map['attachments'] = Variable( - $DraftMessagesTable.$converterattachments.toSql(attachments.value)); + map['attachments'] = Variable($DraftMessagesTable.$converterattachments.toSql(attachments.value)); } if (type.present) { map['type'] = Variable(type.value); } if (mentionedUsers.present) { - map['mentioned_users'] = Variable($DraftMessagesTable - .$convertermentionedUsers - .toSql(mentionedUsers.value)); + map['mentioned_users'] = Variable( + $DraftMessagesTable.$convertermentionedUsers.toSql(mentionedUsers.value), + ); } if (parentId.present) { map['parent_id'] = Variable(parentId.value); @@ -2898,8 +2883,7 @@ class DraftMessagesCompanion extends UpdateCompanion { map['channel_cid'] = Variable(channelCid.value); } if (extraData.present) { - map['extra_data'] = Variable( - $DraftMessagesTable.$converterextraDatan.toSql(extraData.value)); + map['extra_data'] = Variable($DraftMessagesTable.$converterextraDatan.toSql(extraData.value)); } if (rowid.present) { map['rowid'] = Variable(rowid.value); @@ -2930,557 +2914,1087 @@ class DraftMessagesCompanion extends UpdateCompanion { } } -class $PinnedMessagesTable extends PinnedMessages - with TableInfo<$PinnedMessagesTable, PinnedMessageEntity> { +class $LocationsTable extends Locations with TableInfo<$LocationsTable, LocationEntity> { @override final GeneratedDatabase attachedDatabase; final String? _alias; - $PinnedMessagesTable(this.attachedDatabase, [this._alias]); - static const VerificationMeta _idMeta = const VerificationMeta('id'); - @override - late final GeneratedColumn id = GeneratedColumn( - 'id', aliasedName, false, - type: DriftSqlType.string, requiredDuringInsert: true); - static const VerificationMeta _messageTextMeta = - const VerificationMeta('messageText'); - @override - late final GeneratedColumn messageText = GeneratedColumn( - 'message_text', aliasedName, true, - type: DriftSqlType.string, requiredDuringInsert: false); - static const VerificationMeta _attachmentsMeta = - const VerificationMeta('attachments'); - @override - late final GeneratedColumnWithTypeConverter, String> - attachments = GeneratedColumn('attachments', aliasedName, false, - type: DriftSqlType.string, requiredDuringInsert: true) - .withConverter>( - $PinnedMessagesTable.$converterattachments); - static const VerificationMeta _stateMeta = const VerificationMeta('state'); - @override - late final GeneratedColumn state = GeneratedColumn( - 'state', aliasedName, false, - type: DriftSqlType.string, requiredDuringInsert: true); - static const VerificationMeta _typeMeta = const VerificationMeta('type'); - @override - late final GeneratedColumn type = GeneratedColumn( - 'type', aliasedName, false, - type: DriftSqlType.string, - requiredDuringInsert: false, - defaultValue: const Constant('regular')); - static const VerificationMeta _mentionedUsersMeta = - const VerificationMeta('mentionedUsers'); - @override - late final GeneratedColumnWithTypeConverter, String> - mentionedUsers = GeneratedColumn( - 'mentioned_users', aliasedName, false, - type: DriftSqlType.string, requiredDuringInsert: true) - .withConverter>( - $PinnedMessagesTable.$convertermentionedUsers); - static const VerificationMeta _reactionGroupsMeta = - const VerificationMeta('reactionGroups'); - @override - late final GeneratedColumnWithTypeConverter?, - String> reactionGroups = GeneratedColumn( - 'reaction_groups', aliasedName, true, - type: DriftSqlType.string, requiredDuringInsert: false) - .withConverter?>( - $PinnedMessagesTable.$converterreactionGroupsn); - static const VerificationMeta _parentIdMeta = - const VerificationMeta('parentId'); - @override - late final GeneratedColumn parentId = GeneratedColumn( - 'parent_id', aliasedName, true, - type: DriftSqlType.string, requiredDuringInsert: false); - static const VerificationMeta _quotedMessageIdMeta = - const VerificationMeta('quotedMessageId'); - @override - late final GeneratedColumn quotedMessageId = GeneratedColumn( - 'quoted_message_id', aliasedName, true, - type: DriftSqlType.string, requiredDuringInsert: false); - static const VerificationMeta _pollIdMeta = const VerificationMeta('pollId'); - @override - late final GeneratedColumn pollId = GeneratedColumn( - 'poll_id', aliasedName, true, - type: DriftSqlType.string, requiredDuringInsert: false); - static const VerificationMeta _replyCountMeta = - const VerificationMeta('replyCount'); + $LocationsTable(this.attachedDatabase, [this._alias]); + static const VerificationMeta _channelCidMeta = const VerificationMeta('channelCid'); @override - late final GeneratedColumn replyCount = GeneratedColumn( - 'reply_count', aliasedName, true, - type: DriftSqlType.int, requiredDuringInsert: false); - static const VerificationMeta _showInChannelMeta = - const VerificationMeta('showInChannel'); - @override - late final GeneratedColumn showInChannel = GeneratedColumn( - 'show_in_channel', aliasedName, true, - type: DriftSqlType.bool, - requiredDuringInsert: false, - defaultConstraints: GeneratedColumn.constraintIsAlways( - 'CHECK ("show_in_channel" IN (0, 1))')); - static const VerificationMeta _shadowedMeta = - const VerificationMeta('shadowed'); - @override - late final GeneratedColumn shadowed = GeneratedColumn( - 'shadowed', aliasedName, false, - type: DriftSqlType.bool, - requiredDuringInsert: false, - defaultConstraints: - GeneratedColumn.constraintIsAlways('CHECK ("shadowed" IN (0, 1))'), - defaultValue: const Constant(false)); - static const VerificationMeta _commandMeta = - const VerificationMeta('command'); + late final GeneratedColumn channelCid = GeneratedColumn( + 'channel_cid', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + defaultConstraints: GeneratedColumn.constraintIsAlways('REFERENCES channels (cid) ON DELETE CASCADE'), + ); + static const VerificationMeta _messageIdMeta = const VerificationMeta('messageId'); @override - late final GeneratedColumn command = GeneratedColumn( - 'command', aliasedName, true, - type: DriftSqlType.string, requiredDuringInsert: false); - static const VerificationMeta _localCreatedAtMeta = - const VerificationMeta('localCreatedAt'); - @override - late final GeneratedColumn localCreatedAt = - GeneratedColumn('local_created_at', aliasedName, true, - type: DriftSqlType.dateTime, requiredDuringInsert: false); - static const VerificationMeta _remoteCreatedAtMeta = - const VerificationMeta('remoteCreatedAt'); - @override - late final GeneratedColumn remoteCreatedAt = - GeneratedColumn('remote_created_at', aliasedName, true, - type: DriftSqlType.dateTime, requiredDuringInsert: false); - static const VerificationMeta _localUpdatedAtMeta = - const VerificationMeta('localUpdatedAt'); - @override - late final GeneratedColumn localUpdatedAt = - GeneratedColumn('local_updated_at', aliasedName, true, - type: DriftSqlType.dateTime, requiredDuringInsert: false); - static const VerificationMeta _remoteUpdatedAtMeta = - const VerificationMeta('remoteUpdatedAt'); - @override - late final GeneratedColumn remoteUpdatedAt = - GeneratedColumn('remote_updated_at', aliasedName, true, - type: DriftSqlType.dateTime, requiredDuringInsert: false); - static const VerificationMeta _localDeletedAtMeta = - const VerificationMeta('localDeletedAt'); - @override - late final GeneratedColumn localDeletedAt = - GeneratedColumn('local_deleted_at', aliasedName, true, - type: DriftSqlType.dateTime, requiredDuringInsert: false); - static const VerificationMeta _remoteDeletedAtMeta = - const VerificationMeta('remoteDeletedAt'); - @override - late final GeneratedColumn remoteDeletedAt = - GeneratedColumn('remote_deleted_at', aliasedName, true, - type: DriftSqlType.dateTime, requiredDuringInsert: false); - static const VerificationMeta _messageTextUpdatedAtMeta = - const VerificationMeta('messageTextUpdatedAt'); - @override - late final GeneratedColumn messageTextUpdatedAt = - GeneratedColumn('message_text_updated_at', aliasedName, true, - type: DriftSqlType.dateTime, requiredDuringInsert: false); + late final GeneratedColumn messageId = GeneratedColumn( + 'message_id', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + defaultConstraints: GeneratedColumn.constraintIsAlways('REFERENCES messages (id) ON DELETE CASCADE'), + ); static const VerificationMeta _userIdMeta = const VerificationMeta('userId'); @override late final GeneratedColumn userId = GeneratedColumn( - 'user_id', aliasedName, true, - type: DriftSqlType.string, requiredDuringInsert: false); - static const VerificationMeta _channelRoleMeta = - const VerificationMeta('channelRole'); + 'user_id', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); + static const VerificationMeta _latitudeMeta = const VerificationMeta('latitude'); + @override + late final GeneratedColumn latitude = GeneratedColumn( + 'latitude', + aliasedName, + false, + type: DriftSqlType.double, + requiredDuringInsert: true, + ); + static const VerificationMeta _longitudeMeta = const VerificationMeta('longitude'); + @override + late final GeneratedColumn longitude = GeneratedColumn( + 'longitude', + aliasedName, + false, + type: DriftSqlType.double, + requiredDuringInsert: true, + ); + static const VerificationMeta _createdByDeviceIdMeta = const VerificationMeta('createdByDeviceId'); + @override + late final GeneratedColumn createdByDeviceId = GeneratedColumn( + 'created_by_device_id', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); + static const VerificationMeta _endAtMeta = const VerificationMeta('endAt'); + @override + late final GeneratedColumn endAt = GeneratedColumn( + 'end_at', + aliasedName, + true, + type: DriftSqlType.dateTime, + requiredDuringInsert: false, + ); + static const VerificationMeta _createdAtMeta = const VerificationMeta('createdAt'); @override - late final GeneratedColumn channelRole = GeneratedColumn( - 'channel_role', aliasedName, true, - type: DriftSqlType.string, requiredDuringInsert: false); - static const VerificationMeta _pinnedMeta = const VerificationMeta('pinned'); - @override - late final GeneratedColumn pinned = GeneratedColumn( - 'pinned', aliasedName, false, - type: DriftSqlType.bool, - requiredDuringInsert: false, - defaultConstraints: - GeneratedColumn.constraintIsAlways('CHECK ("pinned" IN (0, 1))'), - defaultValue: const Constant(false)); - static const VerificationMeta _pinnedAtMeta = - const VerificationMeta('pinnedAt'); - @override - late final GeneratedColumn pinnedAt = GeneratedColumn( - 'pinned_at', aliasedName, true, - type: DriftSqlType.dateTime, requiredDuringInsert: false); - static const VerificationMeta _pinExpiresMeta = - const VerificationMeta('pinExpires'); - @override - late final GeneratedColumn pinExpires = GeneratedColumn( - 'pin_expires', aliasedName, true, - type: DriftSqlType.dateTime, requiredDuringInsert: false); - static const VerificationMeta _pinnedByUserIdMeta = - const VerificationMeta('pinnedByUserId'); - @override - late final GeneratedColumn pinnedByUserId = GeneratedColumn( - 'pinned_by_user_id', aliasedName, true, - type: DriftSqlType.string, requiredDuringInsert: false); - static const VerificationMeta _channelCidMeta = - const VerificationMeta('channelCid'); + late final GeneratedColumn createdAt = GeneratedColumn( + 'created_at', + aliasedName, + false, + type: DriftSqlType.dateTime, + requiredDuringInsert: false, + defaultValue: currentDateAndTime, + ); + static const VerificationMeta _updatedAtMeta = const VerificationMeta('updatedAt'); @override - late final GeneratedColumn channelCid = GeneratedColumn( - 'channel_cid', aliasedName, false, - type: DriftSqlType.string, requiredDuringInsert: true); - static const VerificationMeta _i18nMeta = const VerificationMeta('i18n'); - @override - late final GeneratedColumnWithTypeConverter?, String> - i18n = GeneratedColumn('i18n', aliasedName, true, - type: DriftSqlType.string, requiredDuringInsert: false) - .withConverter?>( - $PinnedMessagesTable.$converteri18n); - static const VerificationMeta _restrictedVisibilityMeta = - const VerificationMeta('restrictedVisibility'); - @override - late final GeneratedColumnWithTypeConverter?, String> - restrictedVisibility = GeneratedColumn( - 'restricted_visibility', aliasedName, true, - type: DriftSqlType.string, requiredDuringInsert: false) - .withConverter?>( - $PinnedMessagesTable.$converterrestrictedVisibilityn); - static const VerificationMeta _extraDataMeta = - const VerificationMeta('extraData'); - @override - late final GeneratedColumnWithTypeConverter?, String> - extraData = GeneratedColumn('extra_data', aliasedName, true, - type: DriftSqlType.string, requiredDuringInsert: false) - .withConverter?>( - $PinnedMessagesTable.$converterextraDatan); + late final GeneratedColumn updatedAt = GeneratedColumn( + 'updated_at', + aliasedName, + false, + type: DriftSqlType.dateTime, + requiredDuringInsert: false, + defaultValue: currentDateAndTime, + ); @override List get $columns => [ - id, - messageText, - attachments, - state, - type, - mentionedUsers, - reactionGroups, - parentId, - quotedMessageId, - pollId, - replyCount, - showInChannel, - shadowed, - command, - localCreatedAt, - remoteCreatedAt, - localUpdatedAt, - remoteUpdatedAt, - localDeletedAt, - remoteDeletedAt, - messageTextUpdatedAt, - userId, - channelRole, - pinned, - pinnedAt, - pinExpires, - pinnedByUserId, - channelCid, - i18n, - restrictedVisibility, - extraData - ]; + channelCid, + messageId, + userId, + latitude, + longitude, + createdByDeviceId, + endAt, + createdAt, + updatedAt, + ]; @override String get aliasedName => _alias ?? actualTableName; @override String get actualTableName => $name; - static const String $name = 'pinned_messages'; + static const String $name = 'locations'; @override - VerificationContext validateIntegrity( - Insertable instance, - {bool isInserting = false}) { + VerificationContext validateIntegrity(Insertable instance, {bool isInserting = false}) { final context = VerificationContext(); final data = instance.toColumns(true); - if (data.containsKey('id')) { - context.handle(_idMeta, id.isAcceptableOrUnknown(data['id']!, _idMeta)); - } else if (isInserting) { - context.missing(_idMeta); - } - if (data.containsKey('message_text')) { - context.handle( - _messageTextMeta, - messageText.isAcceptableOrUnknown( - data['message_text']!, _messageTextMeta)); - } - context.handle(_attachmentsMeta, const VerificationResult.success()); - if (data.containsKey('state')) { - context.handle( - _stateMeta, state.isAcceptableOrUnknown(data['state']!, _stateMeta)); - } else if (isInserting) { - context.missing(_stateMeta); - } - if (data.containsKey('type')) { - context.handle( - _typeMeta, type.isAcceptableOrUnknown(data['type']!, _typeMeta)); - } - context.handle(_mentionedUsersMeta, const VerificationResult.success()); - context.handle(_reactionGroupsMeta, const VerificationResult.success()); - if (data.containsKey('parent_id')) { - context.handle(_parentIdMeta, - parentId.isAcceptableOrUnknown(data['parent_id']!, _parentIdMeta)); - } - if (data.containsKey('quoted_message_id')) { - context.handle( - _quotedMessageIdMeta, - quotedMessageId.isAcceptableOrUnknown( - data['quoted_message_id']!, _quotedMessageIdMeta)); - } - if (data.containsKey('poll_id')) { - context.handle(_pollIdMeta, - pollId.isAcceptableOrUnknown(data['poll_id']!, _pollIdMeta)); - } - if (data.containsKey('reply_count')) { - context.handle( - _replyCountMeta, - replyCount.isAcceptableOrUnknown( - data['reply_count']!, _replyCountMeta)); - } - if (data.containsKey('show_in_channel')) { - context.handle( - _showInChannelMeta, - showInChannel.isAcceptableOrUnknown( - data['show_in_channel']!, _showInChannelMeta)); - } - if (data.containsKey('shadowed')) { - context.handle(_shadowedMeta, - shadowed.isAcceptableOrUnknown(data['shadowed']!, _shadowedMeta)); - } - if (data.containsKey('command')) { - context.handle(_commandMeta, - command.isAcceptableOrUnknown(data['command']!, _commandMeta)); - } - if (data.containsKey('local_created_at')) { - context.handle( - _localCreatedAtMeta, - localCreatedAt.isAcceptableOrUnknown( - data['local_created_at']!, _localCreatedAtMeta)); - } - if (data.containsKey('remote_created_at')) { - context.handle( - _remoteCreatedAtMeta, - remoteCreatedAt.isAcceptableOrUnknown( - data['remote_created_at']!, _remoteCreatedAtMeta)); - } - if (data.containsKey('local_updated_at')) { - context.handle( - _localUpdatedAtMeta, - localUpdatedAt.isAcceptableOrUnknown( - data['local_updated_at']!, _localUpdatedAtMeta)); - } - if (data.containsKey('remote_updated_at')) { - context.handle( - _remoteUpdatedAtMeta, - remoteUpdatedAt.isAcceptableOrUnknown( - data['remote_updated_at']!, _remoteUpdatedAtMeta)); - } - if (data.containsKey('local_deleted_at')) { - context.handle( - _localDeletedAtMeta, - localDeletedAt.isAcceptableOrUnknown( - data['local_deleted_at']!, _localDeletedAtMeta)); - } - if (data.containsKey('remote_deleted_at')) { - context.handle( - _remoteDeletedAtMeta, - remoteDeletedAt.isAcceptableOrUnknown( - data['remote_deleted_at']!, _remoteDeletedAtMeta)); + if (data.containsKey('channel_cid')) { + context.handle(_channelCidMeta, channelCid.isAcceptableOrUnknown(data['channel_cid']!, _channelCidMeta)); } - if (data.containsKey('message_text_updated_at')) { - context.handle( - _messageTextUpdatedAtMeta, - messageTextUpdatedAt.isAcceptableOrUnknown( - data['message_text_updated_at']!, _messageTextUpdatedAtMeta)); + if (data.containsKey('message_id')) { + context.handle(_messageIdMeta, messageId.isAcceptableOrUnknown(data['message_id']!, _messageIdMeta)); } if (data.containsKey('user_id')) { - context.handle(_userIdMeta, - userId.isAcceptableOrUnknown(data['user_id']!, _userIdMeta)); + context.handle(_userIdMeta, userId.isAcceptableOrUnknown(data['user_id']!, _userIdMeta)); } - if (data.containsKey('channel_role')) { - context.handle( - _channelRoleMeta, - channelRole.isAcceptableOrUnknown( - data['channel_role']!, _channelRoleMeta)); - } - if (data.containsKey('pinned')) { - context.handle(_pinnedMeta, - pinned.isAcceptableOrUnknown(data['pinned']!, _pinnedMeta)); + if (data.containsKey('latitude')) { + context.handle(_latitudeMeta, latitude.isAcceptableOrUnknown(data['latitude']!, _latitudeMeta)); + } else if (isInserting) { + context.missing(_latitudeMeta); } - if (data.containsKey('pinned_at')) { - context.handle(_pinnedAtMeta, - pinnedAt.isAcceptableOrUnknown(data['pinned_at']!, _pinnedAtMeta)); + if (data.containsKey('longitude')) { + context.handle(_longitudeMeta, longitude.isAcceptableOrUnknown(data['longitude']!, _longitudeMeta)); + } else if (isInserting) { + context.missing(_longitudeMeta); } - if (data.containsKey('pin_expires')) { + if (data.containsKey('created_by_device_id')) { context.handle( - _pinExpiresMeta, - pinExpires.isAcceptableOrUnknown( - data['pin_expires']!, _pinExpiresMeta)); + _createdByDeviceIdMeta, + createdByDeviceId.isAcceptableOrUnknown(data['created_by_device_id']!, _createdByDeviceIdMeta), + ); } - if (data.containsKey('pinned_by_user_id')) { - context.handle( - _pinnedByUserIdMeta, - pinnedByUserId.isAcceptableOrUnknown( - data['pinned_by_user_id']!, _pinnedByUserIdMeta)); + if (data.containsKey('end_at')) { + context.handle(_endAtMeta, endAt.isAcceptableOrUnknown(data['end_at']!, _endAtMeta)); } - if (data.containsKey('channel_cid')) { - context.handle( - _channelCidMeta, - channelCid.isAcceptableOrUnknown( - data['channel_cid']!, _channelCidMeta)); - } else if (isInserting) { - context.missing(_channelCidMeta); + if (data.containsKey('created_at')) { + context.handle(_createdAtMeta, createdAt.isAcceptableOrUnknown(data['created_at']!, _createdAtMeta)); + } + if (data.containsKey('updated_at')) { + context.handle(_updatedAtMeta, updatedAt.isAcceptableOrUnknown(data['updated_at']!, _updatedAtMeta)); } - context.handle(_i18nMeta, const VerificationResult.success()); - context.handle( - _restrictedVisibilityMeta, const VerificationResult.success()); - context.handle(_extraDataMeta, const VerificationResult.success()); return context; } @override - Set get $primaryKey => {id}; + Set get $primaryKey => {messageId}; @override - PinnedMessageEntity map(Map data, {String? tablePrefix}) { + LocationEntity map(Map data, {String? tablePrefix}) { final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; - return PinnedMessageEntity( - id: attachedDatabase.typeMapping - .read(DriftSqlType.string, data['${effectivePrefix}id'])!, - messageText: attachedDatabase.typeMapping - .read(DriftSqlType.string, data['${effectivePrefix}message_text']), - attachments: $PinnedMessagesTable.$converterattachments.fromSql( - attachedDatabase.typeMapping.read( - DriftSqlType.string, data['${effectivePrefix}attachments'])!), - state: attachedDatabase.typeMapping - .read(DriftSqlType.string, data['${effectivePrefix}state'])!, - type: attachedDatabase.typeMapping - .read(DriftSqlType.string, data['${effectivePrefix}type'])!, - mentionedUsers: $PinnedMessagesTable.$convertermentionedUsers.fromSql( - attachedDatabase.typeMapping.read( - DriftSqlType.string, data['${effectivePrefix}mentioned_users'])!), - reactionGroups: $PinnedMessagesTable.$converterreactionGroupsn.fromSql( - attachedDatabase.typeMapping.read( - DriftSqlType.string, data['${effectivePrefix}reaction_groups'])), - parentId: attachedDatabase.typeMapping - .read(DriftSqlType.string, data['${effectivePrefix}parent_id']), - quotedMessageId: attachedDatabase.typeMapping.read( - DriftSqlType.string, data['${effectivePrefix}quoted_message_id']), - pollId: attachedDatabase.typeMapping - .read(DriftSqlType.string, data['${effectivePrefix}poll_id']), - replyCount: attachedDatabase.typeMapping - .read(DriftSqlType.int, data['${effectivePrefix}reply_count']), - showInChannel: attachedDatabase.typeMapping - .read(DriftSqlType.bool, data['${effectivePrefix}show_in_channel']), - shadowed: attachedDatabase.typeMapping - .read(DriftSqlType.bool, data['${effectivePrefix}shadowed'])!, - command: attachedDatabase.typeMapping - .read(DriftSqlType.string, data['${effectivePrefix}command']), - localCreatedAt: attachedDatabase.typeMapping.read( - DriftSqlType.dateTime, data['${effectivePrefix}local_created_at']), - remoteCreatedAt: attachedDatabase.typeMapping.read( - DriftSqlType.dateTime, data['${effectivePrefix}remote_created_at']), - localUpdatedAt: attachedDatabase.typeMapping.read( - DriftSqlType.dateTime, data['${effectivePrefix}local_updated_at']), - remoteUpdatedAt: attachedDatabase.typeMapping.read( - DriftSqlType.dateTime, data['${effectivePrefix}remote_updated_at']), - localDeletedAt: attachedDatabase.typeMapping.read( - DriftSqlType.dateTime, data['${effectivePrefix}local_deleted_at']), - remoteDeletedAt: attachedDatabase.typeMapping.read( - DriftSqlType.dateTime, data['${effectivePrefix}remote_deleted_at']), - messageTextUpdatedAt: attachedDatabase.typeMapping.read( - DriftSqlType.dateTime, - data['${effectivePrefix}message_text_updated_at']), - userId: attachedDatabase.typeMapping - .read(DriftSqlType.string, data['${effectivePrefix}user_id']), - channelRole: attachedDatabase.typeMapping - .read(DriftSqlType.string, data['${effectivePrefix}channel_role']), - pinned: attachedDatabase.typeMapping - .read(DriftSqlType.bool, data['${effectivePrefix}pinned'])!, - pinnedAt: attachedDatabase.typeMapping - .read(DriftSqlType.dateTime, data['${effectivePrefix}pinned_at']), - pinExpires: attachedDatabase.typeMapping - .read(DriftSqlType.dateTime, data['${effectivePrefix}pin_expires']), - pinnedByUserId: attachedDatabase.typeMapping.read( - DriftSqlType.string, data['${effectivePrefix}pinned_by_user_id']), - channelCid: attachedDatabase.typeMapping - .read(DriftSqlType.string, data['${effectivePrefix}channel_cid'])!, - i18n: $PinnedMessagesTable.$converteri18n.fromSql(attachedDatabase - .typeMapping - .read(DriftSqlType.string, data['${effectivePrefix}i18n'])), - restrictedVisibility: $PinnedMessagesTable.$converterrestrictedVisibilityn - .fromSql(attachedDatabase.typeMapping.read(DriftSqlType.string, - data['${effectivePrefix}restricted_visibility'])), - extraData: $PinnedMessagesTable.$converterextraDatan.fromSql( - attachedDatabase.typeMapping - .read(DriftSqlType.string, data['${effectivePrefix}extra_data'])), + return LocationEntity( + channelCid: attachedDatabase.typeMapping.read(DriftSqlType.string, data['${effectivePrefix}channel_cid']), + messageId: attachedDatabase.typeMapping.read(DriftSqlType.string, data['${effectivePrefix}message_id']), + userId: attachedDatabase.typeMapping.read(DriftSqlType.string, data['${effectivePrefix}user_id']), + latitude: attachedDatabase.typeMapping.read(DriftSqlType.double, data['${effectivePrefix}latitude'])!, + longitude: attachedDatabase.typeMapping.read(DriftSqlType.double, data['${effectivePrefix}longitude'])!, + createdByDeviceId: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}created_by_device_id'], + ), + endAt: attachedDatabase.typeMapping.read(DriftSqlType.dateTime, data['${effectivePrefix}end_at']), + createdAt: attachedDatabase.typeMapping.read(DriftSqlType.dateTime, data['${effectivePrefix}created_at'])!, + updatedAt: attachedDatabase.typeMapping.read(DriftSqlType.dateTime, data['${effectivePrefix}updated_at'])!, ); } @override - $PinnedMessagesTable createAlias(String alias) { - return $PinnedMessagesTable(attachedDatabase, alias); + $LocationsTable createAlias(String alias) { + return $LocationsTable(attachedDatabase, alias); } - - static TypeConverter, String> $converterattachments = - ListConverter(); - static TypeConverter, String> $convertermentionedUsers = - ListConverter(); - static TypeConverter, String> - $converterreactionGroups = ReactionGroupsConverter(); - static TypeConverter?, String?> - $converterreactionGroupsn = - NullAwareTypeConverter.wrap($converterreactionGroups); - static TypeConverter?, String?> $converteri18n = - NullableMapConverter(); - static TypeConverter, String> $converterrestrictedVisibility = - ListConverter(); - static TypeConverter?, String?> $converterrestrictedVisibilityn = - NullAwareTypeConverter.wrap($converterrestrictedVisibility); - static TypeConverter, String> $converterextraData = - MapConverter(); - static TypeConverter?, String?> $converterextraDatan = - NullAwareTypeConverter.wrap($converterextraData); } -class PinnedMessageEntity extends DataClass - implements Insertable { - /// The message id - final String id; - - /// The text of this message - final String? messageText; - - /// The list of attachments, either provided by the user - /// or generated from a command or as a result of URL scraping. - final List attachments; - - /// The current state of the message. - final String state; +class LocationEntity extends DataClass implements Insertable { + /// The channel CID where the location is shared + final String? channelCid; - /// The message type - final String type; + /// The ID of the message that contains this shared location + final String? messageId; - /// The list of user mentioned in the message - final List mentionedUsers; + /// The ID of the user who shared the location + final String? userId; - /// A map describing the reaction group for every reaction - final Map? reactionGroups; + /// The latitude of the shared location + final double latitude; - /// The ID of the parent message, if the message is a thread reply. - final String? parentId; + /// The longitude of the shared location + final double longitude; - /// The ID of the quoted message, if the message is a quoted reply. - final String? quotedMessageId; + /// The ID of the device that created the location + final String? createdByDeviceId; - /// The ID of the poll, if the message is a poll. - final String? pollId; + /// The date at which the shared location will end (for live locations) + /// If null, this is a static location + final DateTime? endAt; - /// Number of replies for this message. - final int? replyCount; + /// The date at which the location was created + final DateTime createdAt; - /// Check if this message needs to show in the channel. - final bool? showInChannel; + /// The date at which the location was last updated + final DateTime updatedAt; + const LocationEntity({ + this.channelCid, + this.messageId, + this.userId, + required this.latitude, + required this.longitude, + this.createdByDeviceId, + this.endAt, + required this.createdAt, + required this.updatedAt, + }); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (!nullToAbsent || channelCid != null) { + map['channel_cid'] = Variable(channelCid); + } + if (!nullToAbsent || messageId != null) { + map['message_id'] = Variable(messageId); + } + if (!nullToAbsent || userId != null) { + map['user_id'] = Variable(userId); + } + map['latitude'] = Variable(latitude); + map['longitude'] = Variable(longitude); + if (!nullToAbsent || createdByDeviceId != null) { + map['created_by_device_id'] = Variable(createdByDeviceId); + } + if (!nullToAbsent || endAt != null) { + map['end_at'] = Variable(endAt); + } + map['created_at'] = Variable(createdAt); + map['updated_at'] = Variable(updatedAt); + return map; + } - /// If true the message is shadowed - final bool shadowed; + factory LocationEntity.fromJson(Map json, {ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return LocationEntity( + channelCid: serializer.fromJson(json['channelCid']), + messageId: serializer.fromJson(json['messageId']), + userId: serializer.fromJson(json['userId']), + latitude: serializer.fromJson(json['latitude']), + longitude: serializer.fromJson(json['longitude']), + createdByDeviceId: serializer.fromJson(json['createdByDeviceId']), + endAt: serializer.fromJson(json['endAt']), + createdAt: serializer.fromJson(json['createdAt']), + updatedAt: serializer.fromJson(json['updatedAt']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'channelCid': serializer.toJson(channelCid), + 'messageId': serializer.toJson(messageId), + 'userId': serializer.toJson(userId), + 'latitude': serializer.toJson(latitude), + 'longitude': serializer.toJson(longitude), + 'createdByDeviceId': serializer.toJson(createdByDeviceId), + 'endAt': serializer.toJson(endAt), + 'createdAt': serializer.toJson(createdAt), + 'updatedAt': serializer.toJson(updatedAt), + }; + } - /// A used command name. - final String? command; + LocationEntity copyWith({ + Value channelCid = const Value.absent(), + Value messageId = const Value.absent(), + Value userId = const Value.absent(), + double? latitude, + double? longitude, + Value createdByDeviceId = const Value.absent(), + Value endAt = const Value.absent(), + DateTime? createdAt, + DateTime? updatedAt, + }) => LocationEntity( + channelCid: channelCid.present ? channelCid.value : this.channelCid, + messageId: messageId.present ? messageId.value : this.messageId, + userId: userId.present ? userId.value : this.userId, + latitude: latitude ?? this.latitude, + longitude: longitude ?? this.longitude, + createdByDeviceId: createdByDeviceId.present ? createdByDeviceId.value : this.createdByDeviceId, + endAt: endAt.present ? endAt.value : this.endAt, + createdAt: createdAt ?? this.createdAt, + updatedAt: updatedAt ?? this.updatedAt, + ); + LocationEntity copyWithCompanion(LocationsCompanion data) { + return LocationEntity( + channelCid: data.channelCid.present ? data.channelCid.value : this.channelCid, + messageId: data.messageId.present ? data.messageId.value : this.messageId, + userId: data.userId.present ? data.userId.value : this.userId, + latitude: data.latitude.present ? data.latitude.value : this.latitude, + longitude: data.longitude.present ? data.longitude.value : this.longitude, + createdByDeviceId: data.createdByDeviceId.present ? data.createdByDeviceId.value : this.createdByDeviceId, + endAt: data.endAt.present ? data.endAt.value : this.endAt, + createdAt: data.createdAt.present ? data.createdAt.value : this.createdAt, + updatedAt: data.updatedAt.present ? data.updatedAt.value : this.updatedAt, + ); + } + + @override + String toString() { + return (StringBuffer('LocationEntity(') + ..write('channelCid: $channelCid, ') + ..write('messageId: $messageId, ') + ..write('userId: $userId, ') + ..write('latitude: $latitude, ') + ..write('longitude: $longitude, ') + ..write('createdByDeviceId: $createdByDeviceId, ') + ..write('endAt: $endAt, ') + ..write('createdAt: $createdAt, ') + ..write('updatedAt: $updatedAt') + ..write(')')) + .toString(); + } + + @override + int get hashCode => + Object.hash(channelCid, messageId, userId, latitude, longitude, createdByDeviceId, endAt, createdAt, updatedAt); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is LocationEntity && + other.channelCid == this.channelCid && + other.messageId == this.messageId && + other.userId == this.userId && + other.latitude == this.latitude && + other.longitude == this.longitude && + other.createdByDeviceId == this.createdByDeviceId && + other.endAt == this.endAt && + other.createdAt == this.createdAt && + other.updatedAt == this.updatedAt); +} + +class LocationsCompanion extends UpdateCompanion { + final Value channelCid; + final Value messageId; + final Value userId; + final Value latitude; + final Value longitude; + final Value createdByDeviceId; + final Value endAt; + final Value createdAt; + final Value updatedAt; + final Value rowid; + const LocationsCompanion({ + this.channelCid = const Value.absent(), + this.messageId = const Value.absent(), + this.userId = const Value.absent(), + this.latitude = const Value.absent(), + this.longitude = const Value.absent(), + this.createdByDeviceId = const Value.absent(), + this.endAt = const Value.absent(), + this.createdAt = const Value.absent(), + this.updatedAt = const Value.absent(), + this.rowid = const Value.absent(), + }); + LocationsCompanion.insert({ + this.channelCid = const Value.absent(), + this.messageId = const Value.absent(), + this.userId = const Value.absent(), + required double latitude, + required double longitude, + this.createdByDeviceId = const Value.absent(), + this.endAt = const Value.absent(), + this.createdAt = const Value.absent(), + this.updatedAt = const Value.absent(), + this.rowid = const Value.absent(), + }) : latitude = Value(latitude), + longitude = Value(longitude); + static Insertable custom({ + Expression? channelCid, + Expression? messageId, + Expression? userId, + Expression? latitude, + Expression? longitude, + Expression? createdByDeviceId, + Expression? endAt, + Expression? createdAt, + Expression? updatedAt, + Expression? rowid, + }) { + return RawValuesInsertable({ + if (channelCid != null) 'channel_cid': channelCid, + if (messageId != null) 'message_id': messageId, + if (userId != null) 'user_id': userId, + if (latitude != null) 'latitude': latitude, + if (longitude != null) 'longitude': longitude, + if (createdByDeviceId != null) 'created_by_device_id': createdByDeviceId, + if (endAt != null) 'end_at': endAt, + if (createdAt != null) 'created_at': createdAt, + if (updatedAt != null) 'updated_at': updatedAt, + if (rowid != null) 'rowid': rowid, + }); + } + + LocationsCompanion copyWith({ + Value? channelCid, + Value? messageId, + Value? userId, + Value? latitude, + Value? longitude, + Value? createdByDeviceId, + Value? endAt, + Value? createdAt, + Value? updatedAt, + Value? rowid, + }) { + return LocationsCompanion( + channelCid: channelCid ?? this.channelCid, + messageId: messageId ?? this.messageId, + userId: userId ?? this.userId, + latitude: latitude ?? this.latitude, + longitude: longitude ?? this.longitude, + createdByDeviceId: createdByDeviceId ?? this.createdByDeviceId, + endAt: endAt ?? this.endAt, + createdAt: createdAt ?? this.createdAt, + updatedAt: updatedAt ?? this.updatedAt, + rowid: rowid ?? this.rowid, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (channelCid.present) { + map['channel_cid'] = Variable(channelCid.value); + } + if (messageId.present) { + map['message_id'] = Variable(messageId.value); + } + if (userId.present) { + map['user_id'] = Variable(userId.value); + } + if (latitude.present) { + map['latitude'] = Variable(latitude.value); + } + if (longitude.present) { + map['longitude'] = Variable(longitude.value); + } + if (createdByDeviceId.present) { + map['created_by_device_id'] = Variable(createdByDeviceId.value); + } + if (endAt.present) { + map['end_at'] = Variable(endAt.value); + } + if (createdAt.present) { + map['created_at'] = Variable(createdAt.value); + } + if (updatedAt.present) { + map['updated_at'] = Variable(updatedAt.value); + } + if (rowid.present) { + map['rowid'] = Variable(rowid.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('LocationsCompanion(') + ..write('channelCid: $channelCid, ') + ..write('messageId: $messageId, ') + ..write('userId: $userId, ') + ..write('latitude: $latitude, ') + ..write('longitude: $longitude, ') + ..write('createdByDeviceId: $createdByDeviceId, ') + ..write('endAt: $endAt, ') + ..write('createdAt: $createdAt, ') + ..write('updatedAt: $updatedAt, ') + ..write('rowid: $rowid') + ..write(')')) + .toString(); + } +} + +class $PinnedMessagesTable extends PinnedMessages with TableInfo<$PinnedMessagesTable, PinnedMessageEntity> { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + $PinnedMessagesTable(this.attachedDatabase, [this._alias]); + static const VerificationMeta _idMeta = const VerificationMeta('id'); + @override + late final GeneratedColumn id = GeneratedColumn( + 'id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + static const VerificationMeta _messageTextMeta = const VerificationMeta('messageText'); + @override + late final GeneratedColumn messageText = GeneratedColumn( + 'message_text', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); + @override + late final GeneratedColumnWithTypeConverter, String> attachments = GeneratedColumn( + 'attachments', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ).withConverter>($PinnedMessagesTable.$converterattachments); + static const VerificationMeta _stateMeta = const VerificationMeta('state'); + @override + late final GeneratedColumn state = GeneratedColumn( + 'state', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + static const VerificationMeta _typeMeta = const VerificationMeta('type'); + @override + late final GeneratedColumn type = GeneratedColumn( + 'type', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: false, + defaultValue: const Constant('regular'), + ); + @override + late final GeneratedColumnWithTypeConverter, String> mentionedUsers = GeneratedColumn( + 'mentioned_users', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ).withConverter>($PinnedMessagesTable.$convertermentionedUsers); + @override + late final GeneratedColumnWithTypeConverter?, String> reactionGroups = + GeneratedColumn( + 'reaction_groups', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ).withConverter?>($PinnedMessagesTable.$converterreactionGroupsn); + static const VerificationMeta _parentIdMeta = const VerificationMeta('parentId'); + @override + late final GeneratedColumn parentId = GeneratedColumn( + 'parent_id', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); + static const VerificationMeta _quotedMessageIdMeta = const VerificationMeta('quotedMessageId'); + @override + late final GeneratedColumn quotedMessageId = GeneratedColumn( + 'quoted_message_id', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); + static const VerificationMeta _pollIdMeta = const VerificationMeta('pollId'); + @override + late final GeneratedColumn pollId = GeneratedColumn( + 'poll_id', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); + static const VerificationMeta _replyCountMeta = const VerificationMeta('replyCount'); + @override + late final GeneratedColumn replyCount = GeneratedColumn( + 'reply_count', + aliasedName, + true, + type: DriftSqlType.int, + requiredDuringInsert: false, + ); + static const VerificationMeta _showInChannelMeta = const VerificationMeta('showInChannel'); + @override + late final GeneratedColumn showInChannel = GeneratedColumn( + 'show_in_channel', + aliasedName, + true, + type: DriftSqlType.bool, + requiredDuringInsert: false, + defaultConstraints: GeneratedColumn.constraintIsAlways('CHECK ("show_in_channel" IN (0, 1))'), + ); + static const VerificationMeta _shadowedMeta = const VerificationMeta('shadowed'); + @override + late final GeneratedColumn shadowed = GeneratedColumn( + 'shadowed', + aliasedName, + false, + type: DriftSqlType.bool, + requiredDuringInsert: false, + defaultConstraints: GeneratedColumn.constraintIsAlways('CHECK ("shadowed" IN (0, 1))'), + defaultValue: const Constant(false), + ); + static const VerificationMeta _commandMeta = const VerificationMeta('command'); + @override + late final GeneratedColumn command = GeneratedColumn( + 'command', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); + static const VerificationMeta _localCreatedAtMeta = const VerificationMeta('localCreatedAt'); + @override + late final GeneratedColumn localCreatedAt = GeneratedColumn( + 'local_created_at', + aliasedName, + true, + type: DriftSqlType.dateTime, + requiredDuringInsert: false, + ); + static const VerificationMeta _remoteCreatedAtMeta = const VerificationMeta('remoteCreatedAt'); + @override + late final GeneratedColumn remoteCreatedAt = GeneratedColumn( + 'remote_created_at', + aliasedName, + true, + type: DriftSqlType.dateTime, + requiredDuringInsert: false, + ); + static const VerificationMeta _localUpdatedAtMeta = const VerificationMeta('localUpdatedAt'); + @override + late final GeneratedColumn localUpdatedAt = GeneratedColumn( + 'local_updated_at', + aliasedName, + true, + type: DriftSqlType.dateTime, + requiredDuringInsert: false, + ); + static const VerificationMeta _remoteUpdatedAtMeta = const VerificationMeta('remoteUpdatedAt'); + @override + late final GeneratedColumn remoteUpdatedAt = GeneratedColumn( + 'remote_updated_at', + aliasedName, + true, + type: DriftSqlType.dateTime, + requiredDuringInsert: false, + ); + static const VerificationMeta _localDeletedAtMeta = const VerificationMeta('localDeletedAt'); + @override + late final GeneratedColumn localDeletedAt = GeneratedColumn( + 'local_deleted_at', + aliasedName, + true, + type: DriftSqlType.dateTime, + requiredDuringInsert: false, + ); + static const VerificationMeta _remoteDeletedAtMeta = const VerificationMeta('remoteDeletedAt'); + @override + late final GeneratedColumn remoteDeletedAt = GeneratedColumn( + 'remote_deleted_at', + aliasedName, + true, + type: DriftSqlType.dateTime, + requiredDuringInsert: false, + ); + static const VerificationMeta _deletedForMeMeta = const VerificationMeta('deletedForMe'); + @override + late final GeneratedColumn deletedForMe = GeneratedColumn( + 'deleted_for_me', + aliasedName, + true, + type: DriftSqlType.bool, + requiredDuringInsert: false, + defaultConstraints: GeneratedColumn.constraintIsAlways('CHECK ("deleted_for_me" IN (0, 1))'), + ); + static const VerificationMeta _messageTextUpdatedAtMeta = const VerificationMeta('messageTextUpdatedAt'); + @override + late final GeneratedColumn messageTextUpdatedAt = GeneratedColumn( + 'message_text_updated_at', + aliasedName, + true, + type: DriftSqlType.dateTime, + requiredDuringInsert: false, + ); + static const VerificationMeta _userIdMeta = const VerificationMeta('userId'); + @override + late final GeneratedColumn userId = GeneratedColumn( + 'user_id', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); + static const VerificationMeta _channelRoleMeta = const VerificationMeta('channelRole'); + @override + late final GeneratedColumn channelRole = GeneratedColumn( + 'channel_role', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); + static const VerificationMeta _pinnedMeta = const VerificationMeta('pinned'); + @override + late final GeneratedColumn pinned = GeneratedColumn( + 'pinned', + aliasedName, + false, + type: DriftSqlType.bool, + requiredDuringInsert: false, + defaultConstraints: GeneratedColumn.constraintIsAlways('CHECK ("pinned" IN (0, 1))'), + defaultValue: const Constant(false), + ); + static const VerificationMeta _pinnedAtMeta = const VerificationMeta('pinnedAt'); + @override + late final GeneratedColumn pinnedAt = GeneratedColumn( + 'pinned_at', + aliasedName, + true, + type: DriftSqlType.dateTime, + requiredDuringInsert: false, + ); + static const VerificationMeta _pinExpiresMeta = const VerificationMeta('pinExpires'); + @override + late final GeneratedColumn pinExpires = GeneratedColumn( + 'pin_expires', + aliasedName, + true, + type: DriftSqlType.dateTime, + requiredDuringInsert: false, + ); + static const VerificationMeta _pinnedByUserIdMeta = const VerificationMeta('pinnedByUserId'); + @override + late final GeneratedColumn pinnedByUserId = GeneratedColumn( + 'pinned_by_user_id', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); + static const VerificationMeta _channelCidMeta = const VerificationMeta('channelCid'); + @override + late final GeneratedColumn channelCid = GeneratedColumn( + 'channel_cid', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + @override + late final GeneratedColumnWithTypeConverter?, String> i18n = GeneratedColumn( + 'i18n', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ).withConverter?>($PinnedMessagesTable.$converteri18n); + @override + late final GeneratedColumnWithTypeConverter?, String> restrictedVisibility = GeneratedColumn( + 'restricted_visibility', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ).withConverter?>($PinnedMessagesTable.$converterrestrictedVisibilityn); + @override + late final GeneratedColumnWithTypeConverter?, String> extraData = GeneratedColumn( + 'extra_data', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ).withConverter?>($PinnedMessagesTable.$converterextraDatan); + @override + List get $columns => [ + id, + messageText, + attachments, + state, + type, + mentionedUsers, + reactionGroups, + parentId, + quotedMessageId, + pollId, + replyCount, + showInChannel, + shadowed, + command, + localCreatedAt, + remoteCreatedAt, + localUpdatedAt, + remoteUpdatedAt, + localDeletedAt, + remoteDeletedAt, + deletedForMe, + messageTextUpdatedAt, + userId, + channelRole, + pinned, + pinnedAt, + pinExpires, + pinnedByUserId, + channelCid, + i18n, + restrictedVisibility, + extraData, + ]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'pinned_messages'; + @override + VerificationContext validateIntegrity(Insertable instance, {bool isInserting = false}) { + final context = VerificationContext(); + final data = instance.toColumns(true); + if (data.containsKey('id')) { + context.handle(_idMeta, id.isAcceptableOrUnknown(data['id']!, _idMeta)); + } else if (isInserting) { + context.missing(_idMeta); + } + if (data.containsKey('message_text')) { + context.handle(_messageTextMeta, messageText.isAcceptableOrUnknown(data['message_text']!, _messageTextMeta)); + } + if (data.containsKey('state')) { + context.handle(_stateMeta, state.isAcceptableOrUnknown(data['state']!, _stateMeta)); + } else if (isInserting) { + context.missing(_stateMeta); + } + if (data.containsKey('type')) { + context.handle(_typeMeta, type.isAcceptableOrUnknown(data['type']!, _typeMeta)); + } + if (data.containsKey('parent_id')) { + context.handle(_parentIdMeta, parentId.isAcceptableOrUnknown(data['parent_id']!, _parentIdMeta)); + } + if (data.containsKey('quoted_message_id')) { + context.handle( + _quotedMessageIdMeta, + quotedMessageId.isAcceptableOrUnknown(data['quoted_message_id']!, _quotedMessageIdMeta), + ); + } + if (data.containsKey('poll_id')) { + context.handle(_pollIdMeta, pollId.isAcceptableOrUnknown(data['poll_id']!, _pollIdMeta)); + } + if (data.containsKey('reply_count')) { + context.handle(_replyCountMeta, replyCount.isAcceptableOrUnknown(data['reply_count']!, _replyCountMeta)); + } + if (data.containsKey('show_in_channel')) { + context.handle( + _showInChannelMeta, + showInChannel.isAcceptableOrUnknown(data['show_in_channel']!, _showInChannelMeta), + ); + } + if (data.containsKey('shadowed')) { + context.handle(_shadowedMeta, shadowed.isAcceptableOrUnknown(data['shadowed']!, _shadowedMeta)); + } + if (data.containsKey('command')) { + context.handle(_commandMeta, command.isAcceptableOrUnknown(data['command']!, _commandMeta)); + } + if (data.containsKey('local_created_at')) { + context.handle( + _localCreatedAtMeta, + localCreatedAt.isAcceptableOrUnknown(data['local_created_at']!, _localCreatedAtMeta), + ); + } + if (data.containsKey('remote_created_at')) { + context.handle( + _remoteCreatedAtMeta, + remoteCreatedAt.isAcceptableOrUnknown(data['remote_created_at']!, _remoteCreatedAtMeta), + ); + } + if (data.containsKey('local_updated_at')) { + context.handle( + _localUpdatedAtMeta, + localUpdatedAt.isAcceptableOrUnknown(data['local_updated_at']!, _localUpdatedAtMeta), + ); + } + if (data.containsKey('remote_updated_at')) { + context.handle( + _remoteUpdatedAtMeta, + remoteUpdatedAt.isAcceptableOrUnknown(data['remote_updated_at']!, _remoteUpdatedAtMeta), + ); + } + if (data.containsKey('local_deleted_at')) { + context.handle( + _localDeletedAtMeta, + localDeletedAt.isAcceptableOrUnknown(data['local_deleted_at']!, _localDeletedAtMeta), + ); + } + if (data.containsKey('remote_deleted_at')) { + context.handle( + _remoteDeletedAtMeta, + remoteDeletedAt.isAcceptableOrUnknown(data['remote_deleted_at']!, _remoteDeletedAtMeta), + ); + } + if (data.containsKey('deleted_for_me')) { + context.handle(_deletedForMeMeta, deletedForMe.isAcceptableOrUnknown(data['deleted_for_me']!, _deletedForMeMeta)); + } + if (data.containsKey('message_text_updated_at')) { + context.handle( + _messageTextUpdatedAtMeta, + messageTextUpdatedAt.isAcceptableOrUnknown(data['message_text_updated_at']!, _messageTextUpdatedAtMeta), + ); + } + if (data.containsKey('user_id')) { + context.handle(_userIdMeta, userId.isAcceptableOrUnknown(data['user_id']!, _userIdMeta)); + } + if (data.containsKey('channel_role')) { + context.handle(_channelRoleMeta, channelRole.isAcceptableOrUnknown(data['channel_role']!, _channelRoleMeta)); + } + if (data.containsKey('pinned')) { + context.handle(_pinnedMeta, pinned.isAcceptableOrUnknown(data['pinned']!, _pinnedMeta)); + } + if (data.containsKey('pinned_at')) { + context.handle(_pinnedAtMeta, pinnedAt.isAcceptableOrUnknown(data['pinned_at']!, _pinnedAtMeta)); + } + if (data.containsKey('pin_expires')) { + context.handle(_pinExpiresMeta, pinExpires.isAcceptableOrUnknown(data['pin_expires']!, _pinExpiresMeta)); + } + if (data.containsKey('pinned_by_user_id')) { + context.handle( + _pinnedByUserIdMeta, + pinnedByUserId.isAcceptableOrUnknown(data['pinned_by_user_id']!, _pinnedByUserIdMeta), + ); + } + if (data.containsKey('channel_cid')) { + context.handle(_channelCidMeta, channelCid.isAcceptableOrUnknown(data['channel_cid']!, _channelCidMeta)); + } else if (isInserting) { + context.missing(_channelCidMeta); + } + return context; + } + + @override + Set get $primaryKey => {id}; + @override + PinnedMessageEntity map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return PinnedMessageEntity( + id: attachedDatabase.typeMapping.read(DriftSqlType.string, data['${effectivePrefix}id'])!, + messageText: attachedDatabase.typeMapping.read(DriftSqlType.string, data['${effectivePrefix}message_text']), + attachments: $PinnedMessagesTable.$converterattachments.fromSql( + attachedDatabase.typeMapping.read(DriftSqlType.string, data['${effectivePrefix}attachments'])!, + ), + state: attachedDatabase.typeMapping.read(DriftSqlType.string, data['${effectivePrefix}state'])!, + type: attachedDatabase.typeMapping.read(DriftSqlType.string, data['${effectivePrefix}type'])!, + mentionedUsers: $PinnedMessagesTable.$convertermentionedUsers.fromSql( + attachedDatabase.typeMapping.read(DriftSqlType.string, data['${effectivePrefix}mentioned_users'])!, + ), + reactionGroups: $PinnedMessagesTable.$converterreactionGroupsn.fromSql( + attachedDatabase.typeMapping.read(DriftSqlType.string, data['${effectivePrefix}reaction_groups']), + ), + parentId: attachedDatabase.typeMapping.read(DriftSqlType.string, data['${effectivePrefix}parent_id']), + quotedMessageId: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}quoted_message_id'], + ), + pollId: attachedDatabase.typeMapping.read(DriftSqlType.string, data['${effectivePrefix}poll_id']), + replyCount: attachedDatabase.typeMapping.read(DriftSqlType.int, data['${effectivePrefix}reply_count']), + showInChannel: attachedDatabase.typeMapping.read(DriftSqlType.bool, data['${effectivePrefix}show_in_channel']), + shadowed: attachedDatabase.typeMapping.read(DriftSqlType.bool, data['${effectivePrefix}shadowed'])!, + command: attachedDatabase.typeMapping.read(DriftSqlType.string, data['${effectivePrefix}command']), + localCreatedAt: attachedDatabase.typeMapping.read( + DriftSqlType.dateTime, + data['${effectivePrefix}local_created_at'], + ), + remoteCreatedAt: attachedDatabase.typeMapping.read( + DriftSqlType.dateTime, + data['${effectivePrefix}remote_created_at'], + ), + localUpdatedAt: attachedDatabase.typeMapping.read( + DriftSqlType.dateTime, + data['${effectivePrefix}local_updated_at'], + ), + remoteUpdatedAt: attachedDatabase.typeMapping.read( + DriftSqlType.dateTime, + data['${effectivePrefix}remote_updated_at'], + ), + localDeletedAt: attachedDatabase.typeMapping.read( + DriftSqlType.dateTime, + data['${effectivePrefix}local_deleted_at'], + ), + remoteDeletedAt: attachedDatabase.typeMapping.read( + DriftSqlType.dateTime, + data['${effectivePrefix}remote_deleted_at'], + ), + deletedForMe: attachedDatabase.typeMapping.read(DriftSqlType.bool, data['${effectivePrefix}deleted_for_me']), + messageTextUpdatedAt: attachedDatabase.typeMapping.read( + DriftSqlType.dateTime, + data['${effectivePrefix}message_text_updated_at'], + ), + userId: attachedDatabase.typeMapping.read(DriftSqlType.string, data['${effectivePrefix}user_id']), + channelRole: attachedDatabase.typeMapping.read(DriftSqlType.string, data['${effectivePrefix}channel_role']), + pinned: attachedDatabase.typeMapping.read(DriftSqlType.bool, data['${effectivePrefix}pinned'])!, + pinnedAt: attachedDatabase.typeMapping.read(DriftSqlType.dateTime, data['${effectivePrefix}pinned_at']), + pinExpires: attachedDatabase.typeMapping.read(DriftSqlType.dateTime, data['${effectivePrefix}pin_expires']), + pinnedByUserId: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}pinned_by_user_id'], + ), + channelCid: attachedDatabase.typeMapping.read(DriftSqlType.string, data['${effectivePrefix}channel_cid'])!, + i18n: $PinnedMessagesTable.$converteri18n.fromSql( + attachedDatabase.typeMapping.read(DriftSqlType.string, data['${effectivePrefix}i18n']), + ), + restrictedVisibility: $PinnedMessagesTable.$converterrestrictedVisibilityn.fromSql( + attachedDatabase.typeMapping.read(DriftSqlType.string, data['${effectivePrefix}restricted_visibility']), + ), + extraData: $PinnedMessagesTable.$converterextraDatan.fromSql( + attachedDatabase.typeMapping.read(DriftSqlType.string, data['${effectivePrefix}extra_data']), + ), + ); + } + + @override + $PinnedMessagesTable createAlias(String alias) { + return $PinnedMessagesTable(attachedDatabase, alias); + } + + static TypeConverter, String> $converterattachments = ListConverter(); + static TypeConverter, String> $convertermentionedUsers = ListConverter(); + static TypeConverter, String> $converterreactionGroups = ReactionGroupsConverter(); + static TypeConverter?, String?> $converterreactionGroupsn = NullAwareTypeConverter.wrap( + $converterreactionGroups, + ); + static TypeConverter?, String?> $converteri18n = NullableMapConverter(); + static TypeConverter, String> $converterrestrictedVisibility = ListConverter(); + static TypeConverter?, String?> $converterrestrictedVisibilityn = NullAwareTypeConverter.wrap( + $converterrestrictedVisibility, + ); + static TypeConverter, String> $converterextraData = MapConverter(); + static TypeConverter?, String?> $converterextraDatan = NullAwareTypeConverter.wrap( + $converterextraData, + ); +} + +class PinnedMessageEntity extends DataClass implements Insertable { + /// The message id + final String id; + + /// The text of this message + final String? messageText; + + /// The list of attachments, either provided by the user + /// or generated from a command or as a result of URL scraping. + final List attachments; + + /// The current state of the message. + final String state; + + /// The message type + final String type; + + /// The list of user mentioned in the message + final List mentionedUsers; + + /// A map describing the reaction group for every reaction + final Map? reactionGroups; + + /// The ID of the parent message, if the message is a thread reply. + final String? parentId; + + /// The ID of the quoted message, if the message is a quoted reply. + final String? quotedMessageId; + + /// The ID of the poll, if the message is a poll. + final String? pollId; + + /// Number of replies for this message. + final int? replyCount; + + /// Check if this message needs to show in the channel. + final bool? showInChannel; + + /// If true the message is shadowed + final bool shadowed; + + /// A used command name. + final String? command; /// The DateTime on which the message was created on the client. final DateTime? localCreatedAt; @@ -3500,6 +4014,9 @@ class PinnedMessageEntity extends DataClass /// The DateTime on which the message was deleted on the server. final DateTime? remoteDeletedAt; + /// Whether the message was deleted only for the current user. + final bool? deletedForMe; + /// The DateTime at which the message text was edited final DateTime? messageTextUpdatedAt; @@ -3532,38 +4049,40 @@ class PinnedMessageEntity extends DataClass /// Message custom extraData final Map? extraData; - const PinnedMessageEntity( - {required this.id, - this.messageText, - required this.attachments, - required this.state, - required this.type, - required this.mentionedUsers, - this.reactionGroups, - this.parentId, - this.quotedMessageId, - this.pollId, - this.replyCount, - this.showInChannel, - required this.shadowed, - this.command, - this.localCreatedAt, - this.remoteCreatedAt, - this.localUpdatedAt, - this.remoteUpdatedAt, - this.localDeletedAt, - this.remoteDeletedAt, - this.messageTextUpdatedAt, - this.userId, - this.channelRole, - required this.pinned, - this.pinnedAt, - this.pinExpires, - this.pinnedByUserId, - required this.channelCid, - this.i18n, - this.restrictedVisibility, - this.extraData}); + const PinnedMessageEntity({ + required this.id, + this.messageText, + required this.attachments, + required this.state, + required this.type, + required this.mentionedUsers, + this.reactionGroups, + this.parentId, + this.quotedMessageId, + this.pollId, + this.replyCount, + this.showInChannel, + required this.shadowed, + this.command, + this.localCreatedAt, + this.remoteCreatedAt, + this.localUpdatedAt, + this.remoteUpdatedAt, + this.localDeletedAt, + this.remoteDeletedAt, + this.deletedForMe, + this.messageTextUpdatedAt, + this.userId, + this.channelRole, + required this.pinned, + this.pinnedAt, + this.pinExpires, + this.pinnedByUserId, + required this.channelCid, + this.i18n, + this.restrictedVisibility, + this.extraData, + }); @override Map toColumns(bool nullToAbsent) { final map = {}; @@ -3572,18 +4091,15 @@ class PinnedMessageEntity extends DataClass map['message_text'] = Variable(messageText); } { - map['attachments'] = Variable( - $PinnedMessagesTable.$converterattachments.toSql(attachments)); + map['attachments'] = Variable($PinnedMessagesTable.$converterattachments.toSql(attachments)); } map['state'] = Variable(state); map['type'] = Variable(type); { - map['mentioned_users'] = Variable( - $PinnedMessagesTable.$convertermentionedUsers.toSql(mentionedUsers)); + map['mentioned_users'] = Variable($PinnedMessagesTable.$convertermentionedUsers.toSql(mentionedUsers)); } if (!nullToAbsent || reactionGroups != null) { - map['reaction_groups'] = Variable( - $PinnedMessagesTable.$converterreactionGroupsn.toSql(reactionGroups)); + map['reaction_groups'] = Variable($PinnedMessagesTable.$converterreactionGroupsn.toSql(reactionGroups)); } if (!nullToAbsent || parentId != null) { map['parent_id'] = Variable(parentId); @@ -3622,6 +4138,9 @@ class PinnedMessageEntity extends DataClass if (!nullToAbsent || remoteDeletedAt != null) { map['remote_deleted_at'] = Variable(remoteDeletedAt); } + if (!nullToAbsent || deletedForMe != null) { + map['deleted_for_me'] = Variable(deletedForMe); + } if (!nullToAbsent || messageTextUpdatedAt != null) { map['message_text_updated_at'] = Variable(messageTextUpdatedAt); } @@ -3643,23 +4162,20 @@ class PinnedMessageEntity extends DataClass } map['channel_cid'] = Variable(channelCid); if (!nullToAbsent || i18n != null) { - map['i18n'] = - Variable($PinnedMessagesTable.$converteri18n.toSql(i18n)); + map['i18n'] = Variable($PinnedMessagesTable.$converteri18n.toSql(i18n)); } if (!nullToAbsent || restrictedVisibility != null) { - map['restricted_visibility'] = Variable($PinnedMessagesTable - .$converterrestrictedVisibilityn - .toSql(restrictedVisibility)); + map['restricted_visibility'] = Variable( + $PinnedMessagesTable.$converterrestrictedVisibilityn.toSql(restrictedVisibility), + ); } if (!nullToAbsent || extraData != null) { - map['extra_data'] = Variable( - $PinnedMessagesTable.$converterextraDatan.toSql(extraData)); + map['extra_data'] = Variable($PinnedMessagesTable.$converterextraDatan.toSql(extraData)); } return map; } - factory PinnedMessageEntity.fromJson(Map json, - {ValueSerializer? serializer}) { + factory PinnedMessageEntity.fromJson(Map json, {ValueSerializer? serializer}) { serializer ??= driftRuntimeOptions.defaultSerializer; return PinnedMessageEntity( id: serializer.fromJson(json['id']), @@ -3668,8 +4184,7 @@ class PinnedMessageEntity extends DataClass state: serializer.fromJson(json['state']), type: serializer.fromJson(json['type']), mentionedUsers: serializer.fromJson>(json['mentionedUsers']), - reactionGroups: serializer - .fromJson?>(json['reactionGroups']), + reactionGroups: serializer.fromJson?>(json['reactionGroups']), parentId: serializer.fromJson(json['parentId']), quotedMessageId: serializer.fromJson(json['quotedMessageId']), pollId: serializer.fromJson(json['pollId']), @@ -3683,8 +4198,8 @@ class PinnedMessageEntity extends DataClass remoteUpdatedAt: serializer.fromJson(json['remoteUpdatedAt']), localDeletedAt: serializer.fromJson(json['localDeletedAt']), remoteDeletedAt: serializer.fromJson(json['remoteDeletedAt']), - messageTextUpdatedAt: - serializer.fromJson(json['messageTextUpdatedAt']), + deletedForMe: serializer.fromJson(json['deletedForMe']), + messageTextUpdatedAt: serializer.fromJson(json['messageTextUpdatedAt']), userId: serializer.fromJson(json['userId']), channelRole: serializer.fromJson(json['channelRole']), pinned: serializer.fromJson(json['pinned']), @@ -3693,8 +4208,7 @@ class PinnedMessageEntity extends DataClass pinnedByUserId: serializer.fromJson(json['pinnedByUserId']), channelCid: serializer.fromJson(json['channelCid']), i18n: serializer.fromJson?>(json['i18n']), - restrictedVisibility: - serializer.fromJson?>(json['restrictedVisibility']), + restrictedVisibility: serializer.fromJson?>(json['restrictedVisibility']), extraData: serializer.fromJson?>(json['extraData']), ); } @@ -3708,8 +4222,7 @@ class PinnedMessageEntity extends DataClass 'state': serializer.toJson(state), 'type': serializer.toJson(type), 'mentionedUsers': serializer.toJson>(mentionedUsers), - 'reactionGroups': - serializer.toJson?>(reactionGroups), + 'reactionGroups': serializer.toJson?>(reactionGroups), 'parentId': serializer.toJson(parentId), 'quotedMessageId': serializer.toJson(quotedMessageId), 'pollId': serializer.toJson(pollId), @@ -3723,8 +4236,8 @@ class PinnedMessageEntity extends DataClass 'remoteUpdatedAt': serializer.toJson(remoteUpdatedAt), 'localDeletedAt': serializer.toJson(localDeletedAt), 'remoteDeletedAt': serializer.toJson(remoteDeletedAt), - 'messageTextUpdatedAt': - serializer.toJson(messageTextUpdatedAt), + 'deletedForMe': serializer.toJson(deletedForMe), + 'messageTextUpdatedAt': serializer.toJson(messageTextUpdatedAt), 'userId': serializer.toJson(userId), 'channelRole': serializer.toJson(channelRole), 'pinned': serializer.toJson(pinned), @@ -3733,156 +4246,111 @@ class PinnedMessageEntity extends DataClass 'pinnedByUserId': serializer.toJson(pinnedByUserId), 'channelCid': serializer.toJson(channelCid), 'i18n': serializer.toJson?>(i18n), - 'restrictedVisibility': - serializer.toJson?>(restrictedVisibility), + 'restrictedVisibility': serializer.toJson?>(restrictedVisibility), 'extraData': serializer.toJson?>(extraData), }; } - PinnedMessageEntity copyWith( - {String? id, - Value messageText = const Value.absent(), - List? attachments, - String? state, - String? type, - List? mentionedUsers, - Value?> reactionGroups = - const Value.absent(), - Value parentId = const Value.absent(), - Value quotedMessageId = const Value.absent(), - Value pollId = const Value.absent(), - Value replyCount = const Value.absent(), - Value showInChannel = const Value.absent(), - bool? shadowed, - Value command = const Value.absent(), - Value localCreatedAt = const Value.absent(), - Value remoteCreatedAt = const Value.absent(), - Value localUpdatedAt = const Value.absent(), - Value remoteUpdatedAt = const Value.absent(), - Value localDeletedAt = const Value.absent(), - Value remoteDeletedAt = const Value.absent(), - Value messageTextUpdatedAt = const Value.absent(), - Value userId = const Value.absent(), - Value channelRole = const Value.absent(), - bool? pinned, - Value pinnedAt = const Value.absent(), - Value pinExpires = const Value.absent(), - Value pinnedByUserId = const Value.absent(), - String? channelCid, - Value?> i18n = const Value.absent(), - Value?> restrictedVisibility = const Value.absent(), - Value?> extraData = const Value.absent()}) => - PinnedMessageEntity( - id: id ?? this.id, - messageText: messageText.present ? messageText.value : this.messageText, - attachments: attachments ?? this.attachments, - state: state ?? this.state, - type: type ?? this.type, - mentionedUsers: mentionedUsers ?? this.mentionedUsers, - reactionGroups: - reactionGroups.present ? reactionGroups.value : this.reactionGroups, - parentId: parentId.present ? parentId.value : this.parentId, - quotedMessageId: quotedMessageId.present - ? quotedMessageId.value - : this.quotedMessageId, - pollId: pollId.present ? pollId.value : this.pollId, - replyCount: replyCount.present ? replyCount.value : this.replyCount, - showInChannel: - showInChannel.present ? showInChannel.value : this.showInChannel, - shadowed: shadowed ?? this.shadowed, - command: command.present ? command.value : this.command, - localCreatedAt: - localCreatedAt.present ? localCreatedAt.value : this.localCreatedAt, - remoteCreatedAt: remoteCreatedAt.present - ? remoteCreatedAt.value - : this.remoteCreatedAt, - localUpdatedAt: - localUpdatedAt.present ? localUpdatedAt.value : this.localUpdatedAt, - remoteUpdatedAt: remoteUpdatedAt.present - ? remoteUpdatedAt.value - : this.remoteUpdatedAt, - localDeletedAt: - localDeletedAt.present ? localDeletedAt.value : this.localDeletedAt, - remoteDeletedAt: remoteDeletedAt.present - ? remoteDeletedAt.value - : this.remoteDeletedAt, - messageTextUpdatedAt: messageTextUpdatedAt.present - ? messageTextUpdatedAt.value - : this.messageTextUpdatedAt, - userId: userId.present ? userId.value : this.userId, - channelRole: channelRole.present ? channelRole.value : this.channelRole, - pinned: pinned ?? this.pinned, - pinnedAt: pinnedAt.present ? pinnedAt.value : this.pinnedAt, - pinExpires: pinExpires.present ? pinExpires.value : this.pinExpires, - pinnedByUserId: - pinnedByUserId.present ? pinnedByUserId.value : this.pinnedByUserId, - channelCid: channelCid ?? this.channelCid, - i18n: i18n.present ? i18n.value : this.i18n, - restrictedVisibility: restrictedVisibility.present - ? restrictedVisibility.value - : this.restrictedVisibility, - extraData: extraData.present ? extraData.value : this.extraData, - ); + PinnedMessageEntity copyWith({ + String? id, + Value messageText = const Value.absent(), + List? attachments, + String? state, + String? type, + List? mentionedUsers, + Value?> reactionGroups = const Value.absent(), + Value parentId = const Value.absent(), + Value quotedMessageId = const Value.absent(), + Value pollId = const Value.absent(), + Value replyCount = const Value.absent(), + Value showInChannel = const Value.absent(), + bool? shadowed, + Value command = const Value.absent(), + Value localCreatedAt = const Value.absent(), + Value remoteCreatedAt = const Value.absent(), + Value localUpdatedAt = const Value.absent(), + Value remoteUpdatedAt = const Value.absent(), + Value localDeletedAt = const Value.absent(), + Value remoteDeletedAt = const Value.absent(), + Value deletedForMe = const Value.absent(), + Value messageTextUpdatedAt = const Value.absent(), + Value userId = const Value.absent(), + Value channelRole = const Value.absent(), + bool? pinned, + Value pinnedAt = const Value.absent(), + Value pinExpires = const Value.absent(), + Value pinnedByUserId = const Value.absent(), + String? channelCid, + Value?> i18n = const Value.absent(), + Value?> restrictedVisibility = const Value.absent(), + Value?> extraData = const Value.absent(), + }) => PinnedMessageEntity( + id: id ?? this.id, + messageText: messageText.present ? messageText.value : this.messageText, + attachments: attachments ?? this.attachments, + state: state ?? this.state, + type: type ?? this.type, + mentionedUsers: mentionedUsers ?? this.mentionedUsers, + reactionGroups: reactionGroups.present ? reactionGroups.value : this.reactionGroups, + parentId: parentId.present ? parentId.value : this.parentId, + quotedMessageId: quotedMessageId.present ? quotedMessageId.value : this.quotedMessageId, + pollId: pollId.present ? pollId.value : this.pollId, + replyCount: replyCount.present ? replyCount.value : this.replyCount, + showInChannel: showInChannel.present ? showInChannel.value : this.showInChannel, + shadowed: shadowed ?? this.shadowed, + command: command.present ? command.value : this.command, + localCreatedAt: localCreatedAt.present ? localCreatedAt.value : this.localCreatedAt, + remoteCreatedAt: remoteCreatedAt.present ? remoteCreatedAt.value : this.remoteCreatedAt, + localUpdatedAt: localUpdatedAt.present ? localUpdatedAt.value : this.localUpdatedAt, + remoteUpdatedAt: remoteUpdatedAt.present ? remoteUpdatedAt.value : this.remoteUpdatedAt, + localDeletedAt: localDeletedAt.present ? localDeletedAt.value : this.localDeletedAt, + remoteDeletedAt: remoteDeletedAt.present ? remoteDeletedAt.value : this.remoteDeletedAt, + deletedForMe: deletedForMe.present ? deletedForMe.value : this.deletedForMe, + messageTextUpdatedAt: messageTextUpdatedAt.present ? messageTextUpdatedAt.value : this.messageTextUpdatedAt, + userId: userId.present ? userId.value : this.userId, + channelRole: channelRole.present ? channelRole.value : this.channelRole, + pinned: pinned ?? this.pinned, + pinnedAt: pinnedAt.present ? pinnedAt.value : this.pinnedAt, + pinExpires: pinExpires.present ? pinExpires.value : this.pinExpires, + pinnedByUserId: pinnedByUserId.present ? pinnedByUserId.value : this.pinnedByUserId, + channelCid: channelCid ?? this.channelCid, + i18n: i18n.present ? i18n.value : this.i18n, + restrictedVisibility: restrictedVisibility.present ? restrictedVisibility.value : this.restrictedVisibility, + extraData: extraData.present ? extraData.value : this.extraData, + ); PinnedMessageEntity copyWithCompanion(PinnedMessagesCompanion data) { return PinnedMessageEntity( id: data.id.present ? data.id.value : this.id, - messageText: - data.messageText.present ? data.messageText.value : this.messageText, - attachments: - data.attachments.present ? data.attachments.value : this.attachments, + messageText: data.messageText.present ? data.messageText.value : this.messageText, + attachments: data.attachments.present ? data.attachments.value : this.attachments, state: data.state.present ? data.state.value : this.state, type: data.type.present ? data.type.value : this.type, - mentionedUsers: data.mentionedUsers.present - ? data.mentionedUsers.value - : this.mentionedUsers, - reactionGroups: data.reactionGroups.present - ? data.reactionGroups.value - : this.reactionGroups, + mentionedUsers: data.mentionedUsers.present ? data.mentionedUsers.value : this.mentionedUsers, + reactionGroups: data.reactionGroups.present ? data.reactionGroups.value : this.reactionGroups, parentId: data.parentId.present ? data.parentId.value : this.parentId, - quotedMessageId: data.quotedMessageId.present - ? data.quotedMessageId.value - : this.quotedMessageId, + quotedMessageId: data.quotedMessageId.present ? data.quotedMessageId.value : this.quotedMessageId, pollId: data.pollId.present ? data.pollId.value : this.pollId, - replyCount: - data.replyCount.present ? data.replyCount.value : this.replyCount, - showInChannel: data.showInChannel.present - ? data.showInChannel.value - : this.showInChannel, + replyCount: data.replyCount.present ? data.replyCount.value : this.replyCount, + showInChannel: data.showInChannel.present ? data.showInChannel.value : this.showInChannel, shadowed: data.shadowed.present ? data.shadowed.value : this.shadowed, command: data.command.present ? data.command.value : this.command, - localCreatedAt: data.localCreatedAt.present - ? data.localCreatedAt.value - : this.localCreatedAt, - remoteCreatedAt: data.remoteCreatedAt.present - ? data.remoteCreatedAt.value - : this.remoteCreatedAt, - localUpdatedAt: data.localUpdatedAt.present - ? data.localUpdatedAt.value - : this.localUpdatedAt, - remoteUpdatedAt: data.remoteUpdatedAt.present - ? data.remoteUpdatedAt.value - : this.remoteUpdatedAt, - localDeletedAt: data.localDeletedAt.present - ? data.localDeletedAt.value - : this.localDeletedAt, - remoteDeletedAt: data.remoteDeletedAt.present - ? data.remoteDeletedAt.value - : this.remoteDeletedAt, + localCreatedAt: data.localCreatedAt.present ? data.localCreatedAt.value : this.localCreatedAt, + remoteCreatedAt: data.remoteCreatedAt.present ? data.remoteCreatedAt.value : this.remoteCreatedAt, + localUpdatedAt: data.localUpdatedAt.present ? data.localUpdatedAt.value : this.localUpdatedAt, + remoteUpdatedAt: data.remoteUpdatedAt.present ? data.remoteUpdatedAt.value : this.remoteUpdatedAt, + localDeletedAt: data.localDeletedAt.present ? data.localDeletedAt.value : this.localDeletedAt, + remoteDeletedAt: data.remoteDeletedAt.present ? data.remoteDeletedAt.value : this.remoteDeletedAt, + deletedForMe: data.deletedForMe.present ? data.deletedForMe.value : this.deletedForMe, messageTextUpdatedAt: data.messageTextUpdatedAt.present ? data.messageTextUpdatedAt.value : this.messageTextUpdatedAt, userId: data.userId.present ? data.userId.value : this.userId, - channelRole: - data.channelRole.present ? data.channelRole.value : this.channelRole, + channelRole: data.channelRole.present ? data.channelRole.value : this.channelRole, pinned: data.pinned.present ? data.pinned.value : this.pinned, pinnedAt: data.pinnedAt.present ? data.pinnedAt.value : this.pinnedAt, - pinExpires: - data.pinExpires.present ? data.pinExpires.value : this.pinExpires, - pinnedByUserId: data.pinnedByUserId.present - ? data.pinnedByUserId.value - : this.pinnedByUserId, - channelCid: - data.channelCid.present ? data.channelCid.value : this.channelCid, + pinExpires: data.pinExpires.present ? data.pinExpires.value : this.pinExpires, + pinnedByUserId: data.pinnedByUserId.present ? data.pinnedByUserId.value : this.pinnedByUserId, + channelCid: data.channelCid.present ? data.channelCid.value : this.channelCid, i18n: data.i18n.present ? data.i18n.value : this.i18n, restrictedVisibility: data.restrictedVisibility.present ? data.restrictedVisibility.value @@ -3914,6 +4382,7 @@ class PinnedMessageEntity extends DataClass ..write('remoteUpdatedAt: $remoteUpdatedAt, ') ..write('localDeletedAt: $localDeletedAt, ') ..write('remoteDeletedAt: $remoteDeletedAt, ') + ..write('deletedForMe: $deletedForMe, ') ..write('messageTextUpdatedAt: $messageTextUpdatedAt, ') ..write('userId: $userId, ') ..write('channelRole: $channelRole, ') @@ -3931,38 +4400,39 @@ class PinnedMessageEntity extends DataClass @override int get hashCode => Object.hashAll([ - id, - messageText, - attachments, - state, - type, - mentionedUsers, - reactionGroups, - parentId, - quotedMessageId, - pollId, - replyCount, - showInChannel, - shadowed, - command, - localCreatedAt, - remoteCreatedAt, - localUpdatedAt, - remoteUpdatedAt, - localDeletedAt, - remoteDeletedAt, - messageTextUpdatedAt, - userId, - channelRole, - pinned, - pinnedAt, - pinExpires, - pinnedByUserId, - channelCid, - i18n, - restrictedVisibility, - extraData - ]); + id, + messageText, + attachments, + state, + type, + mentionedUsers, + reactionGroups, + parentId, + quotedMessageId, + pollId, + replyCount, + showInChannel, + shadowed, + command, + localCreatedAt, + remoteCreatedAt, + localUpdatedAt, + remoteUpdatedAt, + localDeletedAt, + remoteDeletedAt, + deletedForMe, + messageTextUpdatedAt, + userId, + channelRole, + pinned, + pinnedAt, + pinExpires, + pinnedByUserId, + channelCid, + i18n, + restrictedVisibility, + extraData, + ]); @override bool operator ==(Object other) => identical(this, other) || @@ -3987,6 +4457,7 @@ class PinnedMessageEntity extends DataClass other.remoteUpdatedAt == this.remoteUpdatedAt && other.localDeletedAt == this.localDeletedAt && other.remoteDeletedAt == this.remoteDeletedAt && + other.deletedForMe == this.deletedForMe && other.messageTextUpdatedAt == this.messageTextUpdatedAt && other.userId == this.userId && other.channelRole == this.channelRole && @@ -4021,6 +4492,7 @@ class PinnedMessagesCompanion extends UpdateCompanion { final Value remoteUpdatedAt; final Value localDeletedAt; final Value remoteDeletedAt; + final Value deletedForMe; final Value messageTextUpdatedAt; final Value userId; final Value channelRole; @@ -4054,6 +4526,7 @@ class PinnedMessagesCompanion extends UpdateCompanion { this.remoteUpdatedAt = const Value.absent(), this.localDeletedAt = const Value.absent(), this.remoteDeletedAt = const Value.absent(), + this.deletedForMe = const Value.absent(), this.messageTextUpdatedAt = const Value.absent(), this.userId = const Value.absent(), this.channelRole = const Value.absent(), @@ -4088,6 +4561,7 @@ class PinnedMessagesCompanion extends UpdateCompanion { this.remoteUpdatedAt = const Value.absent(), this.localDeletedAt = const Value.absent(), this.remoteDeletedAt = const Value.absent(), + this.deletedForMe = const Value.absent(), this.messageTextUpdatedAt = const Value.absent(), this.userId = const Value.absent(), this.channelRole = const Value.absent(), @@ -4100,11 +4574,11 @@ class PinnedMessagesCompanion extends UpdateCompanion { this.restrictedVisibility = const Value.absent(), this.extraData = const Value.absent(), this.rowid = const Value.absent(), - }) : id = Value(id), - attachments = Value(attachments), - state = Value(state), - mentionedUsers = Value(mentionedUsers), - channelCid = Value(channelCid); + }) : id = Value(id), + attachments = Value(attachments), + state = Value(state), + mentionedUsers = Value(mentionedUsers), + channelCid = Value(channelCid); static Insertable custom({ Expression? id, Expression? messageText, @@ -4126,6 +4600,7 @@ class PinnedMessagesCompanion extends UpdateCompanion { Expression? remoteUpdatedAt, Expression? localDeletedAt, Expression? remoteDeletedAt, + Expression? deletedForMe, Expression? messageTextUpdatedAt, Expression? userId, Expression? channelRole, @@ -4160,8 +4635,8 @@ class PinnedMessagesCompanion extends UpdateCompanion { if (remoteUpdatedAt != null) 'remote_updated_at': remoteUpdatedAt, if (localDeletedAt != null) 'local_deleted_at': localDeletedAt, if (remoteDeletedAt != null) 'remote_deleted_at': remoteDeletedAt, - if (messageTextUpdatedAt != null) - 'message_text_updated_at': messageTextUpdatedAt, + if (deletedForMe != null) 'deleted_for_me': deletedForMe, + if (messageTextUpdatedAt != null) 'message_text_updated_at': messageTextUpdatedAt, if (userId != null) 'user_id': userId, if (channelRole != null) 'channel_role': channelRole, if (pinned != null) 'pinned': pinned, @@ -4170,46 +4645,47 @@ class PinnedMessagesCompanion extends UpdateCompanion { if (pinnedByUserId != null) 'pinned_by_user_id': pinnedByUserId, if (channelCid != null) 'channel_cid': channelCid, if (i18n != null) 'i18n': i18n, - if (restrictedVisibility != null) - 'restricted_visibility': restrictedVisibility, + if (restrictedVisibility != null) 'restricted_visibility': restrictedVisibility, if (extraData != null) 'extra_data': extraData, if (rowid != null) 'rowid': rowid, }); } - PinnedMessagesCompanion copyWith( - {Value? id, - Value? messageText, - Value>? attachments, - Value? state, - Value? type, - Value>? mentionedUsers, - Value?>? reactionGroups, - Value? parentId, - Value? quotedMessageId, - Value? pollId, - Value? replyCount, - Value? showInChannel, - Value? shadowed, - Value? command, - Value? localCreatedAt, - Value? remoteCreatedAt, - Value? localUpdatedAt, - Value? remoteUpdatedAt, - Value? localDeletedAt, - Value? remoteDeletedAt, - Value? messageTextUpdatedAt, - Value? userId, - Value? channelRole, - Value? pinned, - Value? pinnedAt, - Value? pinExpires, - Value? pinnedByUserId, - Value? channelCid, - Value?>? i18n, - Value?>? restrictedVisibility, - Value?>? extraData, - Value? rowid}) { + PinnedMessagesCompanion copyWith({ + Value? id, + Value? messageText, + Value>? attachments, + Value? state, + Value? type, + Value>? mentionedUsers, + Value?>? reactionGroups, + Value? parentId, + Value? quotedMessageId, + Value? pollId, + Value? replyCount, + Value? showInChannel, + Value? shadowed, + Value? command, + Value? localCreatedAt, + Value? remoteCreatedAt, + Value? localUpdatedAt, + Value? remoteUpdatedAt, + Value? localDeletedAt, + Value? remoteDeletedAt, + Value? deletedForMe, + Value? messageTextUpdatedAt, + Value? userId, + Value? channelRole, + Value? pinned, + Value? pinnedAt, + Value? pinExpires, + Value? pinnedByUserId, + Value? channelCid, + Value?>? i18n, + Value?>? restrictedVisibility, + Value?>? extraData, + Value? rowid, + }) { return PinnedMessagesCompanion( id: id ?? this.id, messageText: messageText ?? this.messageText, @@ -4231,6 +4707,7 @@ class PinnedMessagesCompanion extends UpdateCompanion { remoteUpdatedAt: remoteUpdatedAt ?? this.remoteUpdatedAt, localDeletedAt: localDeletedAt ?? this.localDeletedAt, remoteDeletedAt: remoteDeletedAt ?? this.remoteDeletedAt, + deletedForMe: deletedForMe ?? this.deletedForMe, messageTextUpdatedAt: messageTextUpdatedAt ?? this.messageTextUpdatedAt, userId: userId ?? this.userId, channelRole: channelRole ?? this.channelRole, @@ -4256,8 +4733,7 @@ class PinnedMessagesCompanion extends UpdateCompanion { map['message_text'] = Variable(messageText.value); } if (attachments.present) { - map['attachments'] = Variable( - $PinnedMessagesTable.$converterattachments.toSql(attachments.value)); + map['attachments'] = Variable($PinnedMessagesTable.$converterattachments.toSql(attachments.value)); } if (state.present) { map['state'] = Variable(state.value); @@ -4266,14 +4742,14 @@ class PinnedMessagesCompanion extends UpdateCompanion { map['type'] = Variable(type.value); } if (mentionedUsers.present) { - map['mentioned_users'] = Variable($PinnedMessagesTable - .$convertermentionedUsers - .toSql(mentionedUsers.value)); + map['mentioned_users'] = Variable( + $PinnedMessagesTable.$convertermentionedUsers.toSql(mentionedUsers.value), + ); } if (reactionGroups.present) { - map['reaction_groups'] = Variable($PinnedMessagesTable - .$converterreactionGroupsn - .toSql(reactionGroups.value)); + map['reaction_groups'] = Variable( + $PinnedMessagesTable.$converterreactionGroupsn.toSql(reactionGroups.value), + ); } if (parentId.present) { map['parent_id'] = Variable(parentId.value); @@ -4314,9 +4790,11 @@ class PinnedMessagesCompanion extends UpdateCompanion { if (remoteDeletedAt.present) { map['remote_deleted_at'] = Variable(remoteDeletedAt.value); } + if (deletedForMe.present) { + map['deleted_for_me'] = Variable(deletedForMe.value); + } if (messageTextUpdatedAt.present) { - map['message_text_updated_at'] = - Variable(messageTextUpdatedAt.value); + map['message_text_updated_at'] = Variable(messageTextUpdatedAt.value); } if (userId.present) { map['user_id'] = Variable(userId.value); @@ -4340,17 +4818,15 @@ class PinnedMessagesCompanion extends UpdateCompanion { map['channel_cid'] = Variable(channelCid.value); } if (i18n.present) { - map['i18n'] = Variable( - $PinnedMessagesTable.$converteri18n.toSql(i18n.value)); + map['i18n'] = Variable($PinnedMessagesTable.$converteri18n.toSql(i18n.value)); } if (restrictedVisibility.present) { - map['restricted_visibility'] = Variable($PinnedMessagesTable - .$converterrestrictedVisibilityn - .toSql(restrictedVisibility.value)); + map['restricted_visibility'] = Variable( + $PinnedMessagesTable.$converterrestrictedVisibilityn.toSql(restrictedVisibility.value), + ); } if (extraData.present) { - map['extra_data'] = Variable( - $PinnedMessagesTable.$converterextraDatan.toSql(extraData.value)); + map['extra_data'] = Variable($PinnedMessagesTable.$converterextraDatan.toSql(extraData.value)); } if (rowid.present) { map['rowid'] = Variable(rowid.value); @@ -4381,6 +4857,7 @@ class PinnedMessagesCompanion extends UpdateCompanion { ..write('remoteUpdatedAt: $remoteUpdatedAt, ') ..write('localDeletedAt: $localDeletedAt, ') ..write('remoteDeletedAt: $remoteDeletedAt, ') + ..write('deletedForMe: $deletedForMe, ') ..write('messageTextUpdatedAt: $messageTextUpdatedAt, ') ..write('userId: $userId, ') ..write('channelRole: $channelRole, ') @@ -4406,166 +4883,192 @@ class $PollsTable extends Polls with TableInfo<$PollsTable, PollEntity> { static const VerificationMeta _idMeta = const VerificationMeta('id'); @override late final GeneratedColumn id = GeneratedColumn( - 'id', aliasedName, false, - type: DriftSqlType.string, requiredDuringInsert: true); + 'id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); static const VerificationMeta _nameMeta = const VerificationMeta('name'); @override late final GeneratedColumn name = GeneratedColumn( - 'name', aliasedName, false, - type: DriftSqlType.string, requiredDuringInsert: true); - static const VerificationMeta _descriptionMeta = - const VerificationMeta('description'); + 'name', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + static const VerificationMeta _descriptionMeta = const VerificationMeta('description'); @override late final GeneratedColumn description = GeneratedColumn( - 'description', aliasedName, true, - type: DriftSqlType.string, requiredDuringInsert: false); - static const VerificationMeta _optionsMeta = - const VerificationMeta('options'); - @override - late final GeneratedColumnWithTypeConverter, String> options = - GeneratedColumn('options', aliasedName, false, - type: DriftSqlType.string, requiredDuringInsert: true) - .withConverter>($PollsTable.$converteroptions); - static const VerificationMeta _votingVisibilityMeta = - const VerificationMeta('votingVisibility'); - @override - late final GeneratedColumnWithTypeConverter - votingVisibility = GeneratedColumn( - 'voting_visibility', aliasedName, false, - type: DriftSqlType.string, - requiredDuringInsert: false, - defaultValue: const Constant('public')) - .withConverter( - $PollsTable.$convertervotingVisibility); - static const VerificationMeta _enforceUniqueVoteMeta = - const VerificationMeta('enforceUniqueVote'); + 'description', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); + @override + late final GeneratedColumnWithTypeConverter, String> options = GeneratedColumn( + 'options', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ).withConverter>($PollsTable.$converteroptions); + @override + late final GeneratedColumnWithTypeConverter votingVisibility = GeneratedColumn( + 'voting_visibility', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: false, + defaultValue: const Constant('public'), + ).withConverter($PollsTable.$convertervotingVisibility); + static const VerificationMeta _enforceUniqueVoteMeta = const VerificationMeta('enforceUniqueVote'); @override late final GeneratedColumn enforceUniqueVote = GeneratedColumn( - 'enforce_unique_vote', aliasedName, false, - type: DriftSqlType.bool, - requiredDuringInsert: false, - defaultConstraints: GeneratedColumn.constraintIsAlways( - 'CHECK ("enforce_unique_vote" IN (0, 1))'), - defaultValue: const Constant(false)); - static const VerificationMeta _maxVotesAllowedMeta = - const VerificationMeta('maxVotesAllowed'); + 'enforce_unique_vote', + aliasedName, + false, + type: DriftSqlType.bool, + requiredDuringInsert: false, + defaultConstraints: GeneratedColumn.constraintIsAlways('CHECK ("enforce_unique_vote" IN (0, 1))'), + defaultValue: const Constant(false), + ); + static const VerificationMeta _maxVotesAllowedMeta = const VerificationMeta('maxVotesAllowed'); @override late final GeneratedColumn maxVotesAllowed = GeneratedColumn( - 'max_votes_allowed', aliasedName, true, - type: DriftSqlType.int, requiredDuringInsert: false); - static const VerificationMeta _allowUserSuggestedOptionsMeta = - const VerificationMeta('allowUserSuggestedOptions'); - @override - late final GeneratedColumn allowUserSuggestedOptions = - GeneratedColumn('allow_user_suggested_options', aliasedName, false, - type: DriftSqlType.bool, - requiredDuringInsert: false, - defaultConstraints: GeneratedColumn.constraintIsAlways( - 'CHECK ("allow_user_suggested_options" IN (0, 1))'), - defaultValue: const Constant(false)); - static const VerificationMeta _allowAnswersMeta = - const VerificationMeta('allowAnswers'); + 'max_votes_allowed', + aliasedName, + true, + type: DriftSqlType.int, + requiredDuringInsert: false, + ); + static const VerificationMeta _allowUserSuggestedOptionsMeta = const VerificationMeta('allowUserSuggestedOptions'); + @override + late final GeneratedColumn allowUserSuggestedOptions = GeneratedColumn( + 'allow_user_suggested_options', + aliasedName, + false, + type: DriftSqlType.bool, + requiredDuringInsert: false, + defaultConstraints: GeneratedColumn.constraintIsAlways('CHECK ("allow_user_suggested_options" IN (0, 1))'), + defaultValue: const Constant(false), + ); + static const VerificationMeta _allowAnswersMeta = const VerificationMeta('allowAnswers'); @override late final GeneratedColumn allowAnswers = GeneratedColumn( - 'allow_answers', aliasedName, false, - type: DriftSqlType.bool, - requiredDuringInsert: false, - defaultConstraints: GeneratedColumn.constraintIsAlways( - 'CHECK ("allow_answers" IN (0, 1))'), - defaultValue: const Constant(false)); - static const VerificationMeta _isClosedMeta = - const VerificationMeta('isClosed'); + 'allow_answers', + aliasedName, + false, + type: DriftSqlType.bool, + requiredDuringInsert: false, + defaultConstraints: GeneratedColumn.constraintIsAlways('CHECK ("allow_answers" IN (0, 1))'), + defaultValue: const Constant(false), + ); + static const VerificationMeta _isClosedMeta = const VerificationMeta('isClosed'); @override late final GeneratedColumn isClosed = GeneratedColumn( - 'is_closed', aliasedName, false, - type: DriftSqlType.bool, - requiredDuringInsert: false, - defaultConstraints: - GeneratedColumn.constraintIsAlways('CHECK ("is_closed" IN (0, 1))'), - defaultValue: const Constant(false)); - static const VerificationMeta _answersCountMeta = - const VerificationMeta('answersCount'); + 'is_closed', + aliasedName, + false, + type: DriftSqlType.bool, + requiredDuringInsert: false, + defaultConstraints: GeneratedColumn.constraintIsAlways('CHECK ("is_closed" IN (0, 1))'), + defaultValue: const Constant(false), + ); + static const VerificationMeta _answersCountMeta = const VerificationMeta('answersCount'); @override late final GeneratedColumn answersCount = GeneratedColumn( - 'answers_count', aliasedName, false, - type: DriftSqlType.int, - requiredDuringInsert: false, - defaultValue: const Constant(0)); - static const VerificationMeta _voteCountsByOptionMeta = - const VerificationMeta('voteCountsByOption'); - @override - late final GeneratedColumnWithTypeConverter, String> - voteCountsByOption = GeneratedColumn( - 'vote_counts_by_option', aliasedName, false, - type: DriftSqlType.string, requiredDuringInsert: true) - .withConverter>( - $PollsTable.$convertervoteCountsByOption); - static const VerificationMeta _voteCountMeta = - const VerificationMeta('voteCount'); + 'answers_count', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: false, + defaultValue: const Constant(0), + ); + @override + late final GeneratedColumnWithTypeConverter, String> voteCountsByOption = GeneratedColumn( + 'vote_counts_by_option', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ).withConverter>($PollsTable.$convertervoteCountsByOption); + static const VerificationMeta _voteCountMeta = const VerificationMeta('voteCount'); @override late final GeneratedColumn voteCount = GeneratedColumn( - 'vote_count', aliasedName, false, - type: DriftSqlType.int, - requiredDuringInsert: false, - defaultValue: const Constant(0)); - static const VerificationMeta _createdByIdMeta = - const VerificationMeta('createdById'); + 'vote_count', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: false, + defaultValue: const Constant(0), + ); + static const VerificationMeta _createdByIdMeta = const VerificationMeta('createdById'); @override late final GeneratedColumn createdById = GeneratedColumn( - 'created_by_id', aliasedName, true, - type: DriftSqlType.string, requiredDuringInsert: false); - static const VerificationMeta _createdAtMeta = - const VerificationMeta('createdAt'); + 'created_by_id', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); + static const VerificationMeta _createdAtMeta = const VerificationMeta('createdAt'); @override late final GeneratedColumn createdAt = GeneratedColumn( - 'created_at', aliasedName, false, - type: DriftSqlType.dateTime, - requiredDuringInsert: false, - defaultValue: currentDateAndTime); - static const VerificationMeta _updatedAtMeta = - const VerificationMeta('updatedAt'); + 'created_at', + aliasedName, + false, + type: DriftSqlType.dateTime, + requiredDuringInsert: false, + defaultValue: currentDateAndTime, + ); + static const VerificationMeta _updatedAtMeta = const VerificationMeta('updatedAt'); @override late final GeneratedColumn updatedAt = GeneratedColumn( - 'updated_at', aliasedName, false, - type: DriftSqlType.dateTime, - requiredDuringInsert: false, - defaultValue: currentDateAndTime); - static const VerificationMeta _extraDataMeta = - const VerificationMeta('extraData'); - @override - late final GeneratedColumnWithTypeConverter?, String> - extraData = GeneratedColumn('extra_data', aliasedName, true, - type: DriftSqlType.string, requiredDuringInsert: false) - .withConverter?>( - $PollsTable.$converterextraDatan); + 'updated_at', + aliasedName, + false, + type: DriftSqlType.dateTime, + requiredDuringInsert: false, + defaultValue: currentDateAndTime, + ); + @override + late final GeneratedColumnWithTypeConverter?, String> extraData = GeneratedColumn( + 'extra_data', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ).withConverter?>($PollsTable.$converterextraDatan); @override List get $columns => [ - id, - name, - description, - options, - votingVisibility, - enforceUniqueVote, - maxVotesAllowed, - allowUserSuggestedOptions, - allowAnswers, - isClosed, - answersCount, - voteCountsByOption, - voteCount, - createdById, - createdAt, - updatedAt, - extraData - ]; + id, + name, + description, + options, + votingVisibility, + enforceUniqueVote, + maxVotesAllowed, + allowUserSuggestedOptions, + allowAnswers, + isClosed, + answersCount, + voteCountsByOption, + voteCount, + createdById, + createdAt, + updatedAt, + extraData, + ]; @override String get aliasedName => _alias ?? actualTableName; @override String get actualTableName => $name; static const String $name = 'polls'; @override - VerificationContext validateIntegrity(Insertable instance, - {bool isInserting = false}) { + VerificationContext validateIntegrity(Insertable instance, {bool isInserting = false}) { final context = VerificationContext(); final data = instance.toColumns(true); if (data.containsKey('id')) { @@ -4574,74 +5077,55 @@ class $PollsTable extends Polls with TableInfo<$PollsTable, PollEntity> { context.missing(_idMeta); } if (data.containsKey('name')) { - context.handle( - _nameMeta, name.isAcceptableOrUnknown(data['name']!, _nameMeta)); + context.handle(_nameMeta, name.isAcceptableOrUnknown(data['name']!, _nameMeta)); } else if (isInserting) { context.missing(_nameMeta); } if (data.containsKey('description')) { - context.handle( - _descriptionMeta, - description.isAcceptableOrUnknown( - data['description']!, _descriptionMeta)); + context.handle(_descriptionMeta, description.isAcceptableOrUnknown(data['description']!, _descriptionMeta)); } - context.handle(_optionsMeta, const VerificationResult.success()); - context.handle(_votingVisibilityMeta, const VerificationResult.success()); if (data.containsKey('enforce_unique_vote')) { context.handle( - _enforceUniqueVoteMeta, - enforceUniqueVote.isAcceptableOrUnknown( - data['enforce_unique_vote']!, _enforceUniqueVoteMeta)); + _enforceUniqueVoteMeta, + enforceUniqueVote.isAcceptableOrUnknown(data['enforce_unique_vote']!, _enforceUniqueVoteMeta), + ); } if (data.containsKey('max_votes_allowed')) { context.handle( - _maxVotesAllowedMeta, - maxVotesAllowed.isAcceptableOrUnknown( - data['max_votes_allowed']!, _maxVotesAllowedMeta)); + _maxVotesAllowedMeta, + maxVotesAllowed.isAcceptableOrUnknown(data['max_votes_allowed']!, _maxVotesAllowedMeta), + ); } if (data.containsKey('allow_user_suggested_options')) { context.handle( + _allowUserSuggestedOptionsMeta, + allowUserSuggestedOptions.isAcceptableOrUnknown( + data['allow_user_suggested_options']!, _allowUserSuggestedOptionsMeta, - allowUserSuggestedOptions.isAcceptableOrUnknown( - data['allow_user_suggested_options']!, - _allowUserSuggestedOptionsMeta)); + ), + ); } if (data.containsKey('allow_answers')) { - context.handle( - _allowAnswersMeta, - allowAnswers.isAcceptableOrUnknown( - data['allow_answers']!, _allowAnswersMeta)); + context.handle(_allowAnswersMeta, allowAnswers.isAcceptableOrUnknown(data['allow_answers']!, _allowAnswersMeta)); } if (data.containsKey('is_closed')) { - context.handle(_isClosedMeta, - isClosed.isAcceptableOrUnknown(data['is_closed']!, _isClosedMeta)); + context.handle(_isClosedMeta, isClosed.isAcceptableOrUnknown(data['is_closed']!, _isClosedMeta)); } if (data.containsKey('answers_count')) { - context.handle( - _answersCountMeta, - answersCount.isAcceptableOrUnknown( - data['answers_count']!, _answersCountMeta)); + context.handle(_answersCountMeta, answersCount.isAcceptableOrUnknown(data['answers_count']!, _answersCountMeta)); } - context.handle(_voteCountsByOptionMeta, const VerificationResult.success()); if (data.containsKey('vote_count')) { - context.handle(_voteCountMeta, - voteCount.isAcceptableOrUnknown(data['vote_count']!, _voteCountMeta)); + context.handle(_voteCountMeta, voteCount.isAcceptableOrUnknown(data['vote_count']!, _voteCountMeta)); } if (data.containsKey('created_by_id')) { - context.handle( - _createdByIdMeta, - createdById.isAcceptableOrUnknown( - data['created_by_id']!, _createdByIdMeta)); + context.handle(_createdByIdMeta, createdById.isAcceptableOrUnknown(data['created_by_id']!, _createdByIdMeta)); } if (data.containsKey('created_at')) { - context.handle(_createdAtMeta, - createdAt.isAcceptableOrUnknown(data['created_at']!, _createdAtMeta)); + context.handle(_createdAtMeta, createdAt.isAcceptableOrUnknown(data['created_at']!, _createdAtMeta)); } if (data.containsKey('updated_at')) { - context.handle(_updatedAtMeta, - updatedAt.isAcceptableOrUnknown(data['updated_at']!, _updatedAtMeta)); + context.handle(_updatedAtMeta, updatedAt.isAcceptableOrUnknown(data['updated_at']!, _updatedAtMeta)); } - context.handle(_extraDataMeta, const VerificationResult.success()); return context; } @@ -4651,45 +5135,37 @@ class $PollsTable extends Polls with TableInfo<$PollsTable, PollEntity> { PollEntity map(Map data, {String? tablePrefix}) { final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; return PollEntity( - id: attachedDatabase.typeMapping - .read(DriftSqlType.string, data['${effectivePrefix}id'])!, - name: attachedDatabase.typeMapping - .read(DriftSqlType.string, data['${effectivePrefix}name'])!, - description: attachedDatabase.typeMapping - .read(DriftSqlType.string, data['${effectivePrefix}description']), - options: $PollsTable.$converteroptions.fromSql(attachedDatabase - .typeMapping - .read(DriftSqlType.string, data['${effectivePrefix}options'])!), + id: attachedDatabase.typeMapping.read(DriftSqlType.string, data['${effectivePrefix}id'])!, + name: attachedDatabase.typeMapping.read(DriftSqlType.string, data['${effectivePrefix}name'])!, + description: attachedDatabase.typeMapping.read(DriftSqlType.string, data['${effectivePrefix}description']), + options: $PollsTable.$converteroptions.fromSql( + attachedDatabase.typeMapping.read(DriftSqlType.string, data['${effectivePrefix}options'])!, + ), votingVisibility: $PollsTable.$convertervotingVisibility.fromSql( - attachedDatabase.typeMapping.read(DriftSqlType.string, - data['${effectivePrefix}voting_visibility'])!), + attachedDatabase.typeMapping.read(DriftSqlType.string, data['${effectivePrefix}voting_visibility'])!, + ), enforceUniqueVote: attachedDatabase.typeMapping.read( - DriftSqlType.bool, data['${effectivePrefix}enforce_unique_vote'])!, - maxVotesAllowed: attachedDatabase.typeMapping - .read(DriftSqlType.int, data['${effectivePrefix}max_votes_allowed']), + DriftSqlType.bool, + data['${effectivePrefix}enforce_unique_vote'], + )!, + maxVotesAllowed: attachedDatabase.typeMapping.read(DriftSqlType.int, data['${effectivePrefix}max_votes_allowed']), allowUserSuggestedOptions: attachedDatabase.typeMapping.read( - DriftSqlType.bool, - data['${effectivePrefix}allow_user_suggested_options'])!, - allowAnswers: attachedDatabase.typeMapping - .read(DriftSqlType.bool, data['${effectivePrefix}allow_answers'])!, - isClosed: attachedDatabase.typeMapping - .read(DriftSqlType.bool, data['${effectivePrefix}is_closed'])!, - answersCount: attachedDatabase.typeMapping - .read(DriftSqlType.int, data['${effectivePrefix}answers_count'])!, + DriftSqlType.bool, + data['${effectivePrefix}allow_user_suggested_options'], + )!, + allowAnswers: attachedDatabase.typeMapping.read(DriftSqlType.bool, data['${effectivePrefix}allow_answers'])!, + isClosed: attachedDatabase.typeMapping.read(DriftSqlType.bool, data['${effectivePrefix}is_closed'])!, + answersCount: attachedDatabase.typeMapping.read(DriftSqlType.int, data['${effectivePrefix}answers_count'])!, voteCountsByOption: $PollsTable.$convertervoteCountsByOption.fromSql( - attachedDatabase.typeMapping.read(DriftSqlType.string, - data['${effectivePrefix}vote_counts_by_option'])!), - voteCount: attachedDatabase.typeMapping - .read(DriftSqlType.int, data['${effectivePrefix}vote_count'])!, - createdById: attachedDatabase.typeMapping - .read(DriftSqlType.string, data['${effectivePrefix}created_by_id']), - createdAt: attachedDatabase.typeMapping - .read(DriftSqlType.dateTime, data['${effectivePrefix}created_at'])!, - updatedAt: attachedDatabase.typeMapping - .read(DriftSqlType.dateTime, data['${effectivePrefix}updated_at'])!, - extraData: $PollsTable.$converterextraDatan.fromSql(attachedDatabase - .typeMapping - .read(DriftSqlType.string, data['${effectivePrefix}extra_data'])), + attachedDatabase.typeMapping.read(DriftSqlType.string, data['${effectivePrefix}vote_counts_by_option'])!, + ), + voteCount: attachedDatabase.typeMapping.read(DriftSqlType.int, data['${effectivePrefix}vote_count'])!, + createdById: attachedDatabase.typeMapping.read(DriftSqlType.string, data['${effectivePrefix}created_by_id']), + createdAt: attachedDatabase.typeMapping.read(DriftSqlType.dateTime, data['${effectivePrefix}created_at'])!, + updatedAt: attachedDatabase.typeMapping.read(DriftSqlType.dateTime, data['${effectivePrefix}updated_at'])!, + extraData: $PollsTable.$converterextraDatan.fromSql( + attachedDatabase.typeMapping.read(DriftSqlType.string, data['${effectivePrefix}extra_data']), + ), ); } @@ -4698,16 +5174,13 @@ class $PollsTable extends Polls with TableInfo<$PollsTable, PollEntity> { return $PollsTable(attachedDatabase, alias); } - static TypeConverter, String> $converteroptions = - ListConverter(); - static TypeConverter $convertervotingVisibility = - const VotingVisibilityConverter(); - static TypeConverter, String> $convertervoteCountsByOption = - MapConverter(); - static TypeConverter, String> $converterextraData = - MapConverter(); - static TypeConverter?, String?> $converterextraDatan = - NullAwareTypeConverter.wrap($converterextraData); + static TypeConverter, String> $converteroptions = ListConverter(); + static TypeConverter $convertervotingVisibility = const VotingVisibilityConverter(); + static TypeConverter, String> $convertervoteCountsByOption = MapConverter(); + static TypeConverter, String> $converterextraData = MapConverter(); + static TypeConverter?, String?> $converterextraDatan = NullAwareTypeConverter.wrap( + $converterextraData, + ); } class PollEntity extends DataClass implements Insertable { @@ -4769,24 +5242,25 @@ class PollEntity extends DataClass implements Insertable { /// Map of custom poll extraData final Map? extraData; - const PollEntity( - {required this.id, - required this.name, - this.description, - required this.options, - required this.votingVisibility, - required this.enforceUniqueVote, - this.maxVotesAllowed, - required this.allowUserSuggestedOptions, - required this.allowAnswers, - required this.isClosed, - required this.answersCount, - required this.voteCountsByOption, - required this.voteCount, - this.createdById, - required this.createdAt, - required this.updatedAt, - this.extraData}); + const PollEntity({ + required this.id, + required this.name, + this.description, + required this.options, + required this.votingVisibility, + required this.enforceUniqueVote, + this.maxVotesAllowed, + required this.allowUserSuggestedOptions, + required this.allowAnswers, + required this.isClosed, + required this.answersCount, + required this.voteCountsByOption, + required this.voteCount, + this.createdById, + required this.createdAt, + required this.updatedAt, + this.extraData, + }); @override Map toColumns(bool nullToAbsent) { final map = {}; @@ -4796,25 +5270,23 @@ class PollEntity extends DataClass implements Insertable { map['description'] = Variable(description); } { - map['options'] = - Variable($PollsTable.$converteroptions.toSql(options)); + map['options'] = Variable($PollsTable.$converteroptions.toSql(options)); } { - map['voting_visibility'] = Variable( - $PollsTable.$convertervotingVisibility.toSql(votingVisibility)); + map['voting_visibility'] = Variable($PollsTable.$convertervotingVisibility.toSql(votingVisibility)); } map['enforce_unique_vote'] = Variable(enforceUniqueVote); if (!nullToAbsent || maxVotesAllowed != null) { map['max_votes_allowed'] = Variable(maxVotesAllowed); } - map['allow_user_suggested_options'] = - Variable(allowUserSuggestedOptions); + map['allow_user_suggested_options'] = Variable(allowUserSuggestedOptions); map['allow_answers'] = Variable(allowAnswers); map['is_closed'] = Variable(isClosed); map['answers_count'] = Variable(answersCount); { map['vote_counts_by_option'] = Variable( - $PollsTable.$convertervoteCountsByOption.toSql(voteCountsByOption)); + $PollsTable.$convertervoteCountsByOption.toSql(voteCountsByOption), + ); } map['vote_count'] = Variable(voteCount); if (!nullToAbsent || createdById != null) { @@ -4823,31 +5295,26 @@ class PollEntity extends DataClass implements Insertable { map['created_at'] = Variable(createdAt); map['updated_at'] = Variable(updatedAt); if (!nullToAbsent || extraData != null) { - map['extra_data'] = - Variable($PollsTable.$converterextraDatan.toSql(extraData)); + map['extra_data'] = Variable($PollsTable.$converterextraDatan.toSql(extraData)); } return map; } - factory PollEntity.fromJson(Map json, - {ValueSerializer? serializer}) { + factory PollEntity.fromJson(Map json, {ValueSerializer? serializer}) { serializer ??= driftRuntimeOptions.defaultSerializer; return PollEntity( id: serializer.fromJson(json['id']), name: serializer.fromJson(json['name']), description: serializer.fromJson(json['description']), options: serializer.fromJson>(json['options']), - votingVisibility: - serializer.fromJson(json['votingVisibility']), + votingVisibility: serializer.fromJson(json['votingVisibility']), enforceUniqueVote: serializer.fromJson(json['enforceUniqueVote']), maxVotesAllowed: serializer.fromJson(json['maxVotesAllowed']), - allowUserSuggestedOptions: - serializer.fromJson(json['allowUserSuggestedOptions']), + allowUserSuggestedOptions: serializer.fromJson(json['allowUserSuggestedOptions']), allowAnswers: serializer.fromJson(json['allowAnswers']), isClosed: serializer.fromJson(json['isClosed']), answersCount: serializer.fromJson(json['answersCount']), - voteCountsByOption: - serializer.fromJson>(json['voteCountsByOption']), + voteCountsByOption: serializer.fromJson>(json['voteCountsByOption']), voteCount: serializer.fromJson(json['voteCount']), createdById: serializer.fromJson(json['createdById']), createdAt: serializer.fromJson(json['createdAt']), @@ -4866,13 +5333,11 @@ class PollEntity extends DataClass implements Insertable { 'votingVisibility': serializer.toJson(votingVisibility), 'enforceUniqueVote': serializer.toJson(enforceUniqueVote), 'maxVotesAllowed': serializer.toJson(maxVotesAllowed), - 'allowUserSuggestedOptions': - serializer.toJson(allowUserSuggestedOptions), + 'allowUserSuggestedOptions': serializer.toJson(allowUserSuggestedOptions), 'allowAnswers': serializer.toJson(allowAnswers), 'isClosed': serializer.toJson(isClosed), 'answersCount': serializer.toJson(answersCount), - 'voteCountsByOption': - serializer.toJson>(voteCountsByOption), + 'voteCountsByOption': serializer.toJson>(voteCountsByOption), 'voteCount': serializer.toJson(voteCount), 'createdById': serializer.toJson(createdById), 'createdAt': serializer.toJson(createdAt), @@ -4881,78 +5346,61 @@ class PollEntity extends DataClass implements Insertable { }; } - PollEntity copyWith( - {String? id, - String? name, - Value description = const Value.absent(), - List? options, - VotingVisibility? votingVisibility, - bool? enforceUniqueVote, - Value maxVotesAllowed = const Value.absent(), - bool? allowUserSuggestedOptions, - bool? allowAnswers, - bool? isClosed, - int? answersCount, - Map? voteCountsByOption, - int? voteCount, - Value createdById = const Value.absent(), - DateTime? createdAt, - DateTime? updatedAt, - Value?> extraData = const Value.absent()}) => - PollEntity( - id: id ?? this.id, - name: name ?? this.name, - description: description.present ? description.value : this.description, - options: options ?? this.options, - votingVisibility: votingVisibility ?? this.votingVisibility, - enforceUniqueVote: enforceUniqueVote ?? this.enforceUniqueVote, - maxVotesAllowed: maxVotesAllowed.present - ? maxVotesAllowed.value - : this.maxVotesAllowed, - allowUserSuggestedOptions: - allowUserSuggestedOptions ?? this.allowUserSuggestedOptions, - allowAnswers: allowAnswers ?? this.allowAnswers, - isClosed: isClosed ?? this.isClosed, - answersCount: answersCount ?? this.answersCount, - voteCountsByOption: voteCountsByOption ?? this.voteCountsByOption, - voteCount: voteCount ?? this.voteCount, - createdById: createdById.present ? createdById.value : this.createdById, - createdAt: createdAt ?? this.createdAt, - updatedAt: updatedAt ?? this.updatedAt, - extraData: extraData.present ? extraData.value : this.extraData, - ); + PollEntity copyWith({ + String? id, + String? name, + Value description = const Value.absent(), + List? options, + VotingVisibility? votingVisibility, + bool? enforceUniqueVote, + Value maxVotesAllowed = const Value.absent(), + bool? allowUserSuggestedOptions, + bool? allowAnswers, + bool? isClosed, + int? answersCount, + Map? voteCountsByOption, + int? voteCount, + Value createdById = const Value.absent(), + DateTime? createdAt, + DateTime? updatedAt, + Value?> extraData = const Value.absent(), + }) => PollEntity( + id: id ?? this.id, + name: name ?? this.name, + description: description.present ? description.value : this.description, + options: options ?? this.options, + votingVisibility: votingVisibility ?? this.votingVisibility, + enforceUniqueVote: enforceUniqueVote ?? this.enforceUniqueVote, + maxVotesAllowed: maxVotesAllowed.present ? maxVotesAllowed.value : this.maxVotesAllowed, + allowUserSuggestedOptions: allowUserSuggestedOptions ?? this.allowUserSuggestedOptions, + allowAnswers: allowAnswers ?? this.allowAnswers, + isClosed: isClosed ?? this.isClosed, + answersCount: answersCount ?? this.answersCount, + voteCountsByOption: voteCountsByOption ?? this.voteCountsByOption, + voteCount: voteCount ?? this.voteCount, + createdById: createdById.present ? createdById.value : this.createdById, + createdAt: createdAt ?? this.createdAt, + updatedAt: updatedAt ?? this.updatedAt, + extraData: extraData.present ? extraData.value : this.extraData, + ); PollEntity copyWithCompanion(PollsCompanion data) { return PollEntity( id: data.id.present ? data.id.value : this.id, name: data.name.present ? data.name.value : this.name, - description: - data.description.present ? data.description.value : this.description, + description: data.description.present ? data.description.value : this.description, options: data.options.present ? data.options.value : this.options, - votingVisibility: data.votingVisibility.present - ? data.votingVisibility.value - : this.votingVisibility, - enforceUniqueVote: data.enforceUniqueVote.present - ? data.enforceUniqueVote.value - : this.enforceUniqueVote, - maxVotesAllowed: data.maxVotesAllowed.present - ? data.maxVotesAllowed.value - : this.maxVotesAllowed, + votingVisibility: data.votingVisibility.present ? data.votingVisibility.value : this.votingVisibility, + enforceUniqueVote: data.enforceUniqueVote.present ? data.enforceUniqueVote.value : this.enforceUniqueVote, + maxVotesAllowed: data.maxVotesAllowed.present ? data.maxVotesAllowed.value : this.maxVotesAllowed, allowUserSuggestedOptions: data.allowUserSuggestedOptions.present ? data.allowUserSuggestedOptions.value : this.allowUserSuggestedOptions, - allowAnswers: data.allowAnswers.present - ? data.allowAnswers.value - : this.allowAnswers, + allowAnswers: data.allowAnswers.present ? data.allowAnswers.value : this.allowAnswers, isClosed: data.isClosed.present ? data.isClosed.value : this.isClosed, - answersCount: data.answersCount.present - ? data.answersCount.value - : this.answersCount, - voteCountsByOption: data.voteCountsByOption.present - ? data.voteCountsByOption.value - : this.voteCountsByOption, + answersCount: data.answersCount.present ? data.answersCount.value : this.answersCount, + voteCountsByOption: data.voteCountsByOption.present ? data.voteCountsByOption.value : this.voteCountsByOption, voteCount: data.voteCount.present ? data.voteCount.value : this.voteCount, - createdById: - data.createdById.present ? data.createdById.value : this.createdById, + createdById: data.createdById.present ? data.createdById.value : this.createdById, createdAt: data.createdAt.present ? data.createdAt.value : this.createdAt, updatedAt: data.updatedAt.present ? data.updatedAt.value : this.updatedAt, extraData: data.extraData.present ? data.extraData.value : this.extraData, @@ -4985,23 +5433,24 @@ class PollEntity extends DataClass implements Insertable { @override int get hashCode => Object.hash( - id, - name, - description, - options, - votingVisibility, - enforceUniqueVote, - maxVotesAllowed, - allowUserSuggestedOptions, - allowAnswers, - isClosed, - answersCount, - voteCountsByOption, - voteCount, - createdById, - createdAt, - updatedAt, - extraData); + id, + name, + description, + options, + votingVisibility, + enforceUniqueVote, + maxVotesAllowed, + allowUserSuggestedOptions, + allowAnswers, + isClosed, + answersCount, + voteCountsByOption, + voteCount, + createdById, + createdAt, + updatedAt, + extraData, + ); @override bool operator ==(Object other) => identical(this, other) || @@ -5083,10 +5532,10 @@ class PollsCompanion extends UpdateCompanion { this.updatedAt = const Value.absent(), this.extraData = const Value.absent(), this.rowid = const Value.absent(), - }) : id = Value(id), - name = Value(name), - options = Value(options), - voteCountsByOption = Value(voteCountsByOption); + }) : id = Value(id), + name = Value(name), + options = Value(options), + voteCountsByOption = Value(voteCountsByOption); static Insertable custom({ Expression? id, Expression? name, @@ -5115,13 +5564,11 @@ class PollsCompanion extends UpdateCompanion { if (votingVisibility != null) 'voting_visibility': votingVisibility, if (enforceUniqueVote != null) 'enforce_unique_vote': enforceUniqueVote, if (maxVotesAllowed != null) 'max_votes_allowed': maxVotesAllowed, - if (allowUserSuggestedOptions != null) - 'allow_user_suggested_options': allowUserSuggestedOptions, + if (allowUserSuggestedOptions != null) 'allow_user_suggested_options': allowUserSuggestedOptions, if (allowAnswers != null) 'allow_answers': allowAnswers, if (isClosed != null) 'is_closed': isClosed, if (answersCount != null) 'answers_count': answersCount, - if (voteCountsByOption != null) - 'vote_counts_by_option': voteCountsByOption, + if (voteCountsByOption != null) 'vote_counts_by_option': voteCountsByOption, if (voteCount != null) 'vote_count': voteCount, if (createdById != null) 'created_by_id': createdById, if (createdAt != null) 'created_at': createdAt, @@ -5131,25 +5578,26 @@ class PollsCompanion extends UpdateCompanion { }); } - PollsCompanion copyWith( - {Value? id, - Value? name, - Value? description, - Value>? options, - Value? votingVisibility, - Value? enforceUniqueVote, - Value? maxVotesAllowed, - Value? allowUserSuggestedOptions, - Value? allowAnswers, - Value? isClosed, - Value? answersCount, - Value>? voteCountsByOption, - Value? voteCount, - Value? createdById, - Value? createdAt, - Value? updatedAt, - Value?>? extraData, - Value? rowid}) { + PollsCompanion copyWith({ + Value? id, + Value? name, + Value? description, + Value>? options, + Value? votingVisibility, + Value? enforceUniqueVote, + Value? maxVotesAllowed, + Value? allowUserSuggestedOptions, + Value? allowAnswers, + Value? isClosed, + Value? answersCount, + Value>? voteCountsByOption, + Value? voteCount, + Value? createdById, + Value? createdAt, + Value? updatedAt, + Value?>? extraData, + Value? rowid, + }) { return PollsCompanion( id: id ?? this.id, name: name ?? this.name, @@ -5158,8 +5606,7 @@ class PollsCompanion extends UpdateCompanion { votingVisibility: votingVisibility ?? this.votingVisibility, enforceUniqueVote: enforceUniqueVote ?? this.enforceUniqueVote, maxVotesAllowed: maxVotesAllowed ?? this.maxVotesAllowed, - allowUserSuggestedOptions: - allowUserSuggestedOptions ?? this.allowUserSuggestedOptions, + allowUserSuggestedOptions: allowUserSuggestedOptions ?? this.allowUserSuggestedOptions, allowAnswers: allowAnswers ?? this.allowAnswers, isClosed: isClosed ?? this.isClosed, answersCount: answersCount ?? this.answersCount, @@ -5186,12 +5633,10 @@ class PollsCompanion extends UpdateCompanion { map['description'] = Variable(description.value); } if (options.present) { - map['options'] = - Variable($PollsTable.$converteroptions.toSql(options.value)); + map['options'] = Variable($PollsTable.$converteroptions.toSql(options.value)); } if (votingVisibility.present) { - map['voting_visibility'] = Variable( - $PollsTable.$convertervotingVisibility.toSql(votingVisibility.value)); + map['voting_visibility'] = Variable($PollsTable.$convertervotingVisibility.toSql(votingVisibility.value)); } if (enforceUniqueVote.present) { map['enforce_unique_vote'] = Variable(enforceUniqueVote.value); @@ -5200,8 +5645,7 @@ class PollsCompanion extends UpdateCompanion { map['max_votes_allowed'] = Variable(maxVotesAllowed.value); } if (allowUserSuggestedOptions.present) { - map['allow_user_suggested_options'] = - Variable(allowUserSuggestedOptions.value); + map['allow_user_suggested_options'] = Variable(allowUserSuggestedOptions.value); } if (allowAnswers.present) { map['allow_answers'] = Variable(allowAnswers.value); @@ -5213,9 +5657,9 @@ class PollsCompanion extends UpdateCompanion { map['answers_count'] = Variable(answersCount.value); } if (voteCountsByOption.present) { - map['vote_counts_by_option'] = Variable($PollsTable - .$convertervoteCountsByOption - .toSql(voteCountsByOption.value)); + map['vote_counts_by_option'] = Variable( + $PollsTable.$convertervoteCountsByOption.toSql(voteCountsByOption.value), + ); } if (voteCount.present) { map['vote_count'] = Variable(voteCount.value); @@ -5230,8 +5674,7 @@ class PollsCompanion extends UpdateCompanion { map['updated_at'] = Variable(updatedAt.value); } if (extraData.present) { - map['extra_data'] = Variable( - $PollsTable.$converterextraDatan.toSql(extraData.value)); + map['extra_data'] = Variable($PollsTable.$converterextraDatan.toSql(extraData.value)); } if (rowid.present) { map['rowid'] = Variable(rowid.value); @@ -5265,8 +5708,7 @@ class PollsCompanion extends UpdateCompanion { } } -class $PollVotesTable extends PollVotes - with TableInfo<$PollVotesTable, PollVoteEntity> { +class $PollVotesTable extends PollVotes with TableInfo<$PollVotesTable, PollVoteEntity> { @override final GeneratedDatabase attachedDatabase; final String? _alias; @@ -5274,90 +5716,100 @@ class $PollVotesTable extends PollVotes static const VerificationMeta _idMeta = const VerificationMeta('id'); @override late final GeneratedColumn id = GeneratedColumn( - 'id', aliasedName, true, - type: DriftSqlType.string, requiredDuringInsert: false); + 'id', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); static const VerificationMeta _pollIdMeta = const VerificationMeta('pollId'); @override late final GeneratedColumn pollId = GeneratedColumn( - 'poll_id', aliasedName, true, - type: DriftSqlType.string, - requiredDuringInsert: false, - defaultConstraints: GeneratedColumn.constraintIsAlways( - 'REFERENCES polls (id) ON DELETE CASCADE')); - static const VerificationMeta _optionIdMeta = - const VerificationMeta('optionId'); + 'poll_id', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + defaultConstraints: GeneratedColumn.constraintIsAlways('REFERENCES polls (id) ON DELETE CASCADE'), + ); + static const VerificationMeta _optionIdMeta = const VerificationMeta('optionId'); @override late final GeneratedColumn optionId = GeneratedColumn( - 'option_id', aliasedName, true, - type: DriftSqlType.string, requiredDuringInsert: false); - static const VerificationMeta _answerTextMeta = - const VerificationMeta('answerText'); + 'option_id', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); + static const VerificationMeta _answerTextMeta = const VerificationMeta('answerText'); @override late final GeneratedColumn answerText = GeneratedColumn( - 'answer_text', aliasedName, true, - type: DriftSqlType.string, requiredDuringInsert: false); - static const VerificationMeta _createdAtMeta = - const VerificationMeta('createdAt'); + 'answer_text', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); + static const VerificationMeta _createdAtMeta = const VerificationMeta('createdAt'); @override late final GeneratedColumn createdAt = GeneratedColumn( - 'created_at', aliasedName, false, - type: DriftSqlType.dateTime, - requiredDuringInsert: false, - defaultValue: currentDateAndTime); - static const VerificationMeta _updatedAtMeta = - const VerificationMeta('updatedAt'); + 'created_at', + aliasedName, + false, + type: DriftSqlType.dateTime, + requiredDuringInsert: false, + defaultValue: currentDateAndTime, + ); + static const VerificationMeta _updatedAtMeta = const VerificationMeta('updatedAt'); @override late final GeneratedColumn updatedAt = GeneratedColumn( - 'updated_at', aliasedName, false, - type: DriftSqlType.dateTime, - requiredDuringInsert: false, - defaultValue: currentDateAndTime); + 'updated_at', + aliasedName, + false, + type: DriftSqlType.dateTime, + requiredDuringInsert: false, + defaultValue: currentDateAndTime, + ); static const VerificationMeta _userIdMeta = const VerificationMeta('userId'); @override late final GeneratedColumn userId = GeneratedColumn( - 'user_id', aliasedName, true, - type: DriftSqlType.string, requiredDuringInsert: false); + 'user_id', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); @override - List get $columns => - [id, pollId, optionId, answerText, createdAt, updatedAt, userId]; + List get $columns => [id, pollId, optionId, answerText, createdAt, updatedAt, userId]; @override String get aliasedName => _alias ?? actualTableName; @override String get actualTableName => $name; static const String $name = 'poll_votes'; @override - VerificationContext validateIntegrity(Insertable instance, - {bool isInserting = false}) { + VerificationContext validateIntegrity(Insertable instance, {bool isInserting = false}) { final context = VerificationContext(); final data = instance.toColumns(true); if (data.containsKey('id')) { context.handle(_idMeta, id.isAcceptableOrUnknown(data['id']!, _idMeta)); } if (data.containsKey('poll_id')) { - context.handle(_pollIdMeta, - pollId.isAcceptableOrUnknown(data['poll_id']!, _pollIdMeta)); + context.handle(_pollIdMeta, pollId.isAcceptableOrUnknown(data['poll_id']!, _pollIdMeta)); } if (data.containsKey('option_id')) { - context.handle(_optionIdMeta, - optionId.isAcceptableOrUnknown(data['option_id']!, _optionIdMeta)); + context.handle(_optionIdMeta, optionId.isAcceptableOrUnknown(data['option_id']!, _optionIdMeta)); } if (data.containsKey('answer_text')) { - context.handle( - _answerTextMeta, - answerText.isAcceptableOrUnknown( - data['answer_text']!, _answerTextMeta)); + context.handle(_answerTextMeta, answerText.isAcceptableOrUnknown(data['answer_text']!, _answerTextMeta)); } if (data.containsKey('created_at')) { - context.handle(_createdAtMeta, - createdAt.isAcceptableOrUnknown(data['created_at']!, _createdAtMeta)); + context.handle(_createdAtMeta, createdAt.isAcceptableOrUnknown(data['created_at']!, _createdAtMeta)); } if (data.containsKey('updated_at')) { - context.handle(_updatedAtMeta, - updatedAt.isAcceptableOrUnknown(data['updated_at']!, _updatedAtMeta)); + context.handle(_updatedAtMeta, updatedAt.isAcceptableOrUnknown(data['updated_at']!, _updatedAtMeta)); } if (data.containsKey('user_id')) { - context.handle(_userIdMeta, - userId.isAcceptableOrUnknown(data['user_id']!, _userIdMeta)); + context.handle(_userIdMeta, userId.isAcceptableOrUnknown(data['user_id']!, _userIdMeta)); } return context; } @@ -5368,20 +5820,13 @@ class $PollVotesTable extends PollVotes PollVoteEntity map(Map data, {String? tablePrefix}) { final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; return PollVoteEntity( - id: attachedDatabase.typeMapping - .read(DriftSqlType.string, data['${effectivePrefix}id']), - pollId: attachedDatabase.typeMapping - .read(DriftSqlType.string, data['${effectivePrefix}poll_id']), - optionId: attachedDatabase.typeMapping - .read(DriftSqlType.string, data['${effectivePrefix}option_id']), - answerText: attachedDatabase.typeMapping - .read(DriftSqlType.string, data['${effectivePrefix}answer_text']), - createdAt: attachedDatabase.typeMapping - .read(DriftSqlType.dateTime, data['${effectivePrefix}created_at'])!, - updatedAt: attachedDatabase.typeMapping - .read(DriftSqlType.dateTime, data['${effectivePrefix}updated_at'])!, - userId: attachedDatabase.typeMapping - .read(DriftSqlType.string, data['${effectivePrefix}user_id']), + id: attachedDatabase.typeMapping.read(DriftSqlType.string, data['${effectivePrefix}id']), + pollId: attachedDatabase.typeMapping.read(DriftSqlType.string, data['${effectivePrefix}poll_id']), + optionId: attachedDatabase.typeMapping.read(DriftSqlType.string, data['${effectivePrefix}option_id']), + answerText: attachedDatabase.typeMapping.read(DriftSqlType.string, data['${effectivePrefix}answer_text']), + createdAt: attachedDatabase.typeMapping.read(DriftSqlType.dateTime, data['${effectivePrefix}created_at'])!, + updatedAt: attachedDatabase.typeMapping.read(DriftSqlType.dateTime, data['${effectivePrefix}updated_at'])!, + userId: attachedDatabase.typeMapping.read(DriftSqlType.string, data['${effectivePrefix}user_id']), ); } @@ -5418,14 +5863,15 @@ class PollVoteEntity extends DataClass implements Insertable { /// /// Nullable if the poll is anonymous. final String? userId; - const PollVoteEntity( - {this.id, - this.pollId, - this.optionId, - this.answerText, - required this.createdAt, - required this.updatedAt, - this.userId}); + const PollVoteEntity({ + this.id, + this.pollId, + this.optionId, + this.answerText, + required this.createdAt, + required this.updatedAt, + this.userId, + }); @override Map toColumns(bool nullToAbsent) { final map = {}; @@ -5449,8 +5895,7 @@ class PollVoteEntity extends DataClass implements Insertable { return map; } - factory PollVoteEntity.fromJson(Map json, - {ValueSerializer? serializer}) { + factory PollVoteEntity.fromJson(Map json, {ValueSerializer? serializer}) { serializer ??= driftRuntimeOptions.defaultSerializer; return PollVoteEntity( id: serializer.fromJson(json['id']), @@ -5476,30 +5921,29 @@ class PollVoteEntity extends DataClass implements Insertable { }; } - PollVoteEntity copyWith( - {Value id = const Value.absent(), - Value pollId = const Value.absent(), - Value optionId = const Value.absent(), - Value answerText = const Value.absent(), - DateTime? createdAt, - DateTime? updatedAt, - Value userId = const Value.absent()}) => - PollVoteEntity( - id: id.present ? id.value : this.id, - pollId: pollId.present ? pollId.value : this.pollId, - optionId: optionId.present ? optionId.value : this.optionId, - answerText: answerText.present ? answerText.value : this.answerText, - createdAt: createdAt ?? this.createdAt, - updatedAt: updatedAt ?? this.updatedAt, - userId: userId.present ? userId.value : this.userId, - ); + PollVoteEntity copyWith({ + Value id = const Value.absent(), + Value pollId = const Value.absent(), + Value optionId = const Value.absent(), + Value answerText = const Value.absent(), + DateTime? createdAt, + DateTime? updatedAt, + Value userId = const Value.absent(), + }) => PollVoteEntity( + id: id.present ? id.value : this.id, + pollId: pollId.present ? pollId.value : this.pollId, + optionId: optionId.present ? optionId.value : this.optionId, + answerText: answerText.present ? answerText.value : this.answerText, + createdAt: createdAt ?? this.createdAt, + updatedAt: updatedAt ?? this.updatedAt, + userId: userId.present ? userId.value : this.userId, + ); PollVoteEntity copyWithCompanion(PollVotesCompanion data) { return PollVoteEntity( id: data.id.present ? data.id.value : this.id, pollId: data.pollId.present ? data.pollId.value : this.pollId, optionId: data.optionId.present ? data.optionId.value : this.optionId, - answerText: - data.answerText.present ? data.answerText.value : this.answerText, + answerText: data.answerText.present ? data.answerText.value : this.answerText, createdAt: data.createdAt.present ? data.createdAt.value : this.createdAt, updatedAt: data.updatedAt.present ? data.updatedAt.value : this.updatedAt, userId: data.userId.present ? data.userId.value : this.userId, @@ -5521,8 +5965,7 @@ class PollVoteEntity extends DataClass implements Insertable { } @override - int get hashCode => Object.hash( - id, pollId, optionId, answerText, createdAt, updatedAt, userId); + int get hashCode => Object.hash(id, pollId, optionId, answerText, createdAt, updatedAt, userId); @override bool operator ==(Object other) => identical(this, other) || @@ -5587,15 +6030,16 @@ class PollVotesCompanion extends UpdateCompanion { }); } - PollVotesCompanion copyWith( - {Value? id, - Value? pollId, - Value? optionId, - Value? answerText, - Value? createdAt, - Value? updatedAt, - Value? userId, - Value? rowid}) { + PollVotesCompanion copyWith({ + Value? id, + Value? pollId, + Value? optionId, + Value? answerText, + Value? createdAt, + Value? updatedAt, + Value? userId, + Value? rowid, + }) { return PollVotesCompanion( id: id ?? this.id, pollId: pollId ?? this.pollId, @@ -5663,109 +6107,131 @@ class $PinnedMessageReactionsTable extends PinnedMessageReactions static const VerificationMeta _userIdMeta = const VerificationMeta('userId'); @override late final GeneratedColumn userId = GeneratedColumn( - 'user_id', aliasedName, false, - type: DriftSqlType.string, requiredDuringInsert: true); - static const VerificationMeta _messageIdMeta = - const VerificationMeta('messageId'); + 'user_id', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); + static const VerificationMeta _messageIdMeta = const VerificationMeta('messageId'); @override late final GeneratedColumn messageId = GeneratedColumn( - 'message_id', aliasedName, false, - type: DriftSqlType.string, - requiredDuringInsert: true, - defaultConstraints: GeneratedColumn.constraintIsAlways( - 'REFERENCES pinned_messages (id) ON DELETE CASCADE')); + 'message_id', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + defaultConstraints: GeneratedColumn.constraintIsAlways('REFERENCES pinned_messages (id) ON DELETE CASCADE'), + ); static const VerificationMeta _typeMeta = const VerificationMeta('type'); @override late final GeneratedColumn type = GeneratedColumn( - 'type', aliasedName, false, - type: DriftSqlType.string, requiredDuringInsert: true); - static const VerificationMeta _createdAtMeta = - const VerificationMeta('createdAt'); + 'type', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + static const VerificationMeta _emojiCodeMeta = const VerificationMeta('emojiCode'); + @override + late final GeneratedColumn emojiCode = GeneratedColumn( + 'emoji_code', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); + static const VerificationMeta _createdAtMeta = const VerificationMeta('createdAt'); @override late final GeneratedColumn createdAt = GeneratedColumn( - 'created_at', aliasedName, false, - type: DriftSqlType.dateTime, - requiredDuringInsert: false, - defaultValue: currentDateAndTime); + 'created_at', + aliasedName, + false, + type: DriftSqlType.dateTime, + requiredDuringInsert: false, + defaultValue: currentDateAndTime, + ); + static const VerificationMeta _updatedAtMeta = const VerificationMeta('updatedAt'); + @override + late final GeneratedColumn updatedAt = GeneratedColumn( + 'updated_at', + aliasedName, + false, + type: DriftSqlType.dateTime, + requiredDuringInsert: false, + defaultValue: currentDateAndTime, + ); static const VerificationMeta _scoreMeta = const VerificationMeta('score'); @override late final GeneratedColumn score = GeneratedColumn( - 'score', aliasedName, false, - type: DriftSqlType.int, - requiredDuringInsert: false, - defaultValue: const Constant(0)); - static const VerificationMeta _extraDataMeta = - const VerificationMeta('extraData'); - @override - late final GeneratedColumnWithTypeConverter?, String> - extraData = GeneratedColumn('extra_data', aliasedName, true, - type: DriftSqlType.string, requiredDuringInsert: false) - .withConverter?>( - $PinnedMessageReactionsTable.$converterextraDatan); - @override - List get $columns => - [userId, messageId, type, createdAt, score, extraData]; + 'score', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: false, + defaultValue: const Constant(0), + ); + @override + late final GeneratedColumnWithTypeConverter?, String> extraData = GeneratedColumn( + 'extra_data', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ).withConverter?>($PinnedMessageReactionsTable.$converterextraDatan); + @override + List get $columns => [userId, messageId, type, emojiCode, createdAt, updatedAt, score, extraData]; @override String get aliasedName => _alias ?? actualTableName; @override String get actualTableName => $name; static const String $name = 'pinned_message_reactions'; @override - VerificationContext validateIntegrity( - Insertable instance, - {bool isInserting = false}) { + VerificationContext validateIntegrity(Insertable instance, {bool isInserting = false}) { final context = VerificationContext(); final data = instance.toColumns(true); if (data.containsKey('user_id')) { - context.handle(_userIdMeta, - userId.isAcceptableOrUnknown(data['user_id']!, _userIdMeta)); - } else if (isInserting) { - context.missing(_userIdMeta); + context.handle(_userIdMeta, userId.isAcceptableOrUnknown(data['user_id']!, _userIdMeta)); } if (data.containsKey('message_id')) { - context.handle(_messageIdMeta, - messageId.isAcceptableOrUnknown(data['message_id']!, _messageIdMeta)); - } else if (isInserting) { - context.missing(_messageIdMeta); + context.handle(_messageIdMeta, messageId.isAcceptableOrUnknown(data['message_id']!, _messageIdMeta)); } if (data.containsKey('type')) { - context.handle( - _typeMeta, type.isAcceptableOrUnknown(data['type']!, _typeMeta)); + context.handle(_typeMeta, type.isAcceptableOrUnknown(data['type']!, _typeMeta)); } else if (isInserting) { context.missing(_typeMeta); } + if (data.containsKey('emoji_code')) { + context.handle(_emojiCodeMeta, emojiCode.isAcceptableOrUnknown(data['emoji_code']!, _emojiCodeMeta)); + } if (data.containsKey('created_at')) { - context.handle(_createdAtMeta, - createdAt.isAcceptableOrUnknown(data['created_at']!, _createdAtMeta)); + context.handle(_createdAtMeta, createdAt.isAcceptableOrUnknown(data['created_at']!, _createdAtMeta)); + } + if (data.containsKey('updated_at')) { + context.handle(_updatedAtMeta, updatedAt.isAcceptableOrUnknown(data['updated_at']!, _updatedAtMeta)); } if (data.containsKey('score')) { - context.handle( - _scoreMeta, score.isAcceptableOrUnknown(data['score']!, _scoreMeta)); + context.handle(_scoreMeta, score.isAcceptableOrUnknown(data['score']!, _scoreMeta)); } - context.handle(_extraDataMeta, const VerificationResult.success()); return context; } @override Set get $primaryKey => {messageId, type, userId}; @override - PinnedMessageReactionEntity map(Map data, - {String? tablePrefix}) { + PinnedMessageReactionEntity map(Map data, {String? tablePrefix}) { final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; return PinnedMessageReactionEntity( - userId: attachedDatabase.typeMapping - .read(DriftSqlType.string, data['${effectivePrefix}user_id'])!, - messageId: attachedDatabase.typeMapping - .read(DriftSqlType.string, data['${effectivePrefix}message_id'])!, - type: attachedDatabase.typeMapping - .read(DriftSqlType.string, data['${effectivePrefix}type'])!, - createdAt: attachedDatabase.typeMapping - .read(DriftSqlType.dateTime, data['${effectivePrefix}created_at'])!, - score: attachedDatabase.typeMapping - .read(DriftSqlType.int, data['${effectivePrefix}score'])!, + userId: attachedDatabase.typeMapping.read(DriftSqlType.string, data['${effectivePrefix}user_id']), + messageId: attachedDatabase.typeMapping.read(DriftSqlType.string, data['${effectivePrefix}message_id']), + type: attachedDatabase.typeMapping.read(DriftSqlType.string, data['${effectivePrefix}type'])!, + emojiCode: attachedDatabase.typeMapping.read(DriftSqlType.string, data['${effectivePrefix}emoji_code']), + createdAt: attachedDatabase.typeMapping.read(DriftSqlType.dateTime, data['${effectivePrefix}created_at'])!, + updatedAt: attachedDatabase.typeMapping.read(DriftSqlType.dateTime, data['${effectivePrefix}updated_at'])!, + score: attachedDatabase.typeMapping.read(DriftSqlType.int, data['${effectivePrefix}score'])!, extraData: $PinnedMessageReactionsTable.$converterextraDatan.fromSql( - attachedDatabase.typeMapping - .read(DriftSqlType.string, data['${effectivePrefix}extra_data'])), + attachedDatabase.typeMapping.read(DriftSqlType.string, data['${effectivePrefix}extra_data']), + ), ); } @@ -5774,61 +6240,77 @@ class $PinnedMessageReactionsTable extends PinnedMessageReactions return $PinnedMessageReactionsTable(attachedDatabase, alias); } - static TypeConverter, String> $converterextraData = - MapConverter(); - static TypeConverter?, String?> $converterextraDatan = - NullAwareTypeConverter.wrap($converterextraData); + static TypeConverter, String> $converterextraData = MapConverter(); + static TypeConverter?, String?> $converterextraDatan = NullAwareTypeConverter.wrap( + $converterextraData, + ); } -class PinnedMessageReactionEntity extends DataClass - implements Insertable { +class PinnedMessageReactionEntity extends DataClass implements Insertable { /// The id of the user that sent the reaction - final String userId; + final String? userId; /// The messageId to which the reaction belongs - final String messageId; + final String? messageId; /// The type of the reaction final String type; + /// The emoji code for the reaction + final String? emojiCode; + /// The DateTime on which the reaction is created final DateTime createdAt; + /// The DateTime on which the reaction was last updated + final DateTime updatedAt; + /// The score of the reaction (ie. number of reactions sent) final int score; /// Reaction custom extraData final Map? extraData; - const PinnedMessageReactionEntity( - {required this.userId, - required this.messageId, - required this.type, - required this.createdAt, - required this.score, - this.extraData}); + const PinnedMessageReactionEntity({ + this.userId, + this.messageId, + required this.type, + this.emojiCode, + required this.createdAt, + required this.updatedAt, + required this.score, + this.extraData, + }); @override Map toColumns(bool nullToAbsent) { final map = {}; - map['user_id'] = Variable(userId); - map['message_id'] = Variable(messageId); + if (!nullToAbsent || userId != null) { + map['user_id'] = Variable(userId); + } + if (!nullToAbsent || messageId != null) { + map['message_id'] = Variable(messageId); + } map['type'] = Variable(type); + if (!nullToAbsent || emojiCode != null) { + map['emoji_code'] = Variable(emojiCode); + } map['created_at'] = Variable(createdAt); + map['updated_at'] = Variable(updatedAt); map['score'] = Variable(score); if (!nullToAbsent || extraData != null) { - map['extra_data'] = Variable( - $PinnedMessageReactionsTable.$converterextraDatan.toSql(extraData)); + map['extra_data'] = Variable($PinnedMessageReactionsTable.$converterextraDatan.toSql(extraData)); } return map; } - factory PinnedMessageReactionEntity.fromJson(Map json, - {ValueSerializer? serializer}) { + factory PinnedMessageReactionEntity.fromJson(Map json, {ValueSerializer? serializer}) { serializer ??= driftRuntimeOptions.defaultSerializer; return PinnedMessageReactionEntity( - userId: serializer.fromJson(json['userId']), - messageId: serializer.fromJson(json['messageId']), + userId: serializer.fromJson(json['userId']), + messageId: serializer.fromJson(json['messageId']), type: serializer.fromJson(json['type']), + emojiCode: serializer.fromJson(json['emojiCode']), createdAt: serializer.fromJson(json['createdAt']), + updatedAt: serializer.fromJson(json['updatedAt']), score: serializer.fromJson(json['score']), extraData: serializer.fromJson?>(json['extraData']), ); @@ -5837,37 +6319,44 @@ class PinnedMessageReactionEntity extends DataClass Map toJson({ValueSerializer? serializer}) { serializer ??= driftRuntimeOptions.defaultSerializer; return { - 'userId': serializer.toJson(userId), - 'messageId': serializer.toJson(messageId), + 'userId': serializer.toJson(userId), + 'messageId': serializer.toJson(messageId), 'type': serializer.toJson(type), + 'emojiCode': serializer.toJson(emojiCode), 'createdAt': serializer.toJson(createdAt), + 'updatedAt': serializer.toJson(updatedAt), 'score': serializer.toJson(score), 'extraData': serializer.toJson?>(extraData), }; } - PinnedMessageReactionEntity copyWith( - {String? userId, - String? messageId, - String? type, - DateTime? createdAt, - int? score, - Value?> extraData = const Value.absent()}) => - PinnedMessageReactionEntity( - userId: userId ?? this.userId, - messageId: messageId ?? this.messageId, - type: type ?? this.type, - createdAt: createdAt ?? this.createdAt, - score: score ?? this.score, - extraData: extraData.present ? extraData.value : this.extraData, - ); - PinnedMessageReactionEntity copyWithCompanion( - PinnedMessageReactionsCompanion data) { + PinnedMessageReactionEntity copyWith({ + Value userId = const Value.absent(), + Value messageId = const Value.absent(), + String? type, + Value emojiCode = const Value.absent(), + DateTime? createdAt, + DateTime? updatedAt, + int? score, + Value?> extraData = const Value.absent(), + }) => PinnedMessageReactionEntity( + userId: userId.present ? userId.value : this.userId, + messageId: messageId.present ? messageId.value : this.messageId, + type: type ?? this.type, + emojiCode: emojiCode.present ? emojiCode.value : this.emojiCode, + createdAt: createdAt ?? this.createdAt, + updatedAt: updatedAt ?? this.updatedAt, + score: score ?? this.score, + extraData: extraData.present ? extraData.value : this.extraData, + ); + PinnedMessageReactionEntity copyWithCompanion(PinnedMessageReactionsCompanion data) { return PinnedMessageReactionEntity( userId: data.userId.present ? data.userId.value : this.userId, messageId: data.messageId.present ? data.messageId.value : this.messageId, type: data.type.present ? data.type.value : this.type, + emojiCode: data.emojiCode.present ? data.emojiCode.value : this.emojiCode, createdAt: data.createdAt.present ? data.createdAt.value : this.createdAt, + updatedAt: data.updatedAt.present ? data.updatedAt.value : this.updatedAt, score: data.score.present ? data.score.value : this.score, extraData: data.extraData.present ? data.extraData.value : this.extraData, ); @@ -5879,7 +6368,9 @@ class PinnedMessageReactionEntity extends DataClass ..write('userId: $userId, ') ..write('messageId: $messageId, ') ..write('type: $type, ') + ..write('emojiCode: $emojiCode, ') ..write('createdAt: $createdAt, ') + ..write('updatedAt: $updatedAt, ') ..write('score: $score, ') ..write('extraData: $extraData') ..write(')')) @@ -5887,8 +6378,7 @@ class PinnedMessageReactionEntity extends DataClass } @override - int get hashCode => - Object.hash(userId, messageId, type, createdAt, score, extraData); + int get hashCode => Object.hash(userId, messageId, type, emojiCode, createdAt, updatedAt, score, extraData); @override bool operator ==(Object other) => identical(this, other) || @@ -5896,17 +6386,20 @@ class PinnedMessageReactionEntity extends DataClass other.userId == this.userId && other.messageId == this.messageId && other.type == this.type && + other.emojiCode == this.emojiCode && other.createdAt == this.createdAt && + other.updatedAt == this.updatedAt && other.score == this.score && other.extraData == this.extraData); } -class PinnedMessageReactionsCompanion - extends UpdateCompanion { - final Value userId; - final Value messageId; +class PinnedMessageReactionsCompanion extends UpdateCompanion { + final Value userId; + final Value messageId; final Value type; + final Value emojiCode; final Value createdAt; + final Value updatedAt; final Value score; final Value?> extraData; final Value rowid; @@ -5914,27 +6407,31 @@ class PinnedMessageReactionsCompanion this.userId = const Value.absent(), this.messageId = const Value.absent(), this.type = const Value.absent(), + this.emojiCode = const Value.absent(), this.createdAt = const Value.absent(), + this.updatedAt = const Value.absent(), this.score = const Value.absent(), this.extraData = const Value.absent(), this.rowid = const Value.absent(), }); PinnedMessageReactionsCompanion.insert({ - required String userId, - required String messageId, + this.userId = const Value.absent(), + this.messageId = const Value.absent(), required String type, + this.emojiCode = const Value.absent(), this.createdAt = const Value.absent(), + this.updatedAt = const Value.absent(), this.score = const Value.absent(), this.extraData = const Value.absent(), this.rowid = const Value.absent(), - }) : userId = Value(userId), - messageId = Value(messageId), - type = Value(type); + }) : type = Value(type); static Insertable custom({ Expression? userId, Expression? messageId, Expression? type, + Expression? emojiCode, Expression? createdAt, + Expression? updatedAt, Expression? score, Expression? extraData, Expression? rowid, @@ -5943,26 +6440,33 @@ class PinnedMessageReactionsCompanion if (userId != null) 'user_id': userId, if (messageId != null) 'message_id': messageId, if (type != null) 'type': type, + if (emojiCode != null) 'emoji_code': emojiCode, if (createdAt != null) 'created_at': createdAt, + if (updatedAt != null) 'updated_at': updatedAt, if (score != null) 'score': score, if (extraData != null) 'extra_data': extraData, if (rowid != null) 'rowid': rowid, }); } - PinnedMessageReactionsCompanion copyWith( - {Value? userId, - Value? messageId, - Value? type, - Value? createdAt, - Value? score, - Value?>? extraData, - Value? rowid}) { + PinnedMessageReactionsCompanion copyWith({ + Value? userId, + Value? messageId, + Value? type, + Value? emojiCode, + Value? createdAt, + Value? updatedAt, + Value? score, + Value?>? extraData, + Value? rowid, + }) { return PinnedMessageReactionsCompanion( userId: userId ?? this.userId, messageId: messageId ?? this.messageId, type: type ?? this.type, + emojiCode: emojiCode ?? this.emojiCode, createdAt: createdAt ?? this.createdAt, + updatedAt: updatedAt ?? this.updatedAt, score: score ?? this.score, extraData: extraData ?? this.extraData, rowid: rowid ?? this.rowid, @@ -5981,16 +6485,20 @@ class PinnedMessageReactionsCompanion if (type.present) { map['type'] = Variable(type.value); } + if (emojiCode.present) { + map['emoji_code'] = Variable(emojiCode.value); + } if (createdAt.present) { map['created_at'] = Variable(createdAt.value); } + if (updatedAt.present) { + map['updated_at'] = Variable(updatedAt.value); + } if (score.present) { map['score'] = Variable(score.value); } if (extraData.present) { - map['extra_data'] = Variable($PinnedMessageReactionsTable - .$converterextraDatan - .toSql(extraData.value)); + map['extra_data'] = Variable($PinnedMessageReactionsTable.$converterextraDatan.toSql(extraData.value)); } if (rowid.present) { map['rowid'] = Variable(rowid.value); @@ -6004,7 +6512,9 @@ class PinnedMessageReactionsCompanion ..write('userId: $userId, ') ..write('messageId: $messageId, ') ..write('type: $type, ') + ..write('emojiCode: $emojiCode, ') ..write('createdAt: $createdAt, ') + ..write('updatedAt: $updatedAt, ') ..write('score: $score, ') ..write('extraData: $extraData, ') ..write('rowid: $rowid') @@ -6013,8 +6523,7 @@ class PinnedMessageReactionsCompanion } } -class $ReactionsTable extends Reactions - with TableInfo<$ReactionsTable, ReactionEntity> { +class $ReactionsTable extends Reactions with TableInfo<$ReactionsTable, ReactionEntity> { @override final GeneratedDatabase attachedDatabase; final String? _alias; @@ -6022,85 +6531,112 @@ class $ReactionsTable extends Reactions static const VerificationMeta _userIdMeta = const VerificationMeta('userId'); @override late final GeneratedColumn userId = GeneratedColumn( - 'user_id', aliasedName, false, - type: DriftSqlType.string, requiredDuringInsert: true); - static const VerificationMeta _messageIdMeta = - const VerificationMeta('messageId'); + 'user_id', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); + static const VerificationMeta _messageIdMeta = const VerificationMeta('messageId'); @override late final GeneratedColumn messageId = GeneratedColumn( - 'message_id', aliasedName, false, - type: DriftSqlType.string, - requiredDuringInsert: true, - defaultConstraints: GeneratedColumn.constraintIsAlways( - 'REFERENCES messages (id) ON DELETE CASCADE')); + 'message_id', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + defaultConstraints: GeneratedColumn.constraintIsAlways('REFERENCES messages (id) ON DELETE CASCADE'), + ); static const VerificationMeta _typeMeta = const VerificationMeta('type'); @override late final GeneratedColumn type = GeneratedColumn( - 'type', aliasedName, false, - type: DriftSqlType.string, requiredDuringInsert: true); - static const VerificationMeta _createdAtMeta = - const VerificationMeta('createdAt'); + 'type', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + static const VerificationMeta _emojiCodeMeta = const VerificationMeta('emojiCode'); + @override + late final GeneratedColumn emojiCode = GeneratedColumn( + 'emoji_code', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); + static const VerificationMeta _createdAtMeta = const VerificationMeta('createdAt'); @override late final GeneratedColumn createdAt = GeneratedColumn( - 'created_at', aliasedName, false, - type: DriftSqlType.dateTime, - requiredDuringInsert: false, - defaultValue: currentDateAndTime); + 'created_at', + aliasedName, + false, + type: DriftSqlType.dateTime, + requiredDuringInsert: false, + defaultValue: currentDateAndTime, + ); + static const VerificationMeta _updatedAtMeta = const VerificationMeta('updatedAt'); + @override + late final GeneratedColumn updatedAt = GeneratedColumn( + 'updated_at', + aliasedName, + false, + type: DriftSqlType.dateTime, + requiredDuringInsert: false, + defaultValue: currentDateAndTime, + ); static const VerificationMeta _scoreMeta = const VerificationMeta('score'); @override late final GeneratedColumn score = GeneratedColumn( - 'score', aliasedName, false, - type: DriftSqlType.int, - requiredDuringInsert: false, - defaultValue: const Constant(0)); - static const VerificationMeta _extraDataMeta = - const VerificationMeta('extraData'); - @override - late final GeneratedColumnWithTypeConverter?, String> - extraData = GeneratedColumn('extra_data', aliasedName, true, - type: DriftSqlType.string, requiredDuringInsert: false) - .withConverter?>( - $ReactionsTable.$converterextraDatan); - @override - List get $columns => - [userId, messageId, type, createdAt, score, extraData]; + 'score', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: false, + defaultValue: const Constant(0), + ); + @override + late final GeneratedColumnWithTypeConverter?, String> extraData = GeneratedColumn( + 'extra_data', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ).withConverter?>($ReactionsTable.$converterextraDatan); + @override + List get $columns => [userId, messageId, type, emojiCode, createdAt, updatedAt, score, extraData]; @override String get aliasedName => _alias ?? actualTableName; @override String get actualTableName => $name; static const String $name = 'reactions'; @override - VerificationContext validateIntegrity(Insertable instance, - {bool isInserting = false}) { + VerificationContext validateIntegrity(Insertable instance, {bool isInserting = false}) { final context = VerificationContext(); final data = instance.toColumns(true); if (data.containsKey('user_id')) { - context.handle(_userIdMeta, - userId.isAcceptableOrUnknown(data['user_id']!, _userIdMeta)); - } else if (isInserting) { - context.missing(_userIdMeta); + context.handle(_userIdMeta, userId.isAcceptableOrUnknown(data['user_id']!, _userIdMeta)); } if (data.containsKey('message_id')) { - context.handle(_messageIdMeta, - messageId.isAcceptableOrUnknown(data['message_id']!, _messageIdMeta)); - } else if (isInserting) { - context.missing(_messageIdMeta); + context.handle(_messageIdMeta, messageId.isAcceptableOrUnknown(data['message_id']!, _messageIdMeta)); } if (data.containsKey('type')) { - context.handle( - _typeMeta, type.isAcceptableOrUnknown(data['type']!, _typeMeta)); + context.handle(_typeMeta, type.isAcceptableOrUnknown(data['type']!, _typeMeta)); } else if (isInserting) { context.missing(_typeMeta); } + if (data.containsKey('emoji_code')) { + context.handle(_emojiCodeMeta, emojiCode.isAcceptableOrUnknown(data['emoji_code']!, _emojiCodeMeta)); + } if (data.containsKey('created_at')) { - context.handle(_createdAtMeta, - createdAt.isAcceptableOrUnknown(data['created_at']!, _createdAtMeta)); + context.handle(_createdAtMeta, createdAt.isAcceptableOrUnknown(data['created_at']!, _createdAtMeta)); + } + if (data.containsKey('updated_at')) { + context.handle(_updatedAtMeta, updatedAt.isAcceptableOrUnknown(data['updated_at']!, _updatedAtMeta)); } if (data.containsKey('score')) { - context.handle( - _scoreMeta, score.isAcceptableOrUnknown(data['score']!, _scoreMeta)); + context.handle(_scoreMeta, score.isAcceptableOrUnknown(data['score']!, _scoreMeta)); } - context.handle(_extraDataMeta, const VerificationResult.success()); return context; } @@ -6110,19 +6646,16 @@ class $ReactionsTable extends Reactions ReactionEntity map(Map data, {String? tablePrefix}) { final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; return ReactionEntity( - userId: attachedDatabase.typeMapping - .read(DriftSqlType.string, data['${effectivePrefix}user_id'])!, - messageId: attachedDatabase.typeMapping - .read(DriftSqlType.string, data['${effectivePrefix}message_id'])!, - type: attachedDatabase.typeMapping - .read(DriftSqlType.string, data['${effectivePrefix}type'])!, - createdAt: attachedDatabase.typeMapping - .read(DriftSqlType.dateTime, data['${effectivePrefix}created_at'])!, - score: attachedDatabase.typeMapping - .read(DriftSqlType.int, data['${effectivePrefix}score'])!, - extraData: $ReactionsTable.$converterextraDatan.fromSql(attachedDatabase - .typeMapping - .read(DriftSqlType.string, data['${effectivePrefix}extra_data'])), + userId: attachedDatabase.typeMapping.read(DriftSqlType.string, data['${effectivePrefix}user_id']), + messageId: attachedDatabase.typeMapping.read(DriftSqlType.string, data['${effectivePrefix}message_id']), + type: attachedDatabase.typeMapping.read(DriftSqlType.string, data['${effectivePrefix}type'])!, + emojiCode: attachedDatabase.typeMapping.read(DriftSqlType.string, data['${effectivePrefix}emoji_code']), + createdAt: attachedDatabase.typeMapping.read(DriftSqlType.dateTime, data['${effectivePrefix}created_at'])!, + updatedAt: attachedDatabase.typeMapping.read(DriftSqlType.dateTime, data['${effectivePrefix}updated_at'])!, + score: attachedDatabase.typeMapping.read(DriftSqlType.int, data['${effectivePrefix}score'])!, + extraData: $ReactionsTable.$converterextraDatan.fromSql( + attachedDatabase.typeMapping.read(DriftSqlType.string, data['${effectivePrefix}extra_data']), + ), ); } @@ -6131,60 +6664,77 @@ class $ReactionsTable extends Reactions return $ReactionsTable(attachedDatabase, alias); } - static TypeConverter, String> $converterextraData = - MapConverter(); - static TypeConverter?, String?> $converterextraDatan = - NullAwareTypeConverter.wrap($converterextraData); + static TypeConverter, String> $converterextraData = MapConverter(); + static TypeConverter?, String?> $converterextraDatan = NullAwareTypeConverter.wrap( + $converterextraData, + ); } class ReactionEntity extends DataClass implements Insertable { /// The id of the user that sent the reaction - final String userId; + final String? userId; /// The messageId to which the reaction belongs - final String messageId; + final String? messageId; /// The type of the reaction final String type; + /// The emoji code for the reaction + final String? emojiCode; + /// The DateTime on which the reaction is created final DateTime createdAt; + /// The DateTime on which the reaction was last updated + final DateTime updatedAt; + /// The score of the reaction (ie. number of reactions sent) final int score; /// Reaction custom extraData final Map? extraData; - const ReactionEntity( - {required this.userId, - required this.messageId, - required this.type, - required this.createdAt, - required this.score, - this.extraData}); + const ReactionEntity({ + this.userId, + this.messageId, + required this.type, + this.emojiCode, + required this.createdAt, + required this.updatedAt, + required this.score, + this.extraData, + }); @override Map toColumns(bool nullToAbsent) { final map = {}; - map['user_id'] = Variable(userId); - map['message_id'] = Variable(messageId); + if (!nullToAbsent || userId != null) { + map['user_id'] = Variable(userId); + } + if (!nullToAbsent || messageId != null) { + map['message_id'] = Variable(messageId); + } map['type'] = Variable(type); + if (!nullToAbsent || emojiCode != null) { + map['emoji_code'] = Variable(emojiCode); + } map['created_at'] = Variable(createdAt); + map['updated_at'] = Variable(updatedAt); map['score'] = Variable(score); if (!nullToAbsent || extraData != null) { - map['extra_data'] = Variable( - $ReactionsTable.$converterextraDatan.toSql(extraData)); + map['extra_data'] = Variable($ReactionsTable.$converterextraDatan.toSql(extraData)); } return map; } - factory ReactionEntity.fromJson(Map json, - {ValueSerializer? serializer}) { + factory ReactionEntity.fromJson(Map json, {ValueSerializer? serializer}) { serializer ??= driftRuntimeOptions.defaultSerializer; return ReactionEntity( - userId: serializer.fromJson(json['userId']), - messageId: serializer.fromJson(json['messageId']), + userId: serializer.fromJson(json['userId']), + messageId: serializer.fromJson(json['messageId']), type: serializer.fromJson(json['type']), + emojiCode: serializer.fromJson(json['emojiCode']), createdAt: serializer.fromJson(json['createdAt']), + updatedAt: serializer.fromJson(json['updatedAt']), score: serializer.fromJson(json['score']), extraData: serializer.fromJson?>(json['extraData']), ); @@ -6193,36 +6743,44 @@ class ReactionEntity extends DataClass implements Insertable { Map toJson({ValueSerializer? serializer}) { serializer ??= driftRuntimeOptions.defaultSerializer; return { - 'userId': serializer.toJson(userId), - 'messageId': serializer.toJson(messageId), + 'userId': serializer.toJson(userId), + 'messageId': serializer.toJson(messageId), 'type': serializer.toJson(type), + 'emojiCode': serializer.toJson(emojiCode), 'createdAt': serializer.toJson(createdAt), + 'updatedAt': serializer.toJson(updatedAt), 'score': serializer.toJson(score), 'extraData': serializer.toJson?>(extraData), }; } - ReactionEntity copyWith( - {String? userId, - String? messageId, - String? type, - DateTime? createdAt, - int? score, - Value?> extraData = const Value.absent()}) => - ReactionEntity( - userId: userId ?? this.userId, - messageId: messageId ?? this.messageId, - type: type ?? this.type, - createdAt: createdAt ?? this.createdAt, - score: score ?? this.score, - extraData: extraData.present ? extraData.value : this.extraData, - ); + ReactionEntity copyWith({ + Value userId = const Value.absent(), + Value messageId = const Value.absent(), + String? type, + Value emojiCode = const Value.absent(), + DateTime? createdAt, + DateTime? updatedAt, + int? score, + Value?> extraData = const Value.absent(), + }) => ReactionEntity( + userId: userId.present ? userId.value : this.userId, + messageId: messageId.present ? messageId.value : this.messageId, + type: type ?? this.type, + emojiCode: emojiCode.present ? emojiCode.value : this.emojiCode, + createdAt: createdAt ?? this.createdAt, + updatedAt: updatedAt ?? this.updatedAt, + score: score ?? this.score, + extraData: extraData.present ? extraData.value : this.extraData, + ); ReactionEntity copyWithCompanion(ReactionsCompanion data) { return ReactionEntity( userId: data.userId.present ? data.userId.value : this.userId, messageId: data.messageId.present ? data.messageId.value : this.messageId, type: data.type.present ? data.type.value : this.type, + emojiCode: data.emojiCode.present ? data.emojiCode.value : this.emojiCode, createdAt: data.createdAt.present ? data.createdAt.value : this.createdAt, + updatedAt: data.updatedAt.present ? data.updatedAt.value : this.updatedAt, score: data.score.present ? data.score.value : this.score, extraData: data.extraData.present ? data.extraData.value : this.extraData, ); @@ -6234,7 +6792,9 @@ class ReactionEntity extends DataClass implements Insertable { ..write('userId: $userId, ') ..write('messageId: $messageId, ') ..write('type: $type, ') + ..write('emojiCode: $emojiCode, ') ..write('createdAt: $createdAt, ') + ..write('updatedAt: $updatedAt, ') ..write('score: $score, ') ..write('extraData: $extraData') ..write(')')) @@ -6242,8 +6802,7 @@ class ReactionEntity extends DataClass implements Insertable { } @override - int get hashCode => - Object.hash(userId, messageId, type, createdAt, score, extraData); + int get hashCode => Object.hash(userId, messageId, type, emojiCode, createdAt, updatedAt, score, extraData); @override bool operator ==(Object other) => identical(this, other) || @@ -6251,16 +6810,20 @@ class ReactionEntity extends DataClass implements Insertable { other.userId == this.userId && other.messageId == this.messageId && other.type == this.type && + other.emojiCode == this.emojiCode && other.createdAt == this.createdAt && + other.updatedAt == this.updatedAt && other.score == this.score && other.extraData == this.extraData); } class ReactionsCompanion extends UpdateCompanion { - final Value userId; - final Value messageId; + final Value userId; + final Value messageId; final Value type; + final Value emojiCode; final Value createdAt; + final Value updatedAt; final Value score; final Value?> extraData; final Value rowid; @@ -6268,27 +6831,31 @@ class ReactionsCompanion extends UpdateCompanion { this.userId = const Value.absent(), this.messageId = const Value.absent(), this.type = const Value.absent(), + this.emojiCode = const Value.absent(), this.createdAt = const Value.absent(), + this.updatedAt = const Value.absent(), this.score = const Value.absent(), this.extraData = const Value.absent(), this.rowid = const Value.absent(), }); ReactionsCompanion.insert({ - required String userId, - required String messageId, + this.userId = const Value.absent(), + this.messageId = const Value.absent(), required String type, + this.emojiCode = const Value.absent(), this.createdAt = const Value.absent(), + this.updatedAt = const Value.absent(), this.score = const Value.absent(), this.extraData = const Value.absent(), this.rowid = const Value.absent(), - }) : userId = Value(userId), - messageId = Value(messageId), - type = Value(type); + }) : type = Value(type); static Insertable custom({ Expression? userId, Expression? messageId, Expression? type, + Expression? emojiCode, Expression? createdAt, + Expression? updatedAt, Expression? score, Expression? extraData, Expression? rowid, @@ -6297,26 +6864,33 @@ class ReactionsCompanion extends UpdateCompanion { if (userId != null) 'user_id': userId, if (messageId != null) 'message_id': messageId, if (type != null) 'type': type, + if (emojiCode != null) 'emoji_code': emojiCode, if (createdAt != null) 'created_at': createdAt, + if (updatedAt != null) 'updated_at': updatedAt, if (score != null) 'score': score, if (extraData != null) 'extra_data': extraData, if (rowid != null) 'rowid': rowid, }); } - ReactionsCompanion copyWith( - {Value? userId, - Value? messageId, - Value? type, - Value? createdAt, - Value? score, - Value?>? extraData, - Value? rowid}) { + ReactionsCompanion copyWith({ + Value? userId, + Value? messageId, + Value? type, + Value? emojiCode, + Value? createdAt, + Value? updatedAt, + Value? score, + Value?>? extraData, + Value? rowid, + }) { return ReactionsCompanion( userId: userId ?? this.userId, messageId: messageId ?? this.messageId, type: type ?? this.type, + emojiCode: emojiCode ?? this.emojiCode, createdAt: createdAt ?? this.createdAt, + updatedAt: updatedAt ?? this.updatedAt, score: score ?? this.score, extraData: extraData ?? this.extraData, rowid: rowid ?? this.rowid, @@ -6335,15 +6909,20 @@ class ReactionsCompanion extends UpdateCompanion { if (type.present) { map['type'] = Variable(type.value); } + if (emojiCode.present) { + map['emoji_code'] = Variable(emojiCode.value); + } if (createdAt.present) { map['created_at'] = Variable(createdAt.value); } + if (updatedAt.present) { + map['updated_at'] = Variable(updatedAt.value); + } if (score.present) { map['score'] = Variable(score.value); } if (extraData.present) { - map['extra_data'] = Variable( - $ReactionsTable.$converterextraDatan.toSql(extraData.value)); + map['extra_data'] = Variable($ReactionsTable.$converterextraDatan.toSql(extraData.value)); } if (rowid.present) { map['rowid'] = Variable(rowid.value); @@ -6357,7 +6936,9 @@ class ReactionsCompanion extends UpdateCompanion { ..write('userId: $userId, ') ..write('messageId: $messageId, ') ..write('type: $type, ') + ..write('emojiCode: $emojiCode, ') ..write('createdAt: $createdAt, ') + ..write('updatedAt: $updatedAt, ') ..write('score: $score, ') ..write('extraData: $extraData, ') ..write('rowid: $rowid') @@ -6374,98 +6955,125 @@ class $UsersTable extends Users with TableInfo<$UsersTable, UserEntity> { static const VerificationMeta _idMeta = const VerificationMeta('id'); @override late final GeneratedColumn id = GeneratedColumn( - 'id', aliasedName, false, - type: DriftSqlType.string, requiredDuringInsert: true); + 'id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); static const VerificationMeta _roleMeta = const VerificationMeta('role'); @override late final GeneratedColumn role = GeneratedColumn( - 'role', aliasedName, true, - type: DriftSqlType.string, requiredDuringInsert: false); - static const VerificationMeta _languageMeta = - const VerificationMeta('language'); + 'role', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); + static const VerificationMeta _languageMeta = const VerificationMeta('language'); @override late final GeneratedColumn language = GeneratedColumn( - 'language', aliasedName, true, - type: DriftSqlType.string, requiredDuringInsert: false); - static const VerificationMeta _createdAtMeta = - const VerificationMeta('createdAt'); + 'language', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); + static const VerificationMeta _createdAtMeta = const VerificationMeta('createdAt'); @override late final GeneratedColumn createdAt = GeneratedColumn( - 'created_at', aliasedName, true, - type: DriftSqlType.dateTime, requiredDuringInsert: false); - static const VerificationMeta _updatedAtMeta = - const VerificationMeta('updatedAt'); + 'created_at', + aliasedName, + true, + type: DriftSqlType.dateTime, + requiredDuringInsert: false, + ); + static const VerificationMeta _updatedAtMeta = const VerificationMeta('updatedAt'); @override late final GeneratedColumn updatedAt = GeneratedColumn( - 'updated_at', aliasedName, true, - type: DriftSqlType.dateTime, requiredDuringInsert: false); - static const VerificationMeta _lastActiveMeta = - const VerificationMeta('lastActive'); + 'updated_at', + aliasedName, + true, + type: DriftSqlType.dateTime, + requiredDuringInsert: false, + ); + static const VerificationMeta _lastActiveMeta = const VerificationMeta('lastActive'); @override late final GeneratedColumn lastActive = GeneratedColumn( - 'last_active', aliasedName, true, - type: DriftSqlType.dateTime, requiredDuringInsert: false); + 'last_active', + aliasedName, + true, + type: DriftSqlType.dateTime, + requiredDuringInsert: false, + ); static const VerificationMeta _onlineMeta = const VerificationMeta('online'); @override late final GeneratedColumn online = GeneratedColumn( - 'online', aliasedName, false, - type: DriftSqlType.bool, - requiredDuringInsert: false, - defaultConstraints: - GeneratedColumn.constraintIsAlways('CHECK ("online" IN (0, 1))'), - defaultValue: const Constant(false)); + 'online', + aliasedName, + false, + type: DriftSqlType.bool, + requiredDuringInsert: false, + defaultConstraints: GeneratedColumn.constraintIsAlways('CHECK ("online" IN (0, 1))'), + defaultValue: const Constant(false), + ); static const VerificationMeta _bannedMeta = const VerificationMeta('banned'); @override late final GeneratedColumn banned = GeneratedColumn( - 'banned', aliasedName, false, - type: DriftSqlType.bool, - requiredDuringInsert: false, - defaultConstraints: - GeneratedColumn.constraintIsAlways('CHECK ("banned" IN (0, 1))'), - defaultValue: const Constant(false)); - static const VerificationMeta _teamsRoleMeta = - const VerificationMeta('teamsRole'); - @override - late final GeneratedColumnWithTypeConverter?, String> - teamsRole = GeneratedColumn('teams_role', aliasedName, true, - type: DriftSqlType.string, requiredDuringInsert: false) - .withConverter?>( - $UsersTable.$converterteamsRolen); - static const VerificationMeta _avgResponseTimeMeta = - const VerificationMeta('avgResponseTime'); + 'banned', + aliasedName, + false, + type: DriftSqlType.bool, + requiredDuringInsert: false, + defaultConstraints: GeneratedColumn.constraintIsAlways('CHECK ("banned" IN (0, 1))'), + defaultValue: const Constant(false), + ); + @override + late final GeneratedColumnWithTypeConverter?, String> teamsRole = GeneratedColumn( + 'teams_role', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ).withConverter?>($UsersTable.$converterteamsRolen); + static const VerificationMeta _avgResponseTimeMeta = const VerificationMeta('avgResponseTime'); @override late final GeneratedColumn avgResponseTime = GeneratedColumn( - 'avg_response_time', aliasedName, true, - type: DriftSqlType.int, requiredDuringInsert: false); - static const VerificationMeta _extraDataMeta = - const VerificationMeta('extraData'); - @override - late final GeneratedColumnWithTypeConverter, String> - extraData = GeneratedColumn('extra_data', aliasedName, false, - type: DriftSqlType.string, requiredDuringInsert: true) - .withConverter>($UsersTable.$converterextraData); + 'avg_response_time', + aliasedName, + true, + type: DriftSqlType.int, + requiredDuringInsert: false, + ); + @override + late final GeneratedColumnWithTypeConverter, String> extraData = GeneratedColumn( + 'extra_data', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ).withConverter>($UsersTable.$converterextraData); @override List get $columns => [ - id, - role, - language, - createdAt, - updatedAt, - lastActive, - online, - banned, - teamsRole, - avgResponseTime, - extraData - ]; + id, + role, + language, + createdAt, + updatedAt, + lastActive, + online, + banned, + teamsRole, + avgResponseTime, + extraData, + ]; @override String get aliasedName => _alias ?? actualTableName; @override String get actualTableName => $name; static const String $name = 'users'; @override - VerificationContext validateIntegrity(Insertable instance, - {bool isInserting = false}) { + VerificationContext validateIntegrity(Insertable instance, {bool isInserting = false}) { final context = VerificationContext(); final data = instance.toColumns(true); if (data.containsKey('id')) { @@ -6474,43 +7082,32 @@ class $UsersTable extends Users with TableInfo<$UsersTable, UserEntity> { context.missing(_idMeta); } if (data.containsKey('role')) { - context.handle( - _roleMeta, role.isAcceptableOrUnknown(data['role']!, _roleMeta)); + context.handle(_roleMeta, role.isAcceptableOrUnknown(data['role']!, _roleMeta)); } if (data.containsKey('language')) { - context.handle(_languageMeta, - language.isAcceptableOrUnknown(data['language']!, _languageMeta)); + context.handle(_languageMeta, language.isAcceptableOrUnknown(data['language']!, _languageMeta)); } if (data.containsKey('created_at')) { - context.handle(_createdAtMeta, - createdAt.isAcceptableOrUnknown(data['created_at']!, _createdAtMeta)); + context.handle(_createdAtMeta, createdAt.isAcceptableOrUnknown(data['created_at']!, _createdAtMeta)); } if (data.containsKey('updated_at')) { - context.handle(_updatedAtMeta, - updatedAt.isAcceptableOrUnknown(data['updated_at']!, _updatedAtMeta)); + context.handle(_updatedAtMeta, updatedAt.isAcceptableOrUnknown(data['updated_at']!, _updatedAtMeta)); } if (data.containsKey('last_active')) { - context.handle( - _lastActiveMeta, - lastActive.isAcceptableOrUnknown( - data['last_active']!, _lastActiveMeta)); + context.handle(_lastActiveMeta, lastActive.isAcceptableOrUnknown(data['last_active']!, _lastActiveMeta)); } if (data.containsKey('online')) { - context.handle(_onlineMeta, - online.isAcceptableOrUnknown(data['online']!, _onlineMeta)); + context.handle(_onlineMeta, online.isAcceptableOrUnknown(data['online']!, _onlineMeta)); } if (data.containsKey('banned')) { - context.handle(_bannedMeta, - banned.isAcceptableOrUnknown(data['banned']!, _bannedMeta)); + context.handle(_bannedMeta, banned.isAcceptableOrUnknown(data['banned']!, _bannedMeta)); } - context.handle(_teamsRoleMeta, const VerificationResult.success()); if (data.containsKey('avg_response_time')) { context.handle( - _avgResponseTimeMeta, - avgResponseTime.isAcceptableOrUnknown( - data['avg_response_time']!, _avgResponseTimeMeta)); + _avgResponseTimeMeta, + avgResponseTime.isAcceptableOrUnknown(data['avg_response_time']!, _avgResponseTimeMeta), + ); } - context.handle(_extraDataMeta, const VerificationResult.success()); return context; } @@ -6520,30 +7117,21 @@ class $UsersTable extends Users with TableInfo<$UsersTable, UserEntity> { UserEntity map(Map data, {String? tablePrefix}) { final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; return UserEntity( - id: attachedDatabase.typeMapping - .read(DriftSqlType.string, data['${effectivePrefix}id'])!, - role: attachedDatabase.typeMapping - .read(DriftSqlType.string, data['${effectivePrefix}role']), - language: attachedDatabase.typeMapping - .read(DriftSqlType.string, data['${effectivePrefix}language']), - createdAt: attachedDatabase.typeMapping - .read(DriftSqlType.dateTime, data['${effectivePrefix}created_at']), - updatedAt: attachedDatabase.typeMapping - .read(DriftSqlType.dateTime, data['${effectivePrefix}updated_at']), - lastActive: attachedDatabase.typeMapping - .read(DriftSqlType.dateTime, data['${effectivePrefix}last_active']), - online: attachedDatabase.typeMapping - .read(DriftSqlType.bool, data['${effectivePrefix}online'])!, - banned: attachedDatabase.typeMapping - .read(DriftSqlType.bool, data['${effectivePrefix}banned'])!, - teamsRole: $UsersTable.$converterteamsRolen.fromSql(attachedDatabase - .typeMapping - .read(DriftSqlType.string, data['${effectivePrefix}teams_role'])), - avgResponseTime: attachedDatabase.typeMapping - .read(DriftSqlType.int, data['${effectivePrefix}avg_response_time']), - extraData: $UsersTable.$converterextraData.fromSql(attachedDatabase - .typeMapping - .read(DriftSqlType.string, data['${effectivePrefix}extra_data'])!), + id: attachedDatabase.typeMapping.read(DriftSqlType.string, data['${effectivePrefix}id'])!, + role: attachedDatabase.typeMapping.read(DriftSqlType.string, data['${effectivePrefix}role']), + language: attachedDatabase.typeMapping.read(DriftSqlType.string, data['${effectivePrefix}language']), + createdAt: attachedDatabase.typeMapping.read(DriftSqlType.dateTime, data['${effectivePrefix}created_at']), + updatedAt: attachedDatabase.typeMapping.read(DriftSqlType.dateTime, data['${effectivePrefix}updated_at']), + lastActive: attachedDatabase.typeMapping.read(DriftSqlType.dateTime, data['${effectivePrefix}last_active']), + online: attachedDatabase.typeMapping.read(DriftSqlType.bool, data['${effectivePrefix}online'])!, + banned: attachedDatabase.typeMapping.read(DriftSqlType.bool, data['${effectivePrefix}banned'])!, + teamsRole: $UsersTable.$converterteamsRolen.fromSql( + attachedDatabase.typeMapping.read(DriftSqlType.string, data['${effectivePrefix}teams_role']), + ), + avgResponseTime: attachedDatabase.typeMapping.read(DriftSqlType.int, data['${effectivePrefix}avg_response_time']), + extraData: $UsersTable.$converterextraData.fromSql( + attachedDatabase.typeMapping.read(DriftSqlType.string, data['${effectivePrefix}extra_data'])!, + ), ); } @@ -6552,12 +7140,11 @@ class $UsersTable extends Users with TableInfo<$UsersTable, UserEntity> { return $UsersTable(attachedDatabase, alias); } - static TypeConverter, String> $converterteamsRole = - MapConverter(); - static TypeConverter?, String?> $converterteamsRolen = - NullAwareTypeConverter.wrap($converterteamsRole); - static TypeConverter, String> $converterextraData = - MapConverter(); + static TypeConverter, String> $converterteamsRole = MapConverter(); + static TypeConverter?, String?> $converterteamsRolen = NullAwareTypeConverter.wrap( + $converterteamsRole, + ); + static TypeConverter, String> $converterextraData = MapConverter(); } class UserEntity extends DataClass implements Insertable { @@ -6595,18 +7182,19 @@ class UserEntity extends DataClass implements Insertable { /// Map of custom user extraData final Map extraData; - const UserEntity( - {required this.id, - this.role, - this.language, - this.createdAt, - this.updatedAt, - this.lastActive, - required this.online, - required this.banned, - this.teamsRole, - this.avgResponseTime, - required this.extraData}); + const UserEntity({ + required this.id, + this.role, + this.language, + this.createdAt, + this.updatedAt, + this.lastActive, + required this.online, + required this.banned, + this.teamsRole, + this.avgResponseTime, + required this.extraData, + }); @override Map toColumns(bool nullToAbsent) { final map = {}; @@ -6629,21 +7217,18 @@ class UserEntity extends DataClass implements Insertable { map['online'] = Variable(online); map['banned'] = Variable(banned); if (!nullToAbsent || teamsRole != null) { - map['teams_role'] = - Variable($UsersTable.$converterteamsRolen.toSql(teamsRole)); + map['teams_role'] = Variable($UsersTable.$converterteamsRolen.toSql(teamsRole)); } if (!nullToAbsent || avgResponseTime != null) { map['avg_response_time'] = Variable(avgResponseTime); } { - map['extra_data'] = - Variable($UsersTable.$converterextraData.toSql(extraData)); + map['extra_data'] = Variable($UsersTable.$converterextraData.toSql(extraData)); } return map; } - factory UserEntity.fromJson(Map json, - {ValueSerializer? serializer}) { + factory UserEntity.fromJson(Map json, {ValueSerializer? serializer}) { serializer ??= driftRuntimeOptions.defaultSerializer; return UserEntity( id: serializer.fromJson(json['id']), @@ -6677,33 +7262,31 @@ class UserEntity extends DataClass implements Insertable { }; } - UserEntity copyWith( - {String? id, - Value role = const Value.absent(), - Value language = const Value.absent(), - Value createdAt = const Value.absent(), - Value updatedAt = const Value.absent(), - Value lastActive = const Value.absent(), - bool? online, - bool? banned, - Value?> teamsRole = const Value.absent(), - Value avgResponseTime = const Value.absent(), - Map? extraData}) => - UserEntity( - id: id ?? this.id, - role: role.present ? role.value : this.role, - language: language.present ? language.value : this.language, - createdAt: createdAt.present ? createdAt.value : this.createdAt, - updatedAt: updatedAt.present ? updatedAt.value : this.updatedAt, - lastActive: lastActive.present ? lastActive.value : this.lastActive, - online: online ?? this.online, - banned: banned ?? this.banned, - teamsRole: teamsRole.present ? teamsRole.value : this.teamsRole, - avgResponseTime: avgResponseTime.present - ? avgResponseTime.value - : this.avgResponseTime, - extraData: extraData ?? this.extraData, - ); + UserEntity copyWith({ + String? id, + Value role = const Value.absent(), + Value language = const Value.absent(), + Value createdAt = const Value.absent(), + Value updatedAt = const Value.absent(), + Value lastActive = const Value.absent(), + bool? online, + bool? banned, + Value?> teamsRole = const Value.absent(), + Value avgResponseTime = const Value.absent(), + Map? extraData, + }) => UserEntity( + id: id ?? this.id, + role: role.present ? role.value : this.role, + language: language.present ? language.value : this.language, + createdAt: createdAt.present ? createdAt.value : this.createdAt, + updatedAt: updatedAt.present ? updatedAt.value : this.updatedAt, + lastActive: lastActive.present ? lastActive.value : this.lastActive, + online: online ?? this.online, + banned: banned ?? this.banned, + teamsRole: teamsRole.present ? teamsRole.value : this.teamsRole, + avgResponseTime: avgResponseTime.present ? avgResponseTime.value : this.avgResponseTime, + extraData: extraData ?? this.extraData, + ); UserEntity copyWithCompanion(UsersCompanion data) { return UserEntity( id: data.id.present ? data.id.value : this.id, @@ -6711,14 +7294,11 @@ class UserEntity extends DataClass implements Insertable { language: data.language.present ? data.language.value : this.language, createdAt: data.createdAt.present ? data.createdAt.value : this.createdAt, updatedAt: data.updatedAt.present ? data.updatedAt.value : this.updatedAt, - lastActive: - data.lastActive.present ? data.lastActive.value : this.lastActive, + lastActive: data.lastActive.present ? data.lastActive.value : this.lastActive, online: data.online.present ? data.online.value : this.online, banned: data.banned.present ? data.banned.value : this.banned, teamsRole: data.teamsRole.present ? data.teamsRole.value : this.teamsRole, - avgResponseTime: data.avgResponseTime.present - ? data.avgResponseTime.value - : this.avgResponseTime, + avgResponseTime: data.avgResponseTime.present ? data.avgResponseTime.value : this.avgResponseTime, extraData: data.extraData.present ? data.extraData.value : this.extraData, ); } @@ -6742,8 +7322,19 @@ class UserEntity extends DataClass implements Insertable { } @override - int get hashCode => Object.hash(id, role, language, createdAt, updatedAt, - lastActive, online, banned, teamsRole, avgResponseTime, extraData); + int get hashCode => Object.hash( + id, + role, + language, + createdAt, + updatedAt, + lastActive, + online, + banned, + teamsRole, + avgResponseTime, + extraData, + ); @override bool operator ==(Object other) => identical(this, other) || @@ -6801,8 +7392,8 @@ class UsersCompanion extends UpdateCompanion { this.avgResponseTime = const Value.absent(), required Map extraData, this.rowid = const Value.absent(), - }) : id = Value(id), - extraData = Value(extraData); + }) : id = Value(id), + extraData = Value(extraData); static Insertable custom({ Expression? id, Expression? role, @@ -6833,19 +7424,20 @@ class UsersCompanion extends UpdateCompanion { }); } - UsersCompanion copyWith( - {Value? id, - Value? role, - Value? language, - Value? createdAt, - Value? updatedAt, - Value? lastActive, - Value? online, - Value? banned, - Value?>? teamsRole, - Value? avgResponseTime, - Value>? extraData, - Value? rowid}) { + UsersCompanion copyWith({ + Value? id, + Value? role, + Value? language, + Value? createdAt, + Value? updatedAt, + Value? lastActive, + Value? online, + Value? banned, + Value?>? teamsRole, + Value? avgResponseTime, + Value>? extraData, + Value? rowid, + }) { return UsersCompanion( id: id ?? this.id, role: role ?? this.role, @@ -6890,15 +7482,13 @@ class UsersCompanion extends UpdateCompanion { map['banned'] = Variable(banned.value); } if (teamsRole.present) { - map['teams_role'] = Variable( - $UsersTable.$converterteamsRolen.toSql(teamsRole.value)); + map['teams_role'] = Variable($UsersTable.$converterteamsRolen.toSql(teamsRole.value)); } if (avgResponseTime.present) { map['avg_response_time'] = Variable(avgResponseTime.value); } if (extraData.present) { - map['extra_data'] = Variable( - $UsersTable.$converterextraData.toSql(extraData.value)); + map['extra_data'] = Variable($UsersTable.$converterextraData.toSql(extraData.value)); } if (rowid.present) { map['rowid'] = Variable(rowid.value); @@ -6926,8 +7516,7 @@ class UsersCompanion extends UpdateCompanion { } } -class $MembersTable extends Members - with TableInfo<$MembersTable, MemberEntity> { +class $MembersTable extends Members with TableInfo<$MembersTable, MemberEntity> { @override final GeneratedDatabase attachedDatabase; final String? _alias; @@ -6935,211 +7524,224 @@ class $MembersTable extends Members static const VerificationMeta _userIdMeta = const VerificationMeta('userId'); @override late final GeneratedColumn userId = GeneratedColumn( - 'user_id', aliasedName, false, - type: DriftSqlType.string, requiredDuringInsert: true); - static const VerificationMeta _channelCidMeta = - const VerificationMeta('channelCid'); + 'user_id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + static const VerificationMeta _channelCidMeta = const VerificationMeta('channelCid'); @override late final GeneratedColumn channelCid = GeneratedColumn( - 'channel_cid', aliasedName, false, - type: DriftSqlType.string, - requiredDuringInsert: true, - defaultConstraints: GeneratedColumn.constraintIsAlways( - 'REFERENCES channels (cid) ON DELETE CASCADE')); - static const VerificationMeta _channelRoleMeta = - const VerificationMeta('channelRole'); + 'channel_cid', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + defaultConstraints: GeneratedColumn.constraintIsAlways('REFERENCES channels (cid) ON DELETE CASCADE'), + ); + static const VerificationMeta _channelRoleMeta = const VerificationMeta('channelRole'); @override late final GeneratedColumn channelRole = GeneratedColumn( - 'channel_role', aliasedName, true, - type: DriftSqlType.string, requiredDuringInsert: false); - static const VerificationMeta _inviteAcceptedAtMeta = - const VerificationMeta('inviteAcceptedAt'); - @override - late final GeneratedColumn inviteAcceptedAt = - GeneratedColumn('invite_accepted_at', aliasedName, true, - type: DriftSqlType.dateTime, requiredDuringInsert: false); - static const VerificationMeta _inviteRejectedAtMeta = - const VerificationMeta('inviteRejectedAt'); - @override - late final GeneratedColumn inviteRejectedAt = - GeneratedColumn('invite_rejected_at', aliasedName, true, - type: DriftSqlType.dateTime, requiredDuringInsert: false); - static const VerificationMeta _invitedMeta = - const VerificationMeta('invited'); + 'channel_role', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); + static const VerificationMeta _inviteAcceptedAtMeta = const VerificationMeta('inviteAcceptedAt'); + @override + late final GeneratedColumn inviteAcceptedAt = GeneratedColumn( + 'invite_accepted_at', + aliasedName, + true, + type: DriftSqlType.dateTime, + requiredDuringInsert: false, + ); + static const VerificationMeta _inviteRejectedAtMeta = const VerificationMeta('inviteRejectedAt'); + @override + late final GeneratedColumn inviteRejectedAt = GeneratedColumn( + 'invite_rejected_at', + aliasedName, + true, + type: DriftSqlType.dateTime, + requiredDuringInsert: false, + ); + static const VerificationMeta _invitedMeta = const VerificationMeta('invited'); @override late final GeneratedColumn invited = GeneratedColumn( - 'invited', aliasedName, false, - type: DriftSqlType.bool, - requiredDuringInsert: false, - defaultConstraints: - GeneratedColumn.constraintIsAlways('CHECK ("invited" IN (0, 1))'), - defaultValue: const Constant(false)); + 'invited', + aliasedName, + false, + type: DriftSqlType.bool, + requiredDuringInsert: false, + defaultConstraints: GeneratedColumn.constraintIsAlways('CHECK ("invited" IN (0, 1))'), + defaultValue: const Constant(false), + ); static const VerificationMeta _bannedMeta = const VerificationMeta('banned'); @override late final GeneratedColumn banned = GeneratedColumn( - 'banned', aliasedName, false, - type: DriftSqlType.bool, - requiredDuringInsert: false, - defaultConstraints: - GeneratedColumn.constraintIsAlways('CHECK ("banned" IN (0, 1))'), - defaultValue: const Constant(false)); - static const VerificationMeta _shadowBannedMeta = - const VerificationMeta('shadowBanned'); + 'banned', + aliasedName, + false, + type: DriftSqlType.bool, + requiredDuringInsert: false, + defaultConstraints: GeneratedColumn.constraintIsAlways('CHECK ("banned" IN (0, 1))'), + defaultValue: const Constant(false), + ); + static const VerificationMeta _shadowBannedMeta = const VerificationMeta('shadowBanned'); @override late final GeneratedColumn shadowBanned = GeneratedColumn( - 'shadow_banned', aliasedName, false, - type: DriftSqlType.bool, - requiredDuringInsert: false, - defaultConstraints: GeneratedColumn.constraintIsAlways( - 'CHECK ("shadow_banned" IN (0, 1))'), - defaultValue: const Constant(false)); - static const VerificationMeta _pinnedAtMeta = - const VerificationMeta('pinnedAt'); + 'shadow_banned', + aliasedName, + false, + type: DriftSqlType.bool, + requiredDuringInsert: false, + defaultConstraints: GeneratedColumn.constraintIsAlways('CHECK ("shadow_banned" IN (0, 1))'), + defaultValue: const Constant(false), + ); + static const VerificationMeta _pinnedAtMeta = const VerificationMeta('pinnedAt'); @override late final GeneratedColumn pinnedAt = GeneratedColumn( - 'pinned_at', aliasedName, true, - type: DriftSqlType.dateTime, - requiredDuringInsert: false, - defaultValue: const Constant(null)); - static const VerificationMeta _archivedAtMeta = - const VerificationMeta('archivedAt'); + 'pinned_at', + aliasedName, + true, + type: DriftSqlType.dateTime, + requiredDuringInsert: false, + defaultValue: const Constant(null), + ); + static const VerificationMeta _archivedAtMeta = const VerificationMeta('archivedAt'); @override late final GeneratedColumn archivedAt = GeneratedColumn( - 'archived_at', aliasedName, true, - type: DriftSqlType.dateTime, - requiredDuringInsert: false, - defaultValue: const Constant(null)); - static const VerificationMeta _isModeratorMeta = - const VerificationMeta('isModerator'); + 'archived_at', + aliasedName, + true, + type: DriftSqlType.dateTime, + requiredDuringInsert: false, + defaultValue: const Constant(null), + ); + static const VerificationMeta _isModeratorMeta = const VerificationMeta('isModerator'); @override late final GeneratedColumn isModerator = GeneratedColumn( - 'is_moderator', aliasedName, false, - type: DriftSqlType.bool, - requiredDuringInsert: false, - defaultConstraints: GeneratedColumn.constraintIsAlways( - 'CHECK ("is_moderator" IN (0, 1))'), - defaultValue: const Constant(false)); - static const VerificationMeta _extraDataMeta = - const VerificationMeta('extraData'); - @override - late final GeneratedColumnWithTypeConverter?, String> - extraData = GeneratedColumn('extra_data', aliasedName, true, - type: DriftSqlType.string, requiredDuringInsert: false) - .withConverter?>( - $MembersTable.$converterextraDatan); - static const VerificationMeta _createdAtMeta = - const VerificationMeta('createdAt'); + 'is_moderator', + aliasedName, + false, + type: DriftSqlType.bool, + requiredDuringInsert: false, + defaultConstraints: GeneratedColumn.constraintIsAlways('CHECK ("is_moderator" IN (0, 1))'), + defaultValue: const Constant(false), + ); + @override + late final GeneratedColumnWithTypeConverter?, String> extraData = GeneratedColumn( + 'extra_data', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ).withConverter?>($MembersTable.$converterextraDatan); + static const VerificationMeta _createdAtMeta = const VerificationMeta('createdAt'); @override late final GeneratedColumn createdAt = GeneratedColumn( - 'created_at', aliasedName, false, - type: DriftSqlType.dateTime, - requiredDuringInsert: false, - defaultValue: currentDateAndTime); - static const VerificationMeta _updatedAtMeta = - const VerificationMeta('updatedAt'); + 'created_at', + aliasedName, + false, + type: DriftSqlType.dateTime, + requiredDuringInsert: false, + defaultValue: currentDateAndTime, + ); + static const VerificationMeta _updatedAtMeta = const VerificationMeta('updatedAt'); @override late final GeneratedColumn updatedAt = GeneratedColumn( - 'updated_at', aliasedName, false, - type: DriftSqlType.dateTime, - requiredDuringInsert: false, - defaultValue: currentDateAndTime); + 'updated_at', + aliasedName, + false, + type: DriftSqlType.dateTime, + requiredDuringInsert: false, + defaultValue: currentDateAndTime, + ); + @override + late final GeneratedColumnWithTypeConverter, String> deletedMessages = GeneratedColumn( + 'deleted_messages', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ).withConverter>($MembersTable.$converterdeletedMessages); @override List get $columns => [ - userId, - channelCid, - channelRole, - inviteAcceptedAt, - inviteRejectedAt, - invited, - banned, - shadowBanned, - pinnedAt, - archivedAt, - isModerator, - extraData, - createdAt, - updatedAt - ]; + userId, + channelCid, + channelRole, + inviteAcceptedAt, + inviteRejectedAt, + invited, + banned, + shadowBanned, + pinnedAt, + archivedAt, + isModerator, + extraData, + createdAt, + updatedAt, + deletedMessages, + ]; @override String get aliasedName => _alias ?? actualTableName; @override String get actualTableName => $name; static const String $name = 'members'; @override - VerificationContext validateIntegrity(Insertable instance, - {bool isInserting = false}) { + VerificationContext validateIntegrity(Insertable instance, {bool isInserting = false}) { final context = VerificationContext(); final data = instance.toColumns(true); if (data.containsKey('user_id')) { - context.handle(_userIdMeta, - userId.isAcceptableOrUnknown(data['user_id']!, _userIdMeta)); + context.handle(_userIdMeta, userId.isAcceptableOrUnknown(data['user_id']!, _userIdMeta)); } else if (isInserting) { context.missing(_userIdMeta); } if (data.containsKey('channel_cid')) { - context.handle( - _channelCidMeta, - channelCid.isAcceptableOrUnknown( - data['channel_cid']!, _channelCidMeta)); + context.handle(_channelCidMeta, channelCid.isAcceptableOrUnknown(data['channel_cid']!, _channelCidMeta)); } else if (isInserting) { context.missing(_channelCidMeta); } if (data.containsKey('channel_role')) { - context.handle( - _channelRoleMeta, - channelRole.isAcceptableOrUnknown( - data['channel_role']!, _channelRoleMeta)); + context.handle(_channelRoleMeta, channelRole.isAcceptableOrUnknown(data['channel_role']!, _channelRoleMeta)); } if (data.containsKey('invite_accepted_at')) { context.handle( - _inviteAcceptedAtMeta, - inviteAcceptedAt.isAcceptableOrUnknown( - data['invite_accepted_at']!, _inviteAcceptedAtMeta)); + _inviteAcceptedAtMeta, + inviteAcceptedAt.isAcceptableOrUnknown(data['invite_accepted_at']!, _inviteAcceptedAtMeta), + ); } if (data.containsKey('invite_rejected_at')) { context.handle( - _inviteRejectedAtMeta, - inviteRejectedAt.isAcceptableOrUnknown( - data['invite_rejected_at']!, _inviteRejectedAtMeta)); + _inviteRejectedAtMeta, + inviteRejectedAt.isAcceptableOrUnknown(data['invite_rejected_at']!, _inviteRejectedAtMeta), + ); } if (data.containsKey('invited')) { - context.handle(_invitedMeta, - invited.isAcceptableOrUnknown(data['invited']!, _invitedMeta)); + context.handle(_invitedMeta, invited.isAcceptableOrUnknown(data['invited']!, _invitedMeta)); } if (data.containsKey('banned')) { - context.handle(_bannedMeta, - banned.isAcceptableOrUnknown(data['banned']!, _bannedMeta)); + context.handle(_bannedMeta, banned.isAcceptableOrUnknown(data['banned']!, _bannedMeta)); } if (data.containsKey('shadow_banned')) { - context.handle( - _shadowBannedMeta, - shadowBanned.isAcceptableOrUnknown( - data['shadow_banned']!, _shadowBannedMeta)); + context.handle(_shadowBannedMeta, shadowBanned.isAcceptableOrUnknown(data['shadow_banned']!, _shadowBannedMeta)); } if (data.containsKey('pinned_at')) { - context.handle(_pinnedAtMeta, - pinnedAt.isAcceptableOrUnknown(data['pinned_at']!, _pinnedAtMeta)); + context.handle(_pinnedAtMeta, pinnedAt.isAcceptableOrUnknown(data['pinned_at']!, _pinnedAtMeta)); } if (data.containsKey('archived_at')) { - context.handle( - _archivedAtMeta, - archivedAt.isAcceptableOrUnknown( - data['archived_at']!, _archivedAtMeta)); + context.handle(_archivedAtMeta, archivedAt.isAcceptableOrUnknown(data['archived_at']!, _archivedAtMeta)); } if (data.containsKey('is_moderator')) { - context.handle( - _isModeratorMeta, - isModerator.isAcceptableOrUnknown( - data['is_moderator']!, _isModeratorMeta)); + context.handle(_isModeratorMeta, isModerator.isAcceptableOrUnknown(data['is_moderator']!, _isModeratorMeta)); } - context.handle(_extraDataMeta, const VerificationResult.success()); if (data.containsKey('created_at')) { - context.handle(_createdAtMeta, - createdAt.isAcceptableOrUnknown(data['created_at']!, _createdAtMeta)); + context.handle(_createdAtMeta, createdAt.isAcceptableOrUnknown(data['created_at']!, _createdAtMeta)); } if (data.containsKey('updated_at')) { - context.handle(_updatedAtMeta, - updatedAt.isAcceptableOrUnknown(data['updated_at']!, _updatedAtMeta)); + context.handle(_updatedAtMeta, updatedAt.isAcceptableOrUnknown(data['updated_at']!, _updatedAtMeta)); } return context; } @@ -7150,35 +7752,31 @@ class $MembersTable extends Members MemberEntity map(Map data, {String? tablePrefix}) { final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; return MemberEntity( - userId: attachedDatabase.typeMapping - .read(DriftSqlType.string, data['${effectivePrefix}user_id'])!, - channelCid: attachedDatabase.typeMapping - .read(DriftSqlType.string, data['${effectivePrefix}channel_cid'])!, - channelRole: attachedDatabase.typeMapping - .read(DriftSqlType.string, data['${effectivePrefix}channel_role']), + userId: attachedDatabase.typeMapping.read(DriftSqlType.string, data['${effectivePrefix}user_id'])!, + channelCid: attachedDatabase.typeMapping.read(DriftSqlType.string, data['${effectivePrefix}channel_cid'])!, + channelRole: attachedDatabase.typeMapping.read(DriftSqlType.string, data['${effectivePrefix}channel_role']), inviteAcceptedAt: attachedDatabase.typeMapping.read( - DriftSqlType.dateTime, data['${effectivePrefix}invite_accepted_at']), + DriftSqlType.dateTime, + data['${effectivePrefix}invite_accepted_at'], + ), inviteRejectedAt: attachedDatabase.typeMapping.read( - DriftSqlType.dateTime, data['${effectivePrefix}invite_rejected_at']), - invited: attachedDatabase.typeMapping - .read(DriftSqlType.bool, data['${effectivePrefix}invited'])!, - banned: attachedDatabase.typeMapping - .read(DriftSqlType.bool, data['${effectivePrefix}banned'])!, - shadowBanned: attachedDatabase.typeMapping - .read(DriftSqlType.bool, data['${effectivePrefix}shadow_banned'])!, - pinnedAt: attachedDatabase.typeMapping - .read(DriftSqlType.dateTime, data['${effectivePrefix}pinned_at']), - archivedAt: attachedDatabase.typeMapping - .read(DriftSqlType.dateTime, data['${effectivePrefix}archived_at']), - isModerator: attachedDatabase.typeMapping - .read(DriftSqlType.bool, data['${effectivePrefix}is_moderator'])!, - extraData: $MembersTable.$converterextraDatan.fromSql(attachedDatabase - .typeMapping - .read(DriftSqlType.string, data['${effectivePrefix}extra_data'])), - createdAt: attachedDatabase.typeMapping - .read(DriftSqlType.dateTime, data['${effectivePrefix}created_at'])!, - updatedAt: attachedDatabase.typeMapping - .read(DriftSqlType.dateTime, data['${effectivePrefix}updated_at'])!, + DriftSqlType.dateTime, + data['${effectivePrefix}invite_rejected_at'], + ), + invited: attachedDatabase.typeMapping.read(DriftSqlType.bool, data['${effectivePrefix}invited'])!, + banned: attachedDatabase.typeMapping.read(DriftSqlType.bool, data['${effectivePrefix}banned'])!, + shadowBanned: attachedDatabase.typeMapping.read(DriftSqlType.bool, data['${effectivePrefix}shadow_banned'])!, + pinnedAt: attachedDatabase.typeMapping.read(DriftSqlType.dateTime, data['${effectivePrefix}pinned_at']), + archivedAt: attachedDatabase.typeMapping.read(DriftSqlType.dateTime, data['${effectivePrefix}archived_at']), + isModerator: attachedDatabase.typeMapping.read(DriftSqlType.bool, data['${effectivePrefix}is_moderator'])!, + extraData: $MembersTable.$converterextraDatan.fromSql( + attachedDatabase.typeMapping.read(DriftSqlType.string, data['${effectivePrefix}extra_data']), + ), + createdAt: attachedDatabase.typeMapping.read(DriftSqlType.dateTime, data['${effectivePrefix}created_at'])!, + updatedAt: attachedDatabase.typeMapping.read(DriftSqlType.dateTime, data['${effectivePrefix}updated_at'])!, + deletedMessages: $MembersTable.$converterdeletedMessages.fromSql( + attachedDatabase.typeMapping.read(DriftSqlType.string, data['${effectivePrefix}deleted_messages'])!, + ), ); } @@ -7187,10 +7785,11 @@ class $MembersTable extends Members return $MembersTable(attachedDatabase, alias); } - static TypeConverter, String> $converterextraData = - MapConverter(); - static TypeConverter?, String?> $converterextraDatan = - NullAwareTypeConverter.wrap($converterextraData); + static TypeConverter, String> $converterextraData = MapConverter(); + static TypeConverter?, String?> $converterextraDatan = NullAwareTypeConverter.wrap( + $converterextraData, + ); + static TypeConverter, String> $converterdeletedMessages = ListConverter(); } class MemberEntity extends DataClass implements Insertable { @@ -7235,21 +7834,29 @@ class MemberEntity extends DataClass implements Insertable { /// The last date of update final DateTime updatedAt; - const MemberEntity( - {required this.userId, - required this.channelCid, - this.channelRole, - this.inviteAcceptedAt, - this.inviteRejectedAt, - required this.invited, - required this.banned, - required this.shadowBanned, - this.pinnedAt, - this.archivedAt, - required this.isModerator, - this.extraData, - required this.createdAt, - required this.updatedAt}); + + /// List of message ids deleted by the member only for himself. + /// + /// These messages are now marked deleted for this member, but are still + /// kept as regular to other channel members. + final List deletedMessages; + const MemberEntity({ + required this.userId, + required this.channelCid, + this.channelRole, + this.inviteAcceptedAt, + this.inviteRejectedAt, + required this.invited, + required this.banned, + required this.shadowBanned, + this.pinnedAt, + this.archivedAt, + required this.isModerator, + this.extraData, + required this.createdAt, + required this.updatedAt, + required this.deletedMessages, + }); @override Map toColumns(bool nullToAbsent) { final map = {}; @@ -7275,25 +7882,24 @@ class MemberEntity extends DataClass implements Insertable { } map['is_moderator'] = Variable(isModerator); if (!nullToAbsent || extraData != null) { - map['extra_data'] = - Variable($MembersTable.$converterextraDatan.toSql(extraData)); + map['extra_data'] = Variable($MembersTable.$converterextraDatan.toSql(extraData)); } map['created_at'] = Variable(createdAt); map['updated_at'] = Variable(updatedAt); + { + map['deleted_messages'] = Variable($MembersTable.$converterdeletedMessages.toSql(deletedMessages)); + } return map; } - factory MemberEntity.fromJson(Map json, - {ValueSerializer? serializer}) { + factory MemberEntity.fromJson(Map json, {ValueSerializer? serializer}) { serializer ??= driftRuntimeOptions.defaultSerializer; return MemberEntity( userId: serializer.fromJson(json['userId']), channelCid: serializer.fromJson(json['channelCid']), channelRole: serializer.fromJson(json['channelRole']), - inviteAcceptedAt: - serializer.fromJson(json['inviteAcceptedAt']), - inviteRejectedAt: - serializer.fromJson(json['inviteRejectedAt']), + inviteAcceptedAt: serializer.fromJson(json['inviteAcceptedAt']), + inviteRejectedAt: serializer.fromJson(json['inviteRejectedAt']), invited: serializer.fromJson(json['invited']), banned: serializer.fromJson(json['banned']), shadowBanned: serializer.fromJson(json['shadowBanned']), @@ -7303,6 +7909,7 @@ class MemberEntity extends DataClass implements Insertable { extraData: serializer.fromJson?>(json['extraData']), createdAt: serializer.fromJson(json['createdAt']), updatedAt: serializer.fromJson(json['updatedAt']), + deletedMessages: serializer.fromJson>(json['deletedMessages']), ); } @override @@ -7323,70 +7930,60 @@ class MemberEntity extends DataClass implements Insertable { 'extraData': serializer.toJson?>(extraData), 'createdAt': serializer.toJson(createdAt), 'updatedAt': serializer.toJson(updatedAt), + 'deletedMessages': serializer.toJson>(deletedMessages), }; } - MemberEntity copyWith( - {String? userId, - String? channelCid, - Value channelRole = const Value.absent(), - Value inviteAcceptedAt = const Value.absent(), - Value inviteRejectedAt = const Value.absent(), - bool? invited, - bool? banned, - bool? shadowBanned, - Value pinnedAt = const Value.absent(), - Value archivedAt = const Value.absent(), - bool? isModerator, - Value?> extraData = const Value.absent(), - DateTime? createdAt, - DateTime? updatedAt}) => - MemberEntity( - userId: userId ?? this.userId, - channelCid: channelCid ?? this.channelCid, - channelRole: channelRole.present ? channelRole.value : this.channelRole, - inviteAcceptedAt: inviteAcceptedAt.present - ? inviteAcceptedAt.value - : this.inviteAcceptedAt, - inviteRejectedAt: inviteRejectedAt.present - ? inviteRejectedAt.value - : this.inviteRejectedAt, - invited: invited ?? this.invited, - banned: banned ?? this.banned, - shadowBanned: shadowBanned ?? this.shadowBanned, - pinnedAt: pinnedAt.present ? pinnedAt.value : this.pinnedAt, - archivedAt: archivedAt.present ? archivedAt.value : this.archivedAt, - isModerator: isModerator ?? this.isModerator, - extraData: extraData.present ? extraData.value : this.extraData, - createdAt: createdAt ?? this.createdAt, - updatedAt: updatedAt ?? this.updatedAt, - ); + MemberEntity copyWith({ + String? userId, + String? channelCid, + Value channelRole = const Value.absent(), + Value inviteAcceptedAt = const Value.absent(), + Value inviteRejectedAt = const Value.absent(), + bool? invited, + bool? banned, + bool? shadowBanned, + Value pinnedAt = const Value.absent(), + Value archivedAt = const Value.absent(), + bool? isModerator, + Value?> extraData = const Value.absent(), + DateTime? createdAt, + DateTime? updatedAt, + List? deletedMessages, + }) => MemberEntity( + userId: userId ?? this.userId, + channelCid: channelCid ?? this.channelCid, + channelRole: channelRole.present ? channelRole.value : this.channelRole, + inviteAcceptedAt: inviteAcceptedAt.present ? inviteAcceptedAt.value : this.inviteAcceptedAt, + inviteRejectedAt: inviteRejectedAt.present ? inviteRejectedAt.value : this.inviteRejectedAt, + invited: invited ?? this.invited, + banned: banned ?? this.banned, + shadowBanned: shadowBanned ?? this.shadowBanned, + pinnedAt: pinnedAt.present ? pinnedAt.value : this.pinnedAt, + archivedAt: archivedAt.present ? archivedAt.value : this.archivedAt, + isModerator: isModerator ?? this.isModerator, + extraData: extraData.present ? extraData.value : this.extraData, + createdAt: createdAt ?? this.createdAt, + updatedAt: updatedAt ?? this.updatedAt, + deletedMessages: deletedMessages ?? this.deletedMessages, + ); MemberEntity copyWithCompanion(MembersCompanion data) { return MemberEntity( userId: data.userId.present ? data.userId.value : this.userId, - channelCid: - data.channelCid.present ? data.channelCid.value : this.channelCid, - channelRole: - data.channelRole.present ? data.channelRole.value : this.channelRole, - inviteAcceptedAt: data.inviteAcceptedAt.present - ? data.inviteAcceptedAt.value - : this.inviteAcceptedAt, - inviteRejectedAt: data.inviteRejectedAt.present - ? data.inviteRejectedAt.value - : this.inviteRejectedAt, + channelCid: data.channelCid.present ? data.channelCid.value : this.channelCid, + channelRole: data.channelRole.present ? data.channelRole.value : this.channelRole, + inviteAcceptedAt: data.inviteAcceptedAt.present ? data.inviteAcceptedAt.value : this.inviteAcceptedAt, + inviteRejectedAt: data.inviteRejectedAt.present ? data.inviteRejectedAt.value : this.inviteRejectedAt, invited: data.invited.present ? data.invited.value : this.invited, banned: data.banned.present ? data.banned.value : this.banned, - shadowBanned: data.shadowBanned.present - ? data.shadowBanned.value - : this.shadowBanned, + shadowBanned: data.shadowBanned.present ? data.shadowBanned.value : this.shadowBanned, pinnedAt: data.pinnedAt.present ? data.pinnedAt.value : this.pinnedAt, - archivedAt: - data.archivedAt.present ? data.archivedAt.value : this.archivedAt, - isModerator: - data.isModerator.present ? data.isModerator.value : this.isModerator, + archivedAt: data.archivedAt.present ? data.archivedAt.value : this.archivedAt, + isModerator: data.isModerator.present ? data.isModerator.value : this.isModerator, extraData: data.extraData.present ? data.extraData.value : this.extraData, createdAt: data.createdAt.present ? data.createdAt.value : this.createdAt, updatedAt: data.updatedAt.present ? data.updatedAt.value : this.updatedAt, + deletedMessages: data.deletedMessages.present ? data.deletedMessages.value : this.deletedMessages, ); } @@ -7406,27 +8003,30 @@ class MemberEntity extends DataClass implements Insertable { ..write('isModerator: $isModerator, ') ..write('extraData: $extraData, ') ..write('createdAt: $createdAt, ') - ..write('updatedAt: $updatedAt') + ..write('updatedAt: $updatedAt, ') + ..write('deletedMessages: $deletedMessages') ..write(')')) .toString(); } @override int get hashCode => Object.hash( - userId, - channelCid, - channelRole, - inviteAcceptedAt, - inviteRejectedAt, - invited, - banned, - shadowBanned, - pinnedAt, - archivedAt, - isModerator, - extraData, - createdAt, - updatedAt); + userId, + channelCid, + channelRole, + inviteAcceptedAt, + inviteRejectedAt, + invited, + banned, + shadowBanned, + pinnedAt, + archivedAt, + isModerator, + extraData, + createdAt, + updatedAt, + deletedMessages, + ); @override bool operator ==(Object other) => identical(this, other) || @@ -7444,7 +8044,8 @@ class MemberEntity extends DataClass implements Insertable { other.isModerator == this.isModerator && other.extraData == this.extraData && other.createdAt == this.createdAt && - other.updatedAt == this.updatedAt); + other.updatedAt == this.updatedAt && + other.deletedMessages == this.deletedMessages); } class MembersCompanion extends UpdateCompanion { @@ -7462,6 +8063,7 @@ class MembersCompanion extends UpdateCompanion { final Value?> extraData; final Value createdAt; final Value updatedAt; + final Value> deletedMessages; final Value rowid; const MembersCompanion({ this.userId = const Value.absent(), @@ -7478,6 +8080,7 @@ class MembersCompanion extends UpdateCompanion { this.extraData = const Value.absent(), this.createdAt = const Value.absent(), this.updatedAt = const Value.absent(), + this.deletedMessages = const Value.absent(), this.rowid = const Value.absent(), }); MembersCompanion.insert({ @@ -7495,9 +8098,11 @@ class MembersCompanion extends UpdateCompanion { this.extraData = const Value.absent(), this.createdAt = const Value.absent(), this.updatedAt = const Value.absent(), + required List deletedMessages, this.rowid = const Value.absent(), - }) : userId = Value(userId), - channelCid = Value(channelCid); + }) : userId = Value(userId), + channelCid = Value(channelCid), + deletedMessages = Value(deletedMessages); static Insertable custom({ Expression? userId, Expression? channelCid, @@ -7513,6 +8118,7 @@ class MembersCompanion extends UpdateCompanion { Expression? extraData, Expression? createdAt, Expression? updatedAt, + Expression? deletedMessages, Expression? rowid, }) { return RawValuesInsertable({ @@ -7530,26 +8136,29 @@ class MembersCompanion extends UpdateCompanion { if (extraData != null) 'extra_data': extraData, if (createdAt != null) 'created_at': createdAt, if (updatedAt != null) 'updated_at': updatedAt, + if (deletedMessages != null) 'deleted_messages': deletedMessages, if (rowid != null) 'rowid': rowid, }); } - MembersCompanion copyWith( - {Value? userId, - Value? channelCid, - Value? channelRole, - Value? inviteAcceptedAt, - Value? inviteRejectedAt, - Value? invited, - Value? banned, - Value? shadowBanned, - Value? pinnedAt, - Value? archivedAt, - Value? isModerator, - Value?>? extraData, - Value? createdAt, - Value? updatedAt, - Value? rowid}) { + MembersCompanion copyWith({ + Value? userId, + Value? channelCid, + Value? channelRole, + Value? inviteAcceptedAt, + Value? inviteRejectedAt, + Value? invited, + Value? banned, + Value? shadowBanned, + Value? pinnedAt, + Value? archivedAt, + Value? isModerator, + Value?>? extraData, + Value? createdAt, + Value? updatedAt, + Value>? deletedMessages, + Value? rowid, + }) { return MembersCompanion( userId: userId ?? this.userId, channelCid: channelCid ?? this.channelCid, @@ -7565,6 +8174,7 @@ class MembersCompanion extends UpdateCompanion { extraData: extraData ?? this.extraData, createdAt: createdAt ?? this.createdAt, updatedAt: updatedAt ?? this.updatedAt, + deletedMessages: deletedMessages ?? this.deletedMessages, rowid: rowid ?? this.rowid, ); } @@ -7606,8 +8216,7 @@ class MembersCompanion extends UpdateCompanion { map['is_moderator'] = Variable(isModerator.value); } if (extraData.present) { - map['extra_data'] = Variable( - $MembersTable.$converterextraDatan.toSql(extraData.value)); + map['extra_data'] = Variable($MembersTable.$converterextraDatan.toSql(extraData.value)); } if (createdAt.present) { map['created_at'] = Variable(createdAt.value); @@ -7615,6 +8224,9 @@ class MembersCompanion extends UpdateCompanion { if (updatedAt.present) { map['updated_at'] = Variable(updatedAt.value); } + if (deletedMessages.present) { + map['deleted_messages'] = Variable($MembersTable.$converterdeletedMessages.toSql(deletedMessages.value)); + } if (rowid.present) { map['rowid'] = Variable(rowid.value); } @@ -7638,6 +8250,7 @@ class MembersCompanion extends UpdateCompanion { ..write('extraData: $extraData, ') ..write('createdAt: $createdAt, ') ..write('updatedAt: $updatedAt, ') + ..write('deletedMessages: $deletedMessages, ') ..write('rowid: $rowid') ..write(')')) .toString(); @@ -7649,115 +8262,128 @@ class $ReadsTable extends Reads with TableInfo<$ReadsTable, ReadEntity> { final GeneratedDatabase attachedDatabase; final String? _alias; $ReadsTable(this.attachedDatabase, [this._alias]); - static const VerificationMeta _lastReadMeta = - const VerificationMeta('lastRead'); + static const VerificationMeta _lastReadMeta = const VerificationMeta('lastRead'); @override late final GeneratedColumn lastRead = GeneratedColumn( - 'last_read', aliasedName, false, - type: DriftSqlType.dateTime, requiredDuringInsert: true); + 'last_read', + aliasedName, + false, + type: DriftSqlType.dateTime, + requiredDuringInsert: true, + ); static const VerificationMeta _userIdMeta = const VerificationMeta('userId'); @override late final GeneratedColumn userId = GeneratedColumn( - 'user_id', aliasedName, false, - type: DriftSqlType.string, requiredDuringInsert: true); - static const VerificationMeta _channelCidMeta = - const VerificationMeta('channelCid'); + 'user_id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + static const VerificationMeta _channelCidMeta = const VerificationMeta('channelCid'); @override late final GeneratedColumn channelCid = GeneratedColumn( - 'channel_cid', aliasedName, false, - type: DriftSqlType.string, - requiredDuringInsert: true, - defaultConstraints: GeneratedColumn.constraintIsAlways( - 'REFERENCES channels (cid) ON DELETE CASCADE')); - static const VerificationMeta _unreadMessagesMeta = - const VerificationMeta('unreadMessages'); + 'channel_cid', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + defaultConstraints: GeneratedColumn.constraintIsAlways('REFERENCES channels (cid) ON DELETE CASCADE'), + ); + static const VerificationMeta _unreadMessagesMeta = const VerificationMeta('unreadMessages'); @override late final GeneratedColumn unreadMessages = GeneratedColumn( - 'unread_messages', aliasedName, false, - type: DriftSqlType.int, - requiredDuringInsert: false, - defaultValue: const Constant(0)); - static const VerificationMeta _lastReadMessageIdMeta = - const VerificationMeta('lastReadMessageId'); - @override - late final GeneratedColumn lastReadMessageId = - GeneratedColumn('last_read_message_id', aliasedName, true, - type: DriftSqlType.string, requiredDuringInsert: false); - static const VerificationMeta _lastDeliveredAtMeta = - const VerificationMeta('lastDeliveredAt'); - @override - late final GeneratedColumn lastDeliveredAt = - GeneratedColumn('last_delivered_at', aliasedName, true, - type: DriftSqlType.dateTime, requiredDuringInsert: false); - static const VerificationMeta _lastDeliveredMessageIdMeta = - const VerificationMeta('lastDeliveredMessageId'); - @override - late final GeneratedColumn lastDeliveredMessageId = - GeneratedColumn('last_delivered_message_id', aliasedName, true, - type: DriftSqlType.string, requiredDuringInsert: false); + 'unread_messages', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: false, + defaultValue: const Constant(0), + ); + static const VerificationMeta _lastReadMessageIdMeta = const VerificationMeta('lastReadMessageId'); + @override + late final GeneratedColumn lastReadMessageId = GeneratedColumn( + 'last_read_message_id', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); + static const VerificationMeta _lastDeliveredAtMeta = const VerificationMeta('lastDeliveredAt'); + @override + late final GeneratedColumn lastDeliveredAt = GeneratedColumn( + 'last_delivered_at', + aliasedName, + true, + type: DriftSqlType.dateTime, + requiredDuringInsert: false, + ); + static const VerificationMeta _lastDeliveredMessageIdMeta = const VerificationMeta('lastDeliveredMessageId'); + @override + late final GeneratedColumn lastDeliveredMessageId = GeneratedColumn( + 'last_delivered_message_id', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); @override List get $columns => [ - lastRead, - userId, - channelCid, - unreadMessages, - lastReadMessageId, - lastDeliveredAt, - lastDeliveredMessageId - ]; + lastRead, + userId, + channelCid, + unreadMessages, + lastReadMessageId, + lastDeliveredAt, + lastDeliveredMessageId, + ]; @override String get aliasedName => _alias ?? actualTableName; @override String get actualTableName => $name; static const String $name = 'reads'; @override - VerificationContext validateIntegrity(Insertable instance, - {bool isInserting = false}) { + VerificationContext validateIntegrity(Insertable instance, {bool isInserting = false}) { final context = VerificationContext(); final data = instance.toColumns(true); if (data.containsKey('last_read')) { - context.handle(_lastReadMeta, - lastRead.isAcceptableOrUnknown(data['last_read']!, _lastReadMeta)); + context.handle(_lastReadMeta, lastRead.isAcceptableOrUnknown(data['last_read']!, _lastReadMeta)); } else if (isInserting) { context.missing(_lastReadMeta); } if (data.containsKey('user_id')) { - context.handle(_userIdMeta, - userId.isAcceptableOrUnknown(data['user_id']!, _userIdMeta)); + context.handle(_userIdMeta, userId.isAcceptableOrUnknown(data['user_id']!, _userIdMeta)); } else if (isInserting) { context.missing(_userIdMeta); } if (data.containsKey('channel_cid')) { - context.handle( - _channelCidMeta, - channelCid.isAcceptableOrUnknown( - data['channel_cid']!, _channelCidMeta)); + context.handle(_channelCidMeta, channelCid.isAcceptableOrUnknown(data['channel_cid']!, _channelCidMeta)); } else if (isInserting) { context.missing(_channelCidMeta); } if (data.containsKey('unread_messages')) { context.handle( - _unreadMessagesMeta, - unreadMessages.isAcceptableOrUnknown( - data['unread_messages']!, _unreadMessagesMeta)); + _unreadMessagesMeta, + unreadMessages.isAcceptableOrUnknown(data['unread_messages']!, _unreadMessagesMeta), + ); } if (data.containsKey('last_read_message_id')) { context.handle( - _lastReadMessageIdMeta, - lastReadMessageId.isAcceptableOrUnknown( - data['last_read_message_id']!, _lastReadMessageIdMeta)); + _lastReadMessageIdMeta, + lastReadMessageId.isAcceptableOrUnknown(data['last_read_message_id']!, _lastReadMessageIdMeta), + ); } if (data.containsKey('last_delivered_at')) { context.handle( - _lastDeliveredAtMeta, - lastDeliveredAt.isAcceptableOrUnknown( - data['last_delivered_at']!, _lastDeliveredAtMeta)); + _lastDeliveredAtMeta, + lastDeliveredAt.isAcceptableOrUnknown(data['last_delivered_at']!, _lastDeliveredAtMeta), + ); } if (data.containsKey('last_delivered_message_id')) { context.handle( - _lastDeliveredMessageIdMeta, - lastDeliveredMessageId.isAcceptableOrUnknown( - data['last_delivered_message_id']!, _lastDeliveredMessageIdMeta)); + _lastDeliveredMessageIdMeta, + lastDeliveredMessageId.isAcceptableOrUnknown(data['last_delivered_message_id']!, _lastDeliveredMessageIdMeta), + ); } return context; } @@ -7768,21 +8394,22 @@ class $ReadsTable extends Reads with TableInfo<$ReadsTable, ReadEntity> { ReadEntity map(Map data, {String? tablePrefix}) { final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; return ReadEntity( - lastRead: attachedDatabase.typeMapping - .read(DriftSqlType.dateTime, data['${effectivePrefix}last_read'])!, - userId: attachedDatabase.typeMapping - .read(DriftSqlType.string, data['${effectivePrefix}user_id'])!, - channelCid: attachedDatabase.typeMapping - .read(DriftSqlType.string, data['${effectivePrefix}channel_cid'])!, - unreadMessages: attachedDatabase.typeMapping - .read(DriftSqlType.int, data['${effectivePrefix}unread_messages'])!, + lastRead: attachedDatabase.typeMapping.read(DriftSqlType.dateTime, data['${effectivePrefix}last_read'])!, + userId: attachedDatabase.typeMapping.read(DriftSqlType.string, data['${effectivePrefix}user_id'])!, + channelCid: attachedDatabase.typeMapping.read(DriftSqlType.string, data['${effectivePrefix}channel_cid'])!, + unreadMessages: attachedDatabase.typeMapping.read(DriftSqlType.int, data['${effectivePrefix}unread_messages'])!, lastReadMessageId: attachedDatabase.typeMapping.read( - DriftSqlType.string, data['${effectivePrefix}last_read_message_id']), + DriftSqlType.string, + data['${effectivePrefix}last_read_message_id'], + ), lastDeliveredAt: attachedDatabase.typeMapping.read( - DriftSqlType.dateTime, data['${effectivePrefix}last_delivered_at']), + DriftSqlType.dateTime, + data['${effectivePrefix}last_delivered_at'], + ), lastDeliveredMessageId: attachedDatabase.typeMapping.read( - DriftSqlType.string, - data['${effectivePrefix}last_delivered_message_id']), + DriftSqlType.string, + data['${effectivePrefix}last_delivered_message_id'], + ), ); } @@ -7813,14 +8440,15 @@ class ReadEntity extends DataClass implements Insertable { /// Id of the last delivered message final String? lastDeliveredMessageId; - const ReadEntity( - {required this.lastRead, - required this.userId, - required this.channelCid, - required this.unreadMessages, - this.lastReadMessageId, - this.lastDeliveredAt, - this.lastDeliveredMessageId}); + const ReadEntity({ + required this.lastRead, + required this.userId, + required this.channelCid, + required this.unreadMessages, + this.lastReadMessageId, + this.lastDeliveredAt, + this.lastDeliveredMessageId, + }); @override Map toColumns(bool nullToAbsent) { final map = {}; @@ -7835,25 +8463,21 @@ class ReadEntity extends DataClass implements Insertable { map['last_delivered_at'] = Variable(lastDeliveredAt); } if (!nullToAbsent || lastDeliveredMessageId != null) { - map['last_delivered_message_id'] = - Variable(lastDeliveredMessageId); + map['last_delivered_message_id'] = Variable(lastDeliveredMessageId); } return map; } - factory ReadEntity.fromJson(Map json, - {ValueSerializer? serializer}) { + factory ReadEntity.fromJson(Map json, {ValueSerializer? serializer}) { serializer ??= driftRuntimeOptions.defaultSerializer; return ReadEntity( lastRead: serializer.fromJson(json['lastRead']), userId: serializer.fromJson(json['userId']), channelCid: serializer.fromJson(json['channelCid']), unreadMessages: serializer.fromJson(json['unreadMessages']), - lastReadMessageId: - serializer.fromJson(json['lastReadMessageId']), + lastReadMessageId: serializer.fromJson(json['lastReadMessageId']), lastDeliveredAt: serializer.fromJson(json['lastDeliveredAt']), - lastDeliveredMessageId: - serializer.fromJson(json['lastDeliveredMessageId']), + lastDeliveredMessageId: serializer.fromJson(json['lastDeliveredMessageId']), ); } @override @@ -7866,49 +8490,35 @@ class ReadEntity extends DataClass implements Insertable { 'unreadMessages': serializer.toJson(unreadMessages), 'lastReadMessageId': serializer.toJson(lastReadMessageId), 'lastDeliveredAt': serializer.toJson(lastDeliveredAt), - 'lastDeliveredMessageId': - serializer.toJson(lastDeliveredMessageId), + 'lastDeliveredMessageId': serializer.toJson(lastDeliveredMessageId), }; } - ReadEntity copyWith( - {DateTime? lastRead, - String? userId, - String? channelCid, - int? unreadMessages, - Value lastReadMessageId = const Value.absent(), - Value lastDeliveredAt = const Value.absent(), - Value lastDeliveredMessageId = const Value.absent()}) => - ReadEntity( - lastRead: lastRead ?? this.lastRead, - userId: userId ?? this.userId, - channelCid: channelCid ?? this.channelCid, - unreadMessages: unreadMessages ?? this.unreadMessages, - lastReadMessageId: lastReadMessageId.present - ? lastReadMessageId.value - : this.lastReadMessageId, - lastDeliveredAt: lastDeliveredAt.present - ? lastDeliveredAt.value - : this.lastDeliveredAt, - lastDeliveredMessageId: lastDeliveredMessageId.present - ? lastDeliveredMessageId.value - : this.lastDeliveredMessageId, - ); + ReadEntity copyWith({ + DateTime? lastRead, + String? userId, + String? channelCid, + int? unreadMessages, + Value lastReadMessageId = const Value.absent(), + Value lastDeliveredAt = const Value.absent(), + Value lastDeliveredMessageId = const Value.absent(), + }) => ReadEntity( + lastRead: lastRead ?? this.lastRead, + userId: userId ?? this.userId, + channelCid: channelCid ?? this.channelCid, + unreadMessages: unreadMessages ?? this.unreadMessages, + lastReadMessageId: lastReadMessageId.present ? lastReadMessageId.value : this.lastReadMessageId, + lastDeliveredAt: lastDeliveredAt.present ? lastDeliveredAt.value : this.lastDeliveredAt, + lastDeliveredMessageId: lastDeliveredMessageId.present ? lastDeliveredMessageId.value : this.lastDeliveredMessageId, + ); ReadEntity copyWithCompanion(ReadsCompanion data) { return ReadEntity( lastRead: data.lastRead.present ? data.lastRead.value : this.lastRead, userId: data.userId.present ? data.userId.value : this.userId, - channelCid: - data.channelCid.present ? data.channelCid.value : this.channelCid, - unreadMessages: data.unreadMessages.present - ? data.unreadMessages.value - : this.unreadMessages, - lastReadMessageId: data.lastReadMessageId.present - ? data.lastReadMessageId.value - : this.lastReadMessageId, - lastDeliveredAt: data.lastDeliveredAt.present - ? data.lastDeliveredAt.value - : this.lastDeliveredAt, + channelCid: data.channelCid.present ? data.channelCid.value : this.channelCid, + unreadMessages: data.unreadMessages.present ? data.unreadMessages.value : this.unreadMessages, + lastReadMessageId: data.lastReadMessageId.present ? data.lastReadMessageId.value : this.lastReadMessageId, + lastDeliveredAt: data.lastDeliveredAt.present ? data.lastDeliveredAt.value : this.lastDeliveredAt, lastDeliveredMessageId: data.lastDeliveredMessageId.present ? data.lastDeliveredMessageId.value : this.lastDeliveredMessageId, @@ -7930,8 +8540,15 @@ class ReadEntity extends DataClass implements Insertable { } @override - int get hashCode => Object.hash(lastRead, userId, channelCid, unreadMessages, - lastReadMessageId, lastDeliveredAt, lastDeliveredMessageId); + int get hashCode => Object.hash( + lastRead, + userId, + channelCid, + unreadMessages, + lastReadMessageId, + lastDeliveredAt, + lastDeliveredMessageId, + ); @override bool operator ==(Object other) => identical(this, other) || @@ -7973,9 +8590,9 @@ class ReadsCompanion extends UpdateCompanion { this.lastDeliveredAt = const Value.absent(), this.lastDeliveredMessageId = const Value.absent(), this.rowid = const Value.absent(), - }) : lastRead = Value(lastRead), - userId = Value(userId), - channelCid = Value(channelCid); + }) : lastRead = Value(lastRead), + userId = Value(userId), + channelCid = Value(channelCid); static Insertable custom({ Expression? lastRead, Expression? userId, @@ -7993,21 +8610,21 @@ class ReadsCompanion extends UpdateCompanion { if (unreadMessages != null) 'unread_messages': unreadMessages, if (lastReadMessageId != null) 'last_read_message_id': lastReadMessageId, if (lastDeliveredAt != null) 'last_delivered_at': lastDeliveredAt, - if (lastDeliveredMessageId != null) - 'last_delivered_message_id': lastDeliveredMessageId, + if (lastDeliveredMessageId != null) 'last_delivered_message_id': lastDeliveredMessageId, if (rowid != null) 'rowid': rowid, }); } - ReadsCompanion copyWith( - {Value? lastRead, - Value? userId, - Value? channelCid, - Value? unreadMessages, - Value? lastReadMessageId, - Value? lastDeliveredAt, - Value? lastDeliveredMessageId, - Value? rowid}) { + ReadsCompanion copyWith({ + Value? lastRead, + Value? userId, + Value? channelCid, + Value? unreadMessages, + Value? lastReadMessageId, + Value? lastDeliveredAt, + Value? lastDeliveredMessageId, + Value? rowid, + }) { return ReadsCompanion( lastRead: lastRead ?? this.lastRead, userId: userId ?? this.userId, @@ -8015,8 +8632,7 @@ class ReadsCompanion extends UpdateCompanion { unreadMessages: unreadMessages ?? this.unreadMessages, lastReadMessageId: lastReadMessageId ?? this.lastReadMessageId, lastDeliveredAt: lastDeliveredAt ?? this.lastDeliveredAt, - lastDeliveredMessageId: - lastDeliveredMessageId ?? this.lastDeliveredMessageId, + lastDeliveredMessageId: lastDeliveredMessageId ?? this.lastDeliveredMessageId, rowid: rowid ?? this.rowid, ); } @@ -8043,8 +8659,7 @@ class ReadsCompanion extends UpdateCompanion { map['last_delivered_at'] = Variable(lastDeliveredAt.value); } if (lastDeliveredMessageId.present) { - map['last_delivered_message_id'] = - Variable(lastDeliveredMessageId.value); + map['last_delivered_message_id'] = Variable(lastDeliveredMessageId.value); } if (rowid.present) { map['rowid'] = Variable(rowid.value); @@ -8068,24 +8683,29 @@ class ReadsCompanion extends UpdateCompanion { } } -class $ChannelQueriesTable extends ChannelQueries - with TableInfo<$ChannelQueriesTable, ChannelQueryEntity> { +class $ChannelQueriesTable extends ChannelQueries with TableInfo<$ChannelQueriesTable, ChannelQueryEntity> { @override final GeneratedDatabase attachedDatabase; final String? _alias; $ChannelQueriesTable(this.attachedDatabase, [this._alias]); - static const VerificationMeta _queryHashMeta = - const VerificationMeta('queryHash'); + static const VerificationMeta _queryHashMeta = const VerificationMeta('queryHash'); @override late final GeneratedColumn queryHash = GeneratedColumn( - 'query_hash', aliasedName, false, - type: DriftSqlType.string, requiredDuringInsert: true); - static const VerificationMeta _channelCidMeta = - const VerificationMeta('channelCid'); + 'query_hash', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + static const VerificationMeta _channelCidMeta = const VerificationMeta('channelCid'); @override late final GeneratedColumn channelCid = GeneratedColumn( - 'channel_cid', aliasedName, false, - type: DriftSqlType.string, requiredDuringInsert: true); + 'channel_cid', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); @override List get $columns => [queryHash, channelCid]; @override @@ -8094,21 +8714,16 @@ class $ChannelQueriesTable extends ChannelQueries String get actualTableName => $name; static const String $name = 'channel_queries'; @override - VerificationContext validateIntegrity(Insertable instance, - {bool isInserting = false}) { + VerificationContext validateIntegrity(Insertable instance, {bool isInserting = false}) { final context = VerificationContext(); final data = instance.toColumns(true); if (data.containsKey('query_hash')) { - context.handle(_queryHashMeta, - queryHash.isAcceptableOrUnknown(data['query_hash']!, _queryHashMeta)); + context.handle(_queryHashMeta, queryHash.isAcceptableOrUnknown(data['query_hash']!, _queryHashMeta)); } else if (isInserting) { context.missing(_queryHashMeta); } if (data.containsKey('channel_cid')) { - context.handle( - _channelCidMeta, - channelCid.isAcceptableOrUnknown( - data['channel_cid']!, _channelCidMeta)); + context.handle(_channelCidMeta, channelCid.isAcceptableOrUnknown(data['channel_cid']!, _channelCidMeta)); } else if (isInserting) { context.missing(_channelCidMeta); } @@ -8121,10 +8736,8 @@ class $ChannelQueriesTable extends ChannelQueries ChannelQueryEntity map(Map data, {String? tablePrefix}) { final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; return ChannelQueryEntity( - queryHash: attachedDatabase.typeMapping - .read(DriftSqlType.string, data['${effectivePrefix}query_hash'])!, - channelCid: attachedDatabase.typeMapping - .read(DriftSqlType.string, data['${effectivePrefix}channel_cid'])!, + queryHash: attachedDatabase.typeMapping.read(DriftSqlType.string, data['${effectivePrefix}query_hash'])!, + channelCid: attachedDatabase.typeMapping.read(DriftSqlType.string, data['${effectivePrefix}channel_cid'])!, ); } @@ -8134,8 +8747,7 @@ class $ChannelQueriesTable extends ChannelQueries } } -class ChannelQueryEntity extends DataClass - implements Insertable { +class ChannelQueryEntity extends DataClass implements Insertable { /// The unique hash of this query final String queryHash; @@ -8150,8 +8762,7 @@ class ChannelQueryEntity extends DataClass return map; } - factory ChannelQueryEntity.fromJson(Map json, - {ValueSerializer? serializer}) { + factory ChannelQueryEntity.fromJson(Map json, {ValueSerializer? serializer}) { serializer ??= driftRuntimeOptions.defaultSerializer; return ChannelQueryEntity( queryHash: serializer.fromJson(json['queryHash']), @@ -8167,16 +8778,14 @@ class ChannelQueryEntity extends DataClass }; } - ChannelQueryEntity copyWith({String? queryHash, String? channelCid}) => - ChannelQueryEntity( - queryHash: queryHash ?? this.queryHash, - channelCid: channelCid ?? this.channelCid, - ); + ChannelQueryEntity copyWith({String? queryHash, String? channelCid}) => ChannelQueryEntity( + queryHash: queryHash ?? this.queryHash, + channelCid: channelCid ?? this.channelCid, + ); ChannelQueryEntity copyWithCompanion(ChannelQueriesCompanion data) { return ChannelQueryEntity( queryHash: data.queryHash.present ? data.queryHash.value : this.queryHash, - channelCid: - data.channelCid.present ? data.channelCid.value : this.channelCid, + channelCid: data.channelCid.present ? data.channelCid.value : this.channelCid, ); } @@ -8194,9 +8803,7 @@ class ChannelQueryEntity extends DataClass @override bool operator ==(Object other) => identical(this, other) || - (other is ChannelQueryEntity && - other.queryHash == this.queryHash && - other.channelCid == this.channelCid); + (other is ChannelQueryEntity && other.queryHash == this.queryHash && other.channelCid == this.channelCid); } class ChannelQueriesCompanion extends UpdateCompanion { @@ -8212,8 +8819,8 @@ class ChannelQueriesCompanion extends UpdateCompanion { required String queryHash, required String channelCid, this.rowid = const Value.absent(), - }) : queryHash = Value(queryHash), - channelCid = Value(channelCid); + }) : queryHash = Value(queryHash), + channelCid = Value(channelCid); static Insertable custom({ Expression? queryHash, Expression? channelCid, @@ -8226,10 +8833,7 @@ class ChannelQueriesCompanion extends UpdateCompanion { }); } - ChannelQueriesCompanion copyWith( - {Value? queryHash, - Value? channelCid, - Value? rowid}) { + ChannelQueriesCompanion copyWith({Value? queryHash, Value? channelCid, Value? rowid}) { return ChannelQueriesCompanion( queryHash: queryHash ?? this.queryHash, channelCid: channelCid ?? this.channelCid, @@ -8263,8 +8867,7 @@ class ChannelQueriesCompanion extends UpdateCompanion { } } -class $ConnectionEventsTable extends ConnectionEvents - with TableInfo<$ConnectionEventsTable, ConnectionEventEntity> { +class $ConnectionEventsTable extends ConnectionEvents with TableInfo<$ConnectionEventsTable, ConnectionEventEntity> { @override final GeneratedDatabase attachedDatabase; final String? _alias; @@ -8272,99 +8875,101 @@ class $ConnectionEventsTable extends ConnectionEvents static const VerificationMeta _idMeta = const VerificationMeta('id'); @override late final GeneratedColumn id = GeneratedColumn( - 'id', aliasedName, false, - type: DriftSqlType.int, requiredDuringInsert: false); + 'id', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: false, + ); static const VerificationMeta _typeMeta = const VerificationMeta('type'); @override late final GeneratedColumn type = GeneratedColumn( - 'type', aliasedName, false, - type: DriftSqlType.string, requiredDuringInsert: true); - static const VerificationMeta _ownUserMeta = - const VerificationMeta('ownUser'); - @override - late final GeneratedColumnWithTypeConverter?, String> - ownUser = GeneratedColumn('own_user', aliasedName, true, - type: DriftSqlType.string, requiredDuringInsert: false) - .withConverter?>( - $ConnectionEventsTable.$converterownUsern); - static const VerificationMeta _totalUnreadCountMeta = - const VerificationMeta('totalUnreadCount'); + 'type', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + @override + late final GeneratedColumnWithTypeConverter?, String> ownUser = GeneratedColumn( + 'own_user', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ).withConverter?>($ConnectionEventsTable.$converterownUsern); + static const VerificationMeta _totalUnreadCountMeta = const VerificationMeta('totalUnreadCount'); @override late final GeneratedColumn totalUnreadCount = GeneratedColumn( - 'total_unread_count', aliasedName, true, - type: DriftSqlType.int, requiredDuringInsert: false); - static const VerificationMeta _unreadChannelsMeta = - const VerificationMeta('unreadChannels'); + 'total_unread_count', + aliasedName, + true, + type: DriftSqlType.int, + requiredDuringInsert: false, + ); + static const VerificationMeta _unreadChannelsMeta = const VerificationMeta('unreadChannels'); @override late final GeneratedColumn unreadChannels = GeneratedColumn( - 'unread_channels', aliasedName, true, - type: DriftSqlType.int, requiredDuringInsert: false); - static const VerificationMeta _lastEventAtMeta = - const VerificationMeta('lastEventAt'); + 'unread_channels', + aliasedName, + true, + type: DriftSqlType.int, + requiredDuringInsert: false, + ); + static const VerificationMeta _lastEventAtMeta = const VerificationMeta('lastEventAt'); @override late final GeneratedColumn lastEventAt = GeneratedColumn( - 'last_event_at', aliasedName, true, - type: DriftSqlType.dateTime, requiredDuringInsert: false); - static const VerificationMeta _lastSyncAtMeta = - const VerificationMeta('lastSyncAt'); + 'last_event_at', + aliasedName, + true, + type: DriftSqlType.dateTime, + requiredDuringInsert: false, + ); + static const VerificationMeta _lastSyncAtMeta = const VerificationMeta('lastSyncAt'); @override late final GeneratedColumn lastSyncAt = GeneratedColumn( - 'last_sync_at', aliasedName, true, - type: DriftSqlType.dateTime, requiredDuringInsert: false); + 'last_sync_at', + aliasedName, + true, + type: DriftSqlType.dateTime, + requiredDuringInsert: false, + ); @override - List get $columns => [ - id, - type, - ownUser, - totalUnreadCount, - unreadChannels, - lastEventAt, - lastSyncAt - ]; + List get $columns => [id, type, ownUser, totalUnreadCount, unreadChannels, lastEventAt, lastSyncAt]; @override String get aliasedName => _alias ?? actualTableName; @override String get actualTableName => $name; static const String $name = 'connection_events'; @override - VerificationContext validateIntegrity( - Insertable instance, - {bool isInserting = false}) { + VerificationContext validateIntegrity(Insertable instance, {bool isInserting = false}) { final context = VerificationContext(); final data = instance.toColumns(true); if (data.containsKey('id')) { context.handle(_idMeta, id.isAcceptableOrUnknown(data['id']!, _idMeta)); } if (data.containsKey('type')) { - context.handle( - _typeMeta, type.isAcceptableOrUnknown(data['type']!, _typeMeta)); + context.handle(_typeMeta, type.isAcceptableOrUnknown(data['type']!, _typeMeta)); } else if (isInserting) { context.missing(_typeMeta); } - context.handle(_ownUserMeta, const VerificationResult.success()); if (data.containsKey('total_unread_count')) { context.handle( - _totalUnreadCountMeta, - totalUnreadCount.isAcceptableOrUnknown( - data['total_unread_count']!, _totalUnreadCountMeta)); + _totalUnreadCountMeta, + totalUnreadCount.isAcceptableOrUnknown(data['total_unread_count']!, _totalUnreadCountMeta), + ); } if (data.containsKey('unread_channels')) { context.handle( - _unreadChannelsMeta, - unreadChannels.isAcceptableOrUnknown( - data['unread_channels']!, _unreadChannelsMeta)); + _unreadChannelsMeta, + unreadChannels.isAcceptableOrUnknown(data['unread_channels']!, _unreadChannelsMeta), + ); } if (data.containsKey('last_event_at')) { - context.handle( - _lastEventAtMeta, - lastEventAt.isAcceptableOrUnknown( - data['last_event_at']!, _lastEventAtMeta)); + context.handle(_lastEventAtMeta, lastEventAt.isAcceptableOrUnknown(data['last_event_at']!, _lastEventAtMeta)); } if (data.containsKey('last_sync_at')) { - context.handle( - _lastSyncAtMeta, - lastSyncAt.isAcceptableOrUnknown( - data['last_sync_at']!, _lastSyncAtMeta)); + context.handle(_lastSyncAtMeta, lastSyncAt.isAcceptableOrUnknown(data['last_sync_at']!, _lastSyncAtMeta)); } return context; } @@ -8375,21 +8980,18 @@ class $ConnectionEventsTable extends ConnectionEvents ConnectionEventEntity map(Map data, {String? tablePrefix}) { final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; return ConnectionEventEntity( - id: attachedDatabase.typeMapping - .read(DriftSqlType.int, data['${effectivePrefix}id'])!, - type: attachedDatabase.typeMapping - .read(DriftSqlType.string, data['${effectivePrefix}type'])!, + id: attachedDatabase.typeMapping.read(DriftSqlType.int, data['${effectivePrefix}id'])!, + type: attachedDatabase.typeMapping.read(DriftSqlType.string, data['${effectivePrefix}type'])!, ownUser: $ConnectionEventsTable.$converterownUsern.fromSql( - attachedDatabase.typeMapping - .read(DriftSqlType.string, data['${effectivePrefix}own_user'])), - totalUnreadCount: attachedDatabase.typeMapping - .read(DriftSqlType.int, data['${effectivePrefix}total_unread_count']), - unreadChannels: attachedDatabase.typeMapping - .read(DriftSqlType.int, data['${effectivePrefix}unread_channels']), - lastEventAt: attachedDatabase.typeMapping - .read(DriftSqlType.dateTime, data['${effectivePrefix}last_event_at']), - lastSyncAt: attachedDatabase.typeMapping - .read(DriftSqlType.dateTime, data['${effectivePrefix}last_sync_at']), + attachedDatabase.typeMapping.read(DriftSqlType.string, data['${effectivePrefix}own_user']), + ), + totalUnreadCount: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}total_unread_count'], + ), + unreadChannels: attachedDatabase.typeMapping.read(DriftSqlType.int, data['${effectivePrefix}unread_channels']), + lastEventAt: attachedDatabase.typeMapping.read(DriftSqlType.dateTime, data['${effectivePrefix}last_event_at']), + lastSyncAt: attachedDatabase.typeMapping.read(DriftSqlType.dateTime, data['${effectivePrefix}last_sync_at']), ); } @@ -8398,14 +9000,13 @@ class $ConnectionEventsTable extends ConnectionEvents return $ConnectionEventsTable(attachedDatabase, alias); } - static TypeConverter, String> $converterownUser = - MapConverter(); - static TypeConverter?, String?> $converterownUsern = - NullAwareTypeConverter.wrap($converterownUser); + static TypeConverter, String> $converterownUser = MapConverter(); + static TypeConverter?, String?> $converterownUsern = NullAwareTypeConverter.wrap( + $converterownUser, + ); } -class ConnectionEventEntity extends DataClass - implements Insertable { +class ConnectionEventEntity extends DataClass implements Insertable { /// event id final int id; @@ -8426,22 +9027,22 @@ class ConnectionEventEntity extends DataClass /// DateTime of the last sync final DateTime? lastSyncAt; - const ConnectionEventEntity( - {required this.id, - required this.type, - this.ownUser, - this.totalUnreadCount, - this.unreadChannels, - this.lastEventAt, - this.lastSyncAt}); + const ConnectionEventEntity({ + required this.id, + required this.type, + this.ownUser, + this.totalUnreadCount, + this.unreadChannels, + this.lastEventAt, + this.lastSyncAt, + }); @override Map toColumns(bool nullToAbsent) { final map = {}; map['id'] = Variable(id); map['type'] = Variable(type); if (!nullToAbsent || ownUser != null) { - map['own_user'] = Variable( - $ConnectionEventsTable.$converterownUsern.toSql(ownUser)); + map['own_user'] = Variable($ConnectionEventsTable.$converterownUsern.toSql(ownUser)); } if (!nullToAbsent || totalUnreadCount != null) { map['total_unread_count'] = Variable(totalUnreadCount); @@ -8458,8 +9059,7 @@ class ConnectionEventEntity extends DataClass return map; } - factory ConnectionEventEntity.fromJson(Map json, - {ValueSerializer? serializer}) { + factory ConnectionEventEntity.fromJson(Map json, {ValueSerializer? serializer}) { serializer ??= driftRuntimeOptions.defaultSerializer; return ConnectionEventEntity( id: serializer.fromJson(json['id']), @@ -8485,41 +9085,32 @@ class ConnectionEventEntity extends DataClass }; } - ConnectionEventEntity copyWith( - {int? id, - String? type, - Value?> ownUser = const Value.absent(), - Value totalUnreadCount = const Value.absent(), - Value unreadChannels = const Value.absent(), - Value lastEventAt = const Value.absent(), - Value lastSyncAt = const Value.absent()}) => - ConnectionEventEntity( - id: id ?? this.id, - type: type ?? this.type, - ownUser: ownUser.present ? ownUser.value : this.ownUser, - totalUnreadCount: totalUnreadCount.present - ? totalUnreadCount.value - : this.totalUnreadCount, - unreadChannels: - unreadChannels.present ? unreadChannels.value : this.unreadChannels, - lastEventAt: lastEventAt.present ? lastEventAt.value : this.lastEventAt, - lastSyncAt: lastSyncAt.present ? lastSyncAt.value : this.lastSyncAt, - ); + ConnectionEventEntity copyWith({ + int? id, + String? type, + Value?> ownUser = const Value.absent(), + Value totalUnreadCount = const Value.absent(), + Value unreadChannels = const Value.absent(), + Value lastEventAt = const Value.absent(), + Value lastSyncAt = const Value.absent(), + }) => ConnectionEventEntity( + id: id ?? this.id, + type: type ?? this.type, + ownUser: ownUser.present ? ownUser.value : this.ownUser, + totalUnreadCount: totalUnreadCount.present ? totalUnreadCount.value : this.totalUnreadCount, + unreadChannels: unreadChannels.present ? unreadChannels.value : this.unreadChannels, + lastEventAt: lastEventAt.present ? lastEventAt.value : this.lastEventAt, + lastSyncAt: lastSyncAt.present ? lastSyncAt.value : this.lastSyncAt, + ); ConnectionEventEntity copyWithCompanion(ConnectionEventsCompanion data) { return ConnectionEventEntity( id: data.id.present ? data.id.value : this.id, type: data.type.present ? data.type.value : this.type, ownUser: data.ownUser.present ? data.ownUser.value : this.ownUser, - totalUnreadCount: data.totalUnreadCount.present - ? data.totalUnreadCount.value - : this.totalUnreadCount, - unreadChannels: data.unreadChannels.present - ? data.unreadChannels.value - : this.unreadChannels, - lastEventAt: - data.lastEventAt.present ? data.lastEventAt.value : this.lastEventAt, - lastSyncAt: - data.lastSyncAt.present ? data.lastSyncAt.value : this.lastSyncAt, + totalUnreadCount: data.totalUnreadCount.present ? data.totalUnreadCount.value : this.totalUnreadCount, + unreadChannels: data.unreadChannels.present ? data.unreadChannels.value : this.unreadChannels, + lastEventAt: data.lastEventAt.present ? data.lastEventAt.value : this.lastEventAt, + lastSyncAt: data.lastSyncAt.present ? data.lastSyncAt.value : this.lastSyncAt, ); } @@ -8538,8 +9129,7 @@ class ConnectionEventEntity extends DataClass } @override - int get hashCode => Object.hash(id, type, ownUser, totalUnreadCount, - unreadChannels, lastEventAt, lastSyncAt); + int get hashCode => Object.hash(id, type, ownUser, totalUnreadCount, unreadChannels, lastEventAt, lastSyncAt); @override bool operator ==(Object other) => identical(this, other) || @@ -8599,14 +9189,15 @@ class ConnectionEventsCompanion extends UpdateCompanion { }); } - ConnectionEventsCompanion copyWith( - {Value? id, - Value? type, - Value?>? ownUser, - Value? totalUnreadCount, - Value? unreadChannels, - Value? lastEventAt, - Value? lastSyncAt}) { + ConnectionEventsCompanion copyWith({ + Value? id, + Value? type, + Value?>? ownUser, + Value? totalUnreadCount, + Value? unreadChannels, + Value? lastEventAt, + Value? lastSyncAt, + }) { return ConnectionEventsCompanion( id: id ?? this.id, type: type ?? this.type, @@ -8628,8 +9219,7 @@ class ConnectionEventsCompanion extends UpdateCompanion { map['type'] = Variable(type.value); } if (ownUser.present) { - map['own_user'] = Variable( - $ConnectionEventsTable.$converterownUsern.toSql(ownUser.value)); + map['own_user'] = Variable($ConnectionEventsTable.$converterownUsern.toSql(ownUser.value)); } if (totalUnreadCount.present) { map['total_unread_count'] = Variable(totalUnreadCount.value); @@ -8667,222 +9257,239 @@ abstract class _$DriftChatDatabase extends GeneratedDatabase { late final $ChannelsTable channels = $ChannelsTable(this); late final $MessagesTable messages = $MessagesTable(this); late final $DraftMessagesTable draftMessages = $DraftMessagesTable(this); + late final $LocationsTable locations = $LocationsTable(this); late final $PinnedMessagesTable pinnedMessages = $PinnedMessagesTable(this); late final $PollsTable polls = $PollsTable(this); late final $PollVotesTable pollVotes = $PollVotesTable(this); - late final $PinnedMessageReactionsTable pinnedMessageReactions = - $PinnedMessageReactionsTable(this); + late final $PinnedMessageReactionsTable pinnedMessageReactions = $PinnedMessageReactionsTable(this); late final $ReactionsTable reactions = $ReactionsTable(this); late final $UsersTable users = $UsersTable(this); late final $MembersTable members = $MembersTable(this); late final $ReadsTable reads = $ReadsTable(this); late final $ChannelQueriesTable channelQueries = $ChannelQueriesTable(this); - late final $ConnectionEventsTable connectionEvents = - $ConnectionEventsTable(this); + late final $ConnectionEventsTable connectionEvents = $ConnectionEventsTable(this); late final UserDao userDao = UserDao(this as DriftChatDatabase); late final ChannelDao channelDao = ChannelDao(this as DriftChatDatabase); late final MessageDao messageDao = MessageDao(this as DriftChatDatabase); - late final DraftMessageDao draftMessageDao = - DraftMessageDao(this as DriftChatDatabase); - late final PinnedMessageDao pinnedMessageDao = - PinnedMessageDao(this as DriftChatDatabase); - late final PinnedMessageReactionDao pinnedMessageReactionDao = - PinnedMessageReactionDao(this as DriftChatDatabase); + late final DraftMessageDao draftMessageDao = DraftMessageDao(this as DriftChatDatabase); + late final LocationDao locationDao = LocationDao(this as DriftChatDatabase); + late final PinnedMessageDao pinnedMessageDao = PinnedMessageDao(this as DriftChatDatabase); + late final PinnedMessageReactionDao pinnedMessageReactionDao = PinnedMessageReactionDao(this as DriftChatDatabase); late final MemberDao memberDao = MemberDao(this as DriftChatDatabase); late final PollDao pollDao = PollDao(this as DriftChatDatabase); late final PollVoteDao pollVoteDao = PollVoteDao(this as DriftChatDatabase); late final ReactionDao reactionDao = ReactionDao(this as DriftChatDatabase); late final ReadDao readDao = ReadDao(this as DriftChatDatabase); - late final ChannelQueryDao channelQueryDao = - ChannelQueryDao(this as DriftChatDatabase); - late final ConnectionEventDao connectionEventDao = - ConnectionEventDao(this as DriftChatDatabase); + late final ChannelQueryDao channelQueryDao = ChannelQueryDao(this as DriftChatDatabase); + late final ConnectionEventDao connectionEventDao = ConnectionEventDao(this as DriftChatDatabase); @override - Iterable> get allTables => - allSchemaEntities.whereType>(); + Iterable> get allTables => allSchemaEntities.whereType>(); @override List get allSchemaEntities => [ - channels, - messages, - draftMessages, - pinnedMessages, - polls, - pollVotes, - pinnedMessageReactions, - reactions, - users, - members, - reads, - channelQueries, - connectionEvents - ]; + channels, + messages, + draftMessages, + locations, + pinnedMessages, + polls, + pollVotes, + pinnedMessageReactions, + reactions, + users, + members, + reads, + channelQueries, + connectionEvents, + ]; @override StreamQueryUpdateRules get streamUpdateRules => const StreamQueryUpdateRules( - [ - WritePropagation( - on: TableUpdateQuery.onTableName('channels', - limitUpdateKind: UpdateKind.delete), - result: [ - TableUpdate('messages', kind: UpdateKind.delete), - ], - ), - WritePropagation( - on: TableUpdateQuery.onTableName('messages', - limitUpdateKind: UpdateKind.delete), - result: [ - TableUpdate('draft_messages', kind: UpdateKind.delete), - ], - ), - WritePropagation( - on: TableUpdateQuery.onTableName('channels', - limitUpdateKind: UpdateKind.delete), - result: [ - TableUpdate('draft_messages', kind: UpdateKind.delete), - ], - ), - WritePropagation( - on: TableUpdateQuery.onTableName('polls', - limitUpdateKind: UpdateKind.delete), - result: [ - TableUpdate('poll_votes', kind: UpdateKind.delete), - ], - ), - WritePropagation( - on: TableUpdateQuery.onTableName('pinned_messages', - limitUpdateKind: UpdateKind.delete), - result: [ - TableUpdate('pinned_message_reactions', kind: UpdateKind.delete), - ], - ), - WritePropagation( - on: TableUpdateQuery.onTableName('messages', - limitUpdateKind: UpdateKind.delete), - result: [ - TableUpdate('reactions', kind: UpdateKind.delete), - ], - ), - WritePropagation( - on: TableUpdateQuery.onTableName('channels', - limitUpdateKind: UpdateKind.delete), - result: [ - TableUpdate('members', kind: UpdateKind.delete), - ], - ), - WritePropagation( - on: TableUpdateQuery.onTableName('channels', - limitUpdateKind: UpdateKind.delete), - result: [ - TableUpdate('reads', kind: UpdateKind.delete), - ], - ), + [ + WritePropagation( + on: TableUpdateQuery.onTableName('channels', limitUpdateKind: UpdateKind.delete), + result: [ + TableUpdate('messages', kind: UpdateKind.delete), ], - ); + ), + WritePropagation( + on: TableUpdateQuery.onTableName('messages', limitUpdateKind: UpdateKind.delete), + result: [ + TableUpdate('draft_messages', kind: UpdateKind.delete), + ], + ), + WritePropagation( + on: TableUpdateQuery.onTableName('channels', limitUpdateKind: UpdateKind.delete), + result: [ + TableUpdate('draft_messages', kind: UpdateKind.delete), + ], + ), + WritePropagation( + on: TableUpdateQuery.onTableName('channels', limitUpdateKind: UpdateKind.delete), + result: [ + TableUpdate('locations', kind: UpdateKind.delete), + ], + ), + WritePropagation( + on: TableUpdateQuery.onTableName('messages', limitUpdateKind: UpdateKind.delete), + result: [ + TableUpdate('locations', kind: UpdateKind.delete), + ], + ), + WritePropagation( + on: TableUpdateQuery.onTableName('polls', limitUpdateKind: UpdateKind.delete), + result: [ + TableUpdate('poll_votes', kind: UpdateKind.delete), + ], + ), + WritePropagation( + on: TableUpdateQuery.onTableName('pinned_messages', limitUpdateKind: UpdateKind.delete), + result: [ + TableUpdate('pinned_message_reactions', kind: UpdateKind.delete), + ], + ), + WritePropagation( + on: TableUpdateQuery.onTableName('messages', limitUpdateKind: UpdateKind.delete), + result: [ + TableUpdate('reactions', kind: UpdateKind.delete), + ], + ), + WritePropagation( + on: TableUpdateQuery.onTableName('channels', limitUpdateKind: UpdateKind.delete), + result: [ + TableUpdate('members', kind: UpdateKind.delete), + ], + ), + WritePropagation( + on: TableUpdateQuery.onTableName('channels', limitUpdateKind: UpdateKind.delete), + result: [ + TableUpdate('reads', kind: UpdateKind.delete), + ], + ), + ], + ); } -typedef $$ChannelsTableCreateCompanionBuilder = ChannelsCompanion Function({ - required String id, - required String type, - required String cid, - Value?> ownCapabilities, - required Map config, - Value frozen, - Value lastMessageAt, - Value createdAt, - Value updatedAt, - Value deletedAt, - Value memberCount, - Value messageCount, - Value createdById, - Value?> filterTags, - Value?> extraData, - Value rowid, -}); -typedef $$ChannelsTableUpdateCompanionBuilder = ChannelsCompanion Function({ - Value id, - Value type, - Value cid, - Value?> ownCapabilities, - Value> config, - Value frozen, - Value lastMessageAt, - Value createdAt, - Value updatedAt, - Value deletedAt, - Value memberCount, - Value messageCount, - Value createdById, - Value?> filterTags, - Value?> extraData, - Value rowid, -}); - -final class $$ChannelsTableReferences - extends BaseReferences<_$DriftChatDatabase, $ChannelsTable, ChannelEntity> { +typedef $$ChannelsTableCreateCompanionBuilder = + ChannelsCompanion Function({ + required String id, + required String type, + required String cid, + Value?> ownCapabilities, + required Map config, + Value frozen, + Value lastMessageAt, + Value createdAt, + Value updatedAt, + Value deletedAt, + Value memberCount, + Value messageCount, + Value createdById, + Value?> filterTags, + Value?> extraData, + Value rowid, + }); +typedef $$ChannelsTableUpdateCompanionBuilder = + ChannelsCompanion Function({ + Value id, + Value type, + Value cid, + Value?> ownCapabilities, + Value> config, + Value frozen, + Value lastMessageAt, + Value createdAt, + Value updatedAt, + Value deletedAt, + Value memberCount, + Value messageCount, + Value createdById, + Value?> filterTags, + Value?> extraData, + Value rowid, + }); + +final class $$ChannelsTableReferences extends BaseReferences<_$DriftChatDatabase, $ChannelsTable, ChannelEntity> { $$ChannelsTableReferences(super.$_db, super.$_table, super.$_typedResult); - static MultiTypedResultKey<$MessagesTable, List> - _messagesRefsTable(_$DriftChatDatabase db) => - MultiTypedResultKey.fromTable(db.messages, - aliasName: $_aliasNameGenerator( - db.channels.cid, db.messages.channelCid)); + static MultiTypedResultKey<$MessagesTable, List> _messagesRefsTable(_$DriftChatDatabase db) => + MultiTypedResultKey.fromTable( + db.messages, + aliasName: $_aliasNameGenerator(db.channels.cid, db.messages.channelCid), + ); $$MessagesTableProcessedTableManager get messagesRefs { - final manager = $$MessagesTableTableManager($_db, $_db.messages) - .filter((f) => f.channelCid.cid($_item.cid)); + final manager = $$MessagesTableTableManager( + $_db, + $_db.messages, + ).filter((f) => f.channelCid.cid.sqlEquals($_itemColumn('cid')!)); final cache = $_typedResult.readTableOrNull(_messagesRefsTable($_db)); - return ProcessedTableManager( - manager.$state.copyWith(prefetchedData: cache)); + return ProcessedTableManager(manager.$state.copyWith(prefetchedData: cache)); } - static MultiTypedResultKey<$DraftMessagesTable, List> - _draftMessagesRefsTable(_$DriftChatDatabase db) => - MultiTypedResultKey.fromTable(db.draftMessages, - aliasName: $_aliasNameGenerator( - db.channels.cid, db.draftMessages.channelCid)); + static MultiTypedResultKey<$DraftMessagesTable, List> _draftMessagesRefsTable( + _$DriftChatDatabase db, + ) => MultiTypedResultKey.fromTable( + db.draftMessages, + aliasName: $_aliasNameGenerator(db.channels.cid, db.draftMessages.channelCid), + ); $$DraftMessagesTableProcessedTableManager get draftMessagesRefs { - final manager = $$DraftMessagesTableTableManager($_db, $_db.draftMessages) - .filter((f) => f.channelCid.cid($_item.cid)); + final manager = $$DraftMessagesTableTableManager( + $_db, + $_db.draftMessages, + ).filter((f) => f.channelCid.cid.sqlEquals($_itemColumn('cid')!)); final cache = $_typedResult.readTableOrNull(_draftMessagesRefsTable($_db)); - return ProcessedTableManager( - manager.$state.copyWith(prefetchedData: cache)); + return ProcessedTableManager(manager.$state.copyWith(prefetchedData: cache)); + } + + static MultiTypedResultKey<$LocationsTable, List> _locationsRefsTable(_$DriftChatDatabase db) => + MultiTypedResultKey.fromTable( + db.locations, + aliasName: $_aliasNameGenerator(db.channels.cid, db.locations.channelCid), + ); + + $$LocationsTableProcessedTableManager get locationsRefs { + final manager = $$LocationsTableTableManager( + $_db, + $_db.locations, + ).filter((f) => f.channelCid.cid.sqlEquals($_itemColumn('cid')!)); + + final cache = $_typedResult.readTableOrNull(_locationsRefsTable($_db)); + return ProcessedTableManager(manager.$state.copyWith(prefetchedData: cache)); } - static MultiTypedResultKey<$MembersTable, List> - _membersRefsTable(_$DriftChatDatabase db) => - MultiTypedResultKey.fromTable(db.members, - aliasName: - $_aliasNameGenerator(db.channels.cid, db.members.channelCid)); + static MultiTypedResultKey<$MembersTable, List> _membersRefsTable(_$DriftChatDatabase db) => + MultiTypedResultKey.fromTable( + db.members, + aliasName: $_aliasNameGenerator(db.channels.cid, db.members.channelCid), + ); $$MembersTableProcessedTableManager get membersRefs { - final manager = $$MembersTableTableManager($_db, $_db.members) - .filter((f) => f.channelCid.cid($_item.cid)); + final manager = $$MembersTableTableManager( + $_db, + $_db.members, + ).filter((f) => f.channelCid.cid.sqlEquals($_itemColumn('cid')!)); final cache = $_typedResult.readTableOrNull(_membersRefsTable($_db)); - return ProcessedTableManager( - manager.$state.copyWith(prefetchedData: cache)); + return ProcessedTableManager(manager.$state.copyWith(prefetchedData: cache)); } - static MultiTypedResultKey<$ReadsTable, List> _readsRefsTable( - _$DriftChatDatabase db) => - MultiTypedResultKey.fromTable(db.reads, - aliasName: - $_aliasNameGenerator(db.channels.cid, db.reads.channelCid)); + static MultiTypedResultKey<$ReadsTable, List> _readsRefsTable(_$DriftChatDatabase db) => + MultiTypedResultKey.fromTable(db.reads, aliasName: $_aliasNameGenerator(db.channels.cid, db.reads.channelCid)); $$ReadsTableProcessedTableManager get readsRefs { - final manager = $$ReadsTableTableManager($_db, $_db.reads) - .filter((f) => f.channelCid.cid($_item.cid)); + final manager = $$ReadsTableTableManager( + $_db, + $_db.reads, + ).filter((f) => f.channelCid.cid.sqlEquals($_itemColumn('cid')!)); final cache = $_typedResult.readTableOrNull(_readsRefsTable($_db)); - return ProcessedTableManager( - manager.$state.copyWith(prefetchedData: cache)); + return ProcessedTableManager(manager.$state.copyWith(prefetchedData: cache)); } } -class $$ChannelsTableFilterComposer - extends Composer<_$DriftChatDatabase, $ChannelsTable> { +class $$ChannelsTableFilterComposer extends Composer<_$DriftChatDatabase, $ChannelsTable> { $$ChannelsTableFilterComposer({ required super.$db, required super.$table, @@ -8890,148 +9497,140 @@ class $$ChannelsTableFilterComposer super.$addJoinBuilderToRootComposer, super.$removeJoinBuilderFromRootComposer, }); - ColumnFilters get id => $composableBuilder( - column: $table.id, builder: (column) => ColumnFilters(column)); + ColumnFilters get id => $composableBuilder(column: $table.id, builder: (column) => ColumnFilters(column)); - ColumnFilters get type => $composableBuilder( - column: $table.type, builder: (column) => ColumnFilters(column)); + ColumnFilters get type => $composableBuilder(column: $table.type, builder: (column) => ColumnFilters(column)); - ColumnFilters get cid => $composableBuilder( - column: $table.cid, builder: (column) => ColumnFilters(column)); + ColumnFilters get cid => $composableBuilder(column: $table.cid, builder: (column) => ColumnFilters(column)); - ColumnWithTypeConverterFilters?, List, String> - get ownCapabilities => $composableBuilder( - column: $table.ownCapabilities, - builder: (column) => ColumnWithTypeConverterFilters(column)); + ColumnWithTypeConverterFilters?, List, String> get ownCapabilities => + $composableBuilder(column: $table.ownCapabilities, builder: (column) => ColumnWithTypeConverterFilters(column)); - ColumnWithTypeConverterFilters, Map, - String> - get config => $composableBuilder( - column: $table.config, - builder: (column) => ColumnWithTypeConverterFilters(column)); + ColumnWithTypeConverterFilters, Map, String> get config => + $composableBuilder(column: $table.config, builder: (column) => ColumnWithTypeConverterFilters(column)); - ColumnFilters get frozen => $composableBuilder( - column: $table.frozen, builder: (column) => ColumnFilters(column)); + ColumnFilters get frozen => + $composableBuilder(column: $table.frozen, builder: (column) => ColumnFilters(column)); - ColumnFilters get lastMessageAt => $composableBuilder( - column: $table.lastMessageAt, builder: (column) => ColumnFilters(column)); + ColumnFilters get lastMessageAt => + $composableBuilder(column: $table.lastMessageAt, builder: (column) => ColumnFilters(column)); - ColumnFilters get createdAt => $composableBuilder( - column: $table.createdAt, builder: (column) => ColumnFilters(column)); + ColumnFilters get createdAt => + $composableBuilder(column: $table.createdAt, builder: (column) => ColumnFilters(column)); - ColumnFilters get updatedAt => $composableBuilder( - column: $table.updatedAt, builder: (column) => ColumnFilters(column)); + ColumnFilters get updatedAt => + $composableBuilder(column: $table.updatedAt, builder: (column) => ColumnFilters(column)); - ColumnFilters get deletedAt => $composableBuilder( - column: $table.deletedAt, builder: (column) => ColumnFilters(column)); + ColumnFilters get deletedAt => + $composableBuilder(column: $table.deletedAt, builder: (column) => ColumnFilters(column)); - ColumnFilters get memberCount => $composableBuilder( - column: $table.memberCount, builder: (column) => ColumnFilters(column)); + ColumnFilters get memberCount => + $composableBuilder(column: $table.memberCount, builder: (column) => ColumnFilters(column)); - ColumnFilters get messageCount => $composableBuilder( - column: $table.messageCount, builder: (column) => ColumnFilters(column)); + ColumnFilters get messageCount => + $composableBuilder(column: $table.messageCount, builder: (column) => ColumnFilters(column)); - ColumnFilters get createdById => $composableBuilder( - column: $table.createdById, builder: (column) => ColumnFilters(column)); + ColumnFilters get createdById => + $composableBuilder(column: $table.createdById, builder: (column) => ColumnFilters(column)); - ColumnWithTypeConverterFilters?, List, String> - get filterTags => $composableBuilder( - column: $table.filterTags, - builder: (column) => ColumnWithTypeConverterFilters(column)); + ColumnWithTypeConverterFilters?, List, String> get filterTags => + $composableBuilder(column: $table.filterTags, builder: (column) => ColumnWithTypeConverterFilters(column)); - ColumnWithTypeConverterFilters?, Map, - String> - get extraData => $composableBuilder( - column: $table.extraData, - builder: (column) => ColumnWithTypeConverterFilters(column)); + ColumnWithTypeConverterFilters?, Map, String> get extraData => + $composableBuilder(column: $table.extraData, builder: (column) => ColumnWithTypeConverterFilters(column)); - Expression messagesRefs( - Expression Function($$MessagesTableFilterComposer f) f) { + Expression messagesRefs(Expression Function($$MessagesTableFilterComposer f) f) { final $$MessagesTableFilterComposer composer = $composerBuilder( - composer: this, - getCurrentColumn: (t) => t.cid, - referencedTable: $db.messages, - getReferencedColumn: (t) => t.channelCid, - builder: (joinBuilder, - {$addJoinBuilderToRootComposer, - $removeJoinBuilderFromRootComposer}) => - $$MessagesTableFilterComposer( - $db: $db, - $table: $db.messages, - $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, - joinBuilder: joinBuilder, - $removeJoinBuilderFromRootComposer: - $removeJoinBuilderFromRootComposer, - )); + composer: this, + getCurrentColumn: (t) => t.cid, + referencedTable: $db.messages, + getReferencedColumn: (t) => t.channelCid, + builder: (joinBuilder, {$addJoinBuilderToRootComposer, $removeJoinBuilderFromRootComposer}) => + $$MessagesTableFilterComposer( + $db: $db, + $table: $db.messages, + $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, + joinBuilder: joinBuilder, + $removeJoinBuilderFromRootComposer: $removeJoinBuilderFromRootComposer, + ), + ); return f(composer); } - Expression draftMessagesRefs( - Expression Function($$DraftMessagesTableFilterComposer f) f) { + Expression draftMessagesRefs(Expression Function($$DraftMessagesTableFilterComposer f) f) { final $$DraftMessagesTableFilterComposer composer = $composerBuilder( - composer: this, - getCurrentColumn: (t) => t.cid, - referencedTable: $db.draftMessages, - getReferencedColumn: (t) => t.channelCid, - builder: (joinBuilder, - {$addJoinBuilderToRootComposer, - $removeJoinBuilderFromRootComposer}) => - $$DraftMessagesTableFilterComposer( - $db: $db, - $table: $db.draftMessages, - $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, - joinBuilder: joinBuilder, - $removeJoinBuilderFromRootComposer: - $removeJoinBuilderFromRootComposer, - )); + composer: this, + getCurrentColumn: (t) => t.cid, + referencedTable: $db.draftMessages, + getReferencedColumn: (t) => t.channelCid, + builder: (joinBuilder, {$addJoinBuilderToRootComposer, $removeJoinBuilderFromRootComposer}) => + $$DraftMessagesTableFilterComposer( + $db: $db, + $table: $db.draftMessages, + $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, + joinBuilder: joinBuilder, + $removeJoinBuilderFromRootComposer: $removeJoinBuilderFromRootComposer, + ), + ); + return f(composer); + } + + Expression locationsRefs(Expression Function($$LocationsTableFilterComposer f) f) { + final $$LocationsTableFilterComposer composer = $composerBuilder( + composer: this, + getCurrentColumn: (t) => t.cid, + referencedTable: $db.locations, + getReferencedColumn: (t) => t.channelCid, + builder: (joinBuilder, {$addJoinBuilderToRootComposer, $removeJoinBuilderFromRootComposer}) => + $$LocationsTableFilterComposer( + $db: $db, + $table: $db.locations, + $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, + joinBuilder: joinBuilder, + $removeJoinBuilderFromRootComposer: $removeJoinBuilderFromRootComposer, + ), + ); return f(composer); } - Expression membersRefs( - Expression Function($$MembersTableFilterComposer f) f) { + Expression membersRefs(Expression Function($$MembersTableFilterComposer f) f) { final $$MembersTableFilterComposer composer = $composerBuilder( - composer: this, - getCurrentColumn: (t) => t.cid, - referencedTable: $db.members, - getReferencedColumn: (t) => t.channelCid, - builder: (joinBuilder, - {$addJoinBuilderToRootComposer, - $removeJoinBuilderFromRootComposer}) => - $$MembersTableFilterComposer( - $db: $db, - $table: $db.members, - $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, - joinBuilder: joinBuilder, - $removeJoinBuilderFromRootComposer: - $removeJoinBuilderFromRootComposer, - )); + composer: this, + getCurrentColumn: (t) => t.cid, + referencedTable: $db.members, + getReferencedColumn: (t) => t.channelCid, + builder: (joinBuilder, {$addJoinBuilderToRootComposer, $removeJoinBuilderFromRootComposer}) => + $$MembersTableFilterComposer( + $db: $db, + $table: $db.members, + $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, + joinBuilder: joinBuilder, + $removeJoinBuilderFromRootComposer: $removeJoinBuilderFromRootComposer, + ), + ); return f(composer); } - Expression readsRefs( - Expression Function($$ReadsTableFilterComposer f) f) { + Expression readsRefs(Expression Function($$ReadsTableFilterComposer f) f) { final $$ReadsTableFilterComposer composer = $composerBuilder( - composer: this, - getCurrentColumn: (t) => t.cid, - referencedTable: $db.reads, - getReferencedColumn: (t) => t.channelCid, - builder: (joinBuilder, - {$addJoinBuilderToRootComposer, - $removeJoinBuilderFromRootComposer}) => - $$ReadsTableFilterComposer( - $db: $db, - $table: $db.reads, - $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, - joinBuilder: joinBuilder, - $removeJoinBuilderFromRootComposer: - $removeJoinBuilderFromRootComposer, - )); + composer: this, + getCurrentColumn: (t) => t.cid, + referencedTable: $db.reads, + getReferencedColumn: (t) => t.channelCid, + builder: (joinBuilder, {$addJoinBuilderToRootComposer, $removeJoinBuilderFromRootComposer}) => + $$ReadsTableFilterComposer( + $db: $db, + $table: $db.reads, + $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, + joinBuilder: joinBuilder, + $removeJoinBuilderFromRootComposer: $removeJoinBuilderFromRootComposer, + ), + ); return f(composer); } } -class $$ChannelsTableOrderingComposer - extends Composer<_$DriftChatDatabase, $ChannelsTable> { +class $$ChannelsTableOrderingComposer extends Composer<_$DriftChatDatabase, $ChannelsTable> { $$ChannelsTableOrderingComposer({ required super.$db, required super.$table, @@ -9039,57 +9638,52 @@ class $$ChannelsTableOrderingComposer super.$addJoinBuilderToRootComposer, super.$removeJoinBuilderFromRootComposer, }); - ColumnOrderings get id => $composableBuilder( - column: $table.id, builder: (column) => ColumnOrderings(column)); + ColumnOrderings get id => $composableBuilder(column: $table.id, builder: (column) => ColumnOrderings(column)); - ColumnOrderings get type => $composableBuilder( - column: $table.type, builder: (column) => ColumnOrderings(column)); + ColumnOrderings get type => + $composableBuilder(column: $table.type, builder: (column) => ColumnOrderings(column)); - ColumnOrderings get cid => $composableBuilder( - column: $table.cid, builder: (column) => ColumnOrderings(column)); + ColumnOrderings get cid => + $composableBuilder(column: $table.cid, builder: (column) => ColumnOrderings(column)); - ColumnOrderings get ownCapabilities => $composableBuilder( - column: $table.ownCapabilities, - builder: (column) => ColumnOrderings(column)); + ColumnOrderings get ownCapabilities => + $composableBuilder(column: $table.ownCapabilities, builder: (column) => ColumnOrderings(column)); - ColumnOrderings get config => $composableBuilder( - column: $table.config, builder: (column) => ColumnOrderings(column)); + ColumnOrderings get config => + $composableBuilder(column: $table.config, builder: (column) => ColumnOrderings(column)); - ColumnOrderings get frozen => $composableBuilder( - column: $table.frozen, builder: (column) => ColumnOrderings(column)); + ColumnOrderings get frozen => + $composableBuilder(column: $table.frozen, builder: (column) => ColumnOrderings(column)); - ColumnOrderings get lastMessageAt => $composableBuilder( - column: $table.lastMessageAt, - builder: (column) => ColumnOrderings(column)); + ColumnOrderings get lastMessageAt => + $composableBuilder(column: $table.lastMessageAt, builder: (column) => ColumnOrderings(column)); - ColumnOrderings get createdAt => $composableBuilder( - column: $table.createdAt, builder: (column) => ColumnOrderings(column)); + ColumnOrderings get createdAt => + $composableBuilder(column: $table.createdAt, builder: (column) => ColumnOrderings(column)); - ColumnOrderings get updatedAt => $composableBuilder( - column: $table.updatedAt, builder: (column) => ColumnOrderings(column)); + ColumnOrderings get updatedAt => + $composableBuilder(column: $table.updatedAt, builder: (column) => ColumnOrderings(column)); - ColumnOrderings get deletedAt => $composableBuilder( - column: $table.deletedAt, builder: (column) => ColumnOrderings(column)); + ColumnOrderings get deletedAt => + $composableBuilder(column: $table.deletedAt, builder: (column) => ColumnOrderings(column)); - ColumnOrderings get memberCount => $composableBuilder( - column: $table.memberCount, builder: (column) => ColumnOrderings(column)); + ColumnOrderings get memberCount => + $composableBuilder(column: $table.memberCount, builder: (column) => ColumnOrderings(column)); - ColumnOrderings get messageCount => $composableBuilder( - column: $table.messageCount, - builder: (column) => ColumnOrderings(column)); + ColumnOrderings get messageCount => + $composableBuilder(column: $table.messageCount, builder: (column) => ColumnOrderings(column)); - ColumnOrderings get createdById => $composableBuilder( - column: $table.createdById, builder: (column) => ColumnOrderings(column)); + ColumnOrderings get createdById => + $composableBuilder(column: $table.createdById, builder: (column) => ColumnOrderings(column)); - ColumnOrderings get filterTags => $composableBuilder( - column: $table.filterTags, builder: (column) => ColumnOrderings(column)); + ColumnOrderings get filterTags => + $composableBuilder(column: $table.filterTags, builder: (column) => ColumnOrderings(column)); - ColumnOrderings get extraData => $composableBuilder( - column: $table.extraData, builder: (column) => ColumnOrderings(column)); + ColumnOrderings get extraData => + $composableBuilder(column: $table.extraData, builder: (column) => ColumnOrderings(column)); } -class $$ChannelsTableAnnotationComposer - extends Composer<_$DriftChatDatabase, $ChannelsTable> { +class $$ChannelsTableAnnotationComposer extends Composer<_$DriftChatDatabase, $ChannelsTable> { $$ChannelsTableAnnotationComposer({ required super.$db, required super.$table, @@ -9097,446 +9691,470 @@ class $$ChannelsTableAnnotationComposer super.$addJoinBuilderToRootComposer, super.$removeJoinBuilderFromRootComposer, }); - GeneratedColumn get id => - $composableBuilder(column: $table.id, builder: (column) => column); + GeneratedColumn get id => $composableBuilder(column: $table.id, builder: (column) => column); - GeneratedColumn get type => - $composableBuilder(column: $table.type, builder: (column) => column); + GeneratedColumn get type => $composableBuilder(column: $table.type, builder: (column) => column); - GeneratedColumn get cid => - $composableBuilder(column: $table.cid, builder: (column) => column); + GeneratedColumn get cid => $composableBuilder(column: $table.cid, builder: (column) => column); GeneratedColumnWithTypeConverter?, String> get ownCapabilities => - $composableBuilder( - column: $table.ownCapabilities, builder: (column) => column); + $composableBuilder(column: $table.ownCapabilities, builder: (column) => column); GeneratedColumnWithTypeConverter, String> get config => $composableBuilder(column: $table.config, builder: (column) => column); - GeneratedColumn get frozen => - $composableBuilder(column: $table.frozen, builder: (column) => column); + GeneratedColumn get frozen => $composableBuilder(column: $table.frozen, builder: (column) => column); - GeneratedColumn get lastMessageAt => $composableBuilder( - column: $table.lastMessageAt, builder: (column) => column); + GeneratedColumn get lastMessageAt => + $composableBuilder(column: $table.lastMessageAt, builder: (column) => column); - GeneratedColumn get createdAt => - $composableBuilder(column: $table.createdAt, builder: (column) => column); + GeneratedColumn get createdAt => $composableBuilder(column: $table.createdAt, builder: (column) => column); - GeneratedColumn get updatedAt => - $composableBuilder(column: $table.updatedAt, builder: (column) => column); + GeneratedColumn get updatedAt => $composableBuilder(column: $table.updatedAt, builder: (column) => column); - GeneratedColumn get deletedAt => - $composableBuilder(column: $table.deletedAt, builder: (column) => column); + GeneratedColumn get deletedAt => $composableBuilder(column: $table.deletedAt, builder: (column) => column); - GeneratedColumn get memberCount => $composableBuilder( - column: $table.memberCount, builder: (column) => column); + GeneratedColumn get memberCount => $composableBuilder(column: $table.memberCount, builder: (column) => column); - GeneratedColumn get messageCount => $composableBuilder( - column: $table.messageCount, builder: (column) => column); + GeneratedColumn get messageCount => $composableBuilder(column: $table.messageCount, builder: (column) => column); - GeneratedColumn get createdById => $composableBuilder( - column: $table.createdById, builder: (column) => column); + GeneratedColumn get createdById => + $composableBuilder(column: $table.createdById, builder: (column) => column); GeneratedColumnWithTypeConverter?, String> get filterTags => - $composableBuilder( - column: $table.filterTags, builder: (column) => column); + $composableBuilder(column: $table.filterTags, builder: (column) => column); - GeneratedColumnWithTypeConverter?, String> - get extraData => $composableBuilder( - column: $table.extraData, builder: (column) => column); + GeneratedColumnWithTypeConverter?, String> get extraData => + $composableBuilder(column: $table.extraData, builder: (column) => column); - Expression messagesRefs( - Expression Function($$MessagesTableAnnotationComposer a) f) { + Expression messagesRefs(Expression Function($$MessagesTableAnnotationComposer a) f) { final $$MessagesTableAnnotationComposer composer = $composerBuilder( - composer: this, - getCurrentColumn: (t) => t.cid, - referencedTable: $db.messages, - getReferencedColumn: (t) => t.channelCid, - builder: (joinBuilder, - {$addJoinBuilderToRootComposer, - $removeJoinBuilderFromRootComposer}) => - $$MessagesTableAnnotationComposer( - $db: $db, - $table: $db.messages, - $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, - joinBuilder: joinBuilder, - $removeJoinBuilderFromRootComposer: - $removeJoinBuilderFromRootComposer, - )); + composer: this, + getCurrentColumn: (t) => t.cid, + referencedTable: $db.messages, + getReferencedColumn: (t) => t.channelCid, + builder: (joinBuilder, {$addJoinBuilderToRootComposer, $removeJoinBuilderFromRootComposer}) => + $$MessagesTableAnnotationComposer( + $db: $db, + $table: $db.messages, + $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, + joinBuilder: joinBuilder, + $removeJoinBuilderFromRootComposer: $removeJoinBuilderFromRootComposer, + ), + ); return f(composer); } Expression draftMessagesRefs( - Expression Function($$DraftMessagesTableAnnotationComposer a) f) { + Expression Function($$DraftMessagesTableAnnotationComposer a) f, + ) { final $$DraftMessagesTableAnnotationComposer composer = $composerBuilder( - composer: this, - getCurrentColumn: (t) => t.cid, - referencedTable: $db.draftMessages, - getReferencedColumn: (t) => t.channelCid, - builder: (joinBuilder, - {$addJoinBuilderToRootComposer, - $removeJoinBuilderFromRootComposer}) => - $$DraftMessagesTableAnnotationComposer( - $db: $db, - $table: $db.draftMessages, - $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, - joinBuilder: joinBuilder, - $removeJoinBuilderFromRootComposer: - $removeJoinBuilderFromRootComposer, - )); + composer: this, + getCurrentColumn: (t) => t.cid, + referencedTable: $db.draftMessages, + getReferencedColumn: (t) => t.channelCid, + builder: (joinBuilder, {$addJoinBuilderToRootComposer, $removeJoinBuilderFromRootComposer}) => + $$DraftMessagesTableAnnotationComposer( + $db: $db, + $table: $db.draftMessages, + $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, + joinBuilder: joinBuilder, + $removeJoinBuilderFromRootComposer: $removeJoinBuilderFromRootComposer, + ), + ); + return f(composer); + } + + Expression locationsRefs(Expression Function($$LocationsTableAnnotationComposer a) f) { + final $$LocationsTableAnnotationComposer composer = $composerBuilder( + composer: this, + getCurrentColumn: (t) => t.cid, + referencedTable: $db.locations, + getReferencedColumn: (t) => t.channelCid, + builder: (joinBuilder, {$addJoinBuilderToRootComposer, $removeJoinBuilderFromRootComposer}) => + $$LocationsTableAnnotationComposer( + $db: $db, + $table: $db.locations, + $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, + joinBuilder: joinBuilder, + $removeJoinBuilderFromRootComposer: $removeJoinBuilderFromRootComposer, + ), + ); return f(composer); } - Expression membersRefs( - Expression Function($$MembersTableAnnotationComposer a) f) { + Expression membersRefs(Expression Function($$MembersTableAnnotationComposer a) f) { final $$MembersTableAnnotationComposer composer = $composerBuilder( - composer: this, - getCurrentColumn: (t) => t.cid, - referencedTable: $db.members, - getReferencedColumn: (t) => t.channelCid, - builder: (joinBuilder, - {$addJoinBuilderToRootComposer, - $removeJoinBuilderFromRootComposer}) => - $$MembersTableAnnotationComposer( - $db: $db, - $table: $db.members, - $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, - joinBuilder: joinBuilder, - $removeJoinBuilderFromRootComposer: - $removeJoinBuilderFromRootComposer, - )); + composer: this, + getCurrentColumn: (t) => t.cid, + referencedTable: $db.members, + getReferencedColumn: (t) => t.channelCid, + builder: (joinBuilder, {$addJoinBuilderToRootComposer, $removeJoinBuilderFromRootComposer}) => + $$MembersTableAnnotationComposer( + $db: $db, + $table: $db.members, + $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, + joinBuilder: joinBuilder, + $removeJoinBuilderFromRootComposer: $removeJoinBuilderFromRootComposer, + ), + ); return f(composer); } - Expression readsRefs( - Expression Function($$ReadsTableAnnotationComposer a) f) { + Expression readsRefs(Expression Function($$ReadsTableAnnotationComposer a) f) { final $$ReadsTableAnnotationComposer composer = $composerBuilder( - composer: this, - getCurrentColumn: (t) => t.cid, - referencedTable: $db.reads, - getReferencedColumn: (t) => t.channelCid, - builder: (joinBuilder, - {$addJoinBuilderToRootComposer, - $removeJoinBuilderFromRootComposer}) => - $$ReadsTableAnnotationComposer( - $db: $db, - $table: $db.reads, - $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, - joinBuilder: joinBuilder, - $removeJoinBuilderFromRootComposer: - $removeJoinBuilderFromRootComposer, - )); + composer: this, + getCurrentColumn: (t) => t.cid, + referencedTable: $db.reads, + getReferencedColumn: (t) => t.channelCid, + builder: (joinBuilder, {$addJoinBuilderToRootComposer, $removeJoinBuilderFromRootComposer}) => + $$ReadsTableAnnotationComposer( + $db: $db, + $table: $db.reads, + $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, + joinBuilder: joinBuilder, + $removeJoinBuilderFromRootComposer: $removeJoinBuilderFromRootComposer, + ), + ); return f(composer); } } -class $$ChannelsTableTableManager extends RootTableManager< - _$DriftChatDatabase, - $ChannelsTable, - ChannelEntity, - $$ChannelsTableFilterComposer, - $$ChannelsTableOrderingComposer, - $$ChannelsTableAnnotationComposer, - $$ChannelsTableCreateCompanionBuilder, - $$ChannelsTableUpdateCompanionBuilder, - (ChannelEntity, $$ChannelsTableReferences), - ChannelEntity, - PrefetchHooks Function( - {bool messagesRefs, - bool draftMessagesRefs, - bool membersRefs, - bool readsRefs})> { +class $$ChannelsTableTableManager + extends + RootTableManager< + _$DriftChatDatabase, + $ChannelsTable, + ChannelEntity, + $$ChannelsTableFilterComposer, + $$ChannelsTableOrderingComposer, + $$ChannelsTableAnnotationComposer, + $$ChannelsTableCreateCompanionBuilder, + $$ChannelsTableUpdateCompanionBuilder, + (ChannelEntity, $$ChannelsTableReferences), + ChannelEntity, + PrefetchHooks Function({ + bool messagesRefs, + bool draftMessagesRefs, + bool locationsRefs, + bool membersRefs, + bool readsRefs, + }) + > { $$ChannelsTableTableManager(_$DriftChatDatabase db, $ChannelsTable table) - : super(TableManagerState( + : super( + TableManagerState( db: db, table: table, - createFilteringComposer: () => - $$ChannelsTableFilterComposer($db: db, $table: table), - createOrderingComposer: () => - $$ChannelsTableOrderingComposer($db: db, $table: table), - createComputedFieldComposer: () => - $$ChannelsTableAnnotationComposer($db: db, $table: table), - updateCompanionCallback: ({ - Value id = const Value.absent(), - Value type = const Value.absent(), - Value cid = const Value.absent(), - Value?> ownCapabilities = const Value.absent(), - Value> config = const Value.absent(), - Value frozen = const Value.absent(), - Value lastMessageAt = const Value.absent(), - Value createdAt = const Value.absent(), - Value updatedAt = const Value.absent(), - Value deletedAt = const Value.absent(), - Value memberCount = const Value.absent(), - Value messageCount = const Value.absent(), - Value createdById = const Value.absent(), - Value?> filterTags = const Value.absent(), - Value?> extraData = const Value.absent(), - Value rowid = const Value.absent(), - }) => - ChannelsCompanion( - id: id, - type: type, - cid: cid, - ownCapabilities: ownCapabilities, - config: config, - frozen: frozen, - lastMessageAt: lastMessageAt, - createdAt: createdAt, - updatedAt: updatedAt, - deletedAt: deletedAt, - memberCount: memberCount, - messageCount: messageCount, - createdById: createdById, - filterTags: filterTags, - extraData: extraData, - rowid: rowid, - ), - createCompanionCallback: ({ - required String id, - required String type, - required String cid, - Value?> ownCapabilities = const Value.absent(), - required Map config, - Value frozen = const Value.absent(), - Value lastMessageAt = const Value.absent(), - Value createdAt = const Value.absent(), - Value updatedAt = const Value.absent(), - Value deletedAt = const Value.absent(), - Value memberCount = const Value.absent(), - Value messageCount = const Value.absent(), - Value createdById = const Value.absent(), - Value?> filterTags = const Value.absent(), - Value?> extraData = const Value.absent(), - Value rowid = const Value.absent(), - }) => - ChannelsCompanion.insert( - id: id, - type: type, - cid: cid, - ownCapabilities: ownCapabilities, - config: config, - frozen: frozen, - lastMessageAt: lastMessageAt, - createdAt: createdAt, - updatedAt: updatedAt, - deletedAt: deletedAt, - memberCount: memberCount, - messageCount: messageCount, - createdById: createdById, - filterTags: filterTags, - extraData: extraData, - rowid: rowid, - ), - withReferenceMapper: (p0) => p0 - .map((e) => - (e.readTable(table), $$ChannelsTableReferences(db, table, e))) - .toList(), - prefetchHooksCallback: ( - {messagesRefs = false, - draftMessagesRefs = false, - membersRefs = false, - readsRefs = false}) { - return PrefetchHooks( - db: db, - explicitlyWatchedTables: [ - if (messagesRefs) db.messages, - if (draftMessagesRefs) db.draftMessages, - if (membersRefs) db.members, - if (readsRefs) db.reads - ], - addJoins: null, - getPrefetchedDataCallback: (items) async { - return [ - if (messagesRefs) - await $_getPrefetchedData( - currentTable: table, - referencedTable: - $$ChannelsTableReferences._messagesRefsTable(db), - managerFromTypedResult: (p0) => - $$ChannelsTableReferences(db, table, p0) - .messagesRefs, - referencedItemsForCurrentItem: - (item, referencedItems) => referencedItems - .where((e) => e.channelCid == item.cid), - typedResults: items), - if (draftMessagesRefs) - await $_getPrefetchedData( - currentTable: table, - referencedTable: $$ChannelsTableReferences - ._draftMessagesRefsTable(db), - managerFromTypedResult: (p0) => - $$ChannelsTableReferences(db, table, p0) - .draftMessagesRefs, - referencedItemsForCurrentItem: - (item, referencedItems) => referencedItems - .where((e) => e.channelCid == item.cid), - typedResults: items), - if (membersRefs) - await $_getPrefetchedData( - currentTable: table, - referencedTable: - $$ChannelsTableReferences._membersRefsTable(db), - managerFromTypedResult: (p0) => - $$ChannelsTableReferences(db, table, p0) - .membersRefs, - referencedItemsForCurrentItem: - (item, referencedItems) => referencedItems - .where((e) => e.channelCid == item.cid), - typedResults: items), - if (readsRefs) - await $_getPrefetchedData( - currentTable: table, - referencedTable: - $$ChannelsTableReferences._readsRefsTable(db), - managerFromTypedResult: (p0) => - $$ChannelsTableReferences(db, table, p0).readsRefs, - referencedItemsForCurrentItem: - (item, referencedItems) => referencedItems - .where((e) => e.channelCid == item.cid), - typedResults: items) - ]; + createFilteringComposer: () => $$ChannelsTableFilterComposer($db: db, $table: table), + createOrderingComposer: () => $$ChannelsTableOrderingComposer($db: db, $table: table), + createComputedFieldComposer: () => $$ChannelsTableAnnotationComposer($db: db, $table: table), + updateCompanionCallback: + ({ + Value id = const Value.absent(), + Value type = const Value.absent(), + Value cid = const Value.absent(), + Value?> ownCapabilities = const Value.absent(), + Value> config = const Value.absent(), + Value frozen = const Value.absent(), + Value lastMessageAt = const Value.absent(), + Value createdAt = const Value.absent(), + Value updatedAt = const Value.absent(), + Value deletedAt = const Value.absent(), + Value memberCount = const Value.absent(), + Value messageCount = const Value.absent(), + Value createdById = const Value.absent(), + Value?> filterTags = const Value.absent(), + Value?> extraData = const Value.absent(), + Value rowid = const Value.absent(), + }) => ChannelsCompanion( + id: id, + type: type, + cid: cid, + ownCapabilities: ownCapabilities, + config: config, + frozen: frozen, + lastMessageAt: lastMessageAt, + createdAt: createdAt, + updatedAt: updatedAt, + deletedAt: deletedAt, + memberCount: memberCount, + messageCount: messageCount, + createdById: createdById, + filterTags: filterTags, + extraData: extraData, + rowid: rowid, + ), + createCompanionCallback: + ({ + required String id, + required String type, + required String cid, + Value?> ownCapabilities = const Value.absent(), + required Map config, + Value frozen = const Value.absent(), + Value lastMessageAt = const Value.absent(), + Value createdAt = const Value.absent(), + Value updatedAt = const Value.absent(), + Value deletedAt = const Value.absent(), + Value memberCount = const Value.absent(), + Value messageCount = const Value.absent(), + Value createdById = const Value.absent(), + Value?> filterTags = const Value.absent(), + Value?> extraData = const Value.absent(), + Value rowid = const Value.absent(), + }) => ChannelsCompanion.insert( + id: id, + type: type, + cid: cid, + ownCapabilities: ownCapabilities, + config: config, + frozen: frozen, + lastMessageAt: lastMessageAt, + createdAt: createdAt, + updatedAt: updatedAt, + deletedAt: deletedAt, + memberCount: memberCount, + messageCount: messageCount, + createdById: createdById, + filterTags: filterTags, + extraData: extraData, + rowid: rowid, + ), + withReferenceMapper: (p0) => + p0.map((e) => (e.readTable(table), $$ChannelsTableReferences(db, table, e))).toList(), + prefetchHooksCallback: + ({ + messagesRefs = false, + draftMessagesRefs = false, + locationsRefs = false, + membersRefs = false, + readsRefs = false, + }) { + return PrefetchHooks( + db: db, + explicitlyWatchedTables: [ + if (messagesRefs) db.messages, + if (draftMessagesRefs) db.draftMessages, + if (locationsRefs) db.locations, + if (membersRefs) db.members, + if (readsRefs) db.reads, + ], + addJoins: null, + getPrefetchedDataCallback: (items) async { + return [ + if (messagesRefs) + await $_getPrefetchedData( + currentTable: table, + referencedTable: $$ChannelsTableReferences._messagesRefsTable(db), + managerFromTypedResult: (p0) => $$ChannelsTableReferences(db, table, p0).messagesRefs, + referencedItemsForCurrentItem: (item, referencedItems) => + referencedItems.where((e) => e.channelCid == item.cid), + typedResults: items, + ), + if (draftMessagesRefs) + await $_getPrefetchedData( + currentTable: table, + referencedTable: $$ChannelsTableReferences._draftMessagesRefsTable(db), + managerFromTypedResult: (p0) => $$ChannelsTableReferences(db, table, p0).draftMessagesRefs, + referencedItemsForCurrentItem: (item, referencedItems) => + referencedItems.where((e) => e.channelCid == item.cid), + typedResults: items, + ), + if (locationsRefs) + await $_getPrefetchedData( + currentTable: table, + referencedTable: $$ChannelsTableReferences._locationsRefsTable(db), + managerFromTypedResult: (p0) => $$ChannelsTableReferences(db, table, p0).locationsRefs, + referencedItemsForCurrentItem: (item, referencedItems) => + referencedItems.where((e) => e.channelCid == item.cid), + typedResults: items, + ), + if (membersRefs) + await $_getPrefetchedData( + currentTable: table, + referencedTable: $$ChannelsTableReferences._membersRefsTable(db), + managerFromTypedResult: (p0) => $$ChannelsTableReferences(db, table, p0).membersRefs, + referencedItemsForCurrentItem: (item, referencedItems) => + referencedItems.where((e) => e.channelCid == item.cid), + typedResults: items, + ), + if (readsRefs) + await $_getPrefetchedData( + currentTable: table, + referencedTable: $$ChannelsTableReferences._readsRefsTable(db), + managerFromTypedResult: (p0) => $$ChannelsTableReferences(db, table, p0).readsRefs, + referencedItemsForCurrentItem: (item, referencedItems) => + referencedItems.where((e) => e.channelCid == item.cid), + typedResults: items, + ), + ]; + }, + ); }, - ); - }, - )); + ), + ); } -typedef $$ChannelsTableProcessedTableManager = ProcessedTableManager< - _$DriftChatDatabase, - $ChannelsTable, - ChannelEntity, - $$ChannelsTableFilterComposer, - $$ChannelsTableOrderingComposer, - $$ChannelsTableAnnotationComposer, - $$ChannelsTableCreateCompanionBuilder, - $$ChannelsTableUpdateCompanionBuilder, - (ChannelEntity, $$ChannelsTableReferences), - ChannelEntity, - PrefetchHooks Function( - {bool messagesRefs, +typedef $$ChannelsTableProcessedTableManager = + ProcessedTableManager< + _$DriftChatDatabase, + $ChannelsTable, + ChannelEntity, + $$ChannelsTableFilterComposer, + $$ChannelsTableOrderingComposer, + $$ChannelsTableAnnotationComposer, + $$ChannelsTableCreateCompanionBuilder, + $$ChannelsTableUpdateCompanionBuilder, + (ChannelEntity, $$ChannelsTableReferences), + ChannelEntity, + PrefetchHooks Function({ + bool messagesRefs, bool draftMessagesRefs, + bool locationsRefs, bool membersRefs, - bool readsRefs})>; -typedef $$MessagesTableCreateCompanionBuilder = MessagesCompanion Function({ - required String id, - Value messageText, - required List attachments, - required String state, - Value type, - required List mentionedUsers, - Value?> reactionGroups, - Value parentId, - Value quotedMessageId, - Value pollId, - Value replyCount, - Value showInChannel, - Value shadowed, - Value command, - Value localCreatedAt, - Value remoteCreatedAt, - Value localUpdatedAt, - Value remoteUpdatedAt, - Value localDeletedAt, - Value remoteDeletedAt, - Value messageTextUpdatedAt, - Value userId, - Value channelRole, - Value pinned, - Value pinnedAt, - Value pinExpires, - Value pinnedByUserId, - required String channelCid, - Value?> i18n, - Value?> restrictedVisibility, - Value?> extraData, - Value rowid, -}); -typedef $$MessagesTableUpdateCompanionBuilder = MessagesCompanion Function({ - Value id, - Value messageText, - Value> attachments, - Value state, - Value type, - Value> mentionedUsers, - Value?> reactionGroups, - Value parentId, - Value quotedMessageId, - Value pollId, - Value replyCount, - Value showInChannel, - Value shadowed, - Value command, - Value localCreatedAt, - Value remoteCreatedAt, - Value localUpdatedAt, - Value remoteUpdatedAt, - Value localDeletedAt, - Value remoteDeletedAt, - Value messageTextUpdatedAt, - Value userId, - Value channelRole, - Value pinned, - Value pinnedAt, - Value pinExpires, - Value pinnedByUserId, - Value channelCid, - Value?> i18n, - Value?> restrictedVisibility, - Value?> extraData, - Value rowid, -}); - -final class $$MessagesTableReferences - extends BaseReferences<_$DriftChatDatabase, $MessagesTable, MessageEntity> { + bool readsRefs, + }) + >; +typedef $$MessagesTableCreateCompanionBuilder = + MessagesCompanion Function({ + required String id, + Value messageText, + required List attachments, + required String state, + Value type, + required List mentionedUsers, + Value?> reactionGroups, + Value parentId, + Value quotedMessageId, + Value pollId, + Value replyCount, + Value showInChannel, + Value shadowed, + Value command, + Value localCreatedAt, + Value remoteCreatedAt, + Value localUpdatedAt, + Value remoteUpdatedAt, + Value localDeletedAt, + Value remoteDeletedAt, + Value deletedForMe, + Value messageTextUpdatedAt, + Value userId, + Value channelRole, + Value pinned, + Value pinnedAt, + Value pinExpires, + Value pinnedByUserId, + required String channelCid, + Value?> i18n, + Value?> restrictedVisibility, + Value?> extraData, + Value rowid, + }); +typedef $$MessagesTableUpdateCompanionBuilder = + MessagesCompanion Function({ + Value id, + Value messageText, + Value> attachments, + Value state, + Value type, + Value> mentionedUsers, + Value?> reactionGroups, + Value parentId, + Value quotedMessageId, + Value pollId, + Value replyCount, + Value showInChannel, + Value shadowed, + Value command, + Value localCreatedAt, + Value remoteCreatedAt, + Value localUpdatedAt, + Value remoteUpdatedAt, + Value localDeletedAt, + Value remoteDeletedAt, + Value deletedForMe, + Value messageTextUpdatedAt, + Value userId, + Value channelRole, + Value pinned, + Value pinnedAt, + Value pinExpires, + Value pinnedByUserId, + Value channelCid, + Value?> i18n, + Value?> restrictedVisibility, + Value?> extraData, + Value rowid, + }); + +final class $$MessagesTableReferences extends BaseReferences<_$DriftChatDatabase, $MessagesTable, MessageEntity> { $$MessagesTableReferences(super.$_db, super.$_table, super.$_typedResult); static $ChannelsTable _channelCidTable(_$DriftChatDatabase db) => - db.channels.createAlias( - $_aliasNameGenerator(db.messages.channelCid, db.channels.cid)); + db.channels.createAlias($_aliasNameGenerator(db.messages.channelCid, db.channels.cid)); $$ChannelsTableProcessedTableManager get channelCid { - final manager = $$ChannelsTableTableManager($_db, $_db.channels) - .filter((f) => f.cid($_item.channelCid!)); + final $_column = $_itemColumn('channel_cid')!; + + final manager = $$ChannelsTableTableManager($_db, $_db.channels).filter((f) => f.cid.sqlEquals($_column)); final item = $_typedResult.readTableOrNull(_channelCidTable($_db)); if (item == null) return manager; - return ProcessedTableManager( - manager.$state.copyWith(prefetchedData: [item])); + return ProcessedTableManager(manager.$state.copyWith(prefetchedData: [item])); } - static MultiTypedResultKey<$DraftMessagesTable, List> - _draftMessagesRefsTable(_$DriftChatDatabase db) => - MultiTypedResultKey.fromTable(db.draftMessages, - aliasName: $_aliasNameGenerator( - db.messages.id, db.draftMessages.parentId)); + static MultiTypedResultKey<$DraftMessagesTable, List> _draftMessagesRefsTable( + _$DriftChatDatabase db, + ) => MultiTypedResultKey.fromTable( + db.draftMessages, + aliasName: $_aliasNameGenerator(db.messages.id, db.draftMessages.parentId), + ); $$DraftMessagesTableProcessedTableManager get draftMessagesRefs { - final manager = $$DraftMessagesTableTableManager($_db, $_db.draftMessages) - .filter((f) => f.parentId.id($_item.id)); + final manager = $$DraftMessagesTableTableManager( + $_db, + $_db.draftMessages, + ).filter((f) => f.parentId.id.sqlEquals($_itemColumn('id')!)); final cache = $_typedResult.readTableOrNull(_draftMessagesRefsTable($_db)); - return ProcessedTableManager( - manager.$state.copyWith(prefetchedData: cache)); + return ProcessedTableManager(manager.$state.copyWith(prefetchedData: cache)); + } + + static MultiTypedResultKey<$LocationsTable, List> _locationsRefsTable(_$DriftChatDatabase db) => + MultiTypedResultKey.fromTable( + db.locations, + aliasName: $_aliasNameGenerator(db.messages.id, db.locations.messageId), + ); + + $$LocationsTableProcessedTableManager get locationsRefs { + final manager = $$LocationsTableTableManager( + $_db, + $_db.locations, + ).filter((f) => f.messageId.id.sqlEquals($_itemColumn('id')!)); + + final cache = $_typedResult.readTableOrNull(_locationsRefsTable($_db)); + return ProcessedTableManager(manager.$state.copyWith(prefetchedData: cache)); } - static MultiTypedResultKey<$ReactionsTable, List> - _reactionsRefsTable(_$DriftChatDatabase db) => - MultiTypedResultKey.fromTable(db.reactions, - aliasName: - $_aliasNameGenerator(db.messages.id, db.reactions.messageId)); + static MultiTypedResultKey<$ReactionsTable, List> _reactionsRefsTable(_$DriftChatDatabase db) => + MultiTypedResultKey.fromTable( + db.reactions, + aliasName: $_aliasNameGenerator(db.messages.id, db.reactions.messageId), + ); $$ReactionsTableProcessedTableManager get reactionsRefs { - final manager = $$ReactionsTableTableManager($_db, $_db.reactions) - .filter((f) => f.messageId.id($_item.id)); + final manager = $$ReactionsTableTableManager( + $_db, + $_db.reactions, + ).filter((f) => f.messageId.id.sqlEquals($_itemColumn('id')!)); final cache = $_typedResult.readTableOrNull(_reactionsRefsTable($_db)); - return ProcessedTableManager( - manager.$state.copyWith(prefetchedData: cache)); + return ProcessedTableManager(manager.$state.copyWith(prefetchedData: cache)); } } -class $$MessagesTableFilterComposer - extends Composer<_$DriftChatDatabase, $MessagesTable> { +class $$MessagesTableFilterComposer extends Composer<_$DriftChatDatabase, $MessagesTable> { $$MessagesTableFilterComposer({ required super.$db, required super.$table, @@ -9544,185 +10162,173 @@ class $$MessagesTableFilterComposer super.$addJoinBuilderToRootComposer, super.$removeJoinBuilderFromRootComposer, }); - ColumnFilters get id => $composableBuilder( - column: $table.id, builder: (column) => ColumnFilters(column)); + ColumnFilters get id => $composableBuilder(column: $table.id, builder: (column) => ColumnFilters(column)); - ColumnFilters get messageText => $composableBuilder( - column: $table.messageText, builder: (column) => ColumnFilters(column)); + ColumnFilters get messageText => + $composableBuilder(column: $table.messageText, builder: (column) => ColumnFilters(column)); - ColumnWithTypeConverterFilters, List, String> - get attachments => $composableBuilder( - column: $table.attachments, - builder: (column) => ColumnWithTypeConverterFilters(column)); + ColumnWithTypeConverterFilters, List, String> get attachments => + $composableBuilder(column: $table.attachments, builder: (column) => ColumnWithTypeConverterFilters(column)); - ColumnFilters get state => $composableBuilder( - column: $table.state, builder: (column) => ColumnFilters(column)); + ColumnFilters get state => + $composableBuilder(column: $table.state, builder: (column) => ColumnFilters(column)); - ColumnFilters get type => $composableBuilder( - column: $table.type, builder: (column) => ColumnFilters(column)); + ColumnFilters get type => $composableBuilder(column: $table.type, builder: (column) => ColumnFilters(column)); - ColumnWithTypeConverterFilters, List, String> - get mentionedUsers => $composableBuilder( - column: $table.mentionedUsers, - builder: (column) => ColumnWithTypeConverterFilters(column)); + ColumnWithTypeConverterFilters, List, String> get mentionedUsers => + $composableBuilder(column: $table.mentionedUsers, builder: (column) => ColumnWithTypeConverterFilters(column)); - ColumnWithTypeConverterFilters?, - Map, String> - get reactionGroups => $composableBuilder( - column: $table.reactionGroups, - builder: (column) => ColumnWithTypeConverterFilters(column)); + ColumnWithTypeConverterFilters?, Map, String> get reactionGroups => + $composableBuilder(column: $table.reactionGroups, builder: (column) => ColumnWithTypeConverterFilters(column)); - ColumnFilters get parentId => $composableBuilder( - column: $table.parentId, builder: (column) => ColumnFilters(column)); + ColumnFilters get parentId => + $composableBuilder(column: $table.parentId, builder: (column) => ColumnFilters(column)); - ColumnFilters get quotedMessageId => $composableBuilder( - column: $table.quotedMessageId, - builder: (column) => ColumnFilters(column)); + ColumnFilters get quotedMessageId => + $composableBuilder(column: $table.quotedMessageId, builder: (column) => ColumnFilters(column)); - ColumnFilters get pollId => $composableBuilder( - column: $table.pollId, builder: (column) => ColumnFilters(column)); + ColumnFilters get pollId => + $composableBuilder(column: $table.pollId, builder: (column) => ColumnFilters(column)); - ColumnFilters get replyCount => $composableBuilder( - column: $table.replyCount, builder: (column) => ColumnFilters(column)); + ColumnFilters get replyCount => + $composableBuilder(column: $table.replyCount, builder: (column) => ColumnFilters(column)); - ColumnFilters get showInChannel => $composableBuilder( - column: $table.showInChannel, builder: (column) => ColumnFilters(column)); + ColumnFilters get showInChannel => + $composableBuilder(column: $table.showInChannel, builder: (column) => ColumnFilters(column)); - ColumnFilters get shadowed => $composableBuilder( - column: $table.shadowed, builder: (column) => ColumnFilters(column)); + ColumnFilters get shadowed => + $composableBuilder(column: $table.shadowed, builder: (column) => ColumnFilters(column)); - ColumnFilters get command => $composableBuilder( - column: $table.command, builder: (column) => ColumnFilters(column)); + ColumnFilters get command => + $composableBuilder(column: $table.command, builder: (column) => ColumnFilters(column)); - ColumnFilters get localCreatedAt => $composableBuilder( - column: $table.localCreatedAt, - builder: (column) => ColumnFilters(column)); + ColumnFilters get localCreatedAt => + $composableBuilder(column: $table.localCreatedAt, builder: (column) => ColumnFilters(column)); - ColumnFilters get remoteCreatedAt => $composableBuilder( - column: $table.remoteCreatedAt, - builder: (column) => ColumnFilters(column)); + ColumnFilters get remoteCreatedAt => + $composableBuilder(column: $table.remoteCreatedAt, builder: (column) => ColumnFilters(column)); - ColumnFilters get localUpdatedAt => $composableBuilder( - column: $table.localUpdatedAt, - builder: (column) => ColumnFilters(column)); + ColumnFilters get localUpdatedAt => + $composableBuilder(column: $table.localUpdatedAt, builder: (column) => ColumnFilters(column)); - ColumnFilters get remoteUpdatedAt => $composableBuilder( - column: $table.remoteUpdatedAt, - builder: (column) => ColumnFilters(column)); + ColumnFilters get remoteUpdatedAt => + $composableBuilder(column: $table.remoteUpdatedAt, builder: (column) => ColumnFilters(column)); - ColumnFilters get localDeletedAt => $composableBuilder( - column: $table.localDeletedAt, - builder: (column) => ColumnFilters(column)); + ColumnFilters get localDeletedAt => + $composableBuilder(column: $table.localDeletedAt, builder: (column) => ColumnFilters(column)); - ColumnFilters get remoteDeletedAt => $composableBuilder( - column: $table.remoteDeletedAt, - builder: (column) => ColumnFilters(column)); + ColumnFilters get remoteDeletedAt => + $composableBuilder(column: $table.remoteDeletedAt, builder: (column) => ColumnFilters(column)); - ColumnFilters get messageTextUpdatedAt => $composableBuilder( - column: $table.messageTextUpdatedAt, - builder: (column) => ColumnFilters(column)); + ColumnFilters get deletedForMe => + $composableBuilder(column: $table.deletedForMe, builder: (column) => ColumnFilters(column)); - ColumnFilters get userId => $composableBuilder( - column: $table.userId, builder: (column) => ColumnFilters(column)); + ColumnFilters get messageTextUpdatedAt => + $composableBuilder(column: $table.messageTextUpdatedAt, builder: (column) => ColumnFilters(column)); - ColumnFilters get channelRole => $composableBuilder( - column: $table.channelRole, builder: (column) => ColumnFilters(column)); + ColumnFilters get userId => + $composableBuilder(column: $table.userId, builder: (column) => ColumnFilters(column)); - ColumnFilters get pinned => $composableBuilder( - column: $table.pinned, builder: (column) => ColumnFilters(column)); + ColumnFilters get channelRole => + $composableBuilder(column: $table.channelRole, builder: (column) => ColumnFilters(column)); - ColumnFilters get pinnedAt => $composableBuilder( - column: $table.pinnedAt, builder: (column) => ColumnFilters(column)); + ColumnFilters get pinned => + $composableBuilder(column: $table.pinned, builder: (column) => ColumnFilters(column)); - ColumnFilters get pinExpires => $composableBuilder( - column: $table.pinExpires, builder: (column) => ColumnFilters(column)); + ColumnFilters get pinnedAt => + $composableBuilder(column: $table.pinnedAt, builder: (column) => ColumnFilters(column)); - ColumnFilters get pinnedByUserId => $composableBuilder( - column: $table.pinnedByUserId, - builder: (column) => ColumnFilters(column)); + ColumnFilters get pinExpires => + $composableBuilder(column: $table.pinExpires, builder: (column) => ColumnFilters(column)); - ColumnWithTypeConverterFilters?, Map, - String> - get i18n => $composableBuilder( - column: $table.i18n, - builder: (column) => ColumnWithTypeConverterFilters(column)); + ColumnFilters get pinnedByUserId => + $composableBuilder(column: $table.pinnedByUserId, builder: (column) => ColumnFilters(column)); - ColumnWithTypeConverterFilters?, List, String> - get restrictedVisibility => $composableBuilder( - column: $table.restrictedVisibility, - builder: (column) => ColumnWithTypeConverterFilters(column)); + ColumnWithTypeConverterFilters?, Map, String> get i18n => + $composableBuilder(column: $table.i18n, builder: (column) => ColumnWithTypeConverterFilters(column)); - ColumnWithTypeConverterFilters?, Map, - String> - get extraData => $composableBuilder( - column: $table.extraData, - builder: (column) => ColumnWithTypeConverterFilters(column)); + ColumnWithTypeConverterFilters?, List, String> get restrictedVisibility => $composableBuilder( + column: $table.restrictedVisibility, + builder: (column) => ColumnWithTypeConverterFilters(column), + ); + + ColumnWithTypeConverterFilters?, Map, String> get extraData => + $composableBuilder(column: $table.extraData, builder: (column) => ColumnWithTypeConverterFilters(column)); $$ChannelsTableFilterComposer get channelCid { final $$ChannelsTableFilterComposer composer = $composerBuilder( - composer: this, - getCurrentColumn: (t) => t.channelCid, - referencedTable: $db.channels, - getReferencedColumn: (t) => t.cid, - builder: (joinBuilder, - {$addJoinBuilderToRootComposer, - $removeJoinBuilderFromRootComposer}) => - $$ChannelsTableFilterComposer( - $db: $db, - $table: $db.channels, - $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, - joinBuilder: joinBuilder, - $removeJoinBuilderFromRootComposer: - $removeJoinBuilderFromRootComposer, - )); + composer: this, + getCurrentColumn: (t) => t.channelCid, + referencedTable: $db.channels, + getReferencedColumn: (t) => t.cid, + builder: (joinBuilder, {$addJoinBuilderToRootComposer, $removeJoinBuilderFromRootComposer}) => + $$ChannelsTableFilterComposer( + $db: $db, + $table: $db.channels, + $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, + joinBuilder: joinBuilder, + $removeJoinBuilderFromRootComposer: $removeJoinBuilderFromRootComposer, + ), + ); return composer; } - Expression draftMessagesRefs( - Expression Function($$DraftMessagesTableFilterComposer f) f) { + Expression draftMessagesRefs(Expression Function($$DraftMessagesTableFilterComposer f) f) { final $$DraftMessagesTableFilterComposer composer = $composerBuilder( - composer: this, - getCurrentColumn: (t) => t.id, - referencedTable: $db.draftMessages, - getReferencedColumn: (t) => t.parentId, - builder: (joinBuilder, - {$addJoinBuilderToRootComposer, - $removeJoinBuilderFromRootComposer}) => - $$DraftMessagesTableFilterComposer( - $db: $db, - $table: $db.draftMessages, - $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, - joinBuilder: joinBuilder, - $removeJoinBuilderFromRootComposer: - $removeJoinBuilderFromRootComposer, - )); + composer: this, + getCurrentColumn: (t) => t.id, + referencedTable: $db.draftMessages, + getReferencedColumn: (t) => t.parentId, + builder: (joinBuilder, {$addJoinBuilderToRootComposer, $removeJoinBuilderFromRootComposer}) => + $$DraftMessagesTableFilterComposer( + $db: $db, + $table: $db.draftMessages, + $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, + joinBuilder: joinBuilder, + $removeJoinBuilderFromRootComposer: $removeJoinBuilderFromRootComposer, + ), + ); + return f(composer); + } + + Expression locationsRefs(Expression Function($$LocationsTableFilterComposer f) f) { + final $$LocationsTableFilterComposer composer = $composerBuilder( + composer: this, + getCurrentColumn: (t) => t.id, + referencedTable: $db.locations, + getReferencedColumn: (t) => t.messageId, + builder: (joinBuilder, {$addJoinBuilderToRootComposer, $removeJoinBuilderFromRootComposer}) => + $$LocationsTableFilterComposer( + $db: $db, + $table: $db.locations, + $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, + joinBuilder: joinBuilder, + $removeJoinBuilderFromRootComposer: $removeJoinBuilderFromRootComposer, + ), + ); return f(composer); } - Expression reactionsRefs( - Expression Function($$ReactionsTableFilterComposer f) f) { + Expression reactionsRefs(Expression Function($$ReactionsTableFilterComposer f) f) { final $$ReactionsTableFilterComposer composer = $composerBuilder( - composer: this, - getCurrentColumn: (t) => t.id, - referencedTable: $db.reactions, - getReferencedColumn: (t) => t.messageId, - builder: (joinBuilder, - {$addJoinBuilderToRootComposer, - $removeJoinBuilderFromRootComposer}) => - $$ReactionsTableFilterComposer( - $db: $db, - $table: $db.reactions, - $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, - joinBuilder: joinBuilder, - $removeJoinBuilderFromRootComposer: - $removeJoinBuilderFromRootComposer, - )); + composer: this, + getCurrentColumn: (t) => t.id, + referencedTable: $db.reactions, + getReferencedColumn: (t) => t.messageId, + builder: (joinBuilder, {$addJoinBuilderToRootComposer, $removeJoinBuilderFromRootComposer}) => + $$ReactionsTableFilterComposer( + $db: $db, + $table: $db.reactions, + $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, + joinBuilder: joinBuilder, + $removeJoinBuilderFromRootComposer: $removeJoinBuilderFromRootComposer, + ), + ); return f(composer); } } -class $$MessagesTableOrderingComposer - extends Composer<_$DriftChatDatabase, $MessagesTable> { +class $$MessagesTableOrderingComposer extends Composer<_$DriftChatDatabase, $MessagesTable> { $$MessagesTableOrderingComposer({ required super.$db, required super.$table, @@ -9730,132 +10336,118 @@ class $$MessagesTableOrderingComposer super.$addJoinBuilderToRootComposer, super.$removeJoinBuilderFromRootComposer, }); - ColumnOrderings get id => $composableBuilder( - column: $table.id, builder: (column) => ColumnOrderings(column)); + ColumnOrderings get id => $composableBuilder(column: $table.id, builder: (column) => ColumnOrderings(column)); - ColumnOrderings get messageText => $composableBuilder( - column: $table.messageText, builder: (column) => ColumnOrderings(column)); + ColumnOrderings get messageText => + $composableBuilder(column: $table.messageText, builder: (column) => ColumnOrderings(column)); - ColumnOrderings get attachments => $composableBuilder( - column: $table.attachments, builder: (column) => ColumnOrderings(column)); + ColumnOrderings get attachments => + $composableBuilder(column: $table.attachments, builder: (column) => ColumnOrderings(column)); - ColumnOrderings get state => $composableBuilder( - column: $table.state, builder: (column) => ColumnOrderings(column)); + ColumnOrderings get state => + $composableBuilder(column: $table.state, builder: (column) => ColumnOrderings(column)); - ColumnOrderings get type => $composableBuilder( - column: $table.type, builder: (column) => ColumnOrderings(column)); + ColumnOrderings get type => + $composableBuilder(column: $table.type, builder: (column) => ColumnOrderings(column)); - ColumnOrderings get mentionedUsers => $composableBuilder( - column: $table.mentionedUsers, - builder: (column) => ColumnOrderings(column)); + ColumnOrderings get mentionedUsers => + $composableBuilder(column: $table.mentionedUsers, builder: (column) => ColumnOrderings(column)); - ColumnOrderings get reactionGroups => $composableBuilder( - column: $table.reactionGroups, - builder: (column) => ColumnOrderings(column)); + ColumnOrderings get reactionGroups => + $composableBuilder(column: $table.reactionGroups, builder: (column) => ColumnOrderings(column)); - ColumnOrderings get parentId => $composableBuilder( - column: $table.parentId, builder: (column) => ColumnOrderings(column)); + ColumnOrderings get parentId => + $composableBuilder(column: $table.parentId, builder: (column) => ColumnOrderings(column)); - ColumnOrderings get quotedMessageId => $composableBuilder( - column: $table.quotedMessageId, - builder: (column) => ColumnOrderings(column)); + ColumnOrderings get quotedMessageId => + $composableBuilder(column: $table.quotedMessageId, builder: (column) => ColumnOrderings(column)); - ColumnOrderings get pollId => $composableBuilder( - column: $table.pollId, builder: (column) => ColumnOrderings(column)); + ColumnOrderings get pollId => + $composableBuilder(column: $table.pollId, builder: (column) => ColumnOrderings(column)); - ColumnOrderings get replyCount => $composableBuilder( - column: $table.replyCount, builder: (column) => ColumnOrderings(column)); + ColumnOrderings get replyCount => + $composableBuilder(column: $table.replyCount, builder: (column) => ColumnOrderings(column)); - ColumnOrderings get showInChannel => $composableBuilder( - column: $table.showInChannel, - builder: (column) => ColumnOrderings(column)); + ColumnOrderings get showInChannel => + $composableBuilder(column: $table.showInChannel, builder: (column) => ColumnOrderings(column)); - ColumnOrderings get shadowed => $composableBuilder( - column: $table.shadowed, builder: (column) => ColumnOrderings(column)); + ColumnOrderings get shadowed => + $composableBuilder(column: $table.shadowed, builder: (column) => ColumnOrderings(column)); - ColumnOrderings get command => $composableBuilder( - column: $table.command, builder: (column) => ColumnOrderings(column)); + ColumnOrderings get command => + $composableBuilder(column: $table.command, builder: (column) => ColumnOrderings(column)); - ColumnOrderings get localCreatedAt => $composableBuilder( - column: $table.localCreatedAt, - builder: (column) => ColumnOrderings(column)); + ColumnOrderings get localCreatedAt => + $composableBuilder(column: $table.localCreatedAt, builder: (column) => ColumnOrderings(column)); - ColumnOrderings get remoteCreatedAt => $composableBuilder( - column: $table.remoteCreatedAt, - builder: (column) => ColumnOrderings(column)); + ColumnOrderings get remoteCreatedAt => + $composableBuilder(column: $table.remoteCreatedAt, builder: (column) => ColumnOrderings(column)); - ColumnOrderings get localUpdatedAt => $composableBuilder( - column: $table.localUpdatedAt, - builder: (column) => ColumnOrderings(column)); + ColumnOrderings get localUpdatedAt => + $composableBuilder(column: $table.localUpdatedAt, builder: (column) => ColumnOrderings(column)); - ColumnOrderings get remoteUpdatedAt => $composableBuilder( - column: $table.remoteUpdatedAt, - builder: (column) => ColumnOrderings(column)); + ColumnOrderings get remoteUpdatedAt => + $composableBuilder(column: $table.remoteUpdatedAt, builder: (column) => ColumnOrderings(column)); - ColumnOrderings get localDeletedAt => $composableBuilder( - column: $table.localDeletedAt, - builder: (column) => ColumnOrderings(column)); + ColumnOrderings get localDeletedAt => + $composableBuilder(column: $table.localDeletedAt, builder: (column) => ColumnOrderings(column)); - ColumnOrderings get remoteDeletedAt => $composableBuilder( - column: $table.remoteDeletedAt, - builder: (column) => ColumnOrderings(column)); + ColumnOrderings get remoteDeletedAt => + $composableBuilder(column: $table.remoteDeletedAt, builder: (column) => ColumnOrderings(column)); - ColumnOrderings get messageTextUpdatedAt => $composableBuilder( - column: $table.messageTextUpdatedAt, - builder: (column) => ColumnOrderings(column)); + ColumnOrderings get deletedForMe => + $composableBuilder(column: $table.deletedForMe, builder: (column) => ColumnOrderings(column)); - ColumnOrderings get userId => $composableBuilder( - column: $table.userId, builder: (column) => ColumnOrderings(column)); + ColumnOrderings get messageTextUpdatedAt => + $composableBuilder(column: $table.messageTextUpdatedAt, builder: (column) => ColumnOrderings(column)); - ColumnOrderings get channelRole => $composableBuilder( - column: $table.channelRole, builder: (column) => ColumnOrderings(column)); + ColumnOrderings get userId => + $composableBuilder(column: $table.userId, builder: (column) => ColumnOrderings(column)); - ColumnOrderings get pinned => $composableBuilder( - column: $table.pinned, builder: (column) => ColumnOrderings(column)); + ColumnOrderings get channelRole => + $composableBuilder(column: $table.channelRole, builder: (column) => ColumnOrderings(column)); - ColumnOrderings get pinnedAt => $composableBuilder( - column: $table.pinnedAt, builder: (column) => ColumnOrderings(column)); + ColumnOrderings get pinned => + $composableBuilder(column: $table.pinned, builder: (column) => ColumnOrderings(column)); - ColumnOrderings get pinExpires => $composableBuilder( - column: $table.pinExpires, builder: (column) => ColumnOrderings(column)); + ColumnOrderings get pinnedAt => + $composableBuilder(column: $table.pinnedAt, builder: (column) => ColumnOrderings(column)); - ColumnOrderings get pinnedByUserId => $composableBuilder( - column: $table.pinnedByUserId, - builder: (column) => ColumnOrderings(column)); + ColumnOrderings get pinExpires => + $composableBuilder(column: $table.pinExpires, builder: (column) => ColumnOrderings(column)); - ColumnOrderings get i18n => $composableBuilder( - column: $table.i18n, builder: (column) => ColumnOrderings(column)); + ColumnOrderings get pinnedByUserId => + $composableBuilder(column: $table.pinnedByUserId, builder: (column) => ColumnOrderings(column)); - ColumnOrderings get restrictedVisibility => $composableBuilder( - column: $table.restrictedVisibility, - builder: (column) => ColumnOrderings(column)); + ColumnOrderings get i18n => + $composableBuilder(column: $table.i18n, builder: (column) => ColumnOrderings(column)); - ColumnOrderings get extraData => $composableBuilder( - column: $table.extraData, builder: (column) => ColumnOrderings(column)); + ColumnOrderings get restrictedVisibility => + $composableBuilder(column: $table.restrictedVisibility, builder: (column) => ColumnOrderings(column)); + + ColumnOrderings get extraData => + $composableBuilder(column: $table.extraData, builder: (column) => ColumnOrderings(column)); $$ChannelsTableOrderingComposer get channelCid { final $$ChannelsTableOrderingComposer composer = $composerBuilder( - composer: this, - getCurrentColumn: (t) => t.channelCid, - referencedTable: $db.channels, - getReferencedColumn: (t) => t.cid, - builder: (joinBuilder, - {$addJoinBuilderToRootComposer, - $removeJoinBuilderFromRootComposer}) => - $$ChannelsTableOrderingComposer( - $db: $db, - $table: $db.channels, - $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, - joinBuilder: joinBuilder, - $removeJoinBuilderFromRootComposer: - $removeJoinBuilderFromRootComposer, - )); + composer: this, + getCurrentColumn: (t) => t.channelCid, + referencedTable: $db.channels, + getReferencedColumn: (t) => t.cid, + builder: (joinBuilder, {$addJoinBuilderToRootComposer, $removeJoinBuilderFromRootComposer}) => + $$ChannelsTableOrderingComposer( + $db: $db, + $table: $db.channels, + $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, + joinBuilder: joinBuilder, + $removeJoinBuilderFromRootComposer: $removeJoinBuilderFromRootComposer, + ), + ); return composer; } } -class $$MessagesTableAnnotationComposer - extends Composer<_$DriftChatDatabase, $MessagesTable> { +class $$MessagesTableAnnotationComposer extends Composer<_$DriftChatDatabase, $MessagesTable> { $$MessagesTableAnnotationComposer({ required super.$db, required super.$table, @@ -9863,341 +10455,817 @@ class $$MessagesTableAnnotationComposer super.$addJoinBuilderToRootComposer, super.$removeJoinBuilderFromRootComposer, }); - GeneratedColumn get id => - $composableBuilder(column: $table.id, builder: (column) => column); + GeneratedColumn get id => $composableBuilder(column: $table.id, builder: (column) => column); - GeneratedColumn get messageText => $composableBuilder( - column: $table.messageText, builder: (column) => column); + GeneratedColumn get messageText => + $composableBuilder(column: $table.messageText, builder: (column) => column); GeneratedColumnWithTypeConverter, String> get attachments => - $composableBuilder( - column: $table.attachments, builder: (column) => column); + $composableBuilder(column: $table.attachments, builder: (column) => column); - GeneratedColumn get state => - $composableBuilder(column: $table.state, builder: (column) => column); + GeneratedColumn get state => $composableBuilder(column: $table.state, builder: (column) => column); - GeneratedColumn get type => - $composableBuilder(column: $table.type, builder: (column) => column); + GeneratedColumn get type => $composableBuilder(column: $table.type, builder: (column) => column); GeneratedColumnWithTypeConverter, String> get mentionedUsers => - $composableBuilder( - column: $table.mentionedUsers, builder: (column) => column); + $composableBuilder(column: $table.mentionedUsers, builder: (column) => column); + + GeneratedColumnWithTypeConverter?, String> get reactionGroups => + $composableBuilder(column: $table.reactionGroups, builder: (column) => column); + + GeneratedColumn get parentId => $composableBuilder(column: $table.parentId, builder: (column) => column); + + GeneratedColumn get quotedMessageId => + $composableBuilder(column: $table.quotedMessageId, builder: (column) => column); - GeneratedColumnWithTypeConverter?, String> - get reactionGroups => $composableBuilder( - column: $table.reactionGroups, builder: (column) => column); + GeneratedColumn get pollId => $composableBuilder(column: $table.pollId, builder: (column) => column); - GeneratedColumn get parentId => - $composableBuilder(column: $table.parentId, builder: (column) => column); + GeneratedColumn get replyCount => $composableBuilder(column: $table.replyCount, builder: (column) => column); - GeneratedColumn get quotedMessageId => $composableBuilder( - column: $table.quotedMessageId, builder: (column) => column); + GeneratedColumn get showInChannel => + $composableBuilder(column: $table.showInChannel, builder: (column) => column); - GeneratedColumn get pollId => - $composableBuilder(column: $table.pollId, builder: (column) => column); + GeneratedColumn get shadowed => $composableBuilder(column: $table.shadowed, builder: (column) => column); - GeneratedColumn get replyCount => $composableBuilder( - column: $table.replyCount, builder: (column) => column); + GeneratedColumn get command => $composableBuilder(column: $table.command, builder: (column) => column); - GeneratedColumn get showInChannel => $composableBuilder( - column: $table.showInChannel, builder: (column) => column); + GeneratedColumn get localCreatedAt => + $composableBuilder(column: $table.localCreatedAt, builder: (column) => column); - GeneratedColumn get shadowed => - $composableBuilder(column: $table.shadowed, builder: (column) => column); + GeneratedColumn get remoteCreatedAt => + $composableBuilder(column: $table.remoteCreatedAt, builder: (column) => column); - GeneratedColumn get command => - $composableBuilder(column: $table.command, builder: (column) => column); + GeneratedColumn get localUpdatedAt => + $composableBuilder(column: $table.localUpdatedAt, builder: (column) => column); - GeneratedColumn get localCreatedAt => $composableBuilder( - column: $table.localCreatedAt, builder: (column) => column); + GeneratedColumn get remoteUpdatedAt => + $composableBuilder(column: $table.remoteUpdatedAt, builder: (column) => column); - GeneratedColumn get remoteCreatedAt => $composableBuilder( - column: $table.remoteCreatedAt, builder: (column) => column); + GeneratedColumn get localDeletedAt => + $composableBuilder(column: $table.localDeletedAt, builder: (column) => column); - GeneratedColumn get localUpdatedAt => $composableBuilder( - column: $table.localUpdatedAt, builder: (column) => column); + GeneratedColumn get remoteDeletedAt => + $composableBuilder(column: $table.remoteDeletedAt, builder: (column) => column); + + GeneratedColumn get deletedForMe => + $composableBuilder(column: $table.deletedForMe, builder: (column) => column); + + GeneratedColumn get messageTextUpdatedAt => + $composableBuilder(column: $table.messageTextUpdatedAt, builder: (column) => column); + + GeneratedColumn get userId => $composableBuilder(column: $table.userId, builder: (column) => column); + + GeneratedColumn get channelRole => + $composableBuilder(column: $table.channelRole, builder: (column) => column); + + GeneratedColumn get pinned => $composableBuilder(column: $table.pinned, builder: (column) => column); + + GeneratedColumn get pinnedAt => $composableBuilder(column: $table.pinnedAt, builder: (column) => column); + + GeneratedColumn get pinExpires => + $composableBuilder(column: $table.pinExpires, builder: (column) => column); + + GeneratedColumn get pinnedByUserId => + $composableBuilder(column: $table.pinnedByUserId, builder: (column) => column); + + GeneratedColumnWithTypeConverter?, String> get i18n => + $composableBuilder(column: $table.i18n, builder: (column) => column); + + GeneratedColumnWithTypeConverter?, String> get restrictedVisibility => + $composableBuilder(column: $table.restrictedVisibility, builder: (column) => column); + + GeneratedColumnWithTypeConverter?, String> get extraData => + $composableBuilder(column: $table.extraData, builder: (column) => column); + + $$ChannelsTableAnnotationComposer get channelCid { + final $$ChannelsTableAnnotationComposer composer = $composerBuilder( + composer: this, + getCurrentColumn: (t) => t.channelCid, + referencedTable: $db.channels, + getReferencedColumn: (t) => t.cid, + builder: (joinBuilder, {$addJoinBuilderToRootComposer, $removeJoinBuilderFromRootComposer}) => + $$ChannelsTableAnnotationComposer( + $db: $db, + $table: $db.channels, + $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, + joinBuilder: joinBuilder, + $removeJoinBuilderFromRootComposer: $removeJoinBuilderFromRootComposer, + ), + ); + return composer; + } + + Expression draftMessagesRefs( + Expression Function($$DraftMessagesTableAnnotationComposer a) f, + ) { + final $$DraftMessagesTableAnnotationComposer composer = $composerBuilder( + composer: this, + getCurrentColumn: (t) => t.id, + referencedTable: $db.draftMessages, + getReferencedColumn: (t) => t.parentId, + builder: (joinBuilder, {$addJoinBuilderToRootComposer, $removeJoinBuilderFromRootComposer}) => + $$DraftMessagesTableAnnotationComposer( + $db: $db, + $table: $db.draftMessages, + $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, + joinBuilder: joinBuilder, + $removeJoinBuilderFromRootComposer: $removeJoinBuilderFromRootComposer, + ), + ); + return f(composer); + } + + Expression locationsRefs(Expression Function($$LocationsTableAnnotationComposer a) f) { + final $$LocationsTableAnnotationComposer composer = $composerBuilder( + composer: this, + getCurrentColumn: (t) => t.id, + referencedTable: $db.locations, + getReferencedColumn: (t) => t.messageId, + builder: (joinBuilder, {$addJoinBuilderToRootComposer, $removeJoinBuilderFromRootComposer}) => + $$LocationsTableAnnotationComposer( + $db: $db, + $table: $db.locations, + $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, + joinBuilder: joinBuilder, + $removeJoinBuilderFromRootComposer: $removeJoinBuilderFromRootComposer, + ), + ); + return f(composer); + } + + Expression reactionsRefs(Expression Function($$ReactionsTableAnnotationComposer a) f) { + final $$ReactionsTableAnnotationComposer composer = $composerBuilder( + composer: this, + getCurrentColumn: (t) => t.id, + referencedTable: $db.reactions, + getReferencedColumn: (t) => t.messageId, + builder: (joinBuilder, {$addJoinBuilderToRootComposer, $removeJoinBuilderFromRootComposer}) => + $$ReactionsTableAnnotationComposer( + $db: $db, + $table: $db.reactions, + $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, + joinBuilder: joinBuilder, + $removeJoinBuilderFromRootComposer: $removeJoinBuilderFromRootComposer, + ), + ); + return f(composer); + } +} + +class $$MessagesTableTableManager + extends + RootTableManager< + _$DriftChatDatabase, + $MessagesTable, + MessageEntity, + $$MessagesTableFilterComposer, + $$MessagesTableOrderingComposer, + $$MessagesTableAnnotationComposer, + $$MessagesTableCreateCompanionBuilder, + $$MessagesTableUpdateCompanionBuilder, + (MessageEntity, $$MessagesTableReferences), + MessageEntity, + PrefetchHooks Function({bool channelCid, bool draftMessagesRefs, bool locationsRefs, bool reactionsRefs}) + > { + $$MessagesTableTableManager(_$DriftChatDatabase db, $MessagesTable table) + : super( + TableManagerState( + db: db, + table: table, + createFilteringComposer: () => $$MessagesTableFilterComposer($db: db, $table: table), + createOrderingComposer: () => $$MessagesTableOrderingComposer($db: db, $table: table), + createComputedFieldComposer: () => $$MessagesTableAnnotationComposer($db: db, $table: table), + updateCompanionCallback: + ({ + Value id = const Value.absent(), + Value messageText = const Value.absent(), + Value> attachments = const Value.absent(), + Value state = const Value.absent(), + Value type = const Value.absent(), + Value> mentionedUsers = const Value.absent(), + Value?> reactionGroups = const Value.absent(), + Value parentId = const Value.absent(), + Value quotedMessageId = const Value.absent(), + Value pollId = const Value.absent(), + Value replyCount = const Value.absent(), + Value showInChannel = const Value.absent(), + Value shadowed = const Value.absent(), + Value command = const Value.absent(), + Value localCreatedAt = const Value.absent(), + Value remoteCreatedAt = const Value.absent(), + Value localUpdatedAt = const Value.absent(), + Value remoteUpdatedAt = const Value.absent(), + Value localDeletedAt = const Value.absent(), + Value remoteDeletedAt = const Value.absent(), + Value deletedForMe = const Value.absent(), + Value messageTextUpdatedAt = const Value.absent(), + Value userId = const Value.absent(), + Value channelRole = const Value.absent(), + Value pinned = const Value.absent(), + Value pinnedAt = const Value.absent(), + Value pinExpires = const Value.absent(), + Value pinnedByUserId = const Value.absent(), + Value channelCid = const Value.absent(), + Value?> i18n = const Value.absent(), + Value?> restrictedVisibility = const Value.absent(), + Value?> extraData = const Value.absent(), + Value rowid = const Value.absent(), + }) => MessagesCompanion( + id: id, + messageText: messageText, + attachments: attachments, + state: state, + type: type, + mentionedUsers: mentionedUsers, + reactionGroups: reactionGroups, + parentId: parentId, + quotedMessageId: quotedMessageId, + pollId: pollId, + replyCount: replyCount, + showInChannel: showInChannel, + shadowed: shadowed, + command: command, + localCreatedAt: localCreatedAt, + remoteCreatedAt: remoteCreatedAt, + localUpdatedAt: localUpdatedAt, + remoteUpdatedAt: remoteUpdatedAt, + localDeletedAt: localDeletedAt, + remoteDeletedAt: remoteDeletedAt, + deletedForMe: deletedForMe, + messageTextUpdatedAt: messageTextUpdatedAt, + userId: userId, + channelRole: channelRole, + pinned: pinned, + pinnedAt: pinnedAt, + pinExpires: pinExpires, + pinnedByUserId: pinnedByUserId, + channelCid: channelCid, + i18n: i18n, + restrictedVisibility: restrictedVisibility, + extraData: extraData, + rowid: rowid, + ), + createCompanionCallback: + ({ + required String id, + Value messageText = const Value.absent(), + required List attachments, + required String state, + Value type = const Value.absent(), + required List mentionedUsers, + Value?> reactionGroups = const Value.absent(), + Value parentId = const Value.absent(), + Value quotedMessageId = const Value.absent(), + Value pollId = const Value.absent(), + Value replyCount = const Value.absent(), + Value showInChannel = const Value.absent(), + Value shadowed = const Value.absent(), + Value command = const Value.absent(), + Value localCreatedAt = const Value.absent(), + Value remoteCreatedAt = const Value.absent(), + Value localUpdatedAt = const Value.absent(), + Value remoteUpdatedAt = const Value.absent(), + Value localDeletedAt = const Value.absent(), + Value remoteDeletedAt = const Value.absent(), + Value deletedForMe = const Value.absent(), + Value messageTextUpdatedAt = const Value.absent(), + Value userId = const Value.absent(), + Value channelRole = const Value.absent(), + Value pinned = const Value.absent(), + Value pinnedAt = const Value.absent(), + Value pinExpires = const Value.absent(), + Value pinnedByUserId = const Value.absent(), + required String channelCid, + Value?> i18n = const Value.absent(), + Value?> restrictedVisibility = const Value.absent(), + Value?> extraData = const Value.absent(), + Value rowid = const Value.absent(), + }) => MessagesCompanion.insert( + id: id, + messageText: messageText, + attachments: attachments, + state: state, + type: type, + mentionedUsers: mentionedUsers, + reactionGroups: reactionGroups, + parentId: parentId, + quotedMessageId: quotedMessageId, + pollId: pollId, + replyCount: replyCount, + showInChannel: showInChannel, + shadowed: shadowed, + command: command, + localCreatedAt: localCreatedAt, + remoteCreatedAt: remoteCreatedAt, + localUpdatedAt: localUpdatedAt, + remoteUpdatedAt: remoteUpdatedAt, + localDeletedAt: localDeletedAt, + remoteDeletedAt: remoteDeletedAt, + deletedForMe: deletedForMe, + messageTextUpdatedAt: messageTextUpdatedAt, + userId: userId, + channelRole: channelRole, + pinned: pinned, + pinnedAt: pinnedAt, + pinExpires: pinExpires, + pinnedByUserId: pinnedByUserId, + channelCid: channelCid, + i18n: i18n, + restrictedVisibility: restrictedVisibility, + extraData: extraData, + rowid: rowid, + ), + withReferenceMapper: (p0) => + p0.map((e) => (e.readTable(table), $$MessagesTableReferences(db, table, e))).toList(), + prefetchHooksCallback: + ({channelCid = false, draftMessagesRefs = false, locationsRefs = false, reactionsRefs = false}) { + return PrefetchHooks( + db: db, + explicitlyWatchedTables: [ + if (draftMessagesRefs) db.draftMessages, + if (locationsRefs) db.locations, + if (reactionsRefs) db.reactions, + ], + addJoins: + < + T extends TableManagerState< + dynamic, + dynamic, + dynamic, + dynamic, + dynamic, + dynamic, + dynamic, + dynamic, + dynamic, + dynamic, + dynamic + > + >(state) { + if (channelCid) { + state = + state.withJoin( + currentTable: table, + currentColumn: table.channelCid, + referencedTable: $$MessagesTableReferences._channelCidTable(db), + referencedColumn: $$MessagesTableReferences._channelCidTable(db).cid, + ) + as T; + } + + return state; + }, + getPrefetchedDataCallback: (items) async { + return [ + if (draftMessagesRefs) + await $_getPrefetchedData( + currentTable: table, + referencedTable: $$MessagesTableReferences._draftMessagesRefsTable(db), + managerFromTypedResult: (p0) => $$MessagesTableReferences(db, table, p0).draftMessagesRefs, + referencedItemsForCurrentItem: (item, referencedItems) => + referencedItems.where((e) => e.parentId == item.id), + typedResults: items, + ), + if (locationsRefs) + await $_getPrefetchedData( + currentTable: table, + referencedTable: $$MessagesTableReferences._locationsRefsTable(db), + managerFromTypedResult: (p0) => $$MessagesTableReferences(db, table, p0).locationsRefs, + referencedItemsForCurrentItem: (item, referencedItems) => + referencedItems.where((e) => e.messageId == item.id), + typedResults: items, + ), + if (reactionsRefs) + await $_getPrefetchedData( + currentTable: table, + referencedTable: $$MessagesTableReferences._reactionsRefsTable(db), + managerFromTypedResult: (p0) => $$MessagesTableReferences(db, table, p0).reactionsRefs, + referencedItemsForCurrentItem: (item, referencedItems) => + referencedItems.where((e) => e.messageId == item.id), + typedResults: items, + ), + ]; + }, + ); + }, + ), + ); +} + +typedef $$MessagesTableProcessedTableManager = + ProcessedTableManager< + _$DriftChatDatabase, + $MessagesTable, + MessageEntity, + $$MessagesTableFilterComposer, + $$MessagesTableOrderingComposer, + $$MessagesTableAnnotationComposer, + $$MessagesTableCreateCompanionBuilder, + $$MessagesTableUpdateCompanionBuilder, + (MessageEntity, $$MessagesTableReferences), + MessageEntity, + PrefetchHooks Function({bool channelCid, bool draftMessagesRefs, bool locationsRefs, bool reactionsRefs}) + >; +typedef $$DraftMessagesTableCreateCompanionBuilder = + DraftMessagesCompanion Function({ + required String id, + Value messageText, + required List attachments, + Value type, + required List mentionedUsers, + Value parentId, + Value quotedMessageId, + Value pollId, + Value showInChannel, + Value command, + Value silent, + Value createdAt, + required String channelCid, + Value?> extraData, + Value rowid, + }); +typedef $$DraftMessagesTableUpdateCompanionBuilder = + DraftMessagesCompanion Function({ + Value id, + Value messageText, + Value> attachments, + Value type, + Value> mentionedUsers, + Value parentId, + Value quotedMessageId, + Value pollId, + Value showInChannel, + Value command, + Value silent, + Value createdAt, + Value channelCid, + Value?> extraData, + Value rowid, + }); + +final class $$DraftMessagesTableReferences + extends BaseReferences<_$DriftChatDatabase, $DraftMessagesTable, DraftMessageEntity> { + $$DraftMessagesTableReferences(super.$_db, super.$_table, super.$_typedResult); + + static $MessagesTable _parentIdTable(_$DriftChatDatabase db) => + db.messages.createAlias($_aliasNameGenerator(db.draftMessages.parentId, db.messages.id)); + + $$MessagesTableProcessedTableManager? get parentId { + final $_column = $_itemColumn('parent_id'); + if ($_column == null) return null; + final manager = $$MessagesTableTableManager($_db, $_db.messages).filter((f) => f.id.sqlEquals($_column)); + final item = $_typedResult.readTableOrNull(_parentIdTable($_db)); + if (item == null) return manager; + return ProcessedTableManager(manager.$state.copyWith(prefetchedData: [item])); + } + + static $ChannelsTable _channelCidTable(_$DriftChatDatabase db) => + db.channels.createAlias($_aliasNameGenerator(db.draftMessages.channelCid, db.channels.cid)); + + $$ChannelsTableProcessedTableManager get channelCid { + final $_column = $_itemColumn('channel_cid')!; + + final manager = $$ChannelsTableTableManager($_db, $_db.channels).filter((f) => f.cid.sqlEquals($_column)); + final item = $_typedResult.readTableOrNull(_channelCidTable($_db)); + if (item == null) return manager; + return ProcessedTableManager(manager.$state.copyWith(prefetchedData: [item])); + } +} + +class $$DraftMessagesTableFilterComposer extends Composer<_$DriftChatDatabase, $DraftMessagesTable> { + $$DraftMessagesTableFilterComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + ColumnFilters get id => $composableBuilder(column: $table.id, builder: (column) => ColumnFilters(column)); + + ColumnFilters get messageText => + $composableBuilder(column: $table.messageText, builder: (column) => ColumnFilters(column)); + + ColumnWithTypeConverterFilters, List, String> get attachments => + $composableBuilder(column: $table.attachments, builder: (column) => ColumnWithTypeConverterFilters(column)); + + ColumnFilters get type => $composableBuilder(column: $table.type, builder: (column) => ColumnFilters(column)); + + ColumnWithTypeConverterFilters, List, String> get mentionedUsers => + $composableBuilder(column: $table.mentionedUsers, builder: (column) => ColumnWithTypeConverterFilters(column)); + + ColumnFilters get quotedMessageId => + $composableBuilder(column: $table.quotedMessageId, builder: (column) => ColumnFilters(column)); + + ColumnFilters get pollId => + $composableBuilder(column: $table.pollId, builder: (column) => ColumnFilters(column)); + + ColumnFilters get showInChannel => + $composableBuilder(column: $table.showInChannel, builder: (column) => ColumnFilters(column)); + + ColumnFilters get command => + $composableBuilder(column: $table.command, builder: (column) => ColumnFilters(column)); + + ColumnFilters get silent => + $composableBuilder(column: $table.silent, builder: (column) => ColumnFilters(column)); + + ColumnFilters get createdAt => + $composableBuilder(column: $table.createdAt, builder: (column) => ColumnFilters(column)); + + ColumnWithTypeConverterFilters?, Map, String> get extraData => + $composableBuilder(column: $table.extraData, builder: (column) => ColumnWithTypeConverterFilters(column)); + + $$MessagesTableFilterComposer get parentId { + final $$MessagesTableFilterComposer composer = $composerBuilder( + composer: this, + getCurrentColumn: (t) => t.parentId, + referencedTable: $db.messages, + getReferencedColumn: (t) => t.id, + builder: (joinBuilder, {$addJoinBuilderToRootComposer, $removeJoinBuilderFromRootComposer}) => + $$MessagesTableFilterComposer( + $db: $db, + $table: $db.messages, + $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, + joinBuilder: joinBuilder, + $removeJoinBuilderFromRootComposer: $removeJoinBuilderFromRootComposer, + ), + ); + return composer; + } + + $$ChannelsTableFilterComposer get channelCid { + final $$ChannelsTableFilterComposer composer = $composerBuilder( + composer: this, + getCurrentColumn: (t) => t.channelCid, + referencedTable: $db.channels, + getReferencedColumn: (t) => t.cid, + builder: (joinBuilder, {$addJoinBuilderToRootComposer, $removeJoinBuilderFromRootComposer}) => + $$ChannelsTableFilterComposer( + $db: $db, + $table: $db.channels, + $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, + joinBuilder: joinBuilder, + $removeJoinBuilderFromRootComposer: $removeJoinBuilderFromRootComposer, + ), + ); + return composer; + } +} + +class $$DraftMessagesTableOrderingComposer extends Composer<_$DriftChatDatabase, $DraftMessagesTable> { + $$DraftMessagesTableOrderingComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + ColumnOrderings get id => $composableBuilder(column: $table.id, builder: (column) => ColumnOrderings(column)); + + ColumnOrderings get messageText => + $composableBuilder(column: $table.messageText, builder: (column) => ColumnOrderings(column)); + + ColumnOrderings get attachments => + $composableBuilder(column: $table.attachments, builder: (column) => ColumnOrderings(column)); + + ColumnOrderings get type => + $composableBuilder(column: $table.type, builder: (column) => ColumnOrderings(column)); + + ColumnOrderings get mentionedUsers => + $composableBuilder(column: $table.mentionedUsers, builder: (column) => ColumnOrderings(column)); + + ColumnOrderings get quotedMessageId => + $composableBuilder(column: $table.quotedMessageId, builder: (column) => ColumnOrderings(column)); + + ColumnOrderings get pollId => + $composableBuilder(column: $table.pollId, builder: (column) => ColumnOrderings(column)); + + ColumnOrderings get showInChannel => + $composableBuilder(column: $table.showInChannel, builder: (column) => ColumnOrderings(column)); + + ColumnOrderings get command => + $composableBuilder(column: $table.command, builder: (column) => ColumnOrderings(column)); + + ColumnOrderings get silent => + $composableBuilder(column: $table.silent, builder: (column) => ColumnOrderings(column)); + + ColumnOrderings get createdAt => + $composableBuilder(column: $table.createdAt, builder: (column) => ColumnOrderings(column)); + + ColumnOrderings get extraData => + $composableBuilder(column: $table.extraData, builder: (column) => ColumnOrderings(column)); + + $$MessagesTableOrderingComposer get parentId { + final $$MessagesTableOrderingComposer composer = $composerBuilder( + composer: this, + getCurrentColumn: (t) => t.parentId, + referencedTable: $db.messages, + getReferencedColumn: (t) => t.id, + builder: (joinBuilder, {$addJoinBuilderToRootComposer, $removeJoinBuilderFromRootComposer}) => + $$MessagesTableOrderingComposer( + $db: $db, + $table: $db.messages, + $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, + joinBuilder: joinBuilder, + $removeJoinBuilderFromRootComposer: $removeJoinBuilderFromRootComposer, + ), + ); + return composer; + } + + $$ChannelsTableOrderingComposer get channelCid { + final $$ChannelsTableOrderingComposer composer = $composerBuilder( + composer: this, + getCurrentColumn: (t) => t.channelCid, + referencedTable: $db.channels, + getReferencedColumn: (t) => t.cid, + builder: (joinBuilder, {$addJoinBuilderToRootComposer, $removeJoinBuilderFromRootComposer}) => + $$ChannelsTableOrderingComposer( + $db: $db, + $table: $db.channels, + $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, + joinBuilder: joinBuilder, + $removeJoinBuilderFromRootComposer: $removeJoinBuilderFromRootComposer, + ), + ); + return composer; + } +} - GeneratedColumn get remoteUpdatedAt => $composableBuilder( - column: $table.remoteUpdatedAt, builder: (column) => column); +class $$DraftMessagesTableAnnotationComposer extends Composer<_$DriftChatDatabase, $DraftMessagesTable> { + $$DraftMessagesTableAnnotationComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + GeneratedColumn get id => $composableBuilder(column: $table.id, builder: (column) => column); - GeneratedColumn get localDeletedAt => $composableBuilder( - column: $table.localDeletedAt, builder: (column) => column); + GeneratedColumn get messageText => + $composableBuilder(column: $table.messageText, builder: (column) => column); - GeneratedColumn get remoteDeletedAt => $composableBuilder( - column: $table.remoteDeletedAt, builder: (column) => column); + GeneratedColumnWithTypeConverter, String> get attachments => + $composableBuilder(column: $table.attachments, builder: (column) => column); - GeneratedColumn get messageTextUpdatedAt => $composableBuilder( - column: $table.messageTextUpdatedAt, builder: (column) => column); + GeneratedColumn get type => $composableBuilder(column: $table.type, builder: (column) => column); - GeneratedColumn get userId => - $composableBuilder(column: $table.userId, builder: (column) => column); + GeneratedColumnWithTypeConverter, String> get mentionedUsers => + $composableBuilder(column: $table.mentionedUsers, builder: (column) => column); - GeneratedColumn get channelRole => $composableBuilder( - column: $table.channelRole, builder: (column) => column); + GeneratedColumn get quotedMessageId => + $composableBuilder(column: $table.quotedMessageId, builder: (column) => column); - GeneratedColumn get pinned => - $composableBuilder(column: $table.pinned, builder: (column) => column); + GeneratedColumn get pollId => $composableBuilder(column: $table.pollId, builder: (column) => column); - GeneratedColumn get pinnedAt => - $composableBuilder(column: $table.pinnedAt, builder: (column) => column); + GeneratedColumn get showInChannel => + $composableBuilder(column: $table.showInChannel, builder: (column) => column); - GeneratedColumn get pinExpires => $composableBuilder( - column: $table.pinExpires, builder: (column) => column); + GeneratedColumn get command => $composableBuilder(column: $table.command, builder: (column) => column); - GeneratedColumn get pinnedByUserId => $composableBuilder( - column: $table.pinnedByUserId, builder: (column) => column); + GeneratedColumn get silent => $composableBuilder(column: $table.silent, builder: (column) => column); - GeneratedColumnWithTypeConverter?, String> get i18n => - $composableBuilder(column: $table.i18n, builder: (column) => column); + GeneratedColumn get createdAt => $composableBuilder(column: $table.createdAt, builder: (column) => column); - GeneratedColumnWithTypeConverter?, String> - get restrictedVisibility => $composableBuilder( - column: $table.restrictedVisibility, builder: (column) => column); + GeneratedColumnWithTypeConverter?, String> get extraData => + $composableBuilder(column: $table.extraData, builder: (column) => column); - GeneratedColumnWithTypeConverter?, String> - get extraData => $composableBuilder( - column: $table.extraData, builder: (column) => column); + $$MessagesTableAnnotationComposer get parentId { + final $$MessagesTableAnnotationComposer composer = $composerBuilder( + composer: this, + getCurrentColumn: (t) => t.parentId, + referencedTable: $db.messages, + getReferencedColumn: (t) => t.id, + builder: (joinBuilder, {$addJoinBuilderToRootComposer, $removeJoinBuilderFromRootComposer}) => + $$MessagesTableAnnotationComposer( + $db: $db, + $table: $db.messages, + $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, + joinBuilder: joinBuilder, + $removeJoinBuilderFromRootComposer: $removeJoinBuilderFromRootComposer, + ), + ); + return composer; + } $$ChannelsTableAnnotationComposer get channelCid { final $$ChannelsTableAnnotationComposer composer = $composerBuilder( - composer: this, - getCurrentColumn: (t) => t.channelCid, - referencedTable: $db.channels, - getReferencedColumn: (t) => t.cid, - builder: (joinBuilder, - {$addJoinBuilderToRootComposer, - $removeJoinBuilderFromRootComposer}) => - $$ChannelsTableAnnotationComposer( - $db: $db, - $table: $db.channels, - $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, - joinBuilder: joinBuilder, - $removeJoinBuilderFromRootComposer: - $removeJoinBuilderFromRootComposer, - )); + composer: this, + getCurrentColumn: (t) => t.channelCid, + referencedTable: $db.channels, + getReferencedColumn: (t) => t.cid, + builder: (joinBuilder, {$addJoinBuilderToRootComposer, $removeJoinBuilderFromRootComposer}) => + $$ChannelsTableAnnotationComposer( + $db: $db, + $table: $db.channels, + $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, + joinBuilder: joinBuilder, + $removeJoinBuilderFromRootComposer: $removeJoinBuilderFromRootComposer, + ), + ); return composer; } - - Expression draftMessagesRefs( - Expression Function($$DraftMessagesTableAnnotationComposer a) f) { - final $$DraftMessagesTableAnnotationComposer composer = $composerBuilder( - composer: this, - getCurrentColumn: (t) => t.id, - referencedTable: $db.draftMessages, - getReferencedColumn: (t) => t.parentId, - builder: (joinBuilder, - {$addJoinBuilderToRootComposer, - $removeJoinBuilderFromRootComposer}) => - $$DraftMessagesTableAnnotationComposer( - $db: $db, - $table: $db.draftMessages, - $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, - joinBuilder: joinBuilder, - $removeJoinBuilderFromRootComposer: - $removeJoinBuilderFromRootComposer, - )); - return f(composer); - } - - Expression reactionsRefs( - Expression Function($$ReactionsTableAnnotationComposer a) f) { - final $$ReactionsTableAnnotationComposer composer = $composerBuilder( - composer: this, - getCurrentColumn: (t) => t.id, - referencedTable: $db.reactions, - getReferencedColumn: (t) => t.messageId, - builder: (joinBuilder, - {$addJoinBuilderToRootComposer, - $removeJoinBuilderFromRootComposer}) => - $$ReactionsTableAnnotationComposer( - $db: $db, - $table: $db.reactions, - $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, - joinBuilder: joinBuilder, - $removeJoinBuilderFromRootComposer: - $removeJoinBuilderFromRootComposer, - )); - return f(composer); - } } -class $$MessagesTableTableManager extends RootTableManager< - _$DriftChatDatabase, - $MessagesTable, - MessageEntity, - $$MessagesTableFilterComposer, - $$MessagesTableOrderingComposer, - $$MessagesTableAnnotationComposer, - $$MessagesTableCreateCompanionBuilder, - $$MessagesTableUpdateCompanionBuilder, - (MessageEntity, $$MessagesTableReferences), - MessageEntity, - PrefetchHooks Function( - {bool channelCid, bool draftMessagesRefs, bool reactionsRefs})> { - $$MessagesTableTableManager(_$DriftChatDatabase db, $MessagesTable table) - : super(TableManagerState( +class $$DraftMessagesTableTableManager + extends + RootTableManager< + _$DriftChatDatabase, + $DraftMessagesTable, + DraftMessageEntity, + $$DraftMessagesTableFilterComposer, + $$DraftMessagesTableOrderingComposer, + $$DraftMessagesTableAnnotationComposer, + $$DraftMessagesTableCreateCompanionBuilder, + $$DraftMessagesTableUpdateCompanionBuilder, + (DraftMessageEntity, $$DraftMessagesTableReferences), + DraftMessageEntity, + PrefetchHooks Function({bool parentId, bool channelCid}) + > { + $$DraftMessagesTableTableManager(_$DriftChatDatabase db, $DraftMessagesTable table) + : super( + TableManagerState( db: db, table: table, - createFilteringComposer: () => - $$MessagesTableFilterComposer($db: db, $table: table), - createOrderingComposer: () => - $$MessagesTableOrderingComposer($db: db, $table: table), - createComputedFieldComposer: () => - $$MessagesTableAnnotationComposer($db: db, $table: table), - updateCompanionCallback: ({ - Value id = const Value.absent(), - Value messageText = const Value.absent(), - Value> attachments = const Value.absent(), - Value state = const Value.absent(), - Value type = const Value.absent(), - Value> mentionedUsers = const Value.absent(), - Value?> reactionGroups = - const Value.absent(), - Value parentId = const Value.absent(), - Value quotedMessageId = const Value.absent(), - Value pollId = const Value.absent(), - Value replyCount = const Value.absent(), - Value showInChannel = const Value.absent(), - Value shadowed = const Value.absent(), - Value command = const Value.absent(), - Value localCreatedAt = const Value.absent(), - Value remoteCreatedAt = const Value.absent(), - Value localUpdatedAt = const Value.absent(), - Value remoteUpdatedAt = const Value.absent(), - Value localDeletedAt = const Value.absent(), - Value remoteDeletedAt = const Value.absent(), - Value messageTextUpdatedAt = const Value.absent(), - Value userId = const Value.absent(), - Value channelRole = const Value.absent(), - Value pinned = const Value.absent(), - Value pinnedAt = const Value.absent(), - Value pinExpires = const Value.absent(), - Value pinnedByUserId = const Value.absent(), - Value channelCid = const Value.absent(), - Value?> i18n = const Value.absent(), - Value?> restrictedVisibility = const Value.absent(), - Value?> extraData = const Value.absent(), - Value rowid = const Value.absent(), - }) => - MessagesCompanion( - id: id, - messageText: messageText, - attachments: attachments, - state: state, - type: type, - mentionedUsers: mentionedUsers, - reactionGroups: reactionGroups, - parentId: parentId, - quotedMessageId: quotedMessageId, - pollId: pollId, - replyCount: replyCount, - showInChannel: showInChannel, - shadowed: shadowed, - command: command, - localCreatedAt: localCreatedAt, - remoteCreatedAt: remoteCreatedAt, - localUpdatedAt: localUpdatedAt, - remoteUpdatedAt: remoteUpdatedAt, - localDeletedAt: localDeletedAt, - remoteDeletedAt: remoteDeletedAt, - messageTextUpdatedAt: messageTextUpdatedAt, - userId: userId, - channelRole: channelRole, - pinned: pinned, - pinnedAt: pinnedAt, - pinExpires: pinExpires, - pinnedByUserId: pinnedByUserId, - channelCid: channelCid, - i18n: i18n, - restrictedVisibility: restrictedVisibility, - extraData: extraData, - rowid: rowid, - ), - createCompanionCallback: ({ - required String id, - Value messageText = const Value.absent(), - required List attachments, - required String state, - Value type = const Value.absent(), - required List mentionedUsers, - Value?> reactionGroups = - const Value.absent(), - Value parentId = const Value.absent(), - Value quotedMessageId = const Value.absent(), - Value pollId = const Value.absent(), - Value replyCount = const Value.absent(), - Value showInChannel = const Value.absent(), - Value shadowed = const Value.absent(), - Value command = const Value.absent(), - Value localCreatedAt = const Value.absent(), - Value remoteCreatedAt = const Value.absent(), - Value localUpdatedAt = const Value.absent(), - Value remoteUpdatedAt = const Value.absent(), - Value localDeletedAt = const Value.absent(), - Value remoteDeletedAt = const Value.absent(), - Value messageTextUpdatedAt = const Value.absent(), - Value userId = const Value.absent(), - Value channelRole = const Value.absent(), - Value pinned = const Value.absent(), - Value pinnedAt = const Value.absent(), - Value pinExpires = const Value.absent(), - Value pinnedByUserId = const Value.absent(), - required String channelCid, - Value?> i18n = const Value.absent(), - Value?> restrictedVisibility = const Value.absent(), - Value?> extraData = const Value.absent(), - Value rowid = const Value.absent(), - }) => - MessagesCompanion.insert( - id: id, - messageText: messageText, - attachments: attachments, - state: state, - type: type, - mentionedUsers: mentionedUsers, - reactionGroups: reactionGroups, - parentId: parentId, - quotedMessageId: quotedMessageId, - pollId: pollId, - replyCount: replyCount, - showInChannel: showInChannel, - shadowed: shadowed, - command: command, - localCreatedAt: localCreatedAt, - remoteCreatedAt: remoteCreatedAt, - localUpdatedAt: localUpdatedAt, - remoteUpdatedAt: remoteUpdatedAt, - localDeletedAt: localDeletedAt, - remoteDeletedAt: remoteDeletedAt, - messageTextUpdatedAt: messageTextUpdatedAt, - userId: userId, - channelRole: channelRole, - pinned: pinned, - pinnedAt: pinnedAt, - pinExpires: pinExpires, - pinnedByUserId: pinnedByUserId, - channelCid: channelCid, - i18n: i18n, - restrictedVisibility: restrictedVisibility, - extraData: extraData, - rowid: rowid, - ), - withReferenceMapper: (p0) => p0 - .map((e) => - (e.readTable(table), $$MessagesTableReferences(db, table, e))) - .toList(), - prefetchHooksCallback: ( - {channelCid = false, - draftMessagesRefs = false, - reactionsRefs = false}) { + createFilteringComposer: () => $$DraftMessagesTableFilterComposer($db: db, $table: table), + createOrderingComposer: () => $$DraftMessagesTableOrderingComposer($db: db, $table: table), + createComputedFieldComposer: () => $$DraftMessagesTableAnnotationComposer($db: db, $table: table), + updateCompanionCallback: + ({ + Value id = const Value.absent(), + Value messageText = const Value.absent(), + Value> attachments = const Value.absent(), + Value type = const Value.absent(), + Value> mentionedUsers = const Value.absent(), + Value parentId = const Value.absent(), + Value quotedMessageId = const Value.absent(), + Value pollId = const Value.absent(), + Value showInChannel = const Value.absent(), + Value command = const Value.absent(), + Value silent = const Value.absent(), + Value createdAt = const Value.absent(), + Value channelCid = const Value.absent(), + Value?> extraData = const Value.absent(), + Value rowid = const Value.absent(), + }) => DraftMessagesCompanion( + id: id, + messageText: messageText, + attachments: attachments, + type: type, + mentionedUsers: mentionedUsers, + parentId: parentId, + quotedMessageId: quotedMessageId, + pollId: pollId, + showInChannel: showInChannel, + command: command, + silent: silent, + createdAt: createdAt, + channelCid: channelCid, + extraData: extraData, + rowid: rowid, + ), + createCompanionCallback: + ({ + required String id, + Value messageText = const Value.absent(), + required List attachments, + Value type = const Value.absent(), + required List mentionedUsers, + Value parentId = const Value.absent(), + Value quotedMessageId = const Value.absent(), + Value pollId = const Value.absent(), + Value showInChannel = const Value.absent(), + Value command = const Value.absent(), + Value silent = const Value.absent(), + Value createdAt = const Value.absent(), + required String channelCid, + Value?> extraData = const Value.absent(), + Value rowid = const Value.absent(), + }) => DraftMessagesCompanion.insert( + id: id, + messageText: messageText, + attachments: attachments, + type: type, + mentionedUsers: mentionedUsers, + parentId: parentId, + quotedMessageId: quotedMessageId, + pollId: pollId, + showInChannel: showInChannel, + command: command, + silent: silent, + createdAt: createdAt, + channelCid: channelCid, + extraData: extraData, + rowid: rowid, + ), + withReferenceMapper: (p0) => + p0.map((e) => (e.readTable(table), $$DraftMessagesTableReferences(db, table, e))).toList(), + prefetchHooksCallback: ({parentId = false, channelCid = false}) { return PrefetchHooks( db: db, - explicitlyWatchedTables: [ - if (draftMessagesRefs) db.draftMessages, - if (reactionsRefs) db.reactions - ], - addJoins: < - T extends TableManagerState< + explicitlyWatchedTables: [], + addJoins: + < + T extends TableManagerState< dynamic, dynamic, dynamic, @@ -10208,511 +11276,382 @@ class $$MessagesTableTableManager extends RootTableManager< dynamic, dynamic, dynamic, - dynamic>>(state) { - if (channelCid) { - state = state.withJoin( - currentTable: table, - currentColumn: table.channelCid, - referencedTable: - $$MessagesTableReferences._channelCidTable(db), - referencedColumn: - $$MessagesTableReferences._channelCidTable(db).cid, - ) as T; - } - - return state; - }, + dynamic + > + >(state) { + if (parentId) { + state = + state.withJoin( + currentTable: table, + currentColumn: table.parentId, + referencedTable: $$DraftMessagesTableReferences._parentIdTable(db), + referencedColumn: $$DraftMessagesTableReferences._parentIdTable(db).id, + ) + as T; + } + if (channelCid) { + state = + state.withJoin( + currentTable: table, + currentColumn: table.channelCid, + referencedTable: $$DraftMessagesTableReferences._channelCidTable(db), + referencedColumn: $$DraftMessagesTableReferences._channelCidTable(db).cid, + ) + as T; + } + + return state; + }, getPrefetchedDataCallback: (items) async { - return [ - if (draftMessagesRefs) - await $_getPrefetchedData( - currentTable: table, - referencedTable: $$MessagesTableReferences - ._draftMessagesRefsTable(db), - managerFromTypedResult: (p0) => - $$MessagesTableReferences(db, table, p0) - .draftMessagesRefs, - referencedItemsForCurrentItem: (item, - referencedItems) => - referencedItems.where((e) => e.parentId == item.id), - typedResults: items), - if (reactionsRefs) - await $_getPrefetchedData( - currentTable: table, - referencedTable: - $$MessagesTableReferences._reactionsRefsTable(db), - managerFromTypedResult: (p0) => - $$MessagesTableReferences(db, table, p0) - .reactionsRefs, - referencedItemsForCurrentItem: - (item, referencedItems) => referencedItems - .where((e) => e.messageId == item.id), - typedResults: items) - ]; + return []; }, ); }, - )); + ), + ); } -typedef $$MessagesTableProcessedTableManager = ProcessedTableManager< - _$DriftChatDatabase, - $MessagesTable, - MessageEntity, - $$MessagesTableFilterComposer, - $$MessagesTableOrderingComposer, - $$MessagesTableAnnotationComposer, - $$MessagesTableCreateCompanionBuilder, - $$MessagesTableUpdateCompanionBuilder, - (MessageEntity, $$MessagesTableReferences), - MessageEntity, - PrefetchHooks Function( - {bool channelCid, bool draftMessagesRefs, bool reactionsRefs})>; -typedef $$DraftMessagesTableCreateCompanionBuilder = DraftMessagesCompanion - Function({ - required String id, - Value messageText, - required List attachments, - Value type, - required List mentionedUsers, - Value parentId, - Value quotedMessageId, - Value pollId, - Value showInChannel, - Value command, - Value silent, - Value createdAt, - required String channelCid, - Value?> extraData, - Value rowid, -}); -typedef $$DraftMessagesTableUpdateCompanionBuilder = DraftMessagesCompanion - Function({ - Value id, - Value messageText, - Value> attachments, - Value type, - Value> mentionedUsers, - Value parentId, - Value quotedMessageId, - Value pollId, - Value showInChannel, - Value command, - Value silent, - Value createdAt, - Value channelCid, - Value?> extraData, - Value rowid, -}); - -final class $$DraftMessagesTableReferences extends BaseReferences< - _$DriftChatDatabase, $DraftMessagesTable, DraftMessageEntity> { - $$DraftMessagesTableReferences( - super.$_db, super.$_table, super.$_typedResult); +typedef $$DraftMessagesTableProcessedTableManager = + ProcessedTableManager< + _$DriftChatDatabase, + $DraftMessagesTable, + DraftMessageEntity, + $$DraftMessagesTableFilterComposer, + $$DraftMessagesTableOrderingComposer, + $$DraftMessagesTableAnnotationComposer, + $$DraftMessagesTableCreateCompanionBuilder, + $$DraftMessagesTableUpdateCompanionBuilder, + (DraftMessageEntity, $$DraftMessagesTableReferences), + DraftMessageEntity, + PrefetchHooks Function({bool parentId, bool channelCid}) + >; +typedef $$LocationsTableCreateCompanionBuilder = + LocationsCompanion Function({ + Value channelCid, + Value messageId, + Value userId, + required double latitude, + required double longitude, + Value createdByDeviceId, + Value endAt, + Value createdAt, + Value updatedAt, + Value rowid, + }); +typedef $$LocationsTableUpdateCompanionBuilder = + LocationsCompanion Function({ + Value channelCid, + Value messageId, + Value userId, + Value latitude, + Value longitude, + Value createdByDeviceId, + Value endAt, + Value createdAt, + Value updatedAt, + Value rowid, + }); - static $MessagesTable _parentIdTable(_$DriftChatDatabase db) => - db.messages.createAlias( - $_aliasNameGenerator(db.draftMessages.parentId, db.messages.id)); +final class $$LocationsTableReferences extends BaseReferences<_$DriftChatDatabase, $LocationsTable, LocationEntity> { + $$LocationsTableReferences(super.$_db, super.$_table, super.$_typedResult); - $$MessagesTableProcessedTableManager? get parentId { - if ($_item.parentId == null) return null; - final manager = $$MessagesTableTableManager($_db, $_db.messages) - .filter((f) => f.id($_item.parentId!)); - final item = $_typedResult.readTableOrNull(_parentIdTable($_db)); + static $ChannelsTable _channelCidTable(_$DriftChatDatabase db) => + db.channels.createAlias($_aliasNameGenerator(db.locations.channelCid, db.channels.cid)); + + $$ChannelsTableProcessedTableManager? get channelCid { + final $_column = $_itemColumn('channel_cid'); + if ($_column == null) return null; + final manager = $$ChannelsTableTableManager($_db, $_db.channels).filter((f) => f.cid.sqlEquals($_column)); + final item = $_typedResult.readTableOrNull(_channelCidTable($_db)); if (item == null) return manager; - return ProcessedTableManager( - manager.$state.copyWith(prefetchedData: [item])); + return ProcessedTableManager(manager.$state.copyWith(prefetchedData: [item])); } - static $ChannelsTable _channelCidTable(_$DriftChatDatabase db) => - db.channels.createAlias( - $_aliasNameGenerator(db.draftMessages.channelCid, db.channels.cid)); + static $MessagesTable _messageIdTable(_$DriftChatDatabase db) => + db.messages.createAlias($_aliasNameGenerator(db.locations.messageId, db.messages.id)); - $$ChannelsTableProcessedTableManager get channelCid { - final manager = $$ChannelsTableTableManager($_db, $_db.channels) - .filter((f) => f.cid($_item.channelCid!)); - final item = $_typedResult.readTableOrNull(_channelCidTable($_db)); + $$MessagesTableProcessedTableManager? get messageId { + final $_column = $_itemColumn('message_id'); + if ($_column == null) return null; + final manager = $$MessagesTableTableManager($_db, $_db.messages).filter((f) => f.id.sqlEquals($_column)); + final item = $_typedResult.readTableOrNull(_messageIdTable($_db)); if (item == null) return manager; - return ProcessedTableManager( - manager.$state.copyWith(prefetchedData: [item])); + return ProcessedTableManager(manager.$state.copyWith(prefetchedData: [item])); } } -class $$DraftMessagesTableFilterComposer - extends Composer<_$DriftChatDatabase, $DraftMessagesTable> { - $$DraftMessagesTableFilterComposer({ +class $$LocationsTableFilterComposer extends Composer<_$DriftChatDatabase, $LocationsTable> { + $$LocationsTableFilterComposer({ required super.$db, required super.$table, super.joinBuilder, super.$addJoinBuilderToRootComposer, super.$removeJoinBuilderFromRootComposer, }); - ColumnFilters get id => $composableBuilder( - column: $table.id, builder: (column) => ColumnFilters(column)); - - ColumnFilters get messageText => $composableBuilder( - column: $table.messageText, builder: (column) => ColumnFilters(column)); - - ColumnWithTypeConverterFilters, List, String> - get attachments => $composableBuilder( - column: $table.attachments, - builder: (column) => ColumnWithTypeConverterFilters(column)); + ColumnFilters get userId => + $composableBuilder(column: $table.userId, builder: (column) => ColumnFilters(column)); - ColumnFilters get type => $composableBuilder( - column: $table.type, builder: (column) => ColumnFilters(column)); + ColumnFilters get latitude => + $composableBuilder(column: $table.latitude, builder: (column) => ColumnFilters(column)); - ColumnWithTypeConverterFilters, List, String> - get mentionedUsers => $composableBuilder( - column: $table.mentionedUsers, - builder: (column) => ColumnWithTypeConverterFilters(column)); + ColumnFilters get longitude => + $composableBuilder(column: $table.longitude, builder: (column) => ColumnFilters(column)); - ColumnFilters get quotedMessageId => $composableBuilder( - column: $table.quotedMessageId, - builder: (column) => ColumnFilters(column)); + ColumnFilters get createdByDeviceId => + $composableBuilder(column: $table.createdByDeviceId, builder: (column) => ColumnFilters(column)); - ColumnFilters get pollId => $composableBuilder( - column: $table.pollId, builder: (column) => ColumnFilters(column)); + ColumnFilters get endAt => + $composableBuilder(column: $table.endAt, builder: (column) => ColumnFilters(column)); - ColumnFilters get showInChannel => $composableBuilder( - column: $table.showInChannel, builder: (column) => ColumnFilters(column)); + ColumnFilters get createdAt => + $composableBuilder(column: $table.createdAt, builder: (column) => ColumnFilters(column)); - ColumnFilters get command => $composableBuilder( - column: $table.command, builder: (column) => ColumnFilters(column)); + ColumnFilters get updatedAt => + $composableBuilder(column: $table.updatedAt, builder: (column) => ColumnFilters(column)); - ColumnFilters get silent => $composableBuilder( - column: $table.silent, builder: (column) => ColumnFilters(column)); - - ColumnFilters get createdAt => $composableBuilder( - column: $table.createdAt, builder: (column) => ColumnFilters(column)); - - ColumnWithTypeConverterFilters?, Map, - String> - get extraData => $composableBuilder( - column: $table.extraData, - builder: (column) => ColumnWithTypeConverterFilters(column)); - - $$MessagesTableFilterComposer get parentId { - final $$MessagesTableFilterComposer composer = $composerBuilder( - composer: this, - getCurrentColumn: (t) => t.parentId, - referencedTable: $db.messages, - getReferencedColumn: (t) => t.id, - builder: (joinBuilder, - {$addJoinBuilderToRootComposer, - $removeJoinBuilderFromRootComposer}) => - $$MessagesTableFilterComposer( - $db: $db, - $table: $db.messages, - $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, - joinBuilder: joinBuilder, - $removeJoinBuilderFromRootComposer: - $removeJoinBuilderFromRootComposer, - )); + $$ChannelsTableFilterComposer get channelCid { + final $$ChannelsTableFilterComposer composer = $composerBuilder( + composer: this, + getCurrentColumn: (t) => t.channelCid, + referencedTable: $db.channels, + getReferencedColumn: (t) => t.cid, + builder: (joinBuilder, {$addJoinBuilderToRootComposer, $removeJoinBuilderFromRootComposer}) => + $$ChannelsTableFilterComposer( + $db: $db, + $table: $db.channels, + $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, + joinBuilder: joinBuilder, + $removeJoinBuilderFromRootComposer: $removeJoinBuilderFromRootComposer, + ), + ); return composer; } - $$ChannelsTableFilterComposer get channelCid { - final $$ChannelsTableFilterComposer composer = $composerBuilder( - composer: this, - getCurrentColumn: (t) => t.channelCid, - referencedTable: $db.channels, - getReferencedColumn: (t) => t.cid, - builder: (joinBuilder, - {$addJoinBuilderToRootComposer, - $removeJoinBuilderFromRootComposer}) => - $$ChannelsTableFilterComposer( - $db: $db, - $table: $db.channels, - $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, - joinBuilder: joinBuilder, - $removeJoinBuilderFromRootComposer: - $removeJoinBuilderFromRootComposer, - )); + $$MessagesTableFilterComposer get messageId { + final $$MessagesTableFilterComposer composer = $composerBuilder( + composer: this, + getCurrentColumn: (t) => t.messageId, + referencedTable: $db.messages, + getReferencedColumn: (t) => t.id, + builder: (joinBuilder, {$addJoinBuilderToRootComposer, $removeJoinBuilderFromRootComposer}) => + $$MessagesTableFilterComposer( + $db: $db, + $table: $db.messages, + $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, + joinBuilder: joinBuilder, + $removeJoinBuilderFromRootComposer: $removeJoinBuilderFromRootComposer, + ), + ); return composer; } } -class $$DraftMessagesTableOrderingComposer - extends Composer<_$DriftChatDatabase, $DraftMessagesTable> { - $$DraftMessagesTableOrderingComposer({ +class $$LocationsTableOrderingComposer extends Composer<_$DriftChatDatabase, $LocationsTable> { + $$LocationsTableOrderingComposer({ required super.$db, required super.$table, super.joinBuilder, super.$addJoinBuilderToRootComposer, super.$removeJoinBuilderFromRootComposer, }); - ColumnOrderings get id => $composableBuilder( - column: $table.id, builder: (column) => ColumnOrderings(column)); - - ColumnOrderings get messageText => $composableBuilder( - column: $table.messageText, builder: (column) => ColumnOrderings(column)); - - ColumnOrderings get attachments => $composableBuilder( - column: $table.attachments, builder: (column) => ColumnOrderings(column)); - - ColumnOrderings get type => $composableBuilder( - column: $table.type, builder: (column) => ColumnOrderings(column)); - - ColumnOrderings get mentionedUsers => $composableBuilder( - column: $table.mentionedUsers, - builder: (column) => ColumnOrderings(column)); + ColumnOrderings get userId => + $composableBuilder(column: $table.userId, builder: (column) => ColumnOrderings(column)); - ColumnOrderings get quotedMessageId => $composableBuilder( - column: $table.quotedMessageId, - builder: (column) => ColumnOrderings(column)); + ColumnOrderings get latitude => + $composableBuilder(column: $table.latitude, builder: (column) => ColumnOrderings(column)); - ColumnOrderings get pollId => $composableBuilder( - column: $table.pollId, builder: (column) => ColumnOrderings(column)); + ColumnOrderings get longitude => + $composableBuilder(column: $table.longitude, builder: (column) => ColumnOrderings(column)); - ColumnOrderings get showInChannel => $composableBuilder( - column: $table.showInChannel, - builder: (column) => ColumnOrderings(column)); + ColumnOrderings get createdByDeviceId => + $composableBuilder(column: $table.createdByDeviceId, builder: (column) => ColumnOrderings(column)); - ColumnOrderings get command => $composableBuilder( - column: $table.command, builder: (column) => ColumnOrderings(column)); + ColumnOrderings get endAt => + $composableBuilder(column: $table.endAt, builder: (column) => ColumnOrderings(column)); - ColumnOrderings get silent => $composableBuilder( - column: $table.silent, builder: (column) => ColumnOrderings(column)); + ColumnOrderings get createdAt => + $composableBuilder(column: $table.createdAt, builder: (column) => ColumnOrderings(column)); - ColumnOrderings get createdAt => $composableBuilder( - column: $table.createdAt, builder: (column) => ColumnOrderings(column)); + ColumnOrderings get updatedAt => + $composableBuilder(column: $table.updatedAt, builder: (column) => ColumnOrderings(column)); - ColumnOrderings get extraData => $composableBuilder( - column: $table.extraData, builder: (column) => ColumnOrderings(column)); - - $$MessagesTableOrderingComposer get parentId { - final $$MessagesTableOrderingComposer composer = $composerBuilder( - composer: this, - getCurrentColumn: (t) => t.parentId, - referencedTable: $db.messages, - getReferencedColumn: (t) => t.id, - builder: (joinBuilder, - {$addJoinBuilderToRootComposer, - $removeJoinBuilderFromRootComposer}) => - $$MessagesTableOrderingComposer( - $db: $db, - $table: $db.messages, - $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, - joinBuilder: joinBuilder, - $removeJoinBuilderFromRootComposer: - $removeJoinBuilderFromRootComposer, - )); + $$ChannelsTableOrderingComposer get channelCid { + final $$ChannelsTableOrderingComposer composer = $composerBuilder( + composer: this, + getCurrentColumn: (t) => t.channelCid, + referencedTable: $db.channels, + getReferencedColumn: (t) => t.cid, + builder: (joinBuilder, {$addJoinBuilderToRootComposer, $removeJoinBuilderFromRootComposer}) => + $$ChannelsTableOrderingComposer( + $db: $db, + $table: $db.channels, + $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, + joinBuilder: joinBuilder, + $removeJoinBuilderFromRootComposer: $removeJoinBuilderFromRootComposer, + ), + ); return composer; } - $$ChannelsTableOrderingComposer get channelCid { - final $$ChannelsTableOrderingComposer composer = $composerBuilder( - composer: this, - getCurrentColumn: (t) => t.channelCid, - referencedTable: $db.channels, - getReferencedColumn: (t) => t.cid, - builder: (joinBuilder, - {$addJoinBuilderToRootComposer, - $removeJoinBuilderFromRootComposer}) => - $$ChannelsTableOrderingComposer( - $db: $db, - $table: $db.channels, - $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, - joinBuilder: joinBuilder, - $removeJoinBuilderFromRootComposer: - $removeJoinBuilderFromRootComposer, - )); + $$MessagesTableOrderingComposer get messageId { + final $$MessagesTableOrderingComposer composer = $composerBuilder( + composer: this, + getCurrentColumn: (t) => t.messageId, + referencedTable: $db.messages, + getReferencedColumn: (t) => t.id, + builder: (joinBuilder, {$addJoinBuilderToRootComposer, $removeJoinBuilderFromRootComposer}) => + $$MessagesTableOrderingComposer( + $db: $db, + $table: $db.messages, + $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, + joinBuilder: joinBuilder, + $removeJoinBuilderFromRootComposer: $removeJoinBuilderFromRootComposer, + ), + ); return composer; } } -class $$DraftMessagesTableAnnotationComposer - extends Composer<_$DriftChatDatabase, $DraftMessagesTable> { - $$DraftMessagesTableAnnotationComposer({ +class $$LocationsTableAnnotationComposer extends Composer<_$DriftChatDatabase, $LocationsTable> { + $$LocationsTableAnnotationComposer({ required super.$db, required super.$table, super.joinBuilder, super.$addJoinBuilderToRootComposer, super.$removeJoinBuilderFromRootComposer, }); - GeneratedColumn get id => - $composableBuilder(column: $table.id, builder: (column) => column); - - GeneratedColumn get messageText => $composableBuilder( - column: $table.messageText, builder: (column) => column); - - GeneratedColumnWithTypeConverter, String> get attachments => - $composableBuilder( - column: $table.attachments, builder: (column) => column); - - GeneratedColumn get type => - $composableBuilder(column: $table.type, builder: (column) => column); - - GeneratedColumnWithTypeConverter, String> get mentionedUsers => - $composableBuilder( - column: $table.mentionedUsers, builder: (column) => column); + GeneratedColumn get userId => $composableBuilder(column: $table.userId, builder: (column) => column); - GeneratedColumn get quotedMessageId => $composableBuilder( - column: $table.quotedMessageId, builder: (column) => column); + GeneratedColumn get latitude => $composableBuilder(column: $table.latitude, builder: (column) => column); - GeneratedColumn get pollId => - $composableBuilder(column: $table.pollId, builder: (column) => column); + GeneratedColumn get longitude => $composableBuilder(column: $table.longitude, builder: (column) => column); - GeneratedColumn get showInChannel => $composableBuilder( - column: $table.showInChannel, builder: (column) => column); + GeneratedColumn get createdByDeviceId => + $composableBuilder(column: $table.createdByDeviceId, builder: (column) => column); - GeneratedColumn get command => - $composableBuilder(column: $table.command, builder: (column) => column); + GeneratedColumn get endAt => $composableBuilder(column: $table.endAt, builder: (column) => column); - GeneratedColumn get silent => - $composableBuilder(column: $table.silent, builder: (column) => column); + GeneratedColumn get createdAt => $composableBuilder(column: $table.createdAt, builder: (column) => column); - GeneratedColumn get createdAt => - $composableBuilder(column: $table.createdAt, builder: (column) => column); + GeneratedColumn get updatedAt => $composableBuilder(column: $table.updatedAt, builder: (column) => column); - GeneratedColumnWithTypeConverter?, String> - get extraData => $composableBuilder( - column: $table.extraData, builder: (column) => column); - - $$MessagesTableAnnotationComposer get parentId { - final $$MessagesTableAnnotationComposer composer = $composerBuilder( - composer: this, - getCurrentColumn: (t) => t.parentId, - referencedTable: $db.messages, - getReferencedColumn: (t) => t.id, - builder: (joinBuilder, - {$addJoinBuilderToRootComposer, - $removeJoinBuilderFromRootComposer}) => - $$MessagesTableAnnotationComposer( - $db: $db, - $table: $db.messages, - $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, - joinBuilder: joinBuilder, - $removeJoinBuilderFromRootComposer: - $removeJoinBuilderFromRootComposer, - )); + $$ChannelsTableAnnotationComposer get channelCid { + final $$ChannelsTableAnnotationComposer composer = $composerBuilder( + composer: this, + getCurrentColumn: (t) => t.channelCid, + referencedTable: $db.channels, + getReferencedColumn: (t) => t.cid, + builder: (joinBuilder, {$addJoinBuilderToRootComposer, $removeJoinBuilderFromRootComposer}) => + $$ChannelsTableAnnotationComposer( + $db: $db, + $table: $db.channels, + $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, + joinBuilder: joinBuilder, + $removeJoinBuilderFromRootComposer: $removeJoinBuilderFromRootComposer, + ), + ); return composer; } - $$ChannelsTableAnnotationComposer get channelCid { - final $$ChannelsTableAnnotationComposer composer = $composerBuilder( - composer: this, - getCurrentColumn: (t) => t.channelCid, - referencedTable: $db.channels, - getReferencedColumn: (t) => t.cid, - builder: (joinBuilder, - {$addJoinBuilderToRootComposer, - $removeJoinBuilderFromRootComposer}) => - $$ChannelsTableAnnotationComposer( - $db: $db, - $table: $db.channels, - $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, - joinBuilder: joinBuilder, - $removeJoinBuilderFromRootComposer: - $removeJoinBuilderFromRootComposer, - )); + $$MessagesTableAnnotationComposer get messageId { + final $$MessagesTableAnnotationComposer composer = $composerBuilder( + composer: this, + getCurrentColumn: (t) => t.messageId, + referencedTable: $db.messages, + getReferencedColumn: (t) => t.id, + builder: (joinBuilder, {$addJoinBuilderToRootComposer, $removeJoinBuilderFromRootComposer}) => + $$MessagesTableAnnotationComposer( + $db: $db, + $table: $db.messages, + $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, + joinBuilder: joinBuilder, + $removeJoinBuilderFromRootComposer: $removeJoinBuilderFromRootComposer, + ), + ); return composer; } } -class $$DraftMessagesTableTableManager extends RootTableManager< - _$DriftChatDatabase, - $DraftMessagesTable, - DraftMessageEntity, - $$DraftMessagesTableFilterComposer, - $$DraftMessagesTableOrderingComposer, - $$DraftMessagesTableAnnotationComposer, - $$DraftMessagesTableCreateCompanionBuilder, - $$DraftMessagesTableUpdateCompanionBuilder, - (DraftMessageEntity, $$DraftMessagesTableReferences), - DraftMessageEntity, - PrefetchHooks Function({bool parentId, bool channelCid})> { - $$DraftMessagesTableTableManager( - _$DriftChatDatabase db, $DraftMessagesTable table) - : super(TableManagerState( +class $$LocationsTableTableManager + extends + RootTableManager< + _$DriftChatDatabase, + $LocationsTable, + LocationEntity, + $$LocationsTableFilterComposer, + $$LocationsTableOrderingComposer, + $$LocationsTableAnnotationComposer, + $$LocationsTableCreateCompanionBuilder, + $$LocationsTableUpdateCompanionBuilder, + (LocationEntity, $$LocationsTableReferences), + LocationEntity, + PrefetchHooks Function({bool channelCid, bool messageId}) + > { + $$LocationsTableTableManager(_$DriftChatDatabase db, $LocationsTable table) + : super( + TableManagerState( db: db, table: table, - createFilteringComposer: () => - $$DraftMessagesTableFilterComposer($db: db, $table: table), - createOrderingComposer: () => - $$DraftMessagesTableOrderingComposer($db: db, $table: table), - createComputedFieldComposer: () => - $$DraftMessagesTableAnnotationComposer($db: db, $table: table), - updateCompanionCallback: ({ - Value id = const Value.absent(), - Value messageText = const Value.absent(), - Value> attachments = const Value.absent(), - Value type = const Value.absent(), - Value> mentionedUsers = const Value.absent(), - Value parentId = const Value.absent(), - Value quotedMessageId = const Value.absent(), - Value pollId = const Value.absent(), - Value showInChannel = const Value.absent(), - Value command = const Value.absent(), - Value silent = const Value.absent(), - Value createdAt = const Value.absent(), - Value channelCid = const Value.absent(), - Value?> extraData = const Value.absent(), - Value rowid = const Value.absent(), - }) => - DraftMessagesCompanion( - id: id, - messageText: messageText, - attachments: attachments, - type: type, - mentionedUsers: mentionedUsers, - parentId: parentId, - quotedMessageId: quotedMessageId, - pollId: pollId, - showInChannel: showInChannel, - command: command, - silent: silent, - createdAt: createdAt, - channelCid: channelCid, - extraData: extraData, - rowid: rowid, - ), - createCompanionCallback: ({ - required String id, - Value messageText = const Value.absent(), - required List attachments, - Value type = const Value.absent(), - required List mentionedUsers, - Value parentId = const Value.absent(), - Value quotedMessageId = const Value.absent(), - Value pollId = const Value.absent(), - Value showInChannel = const Value.absent(), - Value command = const Value.absent(), - Value silent = const Value.absent(), - Value createdAt = const Value.absent(), - required String channelCid, - Value?> extraData = const Value.absent(), - Value rowid = const Value.absent(), - }) => - DraftMessagesCompanion.insert( - id: id, - messageText: messageText, - attachments: attachments, - type: type, - mentionedUsers: mentionedUsers, - parentId: parentId, - quotedMessageId: quotedMessageId, - pollId: pollId, - showInChannel: showInChannel, - command: command, - silent: silent, - createdAt: createdAt, - channelCid: channelCid, - extraData: extraData, - rowid: rowid, - ), - withReferenceMapper: (p0) => p0 - .map((e) => ( - e.readTable(table), - $$DraftMessagesTableReferences(db, table, e) - )) - .toList(), - prefetchHooksCallback: ({parentId = false, channelCid = false}) { + createFilteringComposer: () => $$LocationsTableFilterComposer($db: db, $table: table), + createOrderingComposer: () => $$LocationsTableOrderingComposer($db: db, $table: table), + createComputedFieldComposer: () => $$LocationsTableAnnotationComposer($db: db, $table: table), + updateCompanionCallback: + ({ + Value channelCid = const Value.absent(), + Value messageId = const Value.absent(), + Value userId = const Value.absent(), + Value latitude = const Value.absent(), + Value longitude = const Value.absent(), + Value createdByDeviceId = const Value.absent(), + Value endAt = const Value.absent(), + Value createdAt = const Value.absent(), + Value updatedAt = const Value.absent(), + Value rowid = const Value.absent(), + }) => LocationsCompanion( + channelCid: channelCid, + messageId: messageId, + userId: userId, + latitude: latitude, + longitude: longitude, + createdByDeviceId: createdByDeviceId, + endAt: endAt, + createdAt: createdAt, + updatedAt: updatedAt, + rowid: rowid, + ), + createCompanionCallback: + ({ + Value channelCid = const Value.absent(), + Value messageId = const Value.absent(), + Value userId = const Value.absent(), + required double latitude, + required double longitude, + Value createdByDeviceId = const Value.absent(), + Value endAt = const Value.absent(), + Value createdAt = const Value.absent(), + Value updatedAt = const Value.absent(), + Value rowid = const Value.absent(), + }) => LocationsCompanion.insert( + channelCid: channelCid, + messageId: messageId, + userId: userId, + latitude: latitude, + longitude: longitude, + createdByDeviceId: createdByDeviceId, + endAt: endAt, + createdAt: createdAt, + updatedAt: updatedAt, + rowid: rowid, + ), + withReferenceMapper: (p0) => + p0.map((e) => (e.readTable(table), $$LocationsTableReferences(db, table, e))).toList(), + prefetchHooksCallback: ({channelCid = false, messageId = false}) { return PrefetchHooks( db: db, explicitlyWatchedTables: [], - addJoins: < - T extends TableManagerState< + addJoins: + < + T extends TableManagerState< dynamic, dynamic, dynamic, @@ -10723,148 +11662,150 @@ class $$DraftMessagesTableTableManager extends RootTableManager< dynamic, dynamic, dynamic, - dynamic>>(state) { - if (parentId) { - state = state.withJoin( - currentTable: table, - currentColumn: table.parentId, - referencedTable: - $$DraftMessagesTableReferences._parentIdTable(db), - referencedColumn: - $$DraftMessagesTableReferences._parentIdTable(db).id, - ) as T; - } - if (channelCid) { - state = state.withJoin( - currentTable: table, - currentColumn: table.channelCid, - referencedTable: - $$DraftMessagesTableReferences._channelCidTable(db), - referencedColumn: - $$DraftMessagesTableReferences._channelCidTable(db).cid, - ) as T; - } - - return state; - }, + dynamic + > + >(state) { + if (channelCid) { + state = + state.withJoin( + currentTable: table, + currentColumn: table.channelCid, + referencedTable: $$LocationsTableReferences._channelCidTable(db), + referencedColumn: $$LocationsTableReferences._channelCidTable(db).cid, + ) + as T; + } + if (messageId) { + state = + state.withJoin( + currentTable: table, + currentColumn: table.messageId, + referencedTable: $$LocationsTableReferences._messageIdTable(db), + referencedColumn: $$LocationsTableReferences._messageIdTable(db).id, + ) + as T; + } + + return state; + }, getPrefetchedDataCallback: (items) async { return []; }, ); }, - )); + ), + ); } -typedef $$DraftMessagesTableProcessedTableManager = ProcessedTableManager< - _$DriftChatDatabase, - $DraftMessagesTable, - DraftMessageEntity, - $$DraftMessagesTableFilterComposer, - $$DraftMessagesTableOrderingComposer, - $$DraftMessagesTableAnnotationComposer, - $$DraftMessagesTableCreateCompanionBuilder, - $$DraftMessagesTableUpdateCompanionBuilder, - (DraftMessageEntity, $$DraftMessagesTableReferences), - DraftMessageEntity, - PrefetchHooks Function({bool parentId, bool channelCid})>; -typedef $$PinnedMessagesTableCreateCompanionBuilder = PinnedMessagesCompanion - Function({ - required String id, - Value messageText, - required List attachments, - required String state, - Value type, - required List mentionedUsers, - Value?> reactionGroups, - Value parentId, - Value quotedMessageId, - Value pollId, - Value replyCount, - Value showInChannel, - Value shadowed, - Value command, - Value localCreatedAt, - Value remoteCreatedAt, - Value localUpdatedAt, - Value remoteUpdatedAt, - Value localDeletedAt, - Value remoteDeletedAt, - Value messageTextUpdatedAt, - Value userId, - Value channelRole, - Value pinned, - Value pinnedAt, - Value pinExpires, - Value pinnedByUserId, - required String channelCid, - Value?> i18n, - Value?> restrictedVisibility, - Value?> extraData, - Value rowid, -}); -typedef $$PinnedMessagesTableUpdateCompanionBuilder = PinnedMessagesCompanion - Function({ - Value id, - Value messageText, - Value> attachments, - Value state, - Value type, - Value> mentionedUsers, - Value?> reactionGroups, - Value parentId, - Value quotedMessageId, - Value pollId, - Value replyCount, - Value showInChannel, - Value shadowed, - Value command, - Value localCreatedAt, - Value remoteCreatedAt, - Value localUpdatedAt, - Value remoteUpdatedAt, - Value localDeletedAt, - Value remoteDeletedAt, - Value messageTextUpdatedAt, - Value userId, - Value channelRole, - Value pinned, - Value pinnedAt, - Value pinExpires, - Value pinnedByUserId, - Value channelCid, - Value?> i18n, - Value?> restrictedVisibility, - Value?> extraData, - Value rowid, -}); - -final class $$PinnedMessagesTableReferences extends BaseReferences< - _$DriftChatDatabase, $PinnedMessagesTable, PinnedMessageEntity> { - $$PinnedMessagesTableReferences( - super.$_db, super.$_table, super.$_typedResult); - - static MultiTypedResultKey<$PinnedMessageReactionsTable, - List> _pinnedMessageReactionsRefsTable( - _$DriftChatDatabase db) => - MultiTypedResultKey.fromTable(db.pinnedMessageReactions, - aliasName: $_aliasNameGenerator( - db.pinnedMessages.id, db.pinnedMessageReactions.messageId)); - - $$PinnedMessageReactionsTableProcessedTableManager - get pinnedMessageReactionsRefs { +typedef $$LocationsTableProcessedTableManager = + ProcessedTableManager< + _$DriftChatDatabase, + $LocationsTable, + LocationEntity, + $$LocationsTableFilterComposer, + $$LocationsTableOrderingComposer, + $$LocationsTableAnnotationComposer, + $$LocationsTableCreateCompanionBuilder, + $$LocationsTableUpdateCompanionBuilder, + (LocationEntity, $$LocationsTableReferences), + LocationEntity, + PrefetchHooks Function({bool channelCid, bool messageId}) + >; +typedef $$PinnedMessagesTableCreateCompanionBuilder = + PinnedMessagesCompanion Function({ + required String id, + Value messageText, + required List attachments, + required String state, + Value type, + required List mentionedUsers, + Value?> reactionGroups, + Value parentId, + Value quotedMessageId, + Value pollId, + Value replyCount, + Value showInChannel, + Value shadowed, + Value command, + Value localCreatedAt, + Value remoteCreatedAt, + Value localUpdatedAt, + Value remoteUpdatedAt, + Value localDeletedAt, + Value remoteDeletedAt, + Value deletedForMe, + Value messageTextUpdatedAt, + Value userId, + Value channelRole, + Value pinned, + Value pinnedAt, + Value pinExpires, + Value pinnedByUserId, + required String channelCid, + Value?> i18n, + Value?> restrictedVisibility, + Value?> extraData, + Value rowid, + }); +typedef $$PinnedMessagesTableUpdateCompanionBuilder = + PinnedMessagesCompanion Function({ + Value id, + Value messageText, + Value> attachments, + Value state, + Value type, + Value> mentionedUsers, + Value?> reactionGroups, + Value parentId, + Value quotedMessageId, + Value pollId, + Value replyCount, + Value showInChannel, + Value shadowed, + Value command, + Value localCreatedAt, + Value remoteCreatedAt, + Value localUpdatedAt, + Value remoteUpdatedAt, + Value localDeletedAt, + Value remoteDeletedAt, + Value deletedForMe, + Value messageTextUpdatedAt, + Value userId, + Value channelRole, + Value pinned, + Value pinnedAt, + Value pinExpires, + Value pinnedByUserId, + Value channelCid, + Value?> i18n, + Value?> restrictedVisibility, + Value?> extraData, + Value rowid, + }); + +final class $$PinnedMessagesTableReferences + extends BaseReferences<_$DriftChatDatabase, $PinnedMessagesTable, PinnedMessageEntity> { + $$PinnedMessagesTableReferences(super.$_db, super.$_table, super.$_typedResult); + + static MultiTypedResultKey<$PinnedMessageReactionsTable, List> + _pinnedMessageReactionsRefsTable(_$DriftChatDatabase db) => MultiTypedResultKey.fromTable( + db.pinnedMessageReactions, + aliasName: $_aliasNameGenerator(db.pinnedMessages.id, db.pinnedMessageReactions.messageId), + ); + + $$PinnedMessageReactionsTableProcessedTableManager get pinnedMessageReactionsRefs { final manager = $$PinnedMessageReactionsTableTableManager( - $_db, $_db.pinnedMessageReactions) - .filter((f) => f.messageId.id($_item.id)); + $_db, + $_db.pinnedMessageReactions, + ).filter((f) => f.messageId.id.sqlEquals($_itemColumn('id')!)); - final cache = - $_typedResult.readTableOrNull(_pinnedMessageReactionsRefsTable($_db)); - return ProcessedTableManager( - manager.$state.copyWith(prefetchedData: cache)); + final cache = $_typedResult.readTableOrNull(_pinnedMessageReactionsRefsTable($_db)); + return ProcessedTableManager(manager.$state.copyWith(prefetchedData: cache)); } } -class $$PinnedMessagesTableFilterComposer - extends Composer<_$DriftChatDatabase, $PinnedMessagesTable> { +class $$PinnedMessagesTableFilterComposer extends Composer<_$DriftChatDatabase, $PinnedMessagesTable> { $$PinnedMessagesTableFilterComposer({ required super.$db, required super.$table, @@ -10872,149 +11813,124 @@ class $$PinnedMessagesTableFilterComposer super.$addJoinBuilderToRootComposer, super.$removeJoinBuilderFromRootComposer, }); - ColumnFilters get id => $composableBuilder( - column: $table.id, builder: (column) => ColumnFilters(column)); + ColumnFilters get id => $composableBuilder(column: $table.id, builder: (column) => ColumnFilters(column)); + + ColumnFilters get messageText => + $composableBuilder(column: $table.messageText, builder: (column) => ColumnFilters(column)); - ColumnFilters get messageText => $composableBuilder( - column: $table.messageText, builder: (column) => ColumnFilters(column)); + ColumnWithTypeConverterFilters, List, String> get attachments => + $composableBuilder(column: $table.attachments, builder: (column) => ColumnWithTypeConverterFilters(column)); - ColumnWithTypeConverterFilters, List, String> - get attachments => $composableBuilder( - column: $table.attachments, - builder: (column) => ColumnWithTypeConverterFilters(column)); + ColumnFilters get state => + $composableBuilder(column: $table.state, builder: (column) => ColumnFilters(column)); - ColumnFilters get state => $composableBuilder( - column: $table.state, builder: (column) => ColumnFilters(column)); + ColumnFilters get type => $composableBuilder(column: $table.type, builder: (column) => ColumnFilters(column)); - ColumnFilters get type => $composableBuilder( - column: $table.type, builder: (column) => ColumnFilters(column)); + ColumnWithTypeConverterFilters, List, String> get mentionedUsers => + $composableBuilder(column: $table.mentionedUsers, builder: (column) => ColumnWithTypeConverterFilters(column)); - ColumnWithTypeConverterFilters, List, String> - get mentionedUsers => $composableBuilder( - column: $table.mentionedUsers, - builder: (column) => ColumnWithTypeConverterFilters(column)); + ColumnWithTypeConverterFilters?, Map, String> get reactionGroups => + $composableBuilder(column: $table.reactionGroups, builder: (column) => ColumnWithTypeConverterFilters(column)); - ColumnWithTypeConverterFilters?, - Map, String> - get reactionGroups => $composableBuilder( - column: $table.reactionGroups, - builder: (column) => ColumnWithTypeConverterFilters(column)); + ColumnFilters get parentId => + $composableBuilder(column: $table.parentId, builder: (column) => ColumnFilters(column)); - ColumnFilters get parentId => $composableBuilder( - column: $table.parentId, builder: (column) => ColumnFilters(column)); + ColumnFilters get quotedMessageId => + $composableBuilder(column: $table.quotedMessageId, builder: (column) => ColumnFilters(column)); - ColumnFilters get quotedMessageId => $composableBuilder( - column: $table.quotedMessageId, - builder: (column) => ColumnFilters(column)); + ColumnFilters get pollId => + $composableBuilder(column: $table.pollId, builder: (column) => ColumnFilters(column)); - ColumnFilters get pollId => $composableBuilder( - column: $table.pollId, builder: (column) => ColumnFilters(column)); + ColumnFilters get replyCount => + $composableBuilder(column: $table.replyCount, builder: (column) => ColumnFilters(column)); - ColumnFilters get replyCount => $composableBuilder( - column: $table.replyCount, builder: (column) => ColumnFilters(column)); + ColumnFilters get showInChannel => + $composableBuilder(column: $table.showInChannel, builder: (column) => ColumnFilters(column)); - ColumnFilters get showInChannel => $composableBuilder( - column: $table.showInChannel, builder: (column) => ColumnFilters(column)); + ColumnFilters get shadowed => + $composableBuilder(column: $table.shadowed, builder: (column) => ColumnFilters(column)); - ColumnFilters get shadowed => $composableBuilder( - column: $table.shadowed, builder: (column) => ColumnFilters(column)); + ColumnFilters get command => + $composableBuilder(column: $table.command, builder: (column) => ColumnFilters(column)); - ColumnFilters get command => $composableBuilder( - column: $table.command, builder: (column) => ColumnFilters(column)); + ColumnFilters get localCreatedAt => + $composableBuilder(column: $table.localCreatedAt, builder: (column) => ColumnFilters(column)); - ColumnFilters get localCreatedAt => $composableBuilder( - column: $table.localCreatedAt, - builder: (column) => ColumnFilters(column)); + ColumnFilters get remoteCreatedAt => + $composableBuilder(column: $table.remoteCreatedAt, builder: (column) => ColumnFilters(column)); - ColumnFilters get remoteCreatedAt => $composableBuilder( - column: $table.remoteCreatedAt, - builder: (column) => ColumnFilters(column)); + ColumnFilters get localUpdatedAt => + $composableBuilder(column: $table.localUpdatedAt, builder: (column) => ColumnFilters(column)); - ColumnFilters get localUpdatedAt => $composableBuilder( - column: $table.localUpdatedAt, - builder: (column) => ColumnFilters(column)); + ColumnFilters get remoteUpdatedAt => + $composableBuilder(column: $table.remoteUpdatedAt, builder: (column) => ColumnFilters(column)); - ColumnFilters get remoteUpdatedAt => $composableBuilder( - column: $table.remoteUpdatedAt, - builder: (column) => ColumnFilters(column)); + ColumnFilters get localDeletedAt => + $composableBuilder(column: $table.localDeletedAt, builder: (column) => ColumnFilters(column)); - ColumnFilters get localDeletedAt => $composableBuilder( - column: $table.localDeletedAt, - builder: (column) => ColumnFilters(column)); + ColumnFilters get remoteDeletedAt => + $composableBuilder(column: $table.remoteDeletedAt, builder: (column) => ColumnFilters(column)); - ColumnFilters get remoteDeletedAt => $composableBuilder( - column: $table.remoteDeletedAt, - builder: (column) => ColumnFilters(column)); + ColumnFilters get deletedForMe => + $composableBuilder(column: $table.deletedForMe, builder: (column) => ColumnFilters(column)); - ColumnFilters get messageTextUpdatedAt => $composableBuilder( - column: $table.messageTextUpdatedAt, - builder: (column) => ColumnFilters(column)); + ColumnFilters get messageTextUpdatedAt => + $composableBuilder(column: $table.messageTextUpdatedAt, builder: (column) => ColumnFilters(column)); - ColumnFilters get userId => $composableBuilder( - column: $table.userId, builder: (column) => ColumnFilters(column)); + ColumnFilters get userId => + $composableBuilder(column: $table.userId, builder: (column) => ColumnFilters(column)); - ColumnFilters get channelRole => $composableBuilder( - column: $table.channelRole, builder: (column) => ColumnFilters(column)); + ColumnFilters get channelRole => + $composableBuilder(column: $table.channelRole, builder: (column) => ColumnFilters(column)); - ColumnFilters get pinned => $composableBuilder( - column: $table.pinned, builder: (column) => ColumnFilters(column)); + ColumnFilters get pinned => + $composableBuilder(column: $table.pinned, builder: (column) => ColumnFilters(column)); - ColumnFilters get pinnedAt => $composableBuilder( - column: $table.pinnedAt, builder: (column) => ColumnFilters(column)); + ColumnFilters get pinnedAt => + $composableBuilder(column: $table.pinnedAt, builder: (column) => ColumnFilters(column)); - ColumnFilters get pinExpires => $composableBuilder( - column: $table.pinExpires, builder: (column) => ColumnFilters(column)); + ColumnFilters get pinExpires => + $composableBuilder(column: $table.pinExpires, builder: (column) => ColumnFilters(column)); - ColumnFilters get pinnedByUserId => $composableBuilder( - column: $table.pinnedByUserId, - builder: (column) => ColumnFilters(column)); + ColumnFilters get pinnedByUserId => + $composableBuilder(column: $table.pinnedByUserId, builder: (column) => ColumnFilters(column)); - ColumnFilters get channelCid => $composableBuilder( - column: $table.channelCid, builder: (column) => ColumnFilters(column)); + ColumnFilters get channelCid => + $composableBuilder(column: $table.channelCid, builder: (column) => ColumnFilters(column)); - ColumnWithTypeConverterFilters?, Map, - String> - get i18n => $composableBuilder( - column: $table.i18n, - builder: (column) => ColumnWithTypeConverterFilters(column)); + ColumnWithTypeConverterFilters?, Map, String> get i18n => + $composableBuilder(column: $table.i18n, builder: (column) => ColumnWithTypeConverterFilters(column)); - ColumnWithTypeConverterFilters?, List, String> - get restrictedVisibility => $composableBuilder( - column: $table.restrictedVisibility, - builder: (column) => ColumnWithTypeConverterFilters(column)); + ColumnWithTypeConverterFilters?, List, String> get restrictedVisibility => $composableBuilder( + column: $table.restrictedVisibility, + builder: (column) => ColumnWithTypeConverterFilters(column), + ); - ColumnWithTypeConverterFilters?, Map, - String> - get extraData => $composableBuilder( - column: $table.extraData, - builder: (column) => ColumnWithTypeConverterFilters(column)); + ColumnWithTypeConverterFilters?, Map, String> get extraData => + $composableBuilder(column: $table.extraData, builder: (column) => ColumnWithTypeConverterFilters(column)); Expression pinnedMessageReactionsRefs( - Expression Function($$PinnedMessageReactionsTableFilterComposer f) - f) { - final $$PinnedMessageReactionsTableFilterComposer composer = - $composerBuilder( - composer: this, - getCurrentColumn: (t) => t.id, - referencedTable: $db.pinnedMessageReactions, - getReferencedColumn: (t) => t.messageId, - builder: (joinBuilder, - {$addJoinBuilderToRootComposer, - $removeJoinBuilderFromRootComposer}) => - $$PinnedMessageReactionsTableFilterComposer( - $db: $db, - $table: $db.pinnedMessageReactions, - $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, - joinBuilder: joinBuilder, - $removeJoinBuilderFromRootComposer: - $removeJoinBuilderFromRootComposer, - )); + Expression Function($$PinnedMessageReactionsTableFilterComposer f) f, + ) { + final $$PinnedMessageReactionsTableFilterComposer composer = $composerBuilder( + composer: this, + getCurrentColumn: (t) => t.id, + referencedTable: $db.pinnedMessageReactions, + getReferencedColumn: (t) => t.messageId, + builder: (joinBuilder, {$addJoinBuilderToRootComposer, $removeJoinBuilderFromRootComposer}) => + $$PinnedMessageReactionsTableFilterComposer( + $db: $db, + $table: $db.pinnedMessageReactions, + $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, + joinBuilder: joinBuilder, + $removeJoinBuilderFromRootComposer: $removeJoinBuilderFromRootComposer, + ), + ); return f(composer); } } -class $$PinnedMessagesTableOrderingComposer - extends Composer<_$DriftChatDatabase, $PinnedMessagesTable> { +class $$PinnedMessagesTableOrderingComposer extends Composer<_$DriftChatDatabase, $PinnedMessagesTable> { $$PinnedMessagesTableOrderingComposer({ required super.$db, required super.$table, @@ -11022,115 +11938,103 @@ class $$PinnedMessagesTableOrderingComposer super.$addJoinBuilderToRootComposer, super.$removeJoinBuilderFromRootComposer, }); - ColumnOrderings get id => $composableBuilder( - column: $table.id, builder: (column) => ColumnOrderings(column)); + ColumnOrderings get id => $composableBuilder(column: $table.id, builder: (column) => ColumnOrderings(column)); - ColumnOrderings get messageText => $composableBuilder( - column: $table.messageText, builder: (column) => ColumnOrderings(column)); + ColumnOrderings get messageText => + $composableBuilder(column: $table.messageText, builder: (column) => ColumnOrderings(column)); - ColumnOrderings get attachments => $composableBuilder( - column: $table.attachments, builder: (column) => ColumnOrderings(column)); + ColumnOrderings get attachments => + $composableBuilder(column: $table.attachments, builder: (column) => ColumnOrderings(column)); - ColumnOrderings get state => $composableBuilder( - column: $table.state, builder: (column) => ColumnOrderings(column)); + ColumnOrderings get state => + $composableBuilder(column: $table.state, builder: (column) => ColumnOrderings(column)); - ColumnOrderings get type => $composableBuilder( - column: $table.type, builder: (column) => ColumnOrderings(column)); + ColumnOrderings get type => + $composableBuilder(column: $table.type, builder: (column) => ColumnOrderings(column)); - ColumnOrderings get mentionedUsers => $composableBuilder( - column: $table.mentionedUsers, - builder: (column) => ColumnOrderings(column)); + ColumnOrderings get mentionedUsers => + $composableBuilder(column: $table.mentionedUsers, builder: (column) => ColumnOrderings(column)); - ColumnOrderings get reactionGroups => $composableBuilder( - column: $table.reactionGroups, - builder: (column) => ColumnOrderings(column)); + ColumnOrderings get reactionGroups => + $composableBuilder(column: $table.reactionGroups, builder: (column) => ColumnOrderings(column)); - ColumnOrderings get parentId => $composableBuilder( - column: $table.parentId, builder: (column) => ColumnOrderings(column)); + ColumnOrderings get parentId => + $composableBuilder(column: $table.parentId, builder: (column) => ColumnOrderings(column)); - ColumnOrderings get quotedMessageId => $composableBuilder( - column: $table.quotedMessageId, - builder: (column) => ColumnOrderings(column)); + ColumnOrderings get quotedMessageId => + $composableBuilder(column: $table.quotedMessageId, builder: (column) => ColumnOrderings(column)); - ColumnOrderings get pollId => $composableBuilder( - column: $table.pollId, builder: (column) => ColumnOrderings(column)); + ColumnOrderings get pollId => + $composableBuilder(column: $table.pollId, builder: (column) => ColumnOrderings(column)); - ColumnOrderings get replyCount => $composableBuilder( - column: $table.replyCount, builder: (column) => ColumnOrderings(column)); + ColumnOrderings get replyCount => + $composableBuilder(column: $table.replyCount, builder: (column) => ColumnOrderings(column)); - ColumnOrderings get showInChannel => $composableBuilder( - column: $table.showInChannel, - builder: (column) => ColumnOrderings(column)); + ColumnOrderings get showInChannel => + $composableBuilder(column: $table.showInChannel, builder: (column) => ColumnOrderings(column)); - ColumnOrderings get shadowed => $composableBuilder( - column: $table.shadowed, builder: (column) => ColumnOrderings(column)); + ColumnOrderings get shadowed => + $composableBuilder(column: $table.shadowed, builder: (column) => ColumnOrderings(column)); - ColumnOrderings get command => $composableBuilder( - column: $table.command, builder: (column) => ColumnOrderings(column)); + ColumnOrderings get command => + $composableBuilder(column: $table.command, builder: (column) => ColumnOrderings(column)); - ColumnOrderings get localCreatedAt => $composableBuilder( - column: $table.localCreatedAt, - builder: (column) => ColumnOrderings(column)); + ColumnOrderings get localCreatedAt => + $composableBuilder(column: $table.localCreatedAt, builder: (column) => ColumnOrderings(column)); - ColumnOrderings get remoteCreatedAt => $composableBuilder( - column: $table.remoteCreatedAt, - builder: (column) => ColumnOrderings(column)); + ColumnOrderings get remoteCreatedAt => + $composableBuilder(column: $table.remoteCreatedAt, builder: (column) => ColumnOrderings(column)); - ColumnOrderings get localUpdatedAt => $composableBuilder( - column: $table.localUpdatedAt, - builder: (column) => ColumnOrderings(column)); + ColumnOrderings get localUpdatedAt => + $composableBuilder(column: $table.localUpdatedAt, builder: (column) => ColumnOrderings(column)); - ColumnOrderings get remoteUpdatedAt => $composableBuilder( - column: $table.remoteUpdatedAt, - builder: (column) => ColumnOrderings(column)); + ColumnOrderings get remoteUpdatedAt => + $composableBuilder(column: $table.remoteUpdatedAt, builder: (column) => ColumnOrderings(column)); - ColumnOrderings get localDeletedAt => $composableBuilder( - column: $table.localDeletedAt, - builder: (column) => ColumnOrderings(column)); + ColumnOrderings get localDeletedAt => + $composableBuilder(column: $table.localDeletedAt, builder: (column) => ColumnOrderings(column)); - ColumnOrderings get remoteDeletedAt => $composableBuilder( - column: $table.remoteDeletedAt, - builder: (column) => ColumnOrderings(column)); + ColumnOrderings get remoteDeletedAt => + $composableBuilder(column: $table.remoteDeletedAt, builder: (column) => ColumnOrderings(column)); - ColumnOrderings get messageTextUpdatedAt => $composableBuilder( - column: $table.messageTextUpdatedAt, - builder: (column) => ColumnOrderings(column)); + ColumnOrderings get deletedForMe => + $composableBuilder(column: $table.deletedForMe, builder: (column) => ColumnOrderings(column)); - ColumnOrderings get userId => $composableBuilder( - column: $table.userId, builder: (column) => ColumnOrderings(column)); + ColumnOrderings get messageTextUpdatedAt => + $composableBuilder(column: $table.messageTextUpdatedAt, builder: (column) => ColumnOrderings(column)); - ColumnOrderings get channelRole => $composableBuilder( - column: $table.channelRole, builder: (column) => ColumnOrderings(column)); + ColumnOrderings get userId => + $composableBuilder(column: $table.userId, builder: (column) => ColumnOrderings(column)); - ColumnOrderings get pinned => $composableBuilder( - column: $table.pinned, builder: (column) => ColumnOrderings(column)); + ColumnOrderings get channelRole => + $composableBuilder(column: $table.channelRole, builder: (column) => ColumnOrderings(column)); - ColumnOrderings get pinnedAt => $composableBuilder( - column: $table.pinnedAt, builder: (column) => ColumnOrderings(column)); + ColumnOrderings get pinned => + $composableBuilder(column: $table.pinned, builder: (column) => ColumnOrderings(column)); - ColumnOrderings get pinExpires => $composableBuilder( - column: $table.pinExpires, builder: (column) => ColumnOrderings(column)); + ColumnOrderings get pinnedAt => + $composableBuilder(column: $table.pinnedAt, builder: (column) => ColumnOrderings(column)); - ColumnOrderings get pinnedByUserId => $composableBuilder( - column: $table.pinnedByUserId, - builder: (column) => ColumnOrderings(column)); + ColumnOrderings get pinExpires => + $composableBuilder(column: $table.pinExpires, builder: (column) => ColumnOrderings(column)); - ColumnOrderings get channelCid => $composableBuilder( - column: $table.channelCid, builder: (column) => ColumnOrderings(column)); + ColumnOrderings get pinnedByUserId => + $composableBuilder(column: $table.pinnedByUserId, builder: (column) => ColumnOrderings(column)); - ColumnOrderings get i18n => $composableBuilder( - column: $table.i18n, builder: (column) => ColumnOrderings(column)); + ColumnOrderings get channelCid => + $composableBuilder(column: $table.channelCid, builder: (column) => ColumnOrderings(column)); - ColumnOrderings get restrictedVisibility => $composableBuilder( - column: $table.restrictedVisibility, - builder: (column) => ColumnOrderings(column)); + ColumnOrderings get i18n => + $composableBuilder(column: $table.i18n, builder: (column) => ColumnOrderings(column)); - ColumnOrderings get extraData => $composableBuilder( - column: $table.extraData, builder: (column) => ColumnOrderings(column)); + ColumnOrderings get restrictedVisibility => + $composableBuilder(column: $table.restrictedVisibility, builder: (column) => ColumnOrderings(column)); + + ColumnOrderings get extraData => + $composableBuilder(column: $table.extraData, builder: (column) => ColumnOrderings(column)); } -class $$PinnedMessagesTableAnnotationComposer - extends Composer<_$DriftChatDatabase, $PinnedMessagesTable> { +class $$PinnedMessagesTableAnnotationComposer extends Composer<_$DriftChatDatabase, $PinnedMessagesTable> { $$PinnedMessagesTableAnnotationComposer({ required super.$db, required super.$table, @@ -11138,398 +12042,376 @@ class $$PinnedMessagesTableAnnotationComposer super.$addJoinBuilderToRootComposer, super.$removeJoinBuilderFromRootComposer, }); - GeneratedColumn get id => - $composableBuilder(column: $table.id, builder: (column) => column); + GeneratedColumn get id => $composableBuilder(column: $table.id, builder: (column) => column); - GeneratedColumn get messageText => $composableBuilder( - column: $table.messageText, builder: (column) => column); + GeneratedColumn get messageText => + $composableBuilder(column: $table.messageText, builder: (column) => column); GeneratedColumnWithTypeConverter, String> get attachments => - $composableBuilder( - column: $table.attachments, builder: (column) => column); + $composableBuilder(column: $table.attachments, builder: (column) => column); - GeneratedColumn get state => - $composableBuilder(column: $table.state, builder: (column) => column); + GeneratedColumn get state => $composableBuilder(column: $table.state, builder: (column) => column); - GeneratedColumn get type => - $composableBuilder(column: $table.type, builder: (column) => column); + GeneratedColumn get type => $composableBuilder(column: $table.type, builder: (column) => column); GeneratedColumnWithTypeConverter, String> get mentionedUsers => - $composableBuilder( - column: $table.mentionedUsers, builder: (column) => column); + $composableBuilder(column: $table.mentionedUsers, builder: (column) => column); + + GeneratedColumnWithTypeConverter?, String> get reactionGroups => + $composableBuilder(column: $table.reactionGroups, builder: (column) => column); - GeneratedColumnWithTypeConverter?, String> - get reactionGroups => $composableBuilder( - column: $table.reactionGroups, builder: (column) => column); + GeneratedColumn get parentId => $composableBuilder(column: $table.parentId, builder: (column) => column); - GeneratedColumn get parentId => - $composableBuilder(column: $table.parentId, builder: (column) => column); + GeneratedColumn get quotedMessageId => + $composableBuilder(column: $table.quotedMessageId, builder: (column) => column); - GeneratedColumn get quotedMessageId => $composableBuilder( - column: $table.quotedMessageId, builder: (column) => column); + GeneratedColumn get pollId => $composableBuilder(column: $table.pollId, builder: (column) => column); - GeneratedColumn get pollId => - $composableBuilder(column: $table.pollId, builder: (column) => column); + GeneratedColumn get replyCount => $composableBuilder(column: $table.replyCount, builder: (column) => column); - GeneratedColumn get replyCount => $composableBuilder( - column: $table.replyCount, builder: (column) => column); + GeneratedColumn get showInChannel => + $composableBuilder(column: $table.showInChannel, builder: (column) => column); - GeneratedColumn get showInChannel => $composableBuilder( - column: $table.showInChannel, builder: (column) => column); + GeneratedColumn get shadowed => $composableBuilder(column: $table.shadowed, builder: (column) => column); - GeneratedColumn get shadowed => - $composableBuilder(column: $table.shadowed, builder: (column) => column); + GeneratedColumn get command => $composableBuilder(column: $table.command, builder: (column) => column); - GeneratedColumn get command => - $composableBuilder(column: $table.command, builder: (column) => column); + GeneratedColumn get localCreatedAt => + $composableBuilder(column: $table.localCreatedAt, builder: (column) => column); - GeneratedColumn get localCreatedAt => $composableBuilder( - column: $table.localCreatedAt, builder: (column) => column); + GeneratedColumn get remoteCreatedAt => + $composableBuilder(column: $table.remoteCreatedAt, builder: (column) => column); - GeneratedColumn get remoteCreatedAt => $composableBuilder( - column: $table.remoteCreatedAt, builder: (column) => column); + GeneratedColumn get localUpdatedAt => + $composableBuilder(column: $table.localUpdatedAt, builder: (column) => column); - GeneratedColumn get localUpdatedAt => $composableBuilder( - column: $table.localUpdatedAt, builder: (column) => column); + GeneratedColumn get remoteUpdatedAt => + $composableBuilder(column: $table.remoteUpdatedAt, builder: (column) => column); - GeneratedColumn get remoteUpdatedAt => $composableBuilder( - column: $table.remoteUpdatedAt, builder: (column) => column); + GeneratedColumn get localDeletedAt => + $composableBuilder(column: $table.localDeletedAt, builder: (column) => column); - GeneratedColumn get localDeletedAt => $composableBuilder( - column: $table.localDeletedAt, builder: (column) => column); + GeneratedColumn get remoteDeletedAt => + $composableBuilder(column: $table.remoteDeletedAt, builder: (column) => column); - GeneratedColumn get remoteDeletedAt => $composableBuilder( - column: $table.remoteDeletedAt, builder: (column) => column); + GeneratedColumn get deletedForMe => + $composableBuilder(column: $table.deletedForMe, builder: (column) => column); - GeneratedColumn get messageTextUpdatedAt => $composableBuilder( - column: $table.messageTextUpdatedAt, builder: (column) => column); + GeneratedColumn get messageTextUpdatedAt => + $composableBuilder(column: $table.messageTextUpdatedAt, builder: (column) => column); - GeneratedColumn get userId => - $composableBuilder(column: $table.userId, builder: (column) => column); + GeneratedColumn get userId => $composableBuilder(column: $table.userId, builder: (column) => column); - GeneratedColumn get channelRole => $composableBuilder( - column: $table.channelRole, builder: (column) => column); + GeneratedColumn get channelRole => + $composableBuilder(column: $table.channelRole, builder: (column) => column); - GeneratedColumn get pinned => - $composableBuilder(column: $table.pinned, builder: (column) => column); + GeneratedColumn get pinned => $composableBuilder(column: $table.pinned, builder: (column) => column); - GeneratedColumn get pinnedAt => - $composableBuilder(column: $table.pinnedAt, builder: (column) => column); + GeneratedColumn get pinnedAt => $composableBuilder(column: $table.pinnedAt, builder: (column) => column); - GeneratedColumn get pinExpires => $composableBuilder( - column: $table.pinExpires, builder: (column) => column); + GeneratedColumn get pinExpires => + $composableBuilder(column: $table.pinExpires, builder: (column) => column); - GeneratedColumn get pinnedByUserId => $composableBuilder( - column: $table.pinnedByUserId, builder: (column) => column); + GeneratedColumn get pinnedByUserId => + $composableBuilder(column: $table.pinnedByUserId, builder: (column) => column); - GeneratedColumn get channelCid => $composableBuilder( - column: $table.channelCid, builder: (column) => column); + GeneratedColumn get channelCid => $composableBuilder(column: $table.channelCid, builder: (column) => column); GeneratedColumnWithTypeConverter?, String> get i18n => $composableBuilder(column: $table.i18n, builder: (column) => column); - GeneratedColumnWithTypeConverter?, String> - get restrictedVisibility => $composableBuilder( - column: $table.restrictedVisibility, builder: (column) => column); + GeneratedColumnWithTypeConverter?, String> get restrictedVisibility => + $composableBuilder(column: $table.restrictedVisibility, builder: (column) => column); - GeneratedColumnWithTypeConverter?, String> - get extraData => $composableBuilder( - column: $table.extraData, builder: (column) => column); + GeneratedColumnWithTypeConverter?, String> get extraData => + $composableBuilder(column: $table.extraData, builder: (column) => column); Expression pinnedMessageReactionsRefs( - Expression Function($$PinnedMessageReactionsTableAnnotationComposer a) - f) { - final $$PinnedMessageReactionsTableAnnotationComposer composer = - $composerBuilder( - composer: this, - getCurrentColumn: (t) => t.id, - referencedTable: $db.pinnedMessageReactions, - getReferencedColumn: (t) => t.messageId, - builder: (joinBuilder, - {$addJoinBuilderToRootComposer, - $removeJoinBuilderFromRootComposer}) => - $$PinnedMessageReactionsTableAnnotationComposer( - $db: $db, - $table: $db.pinnedMessageReactions, - $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, - joinBuilder: joinBuilder, - $removeJoinBuilderFromRootComposer: - $removeJoinBuilderFromRootComposer, - )); + Expression Function($$PinnedMessageReactionsTableAnnotationComposer a) f, + ) { + final $$PinnedMessageReactionsTableAnnotationComposer composer = $composerBuilder( + composer: this, + getCurrentColumn: (t) => t.id, + referencedTable: $db.pinnedMessageReactions, + getReferencedColumn: (t) => t.messageId, + builder: (joinBuilder, {$addJoinBuilderToRootComposer, $removeJoinBuilderFromRootComposer}) => + $$PinnedMessageReactionsTableAnnotationComposer( + $db: $db, + $table: $db.pinnedMessageReactions, + $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, + joinBuilder: joinBuilder, + $removeJoinBuilderFromRootComposer: $removeJoinBuilderFromRootComposer, + ), + ); return f(composer); } } -class $$PinnedMessagesTableTableManager extends RootTableManager< - _$DriftChatDatabase, - $PinnedMessagesTable, - PinnedMessageEntity, - $$PinnedMessagesTableFilterComposer, - $$PinnedMessagesTableOrderingComposer, - $$PinnedMessagesTableAnnotationComposer, - $$PinnedMessagesTableCreateCompanionBuilder, - $$PinnedMessagesTableUpdateCompanionBuilder, - (PinnedMessageEntity, $$PinnedMessagesTableReferences), - PinnedMessageEntity, - PrefetchHooks Function({bool pinnedMessageReactionsRefs})> { - $$PinnedMessagesTableTableManager( - _$DriftChatDatabase db, $PinnedMessagesTable table) - : super(TableManagerState( +class $$PinnedMessagesTableTableManager + extends + RootTableManager< + _$DriftChatDatabase, + $PinnedMessagesTable, + PinnedMessageEntity, + $$PinnedMessagesTableFilterComposer, + $$PinnedMessagesTableOrderingComposer, + $$PinnedMessagesTableAnnotationComposer, + $$PinnedMessagesTableCreateCompanionBuilder, + $$PinnedMessagesTableUpdateCompanionBuilder, + (PinnedMessageEntity, $$PinnedMessagesTableReferences), + PinnedMessageEntity, + PrefetchHooks Function({bool pinnedMessageReactionsRefs}) + > { + $$PinnedMessagesTableTableManager(_$DriftChatDatabase db, $PinnedMessagesTable table) + : super( + TableManagerState( db: db, table: table, - createFilteringComposer: () => - $$PinnedMessagesTableFilterComposer($db: db, $table: table), - createOrderingComposer: () => - $$PinnedMessagesTableOrderingComposer($db: db, $table: table), - createComputedFieldComposer: () => - $$PinnedMessagesTableAnnotationComposer($db: db, $table: table), - updateCompanionCallback: ({ - Value id = const Value.absent(), - Value messageText = const Value.absent(), - Value> attachments = const Value.absent(), - Value state = const Value.absent(), - Value type = const Value.absent(), - Value> mentionedUsers = const Value.absent(), - Value?> reactionGroups = - const Value.absent(), - Value parentId = const Value.absent(), - Value quotedMessageId = const Value.absent(), - Value pollId = const Value.absent(), - Value replyCount = const Value.absent(), - Value showInChannel = const Value.absent(), - Value shadowed = const Value.absent(), - Value command = const Value.absent(), - Value localCreatedAt = const Value.absent(), - Value remoteCreatedAt = const Value.absent(), - Value localUpdatedAt = const Value.absent(), - Value remoteUpdatedAt = const Value.absent(), - Value localDeletedAt = const Value.absent(), - Value remoteDeletedAt = const Value.absent(), - Value messageTextUpdatedAt = const Value.absent(), - Value userId = const Value.absent(), - Value channelRole = const Value.absent(), - Value pinned = const Value.absent(), - Value pinnedAt = const Value.absent(), - Value pinExpires = const Value.absent(), - Value pinnedByUserId = const Value.absent(), - Value channelCid = const Value.absent(), - Value?> i18n = const Value.absent(), - Value?> restrictedVisibility = const Value.absent(), - Value?> extraData = const Value.absent(), - Value rowid = const Value.absent(), - }) => - PinnedMessagesCompanion( - id: id, - messageText: messageText, - attachments: attachments, - state: state, - type: type, - mentionedUsers: mentionedUsers, - reactionGroups: reactionGroups, - parentId: parentId, - quotedMessageId: quotedMessageId, - pollId: pollId, - replyCount: replyCount, - showInChannel: showInChannel, - shadowed: shadowed, - command: command, - localCreatedAt: localCreatedAt, - remoteCreatedAt: remoteCreatedAt, - localUpdatedAt: localUpdatedAt, - remoteUpdatedAt: remoteUpdatedAt, - localDeletedAt: localDeletedAt, - remoteDeletedAt: remoteDeletedAt, - messageTextUpdatedAt: messageTextUpdatedAt, - userId: userId, - channelRole: channelRole, - pinned: pinned, - pinnedAt: pinnedAt, - pinExpires: pinExpires, - pinnedByUserId: pinnedByUserId, - channelCid: channelCid, - i18n: i18n, - restrictedVisibility: restrictedVisibility, - extraData: extraData, - rowid: rowid, - ), - createCompanionCallback: ({ - required String id, - Value messageText = const Value.absent(), - required List attachments, - required String state, - Value type = const Value.absent(), - required List mentionedUsers, - Value?> reactionGroups = - const Value.absent(), - Value parentId = const Value.absent(), - Value quotedMessageId = const Value.absent(), - Value pollId = const Value.absent(), - Value replyCount = const Value.absent(), - Value showInChannel = const Value.absent(), - Value shadowed = const Value.absent(), - Value command = const Value.absent(), - Value localCreatedAt = const Value.absent(), - Value remoteCreatedAt = const Value.absent(), - Value localUpdatedAt = const Value.absent(), - Value remoteUpdatedAt = const Value.absent(), - Value localDeletedAt = const Value.absent(), - Value remoteDeletedAt = const Value.absent(), - Value messageTextUpdatedAt = const Value.absent(), - Value userId = const Value.absent(), - Value channelRole = const Value.absent(), - Value pinned = const Value.absent(), - Value pinnedAt = const Value.absent(), - Value pinExpires = const Value.absent(), - Value pinnedByUserId = const Value.absent(), - required String channelCid, - Value?> i18n = const Value.absent(), - Value?> restrictedVisibility = const Value.absent(), - Value?> extraData = const Value.absent(), - Value rowid = const Value.absent(), - }) => - PinnedMessagesCompanion.insert( - id: id, - messageText: messageText, - attachments: attachments, - state: state, - type: type, - mentionedUsers: mentionedUsers, - reactionGroups: reactionGroups, - parentId: parentId, - quotedMessageId: quotedMessageId, - pollId: pollId, - replyCount: replyCount, - showInChannel: showInChannel, - shadowed: shadowed, - command: command, - localCreatedAt: localCreatedAt, - remoteCreatedAt: remoteCreatedAt, - localUpdatedAt: localUpdatedAt, - remoteUpdatedAt: remoteUpdatedAt, - localDeletedAt: localDeletedAt, - remoteDeletedAt: remoteDeletedAt, - messageTextUpdatedAt: messageTextUpdatedAt, - userId: userId, - channelRole: channelRole, - pinned: pinned, - pinnedAt: pinnedAt, - pinExpires: pinExpires, - pinnedByUserId: pinnedByUserId, - channelCid: channelCid, - i18n: i18n, - restrictedVisibility: restrictedVisibility, - extraData: extraData, - rowid: rowid, - ), - withReferenceMapper: (p0) => p0 - .map((e) => ( - e.readTable(table), - $$PinnedMessagesTableReferences(db, table, e) - )) - .toList(), + createFilteringComposer: () => $$PinnedMessagesTableFilterComposer($db: db, $table: table), + createOrderingComposer: () => $$PinnedMessagesTableOrderingComposer($db: db, $table: table), + createComputedFieldComposer: () => $$PinnedMessagesTableAnnotationComposer($db: db, $table: table), + updateCompanionCallback: + ({ + Value id = const Value.absent(), + Value messageText = const Value.absent(), + Value> attachments = const Value.absent(), + Value state = const Value.absent(), + Value type = const Value.absent(), + Value> mentionedUsers = const Value.absent(), + Value?> reactionGroups = const Value.absent(), + Value parentId = const Value.absent(), + Value quotedMessageId = const Value.absent(), + Value pollId = const Value.absent(), + Value replyCount = const Value.absent(), + Value showInChannel = const Value.absent(), + Value shadowed = const Value.absent(), + Value command = const Value.absent(), + Value localCreatedAt = const Value.absent(), + Value remoteCreatedAt = const Value.absent(), + Value localUpdatedAt = const Value.absent(), + Value remoteUpdatedAt = const Value.absent(), + Value localDeletedAt = const Value.absent(), + Value remoteDeletedAt = const Value.absent(), + Value deletedForMe = const Value.absent(), + Value messageTextUpdatedAt = const Value.absent(), + Value userId = const Value.absent(), + Value channelRole = const Value.absent(), + Value pinned = const Value.absent(), + Value pinnedAt = const Value.absent(), + Value pinExpires = const Value.absent(), + Value pinnedByUserId = const Value.absent(), + Value channelCid = const Value.absent(), + Value?> i18n = const Value.absent(), + Value?> restrictedVisibility = const Value.absent(), + Value?> extraData = const Value.absent(), + Value rowid = const Value.absent(), + }) => PinnedMessagesCompanion( + id: id, + messageText: messageText, + attachments: attachments, + state: state, + type: type, + mentionedUsers: mentionedUsers, + reactionGroups: reactionGroups, + parentId: parentId, + quotedMessageId: quotedMessageId, + pollId: pollId, + replyCount: replyCount, + showInChannel: showInChannel, + shadowed: shadowed, + command: command, + localCreatedAt: localCreatedAt, + remoteCreatedAt: remoteCreatedAt, + localUpdatedAt: localUpdatedAt, + remoteUpdatedAt: remoteUpdatedAt, + localDeletedAt: localDeletedAt, + remoteDeletedAt: remoteDeletedAt, + deletedForMe: deletedForMe, + messageTextUpdatedAt: messageTextUpdatedAt, + userId: userId, + channelRole: channelRole, + pinned: pinned, + pinnedAt: pinnedAt, + pinExpires: pinExpires, + pinnedByUserId: pinnedByUserId, + channelCid: channelCid, + i18n: i18n, + restrictedVisibility: restrictedVisibility, + extraData: extraData, + rowid: rowid, + ), + createCompanionCallback: + ({ + required String id, + Value messageText = const Value.absent(), + required List attachments, + required String state, + Value type = const Value.absent(), + required List mentionedUsers, + Value?> reactionGroups = const Value.absent(), + Value parentId = const Value.absent(), + Value quotedMessageId = const Value.absent(), + Value pollId = const Value.absent(), + Value replyCount = const Value.absent(), + Value showInChannel = const Value.absent(), + Value shadowed = const Value.absent(), + Value command = const Value.absent(), + Value localCreatedAt = const Value.absent(), + Value remoteCreatedAt = const Value.absent(), + Value localUpdatedAt = const Value.absent(), + Value remoteUpdatedAt = const Value.absent(), + Value localDeletedAt = const Value.absent(), + Value remoteDeletedAt = const Value.absent(), + Value deletedForMe = const Value.absent(), + Value messageTextUpdatedAt = const Value.absent(), + Value userId = const Value.absent(), + Value channelRole = const Value.absent(), + Value pinned = const Value.absent(), + Value pinnedAt = const Value.absent(), + Value pinExpires = const Value.absent(), + Value pinnedByUserId = const Value.absent(), + required String channelCid, + Value?> i18n = const Value.absent(), + Value?> restrictedVisibility = const Value.absent(), + Value?> extraData = const Value.absent(), + Value rowid = const Value.absent(), + }) => PinnedMessagesCompanion.insert( + id: id, + messageText: messageText, + attachments: attachments, + state: state, + type: type, + mentionedUsers: mentionedUsers, + reactionGroups: reactionGroups, + parentId: parentId, + quotedMessageId: quotedMessageId, + pollId: pollId, + replyCount: replyCount, + showInChannel: showInChannel, + shadowed: shadowed, + command: command, + localCreatedAt: localCreatedAt, + remoteCreatedAt: remoteCreatedAt, + localUpdatedAt: localUpdatedAt, + remoteUpdatedAt: remoteUpdatedAt, + localDeletedAt: localDeletedAt, + remoteDeletedAt: remoteDeletedAt, + deletedForMe: deletedForMe, + messageTextUpdatedAt: messageTextUpdatedAt, + userId: userId, + channelRole: channelRole, + pinned: pinned, + pinnedAt: pinnedAt, + pinExpires: pinExpires, + pinnedByUserId: pinnedByUserId, + channelCid: channelCid, + i18n: i18n, + restrictedVisibility: restrictedVisibility, + extraData: extraData, + rowid: rowid, + ), + withReferenceMapper: (p0) => + p0.map((e) => (e.readTable(table), $$PinnedMessagesTableReferences(db, table, e))).toList(), prefetchHooksCallback: ({pinnedMessageReactionsRefs = false}) { return PrefetchHooks( db: db, - explicitlyWatchedTables: [ - if (pinnedMessageReactionsRefs) db.pinnedMessageReactions - ], + explicitlyWatchedTables: [if (pinnedMessageReactionsRefs) db.pinnedMessageReactions], addJoins: null, getPrefetchedDataCallback: (items) async { return [ if (pinnedMessageReactionsRefs) - await $_getPrefetchedData( - currentTable: table, - referencedTable: $$PinnedMessagesTableReferences - ._pinnedMessageReactionsRefsTable(db), - managerFromTypedResult: (p0) => - $$PinnedMessagesTableReferences(db, table, p0) - .pinnedMessageReactionsRefs, - referencedItemsForCurrentItem: - (item, referencedItems) => referencedItems - .where((e) => e.messageId == item.id), - typedResults: items) + await $_getPrefetchedData( + currentTable: table, + referencedTable: $$PinnedMessagesTableReferences._pinnedMessageReactionsRefsTable(db), + managerFromTypedResult: (p0) => + $$PinnedMessagesTableReferences(db, table, p0).pinnedMessageReactionsRefs, + referencedItemsForCurrentItem: (item, referencedItems) => + referencedItems.where((e) => e.messageId == item.id), + typedResults: items, + ), ]; }, ); }, - )); + ), + ); } -typedef $$PinnedMessagesTableProcessedTableManager = ProcessedTableManager< - _$DriftChatDatabase, - $PinnedMessagesTable, - PinnedMessageEntity, - $$PinnedMessagesTableFilterComposer, - $$PinnedMessagesTableOrderingComposer, - $$PinnedMessagesTableAnnotationComposer, - $$PinnedMessagesTableCreateCompanionBuilder, - $$PinnedMessagesTableUpdateCompanionBuilder, - (PinnedMessageEntity, $$PinnedMessagesTableReferences), - PinnedMessageEntity, - PrefetchHooks Function({bool pinnedMessageReactionsRefs})>; -typedef $$PollsTableCreateCompanionBuilder = PollsCompanion Function({ - required String id, - required String name, - Value description, - required List options, - Value votingVisibility, - Value enforceUniqueVote, - Value maxVotesAllowed, - Value allowUserSuggestedOptions, - Value allowAnswers, - Value isClosed, - Value answersCount, - required Map voteCountsByOption, - Value voteCount, - Value createdById, - Value createdAt, - Value updatedAt, - Value?> extraData, - Value rowid, -}); -typedef $$PollsTableUpdateCompanionBuilder = PollsCompanion Function({ - Value id, - Value name, - Value description, - Value> options, - Value votingVisibility, - Value enforceUniqueVote, - Value maxVotesAllowed, - Value allowUserSuggestedOptions, - Value allowAnswers, - Value isClosed, - Value answersCount, - Value> voteCountsByOption, - Value voteCount, - Value createdById, - Value createdAt, - Value updatedAt, - Value?> extraData, - Value rowid, -}); - -final class $$PollsTableReferences - extends BaseReferences<_$DriftChatDatabase, $PollsTable, PollEntity> { +typedef $$PinnedMessagesTableProcessedTableManager = + ProcessedTableManager< + _$DriftChatDatabase, + $PinnedMessagesTable, + PinnedMessageEntity, + $$PinnedMessagesTableFilterComposer, + $$PinnedMessagesTableOrderingComposer, + $$PinnedMessagesTableAnnotationComposer, + $$PinnedMessagesTableCreateCompanionBuilder, + $$PinnedMessagesTableUpdateCompanionBuilder, + (PinnedMessageEntity, $$PinnedMessagesTableReferences), + PinnedMessageEntity, + PrefetchHooks Function({bool pinnedMessageReactionsRefs}) + >; +typedef $$PollsTableCreateCompanionBuilder = + PollsCompanion Function({ + required String id, + required String name, + Value description, + required List options, + Value votingVisibility, + Value enforceUniqueVote, + Value maxVotesAllowed, + Value allowUserSuggestedOptions, + Value allowAnswers, + Value isClosed, + Value answersCount, + required Map voteCountsByOption, + Value voteCount, + Value createdById, + Value createdAt, + Value updatedAt, + Value?> extraData, + Value rowid, + }); +typedef $$PollsTableUpdateCompanionBuilder = + PollsCompanion Function({ + Value id, + Value name, + Value description, + Value> options, + Value votingVisibility, + Value enforceUniqueVote, + Value maxVotesAllowed, + Value allowUserSuggestedOptions, + Value allowAnswers, + Value isClosed, + Value answersCount, + Value> voteCountsByOption, + Value voteCount, + Value createdById, + Value createdAt, + Value updatedAt, + Value?> extraData, + Value rowid, + }); + +final class $$PollsTableReferences extends BaseReferences<_$DriftChatDatabase, $PollsTable, PollEntity> { $$PollsTableReferences(super.$_db, super.$_table, super.$_typedResult); - static MultiTypedResultKey<$PollVotesTable, List> - _pollVotesRefsTable(_$DriftChatDatabase db) => - MultiTypedResultKey.fromTable(db.pollVotes, - aliasName: - $_aliasNameGenerator(db.polls.id, db.pollVotes.pollId)); + static MultiTypedResultKey<$PollVotesTable, List> _pollVotesRefsTable(_$DriftChatDatabase db) => + MultiTypedResultKey.fromTable(db.pollVotes, aliasName: $_aliasNameGenerator(db.polls.id, db.pollVotes.pollId)); $$PollVotesTableProcessedTableManager get pollVotesRefs { - final manager = $$PollVotesTableTableManager($_db, $_db.pollVotes) - .filter((f) => f.pollId.id($_item.id)); + final manager = $$PollVotesTableTableManager( + $_db, + $_db.pollVotes, + ).filter((f) => f.pollId.id.sqlEquals($_itemColumn('id')!)); final cache = $_typedResult.readTableOrNull(_pollVotesRefsTable($_db)); - return ProcessedTableManager( - manager.$state.copyWith(prefetchedData: cache)); + return ProcessedTableManager(manager.$state.copyWith(prefetchedData: cache)); } } -class $$PollsTableFilterComposer - extends Composer<_$DriftChatDatabase, $PollsTable> { +class $$PollsTableFilterComposer extends Composer<_$DriftChatDatabase, $PollsTable> { $$PollsTableFilterComposer({ required super.$db, required super.$table, @@ -11537,93 +12419,78 @@ class $$PollsTableFilterComposer super.$addJoinBuilderToRootComposer, super.$removeJoinBuilderFromRootComposer, }); - ColumnFilters get id => $composableBuilder( - column: $table.id, builder: (column) => ColumnFilters(column)); + ColumnFilters get id => $composableBuilder(column: $table.id, builder: (column) => ColumnFilters(column)); - ColumnFilters get name => $composableBuilder( - column: $table.name, builder: (column) => ColumnFilters(column)); + ColumnFilters get name => $composableBuilder(column: $table.name, builder: (column) => ColumnFilters(column)); - ColumnFilters get description => $composableBuilder( - column: $table.description, builder: (column) => ColumnFilters(column)); + ColumnFilters get description => + $composableBuilder(column: $table.description, builder: (column) => ColumnFilters(column)); - ColumnWithTypeConverterFilters, List, String> - get options => $composableBuilder( - column: $table.options, - builder: (column) => ColumnWithTypeConverterFilters(column)); + ColumnWithTypeConverterFilters, List, String> get options => + $composableBuilder(column: $table.options, builder: (column) => ColumnWithTypeConverterFilters(column)); - ColumnWithTypeConverterFilters - get votingVisibility => $composableBuilder( - column: $table.votingVisibility, - builder: (column) => ColumnWithTypeConverterFilters(column)); + ColumnWithTypeConverterFilters get votingVisibility => + $composableBuilder(column: $table.votingVisibility, builder: (column) => ColumnWithTypeConverterFilters(column)); - ColumnFilters get enforceUniqueVote => $composableBuilder( - column: $table.enforceUniqueVote, - builder: (column) => ColumnFilters(column)); + ColumnFilters get enforceUniqueVote => + $composableBuilder(column: $table.enforceUniqueVote, builder: (column) => ColumnFilters(column)); - ColumnFilters get maxVotesAllowed => $composableBuilder( - column: $table.maxVotesAllowed, - builder: (column) => ColumnFilters(column)); + ColumnFilters get maxVotesAllowed => + $composableBuilder(column: $table.maxVotesAllowed, builder: (column) => ColumnFilters(column)); - ColumnFilters get allowUserSuggestedOptions => $composableBuilder( - column: $table.allowUserSuggestedOptions, - builder: (column) => ColumnFilters(column)); + ColumnFilters get allowUserSuggestedOptions => + $composableBuilder(column: $table.allowUserSuggestedOptions, builder: (column) => ColumnFilters(column)); - ColumnFilters get allowAnswers => $composableBuilder( - column: $table.allowAnswers, builder: (column) => ColumnFilters(column)); + ColumnFilters get allowAnswers => + $composableBuilder(column: $table.allowAnswers, builder: (column) => ColumnFilters(column)); - ColumnFilters get isClosed => $composableBuilder( - column: $table.isClosed, builder: (column) => ColumnFilters(column)); + ColumnFilters get isClosed => + $composableBuilder(column: $table.isClosed, builder: (column) => ColumnFilters(column)); - ColumnFilters get answersCount => $composableBuilder( - column: $table.answersCount, builder: (column) => ColumnFilters(column)); + ColumnFilters get answersCount => + $composableBuilder(column: $table.answersCount, builder: (column) => ColumnFilters(column)); - ColumnWithTypeConverterFilters, Map, String> - get voteCountsByOption => $composableBuilder( - column: $table.voteCountsByOption, - builder: (column) => ColumnWithTypeConverterFilters(column)); + ColumnWithTypeConverterFilters, Map, String> get voteCountsByOption => + $composableBuilder( + column: $table.voteCountsByOption, + builder: (column) => ColumnWithTypeConverterFilters(column), + ); - ColumnFilters get voteCount => $composableBuilder( - column: $table.voteCount, builder: (column) => ColumnFilters(column)); + ColumnFilters get voteCount => + $composableBuilder(column: $table.voteCount, builder: (column) => ColumnFilters(column)); - ColumnFilters get createdById => $composableBuilder( - column: $table.createdById, builder: (column) => ColumnFilters(column)); + ColumnFilters get createdById => + $composableBuilder(column: $table.createdById, builder: (column) => ColumnFilters(column)); - ColumnFilters get createdAt => $composableBuilder( - column: $table.createdAt, builder: (column) => ColumnFilters(column)); + ColumnFilters get createdAt => + $composableBuilder(column: $table.createdAt, builder: (column) => ColumnFilters(column)); - ColumnFilters get updatedAt => $composableBuilder( - column: $table.updatedAt, builder: (column) => ColumnFilters(column)); + ColumnFilters get updatedAt => + $composableBuilder(column: $table.updatedAt, builder: (column) => ColumnFilters(column)); - ColumnWithTypeConverterFilters?, Map, - String> - get extraData => $composableBuilder( - column: $table.extraData, - builder: (column) => ColumnWithTypeConverterFilters(column)); + ColumnWithTypeConverterFilters?, Map, String> get extraData => + $composableBuilder(column: $table.extraData, builder: (column) => ColumnWithTypeConverterFilters(column)); - Expression pollVotesRefs( - Expression Function($$PollVotesTableFilterComposer f) f) { + Expression pollVotesRefs(Expression Function($$PollVotesTableFilterComposer f) f) { final $$PollVotesTableFilterComposer composer = $composerBuilder( - composer: this, - getCurrentColumn: (t) => t.id, - referencedTable: $db.pollVotes, - getReferencedColumn: (t) => t.pollId, - builder: (joinBuilder, - {$addJoinBuilderToRootComposer, - $removeJoinBuilderFromRootComposer}) => - $$PollVotesTableFilterComposer( - $db: $db, - $table: $db.pollVotes, - $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, - joinBuilder: joinBuilder, - $removeJoinBuilderFromRootComposer: - $removeJoinBuilderFromRootComposer, - )); + composer: this, + getCurrentColumn: (t) => t.id, + referencedTable: $db.pollVotes, + getReferencedColumn: (t) => t.pollId, + builder: (joinBuilder, {$addJoinBuilderToRootComposer, $removeJoinBuilderFromRootComposer}) => + $$PollVotesTableFilterComposer( + $db: $db, + $table: $db.pollVotes, + $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, + joinBuilder: joinBuilder, + $removeJoinBuilderFromRootComposer: $removeJoinBuilderFromRootComposer, + ), + ); return f(composer); } } -class $$PollsTableOrderingComposer - extends Composer<_$DriftChatDatabase, $PollsTable> { +class $$PollsTableOrderingComposer extends Composer<_$DriftChatDatabase, $PollsTable> { $$PollsTableOrderingComposer({ required super.$db, required super.$table, @@ -11631,67 +12498,58 @@ class $$PollsTableOrderingComposer super.$addJoinBuilderToRootComposer, super.$removeJoinBuilderFromRootComposer, }); - ColumnOrderings get id => $composableBuilder( - column: $table.id, builder: (column) => ColumnOrderings(column)); + ColumnOrderings get id => $composableBuilder(column: $table.id, builder: (column) => ColumnOrderings(column)); - ColumnOrderings get name => $composableBuilder( - column: $table.name, builder: (column) => ColumnOrderings(column)); + ColumnOrderings get name => + $composableBuilder(column: $table.name, builder: (column) => ColumnOrderings(column)); - ColumnOrderings get description => $composableBuilder( - column: $table.description, builder: (column) => ColumnOrderings(column)); + ColumnOrderings get description => + $composableBuilder(column: $table.description, builder: (column) => ColumnOrderings(column)); - ColumnOrderings get options => $composableBuilder( - column: $table.options, builder: (column) => ColumnOrderings(column)); + ColumnOrderings get options => + $composableBuilder(column: $table.options, builder: (column) => ColumnOrderings(column)); - ColumnOrderings get votingVisibility => $composableBuilder( - column: $table.votingVisibility, - builder: (column) => ColumnOrderings(column)); + ColumnOrderings get votingVisibility => + $composableBuilder(column: $table.votingVisibility, builder: (column) => ColumnOrderings(column)); - ColumnOrderings get enforceUniqueVote => $composableBuilder( - column: $table.enforceUniqueVote, - builder: (column) => ColumnOrderings(column)); + ColumnOrderings get enforceUniqueVote => + $composableBuilder(column: $table.enforceUniqueVote, builder: (column) => ColumnOrderings(column)); - ColumnOrderings get maxVotesAllowed => $composableBuilder( - column: $table.maxVotesAllowed, - builder: (column) => ColumnOrderings(column)); + ColumnOrderings get maxVotesAllowed => + $composableBuilder(column: $table.maxVotesAllowed, builder: (column) => ColumnOrderings(column)); - ColumnOrderings get allowUserSuggestedOptions => $composableBuilder( - column: $table.allowUserSuggestedOptions, - builder: (column) => ColumnOrderings(column)); + ColumnOrderings get allowUserSuggestedOptions => + $composableBuilder(column: $table.allowUserSuggestedOptions, builder: (column) => ColumnOrderings(column)); - ColumnOrderings get allowAnswers => $composableBuilder( - column: $table.allowAnswers, - builder: (column) => ColumnOrderings(column)); + ColumnOrderings get allowAnswers => + $composableBuilder(column: $table.allowAnswers, builder: (column) => ColumnOrderings(column)); - ColumnOrderings get isClosed => $composableBuilder( - column: $table.isClosed, builder: (column) => ColumnOrderings(column)); + ColumnOrderings get isClosed => + $composableBuilder(column: $table.isClosed, builder: (column) => ColumnOrderings(column)); - ColumnOrderings get answersCount => $composableBuilder( - column: $table.answersCount, - builder: (column) => ColumnOrderings(column)); + ColumnOrderings get answersCount => + $composableBuilder(column: $table.answersCount, builder: (column) => ColumnOrderings(column)); - ColumnOrderings get voteCountsByOption => $composableBuilder( - column: $table.voteCountsByOption, - builder: (column) => ColumnOrderings(column)); + ColumnOrderings get voteCountsByOption => + $composableBuilder(column: $table.voteCountsByOption, builder: (column) => ColumnOrderings(column)); - ColumnOrderings get voteCount => $composableBuilder( - column: $table.voteCount, builder: (column) => ColumnOrderings(column)); + ColumnOrderings get voteCount => + $composableBuilder(column: $table.voteCount, builder: (column) => ColumnOrderings(column)); - ColumnOrderings get createdById => $composableBuilder( - column: $table.createdById, builder: (column) => ColumnOrderings(column)); + ColumnOrderings get createdById => + $composableBuilder(column: $table.createdById, builder: (column) => ColumnOrderings(column)); - ColumnOrderings get createdAt => $composableBuilder( - column: $table.createdAt, builder: (column) => ColumnOrderings(column)); + ColumnOrderings get createdAt => + $composableBuilder(column: $table.createdAt, builder: (column) => ColumnOrderings(column)); - ColumnOrderings get updatedAt => $composableBuilder( - column: $table.updatedAt, builder: (column) => ColumnOrderings(column)); + ColumnOrderings get updatedAt => + $composableBuilder(column: $table.updatedAt, builder: (column) => ColumnOrderings(column)); - ColumnOrderings get extraData => $composableBuilder( - column: $table.extraData, builder: (column) => ColumnOrderings(column)); + ColumnOrderings get extraData => + $composableBuilder(column: $table.extraData, builder: (column) => ColumnOrderings(column)); } -class $$PollsTableAnnotationComposer - extends Composer<_$DriftChatDatabase, $PollsTable> { +class $$PollsTableAnnotationComposer extends Composer<_$DriftChatDatabase, $PollsTable> { $$PollsTableAnnotationComposer({ required super.$db, required super.$table, @@ -11699,188 +12557,174 @@ class $$PollsTableAnnotationComposer super.$addJoinBuilderToRootComposer, super.$removeJoinBuilderFromRootComposer, }); - GeneratedColumn get id => - $composableBuilder(column: $table.id, builder: (column) => column); + GeneratedColumn get id => $composableBuilder(column: $table.id, builder: (column) => column); - GeneratedColumn get name => - $composableBuilder(column: $table.name, builder: (column) => column); + GeneratedColumn get name => $composableBuilder(column: $table.name, builder: (column) => column); - GeneratedColumn get description => $composableBuilder( - column: $table.description, builder: (column) => column); + GeneratedColumn get description => + $composableBuilder(column: $table.description, builder: (column) => column); GeneratedColumnWithTypeConverter, String> get options => $composableBuilder(column: $table.options, builder: (column) => column); - GeneratedColumnWithTypeConverter - get votingVisibility => $composableBuilder( - column: $table.votingVisibility, builder: (column) => column); + GeneratedColumnWithTypeConverter get votingVisibility => + $composableBuilder(column: $table.votingVisibility, builder: (column) => column); - GeneratedColumn get enforceUniqueVote => $composableBuilder( - column: $table.enforceUniqueVote, builder: (column) => column); + GeneratedColumn get enforceUniqueVote => + $composableBuilder(column: $table.enforceUniqueVote, builder: (column) => column); - GeneratedColumn get maxVotesAllowed => $composableBuilder( - column: $table.maxVotesAllowed, builder: (column) => column); + GeneratedColumn get maxVotesAllowed => + $composableBuilder(column: $table.maxVotesAllowed, builder: (column) => column); - GeneratedColumn get allowUserSuggestedOptions => $composableBuilder( - column: $table.allowUserSuggestedOptions, builder: (column) => column); + GeneratedColumn get allowUserSuggestedOptions => + $composableBuilder(column: $table.allowUserSuggestedOptions, builder: (column) => column); - GeneratedColumn get allowAnswers => $composableBuilder( - column: $table.allowAnswers, builder: (column) => column); + GeneratedColumn get allowAnswers => + $composableBuilder(column: $table.allowAnswers, builder: (column) => column); - GeneratedColumn get isClosed => - $composableBuilder(column: $table.isClosed, builder: (column) => column); + GeneratedColumn get isClosed => $composableBuilder(column: $table.isClosed, builder: (column) => column); - GeneratedColumn get answersCount => $composableBuilder( - column: $table.answersCount, builder: (column) => column); + GeneratedColumn get answersCount => $composableBuilder(column: $table.answersCount, builder: (column) => column); - GeneratedColumnWithTypeConverter, String> - get voteCountsByOption => $composableBuilder( - column: $table.voteCountsByOption, builder: (column) => column); + GeneratedColumnWithTypeConverter, String> get voteCountsByOption => + $composableBuilder(column: $table.voteCountsByOption, builder: (column) => column); - GeneratedColumn get voteCount => - $composableBuilder(column: $table.voteCount, builder: (column) => column); + GeneratedColumn get voteCount => $composableBuilder(column: $table.voteCount, builder: (column) => column); - GeneratedColumn get createdById => $composableBuilder( - column: $table.createdById, builder: (column) => column); + GeneratedColumn get createdById => + $composableBuilder(column: $table.createdById, builder: (column) => column); - GeneratedColumn get createdAt => - $composableBuilder(column: $table.createdAt, builder: (column) => column); + GeneratedColumn get createdAt => $composableBuilder(column: $table.createdAt, builder: (column) => column); - GeneratedColumn get updatedAt => - $composableBuilder(column: $table.updatedAt, builder: (column) => column); + GeneratedColumn get updatedAt => $composableBuilder(column: $table.updatedAt, builder: (column) => column); - GeneratedColumnWithTypeConverter?, String> - get extraData => $composableBuilder( - column: $table.extraData, builder: (column) => column); + GeneratedColumnWithTypeConverter?, String> get extraData => + $composableBuilder(column: $table.extraData, builder: (column) => column); - Expression pollVotesRefs( - Expression Function($$PollVotesTableAnnotationComposer a) f) { + Expression pollVotesRefs(Expression Function($$PollVotesTableAnnotationComposer a) f) { final $$PollVotesTableAnnotationComposer composer = $composerBuilder( - composer: this, - getCurrentColumn: (t) => t.id, - referencedTable: $db.pollVotes, - getReferencedColumn: (t) => t.pollId, - builder: (joinBuilder, - {$addJoinBuilderToRootComposer, - $removeJoinBuilderFromRootComposer}) => - $$PollVotesTableAnnotationComposer( - $db: $db, - $table: $db.pollVotes, - $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, - joinBuilder: joinBuilder, - $removeJoinBuilderFromRootComposer: - $removeJoinBuilderFromRootComposer, - )); + composer: this, + getCurrentColumn: (t) => t.id, + referencedTable: $db.pollVotes, + getReferencedColumn: (t) => t.pollId, + builder: (joinBuilder, {$addJoinBuilderToRootComposer, $removeJoinBuilderFromRootComposer}) => + $$PollVotesTableAnnotationComposer( + $db: $db, + $table: $db.pollVotes, + $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, + joinBuilder: joinBuilder, + $removeJoinBuilderFromRootComposer: $removeJoinBuilderFromRootComposer, + ), + ); return f(composer); } } -class $$PollsTableTableManager extends RootTableManager< - _$DriftChatDatabase, - $PollsTable, - PollEntity, - $$PollsTableFilterComposer, - $$PollsTableOrderingComposer, - $$PollsTableAnnotationComposer, - $$PollsTableCreateCompanionBuilder, - $$PollsTableUpdateCompanionBuilder, - (PollEntity, $$PollsTableReferences), - PollEntity, - PrefetchHooks Function({bool pollVotesRefs})> { +class $$PollsTableTableManager + extends + RootTableManager< + _$DriftChatDatabase, + $PollsTable, + PollEntity, + $$PollsTableFilterComposer, + $$PollsTableOrderingComposer, + $$PollsTableAnnotationComposer, + $$PollsTableCreateCompanionBuilder, + $$PollsTableUpdateCompanionBuilder, + (PollEntity, $$PollsTableReferences), + PollEntity, + PrefetchHooks Function({bool pollVotesRefs}) + > { $$PollsTableTableManager(_$DriftChatDatabase db, $PollsTable table) - : super(TableManagerState( + : super( + TableManagerState( db: db, table: table, - createFilteringComposer: () => - $$PollsTableFilterComposer($db: db, $table: table), - createOrderingComposer: () => - $$PollsTableOrderingComposer($db: db, $table: table), - createComputedFieldComposer: () => - $$PollsTableAnnotationComposer($db: db, $table: table), - updateCompanionCallback: ({ - Value id = const Value.absent(), - Value name = const Value.absent(), - Value description = const Value.absent(), - Value> options = const Value.absent(), - Value votingVisibility = const Value.absent(), - Value enforceUniqueVote = const Value.absent(), - Value maxVotesAllowed = const Value.absent(), - Value allowUserSuggestedOptions = const Value.absent(), - Value allowAnswers = const Value.absent(), - Value isClosed = const Value.absent(), - Value answersCount = const Value.absent(), - Value> voteCountsByOption = const Value.absent(), - Value voteCount = const Value.absent(), - Value createdById = const Value.absent(), - Value createdAt = const Value.absent(), - Value updatedAt = const Value.absent(), - Value?> extraData = const Value.absent(), - Value rowid = const Value.absent(), - }) => - PollsCompanion( - id: id, - name: name, - description: description, - options: options, - votingVisibility: votingVisibility, - enforceUniqueVote: enforceUniqueVote, - maxVotesAllowed: maxVotesAllowed, - allowUserSuggestedOptions: allowUserSuggestedOptions, - allowAnswers: allowAnswers, - isClosed: isClosed, - answersCount: answersCount, - voteCountsByOption: voteCountsByOption, - voteCount: voteCount, - createdById: createdById, - createdAt: createdAt, - updatedAt: updatedAt, - extraData: extraData, - rowid: rowid, - ), - createCompanionCallback: ({ - required String id, - required String name, - Value description = const Value.absent(), - required List options, - Value votingVisibility = const Value.absent(), - Value enforceUniqueVote = const Value.absent(), - Value maxVotesAllowed = const Value.absent(), - Value allowUserSuggestedOptions = const Value.absent(), - Value allowAnswers = const Value.absent(), - Value isClosed = const Value.absent(), - Value answersCount = const Value.absent(), - required Map voteCountsByOption, - Value voteCount = const Value.absent(), - Value createdById = const Value.absent(), - Value createdAt = const Value.absent(), - Value updatedAt = const Value.absent(), - Value?> extraData = const Value.absent(), - Value rowid = const Value.absent(), - }) => - PollsCompanion.insert( - id: id, - name: name, - description: description, - options: options, - votingVisibility: votingVisibility, - enforceUniqueVote: enforceUniqueVote, - maxVotesAllowed: maxVotesAllowed, - allowUserSuggestedOptions: allowUserSuggestedOptions, - allowAnswers: allowAnswers, - isClosed: isClosed, - answersCount: answersCount, - voteCountsByOption: voteCountsByOption, - voteCount: voteCount, - createdById: createdById, - createdAt: createdAt, - updatedAt: updatedAt, - extraData: extraData, - rowid: rowid, - ), - withReferenceMapper: (p0) => p0 - .map((e) => - (e.readTable(table), $$PollsTableReferences(db, table, e))) - .toList(), + createFilteringComposer: () => $$PollsTableFilterComposer($db: db, $table: table), + createOrderingComposer: () => $$PollsTableOrderingComposer($db: db, $table: table), + createComputedFieldComposer: () => $$PollsTableAnnotationComposer($db: db, $table: table), + updateCompanionCallback: + ({ + Value id = const Value.absent(), + Value name = const Value.absent(), + Value description = const Value.absent(), + Value> options = const Value.absent(), + Value votingVisibility = const Value.absent(), + Value enforceUniqueVote = const Value.absent(), + Value maxVotesAllowed = const Value.absent(), + Value allowUserSuggestedOptions = const Value.absent(), + Value allowAnswers = const Value.absent(), + Value isClosed = const Value.absent(), + Value answersCount = const Value.absent(), + Value> voteCountsByOption = const Value.absent(), + Value voteCount = const Value.absent(), + Value createdById = const Value.absent(), + Value createdAt = const Value.absent(), + Value updatedAt = const Value.absent(), + Value?> extraData = const Value.absent(), + Value rowid = const Value.absent(), + }) => PollsCompanion( + id: id, + name: name, + description: description, + options: options, + votingVisibility: votingVisibility, + enforceUniqueVote: enforceUniqueVote, + maxVotesAllowed: maxVotesAllowed, + allowUserSuggestedOptions: allowUserSuggestedOptions, + allowAnswers: allowAnswers, + isClosed: isClosed, + answersCount: answersCount, + voteCountsByOption: voteCountsByOption, + voteCount: voteCount, + createdById: createdById, + createdAt: createdAt, + updatedAt: updatedAt, + extraData: extraData, + rowid: rowid, + ), + createCompanionCallback: + ({ + required String id, + required String name, + Value description = const Value.absent(), + required List options, + Value votingVisibility = const Value.absent(), + Value enforceUniqueVote = const Value.absent(), + Value maxVotesAllowed = const Value.absent(), + Value allowUserSuggestedOptions = const Value.absent(), + Value allowAnswers = const Value.absent(), + Value isClosed = const Value.absent(), + Value answersCount = const Value.absent(), + required Map voteCountsByOption, + Value voteCount = const Value.absent(), + Value createdById = const Value.absent(), + Value createdAt = const Value.absent(), + Value updatedAt = const Value.absent(), + Value?> extraData = const Value.absent(), + Value rowid = const Value.absent(), + }) => PollsCompanion.insert( + id: id, + name: name, + description: description, + options: options, + votingVisibility: votingVisibility, + enforceUniqueVote: enforceUniqueVote, + maxVotesAllowed: maxVotesAllowed, + allowUserSuggestedOptions: allowUserSuggestedOptions, + allowAnswers: allowAnswers, + isClosed: isClosed, + answersCount: answersCount, + voteCountsByOption: voteCountsByOption, + voteCount: voteCount, + createdById: createdById, + createdAt: createdAt, + updatedAt: updatedAt, + extraData: extraData, + rowid: rowid, + ), + withReferenceMapper: (p0) => + p0.map((e) => (e.readTable(table), $$PollsTableReferences(db, table, e))).toList(), prefetchHooksCallback: ({pollVotesRefs = false}) { return PrefetchHooks( db: db, @@ -11889,76 +12733,76 @@ class $$PollsTableTableManager extends RootTableManager< getPrefetchedDataCallback: (items) async { return [ if (pollVotesRefs) - await $_getPrefetchedData( - currentTable: table, - referencedTable: - $$PollsTableReferences._pollVotesRefsTable(db), - managerFromTypedResult: (p0) => - $$PollsTableReferences(db, table, p0).pollVotesRefs, - referencedItemsForCurrentItem: (item, - referencedItems) => - referencedItems.where((e) => e.pollId == item.id), - typedResults: items) + await $_getPrefetchedData( + currentTable: table, + referencedTable: $$PollsTableReferences._pollVotesRefsTable(db), + managerFromTypedResult: (p0) => $$PollsTableReferences(db, table, p0).pollVotesRefs, + referencedItemsForCurrentItem: (item, referencedItems) => + referencedItems.where((e) => e.pollId == item.id), + typedResults: items, + ), ]; }, ); }, - )); + ), + ); } -typedef $$PollsTableProcessedTableManager = ProcessedTableManager< - _$DriftChatDatabase, - $PollsTable, - PollEntity, - $$PollsTableFilterComposer, - $$PollsTableOrderingComposer, - $$PollsTableAnnotationComposer, - $$PollsTableCreateCompanionBuilder, - $$PollsTableUpdateCompanionBuilder, - (PollEntity, $$PollsTableReferences), - PollEntity, - PrefetchHooks Function({bool pollVotesRefs})>; -typedef $$PollVotesTableCreateCompanionBuilder = PollVotesCompanion Function({ - Value id, - Value pollId, - Value optionId, - Value answerText, - Value createdAt, - Value updatedAt, - Value userId, - Value rowid, -}); -typedef $$PollVotesTableUpdateCompanionBuilder = PollVotesCompanion Function({ - Value id, - Value pollId, - Value optionId, - Value answerText, - Value createdAt, - Value updatedAt, - Value userId, - Value rowid, -}); - -final class $$PollVotesTableReferences extends BaseReferences< - _$DriftChatDatabase, $PollVotesTable, PollVoteEntity> { +typedef $$PollsTableProcessedTableManager = + ProcessedTableManager< + _$DriftChatDatabase, + $PollsTable, + PollEntity, + $$PollsTableFilterComposer, + $$PollsTableOrderingComposer, + $$PollsTableAnnotationComposer, + $$PollsTableCreateCompanionBuilder, + $$PollsTableUpdateCompanionBuilder, + (PollEntity, $$PollsTableReferences), + PollEntity, + PrefetchHooks Function({bool pollVotesRefs}) + >; +typedef $$PollVotesTableCreateCompanionBuilder = + PollVotesCompanion Function({ + Value id, + Value pollId, + Value optionId, + Value answerText, + Value createdAt, + Value updatedAt, + Value userId, + Value rowid, + }); +typedef $$PollVotesTableUpdateCompanionBuilder = + PollVotesCompanion Function({ + Value id, + Value pollId, + Value optionId, + Value answerText, + Value createdAt, + Value updatedAt, + Value userId, + Value rowid, + }); + +final class $$PollVotesTableReferences extends BaseReferences<_$DriftChatDatabase, $PollVotesTable, PollVoteEntity> { $$PollVotesTableReferences(super.$_db, super.$_table, super.$_typedResult); - static $PollsTable _pollIdTable(_$DriftChatDatabase db) => db.polls - .createAlias($_aliasNameGenerator(db.pollVotes.pollId, db.polls.id)); + static $PollsTable _pollIdTable(_$DriftChatDatabase db) => + db.polls.createAlias($_aliasNameGenerator(db.pollVotes.pollId, db.polls.id)); $$PollsTableProcessedTableManager? get pollId { - if ($_item.pollId == null) return null; - final manager = $$PollsTableTableManager($_db, $_db.polls) - .filter((f) => f.id($_item.pollId!)); + final $_column = $_itemColumn('poll_id'); + if ($_column == null) return null; + final manager = $$PollsTableTableManager($_db, $_db.polls).filter((f) => f.id.sqlEquals($_column)); final item = $_typedResult.readTableOrNull(_pollIdTable($_db)); if (item == null) return manager; - return ProcessedTableManager( - manager.$state.copyWith(prefetchedData: [item])); + return ProcessedTableManager(manager.$state.copyWith(prefetchedData: [item])); } } -class $$PollVotesTableFilterComposer - extends Composer<_$DriftChatDatabase, $PollVotesTable> { +class $$PollVotesTableFilterComposer extends Composer<_$DriftChatDatabase, $PollVotesTable> { $$PollVotesTableFilterComposer({ required super.$db, required super.$table, @@ -11966,47 +12810,43 @@ class $$PollVotesTableFilterComposer super.$addJoinBuilderToRootComposer, super.$removeJoinBuilderFromRootComposer, }); - ColumnFilters get id => $composableBuilder( - column: $table.id, builder: (column) => ColumnFilters(column)); + ColumnFilters get id => $composableBuilder(column: $table.id, builder: (column) => ColumnFilters(column)); - ColumnFilters get optionId => $composableBuilder( - column: $table.optionId, builder: (column) => ColumnFilters(column)); + ColumnFilters get optionId => + $composableBuilder(column: $table.optionId, builder: (column) => ColumnFilters(column)); - ColumnFilters get answerText => $composableBuilder( - column: $table.answerText, builder: (column) => ColumnFilters(column)); + ColumnFilters get answerText => + $composableBuilder(column: $table.answerText, builder: (column) => ColumnFilters(column)); - ColumnFilters get createdAt => $composableBuilder( - column: $table.createdAt, builder: (column) => ColumnFilters(column)); + ColumnFilters get createdAt => + $composableBuilder(column: $table.createdAt, builder: (column) => ColumnFilters(column)); - ColumnFilters get updatedAt => $composableBuilder( - column: $table.updatedAt, builder: (column) => ColumnFilters(column)); + ColumnFilters get updatedAt => + $composableBuilder(column: $table.updatedAt, builder: (column) => ColumnFilters(column)); - ColumnFilters get userId => $composableBuilder( - column: $table.userId, builder: (column) => ColumnFilters(column)); + ColumnFilters get userId => + $composableBuilder(column: $table.userId, builder: (column) => ColumnFilters(column)); $$PollsTableFilterComposer get pollId { final $$PollsTableFilterComposer composer = $composerBuilder( - composer: this, - getCurrentColumn: (t) => t.pollId, - referencedTable: $db.polls, - getReferencedColumn: (t) => t.id, - builder: (joinBuilder, - {$addJoinBuilderToRootComposer, - $removeJoinBuilderFromRootComposer}) => - $$PollsTableFilterComposer( - $db: $db, - $table: $db.polls, - $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, - joinBuilder: joinBuilder, - $removeJoinBuilderFromRootComposer: - $removeJoinBuilderFromRootComposer, - )); + composer: this, + getCurrentColumn: (t) => t.pollId, + referencedTable: $db.polls, + getReferencedColumn: (t) => t.id, + builder: (joinBuilder, {$addJoinBuilderToRootComposer, $removeJoinBuilderFromRootComposer}) => + $$PollsTableFilterComposer( + $db: $db, + $table: $db.polls, + $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, + joinBuilder: joinBuilder, + $removeJoinBuilderFromRootComposer: $removeJoinBuilderFromRootComposer, + ), + ); return composer; } } -class $$PollVotesTableOrderingComposer - extends Composer<_$DriftChatDatabase, $PollVotesTable> { +class $$PollVotesTableOrderingComposer extends Composer<_$DriftChatDatabase, $PollVotesTable> { $$PollVotesTableOrderingComposer({ required super.$db, required super.$table, @@ -12014,47 +12854,43 @@ class $$PollVotesTableOrderingComposer super.$addJoinBuilderToRootComposer, super.$removeJoinBuilderFromRootComposer, }); - ColumnOrderings get id => $composableBuilder( - column: $table.id, builder: (column) => ColumnOrderings(column)); + ColumnOrderings get id => $composableBuilder(column: $table.id, builder: (column) => ColumnOrderings(column)); - ColumnOrderings get optionId => $composableBuilder( - column: $table.optionId, builder: (column) => ColumnOrderings(column)); + ColumnOrderings get optionId => + $composableBuilder(column: $table.optionId, builder: (column) => ColumnOrderings(column)); - ColumnOrderings get answerText => $composableBuilder( - column: $table.answerText, builder: (column) => ColumnOrderings(column)); + ColumnOrderings get answerText => + $composableBuilder(column: $table.answerText, builder: (column) => ColumnOrderings(column)); - ColumnOrderings get createdAt => $composableBuilder( - column: $table.createdAt, builder: (column) => ColumnOrderings(column)); + ColumnOrderings get createdAt => + $composableBuilder(column: $table.createdAt, builder: (column) => ColumnOrderings(column)); - ColumnOrderings get updatedAt => $composableBuilder( - column: $table.updatedAt, builder: (column) => ColumnOrderings(column)); + ColumnOrderings get updatedAt => + $composableBuilder(column: $table.updatedAt, builder: (column) => ColumnOrderings(column)); - ColumnOrderings get userId => $composableBuilder( - column: $table.userId, builder: (column) => ColumnOrderings(column)); + ColumnOrderings get userId => + $composableBuilder(column: $table.userId, builder: (column) => ColumnOrderings(column)); $$PollsTableOrderingComposer get pollId { final $$PollsTableOrderingComposer composer = $composerBuilder( - composer: this, - getCurrentColumn: (t) => t.pollId, - referencedTable: $db.polls, - getReferencedColumn: (t) => t.id, - builder: (joinBuilder, - {$addJoinBuilderToRootComposer, - $removeJoinBuilderFromRootComposer}) => - $$PollsTableOrderingComposer( - $db: $db, - $table: $db.polls, - $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, - joinBuilder: joinBuilder, - $removeJoinBuilderFromRootComposer: - $removeJoinBuilderFromRootComposer, - )); + composer: this, + getCurrentColumn: (t) => t.pollId, + referencedTable: $db.polls, + getReferencedColumn: (t) => t.id, + builder: (joinBuilder, {$addJoinBuilderToRootComposer, $removeJoinBuilderFromRootComposer}) => + $$PollsTableOrderingComposer( + $db: $db, + $table: $db.polls, + $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, + joinBuilder: joinBuilder, + $removeJoinBuilderFromRootComposer: $removeJoinBuilderFromRootComposer, + ), + ); return composer; } } -class $$PollVotesTableAnnotationComposer - extends Composer<_$DriftChatDatabase, $PollVotesTable> { +class $$PollVotesTableAnnotationComposer extends Composer<_$DriftChatDatabase, $PollVotesTable> { $$PollVotesTableAnnotationComposer({ required super.$db, required super.$table, @@ -12062,119 +12898,109 @@ class $$PollVotesTableAnnotationComposer super.$addJoinBuilderToRootComposer, super.$removeJoinBuilderFromRootComposer, }); - GeneratedColumn get id => - $composableBuilder(column: $table.id, builder: (column) => column); + GeneratedColumn get id => $composableBuilder(column: $table.id, builder: (column) => column); - GeneratedColumn get optionId => - $composableBuilder(column: $table.optionId, builder: (column) => column); + GeneratedColumn get optionId => $composableBuilder(column: $table.optionId, builder: (column) => column); - GeneratedColumn get answerText => $composableBuilder( - column: $table.answerText, builder: (column) => column); + GeneratedColumn get answerText => $composableBuilder(column: $table.answerText, builder: (column) => column); - GeneratedColumn get createdAt => - $composableBuilder(column: $table.createdAt, builder: (column) => column); + GeneratedColumn get createdAt => $composableBuilder(column: $table.createdAt, builder: (column) => column); - GeneratedColumn get updatedAt => - $composableBuilder(column: $table.updatedAt, builder: (column) => column); + GeneratedColumn get updatedAt => $composableBuilder(column: $table.updatedAt, builder: (column) => column); - GeneratedColumn get userId => - $composableBuilder(column: $table.userId, builder: (column) => column); + GeneratedColumn get userId => $composableBuilder(column: $table.userId, builder: (column) => column); $$PollsTableAnnotationComposer get pollId { final $$PollsTableAnnotationComposer composer = $composerBuilder( - composer: this, - getCurrentColumn: (t) => t.pollId, - referencedTable: $db.polls, - getReferencedColumn: (t) => t.id, - builder: (joinBuilder, - {$addJoinBuilderToRootComposer, - $removeJoinBuilderFromRootComposer}) => - $$PollsTableAnnotationComposer( - $db: $db, - $table: $db.polls, - $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, - joinBuilder: joinBuilder, - $removeJoinBuilderFromRootComposer: - $removeJoinBuilderFromRootComposer, - )); + composer: this, + getCurrentColumn: (t) => t.pollId, + referencedTable: $db.polls, + getReferencedColumn: (t) => t.id, + builder: (joinBuilder, {$addJoinBuilderToRootComposer, $removeJoinBuilderFromRootComposer}) => + $$PollsTableAnnotationComposer( + $db: $db, + $table: $db.polls, + $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, + joinBuilder: joinBuilder, + $removeJoinBuilderFromRootComposer: $removeJoinBuilderFromRootComposer, + ), + ); return composer; } } -class $$PollVotesTableTableManager extends RootTableManager< - _$DriftChatDatabase, - $PollVotesTable, - PollVoteEntity, - $$PollVotesTableFilterComposer, - $$PollVotesTableOrderingComposer, - $$PollVotesTableAnnotationComposer, - $$PollVotesTableCreateCompanionBuilder, - $$PollVotesTableUpdateCompanionBuilder, - (PollVoteEntity, $$PollVotesTableReferences), - PollVoteEntity, - PrefetchHooks Function({bool pollId})> { +class $$PollVotesTableTableManager + extends + RootTableManager< + _$DriftChatDatabase, + $PollVotesTable, + PollVoteEntity, + $$PollVotesTableFilterComposer, + $$PollVotesTableOrderingComposer, + $$PollVotesTableAnnotationComposer, + $$PollVotesTableCreateCompanionBuilder, + $$PollVotesTableUpdateCompanionBuilder, + (PollVoteEntity, $$PollVotesTableReferences), + PollVoteEntity, + PrefetchHooks Function({bool pollId}) + > { $$PollVotesTableTableManager(_$DriftChatDatabase db, $PollVotesTable table) - : super(TableManagerState( + : super( + TableManagerState( db: db, table: table, - createFilteringComposer: () => - $$PollVotesTableFilterComposer($db: db, $table: table), - createOrderingComposer: () => - $$PollVotesTableOrderingComposer($db: db, $table: table), - createComputedFieldComposer: () => - $$PollVotesTableAnnotationComposer($db: db, $table: table), - updateCompanionCallback: ({ - Value id = const Value.absent(), - Value pollId = const Value.absent(), - Value optionId = const Value.absent(), - Value answerText = const Value.absent(), - Value createdAt = const Value.absent(), - Value updatedAt = const Value.absent(), - Value userId = const Value.absent(), - Value rowid = const Value.absent(), - }) => - PollVotesCompanion( - id: id, - pollId: pollId, - optionId: optionId, - answerText: answerText, - createdAt: createdAt, - updatedAt: updatedAt, - userId: userId, - rowid: rowid, - ), - createCompanionCallback: ({ - Value id = const Value.absent(), - Value pollId = const Value.absent(), - Value optionId = const Value.absent(), - Value answerText = const Value.absent(), - Value createdAt = const Value.absent(), - Value updatedAt = const Value.absent(), - Value userId = const Value.absent(), - Value rowid = const Value.absent(), - }) => - PollVotesCompanion.insert( - id: id, - pollId: pollId, - optionId: optionId, - answerText: answerText, - createdAt: createdAt, - updatedAt: updatedAt, - userId: userId, - rowid: rowid, - ), - withReferenceMapper: (p0) => p0 - .map((e) => ( - e.readTable(table), - $$PollVotesTableReferences(db, table, e) - )) - .toList(), + createFilteringComposer: () => $$PollVotesTableFilterComposer($db: db, $table: table), + createOrderingComposer: () => $$PollVotesTableOrderingComposer($db: db, $table: table), + createComputedFieldComposer: () => $$PollVotesTableAnnotationComposer($db: db, $table: table), + updateCompanionCallback: + ({ + Value id = const Value.absent(), + Value pollId = const Value.absent(), + Value optionId = const Value.absent(), + Value answerText = const Value.absent(), + Value createdAt = const Value.absent(), + Value updatedAt = const Value.absent(), + Value userId = const Value.absent(), + Value rowid = const Value.absent(), + }) => PollVotesCompanion( + id: id, + pollId: pollId, + optionId: optionId, + answerText: answerText, + createdAt: createdAt, + updatedAt: updatedAt, + userId: userId, + rowid: rowid, + ), + createCompanionCallback: + ({ + Value id = const Value.absent(), + Value pollId = const Value.absent(), + Value optionId = const Value.absent(), + Value answerText = const Value.absent(), + Value createdAt = const Value.absent(), + Value updatedAt = const Value.absent(), + Value userId = const Value.absent(), + Value rowid = const Value.absent(), + }) => PollVotesCompanion.insert( + id: id, + pollId: pollId, + optionId: optionId, + answerText: answerText, + createdAt: createdAt, + updatedAt: updatedAt, + userId: userId, + rowid: rowid, + ), + withReferenceMapper: (p0) => + p0.map((e) => (e.readTable(table), $$PollVotesTableReferences(db, table, e))).toList(), prefetchHooksCallback: ({pollId = false}) { return PrefetchHooks( db: db, explicitlyWatchedTables: [], - addJoins: < - T extends TableManagerState< + addJoins: + < + T extends TableManagerState< dynamic, dynamic, dynamic, @@ -12185,84 +13011,91 @@ class $$PollVotesTableTableManager extends RootTableManager< dynamic, dynamic, dynamic, - dynamic>>(state) { - if (pollId) { - state = state.withJoin( - currentTable: table, - currentColumn: table.pollId, - referencedTable: - $$PollVotesTableReferences._pollIdTable(db), - referencedColumn: - $$PollVotesTableReferences._pollIdTable(db).id, - ) as T; - } - - return state; - }, + dynamic + > + >(state) { + if (pollId) { + state = + state.withJoin( + currentTable: table, + currentColumn: table.pollId, + referencedTable: $$PollVotesTableReferences._pollIdTable(db), + referencedColumn: $$PollVotesTableReferences._pollIdTable(db).id, + ) + as T; + } + + return state; + }, getPrefetchedDataCallback: (items) async { return []; }, ); }, - )); + ), + ); } -typedef $$PollVotesTableProcessedTableManager = ProcessedTableManager< - _$DriftChatDatabase, - $PollVotesTable, - PollVoteEntity, - $$PollVotesTableFilterComposer, - $$PollVotesTableOrderingComposer, - $$PollVotesTableAnnotationComposer, - $$PollVotesTableCreateCompanionBuilder, - $$PollVotesTableUpdateCompanionBuilder, - (PollVoteEntity, $$PollVotesTableReferences), - PollVoteEntity, - PrefetchHooks Function({bool pollId})>; -typedef $$PinnedMessageReactionsTableCreateCompanionBuilder - = PinnedMessageReactionsCompanion Function({ - required String userId, - required String messageId, - required String type, - Value createdAt, - Value score, - Value?> extraData, - Value rowid, -}); -typedef $$PinnedMessageReactionsTableUpdateCompanionBuilder - = PinnedMessageReactionsCompanion Function({ - Value userId, - Value messageId, - Value type, - Value createdAt, - Value score, - Value?> extraData, - Value rowid, -}); - -final class $$PinnedMessageReactionsTableReferences extends BaseReferences< - _$DriftChatDatabase, - $PinnedMessageReactionsTable, - PinnedMessageReactionEntity> { - $$PinnedMessageReactionsTableReferences( - super.$_db, super.$_table, super.$_typedResult); +typedef $$PollVotesTableProcessedTableManager = + ProcessedTableManager< + _$DriftChatDatabase, + $PollVotesTable, + PollVoteEntity, + $$PollVotesTableFilterComposer, + $$PollVotesTableOrderingComposer, + $$PollVotesTableAnnotationComposer, + $$PollVotesTableCreateCompanionBuilder, + $$PollVotesTableUpdateCompanionBuilder, + (PollVoteEntity, $$PollVotesTableReferences), + PollVoteEntity, + PrefetchHooks Function({bool pollId}) + >; +typedef $$PinnedMessageReactionsTableCreateCompanionBuilder = + PinnedMessageReactionsCompanion Function({ + Value userId, + Value messageId, + required String type, + Value emojiCode, + Value createdAt, + Value updatedAt, + Value score, + Value?> extraData, + Value rowid, + }); +typedef $$PinnedMessageReactionsTableUpdateCompanionBuilder = + PinnedMessageReactionsCompanion Function({ + Value userId, + Value messageId, + Value type, + Value emojiCode, + Value createdAt, + Value updatedAt, + Value score, + Value?> extraData, + Value rowid, + }); + +final class $$PinnedMessageReactionsTableReferences + extends BaseReferences<_$DriftChatDatabase, $PinnedMessageReactionsTable, PinnedMessageReactionEntity> { + $$PinnedMessageReactionsTableReferences(super.$_db, super.$_table, super.$_typedResult); static $PinnedMessagesTable _messageIdTable(_$DriftChatDatabase db) => - db.pinnedMessages.createAlias($_aliasNameGenerator( - db.pinnedMessageReactions.messageId, db.pinnedMessages.id)); - - $$PinnedMessagesTableProcessedTableManager get messageId { - final manager = $$PinnedMessagesTableTableManager($_db, $_db.pinnedMessages) - .filter((f) => f.id($_item.messageId!)); + db.pinnedMessages.createAlias($_aliasNameGenerator(db.pinnedMessageReactions.messageId, db.pinnedMessages.id)); + + $$PinnedMessagesTableProcessedTableManager? get messageId { + final $_column = $_itemColumn('message_id'); + if ($_column == null) return null; + final manager = $$PinnedMessagesTableTableManager( + $_db, + $_db.pinnedMessages, + ).filter((f) => f.id.sqlEquals($_column)); final item = $_typedResult.readTableOrNull(_messageIdTable($_db)); if (item == null) return manager; - return ProcessedTableManager( - manager.$state.copyWith(prefetchedData: [item])); + return ProcessedTableManager(manager.$state.copyWith(prefetchedData: [item])); } } -class $$PinnedMessageReactionsTableFilterComposer - extends Composer<_$DriftChatDatabase, $PinnedMessageReactionsTable> { +class $$PinnedMessageReactionsTableFilterComposer extends Composer<_$DriftChatDatabase, $PinnedMessageReactionsTable> { $$PinnedMessageReactionsTableFilterComposer({ required super.$db, required super.$table, @@ -12270,41 +13103,40 @@ class $$PinnedMessageReactionsTableFilterComposer super.$addJoinBuilderToRootComposer, super.$removeJoinBuilderFromRootComposer, }); - ColumnFilters get userId => $composableBuilder( - column: $table.userId, builder: (column) => ColumnFilters(column)); + ColumnFilters get userId => + $composableBuilder(column: $table.userId, builder: (column) => ColumnFilters(column)); + + ColumnFilters get type => $composableBuilder(column: $table.type, builder: (column) => ColumnFilters(column)); - ColumnFilters get type => $composableBuilder( - column: $table.type, builder: (column) => ColumnFilters(column)); + ColumnFilters get emojiCode => + $composableBuilder(column: $table.emojiCode, builder: (column) => ColumnFilters(column)); - ColumnFilters get createdAt => $composableBuilder( - column: $table.createdAt, builder: (column) => ColumnFilters(column)); + ColumnFilters get createdAt => + $composableBuilder(column: $table.createdAt, builder: (column) => ColumnFilters(column)); - ColumnFilters get score => $composableBuilder( - column: $table.score, builder: (column) => ColumnFilters(column)); + ColumnFilters get updatedAt => + $composableBuilder(column: $table.updatedAt, builder: (column) => ColumnFilters(column)); - ColumnWithTypeConverterFilters?, Map, - String> - get extraData => $composableBuilder( - column: $table.extraData, - builder: (column) => ColumnWithTypeConverterFilters(column)); + ColumnFilters get score => $composableBuilder(column: $table.score, builder: (column) => ColumnFilters(column)); + + ColumnWithTypeConverterFilters?, Map, String> get extraData => + $composableBuilder(column: $table.extraData, builder: (column) => ColumnWithTypeConverterFilters(column)); $$PinnedMessagesTableFilterComposer get messageId { final $$PinnedMessagesTableFilterComposer composer = $composerBuilder( - composer: this, - getCurrentColumn: (t) => t.messageId, - referencedTable: $db.pinnedMessages, - getReferencedColumn: (t) => t.id, - builder: (joinBuilder, - {$addJoinBuilderToRootComposer, - $removeJoinBuilderFromRootComposer}) => - $$PinnedMessagesTableFilterComposer( - $db: $db, - $table: $db.pinnedMessages, - $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, - joinBuilder: joinBuilder, - $removeJoinBuilderFromRootComposer: - $removeJoinBuilderFromRootComposer, - )); + composer: this, + getCurrentColumn: (t) => t.messageId, + referencedTable: $db.pinnedMessages, + getReferencedColumn: (t) => t.id, + builder: (joinBuilder, {$addJoinBuilderToRootComposer, $removeJoinBuilderFromRootComposer}) => + $$PinnedMessagesTableFilterComposer( + $db: $db, + $table: $db.pinnedMessages, + $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, + joinBuilder: joinBuilder, + $removeJoinBuilderFromRootComposer: $removeJoinBuilderFromRootComposer, + ), + ); return composer; } } @@ -12318,38 +13150,42 @@ class $$PinnedMessageReactionsTableOrderingComposer super.$addJoinBuilderToRootComposer, super.$removeJoinBuilderFromRootComposer, }); - ColumnOrderings get userId => $composableBuilder( - column: $table.userId, builder: (column) => ColumnOrderings(column)); + ColumnOrderings get userId => + $composableBuilder(column: $table.userId, builder: (column) => ColumnOrderings(column)); + + ColumnOrderings get type => + $composableBuilder(column: $table.type, builder: (column) => ColumnOrderings(column)); - ColumnOrderings get type => $composableBuilder( - column: $table.type, builder: (column) => ColumnOrderings(column)); + ColumnOrderings get emojiCode => + $composableBuilder(column: $table.emojiCode, builder: (column) => ColumnOrderings(column)); - ColumnOrderings get createdAt => $composableBuilder( - column: $table.createdAt, builder: (column) => ColumnOrderings(column)); + ColumnOrderings get createdAt => + $composableBuilder(column: $table.createdAt, builder: (column) => ColumnOrderings(column)); - ColumnOrderings get score => $composableBuilder( - column: $table.score, builder: (column) => ColumnOrderings(column)); + ColumnOrderings get updatedAt => + $composableBuilder(column: $table.updatedAt, builder: (column) => ColumnOrderings(column)); - ColumnOrderings get extraData => $composableBuilder( - column: $table.extraData, builder: (column) => ColumnOrderings(column)); + ColumnOrderings get score => + $composableBuilder(column: $table.score, builder: (column) => ColumnOrderings(column)); + + ColumnOrderings get extraData => + $composableBuilder(column: $table.extraData, builder: (column) => ColumnOrderings(column)); $$PinnedMessagesTableOrderingComposer get messageId { final $$PinnedMessagesTableOrderingComposer composer = $composerBuilder( - composer: this, - getCurrentColumn: (t) => t.messageId, - referencedTable: $db.pinnedMessages, - getReferencedColumn: (t) => t.id, - builder: (joinBuilder, - {$addJoinBuilderToRootComposer, - $removeJoinBuilderFromRootComposer}) => - $$PinnedMessagesTableOrderingComposer( - $db: $db, - $table: $db.pinnedMessages, - $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, - joinBuilder: joinBuilder, - $removeJoinBuilderFromRootComposer: - $removeJoinBuilderFromRootComposer, - )); + composer: this, + getCurrentColumn: (t) => t.messageId, + referencedTable: $db.pinnedMessages, + getReferencedColumn: (t) => t.id, + builder: (joinBuilder, {$addJoinBuilderToRootComposer, $removeJoinBuilderFromRootComposer}) => + $$PinnedMessagesTableOrderingComposer( + $db: $db, + $table: $db.pinnedMessages, + $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, + joinBuilder: joinBuilder, + $removeJoinBuilderFromRootComposer: $removeJoinBuilderFromRootComposer, + ), + ); return composer; } } @@ -12363,117 +13199,116 @@ class $$PinnedMessageReactionsTableAnnotationComposer super.$addJoinBuilderToRootComposer, super.$removeJoinBuilderFromRootComposer, }); - GeneratedColumn get userId => - $composableBuilder(column: $table.userId, builder: (column) => column); + GeneratedColumn get userId => $composableBuilder(column: $table.userId, builder: (column) => column); + + GeneratedColumn get type => $composableBuilder(column: $table.type, builder: (column) => column); - GeneratedColumn get type => - $composableBuilder(column: $table.type, builder: (column) => column); + GeneratedColumn get emojiCode => $composableBuilder(column: $table.emojiCode, builder: (column) => column); - GeneratedColumn get createdAt => - $composableBuilder(column: $table.createdAt, builder: (column) => column); + GeneratedColumn get createdAt => $composableBuilder(column: $table.createdAt, builder: (column) => column); - GeneratedColumn get score => - $composableBuilder(column: $table.score, builder: (column) => column); + GeneratedColumn get updatedAt => $composableBuilder(column: $table.updatedAt, builder: (column) => column); - GeneratedColumnWithTypeConverter?, String> - get extraData => $composableBuilder( - column: $table.extraData, builder: (column) => column); + GeneratedColumn get score => $composableBuilder(column: $table.score, builder: (column) => column); + + GeneratedColumnWithTypeConverter?, String> get extraData => + $composableBuilder(column: $table.extraData, builder: (column) => column); $$PinnedMessagesTableAnnotationComposer get messageId { final $$PinnedMessagesTableAnnotationComposer composer = $composerBuilder( - composer: this, - getCurrentColumn: (t) => t.messageId, - referencedTable: $db.pinnedMessages, - getReferencedColumn: (t) => t.id, - builder: (joinBuilder, - {$addJoinBuilderToRootComposer, - $removeJoinBuilderFromRootComposer}) => - $$PinnedMessagesTableAnnotationComposer( - $db: $db, - $table: $db.pinnedMessages, - $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, - joinBuilder: joinBuilder, - $removeJoinBuilderFromRootComposer: - $removeJoinBuilderFromRootComposer, - )); + composer: this, + getCurrentColumn: (t) => t.messageId, + referencedTable: $db.pinnedMessages, + getReferencedColumn: (t) => t.id, + builder: (joinBuilder, {$addJoinBuilderToRootComposer, $removeJoinBuilderFromRootComposer}) => + $$PinnedMessagesTableAnnotationComposer( + $db: $db, + $table: $db.pinnedMessages, + $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, + joinBuilder: joinBuilder, + $removeJoinBuilderFromRootComposer: $removeJoinBuilderFromRootComposer, + ), + ); return composer; } } -class $$PinnedMessageReactionsTableTableManager extends RootTableManager< - _$DriftChatDatabase, - $PinnedMessageReactionsTable, - PinnedMessageReactionEntity, - $$PinnedMessageReactionsTableFilterComposer, - $$PinnedMessageReactionsTableOrderingComposer, - $$PinnedMessageReactionsTableAnnotationComposer, - $$PinnedMessageReactionsTableCreateCompanionBuilder, - $$PinnedMessageReactionsTableUpdateCompanionBuilder, - (PinnedMessageReactionEntity, $$PinnedMessageReactionsTableReferences), - PinnedMessageReactionEntity, - PrefetchHooks Function({bool messageId})> { - $$PinnedMessageReactionsTableTableManager( - _$DriftChatDatabase db, $PinnedMessageReactionsTable table) - : super(TableManagerState( +class $$PinnedMessageReactionsTableTableManager + extends + RootTableManager< + _$DriftChatDatabase, + $PinnedMessageReactionsTable, + PinnedMessageReactionEntity, + $$PinnedMessageReactionsTableFilterComposer, + $$PinnedMessageReactionsTableOrderingComposer, + $$PinnedMessageReactionsTableAnnotationComposer, + $$PinnedMessageReactionsTableCreateCompanionBuilder, + $$PinnedMessageReactionsTableUpdateCompanionBuilder, + (PinnedMessageReactionEntity, $$PinnedMessageReactionsTableReferences), + PinnedMessageReactionEntity, + PrefetchHooks Function({bool messageId}) + > { + $$PinnedMessageReactionsTableTableManager(_$DriftChatDatabase db, $PinnedMessageReactionsTable table) + : super( + TableManagerState( db: db, table: table, - createFilteringComposer: () => - $$PinnedMessageReactionsTableFilterComposer( - $db: db, $table: table), - createOrderingComposer: () => - $$PinnedMessageReactionsTableOrderingComposer( - $db: db, $table: table), - createComputedFieldComposer: () => - $$PinnedMessageReactionsTableAnnotationComposer( - $db: db, $table: table), - updateCompanionCallback: ({ - Value userId = const Value.absent(), - Value messageId = const Value.absent(), - Value type = const Value.absent(), - Value createdAt = const Value.absent(), - Value score = const Value.absent(), - Value?> extraData = const Value.absent(), - Value rowid = const Value.absent(), - }) => - PinnedMessageReactionsCompanion( - userId: userId, - messageId: messageId, - type: type, - createdAt: createdAt, - score: score, - extraData: extraData, - rowid: rowid, - ), - createCompanionCallback: ({ - required String userId, - required String messageId, - required String type, - Value createdAt = const Value.absent(), - Value score = const Value.absent(), - Value?> extraData = const Value.absent(), - Value rowid = const Value.absent(), - }) => - PinnedMessageReactionsCompanion.insert( - userId: userId, - messageId: messageId, - type: type, - createdAt: createdAt, - score: score, - extraData: extraData, - rowid: rowid, - ), - withReferenceMapper: (p0) => p0 - .map((e) => ( - e.readTable(table), - $$PinnedMessageReactionsTableReferences(db, table, e) - )) - .toList(), + createFilteringComposer: () => $$PinnedMessageReactionsTableFilterComposer($db: db, $table: table), + createOrderingComposer: () => $$PinnedMessageReactionsTableOrderingComposer($db: db, $table: table), + createComputedFieldComposer: () => $$PinnedMessageReactionsTableAnnotationComposer($db: db, $table: table), + updateCompanionCallback: + ({ + Value userId = const Value.absent(), + Value messageId = const Value.absent(), + Value type = const Value.absent(), + Value emojiCode = const Value.absent(), + Value createdAt = const Value.absent(), + Value updatedAt = const Value.absent(), + Value score = const Value.absent(), + Value?> extraData = const Value.absent(), + Value rowid = const Value.absent(), + }) => PinnedMessageReactionsCompanion( + userId: userId, + messageId: messageId, + type: type, + emojiCode: emojiCode, + createdAt: createdAt, + updatedAt: updatedAt, + score: score, + extraData: extraData, + rowid: rowid, + ), + createCompanionCallback: + ({ + Value userId = const Value.absent(), + Value messageId = const Value.absent(), + required String type, + Value emojiCode = const Value.absent(), + Value createdAt = const Value.absent(), + Value updatedAt = const Value.absent(), + Value score = const Value.absent(), + Value?> extraData = const Value.absent(), + Value rowid = const Value.absent(), + }) => PinnedMessageReactionsCompanion.insert( + userId: userId, + messageId: messageId, + type: type, + emojiCode: emojiCode, + createdAt: createdAt, + updatedAt: updatedAt, + score: score, + extraData: extraData, + rowid: rowid, + ), + withReferenceMapper: (p0) => + p0.map((e) => (e.readTable(table), $$PinnedMessageReactionsTableReferences(db, table, e))).toList(), prefetchHooksCallback: ({messageId = false}) { return PrefetchHooks( db: db, explicitlyWatchedTables: [], - addJoins: < - T extends TableManagerState< + addJoins: + < + T extends TableManagerState< dynamic, dynamic, dynamic, @@ -12484,81 +13319,87 @@ class $$PinnedMessageReactionsTableTableManager extends RootTableManager< dynamic, dynamic, dynamic, - dynamic>>(state) { - if (messageId) { - state = state.withJoin( - currentTable: table, - currentColumn: table.messageId, - referencedTable: $$PinnedMessageReactionsTableReferences - ._messageIdTable(db), - referencedColumn: $$PinnedMessageReactionsTableReferences - ._messageIdTable(db) - .id, - ) as T; - } - - return state; - }, + dynamic + > + >(state) { + if (messageId) { + state = + state.withJoin( + currentTable: table, + currentColumn: table.messageId, + referencedTable: $$PinnedMessageReactionsTableReferences._messageIdTable(db), + referencedColumn: $$PinnedMessageReactionsTableReferences._messageIdTable(db).id, + ) + as T; + } + + return state; + }, getPrefetchedDataCallback: (items) async { return []; }, ); }, - )); + ), + ); } -typedef $$PinnedMessageReactionsTableProcessedTableManager - = ProcessedTableManager< - _$DriftChatDatabase, - $PinnedMessageReactionsTable, - PinnedMessageReactionEntity, - $$PinnedMessageReactionsTableFilterComposer, - $$PinnedMessageReactionsTableOrderingComposer, - $$PinnedMessageReactionsTableAnnotationComposer, - $$PinnedMessageReactionsTableCreateCompanionBuilder, - $$PinnedMessageReactionsTableUpdateCompanionBuilder, - (PinnedMessageReactionEntity, $$PinnedMessageReactionsTableReferences), - PinnedMessageReactionEntity, - PrefetchHooks Function({bool messageId})>; -typedef $$ReactionsTableCreateCompanionBuilder = ReactionsCompanion Function({ - required String userId, - required String messageId, - required String type, - Value createdAt, - Value score, - Value?> extraData, - Value rowid, -}); -typedef $$ReactionsTableUpdateCompanionBuilder = ReactionsCompanion Function({ - Value userId, - Value messageId, - Value type, - Value createdAt, - Value score, - Value?> extraData, - Value rowid, -}); - -final class $$ReactionsTableReferences extends BaseReferences< - _$DriftChatDatabase, $ReactionsTable, ReactionEntity> { +typedef $$PinnedMessageReactionsTableProcessedTableManager = + ProcessedTableManager< + _$DriftChatDatabase, + $PinnedMessageReactionsTable, + PinnedMessageReactionEntity, + $$PinnedMessageReactionsTableFilterComposer, + $$PinnedMessageReactionsTableOrderingComposer, + $$PinnedMessageReactionsTableAnnotationComposer, + $$PinnedMessageReactionsTableCreateCompanionBuilder, + $$PinnedMessageReactionsTableUpdateCompanionBuilder, + (PinnedMessageReactionEntity, $$PinnedMessageReactionsTableReferences), + PinnedMessageReactionEntity, + PrefetchHooks Function({bool messageId}) + >; +typedef $$ReactionsTableCreateCompanionBuilder = + ReactionsCompanion Function({ + Value userId, + Value messageId, + required String type, + Value emojiCode, + Value createdAt, + Value updatedAt, + Value score, + Value?> extraData, + Value rowid, + }); +typedef $$ReactionsTableUpdateCompanionBuilder = + ReactionsCompanion Function({ + Value userId, + Value messageId, + Value type, + Value emojiCode, + Value createdAt, + Value updatedAt, + Value score, + Value?> extraData, + Value rowid, + }); + +final class $$ReactionsTableReferences extends BaseReferences<_$DriftChatDatabase, $ReactionsTable, ReactionEntity> { $$ReactionsTableReferences(super.$_db, super.$_table, super.$_typedResult); static $MessagesTable _messageIdTable(_$DriftChatDatabase db) => - db.messages.createAlias( - $_aliasNameGenerator(db.reactions.messageId, db.messages.id)); + db.messages.createAlias($_aliasNameGenerator(db.reactions.messageId, db.messages.id)); - $$MessagesTableProcessedTableManager get messageId { - final manager = $$MessagesTableTableManager($_db, $_db.messages) - .filter((f) => f.id($_item.messageId!)); + $$MessagesTableProcessedTableManager? get messageId { + final $_column = $_itemColumn('message_id'); + if ($_column == null) return null; + final manager = $$MessagesTableTableManager($_db, $_db.messages).filter((f) => f.id.sqlEquals($_column)); final item = $_typedResult.readTableOrNull(_messageIdTable($_db)); if (item == null) return manager; - return ProcessedTableManager( - manager.$state.copyWith(prefetchedData: [item])); + return ProcessedTableManager(manager.$state.copyWith(prefetchedData: [item])); } } -class $$ReactionsTableFilterComposer - extends Composer<_$DriftChatDatabase, $ReactionsTable> { +class $$ReactionsTableFilterComposer extends Composer<_$DriftChatDatabase, $ReactionsTable> { $$ReactionsTableFilterComposer({ required super.$db, required super.$table, @@ -12566,47 +13407,45 @@ class $$ReactionsTableFilterComposer super.$addJoinBuilderToRootComposer, super.$removeJoinBuilderFromRootComposer, }); - ColumnFilters get userId => $composableBuilder( - column: $table.userId, builder: (column) => ColumnFilters(column)); + ColumnFilters get userId => + $composableBuilder(column: $table.userId, builder: (column) => ColumnFilters(column)); + + ColumnFilters get type => $composableBuilder(column: $table.type, builder: (column) => ColumnFilters(column)); + + ColumnFilters get emojiCode => + $composableBuilder(column: $table.emojiCode, builder: (column) => ColumnFilters(column)); - ColumnFilters get type => $composableBuilder( - column: $table.type, builder: (column) => ColumnFilters(column)); + ColumnFilters get createdAt => + $composableBuilder(column: $table.createdAt, builder: (column) => ColumnFilters(column)); - ColumnFilters get createdAt => $composableBuilder( - column: $table.createdAt, builder: (column) => ColumnFilters(column)); + ColumnFilters get updatedAt => + $composableBuilder(column: $table.updatedAt, builder: (column) => ColumnFilters(column)); - ColumnFilters get score => $composableBuilder( - column: $table.score, builder: (column) => ColumnFilters(column)); + ColumnFilters get score => $composableBuilder(column: $table.score, builder: (column) => ColumnFilters(column)); - ColumnWithTypeConverterFilters?, Map, - String> - get extraData => $composableBuilder( - column: $table.extraData, - builder: (column) => ColumnWithTypeConverterFilters(column)); + ColumnWithTypeConverterFilters?, Map, String> get extraData => + $composableBuilder(column: $table.extraData, builder: (column) => ColumnWithTypeConverterFilters(column)); $$MessagesTableFilterComposer get messageId { final $$MessagesTableFilterComposer composer = $composerBuilder( - composer: this, - getCurrentColumn: (t) => t.messageId, - referencedTable: $db.messages, - getReferencedColumn: (t) => t.id, - builder: (joinBuilder, - {$addJoinBuilderToRootComposer, - $removeJoinBuilderFromRootComposer}) => - $$MessagesTableFilterComposer( - $db: $db, - $table: $db.messages, - $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, - joinBuilder: joinBuilder, - $removeJoinBuilderFromRootComposer: - $removeJoinBuilderFromRootComposer, - )); + composer: this, + getCurrentColumn: (t) => t.messageId, + referencedTable: $db.messages, + getReferencedColumn: (t) => t.id, + builder: (joinBuilder, {$addJoinBuilderToRootComposer, $removeJoinBuilderFromRootComposer}) => + $$MessagesTableFilterComposer( + $db: $db, + $table: $db.messages, + $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, + joinBuilder: joinBuilder, + $removeJoinBuilderFromRootComposer: $removeJoinBuilderFromRootComposer, + ), + ); return composer; } } -class $$ReactionsTableOrderingComposer - extends Composer<_$DriftChatDatabase, $ReactionsTable> { +class $$ReactionsTableOrderingComposer extends Composer<_$DriftChatDatabase, $ReactionsTable> { $$ReactionsTableOrderingComposer({ required super.$db, required super.$table, @@ -12614,44 +13453,47 @@ class $$ReactionsTableOrderingComposer super.$addJoinBuilderToRootComposer, super.$removeJoinBuilderFromRootComposer, }); - ColumnOrderings get userId => $composableBuilder( - column: $table.userId, builder: (column) => ColumnOrderings(column)); + ColumnOrderings get userId => + $composableBuilder(column: $table.userId, builder: (column) => ColumnOrderings(column)); + + ColumnOrderings get type => + $composableBuilder(column: $table.type, builder: (column) => ColumnOrderings(column)); + + ColumnOrderings get emojiCode => + $composableBuilder(column: $table.emojiCode, builder: (column) => ColumnOrderings(column)); - ColumnOrderings get type => $composableBuilder( - column: $table.type, builder: (column) => ColumnOrderings(column)); + ColumnOrderings get createdAt => + $composableBuilder(column: $table.createdAt, builder: (column) => ColumnOrderings(column)); - ColumnOrderings get createdAt => $composableBuilder( - column: $table.createdAt, builder: (column) => ColumnOrderings(column)); + ColumnOrderings get updatedAt => + $composableBuilder(column: $table.updatedAt, builder: (column) => ColumnOrderings(column)); - ColumnOrderings get score => $composableBuilder( - column: $table.score, builder: (column) => ColumnOrderings(column)); + ColumnOrderings get score => + $composableBuilder(column: $table.score, builder: (column) => ColumnOrderings(column)); - ColumnOrderings get extraData => $composableBuilder( - column: $table.extraData, builder: (column) => ColumnOrderings(column)); + ColumnOrderings get extraData => + $composableBuilder(column: $table.extraData, builder: (column) => ColumnOrderings(column)); $$MessagesTableOrderingComposer get messageId { final $$MessagesTableOrderingComposer composer = $composerBuilder( - composer: this, - getCurrentColumn: (t) => t.messageId, - referencedTable: $db.messages, - getReferencedColumn: (t) => t.id, - builder: (joinBuilder, - {$addJoinBuilderToRootComposer, - $removeJoinBuilderFromRootComposer}) => - $$MessagesTableOrderingComposer( - $db: $db, - $table: $db.messages, - $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, - joinBuilder: joinBuilder, - $removeJoinBuilderFromRootComposer: - $removeJoinBuilderFromRootComposer, - )); + composer: this, + getCurrentColumn: (t) => t.messageId, + referencedTable: $db.messages, + getReferencedColumn: (t) => t.id, + builder: (joinBuilder, {$addJoinBuilderToRootComposer, $removeJoinBuilderFromRootComposer}) => + $$MessagesTableOrderingComposer( + $db: $db, + $table: $db.messages, + $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, + joinBuilder: joinBuilder, + $removeJoinBuilderFromRootComposer: $removeJoinBuilderFromRootComposer, + ), + ); return composer; } } -class $$ReactionsTableAnnotationComposer - extends Composer<_$DriftChatDatabase, $ReactionsTable> { +class $$ReactionsTableAnnotationComposer extends Composer<_$DriftChatDatabase, $ReactionsTable> { $$ReactionsTableAnnotationComposer({ required super.$db, required super.$table, @@ -12659,113 +13501,116 @@ class $$ReactionsTableAnnotationComposer super.$addJoinBuilderToRootComposer, super.$removeJoinBuilderFromRootComposer, }); - GeneratedColumn get userId => - $composableBuilder(column: $table.userId, builder: (column) => column); + GeneratedColumn get userId => $composableBuilder(column: $table.userId, builder: (column) => column); + + GeneratedColumn get type => $composableBuilder(column: $table.type, builder: (column) => column); + + GeneratedColumn get emojiCode => $composableBuilder(column: $table.emojiCode, builder: (column) => column); - GeneratedColumn get type => - $composableBuilder(column: $table.type, builder: (column) => column); + GeneratedColumn get createdAt => $composableBuilder(column: $table.createdAt, builder: (column) => column); - GeneratedColumn get createdAt => - $composableBuilder(column: $table.createdAt, builder: (column) => column); + GeneratedColumn get updatedAt => $composableBuilder(column: $table.updatedAt, builder: (column) => column); - GeneratedColumn get score => - $composableBuilder(column: $table.score, builder: (column) => column); + GeneratedColumn get score => $composableBuilder(column: $table.score, builder: (column) => column); - GeneratedColumnWithTypeConverter?, String> - get extraData => $composableBuilder( - column: $table.extraData, builder: (column) => column); + GeneratedColumnWithTypeConverter?, String> get extraData => + $composableBuilder(column: $table.extraData, builder: (column) => column); $$MessagesTableAnnotationComposer get messageId { final $$MessagesTableAnnotationComposer composer = $composerBuilder( - composer: this, - getCurrentColumn: (t) => t.messageId, - referencedTable: $db.messages, - getReferencedColumn: (t) => t.id, - builder: (joinBuilder, - {$addJoinBuilderToRootComposer, - $removeJoinBuilderFromRootComposer}) => - $$MessagesTableAnnotationComposer( - $db: $db, - $table: $db.messages, - $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, - joinBuilder: joinBuilder, - $removeJoinBuilderFromRootComposer: - $removeJoinBuilderFromRootComposer, - )); + composer: this, + getCurrentColumn: (t) => t.messageId, + referencedTable: $db.messages, + getReferencedColumn: (t) => t.id, + builder: (joinBuilder, {$addJoinBuilderToRootComposer, $removeJoinBuilderFromRootComposer}) => + $$MessagesTableAnnotationComposer( + $db: $db, + $table: $db.messages, + $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, + joinBuilder: joinBuilder, + $removeJoinBuilderFromRootComposer: $removeJoinBuilderFromRootComposer, + ), + ); return composer; } } -class $$ReactionsTableTableManager extends RootTableManager< - _$DriftChatDatabase, - $ReactionsTable, - ReactionEntity, - $$ReactionsTableFilterComposer, - $$ReactionsTableOrderingComposer, - $$ReactionsTableAnnotationComposer, - $$ReactionsTableCreateCompanionBuilder, - $$ReactionsTableUpdateCompanionBuilder, - (ReactionEntity, $$ReactionsTableReferences), - ReactionEntity, - PrefetchHooks Function({bool messageId})> { +class $$ReactionsTableTableManager + extends + RootTableManager< + _$DriftChatDatabase, + $ReactionsTable, + ReactionEntity, + $$ReactionsTableFilterComposer, + $$ReactionsTableOrderingComposer, + $$ReactionsTableAnnotationComposer, + $$ReactionsTableCreateCompanionBuilder, + $$ReactionsTableUpdateCompanionBuilder, + (ReactionEntity, $$ReactionsTableReferences), + ReactionEntity, + PrefetchHooks Function({bool messageId}) + > { $$ReactionsTableTableManager(_$DriftChatDatabase db, $ReactionsTable table) - : super(TableManagerState( + : super( + TableManagerState( db: db, table: table, - createFilteringComposer: () => - $$ReactionsTableFilterComposer($db: db, $table: table), - createOrderingComposer: () => - $$ReactionsTableOrderingComposer($db: db, $table: table), - createComputedFieldComposer: () => - $$ReactionsTableAnnotationComposer($db: db, $table: table), - updateCompanionCallback: ({ - Value userId = const Value.absent(), - Value messageId = const Value.absent(), - Value type = const Value.absent(), - Value createdAt = const Value.absent(), - Value score = const Value.absent(), - Value?> extraData = const Value.absent(), - Value rowid = const Value.absent(), - }) => - ReactionsCompanion( - userId: userId, - messageId: messageId, - type: type, - createdAt: createdAt, - score: score, - extraData: extraData, - rowid: rowid, - ), - createCompanionCallback: ({ - required String userId, - required String messageId, - required String type, - Value createdAt = const Value.absent(), - Value score = const Value.absent(), - Value?> extraData = const Value.absent(), - Value rowid = const Value.absent(), - }) => - ReactionsCompanion.insert( - userId: userId, - messageId: messageId, - type: type, - createdAt: createdAt, - score: score, - extraData: extraData, - rowid: rowid, - ), - withReferenceMapper: (p0) => p0 - .map((e) => ( - e.readTable(table), - $$ReactionsTableReferences(db, table, e) - )) - .toList(), + createFilteringComposer: () => $$ReactionsTableFilterComposer($db: db, $table: table), + createOrderingComposer: () => $$ReactionsTableOrderingComposer($db: db, $table: table), + createComputedFieldComposer: () => $$ReactionsTableAnnotationComposer($db: db, $table: table), + updateCompanionCallback: + ({ + Value userId = const Value.absent(), + Value messageId = const Value.absent(), + Value type = const Value.absent(), + Value emojiCode = const Value.absent(), + Value createdAt = const Value.absent(), + Value updatedAt = const Value.absent(), + Value score = const Value.absent(), + Value?> extraData = const Value.absent(), + Value rowid = const Value.absent(), + }) => ReactionsCompanion( + userId: userId, + messageId: messageId, + type: type, + emojiCode: emojiCode, + createdAt: createdAt, + updatedAt: updatedAt, + score: score, + extraData: extraData, + rowid: rowid, + ), + createCompanionCallback: + ({ + Value userId = const Value.absent(), + Value messageId = const Value.absent(), + required String type, + Value emojiCode = const Value.absent(), + Value createdAt = const Value.absent(), + Value updatedAt = const Value.absent(), + Value score = const Value.absent(), + Value?> extraData = const Value.absent(), + Value rowid = const Value.absent(), + }) => ReactionsCompanion.insert( + userId: userId, + messageId: messageId, + type: type, + emojiCode: emojiCode, + createdAt: createdAt, + updatedAt: updatedAt, + score: score, + extraData: extraData, + rowid: rowid, + ), + withReferenceMapper: (p0) => + p0.map((e) => (e.readTable(table), $$ReactionsTableReferences(db, table, e))).toList(), prefetchHooksCallback: ({messageId = false}) { return PrefetchHooks( db: db, explicitlyWatchedTables: [], - addJoins: < - T extends TableManagerState< + addJoins: + < + T extends TableManagerState< dynamic, dynamic, dynamic, @@ -12776,71 +13621,77 @@ class $$ReactionsTableTableManager extends RootTableManager< dynamic, dynamic, dynamic, - dynamic>>(state) { - if (messageId) { - state = state.withJoin( - currentTable: table, - currentColumn: table.messageId, - referencedTable: - $$ReactionsTableReferences._messageIdTable(db), - referencedColumn: - $$ReactionsTableReferences._messageIdTable(db).id, - ) as T; - } - - return state; - }, + dynamic + > + >(state) { + if (messageId) { + state = + state.withJoin( + currentTable: table, + currentColumn: table.messageId, + referencedTable: $$ReactionsTableReferences._messageIdTable(db), + referencedColumn: $$ReactionsTableReferences._messageIdTable(db).id, + ) + as T; + } + + return state; + }, getPrefetchedDataCallback: (items) async { return []; }, ); }, - )); + ), + ); } -typedef $$ReactionsTableProcessedTableManager = ProcessedTableManager< - _$DriftChatDatabase, - $ReactionsTable, - ReactionEntity, - $$ReactionsTableFilterComposer, - $$ReactionsTableOrderingComposer, - $$ReactionsTableAnnotationComposer, - $$ReactionsTableCreateCompanionBuilder, - $$ReactionsTableUpdateCompanionBuilder, - (ReactionEntity, $$ReactionsTableReferences), - ReactionEntity, - PrefetchHooks Function({bool messageId})>; -typedef $$UsersTableCreateCompanionBuilder = UsersCompanion Function({ - required String id, - Value role, - Value language, - Value createdAt, - Value updatedAt, - Value lastActive, - Value online, - Value banned, - Value?> teamsRole, - Value avgResponseTime, - required Map extraData, - Value rowid, -}); -typedef $$UsersTableUpdateCompanionBuilder = UsersCompanion Function({ - Value id, - Value role, - Value language, - Value createdAt, - Value updatedAt, - Value lastActive, - Value online, - Value banned, - Value?> teamsRole, - Value avgResponseTime, - Value> extraData, - Value rowid, -}); - -class $$UsersTableFilterComposer - extends Composer<_$DriftChatDatabase, $UsersTable> { +typedef $$ReactionsTableProcessedTableManager = + ProcessedTableManager< + _$DriftChatDatabase, + $ReactionsTable, + ReactionEntity, + $$ReactionsTableFilterComposer, + $$ReactionsTableOrderingComposer, + $$ReactionsTableAnnotationComposer, + $$ReactionsTableCreateCompanionBuilder, + $$ReactionsTableUpdateCompanionBuilder, + (ReactionEntity, $$ReactionsTableReferences), + ReactionEntity, + PrefetchHooks Function({bool messageId}) + >; +typedef $$UsersTableCreateCompanionBuilder = + UsersCompanion Function({ + required String id, + Value role, + Value language, + Value createdAt, + Value updatedAt, + Value lastActive, + Value online, + Value banned, + Value?> teamsRole, + Value avgResponseTime, + required Map extraData, + Value rowid, + }); +typedef $$UsersTableUpdateCompanionBuilder = + UsersCompanion Function({ + Value id, + Value role, + Value language, + Value createdAt, + Value updatedAt, + Value lastActive, + Value online, + Value banned, + Value?> teamsRole, + Value avgResponseTime, + Value> extraData, + Value rowid, + }); + +class $$UsersTableFilterComposer extends Composer<_$DriftChatDatabase, $UsersTable> { $$UsersTableFilterComposer({ required super.$db, required super.$table, @@ -12848,49 +13699,39 @@ class $$UsersTableFilterComposer super.$addJoinBuilderToRootComposer, super.$removeJoinBuilderFromRootComposer, }); - ColumnFilters get id => $composableBuilder( - column: $table.id, builder: (column) => ColumnFilters(column)); + ColumnFilters get id => $composableBuilder(column: $table.id, builder: (column) => ColumnFilters(column)); - ColumnFilters get role => $composableBuilder( - column: $table.role, builder: (column) => ColumnFilters(column)); + ColumnFilters get role => $composableBuilder(column: $table.role, builder: (column) => ColumnFilters(column)); - ColumnFilters get language => $composableBuilder( - column: $table.language, builder: (column) => ColumnFilters(column)); + ColumnFilters get language => + $composableBuilder(column: $table.language, builder: (column) => ColumnFilters(column)); - ColumnFilters get createdAt => $composableBuilder( - column: $table.createdAt, builder: (column) => ColumnFilters(column)); + ColumnFilters get createdAt => + $composableBuilder(column: $table.createdAt, builder: (column) => ColumnFilters(column)); - ColumnFilters get updatedAt => $composableBuilder( - column: $table.updatedAt, builder: (column) => ColumnFilters(column)); + ColumnFilters get updatedAt => + $composableBuilder(column: $table.updatedAt, builder: (column) => ColumnFilters(column)); - ColumnFilters get lastActive => $composableBuilder( - column: $table.lastActive, builder: (column) => ColumnFilters(column)); + ColumnFilters get lastActive => + $composableBuilder(column: $table.lastActive, builder: (column) => ColumnFilters(column)); - ColumnFilters get online => $composableBuilder( - column: $table.online, builder: (column) => ColumnFilters(column)); + ColumnFilters get online => + $composableBuilder(column: $table.online, builder: (column) => ColumnFilters(column)); - ColumnFilters get banned => $composableBuilder( - column: $table.banned, builder: (column) => ColumnFilters(column)); + ColumnFilters get banned => + $composableBuilder(column: $table.banned, builder: (column) => ColumnFilters(column)); - ColumnWithTypeConverterFilters?, Map, - String> - get teamsRole => $composableBuilder( - column: $table.teamsRole, - builder: (column) => ColumnWithTypeConverterFilters(column)); + ColumnWithTypeConverterFilters?, Map, String> get teamsRole => + $composableBuilder(column: $table.teamsRole, builder: (column) => ColumnWithTypeConverterFilters(column)); - ColumnFilters get avgResponseTime => $composableBuilder( - column: $table.avgResponseTime, - builder: (column) => ColumnFilters(column)); + ColumnFilters get avgResponseTime => + $composableBuilder(column: $table.avgResponseTime, builder: (column) => ColumnFilters(column)); - ColumnWithTypeConverterFilters, Map, - String> - get extraData => $composableBuilder( - column: $table.extraData, - builder: (column) => ColumnWithTypeConverterFilters(column)); + ColumnWithTypeConverterFilters, Map, String> get extraData => + $composableBuilder(column: $table.extraData, builder: (column) => ColumnWithTypeConverterFilters(column)); } -class $$UsersTableOrderingComposer - extends Composer<_$DriftChatDatabase, $UsersTable> { +class $$UsersTableOrderingComposer extends Composer<_$DriftChatDatabase, $UsersTable> { $$UsersTableOrderingComposer({ required super.$db, required super.$table, @@ -12898,43 +13739,40 @@ class $$UsersTableOrderingComposer super.$addJoinBuilderToRootComposer, super.$removeJoinBuilderFromRootComposer, }); - ColumnOrderings get id => $composableBuilder( - column: $table.id, builder: (column) => ColumnOrderings(column)); + ColumnOrderings get id => $composableBuilder(column: $table.id, builder: (column) => ColumnOrderings(column)); - ColumnOrderings get role => $composableBuilder( - column: $table.role, builder: (column) => ColumnOrderings(column)); + ColumnOrderings get role => + $composableBuilder(column: $table.role, builder: (column) => ColumnOrderings(column)); - ColumnOrderings get language => $composableBuilder( - column: $table.language, builder: (column) => ColumnOrderings(column)); + ColumnOrderings get language => + $composableBuilder(column: $table.language, builder: (column) => ColumnOrderings(column)); - ColumnOrderings get createdAt => $composableBuilder( - column: $table.createdAt, builder: (column) => ColumnOrderings(column)); + ColumnOrderings get createdAt => + $composableBuilder(column: $table.createdAt, builder: (column) => ColumnOrderings(column)); - ColumnOrderings get updatedAt => $composableBuilder( - column: $table.updatedAt, builder: (column) => ColumnOrderings(column)); + ColumnOrderings get updatedAt => + $composableBuilder(column: $table.updatedAt, builder: (column) => ColumnOrderings(column)); - ColumnOrderings get lastActive => $composableBuilder( - column: $table.lastActive, builder: (column) => ColumnOrderings(column)); + ColumnOrderings get lastActive => + $composableBuilder(column: $table.lastActive, builder: (column) => ColumnOrderings(column)); - ColumnOrderings get online => $composableBuilder( - column: $table.online, builder: (column) => ColumnOrderings(column)); + ColumnOrderings get online => + $composableBuilder(column: $table.online, builder: (column) => ColumnOrderings(column)); - ColumnOrderings get banned => $composableBuilder( - column: $table.banned, builder: (column) => ColumnOrderings(column)); + ColumnOrderings get banned => + $composableBuilder(column: $table.banned, builder: (column) => ColumnOrderings(column)); - ColumnOrderings get teamsRole => $composableBuilder( - column: $table.teamsRole, builder: (column) => ColumnOrderings(column)); + ColumnOrderings get teamsRole => + $composableBuilder(column: $table.teamsRole, builder: (column) => ColumnOrderings(column)); - ColumnOrderings get avgResponseTime => $composableBuilder( - column: $table.avgResponseTime, - builder: (column) => ColumnOrderings(column)); + ColumnOrderings get avgResponseTime => + $composableBuilder(column: $table.avgResponseTime, builder: (column) => ColumnOrderings(column)); - ColumnOrderings get extraData => $composableBuilder( - column: $table.extraData, builder: (column) => ColumnOrderings(column)); + ColumnOrderings get extraData => + $composableBuilder(column: $table.extraData, builder: (column) => ColumnOrderings(column)); } -class $$UsersTableAnnotationComposer - extends Composer<_$DriftChatDatabase, $UsersTable> { +class $$UsersTableAnnotationComposer extends Composer<_$DriftChatDatabase, $UsersTable> { $$UsersTableAnnotationComposer({ required super.$db, required super.$table, @@ -12942,194 +13780,188 @@ class $$UsersTableAnnotationComposer super.$addJoinBuilderToRootComposer, super.$removeJoinBuilderFromRootComposer, }); - GeneratedColumn get id => - $composableBuilder(column: $table.id, builder: (column) => column); + GeneratedColumn get id => $composableBuilder(column: $table.id, builder: (column) => column); - GeneratedColumn get role => - $composableBuilder(column: $table.role, builder: (column) => column); + GeneratedColumn get role => $composableBuilder(column: $table.role, builder: (column) => column); - GeneratedColumn get language => - $composableBuilder(column: $table.language, builder: (column) => column); + GeneratedColumn get language => $composableBuilder(column: $table.language, builder: (column) => column); - GeneratedColumn get createdAt => - $composableBuilder(column: $table.createdAt, builder: (column) => column); + GeneratedColumn get createdAt => $composableBuilder(column: $table.createdAt, builder: (column) => column); - GeneratedColumn get updatedAt => - $composableBuilder(column: $table.updatedAt, builder: (column) => column); + GeneratedColumn get updatedAt => $composableBuilder(column: $table.updatedAt, builder: (column) => column); - GeneratedColumn get lastActive => $composableBuilder( - column: $table.lastActive, builder: (column) => column); + GeneratedColumn get lastActive => + $composableBuilder(column: $table.lastActive, builder: (column) => column); - GeneratedColumn get online => - $composableBuilder(column: $table.online, builder: (column) => column); + GeneratedColumn get online => $composableBuilder(column: $table.online, builder: (column) => column); - GeneratedColumn get banned => - $composableBuilder(column: $table.banned, builder: (column) => column); + GeneratedColumn get banned => $composableBuilder(column: $table.banned, builder: (column) => column); - GeneratedColumnWithTypeConverter?, String> - get teamsRole => $composableBuilder( - column: $table.teamsRole, builder: (column) => column); + GeneratedColumnWithTypeConverter?, String> get teamsRole => + $composableBuilder(column: $table.teamsRole, builder: (column) => column); - GeneratedColumn get avgResponseTime => $composableBuilder( - column: $table.avgResponseTime, builder: (column) => column); + GeneratedColumn get avgResponseTime => + $composableBuilder(column: $table.avgResponseTime, builder: (column) => column); - GeneratedColumnWithTypeConverter, String> - get extraData => $composableBuilder( - column: $table.extraData, builder: (column) => column); + GeneratedColumnWithTypeConverter, String> get extraData => + $composableBuilder(column: $table.extraData, builder: (column) => column); } -class $$UsersTableTableManager extends RootTableManager< - _$DriftChatDatabase, - $UsersTable, - UserEntity, - $$UsersTableFilterComposer, - $$UsersTableOrderingComposer, - $$UsersTableAnnotationComposer, - $$UsersTableCreateCompanionBuilder, - $$UsersTableUpdateCompanionBuilder, - (UserEntity, BaseReferences<_$DriftChatDatabase, $UsersTable, UserEntity>), - UserEntity, - PrefetchHooks Function()> { +class $$UsersTableTableManager + extends + RootTableManager< + _$DriftChatDatabase, + $UsersTable, + UserEntity, + $$UsersTableFilterComposer, + $$UsersTableOrderingComposer, + $$UsersTableAnnotationComposer, + $$UsersTableCreateCompanionBuilder, + $$UsersTableUpdateCompanionBuilder, + (UserEntity, BaseReferences<_$DriftChatDatabase, $UsersTable, UserEntity>), + UserEntity, + PrefetchHooks Function() + > { $$UsersTableTableManager(_$DriftChatDatabase db, $UsersTable table) - : super(TableManagerState( + : super( + TableManagerState( db: db, table: table, - createFilteringComposer: () => - $$UsersTableFilterComposer($db: db, $table: table), - createOrderingComposer: () => - $$UsersTableOrderingComposer($db: db, $table: table), - createComputedFieldComposer: () => - $$UsersTableAnnotationComposer($db: db, $table: table), - updateCompanionCallback: ({ - Value id = const Value.absent(), - Value role = const Value.absent(), - Value language = const Value.absent(), - Value createdAt = const Value.absent(), - Value updatedAt = const Value.absent(), - Value lastActive = const Value.absent(), - Value online = const Value.absent(), - Value banned = const Value.absent(), - Value?> teamsRole = const Value.absent(), - Value avgResponseTime = const Value.absent(), - Value> extraData = const Value.absent(), - Value rowid = const Value.absent(), - }) => - UsersCompanion( - id: id, - role: role, - language: language, - createdAt: createdAt, - updatedAt: updatedAt, - lastActive: lastActive, - online: online, - banned: banned, - teamsRole: teamsRole, - avgResponseTime: avgResponseTime, - extraData: extraData, - rowid: rowid, - ), - createCompanionCallback: ({ - required String id, - Value role = const Value.absent(), - Value language = const Value.absent(), - Value createdAt = const Value.absent(), - Value updatedAt = const Value.absent(), - Value lastActive = const Value.absent(), - Value online = const Value.absent(), - Value banned = const Value.absent(), - Value?> teamsRole = const Value.absent(), - Value avgResponseTime = const Value.absent(), - required Map extraData, - Value rowid = const Value.absent(), - }) => - UsersCompanion.insert( - id: id, - role: role, - language: language, - createdAt: createdAt, - updatedAt: updatedAt, - lastActive: lastActive, - online: online, - banned: banned, - teamsRole: teamsRole, - avgResponseTime: avgResponseTime, - extraData: extraData, - rowid: rowid, - ), - withReferenceMapper: (p0) => p0 - .map((e) => (e.readTable(table), BaseReferences(db, table, e))) - .toList(), + createFilteringComposer: () => $$UsersTableFilterComposer($db: db, $table: table), + createOrderingComposer: () => $$UsersTableOrderingComposer($db: db, $table: table), + createComputedFieldComposer: () => $$UsersTableAnnotationComposer($db: db, $table: table), + updateCompanionCallback: + ({ + Value id = const Value.absent(), + Value role = const Value.absent(), + Value language = const Value.absent(), + Value createdAt = const Value.absent(), + Value updatedAt = const Value.absent(), + Value lastActive = const Value.absent(), + Value online = const Value.absent(), + Value banned = const Value.absent(), + Value?> teamsRole = const Value.absent(), + Value avgResponseTime = const Value.absent(), + Value> extraData = const Value.absent(), + Value rowid = const Value.absent(), + }) => UsersCompanion( + id: id, + role: role, + language: language, + createdAt: createdAt, + updatedAt: updatedAt, + lastActive: lastActive, + online: online, + banned: banned, + teamsRole: teamsRole, + avgResponseTime: avgResponseTime, + extraData: extraData, + rowid: rowid, + ), + createCompanionCallback: + ({ + required String id, + Value role = const Value.absent(), + Value language = const Value.absent(), + Value createdAt = const Value.absent(), + Value updatedAt = const Value.absent(), + Value lastActive = const Value.absent(), + Value online = const Value.absent(), + Value banned = const Value.absent(), + Value?> teamsRole = const Value.absent(), + Value avgResponseTime = const Value.absent(), + required Map extraData, + Value rowid = const Value.absent(), + }) => UsersCompanion.insert( + id: id, + role: role, + language: language, + createdAt: createdAt, + updatedAt: updatedAt, + lastActive: lastActive, + online: online, + banned: banned, + teamsRole: teamsRole, + avgResponseTime: avgResponseTime, + extraData: extraData, + rowid: rowid, + ), + withReferenceMapper: (p0) => p0.map((e) => (e.readTable(table), BaseReferences(db, table, e))).toList(), prefetchHooksCallback: null, - )); + ), + ); } -typedef $$UsersTableProcessedTableManager = ProcessedTableManager< - _$DriftChatDatabase, - $UsersTable, - UserEntity, - $$UsersTableFilterComposer, - $$UsersTableOrderingComposer, - $$UsersTableAnnotationComposer, - $$UsersTableCreateCompanionBuilder, - $$UsersTableUpdateCompanionBuilder, - (UserEntity, BaseReferences<_$DriftChatDatabase, $UsersTable, UserEntity>), - UserEntity, - PrefetchHooks Function()>; -typedef $$MembersTableCreateCompanionBuilder = MembersCompanion Function({ - required String userId, - required String channelCid, - Value channelRole, - Value inviteAcceptedAt, - Value inviteRejectedAt, - Value invited, - Value banned, - Value shadowBanned, - Value pinnedAt, - Value archivedAt, - Value isModerator, - Value?> extraData, - Value createdAt, - Value updatedAt, - Value rowid, -}); -typedef $$MembersTableUpdateCompanionBuilder = MembersCompanion Function({ - Value userId, - Value channelCid, - Value channelRole, - Value inviteAcceptedAt, - Value inviteRejectedAt, - Value invited, - Value banned, - Value shadowBanned, - Value pinnedAt, - Value archivedAt, - Value isModerator, - Value?> extraData, - Value createdAt, - Value updatedAt, - Value rowid, -}); - -final class $$MembersTableReferences - extends BaseReferences<_$DriftChatDatabase, $MembersTable, MemberEntity> { +typedef $$UsersTableProcessedTableManager = + ProcessedTableManager< + _$DriftChatDatabase, + $UsersTable, + UserEntity, + $$UsersTableFilterComposer, + $$UsersTableOrderingComposer, + $$UsersTableAnnotationComposer, + $$UsersTableCreateCompanionBuilder, + $$UsersTableUpdateCompanionBuilder, + (UserEntity, BaseReferences<_$DriftChatDatabase, $UsersTable, UserEntity>), + UserEntity, + PrefetchHooks Function() + >; +typedef $$MembersTableCreateCompanionBuilder = + MembersCompanion Function({ + required String userId, + required String channelCid, + Value channelRole, + Value inviteAcceptedAt, + Value inviteRejectedAt, + Value invited, + Value banned, + Value shadowBanned, + Value pinnedAt, + Value archivedAt, + Value isModerator, + Value?> extraData, + Value createdAt, + Value updatedAt, + required List deletedMessages, + Value rowid, + }); +typedef $$MembersTableUpdateCompanionBuilder = + MembersCompanion Function({ + Value userId, + Value channelCid, + Value channelRole, + Value inviteAcceptedAt, + Value inviteRejectedAt, + Value invited, + Value banned, + Value shadowBanned, + Value pinnedAt, + Value archivedAt, + Value isModerator, + Value?> extraData, + Value createdAt, + Value updatedAt, + Value> deletedMessages, + Value rowid, + }); + +final class $$MembersTableReferences extends BaseReferences<_$DriftChatDatabase, $MembersTable, MemberEntity> { $$MembersTableReferences(super.$_db, super.$_table, super.$_typedResult); static $ChannelsTable _channelCidTable(_$DriftChatDatabase db) => - db.channels.createAlias( - $_aliasNameGenerator(db.members.channelCid, db.channels.cid)); + db.channels.createAlias($_aliasNameGenerator(db.members.channelCid, db.channels.cid)); $$ChannelsTableProcessedTableManager get channelCid { - final manager = $$ChannelsTableTableManager($_db, $_db.channels) - .filter((f) => f.cid($_item.channelCid!)); + final $_column = $_itemColumn('channel_cid')!; + + final manager = $$ChannelsTableTableManager($_db, $_db.channels).filter((f) => f.cid.sqlEquals($_column)); final item = $_typedResult.readTableOrNull(_channelCidTable($_db)); if (item == null) return manager; - return ProcessedTableManager( - manager.$state.copyWith(prefetchedData: [item])); + return ProcessedTableManager(manager.$state.copyWith(prefetchedData: [item])); } } -class $$MembersTableFilterComposer - extends Composer<_$DriftChatDatabase, $MembersTable> { +class $$MembersTableFilterComposer extends Composer<_$DriftChatDatabase, $MembersTable> { $$MembersTableFilterComposer({ required super.$db, required super.$table, @@ -13137,73 +13969,68 @@ class $$MembersTableFilterComposer super.$addJoinBuilderToRootComposer, super.$removeJoinBuilderFromRootComposer, }); - ColumnFilters get userId => $composableBuilder( - column: $table.userId, builder: (column) => ColumnFilters(column)); + ColumnFilters get userId => + $composableBuilder(column: $table.userId, builder: (column) => ColumnFilters(column)); - ColumnFilters get channelRole => $composableBuilder( - column: $table.channelRole, builder: (column) => ColumnFilters(column)); + ColumnFilters get channelRole => + $composableBuilder(column: $table.channelRole, builder: (column) => ColumnFilters(column)); - ColumnFilters get inviteAcceptedAt => $composableBuilder( - column: $table.inviteAcceptedAt, - builder: (column) => ColumnFilters(column)); + ColumnFilters get inviteAcceptedAt => + $composableBuilder(column: $table.inviteAcceptedAt, builder: (column) => ColumnFilters(column)); - ColumnFilters get inviteRejectedAt => $composableBuilder( - column: $table.inviteRejectedAt, - builder: (column) => ColumnFilters(column)); + ColumnFilters get inviteRejectedAt => + $composableBuilder(column: $table.inviteRejectedAt, builder: (column) => ColumnFilters(column)); - ColumnFilters get invited => $composableBuilder( - column: $table.invited, builder: (column) => ColumnFilters(column)); + ColumnFilters get invited => + $composableBuilder(column: $table.invited, builder: (column) => ColumnFilters(column)); - ColumnFilters get banned => $composableBuilder( - column: $table.banned, builder: (column) => ColumnFilters(column)); + ColumnFilters get banned => + $composableBuilder(column: $table.banned, builder: (column) => ColumnFilters(column)); - ColumnFilters get shadowBanned => $composableBuilder( - column: $table.shadowBanned, builder: (column) => ColumnFilters(column)); + ColumnFilters get shadowBanned => + $composableBuilder(column: $table.shadowBanned, builder: (column) => ColumnFilters(column)); - ColumnFilters get pinnedAt => $composableBuilder( - column: $table.pinnedAt, builder: (column) => ColumnFilters(column)); + ColumnFilters get pinnedAt => + $composableBuilder(column: $table.pinnedAt, builder: (column) => ColumnFilters(column)); - ColumnFilters get archivedAt => $composableBuilder( - column: $table.archivedAt, builder: (column) => ColumnFilters(column)); + ColumnFilters get archivedAt => + $composableBuilder(column: $table.archivedAt, builder: (column) => ColumnFilters(column)); - ColumnFilters get isModerator => $composableBuilder( - column: $table.isModerator, builder: (column) => ColumnFilters(column)); + ColumnFilters get isModerator => + $composableBuilder(column: $table.isModerator, builder: (column) => ColumnFilters(column)); - ColumnWithTypeConverterFilters?, Map, - String> - get extraData => $composableBuilder( - column: $table.extraData, - builder: (column) => ColumnWithTypeConverterFilters(column)); + ColumnWithTypeConverterFilters?, Map, String> get extraData => + $composableBuilder(column: $table.extraData, builder: (column) => ColumnWithTypeConverterFilters(column)); - ColumnFilters get createdAt => $composableBuilder( - column: $table.createdAt, builder: (column) => ColumnFilters(column)); + ColumnFilters get createdAt => + $composableBuilder(column: $table.createdAt, builder: (column) => ColumnFilters(column)); - ColumnFilters get updatedAt => $composableBuilder( - column: $table.updatedAt, builder: (column) => ColumnFilters(column)); + ColumnFilters get updatedAt => + $composableBuilder(column: $table.updatedAt, builder: (column) => ColumnFilters(column)); + + ColumnWithTypeConverterFilters, List, String> get deletedMessages => + $composableBuilder(column: $table.deletedMessages, builder: (column) => ColumnWithTypeConverterFilters(column)); $$ChannelsTableFilterComposer get channelCid { final $$ChannelsTableFilterComposer composer = $composerBuilder( - composer: this, - getCurrentColumn: (t) => t.channelCid, - referencedTable: $db.channels, - getReferencedColumn: (t) => t.cid, - builder: (joinBuilder, - {$addJoinBuilderToRootComposer, - $removeJoinBuilderFromRootComposer}) => - $$ChannelsTableFilterComposer( - $db: $db, - $table: $db.channels, - $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, - joinBuilder: joinBuilder, - $removeJoinBuilderFromRootComposer: - $removeJoinBuilderFromRootComposer, - )); + composer: this, + getCurrentColumn: (t) => t.channelCid, + referencedTable: $db.channels, + getReferencedColumn: (t) => t.cid, + builder: (joinBuilder, {$addJoinBuilderToRootComposer, $removeJoinBuilderFromRootComposer}) => + $$ChannelsTableFilterComposer( + $db: $db, + $table: $db.channels, + $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, + joinBuilder: joinBuilder, + $removeJoinBuilderFromRootComposer: $removeJoinBuilderFromRootComposer, + ), + ); return composer; } } -class $$MembersTableOrderingComposer - extends Composer<_$DriftChatDatabase, $MembersTable> { +class $$MembersTableOrderingComposer extends Composer<_$DriftChatDatabase, $MembersTable> { $$MembersTableOrderingComposer({ required super.$db, required super.$table, @@ -13211,71 +14038,68 @@ class $$MembersTableOrderingComposer super.$addJoinBuilderToRootComposer, super.$removeJoinBuilderFromRootComposer, }); - ColumnOrderings get userId => $composableBuilder( - column: $table.userId, builder: (column) => ColumnOrderings(column)); + ColumnOrderings get userId => + $composableBuilder(column: $table.userId, builder: (column) => ColumnOrderings(column)); + + ColumnOrderings get channelRole => + $composableBuilder(column: $table.channelRole, builder: (column) => ColumnOrderings(column)); - ColumnOrderings get channelRole => $composableBuilder( - column: $table.channelRole, builder: (column) => ColumnOrderings(column)); + ColumnOrderings get inviteAcceptedAt => + $composableBuilder(column: $table.inviteAcceptedAt, builder: (column) => ColumnOrderings(column)); - ColumnOrderings get inviteAcceptedAt => $composableBuilder( - column: $table.inviteAcceptedAt, - builder: (column) => ColumnOrderings(column)); + ColumnOrderings get inviteRejectedAt => + $composableBuilder(column: $table.inviteRejectedAt, builder: (column) => ColumnOrderings(column)); - ColumnOrderings get inviteRejectedAt => $composableBuilder( - column: $table.inviteRejectedAt, - builder: (column) => ColumnOrderings(column)); + ColumnOrderings get invited => + $composableBuilder(column: $table.invited, builder: (column) => ColumnOrderings(column)); - ColumnOrderings get invited => $composableBuilder( - column: $table.invited, builder: (column) => ColumnOrderings(column)); + ColumnOrderings get banned => + $composableBuilder(column: $table.banned, builder: (column) => ColumnOrderings(column)); - ColumnOrderings get banned => $composableBuilder( - column: $table.banned, builder: (column) => ColumnOrderings(column)); + ColumnOrderings get shadowBanned => + $composableBuilder(column: $table.shadowBanned, builder: (column) => ColumnOrderings(column)); - ColumnOrderings get shadowBanned => $composableBuilder( - column: $table.shadowBanned, - builder: (column) => ColumnOrderings(column)); + ColumnOrderings get pinnedAt => + $composableBuilder(column: $table.pinnedAt, builder: (column) => ColumnOrderings(column)); - ColumnOrderings get pinnedAt => $composableBuilder( - column: $table.pinnedAt, builder: (column) => ColumnOrderings(column)); + ColumnOrderings get archivedAt => + $composableBuilder(column: $table.archivedAt, builder: (column) => ColumnOrderings(column)); - ColumnOrderings get archivedAt => $composableBuilder( - column: $table.archivedAt, builder: (column) => ColumnOrderings(column)); + ColumnOrderings get isModerator => + $composableBuilder(column: $table.isModerator, builder: (column) => ColumnOrderings(column)); - ColumnOrderings get isModerator => $composableBuilder( - column: $table.isModerator, builder: (column) => ColumnOrderings(column)); + ColumnOrderings get extraData => + $composableBuilder(column: $table.extraData, builder: (column) => ColumnOrderings(column)); - ColumnOrderings get extraData => $composableBuilder( - column: $table.extraData, builder: (column) => ColumnOrderings(column)); + ColumnOrderings get createdAt => + $composableBuilder(column: $table.createdAt, builder: (column) => ColumnOrderings(column)); - ColumnOrderings get createdAt => $composableBuilder( - column: $table.createdAt, builder: (column) => ColumnOrderings(column)); + ColumnOrderings get updatedAt => + $composableBuilder(column: $table.updatedAt, builder: (column) => ColumnOrderings(column)); - ColumnOrderings get updatedAt => $composableBuilder( - column: $table.updatedAt, builder: (column) => ColumnOrderings(column)); + ColumnOrderings get deletedMessages => + $composableBuilder(column: $table.deletedMessages, builder: (column) => ColumnOrderings(column)); $$ChannelsTableOrderingComposer get channelCid { final $$ChannelsTableOrderingComposer composer = $composerBuilder( - composer: this, - getCurrentColumn: (t) => t.channelCid, - referencedTable: $db.channels, - getReferencedColumn: (t) => t.cid, - builder: (joinBuilder, - {$addJoinBuilderToRootComposer, - $removeJoinBuilderFromRootComposer}) => - $$ChannelsTableOrderingComposer( - $db: $db, - $table: $db.channels, - $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, - joinBuilder: joinBuilder, - $removeJoinBuilderFromRootComposer: - $removeJoinBuilderFromRootComposer, - )); + composer: this, + getCurrentColumn: (t) => t.channelCid, + referencedTable: $db.channels, + getReferencedColumn: (t) => t.cid, + builder: (joinBuilder, {$addJoinBuilderToRootComposer, $removeJoinBuilderFromRootComposer}) => + $$ChannelsTableOrderingComposer( + $db: $db, + $table: $db.channels, + $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, + joinBuilder: joinBuilder, + $removeJoinBuilderFromRootComposer: $removeJoinBuilderFromRootComposer, + ), + ); return composer; } } -class $$MembersTableAnnotationComposer - extends Composer<_$DriftChatDatabase, $MembersTable> { +class $$MembersTableAnnotationComposer extends Composer<_$DriftChatDatabase, $MembersTable> { $$MembersTableAnnotationComposer({ required super.$db, required super.$table, @@ -13283,167 +14107,164 @@ class $$MembersTableAnnotationComposer super.$addJoinBuilderToRootComposer, super.$removeJoinBuilderFromRootComposer, }); - GeneratedColumn get userId => - $composableBuilder(column: $table.userId, builder: (column) => column); + GeneratedColumn get userId => $composableBuilder(column: $table.userId, builder: (column) => column); - GeneratedColumn get channelRole => $composableBuilder( - column: $table.channelRole, builder: (column) => column); + GeneratedColumn get channelRole => + $composableBuilder(column: $table.channelRole, builder: (column) => column); - GeneratedColumn get inviteAcceptedAt => $composableBuilder( - column: $table.inviteAcceptedAt, builder: (column) => column); + GeneratedColumn get inviteAcceptedAt => + $composableBuilder(column: $table.inviteAcceptedAt, builder: (column) => column); - GeneratedColumn get inviteRejectedAt => $composableBuilder( - column: $table.inviteRejectedAt, builder: (column) => column); + GeneratedColumn get inviteRejectedAt => + $composableBuilder(column: $table.inviteRejectedAt, builder: (column) => column); - GeneratedColumn get invited => - $composableBuilder(column: $table.invited, builder: (column) => column); + GeneratedColumn get invited => $composableBuilder(column: $table.invited, builder: (column) => column); - GeneratedColumn get banned => - $composableBuilder(column: $table.banned, builder: (column) => column); + GeneratedColumn get banned => $composableBuilder(column: $table.banned, builder: (column) => column); - GeneratedColumn get shadowBanned => $composableBuilder( - column: $table.shadowBanned, builder: (column) => column); + GeneratedColumn get shadowBanned => + $composableBuilder(column: $table.shadowBanned, builder: (column) => column); - GeneratedColumn get pinnedAt => - $composableBuilder(column: $table.pinnedAt, builder: (column) => column); + GeneratedColumn get pinnedAt => $composableBuilder(column: $table.pinnedAt, builder: (column) => column); - GeneratedColumn get archivedAt => $composableBuilder( - column: $table.archivedAt, builder: (column) => column); + GeneratedColumn get archivedAt => + $composableBuilder(column: $table.archivedAt, builder: (column) => column); - GeneratedColumn get isModerator => $composableBuilder( - column: $table.isModerator, builder: (column) => column); + GeneratedColumn get isModerator => $composableBuilder(column: $table.isModerator, builder: (column) => column); - GeneratedColumnWithTypeConverter?, String> - get extraData => $composableBuilder( - column: $table.extraData, builder: (column) => column); + GeneratedColumnWithTypeConverter?, String> get extraData => + $composableBuilder(column: $table.extraData, builder: (column) => column); - GeneratedColumn get createdAt => - $composableBuilder(column: $table.createdAt, builder: (column) => column); + GeneratedColumn get createdAt => $composableBuilder(column: $table.createdAt, builder: (column) => column); - GeneratedColumn get updatedAt => - $composableBuilder(column: $table.updatedAt, builder: (column) => column); + GeneratedColumn get updatedAt => $composableBuilder(column: $table.updatedAt, builder: (column) => column); + + GeneratedColumnWithTypeConverter, String> get deletedMessages => + $composableBuilder(column: $table.deletedMessages, builder: (column) => column); $$ChannelsTableAnnotationComposer get channelCid { final $$ChannelsTableAnnotationComposer composer = $composerBuilder( - composer: this, - getCurrentColumn: (t) => t.channelCid, - referencedTable: $db.channels, - getReferencedColumn: (t) => t.cid, - builder: (joinBuilder, - {$addJoinBuilderToRootComposer, - $removeJoinBuilderFromRootComposer}) => - $$ChannelsTableAnnotationComposer( - $db: $db, - $table: $db.channels, - $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, - joinBuilder: joinBuilder, - $removeJoinBuilderFromRootComposer: - $removeJoinBuilderFromRootComposer, - )); + composer: this, + getCurrentColumn: (t) => t.channelCid, + referencedTable: $db.channels, + getReferencedColumn: (t) => t.cid, + builder: (joinBuilder, {$addJoinBuilderToRootComposer, $removeJoinBuilderFromRootComposer}) => + $$ChannelsTableAnnotationComposer( + $db: $db, + $table: $db.channels, + $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, + joinBuilder: joinBuilder, + $removeJoinBuilderFromRootComposer: $removeJoinBuilderFromRootComposer, + ), + ); return composer; } } -class $$MembersTableTableManager extends RootTableManager< - _$DriftChatDatabase, - $MembersTable, - MemberEntity, - $$MembersTableFilterComposer, - $$MembersTableOrderingComposer, - $$MembersTableAnnotationComposer, - $$MembersTableCreateCompanionBuilder, - $$MembersTableUpdateCompanionBuilder, - (MemberEntity, $$MembersTableReferences), - MemberEntity, - PrefetchHooks Function({bool channelCid})> { +class $$MembersTableTableManager + extends + RootTableManager< + _$DriftChatDatabase, + $MembersTable, + MemberEntity, + $$MembersTableFilterComposer, + $$MembersTableOrderingComposer, + $$MembersTableAnnotationComposer, + $$MembersTableCreateCompanionBuilder, + $$MembersTableUpdateCompanionBuilder, + (MemberEntity, $$MembersTableReferences), + MemberEntity, + PrefetchHooks Function({bool channelCid}) + > { $$MembersTableTableManager(_$DriftChatDatabase db, $MembersTable table) - : super(TableManagerState( + : super( + TableManagerState( db: db, table: table, - createFilteringComposer: () => - $$MembersTableFilterComposer($db: db, $table: table), - createOrderingComposer: () => - $$MembersTableOrderingComposer($db: db, $table: table), - createComputedFieldComposer: () => - $$MembersTableAnnotationComposer($db: db, $table: table), - updateCompanionCallback: ({ - Value userId = const Value.absent(), - Value channelCid = const Value.absent(), - Value channelRole = const Value.absent(), - Value inviteAcceptedAt = const Value.absent(), - Value inviteRejectedAt = const Value.absent(), - Value invited = const Value.absent(), - Value banned = const Value.absent(), - Value shadowBanned = const Value.absent(), - Value pinnedAt = const Value.absent(), - Value archivedAt = const Value.absent(), - Value isModerator = const Value.absent(), - Value?> extraData = const Value.absent(), - Value createdAt = const Value.absent(), - Value updatedAt = const Value.absent(), - Value rowid = const Value.absent(), - }) => - MembersCompanion( - userId: userId, - channelCid: channelCid, - channelRole: channelRole, - inviteAcceptedAt: inviteAcceptedAt, - inviteRejectedAt: inviteRejectedAt, - invited: invited, - banned: banned, - shadowBanned: shadowBanned, - pinnedAt: pinnedAt, - archivedAt: archivedAt, - isModerator: isModerator, - extraData: extraData, - createdAt: createdAt, - updatedAt: updatedAt, - rowid: rowid, - ), - createCompanionCallback: ({ - required String userId, - required String channelCid, - Value channelRole = const Value.absent(), - Value inviteAcceptedAt = const Value.absent(), - Value inviteRejectedAt = const Value.absent(), - Value invited = const Value.absent(), - Value banned = const Value.absent(), - Value shadowBanned = const Value.absent(), - Value pinnedAt = const Value.absent(), - Value archivedAt = const Value.absent(), - Value isModerator = const Value.absent(), - Value?> extraData = const Value.absent(), - Value createdAt = const Value.absent(), - Value updatedAt = const Value.absent(), - Value rowid = const Value.absent(), - }) => - MembersCompanion.insert( - userId: userId, - channelCid: channelCid, - channelRole: channelRole, - inviteAcceptedAt: inviteAcceptedAt, - inviteRejectedAt: inviteRejectedAt, - invited: invited, - banned: banned, - shadowBanned: shadowBanned, - pinnedAt: pinnedAt, - archivedAt: archivedAt, - isModerator: isModerator, - extraData: extraData, - createdAt: createdAt, - updatedAt: updatedAt, - rowid: rowid, - ), - withReferenceMapper: (p0) => p0 - .map((e) => - (e.readTable(table), $$MembersTableReferences(db, table, e))) - .toList(), + createFilteringComposer: () => $$MembersTableFilterComposer($db: db, $table: table), + createOrderingComposer: () => $$MembersTableOrderingComposer($db: db, $table: table), + createComputedFieldComposer: () => $$MembersTableAnnotationComposer($db: db, $table: table), + updateCompanionCallback: + ({ + Value userId = const Value.absent(), + Value channelCid = const Value.absent(), + Value channelRole = const Value.absent(), + Value inviteAcceptedAt = const Value.absent(), + Value inviteRejectedAt = const Value.absent(), + Value invited = const Value.absent(), + Value banned = const Value.absent(), + Value shadowBanned = const Value.absent(), + Value pinnedAt = const Value.absent(), + Value archivedAt = const Value.absent(), + Value isModerator = const Value.absent(), + Value?> extraData = const Value.absent(), + Value createdAt = const Value.absent(), + Value updatedAt = const Value.absent(), + Value> deletedMessages = const Value.absent(), + Value rowid = const Value.absent(), + }) => MembersCompanion( + userId: userId, + channelCid: channelCid, + channelRole: channelRole, + inviteAcceptedAt: inviteAcceptedAt, + inviteRejectedAt: inviteRejectedAt, + invited: invited, + banned: banned, + shadowBanned: shadowBanned, + pinnedAt: pinnedAt, + archivedAt: archivedAt, + isModerator: isModerator, + extraData: extraData, + createdAt: createdAt, + updatedAt: updatedAt, + deletedMessages: deletedMessages, + rowid: rowid, + ), + createCompanionCallback: + ({ + required String userId, + required String channelCid, + Value channelRole = const Value.absent(), + Value inviteAcceptedAt = const Value.absent(), + Value inviteRejectedAt = const Value.absent(), + Value invited = const Value.absent(), + Value banned = const Value.absent(), + Value shadowBanned = const Value.absent(), + Value pinnedAt = const Value.absent(), + Value archivedAt = const Value.absent(), + Value isModerator = const Value.absent(), + Value?> extraData = const Value.absent(), + Value createdAt = const Value.absent(), + Value updatedAt = const Value.absent(), + required List deletedMessages, + Value rowid = const Value.absent(), + }) => MembersCompanion.insert( + userId: userId, + channelCid: channelCid, + channelRole: channelRole, + inviteAcceptedAt: inviteAcceptedAt, + inviteRejectedAt: inviteRejectedAt, + invited: invited, + banned: banned, + shadowBanned: shadowBanned, + pinnedAt: pinnedAt, + archivedAt: archivedAt, + isModerator: isModerator, + extraData: extraData, + createdAt: createdAt, + updatedAt: updatedAt, + deletedMessages: deletedMessages, + rowid: rowid, + ), + withReferenceMapper: (p0) => + p0.map((e) => (e.readTable(table), $$MembersTableReferences(db, table, e))).toList(), prefetchHooksCallback: ({channelCid = false}) { return PrefetchHooks( db: db, explicitlyWatchedTables: [], - addJoins: < - T extends TableManagerState< + addJoins: + < + T extends TableManagerState< dynamic, dynamic, dynamic, @@ -13454,80 +14275,85 @@ class $$MembersTableTableManager extends RootTableManager< dynamic, dynamic, dynamic, - dynamic>>(state) { - if (channelCid) { - state = state.withJoin( - currentTable: table, - currentColumn: table.channelCid, - referencedTable: - $$MembersTableReferences._channelCidTable(db), - referencedColumn: - $$MembersTableReferences._channelCidTable(db).cid, - ) as T; - } - - return state; - }, + dynamic + > + >(state) { + if (channelCid) { + state = + state.withJoin( + currentTable: table, + currentColumn: table.channelCid, + referencedTable: $$MembersTableReferences._channelCidTable(db), + referencedColumn: $$MembersTableReferences._channelCidTable(db).cid, + ) + as T; + } + + return state; + }, getPrefetchedDataCallback: (items) async { return []; }, ); }, - )); + ), + ); } -typedef $$MembersTableProcessedTableManager = ProcessedTableManager< - _$DriftChatDatabase, - $MembersTable, - MemberEntity, - $$MembersTableFilterComposer, - $$MembersTableOrderingComposer, - $$MembersTableAnnotationComposer, - $$MembersTableCreateCompanionBuilder, - $$MembersTableUpdateCompanionBuilder, - (MemberEntity, $$MembersTableReferences), - MemberEntity, - PrefetchHooks Function({bool channelCid})>; -typedef $$ReadsTableCreateCompanionBuilder = ReadsCompanion Function({ - required DateTime lastRead, - required String userId, - required String channelCid, - Value unreadMessages, - Value lastReadMessageId, - Value lastDeliveredAt, - Value lastDeliveredMessageId, - Value rowid, -}); -typedef $$ReadsTableUpdateCompanionBuilder = ReadsCompanion Function({ - Value lastRead, - Value userId, - Value channelCid, - Value unreadMessages, - Value lastReadMessageId, - Value lastDeliveredAt, - Value lastDeliveredMessageId, - Value rowid, -}); - -final class $$ReadsTableReferences - extends BaseReferences<_$DriftChatDatabase, $ReadsTable, ReadEntity> { +typedef $$MembersTableProcessedTableManager = + ProcessedTableManager< + _$DriftChatDatabase, + $MembersTable, + MemberEntity, + $$MembersTableFilterComposer, + $$MembersTableOrderingComposer, + $$MembersTableAnnotationComposer, + $$MembersTableCreateCompanionBuilder, + $$MembersTableUpdateCompanionBuilder, + (MemberEntity, $$MembersTableReferences), + MemberEntity, + PrefetchHooks Function({bool channelCid}) + >; +typedef $$ReadsTableCreateCompanionBuilder = + ReadsCompanion Function({ + required DateTime lastRead, + required String userId, + required String channelCid, + Value unreadMessages, + Value lastReadMessageId, + Value lastDeliveredAt, + Value lastDeliveredMessageId, + Value rowid, + }); +typedef $$ReadsTableUpdateCompanionBuilder = + ReadsCompanion Function({ + Value lastRead, + Value userId, + Value channelCid, + Value unreadMessages, + Value lastReadMessageId, + Value lastDeliveredAt, + Value lastDeliveredMessageId, + Value rowid, + }); + +final class $$ReadsTableReferences extends BaseReferences<_$DriftChatDatabase, $ReadsTable, ReadEntity> { $$ReadsTableReferences(super.$_db, super.$_table, super.$_typedResult); - static $ChannelsTable _channelCidTable(_$DriftChatDatabase db) => db.channels - .createAlias($_aliasNameGenerator(db.reads.channelCid, db.channels.cid)); + static $ChannelsTable _channelCidTable(_$DriftChatDatabase db) => + db.channels.createAlias($_aliasNameGenerator(db.reads.channelCid, db.channels.cid)); $$ChannelsTableProcessedTableManager get channelCid { - final manager = $$ChannelsTableTableManager($_db, $_db.channels) - .filter((f) => f.cid($_item.channelCid!)); + final $_column = $_itemColumn('channel_cid')!; + + final manager = $$ChannelsTableTableManager($_db, $_db.channels).filter((f) => f.cid.sqlEquals($_column)); final item = $_typedResult.readTableOrNull(_channelCidTable($_db)); if (item == null) return manager; - return ProcessedTableManager( - manager.$state.copyWith(prefetchedData: [item])); + return ProcessedTableManager(manager.$state.copyWith(prefetchedData: [item])); } } -class $$ReadsTableFilterComposer - extends Composer<_$DriftChatDatabase, $ReadsTable> { +class $$ReadsTableFilterComposer extends Composer<_$DriftChatDatabase, $ReadsTable> { $$ReadsTableFilterComposer({ required super.$db, required super.$table, @@ -13535,51 +14361,44 @@ class $$ReadsTableFilterComposer super.$addJoinBuilderToRootComposer, super.$removeJoinBuilderFromRootComposer, }); - ColumnFilters get lastRead => $composableBuilder( - column: $table.lastRead, builder: (column) => ColumnFilters(column)); + ColumnFilters get lastRead => + $composableBuilder(column: $table.lastRead, builder: (column) => ColumnFilters(column)); - ColumnFilters get userId => $composableBuilder( - column: $table.userId, builder: (column) => ColumnFilters(column)); + ColumnFilters get userId => + $composableBuilder(column: $table.userId, builder: (column) => ColumnFilters(column)); - ColumnFilters get unreadMessages => $composableBuilder( - column: $table.unreadMessages, - builder: (column) => ColumnFilters(column)); + ColumnFilters get unreadMessages => + $composableBuilder(column: $table.unreadMessages, builder: (column) => ColumnFilters(column)); - ColumnFilters get lastReadMessageId => $composableBuilder( - column: $table.lastReadMessageId, - builder: (column) => ColumnFilters(column)); + ColumnFilters get lastReadMessageId => + $composableBuilder(column: $table.lastReadMessageId, builder: (column) => ColumnFilters(column)); - ColumnFilters get lastDeliveredAt => $composableBuilder( - column: $table.lastDeliveredAt, - builder: (column) => ColumnFilters(column)); + ColumnFilters get lastDeliveredAt => + $composableBuilder(column: $table.lastDeliveredAt, builder: (column) => ColumnFilters(column)); - ColumnFilters get lastDeliveredMessageId => $composableBuilder( - column: $table.lastDeliveredMessageId, - builder: (column) => ColumnFilters(column)); + ColumnFilters get lastDeliveredMessageId => + $composableBuilder(column: $table.lastDeliveredMessageId, builder: (column) => ColumnFilters(column)); $$ChannelsTableFilterComposer get channelCid { final $$ChannelsTableFilterComposer composer = $composerBuilder( - composer: this, - getCurrentColumn: (t) => t.channelCid, - referencedTable: $db.channels, - getReferencedColumn: (t) => t.cid, - builder: (joinBuilder, - {$addJoinBuilderToRootComposer, - $removeJoinBuilderFromRootComposer}) => - $$ChannelsTableFilterComposer( - $db: $db, - $table: $db.channels, - $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, - joinBuilder: joinBuilder, - $removeJoinBuilderFromRootComposer: - $removeJoinBuilderFromRootComposer, - )); + composer: this, + getCurrentColumn: (t) => t.channelCid, + referencedTable: $db.channels, + getReferencedColumn: (t) => t.cid, + builder: (joinBuilder, {$addJoinBuilderToRootComposer, $removeJoinBuilderFromRootComposer}) => + $$ChannelsTableFilterComposer( + $db: $db, + $table: $db.channels, + $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, + joinBuilder: joinBuilder, + $removeJoinBuilderFromRootComposer: $removeJoinBuilderFromRootComposer, + ), + ); return composer; } } -class $$ReadsTableOrderingComposer - extends Composer<_$DriftChatDatabase, $ReadsTable> { +class $$ReadsTableOrderingComposer extends Composer<_$DriftChatDatabase, $ReadsTable> { $$ReadsTableOrderingComposer({ required super.$db, required super.$table, @@ -13587,51 +14406,44 @@ class $$ReadsTableOrderingComposer super.$addJoinBuilderToRootComposer, super.$removeJoinBuilderFromRootComposer, }); - ColumnOrderings get lastRead => $composableBuilder( - column: $table.lastRead, builder: (column) => ColumnOrderings(column)); + ColumnOrderings get lastRead => + $composableBuilder(column: $table.lastRead, builder: (column) => ColumnOrderings(column)); - ColumnOrderings get userId => $composableBuilder( - column: $table.userId, builder: (column) => ColumnOrderings(column)); + ColumnOrderings get userId => + $composableBuilder(column: $table.userId, builder: (column) => ColumnOrderings(column)); - ColumnOrderings get unreadMessages => $composableBuilder( - column: $table.unreadMessages, - builder: (column) => ColumnOrderings(column)); + ColumnOrderings get unreadMessages => + $composableBuilder(column: $table.unreadMessages, builder: (column) => ColumnOrderings(column)); - ColumnOrderings get lastReadMessageId => $composableBuilder( - column: $table.lastReadMessageId, - builder: (column) => ColumnOrderings(column)); + ColumnOrderings get lastReadMessageId => + $composableBuilder(column: $table.lastReadMessageId, builder: (column) => ColumnOrderings(column)); - ColumnOrderings get lastDeliveredAt => $composableBuilder( - column: $table.lastDeliveredAt, - builder: (column) => ColumnOrderings(column)); + ColumnOrderings get lastDeliveredAt => + $composableBuilder(column: $table.lastDeliveredAt, builder: (column) => ColumnOrderings(column)); - ColumnOrderings get lastDeliveredMessageId => $composableBuilder( - column: $table.lastDeliveredMessageId, - builder: (column) => ColumnOrderings(column)); + ColumnOrderings get lastDeliveredMessageId => + $composableBuilder(column: $table.lastDeliveredMessageId, builder: (column) => ColumnOrderings(column)); $$ChannelsTableOrderingComposer get channelCid { final $$ChannelsTableOrderingComposer composer = $composerBuilder( - composer: this, - getCurrentColumn: (t) => t.channelCid, - referencedTable: $db.channels, - getReferencedColumn: (t) => t.cid, - builder: (joinBuilder, - {$addJoinBuilderToRootComposer, - $removeJoinBuilderFromRootComposer}) => - $$ChannelsTableOrderingComposer( - $db: $db, - $table: $db.channels, - $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, - joinBuilder: joinBuilder, - $removeJoinBuilderFromRootComposer: - $removeJoinBuilderFromRootComposer, - )); + composer: this, + getCurrentColumn: (t) => t.channelCid, + referencedTable: $db.channels, + getReferencedColumn: (t) => t.cid, + builder: (joinBuilder, {$addJoinBuilderToRootComposer, $removeJoinBuilderFromRootComposer}) => + $$ChannelsTableOrderingComposer( + $db: $db, + $table: $db.channels, + $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, + joinBuilder: joinBuilder, + $removeJoinBuilderFromRootComposer: $removeJoinBuilderFromRootComposer, + ), + ); return composer; } } -class $$ReadsTableAnnotationComposer - extends Composer<_$DriftChatDatabase, $ReadsTable> { +class $$ReadsTableAnnotationComposer extends Composer<_$DriftChatDatabase, $ReadsTable> { $$ReadsTableAnnotationComposer({ required super.$db, required super.$table, @@ -13639,117 +14451,113 @@ class $$ReadsTableAnnotationComposer super.$addJoinBuilderToRootComposer, super.$removeJoinBuilderFromRootComposer, }); - GeneratedColumn get lastRead => - $composableBuilder(column: $table.lastRead, builder: (column) => column); + GeneratedColumn get lastRead => $composableBuilder(column: $table.lastRead, builder: (column) => column); - GeneratedColumn get userId => - $composableBuilder(column: $table.userId, builder: (column) => column); + GeneratedColumn get userId => $composableBuilder(column: $table.userId, builder: (column) => column); - GeneratedColumn get unreadMessages => $composableBuilder( - column: $table.unreadMessages, builder: (column) => column); + GeneratedColumn get unreadMessages => + $composableBuilder(column: $table.unreadMessages, builder: (column) => column); - GeneratedColumn get lastReadMessageId => $composableBuilder( - column: $table.lastReadMessageId, builder: (column) => column); + GeneratedColumn get lastReadMessageId => + $composableBuilder(column: $table.lastReadMessageId, builder: (column) => column); - GeneratedColumn get lastDeliveredAt => $composableBuilder( - column: $table.lastDeliveredAt, builder: (column) => column); + GeneratedColumn get lastDeliveredAt => + $composableBuilder(column: $table.lastDeliveredAt, builder: (column) => column); - GeneratedColumn get lastDeliveredMessageId => $composableBuilder( - column: $table.lastDeliveredMessageId, builder: (column) => column); + GeneratedColumn get lastDeliveredMessageId => + $composableBuilder(column: $table.lastDeliveredMessageId, builder: (column) => column); $$ChannelsTableAnnotationComposer get channelCid { final $$ChannelsTableAnnotationComposer composer = $composerBuilder( - composer: this, - getCurrentColumn: (t) => t.channelCid, - referencedTable: $db.channels, - getReferencedColumn: (t) => t.cid, - builder: (joinBuilder, - {$addJoinBuilderToRootComposer, - $removeJoinBuilderFromRootComposer}) => - $$ChannelsTableAnnotationComposer( - $db: $db, - $table: $db.channels, - $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, - joinBuilder: joinBuilder, - $removeJoinBuilderFromRootComposer: - $removeJoinBuilderFromRootComposer, - )); + composer: this, + getCurrentColumn: (t) => t.channelCid, + referencedTable: $db.channels, + getReferencedColumn: (t) => t.cid, + builder: (joinBuilder, {$addJoinBuilderToRootComposer, $removeJoinBuilderFromRootComposer}) => + $$ChannelsTableAnnotationComposer( + $db: $db, + $table: $db.channels, + $addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer, + joinBuilder: joinBuilder, + $removeJoinBuilderFromRootComposer: $removeJoinBuilderFromRootComposer, + ), + ); return composer; } } -class $$ReadsTableTableManager extends RootTableManager< - _$DriftChatDatabase, - $ReadsTable, - ReadEntity, - $$ReadsTableFilterComposer, - $$ReadsTableOrderingComposer, - $$ReadsTableAnnotationComposer, - $$ReadsTableCreateCompanionBuilder, - $$ReadsTableUpdateCompanionBuilder, - (ReadEntity, $$ReadsTableReferences), - ReadEntity, - PrefetchHooks Function({bool channelCid})> { +class $$ReadsTableTableManager + extends + RootTableManager< + _$DriftChatDatabase, + $ReadsTable, + ReadEntity, + $$ReadsTableFilterComposer, + $$ReadsTableOrderingComposer, + $$ReadsTableAnnotationComposer, + $$ReadsTableCreateCompanionBuilder, + $$ReadsTableUpdateCompanionBuilder, + (ReadEntity, $$ReadsTableReferences), + ReadEntity, + PrefetchHooks Function({bool channelCid}) + > { $$ReadsTableTableManager(_$DriftChatDatabase db, $ReadsTable table) - : super(TableManagerState( + : super( + TableManagerState( db: db, table: table, - createFilteringComposer: () => - $$ReadsTableFilterComposer($db: db, $table: table), - createOrderingComposer: () => - $$ReadsTableOrderingComposer($db: db, $table: table), - createComputedFieldComposer: () => - $$ReadsTableAnnotationComposer($db: db, $table: table), - updateCompanionCallback: ({ - Value lastRead = const Value.absent(), - Value userId = const Value.absent(), - Value channelCid = const Value.absent(), - Value unreadMessages = const Value.absent(), - Value lastReadMessageId = const Value.absent(), - Value lastDeliveredAt = const Value.absent(), - Value lastDeliveredMessageId = const Value.absent(), - Value rowid = const Value.absent(), - }) => - ReadsCompanion( - lastRead: lastRead, - userId: userId, - channelCid: channelCid, - unreadMessages: unreadMessages, - lastReadMessageId: lastReadMessageId, - lastDeliveredAt: lastDeliveredAt, - lastDeliveredMessageId: lastDeliveredMessageId, - rowid: rowid, - ), - createCompanionCallback: ({ - required DateTime lastRead, - required String userId, - required String channelCid, - Value unreadMessages = const Value.absent(), - Value lastReadMessageId = const Value.absent(), - Value lastDeliveredAt = const Value.absent(), - Value lastDeliveredMessageId = const Value.absent(), - Value rowid = const Value.absent(), - }) => - ReadsCompanion.insert( - lastRead: lastRead, - userId: userId, - channelCid: channelCid, - unreadMessages: unreadMessages, - lastReadMessageId: lastReadMessageId, - lastDeliveredAt: lastDeliveredAt, - lastDeliveredMessageId: lastDeliveredMessageId, - rowid: rowid, - ), - withReferenceMapper: (p0) => p0 - .map((e) => - (e.readTable(table), $$ReadsTableReferences(db, table, e))) - .toList(), + createFilteringComposer: () => $$ReadsTableFilterComposer($db: db, $table: table), + createOrderingComposer: () => $$ReadsTableOrderingComposer($db: db, $table: table), + createComputedFieldComposer: () => $$ReadsTableAnnotationComposer($db: db, $table: table), + updateCompanionCallback: + ({ + Value lastRead = const Value.absent(), + Value userId = const Value.absent(), + Value channelCid = const Value.absent(), + Value unreadMessages = const Value.absent(), + Value lastReadMessageId = const Value.absent(), + Value lastDeliveredAt = const Value.absent(), + Value lastDeliveredMessageId = const Value.absent(), + Value rowid = const Value.absent(), + }) => ReadsCompanion( + lastRead: lastRead, + userId: userId, + channelCid: channelCid, + unreadMessages: unreadMessages, + lastReadMessageId: lastReadMessageId, + lastDeliveredAt: lastDeliveredAt, + lastDeliveredMessageId: lastDeliveredMessageId, + rowid: rowid, + ), + createCompanionCallback: + ({ + required DateTime lastRead, + required String userId, + required String channelCid, + Value unreadMessages = const Value.absent(), + Value lastReadMessageId = const Value.absent(), + Value lastDeliveredAt = const Value.absent(), + Value lastDeliveredMessageId = const Value.absent(), + Value rowid = const Value.absent(), + }) => ReadsCompanion.insert( + lastRead: lastRead, + userId: userId, + channelCid: channelCid, + unreadMessages: unreadMessages, + lastReadMessageId: lastReadMessageId, + lastDeliveredAt: lastDeliveredAt, + lastDeliveredMessageId: lastDeliveredMessageId, + rowid: rowid, + ), + withReferenceMapper: (p0) => + p0.map((e) => (e.readTable(table), $$ReadsTableReferences(db, table, e))).toList(), prefetchHooksCallback: ({channelCid = false}) { return PrefetchHooks( db: db, explicitlyWatchedTables: [], - addJoins: < - T extends TableManagerState< + addJoins: + < + T extends TableManagerState< dynamic, dynamic, dynamic, @@ -13760,55 +14568,59 @@ class $$ReadsTableTableManager extends RootTableManager< dynamic, dynamic, dynamic, - dynamic>>(state) { - if (channelCid) { - state = state.withJoin( - currentTable: table, - currentColumn: table.channelCid, - referencedTable: - $$ReadsTableReferences._channelCidTable(db), - referencedColumn: - $$ReadsTableReferences._channelCidTable(db).cid, - ) as T; - } - - return state; - }, + dynamic + > + >(state) { + if (channelCid) { + state = + state.withJoin( + currentTable: table, + currentColumn: table.channelCid, + referencedTable: $$ReadsTableReferences._channelCidTable(db), + referencedColumn: $$ReadsTableReferences._channelCidTable(db).cid, + ) + as T; + } + + return state; + }, getPrefetchedDataCallback: (items) async { return []; }, ); }, - )); + ), + ); } -typedef $$ReadsTableProcessedTableManager = ProcessedTableManager< - _$DriftChatDatabase, - $ReadsTable, - ReadEntity, - $$ReadsTableFilterComposer, - $$ReadsTableOrderingComposer, - $$ReadsTableAnnotationComposer, - $$ReadsTableCreateCompanionBuilder, - $$ReadsTableUpdateCompanionBuilder, - (ReadEntity, $$ReadsTableReferences), - ReadEntity, - PrefetchHooks Function({bool channelCid})>; -typedef $$ChannelQueriesTableCreateCompanionBuilder = ChannelQueriesCompanion - Function({ - required String queryHash, - required String channelCid, - Value rowid, -}); -typedef $$ChannelQueriesTableUpdateCompanionBuilder = ChannelQueriesCompanion - Function({ - Value queryHash, - Value channelCid, - Value rowid, -}); - -class $$ChannelQueriesTableFilterComposer - extends Composer<_$DriftChatDatabase, $ChannelQueriesTable> { +typedef $$ReadsTableProcessedTableManager = + ProcessedTableManager< + _$DriftChatDatabase, + $ReadsTable, + ReadEntity, + $$ReadsTableFilterComposer, + $$ReadsTableOrderingComposer, + $$ReadsTableAnnotationComposer, + $$ReadsTableCreateCompanionBuilder, + $$ReadsTableUpdateCompanionBuilder, + (ReadEntity, $$ReadsTableReferences), + ReadEntity, + PrefetchHooks Function({bool channelCid}) + >; +typedef $$ChannelQueriesTableCreateCompanionBuilder = + ChannelQueriesCompanion Function({ + required String queryHash, + required String channelCid, + Value rowid, + }); +typedef $$ChannelQueriesTableUpdateCompanionBuilder = + ChannelQueriesCompanion Function({ + Value queryHash, + Value channelCid, + Value rowid, + }); + +class $$ChannelQueriesTableFilterComposer extends Composer<_$DriftChatDatabase, $ChannelQueriesTable> { $$ChannelQueriesTableFilterComposer({ required super.$db, required super.$table, @@ -13816,15 +14628,14 @@ class $$ChannelQueriesTableFilterComposer super.$addJoinBuilderToRootComposer, super.$removeJoinBuilderFromRootComposer, }); - ColumnFilters get queryHash => $composableBuilder( - column: $table.queryHash, builder: (column) => ColumnFilters(column)); + ColumnFilters get queryHash => + $composableBuilder(column: $table.queryHash, builder: (column) => ColumnFilters(column)); - ColumnFilters get channelCid => $composableBuilder( - column: $table.channelCid, builder: (column) => ColumnFilters(column)); + ColumnFilters get channelCid => + $composableBuilder(column: $table.channelCid, builder: (column) => ColumnFilters(column)); } -class $$ChannelQueriesTableOrderingComposer - extends Composer<_$DriftChatDatabase, $ChannelQueriesTable> { +class $$ChannelQueriesTableOrderingComposer extends Composer<_$DriftChatDatabase, $ChannelQueriesTable> { $$ChannelQueriesTableOrderingComposer({ required super.$db, required super.$table, @@ -13832,15 +14643,14 @@ class $$ChannelQueriesTableOrderingComposer super.$addJoinBuilderToRootComposer, super.$removeJoinBuilderFromRootComposer, }); - ColumnOrderings get queryHash => $composableBuilder( - column: $table.queryHash, builder: (column) => ColumnOrderings(column)); + ColumnOrderings get queryHash => + $composableBuilder(column: $table.queryHash, builder: (column) => ColumnOrderings(column)); - ColumnOrderings get channelCid => $composableBuilder( - column: $table.channelCid, builder: (column) => ColumnOrderings(column)); + ColumnOrderings get channelCid => + $composableBuilder(column: $table.channelCid, builder: (column) => ColumnOrderings(column)); } -class $$ChannelQueriesTableAnnotationComposer - extends Composer<_$DriftChatDatabase, $ChannelQueriesTable> { +class $$ChannelQueriesTableAnnotationComposer extends Composer<_$DriftChatDatabase, $ChannelQueriesTable> { $$ChannelQueriesTableAnnotationComposer({ required super.$db, required super.$table, @@ -13848,106 +14658,96 @@ class $$ChannelQueriesTableAnnotationComposer super.$addJoinBuilderToRootComposer, super.$removeJoinBuilderFromRootComposer, }); - GeneratedColumn get queryHash => - $composableBuilder(column: $table.queryHash, builder: (column) => column); + GeneratedColumn get queryHash => $composableBuilder(column: $table.queryHash, builder: (column) => column); - GeneratedColumn get channelCid => $composableBuilder( - column: $table.channelCid, builder: (column) => column); + GeneratedColumn get channelCid => $composableBuilder(column: $table.channelCid, builder: (column) => column); } -class $$ChannelQueriesTableTableManager extends RootTableManager< - _$DriftChatDatabase, - $ChannelQueriesTable, - ChannelQueryEntity, - $$ChannelQueriesTableFilterComposer, - $$ChannelQueriesTableOrderingComposer, - $$ChannelQueriesTableAnnotationComposer, - $$ChannelQueriesTableCreateCompanionBuilder, - $$ChannelQueriesTableUpdateCompanionBuilder, - ( - ChannelQueryEntity, - BaseReferences<_$DriftChatDatabase, $ChannelQueriesTable, - ChannelQueryEntity> - ), - ChannelQueryEntity, - PrefetchHooks Function()> { - $$ChannelQueriesTableTableManager( - _$DriftChatDatabase db, $ChannelQueriesTable table) - : super(TableManagerState( +class $$ChannelQueriesTableTableManager + extends + RootTableManager< + _$DriftChatDatabase, + $ChannelQueriesTable, + ChannelQueryEntity, + $$ChannelQueriesTableFilterComposer, + $$ChannelQueriesTableOrderingComposer, + $$ChannelQueriesTableAnnotationComposer, + $$ChannelQueriesTableCreateCompanionBuilder, + $$ChannelQueriesTableUpdateCompanionBuilder, + (ChannelQueryEntity, BaseReferences<_$DriftChatDatabase, $ChannelQueriesTable, ChannelQueryEntity>), + ChannelQueryEntity, + PrefetchHooks Function() + > { + $$ChannelQueriesTableTableManager(_$DriftChatDatabase db, $ChannelQueriesTable table) + : super( + TableManagerState( db: db, table: table, - createFilteringComposer: () => - $$ChannelQueriesTableFilterComposer($db: db, $table: table), - createOrderingComposer: () => - $$ChannelQueriesTableOrderingComposer($db: db, $table: table), - createComputedFieldComposer: () => - $$ChannelQueriesTableAnnotationComposer($db: db, $table: table), - updateCompanionCallback: ({ - Value queryHash = const Value.absent(), - Value channelCid = const Value.absent(), - Value rowid = const Value.absent(), - }) => - ChannelQueriesCompanion( - queryHash: queryHash, - channelCid: channelCid, - rowid: rowid, - ), - createCompanionCallback: ({ - required String queryHash, - required String channelCid, - Value rowid = const Value.absent(), - }) => - ChannelQueriesCompanion.insert( - queryHash: queryHash, - channelCid: channelCid, - rowid: rowid, - ), - withReferenceMapper: (p0) => p0 - .map((e) => (e.readTable(table), BaseReferences(db, table, e))) - .toList(), + createFilteringComposer: () => $$ChannelQueriesTableFilterComposer($db: db, $table: table), + createOrderingComposer: () => $$ChannelQueriesTableOrderingComposer($db: db, $table: table), + createComputedFieldComposer: () => $$ChannelQueriesTableAnnotationComposer($db: db, $table: table), + updateCompanionCallback: + ({ + Value queryHash = const Value.absent(), + Value channelCid = const Value.absent(), + Value rowid = const Value.absent(), + }) => ChannelQueriesCompanion( + queryHash: queryHash, + channelCid: channelCid, + rowid: rowid, + ), + createCompanionCallback: + ({ + required String queryHash, + required String channelCid, + Value rowid = const Value.absent(), + }) => ChannelQueriesCompanion.insert( + queryHash: queryHash, + channelCid: channelCid, + rowid: rowid, + ), + withReferenceMapper: (p0) => p0.map((e) => (e.readTable(table), BaseReferences(db, table, e))).toList(), prefetchHooksCallback: null, - )); + ), + ); } -typedef $$ChannelQueriesTableProcessedTableManager = ProcessedTableManager< - _$DriftChatDatabase, - $ChannelQueriesTable, - ChannelQueryEntity, - $$ChannelQueriesTableFilterComposer, - $$ChannelQueriesTableOrderingComposer, - $$ChannelQueriesTableAnnotationComposer, - $$ChannelQueriesTableCreateCompanionBuilder, - $$ChannelQueriesTableUpdateCompanionBuilder, - ( +typedef $$ChannelQueriesTableProcessedTableManager = + ProcessedTableManager< + _$DriftChatDatabase, + $ChannelQueriesTable, + ChannelQueryEntity, + $$ChannelQueriesTableFilterComposer, + $$ChannelQueriesTableOrderingComposer, + $$ChannelQueriesTableAnnotationComposer, + $$ChannelQueriesTableCreateCompanionBuilder, + $$ChannelQueriesTableUpdateCompanionBuilder, + (ChannelQueryEntity, BaseReferences<_$DriftChatDatabase, $ChannelQueriesTable, ChannelQueryEntity>), ChannelQueryEntity, - BaseReferences<_$DriftChatDatabase, $ChannelQueriesTable, - ChannelQueryEntity> - ), - ChannelQueryEntity, - PrefetchHooks Function()>; -typedef $$ConnectionEventsTableCreateCompanionBuilder - = ConnectionEventsCompanion Function({ - Value id, - required String type, - Value?> ownUser, - Value totalUnreadCount, - Value unreadChannels, - Value lastEventAt, - Value lastSyncAt, -}); -typedef $$ConnectionEventsTableUpdateCompanionBuilder - = ConnectionEventsCompanion Function({ - Value id, - Value type, - Value?> ownUser, - Value totalUnreadCount, - Value unreadChannels, - Value lastEventAt, - Value lastSyncAt, -}); - -class $$ConnectionEventsTableFilterComposer - extends Composer<_$DriftChatDatabase, $ConnectionEventsTable> { + PrefetchHooks Function() + >; +typedef $$ConnectionEventsTableCreateCompanionBuilder = + ConnectionEventsCompanion Function({ + Value id, + required String type, + Value?> ownUser, + Value totalUnreadCount, + Value unreadChannels, + Value lastEventAt, + Value lastSyncAt, + }); +typedef $$ConnectionEventsTableUpdateCompanionBuilder = + ConnectionEventsCompanion Function({ + Value id, + Value type, + Value?> ownUser, + Value totalUnreadCount, + Value unreadChannels, + Value lastEventAt, + Value lastSyncAt, + }); + +class $$ConnectionEventsTableFilterComposer extends Composer<_$DriftChatDatabase, $ConnectionEventsTable> { $$ConnectionEventsTableFilterComposer({ required super.$db, required super.$table, @@ -13955,35 +14755,27 @@ class $$ConnectionEventsTableFilterComposer super.$addJoinBuilderToRootComposer, super.$removeJoinBuilderFromRootComposer, }); - ColumnFilters get id => $composableBuilder( - column: $table.id, builder: (column) => ColumnFilters(column)); + ColumnFilters get id => $composableBuilder(column: $table.id, builder: (column) => ColumnFilters(column)); - ColumnFilters get type => $composableBuilder( - column: $table.type, builder: (column) => ColumnFilters(column)); + ColumnFilters get type => $composableBuilder(column: $table.type, builder: (column) => ColumnFilters(column)); - ColumnWithTypeConverterFilters?, Map, - String> - get ownUser => $composableBuilder( - column: $table.ownUser, - builder: (column) => ColumnWithTypeConverterFilters(column)); + ColumnWithTypeConverterFilters?, Map, String> get ownUser => + $composableBuilder(column: $table.ownUser, builder: (column) => ColumnWithTypeConverterFilters(column)); - ColumnFilters get totalUnreadCount => $composableBuilder( - column: $table.totalUnreadCount, - builder: (column) => ColumnFilters(column)); + ColumnFilters get totalUnreadCount => + $composableBuilder(column: $table.totalUnreadCount, builder: (column) => ColumnFilters(column)); - ColumnFilters get unreadChannels => $composableBuilder( - column: $table.unreadChannels, - builder: (column) => ColumnFilters(column)); + ColumnFilters get unreadChannels => + $composableBuilder(column: $table.unreadChannels, builder: (column) => ColumnFilters(column)); - ColumnFilters get lastEventAt => $composableBuilder( - column: $table.lastEventAt, builder: (column) => ColumnFilters(column)); + ColumnFilters get lastEventAt => + $composableBuilder(column: $table.lastEventAt, builder: (column) => ColumnFilters(column)); - ColumnFilters get lastSyncAt => $composableBuilder( - column: $table.lastSyncAt, builder: (column) => ColumnFilters(column)); + ColumnFilters get lastSyncAt => + $composableBuilder(column: $table.lastSyncAt, builder: (column) => ColumnFilters(column)); } -class $$ConnectionEventsTableOrderingComposer - extends Composer<_$DriftChatDatabase, $ConnectionEventsTable> { +class $$ConnectionEventsTableOrderingComposer extends Composer<_$DriftChatDatabase, $ConnectionEventsTable> { $$ConnectionEventsTableOrderingComposer({ required super.$db, required super.$table, @@ -13991,32 +14783,28 @@ class $$ConnectionEventsTableOrderingComposer super.$addJoinBuilderToRootComposer, super.$removeJoinBuilderFromRootComposer, }); - ColumnOrderings get id => $composableBuilder( - column: $table.id, builder: (column) => ColumnOrderings(column)); + ColumnOrderings get id => $composableBuilder(column: $table.id, builder: (column) => ColumnOrderings(column)); - ColumnOrderings get type => $composableBuilder( - column: $table.type, builder: (column) => ColumnOrderings(column)); + ColumnOrderings get type => + $composableBuilder(column: $table.type, builder: (column) => ColumnOrderings(column)); - ColumnOrderings get ownUser => $composableBuilder( - column: $table.ownUser, builder: (column) => ColumnOrderings(column)); + ColumnOrderings get ownUser => + $composableBuilder(column: $table.ownUser, builder: (column) => ColumnOrderings(column)); - ColumnOrderings get totalUnreadCount => $composableBuilder( - column: $table.totalUnreadCount, - builder: (column) => ColumnOrderings(column)); + ColumnOrderings get totalUnreadCount => + $composableBuilder(column: $table.totalUnreadCount, builder: (column) => ColumnOrderings(column)); - ColumnOrderings get unreadChannels => $composableBuilder( - column: $table.unreadChannels, - builder: (column) => ColumnOrderings(column)); + ColumnOrderings get unreadChannels => + $composableBuilder(column: $table.unreadChannels, builder: (column) => ColumnOrderings(column)); - ColumnOrderings get lastEventAt => $composableBuilder( - column: $table.lastEventAt, builder: (column) => ColumnOrderings(column)); + ColumnOrderings get lastEventAt => + $composableBuilder(column: $table.lastEventAt, builder: (column) => ColumnOrderings(column)); - ColumnOrderings get lastSyncAt => $composableBuilder( - column: $table.lastSyncAt, builder: (column) => ColumnOrderings(column)); + ColumnOrderings get lastSyncAt => + $composableBuilder(column: $table.lastSyncAt, builder: (column) => ColumnOrderings(column)); } -class $$ConnectionEventsTableAnnotationComposer - extends Composer<_$DriftChatDatabase, $ConnectionEventsTable> { +class $$ConnectionEventsTableAnnotationComposer extends Composer<_$DriftChatDatabase, $ConnectionEventsTable> { $$ConnectionEventsTableAnnotationComposer({ required super.$db, required super.$table, @@ -14024,143 +14812,123 @@ class $$ConnectionEventsTableAnnotationComposer super.$addJoinBuilderToRootComposer, super.$removeJoinBuilderFromRootComposer, }); - GeneratedColumn get id => - $composableBuilder(column: $table.id, builder: (column) => column); + GeneratedColumn get id => $composableBuilder(column: $table.id, builder: (column) => column); - GeneratedColumn get type => - $composableBuilder(column: $table.type, builder: (column) => column); + GeneratedColumn get type => $composableBuilder(column: $table.type, builder: (column) => column); GeneratedColumnWithTypeConverter?, String> get ownUser => $composableBuilder(column: $table.ownUser, builder: (column) => column); - GeneratedColumn get totalUnreadCount => $composableBuilder( - column: $table.totalUnreadCount, builder: (column) => column); + GeneratedColumn get totalUnreadCount => + $composableBuilder(column: $table.totalUnreadCount, builder: (column) => column); - GeneratedColumn get unreadChannels => $composableBuilder( - column: $table.unreadChannels, builder: (column) => column); + GeneratedColumn get unreadChannels => + $composableBuilder(column: $table.unreadChannels, builder: (column) => column); - GeneratedColumn get lastEventAt => $composableBuilder( - column: $table.lastEventAt, builder: (column) => column); + GeneratedColumn get lastEventAt => + $composableBuilder(column: $table.lastEventAt, builder: (column) => column); - GeneratedColumn get lastSyncAt => $composableBuilder( - column: $table.lastSyncAt, builder: (column) => column); + GeneratedColumn get lastSyncAt => + $composableBuilder(column: $table.lastSyncAt, builder: (column) => column); } -class $$ConnectionEventsTableTableManager extends RootTableManager< - _$DriftChatDatabase, - $ConnectionEventsTable, - ConnectionEventEntity, - $$ConnectionEventsTableFilterComposer, - $$ConnectionEventsTableOrderingComposer, - $$ConnectionEventsTableAnnotationComposer, - $$ConnectionEventsTableCreateCompanionBuilder, - $$ConnectionEventsTableUpdateCompanionBuilder, - ( - ConnectionEventEntity, - BaseReferences<_$DriftChatDatabase, $ConnectionEventsTable, - ConnectionEventEntity> - ), - ConnectionEventEntity, - PrefetchHooks Function()> { - $$ConnectionEventsTableTableManager( - _$DriftChatDatabase db, $ConnectionEventsTable table) - : super(TableManagerState( +class $$ConnectionEventsTableTableManager + extends + RootTableManager< + _$DriftChatDatabase, + $ConnectionEventsTable, + ConnectionEventEntity, + $$ConnectionEventsTableFilterComposer, + $$ConnectionEventsTableOrderingComposer, + $$ConnectionEventsTableAnnotationComposer, + $$ConnectionEventsTableCreateCompanionBuilder, + $$ConnectionEventsTableUpdateCompanionBuilder, + (ConnectionEventEntity, BaseReferences<_$DriftChatDatabase, $ConnectionEventsTable, ConnectionEventEntity>), + ConnectionEventEntity, + PrefetchHooks Function() + > { + $$ConnectionEventsTableTableManager(_$DriftChatDatabase db, $ConnectionEventsTable table) + : super( + TableManagerState( db: db, table: table, - createFilteringComposer: () => - $$ConnectionEventsTableFilterComposer($db: db, $table: table), - createOrderingComposer: () => - $$ConnectionEventsTableOrderingComposer($db: db, $table: table), - createComputedFieldComposer: () => - $$ConnectionEventsTableAnnotationComposer($db: db, $table: table), - updateCompanionCallback: ({ - Value id = const Value.absent(), - Value type = const Value.absent(), - Value?> ownUser = const Value.absent(), - Value totalUnreadCount = const Value.absent(), - Value unreadChannels = const Value.absent(), - Value lastEventAt = const Value.absent(), - Value lastSyncAt = const Value.absent(), - }) => - ConnectionEventsCompanion( - id: id, - type: type, - ownUser: ownUser, - totalUnreadCount: totalUnreadCount, - unreadChannels: unreadChannels, - lastEventAt: lastEventAt, - lastSyncAt: lastSyncAt, - ), - createCompanionCallback: ({ - Value id = const Value.absent(), - required String type, - Value?> ownUser = const Value.absent(), - Value totalUnreadCount = const Value.absent(), - Value unreadChannels = const Value.absent(), - Value lastEventAt = const Value.absent(), - Value lastSyncAt = const Value.absent(), - }) => - ConnectionEventsCompanion.insert( - id: id, - type: type, - ownUser: ownUser, - totalUnreadCount: totalUnreadCount, - unreadChannels: unreadChannels, - lastEventAt: lastEventAt, - lastSyncAt: lastSyncAt, - ), - withReferenceMapper: (p0) => p0 - .map((e) => (e.readTable(table), BaseReferences(db, table, e))) - .toList(), + createFilteringComposer: () => $$ConnectionEventsTableFilterComposer($db: db, $table: table), + createOrderingComposer: () => $$ConnectionEventsTableOrderingComposer($db: db, $table: table), + createComputedFieldComposer: () => $$ConnectionEventsTableAnnotationComposer($db: db, $table: table), + updateCompanionCallback: + ({ + Value id = const Value.absent(), + Value type = const Value.absent(), + Value?> ownUser = const Value.absent(), + Value totalUnreadCount = const Value.absent(), + Value unreadChannels = const Value.absent(), + Value lastEventAt = const Value.absent(), + Value lastSyncAt = const Value.absent(), + }) => ConnectionEventsCompanion( + id: id, + type: type, + ownUser: ownUser, + totalUnreadCount: totalUnreadCount, + unreadChannels: unreadChannels, + lastEventAt: lastEventAt, + lastSyncAt: lastSyncAt, + ), + createCompanionCallback: + ({ + Value id = const Value.absent(), + required String type, + Value?> ownUser = const Value.absent(), + Value totalUnreadCount = const Value.absent(), + Value unreadChannels = const Value.absent(), + Value lastEventAt = const Value.absent(), + Value lastSyncAt = const Value.absent(), + }) => ConnectionEventsCompanion.insert( + id: id, + type: type, + ownUser: ownUser, + totalUnreadCount: totalUnreadCount, + unreadChannels: unreadChannels, + lastEventAt: lastEventAt, + lastSyncAt: lastSyncAt, + ), + withReferenceMapper: (p0) => p0.map((e) => (e.readTable(table), BaseReferences(db, table, e))).toList(), prefetchHooksCallback: null, - )); + ), + ); } -typedef $$ConnectionEventsTableProcessedTableManager = ProcessedTableManager< - _$DriftChatDatabase, - $ConnectionEventsTable, - ConnectionEventEntity, - $$ConnectionEventsTableFilterComposer, - $$ConnectionEventsTableOrderingComposer, - $$ConnectionEventsTableAnnotationComposer, - $$ConnectionEventsTableCreateCompanionBuilder, - $$ConnectionEventsTableUpdateCompanionBuilder, - ( +typedef $$ConnectionEventsTableProcessedTableManager = + ProcessedTableManager< + _$DriftChatDatabase, + $ConnectionEventsTable, + ConnectionEventEntity, + $$ConnectionEventsTableFilterComposer, + $$ConnectionEventsTableOrderingComposer, + $$ConnectionEventsTableAnnotationComposer, + $$ConnectionEventsTableCreateCompanionBuilder, + $$ConnectionEventsTableUpdateCompanionBuilder, + (ConnectionEventEntity, BaseReferences<_$DriftChatDatabase, $ConnectionEventsTable, ConnectionEventEntity>), ConnectionEventEntity, - BaseReferences<_$DriftChatDatabase, $ConnectionEventsTable, - ConnectionEventEntity> - ), - ConnectionEventEntity, - PrefetchHooks Function()>; + PrefetchHooks Function() + >; class $DriftChatDatabaseManager { final _$DriftChatDatabase _db; $DriftChatDatabaseManager(this._db); - $$ChannelsTableTableManager get channels => - $$ChannelsTableTableManager(_db, _db.channels); - $$MessagesTableTableManager get messages => - $$MessagesTableTableManager(_db, _db.messages); - $$DraftMessagesTableTableManager get draftMessages => - $$DraftMessagesTableTableManager(_db, _db.draftMessages); - $$PinnedMessagesTableTableManager get pinnedMessages => - $$PinnedMessagesTableTableManager(_db, _db.pinnedMessages); - $$PollsTableTableManager get polls => - $$PollsTableTableManager(_db, _db.polls); - $$PollVotesTableTableManager get pollVotes => - $$PollVotesTableTableManager(_db, _db.pollVotes); + $$ChannelsTableTableManager get channels => $$ChannelsTableTableManager(_db, _db.channels); + $$MessagesTableTableManager get messages => $$MessagesTableTableManager(_db, _db.messages); + $$DraftMessagesTableTableManager get draftMessages => $$DraftMessagesTableTableManager(_db, _db.draftMessages); + $$LocationsTableTableManager get locations => $$LocationsTableTableManager(_db, _db.locations); + $$PinnedMessagesTableTableManager get pinnedMessages => $$PinnedMessagesTableTableManager(_db, _db.pinnedMessages); + $$PollsTableTableManager get polls => $$PollsTableTableManager(_db, _db.polls); + $$PollVotesTableTableManager get pollVotes => $$PollVotesTableTableManager(_db, _db.pollVotes); $$PinnedMessageReactionsTableTableManager get pinnedMessageReactions => - $$PinnedMessageReactionsTableTableManager( - _db, _db.pinnedMessageReactions); - $$ReactionsTableTableManager get reactions => - $$ReactionsTableTableManager(_db, _db.reactions); - $$UsersTableTableManager get users => - $$UsersTableTableManager(_db, _db.users); - $$MembersTableTableManager get members => - $$MembersTableTableManager(_db, _db.members); - $$ReadsTableTableManager get reads => - $$ReadsTableTableManager(_db, _db.reads); - $$ChannelQueriesTableTableManager get channelQueries => - $$ChannelQueriesTableTableManager(_db, _db.channelQueries); + $$PinnedMessageReactionsTableTableManager(_db, _db.pinnedMessageReactions); + $$ReactionsTableTableManager get reactions => $$ReactionsTableTableManager(_db, _db.reactions); + $$UsersTableTableManager get users => $$UsersTableTableManager(_db, _db.users); + $$MembersTableTableManager get members => $$MembersTableTableManager(_db, _db.members); + $$ReadsTableTableManager get reads => $$ReadsTableTableManager(_db, _db.reads); + $$ChannelQueriesTableTableManager get channelQueries => $$ChannelQueriesTableTableManager(_db, _db.channelQueries); $$ConnectionEventsTableTableManager get connectionEvents => $$ConnectionEventsTableTableManager(_db, _db.connectionEvents); } diff --git a/packages/stream_chat_persistence/lib/src/db/shared/native_db.dart b/packages/stream_chat_persistence/lib/src/db/shared/native_db.dart index ca31c3bb2d..0af7d3a543 100644 --- a/packages/stream_chat_persistence/lib/src/db/shared/native_db.dart +++ b/packages/stream_chat_persistence/lib/src/db/shared/native_db.dart @@ -25,13 +25,15 @@ class SharedDB { if (connectionMode == ConnectionMode.background) { return DriftChatDatabase( userId, - DatabaseConnection.delayed(Future(() async { - final isolate = await _createMoorIsolate( - dbName, - logStatements: logStatements, - ); - return isolate.connect(); - })), + DatabaseConnection.delayed( + Future(() async { + final isolate = await _createMoorIsolate( + dbName, + logStatements: logStatements, + ); + return isolate.connect(); + }), + ), ); } @@ -64,10 +66,12 @@ class SharedDB { } static void _startBackground(_IsolateStartRequest request) { - final executor = LazyDatabase(() async => NativeDatabase( - File(request.targetPath), - logStatements: request.logStatements, - )); + final executor = LazyDatabase( + () async => NativeDatabase( + File(request.targetPath), + logStatements: request.logStatements, + ), + ); final moorIsolate = DriftIsolate.inCurrent( () => DatabaseConnection(executor), ); diff --git a/packages/stream_chat_persistence/lib/src/entity/channel_queries.dart b/packages/stream_chat_persistence/lib/src/entity/channel_queries.dart index a9d3dc6cc0..44829c0fa0 100644 --- a/packages/stream_chat_persistence/lib/src/entity/channel_queries.dart +++ b/packages/stream_chat_persistence/lib/src/entity/channel_queries.dart @@ -12,7 +12,7 @@ class ChannelQueries extends Table { @override Set get primaryKey => { - queryHash, - channelCid, - }; + queryHash, + channelCid, + }; } diff --git a/packages/stream_chat_persistence/lib/src/entity/channels.dart b/packages/stream_chat_persistence/lib/src/entity/channels.dart index d7e6cc4112..1e417e3ab0 100644 --- a/packages/stream_chat_persistence/lib/src/entity/channels.dart +++ b/packages/stream_chat_persistence/lib/src/entity/channels.dart @@ -15,8 +15,7 @@ class Channels extends Table { TextColumn get cid => text()(); /// List of user permissions on this channel - TextColumn get ownCapabilities => - text().nullable().map(ListConverter())(); + TextColumn get ownCapabilities => text().nullable().map(ListConverter())(); /// The channel configuration data TextColumn get config => text().map(MapConverter())(); diff --git a/packages/stream_chat_persistence/lib/src/entity/draft_messages.dart b/packages/stream_chat_persistence/lib/src/entity/draft_messages.dart index 0105aec3a1..6a9054eec1 100644 --- a/packages/stream_chat_persistence/lib/src/entity/draft_messages.dart +++ b/packages/stream_chat_persistence/lib/src/entity/draft_messages.dart @@ -24,9 +24,7 @@ class DraftMessages extends Table { TextColumn get mentionedUsers => text().map(ListConverter())(); /// The ID of the parent message, if the message is a thread reply. - TextColumn get parentId => text() - .nullable() - .references(Messages, #id, onDelete: KeyAction.cascade)(); + TextColumn get parentId => text().nullable().references(Messages, #id, onDelete: KeyAction.cascade)(); /// The ID of the quoted message, if the message is a quoted reply. TextColumn get quotedMessageId => text().nullable()(); @@ -47,8 +45,7 @@ class DraftMessages extends Table { DateTimeColumn get createdAt => dateTime().withDefault(currentDateAndTime)(); /// The channel cid of which this message is part of - TextColumn get channelCid => - text().references(Channels, #cid, onDelete: KeyAction.cascade)(); + TextColumn get channelCid => text().references(Channels, #cid, onDelete: KeyAction.cascade)(); /// Message custom extraData TextColumn get extraData => text().nullable().map(MapConverter())(); diff --git a/packages/stream_chat_persistence/lib/src/entity/entity.dart b/packages/stream_chat_persistence/lib/src/entity/entity.dart index 2ef87c5cb6..58fb6a164d 100644 --- a/packages/stream_chat_persistence/lib/src/entity/entity.dart +++ b/packages/stream_chat_persistence/lib/src/entity/entity.dart @@ -2,6 +2,7 @@ export 'channel_queries.dart'; export 'channels.dart'; export 'connection_events.dart'; export 'draft_messages.dart'; +export 'locations.dart'; export 'members.dart'; export 'messages.dart'; export 'pinned_message_reactions.dart'; diff --git a/packages/stream_chat_persistence/lib/src/entity/locations.dart b/packages/stream_chat_persistence/lib/src/entity/locations.dart new file mode 100644 index 0000000000..93b0678ec9 --- /dev/null +++ b/packages/stream_chat_persistence/lib/src/entity/locations.dart @@ -0,0 +1,39 @@ +// coverage:ignore-file +import 'package:drift/drift.dart'; +import 'package:stream_chat_persistence/src/entity/channels.dart'; +import 'package:stream_chat_persistence/src/entity/messages.dart'; + +/// Represents a [Locations] table in [DriftChatDatabase]. +@DataClassName('LocationEntity') +class Locations extends Table { + /// The channel CID where the location is shared + TextColumn get channelCid => text().nullable().references(Channels, #cid, onDelete: KeyAction.cascade)(); + + /// The ID of the message that contains this shared location + TextColumn get messageId => text().nullable().references(Messages, #id, onDelete: KeyAction.cascade)(); + + /// The ID of the user who shared the location + TextColumn get userId => text().nullable()(); + + /// The latitude of the shared location + RealColumn get latitude => real()(); + + /// The longitude of the shared location + RealColumn get longitude => real()(); + + /// The ID of the device that created the location + TextColumn get createdByDeviceId => text().nullable()(); + + /// The date at which the shared location will end (for live locations) + /// If null, this is a static location + DateTimeColumn get endAt => dateTime().nullable()(); + + /// The date at which the location was created + DateTimeColumn get createdAt => dateTime().withDefault(currentDateAndTime)(); + + /// The date at which the location was last updated + DateTimeColumn get updatedAt => dateTime().withDefault(currentDateAndTime)(); + + @override + Set get primaryKey => {messageId}; +} diff --git a/packages/stream_chat_persistence/lib/src/entity/members.dart b/packages/stream_chat_persistence/lib/src/entity/members.dart index 086effca7d..801c44c594 100644 --- a/packages/stream_chat_persistence/lib/src/entity/members.dart +++ b/packages/stream_chat_persistence/lib/src/entity/members.dart @@ -1,6 +1,6 @@ // coverage:ignore-file import 'package:drift/drift.dart'; -import 'package:stream_chat_persistence/src/converter/map_converter.dart'; +import 'package:stream_chat_persistence/src/converter/converter.dart'; import 'package:stream_chat_persistence/src/entity/channels.dart'; /// Represents a [Members] table in [MoorChatDatabase]. @@ -10,8 +10,7 @@ class Members extends Table { TextColumn get userId => text()(); /// The channel cid of which this user is part of - TextColumn get channelCid => - text().references(Channels, #cid, onDelete: KeyAction.cascade)(); + TextColumn get channelCid => text().references(Channels, #cid, onDelete: KeyAction.cascade)(); /// The role of the user in the channel TextColumn get channelRole => text().nullable()(); @@ -32,12 +31,10 @@ class Members extends Table { BoolColumn get shadowBanned => boolean().withDefault(const Constant(false))(); /// The date at which the channel was pinned by the member - DateTimeColumn get pinnedAt => - dateTime().nullable().withDefault(const Constant(null))(); + DateTimeColumn get pinnedAt => dateTime().nullable().withDefault(const Constant(null))(); /// The date at which the channel was archived by the member - DateTimeColumn get archivedAt => - dateTime().nullable().withDefault(const Constant(null))(); + DateTimeColumn get archivedAt => dateTime().nullable().withDefault(const Constant(null))(); /// True if the user is a moderator of the channel BoolColumn get isModerator => boolean().withDefault(const Constant(false))(); @@ -51,6 +48,12 @@ class Members extends Table { /// The last date of update DateTimeColumn get updatedAt => dateTime().withDefault(currentDateAndTime)(); + /// List of message ids deleted by the member only for himself. + /// + /// These messages are now marked deleted for this member, but are still + /// kept as regular to other channel members. + TextColumn get deletedMessages => text().map(ListConverter())(); + @override Set get primaryKey => {userId, channelCid}; } diff --git a/packages/stream_chat_persistence/lib/src/entity/messages.dart b/packages/stream_chat_persistence/lib/src/entity/messages.dart index 537c6ecdb3..6ede2f9f12 100644 --- a/packages/stream_chat_persistence/lib/src/entity/messages.dart +++ b/packages/stream_chat_persistence/lib/src/entity/messages.dart @@ -28,8 +28,7 @@ class Messages extends Table { TextColumn get mentionedUsers => text().map(ListConverter())(); /// A map describing the reaction group for every reaction - TextColumn get reactionGroups => - text().map(ReactionGroupsConverter()).nullable()(); + TextColumn get reactionGroups => text().map(ReactionGroupsConverter()).nullable()(); /// The ID of the parent message, if the message is a thread reply. TextColumn get parentId => text().nullable()(); @@ -99,6 +98,9 @@ class Messages extends Table { /// The DateTime on which the message was deleted on the server. DateTimeColumn get remoteDeletedAt => dateTime().nullable()(); + /// Whether the message was deleted only for the current user. + BoolColumn get deletedForMe => boolean().nullable()(); + /// The DateTime at which the message text was edited DateTimeColumn get messageTextUpdatedAt => dateTime().nullable()(); @@ -121,16 +123,13 @@ class Messages extends Table { TextColumn get pinnedByUserId => text().nullable()(); /// The channel cid of which this message is part of - TextColumn get channelCid => - text().references(Channels, #cid, onDelete: KeyAction.cascade)(); + TextColumn get channelCid => text().references(Channels, #cid, onDelete: KeyAction.cascade)(); /// A Map of [messageText] translations. - TextColumn get i18n => - text().nullable().map(NullableMapConverter())(); + TextColumn get i18n => text().nullable().map(NullableMapConverter())(); /// The list of user ids that should be able to see the message. - TextColumn get restrictedVisibility => - text().nullable().map(ListConverter())(); + TextColumn get restrictedVisibility => text().nullable().map(ListConverter())(); /// Message custom extraData TextColumn get extraData => text().nullable().map(MapConverter())(); diff --git a/packages/stream_chat_persistence/lib/src/entity/pinned_message_reactions.dart b/packages/stream_chat_persistence/lib/src/entity/pinned_message_reactions.dart index e4c9b06e58..97fa438f95 100644 --- a/packages/stream_chat_persistence/lib/src/entity/pinned_message_reactions.dart +++ b/packages/stream_chat_persistence/lib/src/entity/pinned_message_reactions.dart @@ -8,6 +8,5 @@ import 'package:stream_chat_persistence/src/entity/reactions.dart'; class PinnedMessageReactions extends Reactions { /// The messageId to which the reaction belongs @override - TextColumn get messageId => - text().references(PinnedMessages, #id, onDelete: KeyAction.cascade)(); + TextColumn get messageId => text().nullable().references(PinnedMessages, #id, onDelete: KeyAction.cascade)(); } diff --git a/packages/stream_chat_persistence/lib/src/entity/poll_votes.dart b/packages/stream_chat_persistence/lib/src/entity/poll_votes.dart index 5087b5a0df..f6a069d8d0 100644 --- a/packages/stream_chat_persistence/lib/src/entity/poll_votes.dart +++ b/packages/stream_chat_persistence/lib/src/entity/poll_votes.dart @@ -9,8 +9,7 @@ class PollVotes extends Table { TextColumn get id => text().nullable()(); /// The unique identifier of the poll the vote belongs to. - TextColumn get pollId => - text().nullable().references(Polls, #id, onDelete: KeyAction.cascade)(); + TextColumn get pollId => text().nullable().references(Polls, #id, onDelete: KeyAction.cascade)(); /// The unique identifier of the option selected in the poll. /// diff --git a/packages/stream_chat_persistence/lib/src/entity/polls.dart b/packages/stream_chat_persistence/lib/src/entity/polls.dart index f377301dba..ee4d99e85c 100644 --- a/packages/stream_chat_persistence/lib/src/entity/polls.dart +++ b/packages/stream_chat_persistence/lib/src/entity/polls.dart @@ -22,15 +22,13 @@ class Polls extends Table { /// Represents the visibility of the voting process. /// /// Defaults to 'public'. - TextColumn get votingVisibility => text() - .map(const VotingVisibilityConverter()) - .withDefault(const Constant('public'))(); + TextColumn get votingVisibility => + text().map(const VotingVisibilityConverter()).withDefault(const Constant('public'))(); /// If true, only unique votes are allowed. /// /// Defaults to false. - BoolColumn get enforceUniqueVote => - boolean().withDefault(const Constant(false))(); + BoolColumn get enforceUniqueVote => boolean().withDefault(const Constant(false))(); /// The maximum number of votes allowed per user. IntColumn get maxVotesAllowed => integer().nullable()(); @@ -38,8 +36,7 @@ class Polls extends Table { /// If true, users can suggest their own options. /// /// Defaults to false. - BoolColumn get allowUserSuggestedOptions => - boolean().withDefault(const Constant(false))(); + BoolColumn get allowUserSuggestedOptions => boolean().withDefault(const Constant(false))(); /// If true, users can provide their own answers/comments. /// diff --git a/packages/stream_chat_persistence/lib/src/entity/reactions.dart b/packages/stream_chat_persistence/lib/src/entity/reactions.dart index 39bf42589f..b43044e772 100644 --- a/packages/stream_chat_persistence/lib/src/entity/reactions.dart +++ b/packages/stream_chat_persistence/lib/src/entity/reactions.dart @@ -7,18 +7,23 @@ import 'package:stream_chat_persistence/src/entity/messages.dart'; @DataClassName('ReactionEntity') class Reactions extends Table { /// The id of the user that sent the reaction - TextColumn get userId => text()(); + TextColumn get userId => text().nullable()(); /// The messageId to which the reaction belongs - TextColumn get messageId => - text().references(Messages, #id, onDelete: KeyAction.cascade)(); + TextColumn get messageId => text().nullable().references(Messages, #id, onDelete: KeyAction.cascade)(); /// The type of the reaction TextColumn get type => text()(); + /// The emoji code for the reaction + TextColumn get emojiCode => text().nullable()(); + /// The DateTime on which the reaction is created DateTimeColumn get createdAt => dateTime().withDefault(currentDateAndTime)(); + /// The DateTime on which the reaction was last updated + DateTimeColumn get updatedAt => dateTime().withDefault(currentDateAndTime)(); + /// The score of the reaction (ie. number of reactions sent) IntColumn get score => integer().withDefault(const Constant(0))(); @@ -27,8 +32,8 @@ class Reactions extends Table { @override Set get primaryKey => { - messageId, - type, - userId, - }; + messageId, + type, + userId, + }; } diff --git a/packages/stream_chat_persistence/lib/src/entity/reads.dart b/packages/stream_chat_persistence/lib/src/entity/reads.dart index 2a842d3d69..aadac4c69c 100644 --- a/packages/stream_chat_persistence/lib/src/entity/reads.dart +++ b/packages/stream_chat_persistence/lib/src/entity/reads.dart @@ -12,8 +12,7 @@ class Reads extends Table { TextColumn get userId => text()(); /// The channel cid of which this read belongs - TextColumn get channelCid => - text().references(Channels, #cid, onDelete: KeyAction.cascade)(); + TextColumn get channelCid => text().references(Channels, #cid, onDelete: KeyAction.cascade)(); /// Number of unread messages IntColumn get unreadMessages => integer().withDefault(const Constant(0))(); @@ -29,7 +28,7 @@ class Reads extends Table { @override Set get primaryKey => { - userId, - channelCid, - }; + userId, + channelCid, + }; } diff --git a/packages/stream_chat_persistence/lib/src/mapper/channel_mapper.dart b/packages/stream_chat_persistence/lib/src/mapper/channel_mapper.dart index 60c0910fba..35b3bc1d84 100644 --- a/packages/stream_chat_persistence/lib/src/mapper/channel_mapper.dart +++ b/packages/stream_chat_persistence/lib/src/mapper/channel_mapper.dart @@ -32,34 +32,33 @@ extension ChannelEntityX on ChannelEntity { List reads = const [], List messages = const [], List pinnedMessages = const [], - }) => - ChannelState( - members: members, - read: reads, - messages: messages, - pinnedMessages: pinnedMessages, - channel: toChannelModel(createdBy: createdBy), - ); + }) => ChannelState( + members: members, + read: reads, + messages: messages, + pinnedMessages: pinnedMessages, + channel: toChannelModel(createdBy: createdBy), + ); } /// Useful mapping functions for [ChannelModel] extension ChannelModelX on ChannelModel { /// Maps a [ChannelModel] into [ChannelEntity] ChannelEntity toEntity() => ChannelEntity( - id: id, - type: type, - cid: cid, - ownCapabilities: ownCapabilities, - config: config.toJson(), - frozen: frozen, - lastMessageAt: lastMessageAt, - createdAt: createdAt, - updatedAt: updatedAt, - deletedAt: deletedAt, - memberCount: memberCount, - messageCount: messageCount, - createdById: createdBy?.id, - filterTags: filterTags, - extraData: extraData, - ); + id: id, + type: type, + cid: cid, + ownCapabilities: ownCapabilities, + config: config.toJson(), + frozen: frozen, + lastMessageAt: lastMessageAt, + createdAt: createdAt, + updatedAt: updatedAt, + deletedAt: deletedAt, + memberCount: memberCount, + messageCount: messageCount, + createdById: createdBy?.id, + filterTags: filterTags, + extraData: extraData, + ); } diff --git a/packages/stream_chat_persistence/lib/src/mapper/draft_message_mapper.dart b/packages/stream_chat_persistence/lib/src/mapper/draft_message_mapper.dart index f4ae5b31ce..9dd08a0d00 100644 --- a/packages/stream_chat_persistence/lib/src/mapper/draft_message_mapper.dart +++ b/packages/stream_chat_persistence/lib/src/mapper/draft_message_mapper.dart @@ -47,23 +47,23 @@ extension DraftMessageEntityX on DraftMessageEntity { extension DraftMessageX on Draft { /// Maps a [DraftMessage] into [DraftMessageEntity] DraftMessageEntity toEntity() => DraftMessageEntity( - id: message.id, - channelCid: channelCid, - messageText: message.text, - type: message.type, - createdAt: createdAt, - attachments: message.attachments.map((it) { - return jsonEncode(it.toData()); - }).toList(), - parentId: parentId, - showInChannel: message.showInChannel, - mentionedUsers: message.mentionedUsers.map((e) { - return jsonEncode(e.toJson()); - }).toList(), - quotedMessageId: message.quotedMessageId, - silent: message.silent, - command: message.command, - pollId: message.pollId, - extraData: message.extraData, - ); + id: message.id, + channelCid: channelCid, + messageText: message.text, + type: message.type, + createdAt: createdAt, + attachments: message.attachments.map((it) { + return jsonEncode(it.toData()); + }).toList(), + parentId: parentId, + showInChannel: message.showInChannel, + mentionedUsers: message.mentionedUsers.map((e) { + return jsonEncode(e.toJson()); + }).toList(), + quotedMessageId: message.quotedMessageId, + silent: message.silent, + command: message.command, + pollId: message.pollId, + extraData: message.extraData, + ); } diff --git a/packages/stream_chat_persistence/lib/src/mapper/event_mapper.dart b/packages/stream_chat_persistence/lib/src/mapper/event_mapper.dart index 0c3adb7b07..43a420b060 100644 --- a/packages/stream_chat_persistence/lib/src/mapper/event_mapper.dart +++ b/packages/stream_chat_persistence/lib/src/mapper/event_mapper.dart @@ -5,10 +5,10 @@ import 'package:stream_chat_persistence/src/db/drift_chat_database.dart'; extension ConnectionEventX on ConnectionEventEntity { /// Maps a [ConnectionEventEntity] into [Event] Event toEvent() => Event( - type: type, - createdAt: lastEventAt, - me: ownUser != null ? OwnUser.fromJson(ownUser!) : null, - totalUnreadCount: totalUnreadCount, - unreadChannels: unreadChannels, - ); + type: type, + createdAt: lastEventAt, + me: ownUser != null ? OwnUser.fromJson(ownUser!) : null, + totalUnreadCount: totalUnreadCount, + unreadChannels: unreadChannels, + ); } diff --git a/packages/stream_chat_persistence/lib/src/mapper/location_mapper.dart b/packages/stream_chat_persistence/lib/src/mapper/location_mapper.dart new file mode 100644 index 0000000000..35985f9132 --- /dev/null +++ b/packages/stream_chat_persistence/lib/src/mapper/location_mapper.dart @@ -0,0 +1,39 @@ +import 'package:stream_chat/stream_chat.dart'; +import 'package:stream_chat_persistence/src/db/drift_chat_database.dart'; + +/// Useful mapping functions for [LocationEntity] +extension LocationEntityX on LocationEntity { + /// Maps a [LocationEntity] into [Location] + Location toLocation({ + ChannelModel? channel, + Message? message, + }) => Location( + channelCid: channelCid, + channel: channel, + messageId: messageId, + message: message, + userId: userId, + latitude: latitude, + longitude: longitude, + createdByDeviceId: createdByDeviceId, + endAt: endAt, + createdAt: createdAt, + updatedAt: updatedAt, + ); +} + +/// Useful mapping functions for [Location] +extension LocationX on Location { + /// Maps a [Location] into [LocationEntity] + LocationEntity toEntity() => LocationEntity( + channelCid: channelCid, + messageId: messageId, + userId: userId, + latitude: latitude, + longitude: longitude, + createdByDeviceId: createdByDeviceId, + endAt: endAt, + createdAt: createdAt, + updatedAt: updatedAt, + ); +} diff --git a/packages/stream_chat_persistence/lib/src/mapper/mapper.dart b/packages/stream_chat_persistence/lib/src/mapper/mapper.dart index 742776f504..45b35dba81 100644 --- a/packages/stream_chat_persistence/lib/src/mapper/mapper.dart +++ b/packages/stream_chat_persistence/lib/src/mapper/mapper.dart @@ -1,6 +1,7 @@ export 'channel_mapper.dart'; export 'draft_message_mapper.dart'; export 'event_mapper.dart'; +export 'location_mapper.dart'; export 'member_mapper.dart'; export 'message_mapper.dart'; export 'pinned_message_mapper.dart'; diff --git a/packages/stream_chat_persistence/lib/src/mapper/member_mapper.dart b/packages/stream_chat_persistence/lib/src/mapper/member_mapper.dart index 6c15e54e5d..26f7df3015 100644 --- a/packages/stream_chat_persistence/lib/src/mapper/member_mapper.dart +++ b/packages/stream_chat_persistence/lib/src/mapper/member_mapper.dart @@ -5,40 +5,42 @@ import 'package:stream_chat_persistence/src/db/drift_chat_database.dart'; extension MemberEntityX on MemberEntity { /// Maps a [MemberEntity] into [Member] Member toMember({User? user}) => Member( - user: user, - userId: userId, - banned: banned, - shadowBanned: shadowBanned, - updatedAt: updatedAt, - createdAt: createdAt, - channelRole: channelRole, - inviteAcceptedAt: inviteAcceptedAt, - invited: invited, - inviteRejectedAt: inviteRejectedAt, - pinnedAt: pinnedAt, - archivedAt: archivedAt, - isModerator: isModerator, - extraData: extraData ?? {}, - ); + user: user, + userId: userId, + banned: banned, + shadowBanned: shadowBanned, + updatedAt: updatedAt, + createdAt: createdAt, + channelRole: channelRole, + inviteAcceptedAt: inviteAcceptedAt, + invited: invited, + inviteRejectedAt: inviteRejectedAt, + pinnedAt: pinnedAt, + archivedAt: archivedAt, + isModerator: isModerator, + deletedMessages: deletedMessages, + extraData: extraData ?? {}, + ); } /// Useful mapping functions for [Member] extension MemberX on Member { /// Maps a [Member] into [MemberEntity] MemberEntity toEntity({required String cid}) => MemberEntity( - userId: user!.id, - banned: banned, - shadowBanned: shadowBanned, - channelCid: cid, - createdAt: createdAt, - isModerator: isModerator, - inviteRejectedAt: inviteRejectedAt, - invited: invited, - inviteAcceptedAt: inviteAcceptedAt, - pinnedAt: pinnedAt, - archivedAt: archivedAt, - channelRole: channelRole, - updatedAt: updatedAt, - extraData: extraData, - ); + userId: user!.id, + banned: banned, + shadowBanned: shadowBanned, + channelCid: cid, + createdAt: createdAt, + isModerator: isModerator, + inviteRejectedAt: inviteRejectedAt, + invited: invited, + inviteAcceptedAt: inviteAcceptedAt, + pinnedAt: pinnedAt, + archivedAt: archivedAt, + channelRole: channelRole, + updatedAt: updatedAt, + deletedMessages: deletedMessages, + extraData: extraData, + ); } diff --git a/packages/stream_chat_persistence/lib/src/mapper/message_mapper.dart b/packages/stream_chat_persistence/lib/src/mapper/message_mapper.dart index 4e24e0d3ba..584ac6f222 100644 --- a/packages/stream_chat_persistence/lib/src/mapper/message_mapper.dart +++ b/packages/stream_chat_persistence/lib/src/mapper/message_mapper.dart @@ -14,84 +14,86 @@ extension MessageEntityX on MessageEntity { Message? quotedMessage, Poll? poll, Draft? draft, - }) => - Message( - shadowed: shadowed, - latestReactions: latestReactions, - ownReactions: ownReactions, - attachments: attachments.map((it) { - final json = jsonDecode(it); - return Attachment.fromData(json); - }).toList(), - extraData: extraData ?? {}, - createdAt: remoteCreatedAt, - localCreatedAt: localCreatedAt, - updatedAt: remoteUpdatedAt, - localUpdatedAt: localUpdatedAt, - deletedAt: remoteDeletedAt, - localDeletedAt: localDeletedAt, - messageTextUpdatedAt: messageTextUpdatedAt, - id: id, - type: type, - state: MessageState.fromJson(jsonDecode(state)), - command: command, - parentId: parentId, - quotedMessageId: quotedMessageId, - quotedMessage: quotedMessage, - pollId: pollId, - poll: poll, - reactionGroups: reactionGroups, - replyCount: replyCount, - showInChannel: showInChannel, - text: messageText, - user: user, - channelRole: channelRole, - pinned: pinned, - pinnedAt: pinnedAt, - pinExpires: pinExpires, - pinnedBy: pinnedBy, - mentionedUsers: - mentionedUsers.map((e) => User.fromJson(jsonDecode(e))).toList(), - i18n: i18n, - restrictedVisibility: restrictedVisibility, - draft: draft, - ); + Location? sharedLocation, + }) => Message( + shadowed: shadowed, + latestReactions: latestReactions, + ownReactions: ownReactions, + attachments: attachments.map((it) { + final json = jsonDecode(it); + return Attachment.fromData(json); + }).toList(), + extraData: extraData ?? {}, + createdAt: remoteCreatedAt, + localCreatedAt: localCreatedAt, + updatedAt: remoteUpdatedAt, + localUpdatedAt: localUpdatedAt, + deletedAt: remoteDeletedAt, + localDeletedAt: localDeletedAt, + deletedForMe: deletedForMe, + messageTextUpdatedAt: messageTextUpdatedAt, + id: id, + type: type, + state: MessageState.fromJson(jsonDecode(state)), + command: command, + parentId: parentId, + quotedMessageId: quotedMessageId, + quotedMessage: quotedMessage, + pollId: pollId, + poll: poll, + reactionGroups: reactionGroups, + replyCount: replyCount, + showInChannel: showInChannel, + text: messageText, + user: user, + channelRole: channelRole, + pinned: pinned, + pinnedAt: pinnedAt, + pinExpires: pinExpires, + pinnedBy: pinnedBy, + mentionedUsers: mentionedUsers.map((e) => User.fromJson(jsonDecode(e))).toList(), + i18n: i18n, + restrictedVisibility: restrictedVisibility, + draft: draft, + sharedLocation: sharedLocation, + ); } /// Useful mapping functions for [Message] extension MessageX on Message { /// Maps a [Message] into [MessageEntity] MessageEntity toEntity({required String cid}) => MessageEntity( - id: id, - attachments: attachments.map((it) => jsonEncode(it.toData())).toList(), - channelCid: cid, - type: type, - parentId: parentId, - quotedMessageId: quotedMessageId, - pollId: pollId, - command: command, - remoteCreatedAt: remoteCreatedAt, - localCreatedAt: localCreatedAt, - shadowed: shadowed, - showInChannel: showInChannel, - replyCount: replyCount, - reactionGroups: reactionGroups, - mentionedUsers: mentionedUsers.map(jsonEncode).toList(), - state: jsonEncode(state), - remoteUpdatedAt: remoteUpdatedAt, - localUpdatedAt: localUpdatedAt, - extraData: extraData, - userId: user?.id, - channelRole: channelRole, - remoteDeletedAt: remoteDeletedAt, - localDeletedAt: localDeletedAt, - messageTextUpdatedAt: messageTextUpdatedAt, - messageText: text, - pinned: pinned, - pinnedAt: pinnedAt, - pinExpires: pinExpires, - pinnedByUserId: pinnedBy?.id, - i18n: i18n, - restrictedVisibility: restrictedVisibility, - ); + id: id, + attachments: attachments.map((it) => jsonEncode(it.toData())).toList(), + channelCid: cid, + type: type, + parentId: parentId, + quotedMessageId: quotedMessageId, + pollId: pollId, + command: command, + remoteCreatedAt: remoteCreatedAt, + localCreatedAt: localCreatedAt, + shadowed: shadowed, + showInChannel: showInChannel, + replyCount: replyCount, + reactionGroups: reactionGroups, + mentionedUsers: mentionedUsers.map(jsonEncode).toList(), + state: jsonEncode(state.toJson()), + remoteUpdatedAt: remoteUpdatedAt, + localUpdatedAt: localUpdatedAt, + extraData: extraData, + userId: user?.id, + channelRole: channelRole, + remoteDeletedAt: remoteDeletedAt, + localDeletedAt: localDeletedAt, + deletedForMe: deletedForMe, + messageTextUpdatedAt: messageTextUpdatedAt, + messageText: text, + pinned: pinned, + pinnedAt: pinnedAt, + pinExpires: pinExpires, + pinnedByUserId: pinnedBy?.id, + i18n: i18n, + restrictedVisibility: restrictedVisibility, + ); } diff --git a/packages/stream_chat_persistence/lib/src/mapper/pinned_message_mapper.dart b/packages/stream_chat_persistence/lib/src/mapper/pinned_message_mapper.dart index a6c70046f2..90a06b5f55 100644 --- a/packages/stream_chat_persistence/lib/src/mapper/pinned_message_mapper.dart +++ b/packages/stream_chat_persistence/lib/src/mapper/pinned_message_mapper.dart @@ -14,85 +14,86 @@ extension PinnedMessageEntityX on PinnedMessageEntity { Message? quotedMessage, Poll? poll, Draft? draft, - }) => - Message( - shadowed: shadowed, - latestReactions: latestReactions, - ownReactions: ownReactions, - attachments: attachments.map((it) { - final json = jsonDecode(it); - return Attachment.fromData(json); - }).toList(), - extraData: extraData ?? {}, - createdAt: remoteCreatedAt, - localCreatedAt: localCreatedAt, - updatedAt: remoteUpdatedAt, - localUpdatedAt: localUpdatedAt, - deletedAt: remoteDeletedAt, - localDeletedAt: localDeletedAt, - messageTextUpdatedAt: messageTextUpdatedAt, - id: id, - type: type, - state: MessageState.fromJson(jsonDecode(state)), - command: command, - parentId: parentId, - quotedMessageId: quotedMessageId, - quotedMessage: quotedMessage, - pollId: pollId, - poll: poll, - reactionGroups: reactionGroups, - replyCount: replyCount, - showInChannel: showInChannel, - text: messageText, - user: user, - channelRole: channelRole, - pinned: pinned, - pinnedAt: pinnedAt, - pinExpires: pinExpires, - pinnedBy: pinnedBy, - mentionedUsers: - mentionedUsers.map((e) => User.fromJson(jsonDecode(e))).toList(), - i18n: i18n, - restrictedVisibility: restrictedVisibility, - draft: draft, - ); + Location? sharedLocation, + }) => Message( + shadowed: shadowed, + latestReactions: latestReactions, + ownReactions: ownReactions, + attachments: attachments.map((it) { + final json = jsonDecode(it); + return Attachment.fromData(json); + }).toList(), + extraData: extraData ?? {}, + createdAt: remoteCreatedAt, + localCreatedAt: localCreatedAt, + updatedAt: remoteUpdatedAt, + localUpdatedAt: localUpdatedAt, + deletedAt: remoteDeletedAt, + localDeletedAt: localDeletedAt, + deletedForMe: deletedForMe, + messageTextUpdatedAt: messageTextUpdatedAt, + id: id, + type: type, + state: MessageState.fromJson(jsonDecode(state)), + command: command, + parentId: parentId, + quotedMessageId: quotedMessageId, + quotedMessage: quotedMessage, + pollId: pollId, + poll: poll, + reactionGroups: reactionGroups, + replyCount: replyCount, + showInChannel: showInChannel, + text: messageText, + user: user, + channelRole: channelRole, + pinned: pinned, + pinnedAt: pinnedAt, + pinExpires: pinExpires, + pinnedBy: pinnedBy, + mentionedUsers: mentionedUsers.map((e) => User.fromJson(jsonDecode(e))).toList(), + i18n: i18n, + restrictedVisibility: restrictedVisibility, + draft: draft, + sharedLocation: sharedLocation, + ); } /// Useful mapping functions for [Message] extension PMessageX on Message { /// Maps a [Message] into [PinnedMessageEntity] - PinnedMessageEntity toPinnedEntity({required String cid}) => - PinnedMessageEntity( - id: id, - attachments: attachments.map((it) => jsonEncode(it.toData())).toList(), - channelCid: cid, - type: type, - parentId: parentId, - quotedMessageId: quotedMessageId, - pollId: pollId, - command: command, - remoteCreatedAt: remoteCreatedAt, - localCreatedAt: localCreatedAt, - shadowed: shadowed, - showInChannel: showInChannel, - replyCount: replyCount, - reactionGroups: reactionGroups, - mentionedUsers: mentionedUsers.map(jsonEncode).toList(), - state: jsonEncode(state), - remoteUpdatedAt: remoteUpdatedAt, - localUpdatedAt: localUpdatedAt, - extraData: extraData, - userId: user?.id, - channelRole: channelRole, - remoteDeletedAt: remoteDeletedAt, - localDeletedAt: localDeletedAt, - messageTextUpdatedAt: messageTextUpdatedAt, - messageText: text, - pinned: pinned, - pinnedAt: pinnedAt, - pinExpires: pinExpires, - pinnedByUserId: pinnedBy?.id, - i18n: i18n, - restrictedVisibility: restrictedVisibility, - ); + PinnedMessageEntity toPinnedEntity({required String cid}) => PinnedMessageEntity( + id: id, + attachments: attachments.map((it) => jsonEncode(it.toData())).toList(), + channelCid: cid, + type: type, + parentId: parentId, + quotedMessageId: quotedMessageId, + pollId: pollId, + command: command, + remoteCreatedAt: remoteCreatedAt, + localCreatedAt: localCreatedAt, + shadowed: shadowed, + showInChannel: showInChannel, + replyCount: replyCount, + reactionGroups: reactionGroups, + mentionedUsers: mentionedUsers.map(jsonEncode).toList(), + state: jsonEncode(state.toJson()), + remoteUpdatedAt: remoteUpdatedAt, + localUpdatedAt: localUpdatedAt, + extraData: extraData, + userId: user?.id, + channelRole: channelRole, + remoteDeletedAt: remoteDeletedAt, + localDeletedAt: localDeletedAt, + deletedForMe: deletedForMe, + messageTextUpdatedAt: messageTextUpdatedAt, + messageText: text, + pinned: pinned, + pinnedAt: pinnedAt, + pinExpires: pinExpires, + pinnedByUserId: pinnedBy?.id, + i18n: i18n, + restrictedVisibility: restrictedVisibility, + ); } diff --git a/packages/stream_chat_persistence/lib/src/mapper/pinned_message_reaction_mapper.dart b/packages/stream_chat_persistence/lib/src/mapper/pinned_message_reaction_mapper.dart index ecab7cb40e..1802280fd5 100644 --- a/packages/stream_chat_persistence/lib/src/mapper/pinned_message_reaction_mapper.dart +++ b/packages/stream_chat_persistence/lib/src/mapper/pinned_message_reaction_mapper.dart @@ -5,25 +5,29 @@ import 'package:stream_chat_persistence/src/db/drift_chat_database.dart'; extension PinnedMessageReactionEntityX on PinnedMessageReactionEntity { /// Maps a [PinnedMessageReactionEntity] into [Reaction] Reaction toReaction({User? user}) => Reaction( - extraData: extraData ?? {}, - type: type, - createdAt: createdAt, - userId: userId, - user: user, - messageId: messageId, - score: score, - ); + type: type, + userId: userId, + user: user, + messageId: messageId, + score: score, + emojiCode: emojiCode, + createdAt: createdAt, + updatedAt: updatedAt, + extraData: extraData ?? {}, + ); } /// Useful mapping functions for [Reaction] extension PReactionX on Reaction { /// Maps a [Reaction] into [ReactionEntity] PinnedMessageReactionEntity toPinnedEntity() => PinnedMessageReactionEntity( - extraData: extraData, - type: type, - createdAt: createdAt, - userId: userId!, - messageId: messageId!, - score: score, - ); + type: type, + userId: userId ?? user?.id, + messageId: messageId, + score: score, + emojiCode: emojiCode, + createdAt: createdAt, + updatedAt: updatedAt, + extraData: extraData, + ); } diff --git a/packages/stream_chat_persistence/lib/src/mapper/poll_mapper.dart b/packages/stream_chat_persistence/lib/src/mapper/poll_mapper.dart index 451b5e853c..2b9a388ce9 100644 --- a/packages/stream_chat_persistence/lib/src/mapper/poll_mapper.dart +++ b/packages/stream_chat_persistence/lib/src/mapper/poll_mapper.dart @@ -45,22 +45,22 @@ extension PollEntityX on PollEntity { extension PollX on Poll { /// Maps a [Poll] into [PollEntity] PollEntity toEntity() => PollEntity( - id: id, - name: name, - description: description, - options: options.map(jsonEncode).toList(), - votingVisibility: votingVisibility, - enforceUniqueVote: enforceUniqueVote, - maxVotesAllowed: maxVotesAllowed, - allowAnswers: allowAnswers, - answersCount: answersCount, - allowUserSuggestedOptions: allowUserSuggestedOptions, - isClosed: isClosed, - createdAt: createdAt, - updatedAt: updatedAt, - voteCountsByOption: voteCountsByOption, - voteCount: voteCount, - createdById: createdById, - extraData: extraData, - ); + id: id, + name: name, + description: description, + options: options.map(jsonEncode).toList(), + votingVisibility: votingVisibility, + enforceUniqueVote: enforceUniqueVote, + maxVotesAllowed: maxVotesAllowed, + allowAnswers: allowAnswers, + answersCount: answersCount, + allowUserSuggestedOptions: allowUserSuggestedOptions, + isClosed: isClosed, + createdAt: createdAt, + updatedAt: updatedAt, + voteCountsByOption: voteCountsByOption, + voteCount: voteCount, + createdById: createdById, + extraData: extraData, + ); } diff --git a/packages/stream_chat_persistence/lib/src/mapper/poll_vote_mapper.dart b/packages/stream_chat_persistence/lib/src/mapper/poll_vote_mapper.dart index 25fa23ab35..a9cac39709 100644 --- a/packages/stream_chat_persistence/lib/src/mapper/poll_vote_mapper.dart +++ b/packages/stream_chat_persistence/lib/src/mapper/poll_vote_mapper.dart @@ -6,29 +6,28 @@ extension PollVoteEntityX on PollVoteEntity { /// Maps a [PollVoteEntity] into [PollVote] PollVote toPollVote({ User? user, - }) => - PollVote( - id: id, - pollId: pollId, - optionId: optionId, - answerText: answerText, - createdAt: createdAt, - updatedAt: updatedAt, - userId: userId, - user: user, - ); + }) => PollVote( + id: id, + pollId: pollId, + optionId: optionId, + answerText: answerText, + createdAt: createdAt, + updatedAt: updatedAt, + userId: userId, + user: user, + ); } /// Useful mapping functions for [PollVote] extension PollVoteX on PollVote { /// Maps a [PollVote] into [PollVoteEntity] PollVoteEntity toEntity() => PollVoteEntity( - id: id, - pollId: pollId, - optionId: optionId, - answerText: answerText, - createdAt: createdAt, - updatedAt: updatedAt, - userId: userId, - ); + id: id, + pollId: pollId, + optionId: optionId, + answerText: answerText, + createdAt: createdAt, + updatedAt: updatedAt, + userId: userId, + ); } diff --git a/packages/stream_chat_persistence/lib/src/mapper/reaction_mapper.dart b/packages/stream_chat_persistence/lib/src/mapper/reaction_mapper.dart index a524fafe1c..cc62e59db7 100644 --- a/packages/stream_chat_persistence/lib/src/mapper/reaction_mapper.dart +++ b/packages/stream_chat_persistence/lib/src/mapper/reaction_mapper.dart @@ -5,25 +5,29 @@ import 'package:stream_chat_persistence/src/db/drift_chat_database.dart'; extension ReactionEntityX on ReactionEntity { /// Maps a [ReactionEntity] into [Reaction] Reaction toReaction({User? user}) => Reaction( - extraData: extraData ?? {}, - type: type, - createdAt: createdAt, - userId: userId, - user: user, - messageId: messageId, - score: score, - ); + type: type, + userId: userId, + user: user, + messageId: messageId, + score: score, + emojiCode: emojiCode, + createdAt: createdAt, + updatedAt: updatedAt, + extraData: extraData ?? {}, + ); } /// Useful mapping functions for [Reaction] extension ReactionX on Reaction { /// Maps a [Reaction] into [ReactionEntity] ReactionEntity toEntity() => ReactionEntity( - extraData: extraData, - type: type, - createdAt: createdAt, - userId: userId!, - messageId: messageId!, - score: score, - ); + type: type, + userId: userId ?? user?.id, + messageId: messageId, + score: score, + emojiCode: emojiCode, + createdAt: createdAt, + updatedAt: updatedAt, + extraData: extraData, + ); } diff --git a/packages/stream_chat_persistence/lib/src/mapper/read_mapper.dart b/packages/stream_chat_persistence/lib/src/mapper/read_mapper.dart index bdcaefc70e..d26e8bf80a 100644 --- a/packages/stream_chat_persistence/lib/src/mapper/read_mapper.dart +++ b/packages/stream_chat_persistence/lib/src/mapper/read_mapper.dart @@ -5,25 +5,25 @@ import 'package:stream_chat_persistence/src/db/drift_chat_database.dart'; extension ReadEntityX on ReadEntity { /// Maps a [ReadEntity] into [Read] Read toRead({required User user}) => Read( - user: user, - lastRead: lastRead, - unreadMessages: unreadMessages, - lastReadMessageId: lastReadMessageId, - lastDeliveredAt: lastDeliveredAt, - lastDeliveredMessageId: lastDeliveredMessageId, - ); + user: user, + lastRead: lastRead, + unreadMessages: unreadMessages, + lastReadMessageId: lastReadMessageId, + lastDeliveredAt: lastDeliveredAt, + lastDeliveredMessageId: lastDeliveredMessageId, + ); } /// Useful mapping functions for [Read] extension ReadX on Read { /// Maps a [Read] into [ReadEntity] ReadEntity toEntity({required String cid}) => ReadEntity( - lastRead: lastRead, - userId: user.id, - channelCid: cid, - unreadMessages: unreadMessages, - lastReadMessageId: lastReadMessageId, - lastDeliveredAt: lastDeliveredAt, - lastDeliveredMessageId: lastDeliveredMessageId, - ); + lastRead: lastRead, + userId: user.id, + channelCid: cid, + unreadMessages: unreadMessages, + lastReadMessageId: lastReadMessageId, + lastDeliveredAt: lastDeliveredAt, + lastDeliveredMessageId: lastDeliveredMessageId, + ); } diff --git a/packages/stream_chat_persistence/lib/src/mapper/user_mapper.dart b/packages/stream_chat_persistence/lib/src/mapper/user_mapper.dart index 4e894e81c6..678f4cc3a9 100644 --- a/packages/stream_chat_persistence/lib/src/mapper/user_mapper.dart +++ b/packages/stream_chat_persistence/lib/src/mapper/user_mapper.dart @@ -5,34 +5,34 @@ import 'package:stream_chat_persistence/src/db/drift_chat_database.dart'; extension UserEntityX on UserEntity { /// Maps a [UserEntity] into [User] User toUser() => User( - id: id, - updatedAt: updatedAt, - language: language, - role: role, - online: online, - lastActive: lastActive, - extraData: extraData, - banned: banned, - createdAt: createdAt, - teamsRole: teamsRole, - avgResponseTime: avgResponseTime, - ); + id: id, + updatedAt: updatedAt, + language: language, + role: role, + online: online, + lastActive: lastActive, + extraData: extraData, + banned: banned, + createdAt: createdAt, + teamsRole: teamsRole, + avgResponseTime: avgResponseTime, + ); } /// Useful mapping functions for [User] extension UserX on User { /// Maps a [User] into [UserEntity] UserEntity toEntity() => UserEntity( - id: id, - role: role, - language: language, - createdAt: createdAt, - updatedAt: updatedAt, - lastActive: lastActive, - online: online, - banned: banned, - teamsRole: teamsRole, - avgResponseTime: avgResponseTime, - extraData: extraData, - ); + id: id, + role: role, + language: language, + createdAt: createdAt, + updatedAt: updatedAt, + lastActive: lastActive, + online: online, + banned: banned, + teamsRole: teamsRole, + avgResponseTime: avgResponseTime, + extraData: extraData, + ); } diff --git a/packages/stream_chat_persistence/lib/src/stream_chat_persistence_client.dart b/packages/stream_chat_persistence/lib/src/stream_chat_persistence_client.dart index 588510381e..3db937a51a 100644 --- a/packages/stream_chat_persistence/lib/src/stream_chat_persistence_client.dart +++ b/packages/stream_chat_persistence/lib/src/stream_chat_persistence_client.dart @@ -33,9 +33,9 @@ class StreamChatPersistenceClient extends ChatPersistenceClient { /// Otherwise, falls back to the local storage based implementation. bool webUseExperimentalIndexedDb = false, LogHandlerFunction? logHandlerFunction, - }) : _connectionMode = connectionMode, - _webUseIndexedDbIfSupported = webUseExperimentalIndexedDb, - _logger = Logger.detached('💽')..level = logLevel { + }) : _connectionMode = connectionMode, + _webUseIndexedDbIfSupported = webUseExperimentalIndexedDb, + _logger = Logger.detached('💽')..level = logLevel { _logger.onRecord.listen(logHandlerFunction ?? _defaultLogHandler); } @@ -72,12 +72,11 @@ class StreamChatPersistenceClient extends ChatPersistenceClient { Future _defaultDatabaseProvider( String userId, ConnectionMode mode, - ) => - SharedDB.constructDatabase( - userId, - connectionMode: mode, - webUseIndexedDbIfSupported: _webUseIndexedDbIfSupported, - ); + ) => SharedDB.constructDatabase( + userId, + connectionMode: mode, + webUseIndexedDbIfSupported: _webUseIndexedDbIfSupported, + ); @override bool get isConnected => db != null; @@ -97,8 +96,7 @@ class StreamChatPersistenceClient extends ChatPersistenceClient { ); } _logger.info('connect'); - db = databaseProvider?.call(userId, _connectionMode) ?? - await _defaultDatabaseProvider(userId, _connectionMode); + db = databaseProvider?.call(userId, _connectionMode) ?? await _defaultDatabaseProvider(userId, _connectionMode); } @override @@ -211,6 +209,32 @@ class StreamChatPersistenceClient extends ChatPersistenceClient { ); } + @override + Future deleteMessagesFromUser({ + String? cid, + required String userId, + bool hardDelete = false, + DateTime? deletedAt, + }) async { + assert(_debugIsConnected, ''); + _logger.info('deleteMessagesFromUser'); + + // Delete from both messages and pinned_messages tables + await Future.wait( + [ + db!.messageDao.deleteMessagesByUser, + db!.pinnedMessageDao.deleteMessagesByUser, + ].map( + (f) => f.call( + cid: cid, + userId: userId, + hardDelete: hardDelete, + deletedAt: deletedAt, + ), + ), + ); + } + @override Future getDraftMessageByCid( String cid, { @@ -224,6 +248,20 @@ class StreamChatPersistenceClient extends ChatPersistenceClient { ); } + @override + Future> getLocationsByCid(String cid) async { + assert(_debugIsConnected, ''); + _logger.info('getLocationsByCid'); + return db!.locationDao.getLocationsByCid(cid); + } + + @override + Future getLocationByMessageId(String messageId) async { + assert(_debugIsConnected, ''); + _logger.info('getLocationByMessageId'); + return db!.locationDao.getLocationByMessageId(messageId); + } + @override Future> getReadsByCid(String cid) async { assert(_debugIsConnected, ''); @@ -265,6 +303,7 @@ class StreamChatPersistenceClient extends ChatPersistenceClient { Future> getChannelStates({ Filter? filter, SortOrder? channelStateSort, + int? messageLimit, PaginationParams? paginationParams, }) async { assert(_debugIsConnected, ''); @@ -272,8 +311,18 @@ class StreamChatPersistenceClient extends ChatPersistenceClient { final channels = await db!.channelQueryDao.getChannels(filter: filter); + final messagePagination = PaginationParams( + // Default limit is set to 25 in backend. + limit: messageLimit ?? 25, + ); + final channelStates = await Future.wait( - channels.map((e) => getChannelStateByCid(e.cid)), + channels.map( + (e) => getChannelStateByCid( + e.cid, + messagePagination: messagePagination, + ), + ), ); // Sort the channel states @@ -394,6 +443,13 @@ class StreamChatPersistenceClient extends ChatPersistenceClient { return db!.userDao.updateUsers(users); } + @override + Future updateLocations(List locations) async { + assert(_debugIsConnected, ''); + _logger.info('updateLocations'); + return db!.locationDao.updateLocations(locations); + } + @override Future deletePinnedMessageReactionsByMessageId( List messageIds, @@ -444,6 +500,20 @@ class StreamChatPersistenceClient extends ChatPersistenceClient { ); } + @override + Future deleteLocationsByCid(String cid) { + assert(_debugIsConnected, ''); + _logger.info('deleteLocationsByCid'); + return db!.locationDao.deleteLocationsByCid(cid); + } + + @override + Future deleteLocationsByMessageIds(List messageIds) { + assert(_debugIsConnected, ''); + _logger.info('deleteLocationsByMessageIds'); + return db!.locationDao.deleteLocationsByMessageIds(messageIds); + } + @override Future updateChannelThreads( String cid, diff --git a/packages/stream_chat_persistence/pubspec.yaml b/packages/stream_chat_persistence/pubspec.yaml index 45099d28e5..b38fb54dae 100644 --- a/packages/stream_chat_persistence/pubspec.yaml +++ b/packages/stream_chat_persistence/pubspec.yaml @@ -1,7 +1,7 @@ name: stream_chat_persistence homepage: https://github.com/GetStream/stream-chat-flutter description: Official Stream Chat Persistence library. Build your own chat experience using Dart and Flutter. -version: 9.23.0 +version: 10.0.0-beta.13 repository: https://github.com/GetStream/stream-chat-flutter issue_tracker: https://github.com/GetStream/stream-chat-flutter/issues @@ -18,11 +18,11 @@ issue_tracker: https://github.com/GetStream/stream-chat-flutter/issues # 2. Add it to the melos.yaml file for future updates. environment: - sdk: ^3.6.2 - flutter: ">=3.27.4" + sdk: ^3.10.0 + flutter: ">=3.38.1" dependencies: - drift: ^2.22.1 + drift: ^2.28.0 flutter: sdk: flutter logging: ^1.2.0 @@ -30,11 +30,11 @@ dependencies: path: ^1.8.3 path_provider: ^2.1.3 sqlite3_flutter_libs: ^0.5.26 - stream_chat: ^9.23.0 + stream_chat: ^10.0.0-beta.13 dev_dependencies: build_runner: ^2.4.9 - drift_dev: ^2.22.1 + drift_dev: ^2.28.0 flutter_test: sdk: flutter mocktail: ^1.0.0 \ No newline at end of file diff --git a/packages/stream_chat_persistence/test/mock_chat_database.dart b/packages/stream_chat_persistence/test/mock_chat_database.dart index 6f1f61af0d..fe4facfd75 100644 --- a/packages/stream_chat_persistence/test/mock_chat_database.dart +++ b/packages/stream_chat_persistence/test/mock_chat_database.dart @@ -19,8 +19,7 @@ class MockChatDatabase extends Mock implements DriftChatDatabase { MessageDao? _messageDao; @override - PinnedMessageDao get pinnedMessageDao => - _pinnedMessageDao ??= MockPinnedMessageDao(); + PinnedMessageDao get pinnedMessageDao => _pinnedMessageDao ??= MockPinnedMessageDao(); PinnedMessageDao? _pinnedMessageDao; @override @@ -32,8 +31,7 @@ class MockChatDatabase extends Mock implements DriftChatDatabase { ReactionDao? _reactionDao; @override - PinnedMessageReactionDao get pinnedMessageReactionDao => - _pinnedMessageReactionDao ??= MockPinnedMessageReactionDao(); + PinnedMessageReactionDao get pinnedMessageReactionDao => _pinnedMessageReactionDao ??= MockPinnedMessageReactionDao(); PinnedMessageReactionDao? _pinnedMessageReactionDao; @override @@ -41,13 +39,11 @@ class MockChatDatabase extends Mock implements DriftChatDatabase { ReadDao? _readDao; @override - ChannelQueryDao get channelQueryDao => - _channelQueryDao ??= MockChannelQueryDao(); + ChannelQueryDao get channelQueryDao => _channelQueryDao ??= MockChannelQueryDao(); ChannelQueryDao? _channelQueryDao; @override - ConnectionEventDao get connectionEventDao => - _connectionEventDao ??= MockConnectionEventDao(); + ConnectionEventDao get connectionEventDao => _connectionEventDao ??= MockConnectionEventDao(); ConnectionEventDao? _connectionEventDao; @override @@ -59,10 +55,13 @@ class MockChatDatabase extends Mock implements DriftChatDatabase { PollVoteDao? _pollVoteDao; @override - DraftMessageDao get draftMessageDao => - _draftMessageDao ??= MockDraftMessageDao(); + DraftMessageDao get draftMessageDao => _draftMessageDao ??= MockDraftMessageDao(); DraftMessageDao? _draftMessageDao; + @override + LocationDao get locationDao => _locationDao ??= MockLocationDao(); + LocationDao? _locationDao; + @override Future flush() => Future.value(); @@ -82,8 +81,7 @@ class MockMemberDao extends Mock implements MemberDao {} class MockReactionDao extends Mock implements ReactionDao {} -class MockPinnedMessageReactionDao extends Mock - implements PinnedMessageReactionDao {} +class MockPinnedMessageReactionDao extends Mock implements PinnedMessageReactionDao {} class MockReadDao extends Mock implements ReadDao {} @@ -96,3 +94,5 @@ class MockPollDao extends Mock implements PollDao {} class MockPollVoteDao extends Mock implements PollVoteDao {} class MockDraftMessageDao extends Mock implements DraftMessageDao {} + +class MockLocationDao extends Mock implements LocationDao {} diff --git a/packages/stream_chat_persistence/test/src/dao/channel_dao_test.dart b/packages/stream_chat_persistence/test/src/dao/channel_dao_test.dart index da974fa31c..44dde6b1f6 100644 --- a/packages/stream_chat_persistence/test/src/dao/channel_dao_test.dart +++ b/packages/stream_chat_persistence/test/src/dao/channel_dao_test.dart @@ -99,13 +99,11 @@ void main() { expect(updatedReads.first.user, dummyUser); // Saving a dummy reaction - final dummyReaction = - Reaction(type: 'type', messageId: messageId, userId: userId); + final dummyReaction = Reaction(type: 'type', messageId: messageId, userId: userId); await database.reactionDao.updateReactions([dummyReaction]); // Should match the dummy reaction - final updatedReactions = - await database.reactionDao.getReactionsByUserId(messageId, userId); + final updatedReactions = await database.reactionDao.getReactionsByUserId(messageId, userId); expect(updatedReactions.length, 1); expect(updatedReactions.first.messageId, messageId); @@ -129,8 +127,7 @@ void main() { expect(reads, isEmpty); // Fetched readtions for passed message id and user id should be empty - final reactions = - await database.reactionDao.getReactionsByUserId(messageId, userId); + final reactions = await database.reactionDao.getReactionsByUserId(messageId, userId); expect(reactions, isEmpty); }); diff --git a/packages/stream_chat_persistence/test/src/dao/draft_message_dao_test.dart b/packages/stream_chat_persistence/test/src/dao/draft_message_dao_test.dart index 15c8e48b2f..0a020da477 100644 --- a/packages/stream_chat_persistence/test/src/dao/draft_message_dao_test.dart +++ b/packages/stream_chat_persistence/test/src/dao/draft_message_dao_test.dart @@ -62,8 +62,7 @@ void main() { (index) { // When count is 1, use the exact cid provided // Otherwise, create unique cids for each draft to avoid conflicts - final draftChannelCid = - count == 1 ? cid : (withParentMessage ? cid : '$cid$index'); + final draftChannelCid = count == 1 ? cid : (withParentMessage ? cid : '$cid$index'); final draftMessage = DraftMessage( id: 'testDraftId$cid$index', @@ -236,8 +235,7 @@ void main() { await draftMessageDao.updateDraftMessages([firstDraft]); // Verify first draft exists - final firstFetchedDraft = - await draftMessageDao.getDraftMessageByCid(cid); + final firstFetchedDraft = await draftMessageDao.getDraftMessageByCid(cid); expect(firstFetchedDraft, isNotNull); expect(firstFetchedDraft!.message.text, 'First channel draft'); @@ -254,16 +252,13 @@ void main() { await draftMessageDao.updateDraftMessages([secondDraft]); // Verify only the second draft exists - final secondFetchedDraft = - await draftMessageDao.getDraftMessageByCid(cid); + final secondFetchedDraft = await draftMessageDao.getDraftMessageByCid(cid); expect(secondFetchedDraft, isNotNull); expect(secondFetchedDraft!.message.text, 'Second channel draft'); // Verify the first draft no longer exists - final firstDraftAfterUpdate = - await draftMessageDao.getDraftMessageByCid(firstDraft.channelCid); - expect( - firstDraftAfterUpdate!.message.text, isNot('First channel draft')); + final firstDraftAfterUpdate = await draftMessageDao.getDraftMessageByCid(firstDraft.channelCid); + expect(firstDraftAfterUpdate!.message.text, isNot('First channel draft')); // Verify there's only one draft message for this channel final channelDraft = await draftMessageDao.getDraftMessageByCid(cid); @@ -303,8 +298,7 @@ void main() { await draftMessageDao.updateDraftMessages([firstDraft]); // Verify first thread draft exists - final firstFetchedDraft = await draftMessageDao - .getDraftMessageByCid(cid, parentId: firstDraft.parentId); + final firstFetchedDraft = await draftMessageDao.getDraftMessageByCid(cid, parentId: firstDraft.parentId); expect(firstFetchedDraft, isNotNull); expect(firstFetchedDraft!.message.text, 'First thread draft'); @@ -323,20 +317,16 @@ void main() { await draftMessageDao.updateDraftMessages([secondDraft]); // Verify only the second draft exists - final secondFetchedDraft = await draftMessageDao - .getDraftMessageByCid(cid, parentId: secondDraft.parentId); + final secondFetchedDraft = await draftMessageDao.getDraftMessageByCid(cid, parentId: secondDraft.parentId); expect(secondFetchedDraft, isNotNull); expect(secondFetchedDraft!.message.text, 'Second thread draft'); // Verify the first draft no longer exists - final firstDraftAfterUpdate = await draftMessageDao - .getDraftMessageByCid(cid, parentId: firstDraft.parentId); - expect( - firstDraftAfterUpdate!.message.text, isNot('First thread draft')); + final firstDraftAfterUpdate = await draftMessageDao.getDraftMessageByCid(cid, parentId: firstDraft.parentId); + expect(firstDraftAfterUpdate!.message.text, isNot('First thread draft')); // Verify there's only one draft message for this thread - final threadDraft = await draftMessageDao.getDraftMessageByCid(cid, - parentId: parentMessage.id); + final threadDraft = await draftMessageDao.getDraftMessageByCid(cid, parentId: parentMessage.id); expect(threadDraft, isNotNull); expect(threadDraft!.message.text, 'Second thread draft'); }, @@ -387,16 +377,14 @@ void main() { await _prepareTestData(cid, count: 1); // Verify draft exists - final draftBeforeChannelDelete = - await draftMessageDao.getDraftMessageByCid(cid); + final draftBeforeChannelDelete = await draftMessageDao.getDraftMessageByCid(cid); expect(draftBeforeChannelDelete, isNotNull); // Delete the channel await database.channelDao.deleteChannelByCids([cid]); // Verify draft has been deleted (cascade) - final draftAfterChannelDelete = - await draftMessageDao.getDraftMessageByCid(cid); + final draftAfterChannelDelete = await draftMessageDao.getDraftMessageByCid(cid); expect(draftAfterChannelDelete, isNull); }, ); @@ -454,14 +442,12 @@ void main() { ); // Verify drafts exist before channel deletion - final channelDraftBeforeDelete = - await draftMessageDao.getDraftMessageByCid(cid); + final channelDraftBeforeDelete = await draftMessageDao.getDraftMessageByCid(cid); expect(channelDraftBeforeDelete, isNotNull); expect(channelDraftBeforeDelete!.parentId, isNull); for (var i = 0; i < threadDrafts.length; i++) { - final threadDraft = await draftMessageDao.getDraftMessageByCid(cid, - parentId: threadDrafts[i].parentId); + final threadDraft = await draftMessageDao.getDraftMessageByCid(cid, parentId: threadDrafts[i].parentId); expect(threadDraft, isNotNull); expect(threadDraft!.parentId, messages[i].id); } @@ -470,13 +456,11 @@ void main() { await database.channelDao.deleteChannelByCids([cid]); // Verify all drafts have been deleted (cascade) - final channelDraftAfterDelete = - await draftMessageDao.getDraftMessageByCid(cid); + final channelDraftAfterDelete = await draftMessageDao.getDraftMessageByCid(cid); expect(channelDraftAfterDelete, isNull); for (final threadDraft in threadDrafts) { - final draft = await draftMessageDao.getDraftMessageByCid(cid, - parentId: threadDraft.parentId); + final draft = await draftMessageDao.getDraftMessageByCid(cid, parentId: threadDraft.parentId); expect(draft, isNull); } }, @@ -486,21 +470,18 @@ void main() { 'should delete draft messages when referenced parent message is deleted', () async { const cid = 'test:parentRefCascade'; - final testDrafts = - await _prepareTestData(cid, withParentMessage: true, count: 1); + final testDrafts = await _prepareTestData(cid, withParentMessage: true, count: 1); final parentId = testDrafts.first.parentId!; // Verify draft with parent exists - final draftBeforeMessageDelete = - await draftMessageDao.getDraftMessageByCid(cid, parentId: parentId); + final draftBeforeMessageDelete = await draftMessageDao.getDraftMessageByCid(cid, parentId: parentId); expect(draftBeforeMessageDelete, isNotNull); // Delete the parent message await database.messageDao.deleteMessageByIds([parentId]); // Verify draft has been deleted (cascade) - final draftAfterMessageDelete = - await draftMessageDao.getDraftMessageByCid(cid, parentId: parentId); + final draftAfterMessageDelete = await draftMessageDao.getDraftMessageByCid(cid, parentId: parentId); expect(draftAfterMessageDelete, isNull); }, ); diff --git a/packages/stream_chat_persistence/test/src/dao/location_dao_test.dart b/packages/stream_chat_persistence/test/src/dao/location_dao_test.dart new file mode 100644 index 0000000000..a521d2836a --- /dev/null +++ b/packages/stream_chat_persistence/test/src/dao/location_dao_test.dart @@ -0,0 +1,301 @@ +// ignore_for_file: avoid_redundant_argument_values + +import 'package:flutter_test/flutter_test.dart'; +import 'package:stream_chat/stream_chat.dart'; +import 'package:stream_chat_persistence/src/dao/dao.dart'; +import 'package:stream_chat_persistence/src/db/drift_chat_database.dart'; + +import '../../stream_chat_persistence_client_test.dart'; + +void main() { + late LocationDao locationDao; + late DriftChatDatabase database; + + setUp(() { + database = testDatabaseProvider('testUserId'); + locationDao = database.locationDao; + }); + + Future> _prepareLocationData({ + required String cid, + int count = 3, + }) async { + final channels = [ChannelModel(cid: cid)]; + final users = List.generate(count, (index) => User(id: 'testUserId$index')); + final messages = List.generate( + count, + (index) => Message( + id: 'testMessageId$cid$index', + type: 'testType', + user: users[index], + createdAt: DateTime.now(), + text: 'Test message #$index', + ), + ); + + final locations = List.generate( + count, + (index) => Location( + channelCid: cid, + messageId: messages[index].id, + userId: users[index].id, + latitude: 37.7749 + index * 0.001, // San Francisco area + longitude: -122.4194 + index * 0.001, + createdByDeviceId: 'testDevice$index', + endAt: index.isEven ? DateTime.now().add(const Duration(hours: 1)) : null, // Some live, some static + createdAt: DateTime.now(), + updatedAt: DateTime.now(), + ), + ); + + await database.userDao.updateUsers(users); + await database.channelDao.updateChannels(channels); + await database.messageDao.updateMessages(cid, messages); + await locationDao.updateLocations(locations); + + return locations; + } + + test('getLocationsByCid', () async { + const cid = 'test:Cid'; + + // Should be empty initially + final locations = await locationDao.getLocationsByCid(cid); + expect(locations, isEmpty); + + // Adding sample locations + final insertedLocations = await _prepareLocationData(cid: cid); + expect(insertedLocations, isNotEmpty); + + // Fetched locations length should match inserted locations length + final fetchedLocations = await locationDao.getLocationsByCid(cid); + expect(fetchedLocations.length, insertedLocations.length); + + // Every location channelCid should match the provided cid + expect(fetchedLocations.every((it) => it.channelCid == cid), true); + }); + + test('updateLocations', () async { + const cid = 'test:Cid'; + + // Preparing test data + final insertedLocations = await _prepareLocationData(cid: cid); + + // Adding a new location + final newUser = User(id: 'newUserId'); + final newMessage = Message( + id: 'newMessageId', + type: 'testType', + user: newUser, + createdAt: DateTime.now(), + text: 'New test message', + ); + final newLocation = Location( + channelCid: cid, + messageId: newMessage.id, + userId: newUser.id, + latitude: 40.7128, // New York + longitude: -74.0060, + createdByDeviceId: 'newDevice', + createdAt: DateTime.now(), + updatedAt: DateTime.now(), + ); + + await database.userDao.updateUsers([newUser]); + await database.messageDao.updateMessages(cid, [newMessage]); + await locationDao.updateLocations([newLocation]); + + // Fetched locations length should be one more than inserted locations + // Fetched locations should contain the newLocation + final fetchedLocations = await locationDao.getLocationsByCid(cid); + expect(fetchedLocations.length, insertedLocations.length + 1); + expect( + fetchedLocations.any( + (it) => + it.messageId == newLocation.messageId && + it.latitude == newLocation.latitude && + it.longitude == newLocation.longitude, + ), + isTrue, + ); + }); + + test('getLocationByMessageId', () async { + const cid = 'test:Cid'; + + // Preparing test data + final insertedLocations = await _prepareLocationData(cid: cid); + + // Fetched location should not be null + final locationToFetch = insertedLocations.first; + final fetchedLocation = await locationDao.getLocationByMessageId(locationToFetch.messageId!); + expect(fetchedLocation, isNotNull); + expect(fetchedLocation!.messageId, locationToFetch.messageId); + expect(fetchedLocation.latitude, locationToFetch.latitude); + expect(fetchedLocation.longitude, locationToFetch.longitude); + }); + + test( + 'getLocationByMessageId should return null for non-existent messageId', + () async { + // Should return null for non-existent messageId + final fetchedLocation = await locationDao.getLocationByMessageId('nonExistentMessageId'); + expect(fetchedLocation, isNull); + }, + ); + + test('deleteLocationsByCid', () async { + const cid = 'test:Cid'; + + // Preparing test data + final insertedLocations = await _prepareLocationData(cid: cid); + + // Verify locations exist + final locationsBeforeDelete = await locationDao.getLocationsByCid(cid); + expect(locationsBeforeDelete.length, insertedLocations.length); + + // Deleting all locations for the channel + await locationDao.deleteLocationsByCid(cid); + + // Fetched location list should be empty + final fetchedLocations = await locationDao.getLocationsByCid(cid); + expect(fetchedLocations, isEmpty); + }); + + test('deleteLocationsByMessageIds', () async { + const cid = 'test:Cid'; + + // Preparing test data + final insertedLocations = await _prepareLocationData(cid: cid); + + // Deleting the first two locations by their message IDs + final messageIdsToDelete = insertedLocations.take(2).map((it) => it.messageId!).toList(); + await locationDao.deleteLocationsByMessageIds(messageIdsToDelete); + + // Fetched location list should be one less than inserted locations + final fetchedLocations = await locationDao.getLocationsByCid(cid); + expect(fetchedLocations.length, insertedLocations.length - messageIdsToDelete.length); + + // Deleted locations should not exist in fetched locations + expect( + fetchedLocations.any((it) => messageIdsToDelete.contains(it.messageId)), + isFalse, + ); + }); + + group('deleteLocationsByMessageIds', () { + test('should delete locations for specific message IDs only', () async { + const cid1 = 'test:Cid1'; + const cid2 = 'test:Cid2'; + + // Preparing test data for two channels + final insertedLocations1 = await _prepareLocationData(cid: cid1, count: 2); + final insertedLocations2 = await _prepareLocationData(cid: cid2, count: 2); + + // Verify all locations exist + final locations1 = await locationDao.getLocationsByCid(cid1); + final locations2 = await locationDao.getLocationsByCid(cid2); + expect(locations1.length, insertedLocations1.length); + expect(locations2.length, insertedLocations2.length); + + // Delete only locations from the first channel + final messageIdsToDelete = insertedLocations1.map((it) => it.messageId!).toList(); + await locationDao.deleteLocationsByMessageIds(messageIdsToDelete); + + // Only locations from cid1 should be deleted + final fetchedLocations1 = await locationDao.getLocationsByCid(cid1); + final fetchedLocations2 = await locationDao.getLocationsByCid(cid2); + expect(fetchedLocations1, isEmpty); + expect(fetchedLocations2.length, insertedLocations2.length); + }); + }); + + group('Location entity references', () { + test( + 'should delete locations when referenced channel is deleted', + () async { + const cid = 'test:channelRefCascade'; + + // Prepare test data + await _prepareLocationData(cid: cid, count: 2); + + // Verify locations exist before channel deletion + final locationsBeforeDelete = await locationDao.getLocationsByCid(cid); + expect(locationsBeforeDelete, isNotEmpty); + expect(locationsBeforeDelete.length, 2); + + // Delete the channel + await database.channelDao.deleteChannelByCids([cid]); + + // Verify locations have been deleted (cascade) + final locationsAfterDelete = await locationDao.getLocationsByCid(cid); + expect(locationsAfterDelete, isEmpty); + }, + ); + + test( + 'should delete locations when referenced message is deleted', + () async { + const cid = 'test:messageRefCascade'; + + // Prepare test data + final insertedLocations = await _prepareLocationData(cid: cid, count: 3); + final messageToDelete = insertedLocations.first.messageId!; + + // Verify location exists before message deletion + final locationBeforeDelete = await locationDao.getLocationByMessageId(messageToDelete); + expect(locationBeforeDelete, isNotNull); + expect(locationBeforeDelete!.messageId, messageToDelete); + + // Verify all locations exist + final allLocationsBeforeDelete = await locationDao.getLocationsByCid(cid); + expect(allLocationsBeforeDelete.length, 3); + + // Delete the message + await database.messageDao.deleteMessageByIds([messageToDelete]); + + // Verify the specific location has been deleted (cascade) + final locationAfterDelete = await locationDao.getLocationByMessageId(messageToDelete); + expect(locationAfterDelete, isNull); + + // Verify other locations still exist + final allLocationsAfterDelete = await locationDao.getLocationsByCid(cid); + expect(allLocationsAfterDelete.length, 2); + expect( + allLocationsAfterDelete.any((it) => it.messageId == messageToDelete), + isFalse, + ); + }, + ); + + test( + 'should delete all locations when multiple messages are deleted', + () async { + const cid = 'test:multipleMessageRefCascade'; + + // Prepare test data + final insertedLocations = await _prepareLocationData(cid: cid, count: 3); + final messageIdsToDelete = insertedLocations.take(2).map((it) => it.messageId!).toList(); + + // Verify locations exist before message deletion + final allLocationsBeforeDelete = await locationDao.getLocationsByCid(cid); + expect(allLocationsBeforeDelete.length, 3); + + // Delete multiple messages + await database.messageDao.deleteMessageByIds(messageIdsToDelete); + + // Verify corresponding locations have been deleted (cascade) + final allLocationsAfterDelete = await locationDao.getLocationsByCid(cid); + expect(allLocationsAfterDelete.length, 1); + expect( + allLocationsAfterDelete.any((it) => messageIdsToDelete.contains(it.messageId)), + isFalse, + ); + }, + ); + }); + + tearDown(() async { + await database.disconnect(); + }); +} diff --git a/packages/stream_chat_persistence/test/src/dao/member_dao_test.dart b/packages/stream_chat_persistence/test/src/dao/member_dao_test.dart index 936097fe8f..88f034a793 100644 --- a/packages/stream_chat_persistence/test/src/dao/member_dao_test.dart +++ b/packages/stream_chat_persistence/test/src/dao/member_dao_test.dart @@ -129,15 +129,11 @@ void main() { final newFetchedMembers = await memberDao.getMembersByCid(cid); expect(newFetchedMembers.length, fetchedMembers.length + 1); expect( - newFetchedMembers - .firstWhere((it) => it.user!.id == copyMember.user!.id) - .banned, + newFetchedMembers.firstWhere((it) => it.user!.id == copyMember.user!.id).banned, true, ); expect( - newFetchedMembers - .where((it) => it.user!.id == newMember.user!.id) - .isNotEmpty, + newFetchedMembers.where((it) => it.user!.id == newMember.user!.id).isNotEmpty, true, ); }); diff --git a/packages/stream_chat_persistence/test/src/dao/message_dao_test.dart b/packages/stream_chat_persistence/test/src/dao/message_dao_test.dart index 29cda8c938..80472a1a78 100644 --- a/packages/stream_chat_persistence/test/src/dao/message_dao_test.dart +++ b/packages/stream_chat_persistence/test/src/dao/message_dao_test.dart @@ -1,3 +1,5 @@ +// ignore_for_file: avoid_redundant_argument_values + import 'dart:math' as math; import 'package:flutter_test/flutter_test.dart'; @@ -83,8 +85,7 @@ void main() { type: 'testType', user: users[index], channelRole: 'channel_member', - parentId: - mapAllThreadToFirstMessage ? messages[0].id : messages[index].id, + parentId: mapAllThreadToFirstMessage ? messages[0].id : messages[index].id, createdAt: DateTime.now(), shadowed: math.Random().nextBool(), replyCount: index, @@ -101,11 +102,7 @@ void main() { }, ), ); - final allMessages = [ - ...messages, - if (quoted) ...quotedMessages, - if (threads) ...threadMessages - ]; + final allMessages = [...messages, if (quoted) ...quotedMessages, if (threads) ...threadMessages]; final reaction = Reaction( type: 'type', messageId: allMessages.first.id, @@ -145,8 +142,7 @@ void main() { expect(newMessages.length, messages.length - 2); // Reaction for the first message should be deleted too - final newReactions = - await database.reactionDao.getReactions(firstMessageId); + final newReactions = await database.reactionDao.getReactions(firstMessageId); expect(newReactions, isEmpty); }); @@ -169,8 +165,7 @@ void main() { // Fetched reactions list should have one reaction for given message id final cid1firstMessageId = cid1Messages.first.id; - final cid1Reactions = - await database.reactionDao.getReactions(cid1firstMessageId); + final cid1Reactions = await database.reactionDao.getReactions(cid1firstMessageId); expect(cid1Reactions.length, 1); // Deleting all the messages of cid1 @@ -183,8 +178,7 @@ void main() { expect(cid2FetchedMessages, isNotEmpty); // Reaction for the first message should be deleted too - final cid1FetchedReactions = - await database.reactionDao.getReactions(cid1firstMessageId); + final cid1FetchedReactions = await database.reactionDao.getReactions(cid1firstMessageId); expect(cid1FetchedReactions, isEmpty); }, ); @@ -204,12 +198,10 @@ void main() { // Fetched reactions list should have one reaction for given message id final cid1FirstMessageId = cid1Messages.first.id; - final cid1Reactions = - await database.reactionDao.getReactions(cid1FirstMessageId); + final cid1Reactions = await database.reactionDao.getReactions(cid1FirstMessageId); expect(cid1Reactions.length, 1); final cid2FirstMessageId = cid2Messages.first.id; - final cid2Reactions = - await database.reactionDao.getReactions(cid2FirstMessageId); + final cid2Reactions = await database.reactionDao.getReactions(cid2FirstMessageId); expect(cid2Reactions.length, 1); // Deleting all the messages of cid1 @@ -222,11 +214,9 @@ void main() { expect(cid2FetchedMessages, isEmpty); // Reaction for the first message should be deleted too - final cid1FetchedReactions = - await database.reactionDao.getReactions(cid1FirstMessageId); + final cid1FetchedReactions = await database.reactionDao.getReactions(cid1FirstMessageId); expect(cid1FetchedReactions, isEmpty); - final cid2FetchedReactions = - await database.reactionDao.getReactions(cid2FirstMessageId); + final cid2FetchedReactions = await database.reactionDao.getReactions(cid2FirstMessageId); expect(cid2FetchedReactions, isEmpty); }, ); @@ -282,8 +272,7 @@ void main() { expect(insertedMessages, isNotEmpty); // Should fetch all the thread messages of parentId - final threadMessages = - await messageDao.getThreadMessagesByParentId(parentId); + final threadMessages = await messageDao.getThreadMessagesByParentId(parentId); expect(threadMessages.length, 1); expect(threadMessages.first.parentId, parentId); }); @@ -435,6 +424,185 @@ void main() { ); }); + group('deleteMessagesByUser', () { + const cid1 = 'test:Cid1'; + const cid2 = 'test:Cid2'; + const userId = 'testUserId0'; + + test('hard deletes user messages in specific channel', () async { + // Preparing test data for two channels + await _prepareTestData(cid1); + await _prepareTestData(cid2); + + // Verify messages exist in both channels + final cid1Messages = await messageDao.getMessagesByCid(cid1); + final cid2Messages = await messageDao.getMessagesByCid(cid2); + expect(cid1Messages, isNotEmpty); + expect(cid2Messages, isNotEmpty); + + // Count messages from the specific user in cid1 + final cid1UserMessages = cid1Messages.where((m) => m.user?.id == userId).length; + expect(cid1UserMessages, greaterThan(0)); + + // Hard delete messages from user in cid1 only + await messageDao.deleteMessagesByUser( + cid: cid1, + userId: userId, + hardDelete: true, + ); + + // Verify user's messages are deleted from cid1 + final cid1MessagesAfter = await messageDao.getMessagesByCid(cid1); + final cid1UserMessagesAfter = cid1MessagesAfter.where((m) => m.user?.id == userId).length; + expect(cid1UserMessagesAfter, 0); + + // Verify other users' messages in cid1 are not affected + expect(cid1MessagesAfter.length, cid1Messages.length - cid1UserMessages); + + // Verify messages in cid2 are not affected + final cid2MessagesAfter = await messageDao.getMessagesByCid(cid2); + expect(cid2MessagesAfter.length, cid2Messages.length); + }); + + test('soft deletes user messages in specific channel', () async { + // Preparing test data + await _prepareTestData(cid1); + + final cid1Messages = await messageDao.getMessagesByCid(cid1); + final cid1UserMessages = cid1Messages.where((m) => m.user?.id == userId).toList(); + expect(cid1UserMessages, isNotEmpty); + + // Verify messages are not deleted initially + for (final message in cid1UserMessages) { + expect(message.type, isNot('deleted')); + expect(message.deletedAt, isNull); + } + + // Soft delete messages from user + final deletedAt = DateTime.now(); + await messageDao.deleteMessagesByUser( + cid: cid1, + userId: userId, + hardDelete: false, + deletedAt: deletedAt, + ); + + // Verify messages are marked as deleted + final cid1MessagesAfter = await messageDao.getMessagesByCid(cid1); + final cid1UserMessagesAfter = cid1MessagesAfter.where((m) => m.user?.id == userId).toList(); + + // Messages should still exist in DB + expect(cid1UserMessagesAfter.length, cid1UserMessages.length); + + // But they should be marked as deleted + for (final message in cid1UserMessagesAfter) { + expect(message.type, 'deleted'); + expect(message.deletedAt, isNotNull); + } + + // Other users' messages should not be affected + final otherUserMessages = cid1MessagesAfter.where((m) => m.user?.id != userId).toList(); + for (final message in otherUserMessages) { + expect(message.type, isNot('deleted')); + } + }); + + test('hard deletes user messages across all channels when cid is null', () async { + // Preparing test data for multiple channels + await _prepareTestData(cid1); + await _prepareTestData(cid2); + + final cid1Messages = await messageDao.getMessagesByCid(cid1); + final cid2Messages = await messageDao.getMessagesByCid(cid2); + + final cid1UserMessages = cid1Messages.where((m) => m.user?.id == userId).length; + final cid2UserMessages = cid2Messages.where((m) => m.user?.id == userId).length; + + expect(cid1UserMessages, greaterThan(0)); + expect(cid2UserMessages, greaterThan(0)); + + // Hard delete all messages from user across all channels + await messageDao.deleteMessagesByUser( + userId: userId, + hardDelete: true, + ); + + // Verify user's messages are deleted from both channels + final cid1MessagesAfter = await messageDao.getMessagesByCid(cid1); + final cid2MessagesAfter = await messageDao.getMessagesByCid(cid2); + + expect(cid1MessagesAfter.where((m) => m.user?.id == userId).length, 0); + expect(cid2MessagesAfter.where((m) => m.user?.id == userId).length, 0); + + // Verify other messages are preserved + expect( + cid1MessagesAfter.length, + cid1Messages.length - cid1UserMessages, + ); + expect( + cid2MessagesAfter.length, + cid2Messages.length - cid2UserMessages, + ); + }); + + test('soft deletes user messages across all channels when cid is null', () async { + // Preparing test data for multiple channels + await _prepareTestData(cid1); + await _prepareTestData(cid2); + + final cid1Messages = await messageDao.getMessagesByCid(cid1); + final cid2Messages = await messageDao.getMessagesByCid(cid2); + + final cid1UserMessages = cid1Messages.where((m) => m.user?.id == userId).length; + final cid2UserMessages = cid2Messages.where((m) => m.user?.id == userId).length; + + // Soft delete all messages from user across all channels + await messageDao.deleteMessagesByUser( + userId: userId, + hardDelete: false, + ); + + // Verify user's messages are marked as deleted in both channels + final cid1MessagesAfter = await messageDao.getMessagesByCid(cid1); + final cid2MessagesAfter = await messageDao.getMessagesByCid(cid2); + + final cid1UserMessagesAfter = cid1MessagesAfter.where((m) => m.user?.id == userId).toList(); + final cid2UserMessagesAfter = cid2MessagesAfter.where((m) => m.user?.id == userId).toList(); + + // Messages should still exist + expect(cid1UserMessagesAfter.length, cid1UserMessages); + expect(cid2UserMessagesAfter.length, cid2UserMessages); + + // All user messages should be marked as deleted + for (final message in [...cid1UserMessagesAfter, ...cid2UserMessagesAfter]) { + expect(message.type, 'deleted'); + expect(message.deletedAt, isNotNull); + } + }); + + test('handles thread messages correctly', () async { + // Preparing test data with threads + await _prepareTestData(cid1, threads: true); + + final cid1ThreadMessages = await messageDao.getThreadMessages(cid1); + + final userThreadMessages = cid1ThreadMessages.where((m) => m.user?.id == userId).length; + expect(userThreadMessages, greaterThan(0)); + + // Hard delete all messages from user + await messageDao.deleteMessagesByUser( + cid: cid1, + userId: userId, + hardDelete: true, + ); + + // Verify thread messages from user are also deleted + final cid1ThreadMessagesAfter = await messageDao.getThreadMessages(cid1); + final userThreadMessagesAfter = cid1ThreadMessagesAfter.where((m) => m.user?.id == userId).length; + expect(userThreadMessagesAfter, 0); + }); + }); + tearDown(() async { await database.disconnect(); }); diff --git a/packages/stream_chat_persistence/test/src/dao/pinned_message_dao_test.dart b/packages/stream_chat_persistence/test/src/dao/pinned_message_dao_test.dart index 0bd5166e52..ca7567b1de 100644 --- a/packages/stream_chat_persistence/test/src/dao/pinned_message_dao_test.dart +++ b/packages/stream_chat_persistence/test/src/dao/pinned_message_dao_test.dart @@ -1,3 +1,5 @@ +// ignore_for_file: avoid_redundant_argument_values + import 'dart:math' as math; import 'package:flutter_test/flutter_test.dart'; @@ -73,8 +75,7 @@ void main() { type: 'testType', user: users[index], channelRole: 'channel_member', - parentId: - mapAllThreadToFirstMessage ? messages[0].id : messages[index].id, + parentId: mapAllThreadToFirstMessage ? messages[0].id : messages[index].id, createdAt: DateTime.now(), shadowed: math.Random().nextBool(), replyCount: index, @@ -86,11 +87,7 @@ void main() { pinnedBy: User(id: 'testUserId$index'), ), ); - final allMessages = [ - ...messages, - if (quoted) ...quotedMessages, - if (threads) ...threadMessages - ]; + final allMessages = [...messages, if (quoted) ...quotedMessages, if (threads) ...threadMessages]; final reaction = Reaction( type: 'type', messageId: allMessages.first.id, @@ -116,8 +113,7 @@ void main() { final firstMessageId = messages.first.id; // Fetched reactions list should have one reaction for given message id - final reactions = - await database.pinnedMessageReactionDao.getReactions(firstMessageId); + final reactions = await database.pinnedMessageReactionDao.getReactions(firstMessageId); expect(reactions.length, 1); // Deleting 2 messages from DB @@ -131,8 +127,7 @@ void main() { expect(newMessages.length, messages.length - 2); // Reaction for the first message should be deleted too - final newReactions = - await database.pinnedMessageReactionDao.getReactions(firstMessageId); + final newReactions = await database.pinnedMessageReactionDao.getReactions(firstMessageId); expect(newReactions, isEmpty); }); @@ -155,24 +150,20 @@ void main() { // Fetched reactions list should have one reaction for given message id final cid1firstMessageId = cid1Messages.first.id; - final cid1Reactions = await database.pinnedMessageReactionDao - .getReactions(cid1firstMessageId); + final cid1Reactions = await database.pinnedMessageReactionDao.getReactions(cid1firstMessageId); expect(cid1Reactions.length, 1); // Deleting all the messages of cid1 await pinnedMessageDao.deleteMessageByCids([cid1]); // Fetched messages length of only cid1 should be empty - final cid1FetchedMessages = - await pinnedMessageDao.getMessagesByCid(cid1); - final cid2FetchedMessages = - await pinnedMessageDao.getMessagesByCid(cid2); + final cid1FetchedMessages = await pinnedMessageDao.getMessagesByCid(cid1); + final cid2FetchedMessages = await pinnedMessageDao.getMessagesByCid(cid2); expect(cid1FetchedMessages, isEmpty); expect(cid2FetchedMessages, isNotEmpty); // Reaction for the first message should be deleted too - final cid1FetchedReactions = await database.pinnedMessageReactionDao - .getReactions(cid1firstMessageId); + final cid1FetchedReactions = await database.pinnedMessageReactionDao.getReactions(cid1firstMessageId); expect(cid1FetchedReactions, isEmpty); }, ); @@ -192,31 +183,25 @@ void main() { // Fetched reactions list should have one reaction for given message id final cid1FirstMessageId = cid1Messages.first.id; - final cid1Reactions = await database.pinnedMessageReactionDao - .getReactions(cid1FirstMessageId); + final cid1Reactions = await database.pinnedMessageReactionDao.getReactions(cid1FirstMessageId); expect(cid1Reactions.length, 1); final cid2FirstMessageId = cid2Messages.first.id; - final cid2Reactions = await database.pinnedMessageReactionDao - .getReactions(cid2FirstMessageId); + final cid2Reactions = await database.pinnedMessageReactionDao.getReactions(cid2FirstMessageId); expect(cid2Reactions.length, 1); // Deleting all the messages of cid1 await pinnedMessageDao.deleteMessageByCids([cid1, cid2]); // Fetched messages length of both cid1 and cid2 should be empty - final cid1FetchedMessages = - await pinnedMessageDao.getMessagesByCid(cid1); - final cid2FetchedMessages = - await pinnedMessageDao.getMessagesByCid(cid2); + final cid1FetchedMessages = await pinnedMessageDao.getMessagesByCid(cid1); + final cid2FetchedMessages = await pinnedMessageDao.getMessagesByCid(cid2); expect(cid1FetchedMessages, isEmpty); expect(cid2FetchedMessages, isEmpty); // Reaction for the first message should be deleted too - final cid1FetchedReactions = await database.pinnedMessageReactionDao - .getReactions(cid1FirstMessageId); + final cid1FetchedReactions = await database.pinnedMessageReactionDao.getReactions(cid1FirstMessageId); expect(cid1FetchedReactions, isEmpty); - final cid2FetchedReactions = await database.pinnedMessageReactionDao - .getReactions(cid2FirstMessageId); + final cid2FetchedReactions = await database.pinnedMessageReactionDao.getReactions(cid2FirstMessageId); expect(cid2FetchedReactions, isEmpty); }, ); @@ -264,8 +249,7 @@ void main() { const parentId = 'testMessageId${cid}0'; // Messages should be empty initially - final messages = - await pinnedMessageDao.getThreadMessagesByParentId(parentId); + final messages = await pinnedMessageDao.getThreadMessagesByParentId(parentId); expect(messages, isEmpty); // Preparing test data @@ -273,8 +257,7 @@ void main() { expect(insertedMessages, isNotEmpty); // Should fetch all the thread messages of parentId - final threadMessages = - await pinnedMessageDao.getThreadMessagesByParentId(parentId); + final threadMessages = await pinnedMessageDao.getThreadMessagesByParentId(parentId); expect(threadMessages.length, 1); expect(threadMessages.first.parentId, parentId); }); @@ -426,6 +409,169 @@ void main() { ); }); + group('deleteMessagesByUser', () { + const cid1 = 'test:Cid1'; + const cid2 = 'test:Cid2'; + const userId = 'testUserId0'; + + test('hard deletes user pinned messages in specific channel', () async { + // Preparing test data for two channels + await _prepareTestData(cid1); + await _prepareTestData(cid2); + + // Verify messages exist in both channels + final cid1Messages = await pinnedMessageDao.getMessagesByCid(cid1); + final cid2Messages = await pinnedMessageDao.getMessagesByCid(cid2); + expect(cid1Messages, isNotEmpty); + expect(cid2Messages, isNotEmpty); + + // Count messages from the specific user in cid1 + final cid1UserMessages = cid1Messages.where((m) => m.user?.id == userId).length; + expect(cid1UserMessages, greaterThan(0)); + + // Hard delete messages from user in cid1 only + await pinnedMessageDao.deleteMessagesByUser( + cid: cid1, + userId: userId, + hardDelete: true, + ); + + // Verify user's messages are deleted from cid1 + final cid1MessagesAfter = await pinnedMessageDao.getMessagesByCid(cid1); + final cid1UserMessagesAfter = cid1MessagesAfter.where((m) => m.user?.id == userId).length; + expect(cid1UserMessagesAfter, 0); + + // Verify other users' messages in cid1 are not affected + expect(cid1MessagesAfter.length, cid1Messages.length - cid1UserMessages); + + // Verify messages in cid2 are not affected + final cid2MessagesAfter = await pinnedMessageDao.getMessagesByCid(cid2); + expect(cid2MessagesAfter.length, cid2Messages.length); + }); + + test('soft deletes user pinned messages in specific channel', () async { + // Preparing test data + await _prepareTestData(cid1); + + final cid1Messages = await pinnedMessageDao.getMessagesByCid(cid1); + final cid1UserMessages = cid1Messages.where((m) => m.user?.id == userId).toList(); + expect(cid1UserMessages, isNotEmpty); + + // Verify messages are not deleted initially + for (final message in cid1UserMessages) { + expect(message.type, isNot('deleted')); + expect(message.deletedAt, isNull); + } + + // Soft delete messages from user + final deletedAt = DateTime.now(); + await pinnedMessageDao.deleteMessagesByUser( + cid: cid1, + userId: userId, + hardDelete: false, + deletedAt: deletedAt, + ); + + // Verify messages are marked as deleted + final cid1MessagesAfter = await pinnedMessageDao.getMessagesByCid(cid1); + final cid1UserMessagesAfter = cid1MessagesAfter.where((m) => m.user?.id == userId).toList(); + + // Messages should still exist in DB + expect(cid1UserMessagesAfter.length, cid1UserMessages.length); + + // But they should be marked as deleted + for (final message in cid1UserMessagesAfter) { + expect(message.type, 'deleted'); + expect(message.deletedAt, isNotNull); + } + + // Other users' messages should not be affected + final otherUserMessages = cid1MessagesAfter.where((m) => m.user?.id != userId).toList(); + for (final message in otherUserMessages) { + expect(message.type, isNot('deleted')); + } + }); + + test('hard deletes user pinned messages across all channels when cid null', () async { + // Preparing test data for multiple channels + await _prepareTestData(cid1); + await _prepareTestData(cid2); + + final cid1Messages = await pinnedMessageDao.getMessagesByCid(cid1); + final cid2Messages = await pinnedMessageDao.getMessagesByCid(cid2); + + final cid1UserMessages = cid1Messages.where((m) => m.user?.id == userId).length; + final cid2UserMessages = cid2Messages.where((m) => m.user?.id == userId).length; + + expect(cid1UserMessages, greaterThan(0)); + expect(cid2UserMessages, greaterThan(0)); + + // Hard delete all messages from user across all channels + await pinnedMessageDao.deleteMessagesByUser( + userId: userId, + hardDelete: true, + ); + + // Verify user's messages are deleted from both channels + final cid1MessagesAfter = await pinnedMessageDao.getMessagesByCid(cid1); + final cid2MessagesAfter = await pinnedMessageDao.getMessagesByCid(cid2); + + expect( + cid1MessagesAfter.where((m) => m.user?.id == userId).length, + 0, + ); + expect( + cid2MessagesAfter.where((m) => m.user?.id == userId).length, + 0, + ); + + // Verify other messages are preserved + expect( + cid1MessagesAfter.length, + cid1Messages.length - cid1UserMessages, + ); + expect( + cid2MessagesAfter.length, + cid2Messages.length - cid2UserMessages, + ); + }); + + test('soft deletes user pinned messages across all channels when cid null', () async { + // Preparing test data for multiple channels + await _prepareTestData(cid1); + await _prepareTestData(cid2); + + final cid1Messages = await pinnedMessageDao.getMessagesByCid(cid1); + final cid2Messages = await pinnedMessageDao.getMessagesByCid(cid2); + + final cid1UserMessages = cid1Messages.where((m) => m.user?.id == userId).length; + final cid2UserMessages = cid2Messages.where((m) => m.user?.id == userId).length; + + // Soft delete all messages from user across all channels + await pinnedMessageDao.deleteMessagesByUser( + userId: userId, + hardDelete: false, + ); + + // Verify user's messages are marked as deleted in both channels + final cid1MessagesAfter = await pinnedMessageDao.getMessagesByCid(cid1); + final cid2MessagesAfter = await pinnedMessageDao.getMessagesByCid(cid2); + + final cid1UserMessagesAfter = cid1MessagesAfter.where((m) => m.user?.id == userId).toList(); + final cid2UserMessagesAfter = cid2MessagesAfter.where((m) => m.user?.id == userId).toList(); + + // Messages should still exist + expect(cid1UserMessagesAfter.length, cid1UserMessages); + expect(cid2UserMessagesAfter.length, cid2UserMessages); + + // All user messages should be marked as deleted + for (final message in [...cid1UserMessagesAfter, ...cid2UserMessagesAfter]) { + expect(message.type, 'deleted'); + expect(message.deletedAt, isNotNull); + } + }); + }); + tearDown(() async { await database.disconnect(); }); diff --git a/packages/stream_chat_persistence/test/src/dao/pinned_message_reaction_dao_test.dart b/packages/stream_chat_persistence/test/src/dao/pinned_message_reaction_dao_test.dart index 44abc4a644..4b76fe095c 100644 --- a/packages/stream_chat_persistence/test/src/dao/pinned_message_reaction_dao_test.dart +++ b/packages/stream_chat_persistence/test/src/dao/pinned_message_reaction_dao_test.dart @@ -6,6 +6,7 @@ import 'package:stream_chat_persistence/src/dao/pinned_message_reaction_dao.dart import 'package:stream_chat_persistence/src/db/drift_chat_database.dart'; import '../../stream_chat_persistence_client_test.dart'; +import '../utils/date_matcher.dart'; void main() { late PinnedMessageReactionDao pinnedMessageReactionDao; @@ -39,16 +40,23 @@ void main() { pinnedAt: DateTime.now(), pinnedBy: users.first, ); + + final now = DateTime.now(); final reactions = List.generate( count, - (index) => Reaction( - type: 'testType$index', - createdAt: DateTime.now(), - userId: userId ?? users[index].id, - messageId: message.id, - score: count + 3, - extraData: {'extra_test_field': 'extraTestData'}, - ), + (index) { + final createdAt = now.add(Duration(minutes: index)); + return Reaction( + type: 'testType$index', + createdAt: createdAt, + updatedAt: createdAt.add(const Duration(minutes: 5)), + userId: userId ?? users[index].id, + messageId: message.id, + score: count + 3, + emojiCode: '😂$index', + extraData: const {'extra_test_field': 'extraTestData'}, + ); + }, ); await database.userDao.updateUsers(users); @@ -72,10 +80,17 @@ void main() { // Fetched reaction length should match inserted reactions length. // Every reaction messageId should match the provided messageId. - final fetchedReactions = - await pinnedMessageReactionDao.getReactions(messageId); + final fetchedReactions = await pinnedMessageReactionDao.getReactions(messageId); expect(fetchedReactions.length, insertedReactions.length); expect(fetchedReactions.every((it) => it.messageId == messageId), true); + + // Verify score and emojiCode are preserved + for (var i = 0; i < fetchedReactions.length; i++) { + final inserted = insertedReactions[i]; + final fetched = fetchedReactions[i]; + expect(fetched.score, inserted.score); + expect(fetched.emojiCode, inserted.emojiCode); + } }); test('getReactionsByUserId', () async { @@ -83,23 +98,28 @@ void main() { const userId = 'testUserId'; // Should be empty initially - final reactions = - await pinnedMessageReactionDao.getReactionsByUserId(messageId, userId); + final reactions = await pinnedMessageReactionDao.getReactionsByUserId(messageId, userId); expect(reactions, isEmpty); // Adding sample reactions - final insertedReactions = - await _prepareReactionData(messageId, userId: userId); + final insertedReactions = await _prepareReactionData(messageId, userId: userId); expect(insertedReactions, isNotEmpty); // Fetched reaction length should match inserted reactions length. // Every reaction messageId should match the provided messageId. // Every reaction userId should match the provided userId. - final fetchedReactions = - await pinnedMessageReactionDao.getReactionsByUserId(messageId, userId); + final fetchedReactions = await pinnedMessageReactionDao.getReactionsByUserId(messageId, userId); expect(fetchedReactions.length, insertedReactions.length); expect(fetchedReactions.every((it) => it.messageId == messageId), true); expect(fetchedReactions.every((it) => it.userId == userId), true); + + // Verify score and emojiCode are preserved + for (var i = 0; i < fetchedReactions.length; i++) { + final inserted = insertedReactions[i]; + final fetched = fetchedReactions[i]; + expect(fetched.score, inserted.score); + expect(fetched.emojiCode, inserted.emojiCode); + } }); test('updateReactions', () async { @@ -109,37 +129,45 @@ void main() { final reactions = await _prepareReactionData(messageId); // Modifying one of the reaction and also adding one new - final copyReaction = reactions.first.copyWith(score: 33); + final now = DateTime.now(); + final copyReaction = reactions.first.copyWith( + score: 33, + emojiCode: '🎉', + updatedAt: now, + ); final newReaction = Reaction( type: 'testType3', - createdAt: DateTime.now(), + createdAt: now, + updatedAt: now.add(const Duration(minutes: 5)), userId: 'testUserId3', messageId: messageId, score: 30, - extraData: {'extra_test_field': 'extraTestData'}, + emojiCode: '🎈', + extraData: const {'extra_test_field': 'extraTestData'}, ); await pinnedMessageReactionDao.updateReactions([copyReaction, newReaction]); // Fetched reaction length should be one more than inserted reactions. - // copyReaction `score` modified field should be 33. + // copyReaction modified fields should match // Fetched reactions should contain the newReaction. - final fetchedReactions = - await pinnedMessageReactionDao.getReactions(messageId); + final fetchedReactions = await pinnedMessageReactionDao.getReactions(messageId); expect(fetchedReactions.length, reactions.length + 1); - expect( - fetchedReactions - .firstWhere((it) => - it.userId == copyReaction.userId && it.type == copyReaction.type) - .score, - 33, + + final fetchedCopyReaction = fetchedReactions.firstWhere( + (it) => it.userId == copyReaction.userId && it.type == copyReaction.type, ); + expect(fetchedCopyReaction.score, 33); + expect(fetchedCopyReaction.emojiCode, '🎉'); + expect(fetchedCopyReaction.updatedAt, isSameDateAs(now)); + + final fetchedNewReaction = fetchedReactions.firstWhere( + (it) => it.userId == newReaction.userId && it.type == newReaction.type, + ); + expect(fetchedNewReaction.emojiCode, '🎈'); expect( - fetchedReactions - .where((it) => - it.userId == newReaction.userId && it.type == newReaction.type) - .isNotEmpty, - true, + fetchedNewReaction.updatedAt, + isSameDateAs(now.add(const Duration(minutes: 5))), ); }); @@ -153,10 +181,8 @@ void main() { // Fetched reaction list length should match // the inserted reactions list length - final reactions1 = - await pinnedMessageReactionDao.getReactions(messageId1); - final reactions2 = - await pinnedMessageReactionDao.getReactions(messageId2); + final reactions1 = await pinnedMessageReactionDao.getReactions(messageId1); + final reactions2 = await pinnedMessageReactionDao.getReactions(messageId2); expect(reactions1.length, insertedReactions1.length); expect(reactions2.length, insertedReactions2.length); @@ -164,36 +190,30 @@ void main() { await pinnedMessageReactionDao.deleteReactionsByMessageIds([messageId1]); // Fetched reactions length of only messageId1 should be empty - final fetchedReactions1 = - await pinnedMessageReactionDao.getReactions(messageId1); - final fetchedReactions2 = - await pinnedMessageReactionDao.getReactions(messageId2); + final fetchedReactions1 = await pinnedMessageReactionDao.getReactions(messageId1); + final fetchedReactions2 = await pinnedMessageReactionDao.getReactions(messageId2); expect(fetchedReactions1, isEmpty); expect(fetchedReactions2, isNotEmpty); }); - test('should delete all the messages of both message', () async { + + test('should delete all the reactions of both message', () async { // Preparing test data final insertedReactions1 = await _prepareReactionData(messageId1); final insertedReactions2 = await _prepareReactionData(messageId2); // Fetched reaction list length should match // the inserted reactions list length - final reactions1 = - await pinnedMessageReactionDao.getReactions(messageId1); - final reactions2 = - await pinnedMessageReactionDao.getReactions(messageId2); + final reactions1 = await pinnedMessageReactionDao.getReactions(messageId1); + final reactions2 = await pinnedMessageReactionDao.getReactions(messageId2); expect(reactions1.length, insertedReactions1.length); expect(reactions2.length, insertedReactions2.length); // Deleting all the reactions of messageId1 and messageId2 - await pinnedMessageReactionDao - .deleteReactionsByMessageIds([messageId1, messageId2]); + await pinnedMessageReactionDao.deleteReactionsByMessageIds([messageId1, messageId2]); // Fetched reactions length of both messages should be empty - final fetchedReactions1 = - await pinnedMessageReactionDao.getReactions(messageId1); - final fetchedReactions2 = - await pinnedMessageReactionDao.getReactions(messageId2); + final fetchedReactions1 = await pinnedMessageReactionDao.getReactions(messageId1); + final fetchedReactions2 = await pinnedMessageReactionDao.getReactions(messageId2); expect(fetchedReactions1, isEmpty); expect(fetchedReactions2, isEmpty); }); diff --git a/packages/stream_chat_persistence/test/src/dao/poll_vote_dao_test.dart b/packages/stream_chat_persistence/test/src/dao/poll_vote_dao_test.dart index 093aac84bf..2c13ee1cdb 100644 --- a/packages/stream_chat_persistence/test/src/dao/poll_vote_dao_test.dart +++ b/packages/stream_chat_persistence/test/src/dao/poll_vote_dao_test.dart @@ -44,9 +44,7 @@ void main() { (key, value) => MapEntry(key, value.length), ); - final users = latestVotesByOption.values - .expand((it) => it.map((it) => it.user!)) - .toList(); + final users = latestVotesByOption.values.expand((it) => it.map((it) => it.user!)).toList(); final poll = Poll( id: pollId, @@ -114,11 +112,13 @@ void main() { final fetchedPollVotes = await pollVoteDao.getPollVotes(pollId); expect(fetchedPollVotes.length, pollVotes.length + 1); expect( - fetchedPollVotes.any((it) => - it.id == newPollVote.id && - it.pollId == newPollVote.pollId && - it.optionId == newPollVote.optionId && - it.answerText == newPollVote.answerText), + fetchedPollVotes.any( + (it) => + it.id == newPollVote.id && + it.pollId == newPollVote.pollId && + it.optionId == newPollVote.optionId && + it.answerText == newPollVote.answerText, + ), true, ); }); diff --git a/packages/stream_chat_persistence/test/src/dao/reaction_dao_test.dart b/packages/stream_chat_persistence/test/src/dao/reaction_dao_test.dart index 7fa3569e4e..2252641bc1 100644 --- a/packages/stream_chat_persistence/test/src/dao/reaction_dao_test.dart +++ b/packages/stream_chat_persistence/test/src/dao/reaction_dao_test.dart @@ -6,6 +6,7 @@ import 'package:stream_chat_persistence/src/dao/reaction_dao.dart'; import 'package:stream_chat_persistence/src/db/drift_chat_database.dart'; import '../../stream_chat_persistence_client_test.dart'; +import '../utils/date_matcher.dart'; void main() { late ReactionDao reactionDao; @@ -39,16 +40,23 @@ void main() { pinnedAt: DateTime.now(), pinnedBy: users.first, ); + + final now = DateTime.now(); final reactions = List.generate( count, - (index) => Reaction( - type: 'testType$index', - createdAt: DateTime.now(), - userId: userId ?? users[index].id, - messageId: message.id, - score: count + 3, - extraData: {'extra_test_field': 'extraTestData'}, - ), + (index) { + final createdAt = now.add(Duration(minutes: index)); + return Reaction( + type: 'testType$index', + createdAt: createdAt, + updatedAt: createdAt.add(const Duration(minutes: 5)), + userId: userId ?? users[index].id, + messageId: message.id, + score: count + 3, + emojiCode: '😂$index', + extraData: const {'extra_test_field': 'extraTestData'}, + ); + }, ); await database.userDao.updateUsers(users); @@ -75,6 +83,14 @@ void main() { final fetchedReactions = await reactionDao.getReactions(messageId); expect(fetchedReactions.length, insertedReactions.length); expect(fetchedReactions.every((it) => it.messageId == messageId), true); + + // Verify score and emojiCode are preserved + for (var i = 0; i < fetchedReactions.length; i++) { + final inserted = insertedReactions[i]; + final fetched = fetchedReactions[i]; + expect(fetched.score, inserted.score); + expect(fetched.emojiCode, inserted.emojiCode); + } }); test('getReactionsByUserId', () async { @@ -86,18 +102,24 @@ void main() { expect(reactions, isEmpty); // Adding sample reactions - final insertedReactions = - await _prepareReactionData(messageId, userId: userId); + final insertedReactions = await _prepareReactionData(messageId, userId: userId); expect(insertedReactions, isNotEmpty); // Fetched reaction length should match inserted reactions length. // Every reaction messageId should match the provided messageId. // Every reaction userId should match the provided userId. - final fetchedReactions = - await reactionDao.getReactionsByUserId(messageId, userId); + final fetchedReactions = await reactionDao.getReactionsByUserId(messageId, userId); expect(fetchedReactions.length, insertedReactions.length); expect(fetchedReactions.every((it) => it.messageId == messageId), true); expect(fetchedReactions.every((it) => it.userId == userId), true); + + // Verify score and emojiCode are preserved + for (var i = 0; i < fetchedReactions.length; i++) { + final inserted = insertedReactions[i]; + final fetched = fetchedReactions[i]; + expect(fetched.score, inserted.score); + expect(fetched.emojiCode, inserted.emojiCode); + } }); test('updateReactions', () async { @@ -107,36 +129,45 @@ void main() { final reactions = await _prepareReactionData(messageId); // Modifying one of the reaction and also adding one new - final copyReaction = reactions.first.copyWith(score: 33); + final now = DateTime.now(); + final copyReaction = reactions.first.copyWith( + score: 33, + emojiCode: '🎉', + updatedAt: now, + ); final newReaction = Reaction( type: 'testType3', - createdAt: DateTime.now(), + createdAt: now, + updatedAt: now.add(const Duration(minutes: 5)), userId: 'testUserId3', messageId: messageId, score: 30, - extraData: {'extra_test_field': 'extraTestData'}, + emojiCode: '🎈', + extraData: const {'extra_test_field': 'extraTestData'}, ); await reactionDao.updateReactions([copyReaction, newReaction]); // Fetched reaction length should be one more than inserted reactions. - // copyReaction `score` modified field should be 33. + // copyReaction modified fields should match // Fetched reactions should contain the newReaction. final fetchedReactions = await reactionDao.getReactions(messageId); expect(fetchedReactions.length, reactions.length + 1); - expect( - fetchedReactions - .firstWhere((it) => - it.userId == copyReaction.userId && it.type == copyReaction.type) - .score, - 33, + + final fetchedCopyReaction = fetchedReactions.firstWhere( + (it) => it.userId == copyReaction.userId && it.type == copyReaction.type, ); + expect(fetchedCopyReaction.score, 33); + expect(fetchedCopyReaction.emojiCode, '🎉'); + expect(fetchedCopyReaction.updatedAt, isSameDateAs(now)); + + final fetchedNewReaction = fetchedReactions.firstWhere( + (it) => it.userId == newReaction.userId && it.type == newReaction.type, + ); + expect(fetchedNewReaction.emojiCode, '🎈'); expect( - fetchedReactions - .where((it) => - it.userId == newReaction.userId && it.type == newReaction.type) - .isNotEmpty, - true, + fetchedNewReaction.updatedAt, + isSameDateAs(now.add(const Duration(minutes: 5))), ); }); @@ -164,6 +195,7 @@ void main() { expect(fetchedReactions1, isEmpty); expect(fetchedReactions2, isNotEmpty); }); + test('should delete all the reactions of both message', () async { // Preparing test data final insertedReactions1 = await _prepareReactionData(messageId1); diff --git a/packages/stream_chat_persistence/test/src/dao/read_dao_test.dart b/packages/stream_chat_persistence/test/src/dao/read_dao_test.dart index 142daf4155..473e2de82f 100644 --- a/packages/stream_chat_persistence/test/src/dao/read_dao_test.dart +++ b/packages/stream_chat_persistence/test/src/dao/read_dao_test.dart @@ -56,10 +56,8 @@ void main() { expect(fetchedRead.user.id, insertedRead.user.id); expect(fetchedRead.lastRead, isSameDateAs(insertedRead.lastRead)); expect(fetchedRead.unreadMessages, insertedRead.unreadMessages); - expect(fetchedRead.lastDeliveredAt, - isSameDateAs(insertedRead.lastDeliveredAt)); - expect(fetchedRead.lastDeliveredMessageId, - insertedRead.lastDeliveredMessageId); + expect(fetchedRead.lastDeliveredAt, isSameDateAs(insertedRead.lastDeliveredAt)); + expect(fetchedRead.lastDeliveredMessageId, insertedRead.lastDeliveredMessageId); } }); @@ -89,16 +87,12 @@ void main() { final fetchedReads = await readDao.getReadsByCid(cid); expect(fetchedReads.length, insertedReads.length + 1); expect( - fetchedReads - .firstWhere((it) => it.user.id == copyRead.user.id) - .unreadMessages, + fetchedReads.firstWhere((it) => it.user.id == copyRead.user.id).unreadMessages, 33, ); expect( fetchedReads - .where((it) => - it.user.id == newRead.user.id && - it.unreadMessages == newRead.unreadMessages) + .where((it) => it.user.id == newRead.user.id && it.unreadMessages == newRead.unreadMessages) .isNotEmpty, true, ); diff --git a/packages/stream_chat_persistence/test/src/db/drift_chat_database_test.dart b/packages/stream_chat_persistence/test/src/db/drift_chat_database_test.dart index 996c152fb1..dd14acf8c1 100644 --- a/packages/stream_chat_persistence/test/src/db/drift_chat_database_test.dart +++ b/packages/stream_chat_persistence/test/src/db/drift_chat_database_test.dart @@ -4,8 +4,7 @@ import 'package:drift/native.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:stream_chat_persistence/src/db/drift_chat_database.dart'; -DatabaseConnection _backgroundConnection() => - DatabaseConnection(NativeDatabase.memory()); +DatabaseConnection _backgroundConnection() => DatabaseConnection(NativeDatabase.memory()); void main() { test( diff --git a/packages/stream_chat_persistence/test/src/mapper/location_mapper_test.dart b/packages/stream_chat_persistence/test/src/mapper/location_mapper_test.dart new file mode 100644 index 0000000000..ba728659d2 --- /dev/null +++ b/packages/stream_chat_persistence/test/src/mapper/location_mapper_test.dart @@ -0,0 +1,140 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:stream_chat/stream_chat.dart'; +import 'package:stream_chat_persistence/src/db/drift_chat_database.dart'; +import 'package:stream_chat_persistence/src/mapper/location_mapper.dart'; + +void main() { + group('LocationMapper', () { + test('toLocation should map the entity into Location', () { + final createdAt = DateTime.now(); + final updatedAt = DateTime.now(); + final endAt = DateTime.timestamp().add(const Duration(hours: 1)); + + final entity = LocationEntity( + channelCid: 'testCid', + userId: 'testUserId', + messageId: 'testMessageId', + latitude: 40.7128, + longitude: -74.0060, + createdByDeviceId: 'testDeviceId', + endAt: endAt, + createdAt: createdAt, + updatedAt: updatedAt, + ); + + final location = entity.toLocation(); + + expect(location, isA()); + expect(location.channelCid, entity.channelCid); + expect(location.userId, entity.userId); + expect(location.messageId, entity.messageId); + expect(location.latitude, entity.latitude); + expect(location.longitude, entity.longitude); + expect(location.createdByDeviceId, entity.createdByDeviceId); + expect(location.endAt, entity.endAt); + expect(location.createdAt, entity.createdAt); + expect(location.updatedAt, entity.updatedAt); + }); + + test('toEntity should map the Location into LocationEntity', () { + final createdAt = DateTime.timestamp(); + final updatedAt = DateTime.timestamp(); + final endAt = DateTime.timestamp().add(const Duration(hours: 1)); + + final location = Location( + channelCid: 'testCid', + userId: 'testUserId', + messageId: 'testMessageId', + latitude: 40.7128, + longitude: -74.0060, + createdByDeviceId: 'testDeviceId', + endAt: endAt, + createdAt: createdAt, + updatedAt: updatedAt, + ); + + final entity = location.toEntity(); + + expect(entity, isA()); + expect(entity.channelCid, location.channelCid); + expect(entity.userId, location.userId); + expect(entity.messageId, location.messageId); + expect(entity.latitude, location.latitude); + expect(entity.longitude, location.longitude); + expect(entity.createdByDeviceId, location.createdByDeviceId); + expect(entity.endAt, location.endAt); + expect(entity.createdAt, location.createdAt); + expect(entity.updatedAt, location.updatedAt); + }); + + test('roundtrip conversion should preserve data', () { + final createdAt = DateTime.timestamp(); + final updatedAt = DateTime.timestamp(); + final endAt = DateTime.timestamp().add(const Duration(hours: 1)); + + final originalLocation = Location( + channelCid: 'testCid', + userId: 'testUserId', + messageId: 'testMessageId', + latitude: 40.7128, + longitude: -74.0060, + createdByDeviceId: 'testDeviceId', + endAt: endAt, + createdAt: createdAt, + updatedAt: updatedAt, + ); + + final entity = originalLocation.toEntity(); + final convertedLocation = entity.toLocation(); + + expect(convertedLocation.channelCid, originalLocation.channelCid); + expect(convertedLocation.userId, originalLocation.userId); + expect(convertedLocation.messageId, originalLocation.messageId); + expect(convertedLocation.latitude, originalLocation.latitude); + expect(convertedLocation.longitude, originalLocation.longitude); + expect(convertedLocation.createdByDeviceId, originalLocation.createdByDeviceId); + expect(convertedLocation.endAt, originalLocation.endAt); + expect(convertedLocation.createdAt, originalLocation.createdAt); + expect(convertedLocation.updatedAt, originalLocation.updatedAt); + }); + + test('should handle live location conversion', () { + final endAt = DateTime.timestamp().add(const Duration(hours: 1)); + final location = Location( + channelCid: 'testCid', + userId: 'testUserId', + messageId: 'testMessageId', + latitude: 40.7128, + longitude: -74.0060, + createdByDeviceId: 'testDeviceId', + endAt: endAt, + ); + + final entity = location.toEntity(); + final convertedLocation = entity.toLocation(); + + expect(convertedLocation.isLive, isTrue); + expect(convertedLocation.isStatic, isFalse); + expect(convertedLocation.endAt, endAt); + }); + + test('should handle static location conversion', () { + final location = Location( + channelCid: 'testCid', + userId: 'testUserId', + messageId: 'testMessageId', + latitude: 40.7128, + longitude: -74.0060, + createdByDeviceId: 'testDeviceId', + // No endAt = static location + ); + + final entity = location.toEntity(); + final convertedLocation = entity.toLocation(); + + expect(convertedLocation.isLive, isFalse); + expect(convertedLocation.isStatic, isTrue); + expect(convertedLocation.endAt, isNull); + }); + }); +} diff --git a/packages/stream_chat_persistence/test/src/mapper/member_mapper_test.dart b/packages/stream_chat_persistence/test/src/mapper/member_mapper_test.dart index 0c2bea8fca..a577a6d078 100644 --- a/packages/stream_chat_persistence/test/src/mapper/member_mapper_test.dart +++ b/packages/stream_chat_persistence/test/src/mapper/member_mapper_test.dart @@ -24,6 +24,7 @@ void main() { pinnedAt: DateTime.now(), archivedAt: DateTime.now(), isModerator: math.Random().nextBool(), + deletedMessages: ['msg1', 'msg2', 'msg3'], extraData: {'test_extra_data': 'testData'}, ); final member = entity.toMember(user: user); @@ -40,6 +41,7 @@ void main() { expect(member.pinnedAt, isSameDateAs(entity.pinnedAt)); expect(member.archivedAt, isSameDateAs(entity.archivedAt)); expect(member.isModerator, entity.isModerator); + expect(member.deletedMessages, entity.deletedMessages); expect(member.extraData, entity.extraData); }); @@ -59,6 +61,7 @@ void main() { pinnedAt: DateTime.now(), archivedAt: DateTime.now(), isModerator: math.Random().nextBool(), + deletedMessages: const ['msg1', 'msg2', 'msg3'], extraData: const {'test_extra_data': 'testData'}, ); final entity = member.toEntity(cid: cid); @@ -76,6 +79,7 @@ void main() { expect(entity.pinnedAt, isSameDateAs(member.pinnedAt)); expect(entity.archivedAt, isSameDateAs(member.archivedAt)); expect(entity.isModerator, member.isModerator); + expect(entity.deletedMessages, member.deletedMessages); expect(entity.extraData, member.extraData); }); } diff --git a/packages/stream_chat_persistence/test/src/mapper/message_mapper_test.dart b/packages/stream_chat_persistence/test/src/mapper/message_mapper_test.dart index 0c26454f94..be7cc74e9b 100644 --- a/packages/stream_chat_persistence/test/src/mapper/message_mapper_test.dart +++ b/packages/stream_chat_persistence/test/src/mapper/message_mapper_test.dart @@ -81,6 +81,7 @@ void main() { channelRole: 'channel_member', localDeletedAt: DateTime.now(), remoteDeletedAt: DateTime.now().add(const Duration(seconds: 1)), + deletedForMe: false, messageText: 'Hello', pinned: true, pinExpires: DateTime.now().toUtc(), @@ -114,8 +115,7 @@ void main() { expect(message.shadowed, entity.shadowed); expect(message.showInChannel, entity.showInChannel); for (var i = 0; i < message.mentionedUsers.length; i++) { - final entityMentionedUser = - User.fromJson(jsonDecode(entity.mentionedUsers[i])); + final entityMentionedUser = User.fromJson(jsonDecode(entity.mentionedUsers[i])); expect(message.mentionedUsers[i].id, entityMentionedUser.id); } expect(message.replyCount, entity.replyCount); @@ -131,6 +131,7 @@ void main() { expect(message.user!.id, entity.userId); expect(message.localDeletedAt, isSameDateAs(entity.localDeletedAt)); expect(message.remoteDeletedAt, isSameDateAs(entity.remoteDeletedAt)); + expect(message.deletedForMe, entity.deletedForMe); expect(message.text, entity.messageText); expect(message.channelRole, entity.channelRole); expect(message.pinned, entity.pinned); @@ -221,6 +222,7 @@ void main() { channelRole: 'channel_member', localDeletedAt: DateTime.now(), deletedAt: DateTime.now().add(const Duration(seconds: 1)), + deletedForMe: true, text: 'Hello', pinned: true, pinExpires: DateTime.now(), @@ -246,8 +248,7 @@ void main() { expect(entity.shadowed, message.shadowed); expect(entity.showInChannel, message.showInChannel); expect(entity.replyCount, message.replyCount); - expect( - entity.mentionedUsers, message.mentionedUsers.map(jsonEncode).toList()); + expect(entity.mentionedUsers, message.mentionedUsers.map(jsonEncode).toList()); expect(entity.state, jsonEncode(message.state)); expect(entity.localUpdatedAt, isSameDateAs(message.localUpdatedAt)); expect(entity.remoteUpdatedAt, isSameDateAs(message.remoteUpdatedAt)); @@ -259,6 +260,7 @@ void main() { expect(entity.userId, message.user!.id); expect(entity.localDeletedAt, isSameDateAs(message.localDeletedAt)); expect(entity.remoteDeletedAt, isSameDateAs(message.remoteDeletedAt)); + expect(entity.deletedForMe, message.deletedForMe); expect(entity.messageText, message.text); expect(entity.channelRole, message.channelRole); expect(entity.pinned, message.pinned); diff --git a/packages/stream_chat_persistence/test/src/mapper/pinned_message_mapper_test.dart b/packages/stream_chat_persistence/test/src/mapper/pinned_message_mapper_test.dart index e0bb30feba..170026665c 100644 --- a/packages/stream_chat_persistence/test/src/mapper/pinned_message_mapper_test.dart +++ b/packages/stream_chat_persistence/test/src/mapper/pinned_message_mapper_test.dart @@ -81,6 +81,7 @@ void main() { channelRole: 'channel_member', localDeletedAt: DateTime.now(), remoteDeletedAt: DateTime.now().add(const Duration(seconds: 1)), + deletedForMe: false, messageText: 'Hello', pinned: true, pinExpires: DateTime.now().toUtc(), @@ -114,8 +115,7 @@ void main() { expect(message.shadowed, entity.shadowed); expect(message.showInChannel, entity.showInChannel); for (var i = 0; i < message.mentionedUsers.length; i++) { - final entityMentionedUser = - User.fromJson(jsonDecode(entity.mentionedUsers[i])); + final entityMentionedUser = User.fromJson(jsonDecode(entity.mentionedUsers[i])); expect(message.mentionedUsers[i].id, entityMentionedUser.id); } expect(message.replyCount, entity.replyCount); @@ -131,6 +131,7 @@ void main() { expect(message.user!.id, entity.userId); expect(message.localDeletedAt, isSameDateAs(entity.localDeletedAt)); expect(message.remoteDeletedAt, isSameDateAs(entity.remoteDeletedAt)); + expect(message.deletedForMe, entity.deletedForMe); expect(message.text, entity.messageText); expect(message.channelRole, entity.channelRole); expect(message.pinned, entity.pinned); @@ -221,6 +222,7 @@ void main() { channelRole: 'channel_member', localDeletedAt: DateTime.now(), deletedAt: DateTime.now().add(const Duration(seconds: 1)), + deletedForMe: true, text: 'Hello', pinned: true, pinExpires: DateTime.now(), @@ -246,8 +248,7 @@ void main() { expect(entity.shadowed, message.shadowed); expect(entity.showInChannel, message.showInChannel); expect(entity.replyCount, message.replyCount); - expect( - entity.mentionedUsers, message.mentionedUsers.map(jsonEncode).toList()); + expect(entity.mentionedUsers, message.mentionedUsers.map(jsonEncode).toList()); expect(entity.state, jsonEncode(message.state)); expect(entity.localUpdatedAt, isSameDateAs(message.localUpdatedAt)); expect(entity.remoteUpdatedAt, isSameDateAs(message.remoteUpdatedAt)); @@ -259,6 +260,7 @@ void main() { expect(entity.userId, message.user!.id); expect(entity.localDeletedAt, isSameDateAs(message.localDeletedAt)); expect(entity.remoteDeletedAt, isSameDateAs(message.remoteDeletedAt)); + expect(entity.deletedForMe, message.deletedForMe); expect(entity.messageText, message.text); expect(entity.channelRole, message.channelRole); expect(entity.pinned, message.pinned); diff --git a/packages/stream_chat_persistence/test/src/mapper/pinned_message_reaction_mapper_test.dart b/packages/stream_chat_persistence/test/src/mapper/pinned_message_reaction_mapper_test.dart index 2753f4f4ce..d2b4a67094 100644 --- a/packages/stream_chat_persistence/test/src/mapper/pinned_message_reaction_mapper_test.dart +++ b/packages/stream_chat_persistence/test/src/mapper/pinned_message_reaction_mapper_test.dart @@ -9,12 +9,15 @@ void main() { test('toReaction should map the entity into Reaction', () { final user = User(id: 'testUserId'); final message = Message(id: 'testMessageId'); + final now = DateTime.now(); final entity = PinnedMessageReactionEntity( userId: user.id, messageId: message.id, type: 'haha', score: 33, - createdAt: DateTime.now(), + emojiCode: '😂', + createdAt: now, + updatedAt: now.add(const Duration(minutes: 5)), extraData: {'extra_test_data': 'extraData'}, ); @@ -24,20 +27,25 @@ void main() { expect(reaction.messageId, entity.messageId); expect(reaction.type, entity.type); expect(reaction.score, entity.score); + expect(reaction.emojiCode, entity.emojiCode); expect(reaction.createdAt, isSameDateAs(entity.createdAt)); + expect(reaction.updatedAt, isSameDateAs(entity.updatedAt)); expect(reaction.extraData, entity.extraData); }); test('toEntity should map reaction into PinnedMessageReactionEntity', () { final user = User(id: 'testUserId'); final message = Message(id: 'testMessageId'); + final now = DateTime.now(); final reaction = Reaction( userId: user.id, messageId: message.id, type: 'haha', score: 33, - createdAt: DateTime.now(), - extraData: {'extra_test_data': 'extraData'}, + emojiCode: '😂', + createdAt: now, + updatedAt: now.add(const Duration(minutes: 5)), + extraData: const {'extra_test_data': 'extraData'}, ); final entity = reaction.toPinnedEntity(); @@ -46,7 +54,9 @@ void main() { expect(entity.messageId, reaction.messageId); expect(entity.type, reaction.type); expect(entity.score, reaction.score); + expect(entity.emojiCode, reaction.emojiCode); expect(entity.createdAt, isSameDateAs(reaction.createdAt)); + expect(entity.updatedAt, isSameDateAs(reaction.updatedAt)); expect(entity.extraData, reaction.extraData); }); } diff --git a/packages/stream_chat_persistence/test/src/mapper/reaction_mapper_test.dart b/packages/stream_chat_persistence/test/src/mapper/reaction_mapper_test.dart index 844eb3b86b..43a3b93cfb 100644 --- a/packages/stream_chat_persistence/test/src/mapper/reaction_mapper_test.dart +++ b/packages/stream_chat_persistence/test/src/mapper/reaction_mapper_test.dart @@ -9,12 +9,15 @@ void main() { test('toReaction should map the entity into Reaction', () { final user = User(id: 'testUserId'); final message = Message(id: 'testMessageId'); + final now = DateTime.now(); final entity = ReactionEntity( userId: user.id, messageId: message.id, type: 'haha', score: 33, - createdAt: DateTime.now(), + emojiCode: '😂', + createdAt: now, + updatedAt: now.add(const Duration(minutes: 5)), extraData: {'extra_test_data': 'extraData'}, ); @@ -24,20 +27,25 @@ void main() { expect(reaction.messageId, entity.messageId); expect(reaction.type, entity.type); expect(reaction.score, entity.score); + expect(reaction.emojiCode, entity.emojiCode); expect(reaction.createdAt, isSameDateAs(entity.createdAt)); + expect(reaction.updatedAt, isSameDateAs(entity.updatedAt)); expect(reaction.extraData, entity.extraData); }); test('toEntity should map reaction into ReactionEntity', () { final user = User(id: 'testUserId'); final message = Message(id: 'testMessageId'); + final now = DateTime.now(); final reaction = Reaction( userId: user.id, messageId: message.id, type: 'haha', score: 33, - createdAt: DateTime.now(), - extraData: {'extra_test_data': 'extraData'}, + emojiCode: '😂', + createdAt: now, + updatedAt: now.add(const Duration(minutes: 5)), + extraData: const {'extra_test_data': 'extraData'}, ); final entity = reaction.toEntity(); @@ -46,7 +54,9 @@ void main() { expect(entity.messageId, reaction.messageId); expect(entity.type, reaction.type); expect(entity.score, reaction.score); + expect(entity.emojiCode, reaction.emojiCode); expect(entity.createdAt, isSameDateAs(reaction.createdAt)); + expect(entity.updatedAt, isSameDateAs(reaction.updatedAt)); expect(entity.extraData, reaction.extraData); }); } diff --git a/packages/stream_chat_persistence/test/src/utils/date_matcher.dart b/packages/stream_chat_persistence/test/src/utils/date_matcher.dart index d19d25f2a6..1d49e4a719 100644 --- a/packages/stream_chat_persistence/test/src/utils/date_matcher.dart +++ b/packages/stream_chat_persistence/test/src/utils/date_matcher.dart @@ -1,7 +1,6 @@ import 'package:flutter_test/flutter_test.dart'; -Matcher isSameDateAs(DateTime? targetDate) => - _IsSameDateAs(targetDate: targetDate); +Matcher isSameDateAs(DateTime? targetDate) => _IsSameDateAs(targetDate: targetDate); class _IsSameDateAs extends Matcher { const _IsSameDateAs({required this.targetDate}); @@ -19,6 +18,5 @@ class _IsSameDateAs extends Matcher { } @override - Description describe(Description description) => - description.add('is same date as $targetDate'); + Description describe(Description description) => description.add('is same date as $targetDate'); } diff --git a/packages/stream_chat_persistence/test/stream_chat_persistence_client_test.dart b/packages/stream_chat_persistence/test/stream_chat_persistence_client_test.dart index 018327e927..3fe63bef88 100644 --- a/packages/stream_chat_persistence/test/stream_chat_persistence_client_test.dart +++ b/packages/stream_chat_persistence/test/stream_chat_persistence_client_test.dart @@ -1,3 +1,5 @@ +// ignore_for_file: avoid_redundant_argument_values + import 'package:drift/native.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:mocktail/mocktail.dart'; @@ -113,20 +115,16 @@ void main() { const parentId = 'testParentId'; final replies = List.generate(3, (index) => Message(id: 'testId$index')); - when(() => mockDatabase.messageDao.getThreadMessagesByParentId(parentId)) - .thenAnswer((_) async => replies); + when(() => mockDatabase.messageDao.getThreadMessagesByParentId(parentId)).thenAnswer((_) async => replies); final fetchedReplies = await client.getReplies(parentId); expect(fetchedReplies.length, replies.length); - verify(() => - mockDatabase.messageDao.getThreadMessagesByParentId(parentId)) - .called(1); + verify(() => mockDatabase.messageDao.getThreadMessagesByParentId(parentId)).called(1); }); test('getConnectionInfo', () async { final event = Event(); - when(() => mockDatabase.connectionEventDao.connectionEvent) - .thenAnswer((_) async => event); + when(() => mockDatabase.connectionEventDao.connectionEvent).thenAnswer((_) async => event); final fetchedEvent = await client.getConnectionInfo(); expect(fetchedEvent, isNotNull); @@ -136,8 +134,7 @@ void main() { test('getLastSyncAt', () async { final lastSync = DateTime.now(); - when(() => mockDatabase.connectionEventDao.lastSyncAt) - .thenAnswer((_) async => lastSync); + when(() => mockDatabase.connectionEventDao.lastSyncAt).thenAnswer((_) async => lastSync); final fetchedLastSync = await client.getLastSyncAt(); expect(fetchedLastSync, isSameDateAs(lastSync)); @@ -146,28 +143,23 @@ void main() { test('updateConnectionInfo', () async { final event = Event(); - when(() => mockDatabase.connectionEventDao.updateConnectionEvent(event)) - .thenAnswer((_) async => 1); + when(() => mockDatabase.connectionEventDao.updateConnectionEvent(event)).thenAnswer((_) async => 1); await client.updateConnectionInfo(event); - verify(() => mockDatabase.connectionEventDao.updateConnectionEvent(event)) - .called(1); + verify(() => mockDatabase.connectionEventDao.updateConnectionEvent(event)).called(1); }); test('updateLastSyncAt', () async { final lastSync = DateTime.now(); - when(() => mockDatabase.connectionEventDao.updateLastSyncAt(lastSync)) - .thenAnswer((_) async => 1); + when(() => mockDatabase.connectionEventDao.updateLastSyncAt(lastSync)).thenAnswer((_) async => 1); await client.updateLastSyncAt(lastSync); - verify(() => mockDatabase.connectionEventDao.updateLastSyncAt(lastSync)) - .called(1); + verify(() => mockDatabase.connectionEventDao.updateLastSyncAt(lastSync)).called(1); }); test('getChannelCids', () async { final channelCids = List.generate(3, (index) => 'testCid$index'); - when(() => mockDatabase.channelDao.cids) - .thenAnswer((_) async => channelCids); + when(() => mockDatabase.channelDao.cids).thenAnswer((_) async => channelCids); final fetchedChannelCids = await client.getChannelCids(); expect(fetchedChannelCids.length, channelCids.length); @@ -177,8 +169,7 @@ void main() { test('getChannelByCid', () async { const cid = 'testType:testId'; final channelModel = ChannelModel(cid: cid); - when(() => mockDatabase.channelDao.getChannelByCid(cid)) - .thenAnswer((_) async => channelModel); + when(() => mockDatabase.channelDao.getChannelByCid(cid)).thenAnswer((_) async => channelModel); final fetchedChannelModel = await client.getChannelByCid(cid); expect(fetchedChannelModel, isNotNull); @@ -189,8 +180,7 @@ void main() { test('getMembersByCid', () async { const cid = 'testCid'; final members = List.generate(3, (index) => Member()); - when(() => mockDatabase.memberDao.getMembersByCid(cid)) - .thenAnswer((_) async => members); + when(() => mockDatabase.memberDao.getMembersByCid(cid)).thenAnswer((_) async => members); final fetchedMembers = await client.getMembersByCid(cid); expect(fetchedMembers.length, members.length); @@ -207,8 +197,7 @@ void main() { lastReadMessageId: 'lastMessageId$index', ), ); - when(() => mockDatabase.readDao.getReadsByCid(cid)) - .thenAnswer((_) async => reads); + when(() => mockDatabase.readDao.getReadsByCid(cid)).thenAnswer((_) async => reads); final fetchedReads = await client.getReadsByCid(cid); expect(fetchedReads.length, reads.length); @@ -218,8 +207,7 @@ void main() { test('getMessagesByCid', () async { const cid = 'testCid'; final messages = List.generate(3, (index) => Message()); - when(() => mockDatabase.messageDao.getMessagesByCid(cid)) - .thenAnswer((_) async => messages); + when(() => mockDatabase.messageDao.getMessagesByCid(cid)).thenAnswer((_) async => messages); final fetchedMessages = await client.getMessagesByCid(cid); expect(fetchedMessages.length, messages.length); @@ -229,13 +217,11 @@ void main() { test('getPinnedMessagesByCid', () async { const cid = 'testCid'; final messages = List.generate(3, (index) => Message()); - when(() => mockDatabase.pinnedMessageDao.getMessagesByCid(cid)) - .thenAnswer((_) async => messages); + when(() => mockDatabase.pinnedMessageDao.getMessagesByCid(cid)).thenAnswer((_) async => messages); final fetchedMessages = await client.getPinnedMessagesByCid(cid); expect(fetchedMessages.length, messages.length); - verify(() => mockDatabase.pinnedMessageDao.getMessagesByCid(cid)) - .called(1); + verify(() => mockDatabase.pinnedMessageDao.getMessagesByCid(cid)).called(1); }); test('getChannelStateByCid', () async { @@ -260,21 +246,14 @@ void main() { ), ); - when(() => mockDatabase.draftMessageDao.getDraftMessageByCid(cid)) - .thenAnswer((_) async => draft); - - when(() => mockDatabase.memberDao.getMembersByCid(cid)) - .thenAnswer((_) async => members); - when(() => mockDatabase.readDao.getReadsByCid(cid)) - .thenAnswer((_) async => reads); - when(() => mockDatabase.channelDao.getChannelByCid(cid)) - .thenAnswer((_) async => channel); - when(() => mockDatabase.messageDao.getMessagesByCid(cid)) - .thenAnswer((_) async => messages); - when(() => mockDatabase.pinnedMessageDao.getMessagesByCid(cid)) - .thenAnswer((_) async => messages); - when(() => mockDatabase.draftMessageDao.getDraftMessageByCid(cid)) - .thenAnswer((_) async => draft); + when(() => mockDatabase.draftMessageDao.getDraftMessageByCid(cid)).thenAnswer((_) async => draft); + + when(() => mockDatabase.memberDao.getMembersByCid(cid)).thenAnswer((_) async => members); + when(() => mockDatabase.readDao.getReadsByCid(cid)).thenAnswer((_) async => reads); + when(() => mockDatabase.channelDao.getChannelByCid(cid)).thenAnswer((_) async => channel); + when(() => mockDatabase.messageDao.getMessagesByCid(cid)).thenAnswer((_) async => messages); + when(() => mockDatabase.pinnedMessageDao.getMessagesByCid(cid)).thenAnswer((_) async => messages); + when(() => mockDatabase.draftMessageDao.getDraftMessageByCid(cid)).thenAnswer((_) async => draft); final fetchedChannelState = await client.getChannelStateByCid(cid); expect(fetchedChannelState.messages?.length, messages.length); @@ -288,10 +267,8 @@ void main() { verify(() => mockDatabase.readDao.getReadsByCid(cid)).called(1); verify(() => mockDatabase.channelDao.getChannelByCid(cid)).called(1); verify(() => mockDatabase.messageDao.getMessagesByCid(cid)).called(1); - verify(() => mockDatabase.pinnedMessageDao.getMessagesByCid(cid)) - .called(1); - verify(() => mockDatabase.draftMessageDao.getDraftMessageByCid(cid)) - .called(1); + verify(() => mockDatabase.pinnedMessageDao.getMessagesByCid(cid)).called(1); + verify(() => mockDatabase.draftMessageDao.getDraftMessageByCid(cid)).called(1); }); group('getChannelState', () { @@ -321,18 +298,15 @@ void main() { ) .toList(growable: false); - when(() => mockDatabase.channelQueryDao.getChannels()) - .thenAnswer((_) async => channels); - when(() => mockDatabase.memberDao.getMembersByCid(cid)) - .thenAnswer((_) async => members); - when(() => mockDatabase.readDao.getReadsByCid(cid)) - .thenAnswer((_) async => reads); - when(() => mockDatabase.channelDao.getChannelByCid(cid)) - .thenAnswer((_) async => channel); - when(() => mockDatabase.messageDao.getMessagesByCid(cid)) - .thenAnswer((_) async => messages); - when(() => mockDatabase.pinnedMessageDao.getMessagesByCid(cid)) - .thenAnswer((_) async => messages); + when(() => mockDatabase.channelQueryDao.getChannels()).thenAnswer((_) async => channels); + when(() => mockDatabase.memberDao.getMembersByCid(cid)).thenAnswer((_) async => members); + when(() => mockDatabase.readDao.getReadsByCid(cid)).thenAnswer((_) async => reads); + when(() => mockDatabase.channelDao.getChannelByCid(cid)).thenAnswer((_) async => channel); + when( + () => mockDatabase.messageDao.getMessagesByCid(cid, messagePagination: any(named: 'messagePagination')), + ).thenAnswer((_) async => messages); + when(() => mockDatabase.pinnedMessageDao.getMessagesByCid(cid)).thenAnswer((_) async => messages); + when(() => mockDatabase.draftMessageDao.getDraftMessageByCid(cid)).thenAnswer((_) async => null); final fetchedChannelStates = await client.getChannelStates(); expect(fetchedChannelStates.length, channelStates.length); @@ -342,8 +316,7 @@ void main() { final fetched = fetchedChannelStates[i]; expect(fetched.members?.length, original.members?.length); expect(fetched.messages?.length, original.messages?.length); - expect( - fetched.pinnedMessages?.length, original.pinnedMessages?.length); + expect(fetched.pinnedMessages?.length, original.pinnedMessages?.length); expect(fetched.read?.length, original.read?.length); expect(fetched.channel!.cid, original.channel!.cid); } @@ -352,90 +325,74 @@ void main() { verify(() => mockDatabase.memberDao.getMembersByCid(cid)).called(3); verify(() => mockDatabase.readDao.getReadsByCid(cid)).called(3); verify(() => mockDatabase.channelDao.getChannelByCid(cid)).called(3); - verify(() => mockDatabase.messageDao.getMessagesByCid(cid)).called(3); - verify(() => mockDatabase.pinnedMessageDao.getMessagesByCid(cid)) - .called(3); + verify( + () => mockDatabase.messageDao.getMessagesByCid(cid, messagePagination: any(named: 'messagePagination')), + ).called(3); + verify(() => mockDatabase.pinnedMessageDao.getMessagesByCid(cid)).called(3); + verify(() => mockDatabase.draftMessageDao.getDraftMessageByCid(cid)).called(3); }); }); test('updateChannelQueries', () async { final filter = Filter.in_('members', const ['testUserId']); const cids = []; - when(() => - mockDatabase.channelQueryDao.updateChannelQueries(filter, cids)) - .thenAnswer((_) => Future.value()); + when(() => mockDatabase.channelQueryDao.updateChannelQueries(filter, cids)).thenAnswer((_) => Future.value()); await client.updateChannelQueries(filter, cids); - verify(() => - mockDatabase.channelQueryDao.updateChannelQueries(filter, cids)) - .called(1); + verify(() => mockDatabase.channelQueryDao.updateChannelQueries(filter, cids)).called(1); }); test('deleteMessageById', () async { const messageId = 'testMessageId'; - when(() => mockDatabase.messageDao.deleteMessageByIds([messageId])) - .thenAnswer((_) async => 1); + when(() => mockDatabase.messageDao.deleteMessageByIds([messageId])).thenAnswer((_) async => 1); await client.deleteMessageById(messageId); - verify(() => mockDatabase.messageDao.deleteMessageByIds([messageId])) - .called(1); + verify(() => mockDatabase.messageDao.deleteMessageByIds([messageId])).called(1); }); test('deletePinnedMessageById', () async { const messageId = 'testMessageId'; - when(() => mockDatabase.pinnedMessageDao.deleteMessageByIds([messageId])) - .thenAnswer((_) async => 1); + when(() => mockDatabase.pinnedMessageDao.deleteMessageByIds([messageId])).thenAnswer((_) async => 1); await client.deletePinnedMessageById(messageId); - verify(() => - mockDatabase.pinnedMessageDao.deleteMessageByIds([messageId])) - .called(1); + verify(() => mockDatabase.pinnedMessageDao.deleteMessageByIds([messageId])).called(1); }); test('deleteMessageByIds', () async { const messageIds = []; - when(() => mockDatabase.messageDao.deleteMessageByIds(messageIds)) - .thenAnswer((_) async => 1); + when(() => mockDatabase.messageDao.deleteMessageByIds(messageIds)).thenAnswer((_) async => 1); await client.deleteMessageByIds(messageIds); - verify(() => mockDatabase.messageDao.deleteMessageByIds(messageIds)) - .called(1); + verify(() => mockDatabase.messageDao.deleteMessageByIds(messageIds)).called(1); }); test('deletePinnedMessageByIds', () async { const messageIds = []; - when(() => mockDatabase.pinnedMessageDao.deleteMessageByIds(messageIds)) - .thenAnswer((_) async => 1); + when(() => mockDatabase.pinnedMessageDao.deleteMessageByIds(messageIds)).thenAnswer((_) async => 1); await client.deletePinnedMessageByIds(messageIds); - verify(() => mockDatabase.pinnedMessageDao.deleteMessageByIds(messageIds)) - .called(1); + verify(() => mockDatabase.pinnedMessageDao.deleteMessageByIds(messageIds)).called(1); }); test('deleteMessageByCid', () async { const cid = 'testCid'; - when(() => mockDatabase.messageDao.deleteMessageByCids([cid])) - .thenAnswer((_) async => 1); + when(() => mockDatabase.messageDao.deleteMessageByCids([cid])).thenAnswer((_) async => 1); await client.deleteMessageByCid(cid); - verify(() => mockDatabase.messageDao.deleteMessageByCids([cid])) - .called(1); + verify(() => mockDatabase.messageDao.deleteMessageByCids([cid])).called(1); }); test('deletePinnedMessageByCid', () async { const cid = 'testCid'; - when(() => mockDatabase.pinnedMessageDao.deleteMessageByCids([cid])) - .thenAnswer((_) async => 1); + when(() => mockDatabase.pinnedMessageDao.deleteMessageByCids([cid])).thenAnswer((_) async => 1); await client.deletePinnedMessageByCid(cid); - verify(() => mockDatabase.pinnedMessageDao.deleteMessageByCids([cid])) - .called(1); + verify(() => mockDatabase.pinnedMessageDao.deleteMessageByCids([cid])).called(1); }); test('deleteMessageByCids', () async { const cids = []; - when(() => mockDatabase.messageDao.deleteMessageByCids(cids)) - .thenAnswer((_) async => 1); + when(() => mockDatabase.messageDao.deleteMessageByCids(cids)).thenAnswer((_) async => 1); await client.deleteMessageByCids(cids); verify(() => mockDatabase.messageDao.deleteMessageByCids(cids)).called(1); @@ -443,18 +400,15 @@ void main() { test('deletePinnedMessageByCids', () async { const cids = []; - when(() => mockDatabase.pinnedMessageDao.deleteMessageByCids(cids)) - .thenAnswer((_) async => 1); + when(() => mockDatabase.pinnedMessageDao.deleteMessageByCids(cids)).thenAnswer((_) async => 1); await client.deletePinnedMessageByCids(cids); - verify(() => mockDatabase.pinnedMessageDao.deleteMessageByCids(cids)) - .called(1); + verify(() => mockDatabase.pinnedMessageDao.deleteMessageByCids(cids)).called(1); }); test('deleteChannels', () async { const cids = []; - when(() => mockDatabase.channelDao.deleteChannelByCids(cids)) - .thenAnswer((_) async => 1); + when(() => mockDatabase.channelDao.deleteChannelByCids(cids)).thenAnswer((_) async => 1); await client.deleteChannels(cids); verify(() => mockDatabase.channelDao.deleteChannelByCids(cids)).called(1); @@ -464,12 +418,10 @@ void main() { const cid = 'testCid'; final messages = List.generate(3, (index) => Message()); - when(() => mockDatabase.messageDao.bulkUpdateMessages({cid: messages})) - .thenAnswer((_) => Future.value()); + when(() => mockDatabase.messageDao.bulkUpdateMessages({cid: messages})).thenAnswer((_) => Future.value()); await client.updateMessages(cid, messages); - verify(() => mockDatabase.messageDao.bulkUpdateMessages({cid: messages})) - .called(1); + verify(() => mockDatabase.messageDao.bulkUpdateMessages({cid: messages})).called(1); }); test('updatePinnedMessages', () async { @@ -487,8 +439,7 @@ void main() { test('getChannelThreads', () async { const cid = 'testCid'; - final messages = - List.generate(3, (index) => Message(parentId: 'testParentId$index')); + final messages = List.generate(3, (index) => Message(parentId: 'testParentId$index')); final threads = messages.fold>>( {}, (prev, curr) => prev @@ -498,8 +449,7 @@ void main() { ifAbsent: () => [], ), ); - when(() => mockDatabase.messageDao.getThreadMessages(cid)) - .thenAnswer((realInvocation) async => messages); + when(() => mockDatabase.messageDao.getThreadMessages(cid)).thenAnswer((realInvocation) async => messages); final fetchedThreads = await client.getChannelThreads(cid); expect(fetchedThreads.length, threads.length); @@ -515,8 +465,7 @@ void main() { test('updateChannels', () async { const cid = 'testType:testId'; final channels = List.generate(3, (index) => ChannelModel(cid: cid)); - when(() => mockDatabase.channelDao.updateChannels(channels)) - .thenAnswer((_) => Future.value()); + when(() => mockDatabase.channelDao.updateChannels(channels)).thenAnswer((_) => Future.value()); await client.updateChannels(channels); verify(() => mockDatabase.channelDao.updateChannels(channels)).called(1); @@ -525,10 +474,8 @@ void main() { test('updatePolls', () async { const name = 'testPollName'; final options = List.generate(3, (index) => PollOption(text: '$index')); - final polls = - List.generate(3, (index) => Poll(name: name, options: options)); - when(() => mockDatabase.pollDao.updatePolls(polls)) - .thenAnswer((_) => Future.value()); + final polls = List.generate(3, (index) => Poll(name: name, options: options)); + when(() => mockDatabase.pollDao.updatePolls(polls)).thenAnswer((_) => Future.value()); await client.updatePolls(polls); verify(() => mockDatabase.pollDao.updatePolls(polls)).called(1); @@ -536,43 +483,35 @@ void main() { test('deletePollsByIds', () async { final pollIds = ['testPollId']; - when(() => mockDatabase.pollDao.deletePollsByIds(pollIds)) - .thenAnswer((_) => Future.value()); + when(() => mockDatabase.pollDao.deletePollsByIds(pollIds)).thenAnswer((_) => Future.value()); await client.deletePollsByIds(pollIds); verify(() => mockDatabase.pollDao.deletePollsByIds(pollIds)).called(1); }); test('updatePollVotes', () async { - final pollVotes = List.generate( - 3, (index) => PollVote(id: '$index', optionId: 'testOptionId$index')); - when(() => mockDatabase.pollVoteDao.updatePollVotes(pollVotes)) - .thenAnswer((_) => Future.value()); + final pollVotes = List.generate(3, (index) => PollVote(id: '$index', optionId: 'testOptionId$index')); + when(() => mockDatabase.pollVoteDao.updatePollVotes(pollVotes)).thenAnswer((_) => Future.value()); await client.updatePollVotes(pollVotes); - verify(() => mockDatabase.pollVoteDao.updatePollVotes(pollVotes)) - .called(1); + verify(() => mockDatabase.pollVoteDao.updatePollVotes(pollVotes)).called(1); }); test('deletePollVotesByPollIds', () async { final pollIds = ['testPollId']; - when(() => mockDatabase.pollVoteDao.deletePollVotesByPollIds(pollIds)) - .thenAnswer((_) => Future.value()); + when(() => mockDatabase.pollVoteDao.deletePollVotesByPollIds(pollIds)).thenAnswer((_) => Future.value()); await client.deletePollVotesByPollIds(pollIds); - verify(() => mockDatabase.pollVoteDao.deletePollVotesByPollIds(pollIds)) - .called(1); + verify(() => mockDatabase.pollVoteDao.deletePollVotesByPollIds(pollIds)).called(1); }); test('updateMembers', () async { const cid = 'testCid'; final members = List.generate(3, (index) => Member()); - when(() => mockDatabase.memberDao.bulkUpdateMembers({cid: members})) - .thenAnswer((_) => Future.value()); + when(() => mockDatabase.memberDao.bulkUpdateMembers({cid: members})).thenAnswer((_) => Future.value()); await client.updateMembers(cid, members); - verify(() => mockDatabase.memberDao.bulkUpdateMembers({cid: members})) - .called(1); + verify(() => mockDatabase.memberDao.bulkUpdateMembers({cid: members})).called(1); }); test('updateReads', () async { @@ -585,18 +524,15 @@ void main() { lastReadMessageId: 'lastMessageId$index', ), ); - when(() => mockDatabase.readDao.bulkUpdateReads({cid: reads})) - .thenAnswer((_) => Future.value()); + when(() => mockDatabase.readDao.bulkUpdateReads({cid: reads})).thenAnswer((_) => Future.value()); await client.updateReads(cid, reads); - verify(() => mockDatabase.readDao.bulkUpdateReads({cid: reads})) - .called(1); + verify(() => mockDatabase.readDao.bulkUpdateReads({cid: reads})).called(1); }); test('updateUsers', () async { final users = List.generate(3, (index) => User(id: 'testUserId$index')); - when(() => mockDatabase.userDao.updateUsers(users)) - .thenAnswer((_) => Future.value()); + when(() => mockDatabase.userDao.updateUsers(users)).thenAnswer((_) => Future.value()); await client.updateUsers(users); verify(() => mockDatabase.userDao.updateUsers(users)).called(1); @@ -607,12 +543,10 @@ void main() { 3, (index) => Reaction(type: 'testType$index'), ); - when(() => mockDatabase.reactionDao.updateReactions(reactions)) - .thenAnswer((_) => Future.value()); + when(() => mockDatabase.reactionDao.updateReactions(reactions)).thenAnswer((_) => Future.value()); await client.updateReactions(reactions); - verify(() => mockDatabase.reactionDao.updateReactions(reactions)) - .called(1); + verify(() => mockDatabase.reactionDao.updateReactions(reactions)).called(1); }); test('updatePinnedMessageReactions', () async { @@ -620,43 +554,33 @@ void main() { 3, (index) => Reaction(type: 'testType$index'), ); - when(() => - mockDatabase.pinnedMessageReactionDao.updateReactions(reactions)) - .thenAnswer((_) => Future.value()); + when(() => mockDatabase.pinnedMessageReactionDao.updateReactions(reactions)).thenAnswer((_) => Future.value()); await client.updatePinnedMessageReactions(reactions); - verify(() => - mockDatabase.pinnedMessageReactionDao.updateReactions(reactions)) - .called(1); + verify(() => mockDatabase.pinnedMessageReactionDao.updateReactions(reactions)).called(1); }); test('deleteReactionsByMessageId', () async { final messageIds = []; - when(() => - mockDatabase.reactionDao.deleteReactionsByMessageIds(messageIds)) - .thenAnswer((_) => Future.value()); + when(() => mockDatabase.reactionDao.deleteReactionsByMessageIds(messageIds)).thenAnswer((_) => Future.value()); await client.deleteReactionsByMessageId(messageIds); - verify(() => - mockDatabase.reactionDao.deleteReactionsByMessageIds(messageIds)) - .called(1); + verify(() => mockDatabase.reactionDao.deleteReactionsByMessageIds(messageIds)).called(1); }); test('deletePinnedMessageReactionsByMessageId', () async { final messageIds = []; - when(() => mockDatabase.pinnedMessageReactionDao - .deleteReactionsByMessageIds(messageIds)) - .thenAnswer((_) => Future.value()); + when( + () => mockDatabase.pinnedMessageReactionDao.deleteReactionsByMessageIds(messageIds), + ).thenAnswer((_) => Future.value()); await client.deletePinnedMessageReactionsByMessageId(messageIds); - verify(() => mockDatabase.pinnedMessageReactionDao - .deleteReactionsByMessageIds(messageIds)).called(1); + verify(() => mockDatabase.pinnedMessageReactionDao.deleteReactionsByMessageIds(messageIds)).called(1); }); test('deleteMembersByCids', () async { final cids = []; - when(() => mockDatabase.memberDao.deleteMemberByCids(cids)) - .thenAnswer((_) => Future.value()); + when(() => mockDatabase.memberDao.deleteMemberByCids(cids)).thenAnswer((_) => Future.value()); await client.deleteMembersByCids(cids); verify(() => mockDatabase.memberDao.deleteMemberByCids(cids)).called(1); @@ -664,12 +588,10 @@ void main() { test('deleteDraftMessagesByCids', () async { final cids = []; - when(() => mockDatabase.draftMessageDao.deleteDraftMessagesByCids(cids)) - .thenAnswer((_) => Future.value()); + when(() => mockDatabase.draftMessageDao.deleteDraftMessagesByCids(cids)).thenAnswer((_) => Future.value()); await client.deleteDraftMessagesByCids(cids); - verify(() => mockDatabase.draftMessageDao.deleteDraftMessagesByCids(cids)) - .called(1); + verify(() => mockDatabase.draftMessageDao.deleteDraftMessagesByCids(cids)).called(1); }); test('getDraftMessageByCid', () async { @@ -685,16 +607,14 @@ void main() { ), ); - when(() => mockDatabase.draftMessageDao.getDraftMessageByCid(cid)) - .thenAnswer((_) async => draft); + when(() => mockDatabase.draftMessageDao.getDraftMessageByCid(cid)).thenAnswer((_) async => draft); final fetchedDraft = await client.getDraftMessageByCid(cid); expect(fetchedDraft, isNotNull); expect(fetchedDraft!.channelCid, cid); expect(fetchedDraft.message.id, draft.message.id); expect(fetchedDraft.message.text, draft.message.text); - verify(() => mockDatabase.draftMessageDao.getDraftMessageByCid(cid)) - .called(1); + verify(() => mockDatabase.draftMessageDao.getDraftMessageByCid(cid)).called(1); }); test('updateDraftMessages', () async { @@ -710,24 +630,242 @@ void main() { ), ); - when(() => mockDatabase.draftMessageDao.updateDraftMessages(drafts)) - .thenAnswer((_) async {}); + when(() => mockDatabase.draftMessageDao.updateDraftMessages(drafts)).thenAnswer((_) async {}); await client.updateDraftMessages(drafts); - verify(() => mockDatabase.draftMessageDao.updateDraftMessages(drafts)) - .called(1); + verify(() => mockDatabase.draftMessageDao.updateDraftMessages(drafts)).called(1); }); test('deleteDraftMessageByCid', () async { const cid = 'testCid'; const parentId = 'testParentId'; - when(() => mockDatabase.draftMessageDao.deleteDraftMessageByCid(cid, - parentId: parentId)).thenAnswer((_) async {}); + when( + () => mockDatabase.draftMessageDao.deleteDraftMessageByCid(cid, parentId: parentId), + ).thenAnswer((_) async {}); await client.deleteDraftMessageByCid(cid, parentId: parentId); - verify(() => mockDatabase.draftMessageDao - .deleteDraftMessageByCid(cid, parentId: parentId)).called(1); + verify(() => mockDatabase.draftMessageDao.deleteDraftMessageByCid(cid, parentId: parentId)).called(1); + }); + + test('getLocationsByCid', () async { + const cid = 'testCid'; + final locations = List.generate( + 3, + (index) => Location( + channelCid: cid, + messageId: 'testMessageId$index', + userId: 'testUserId$index', + latitude: 37.7749 + index * 0.001, + longitude: -122.4194 + index * 0.001, + createdByDeviceId: 'testDevice$index', + createdAt: DateTime.now(), + updatedAt: DateTime.now(), + ), + ); + + when(() => mockDatabase.locationDao.getLocationsByCid(cid)).thenAnswer((_) async => locations); + + final fetchedLocations = await client.getLocationsByCid(cid); + expect(fetchedLocations.length, locations.length); + verify(() => mockDatabase.locationDao.getLocationsByCid(cid)).called(1); + }); + + test('getLocationByMessageId', () async { + const messageId = 'testMessageId'; + final location = Location( + channelCid: 'testCid', + messageId: messageId, + userId: 'testUserId', + latitude: 37.7749, + longitude: -122.4194, + createdByDeviceId: 'testDevice', + createdAt: DateTime.now(), + updatedAt: DateTime.now(), + ); + + when(() => mockDatabase.locationDao.getLocationByMessageId(messageId)).thenAnswer((_) async => location); + + final fetchedLocation = await client.getLocationByMessageId(messageId); + expect(fetchedLocation, isNotNull); + expect(fetchedLocation!.messageId, messageId); + verify(() => mockDatabase.locationDao.getLocationByMessageId(messageId)).called(1); + }); + + test('updateLocations', () async { + final locations = List.generate( + 3, + (index) => Location( + channelCid: 'testCid$index', + messageId: 'testMessageId$index', + userId: 'testUserId$index', + latitude: 37.7749 + index * 0.001, + longitude: -122.4194 + index * 0.001, + createdByDeviceId: 'testDevice$index', + createdAt: DateTime.now(), + updatedAt: DateTime.now(), + ), + ); + + when(() => mockDatabase.locationDao.updateLocations(locations)).thenAnswer((_) async {}); + + await client.updateLocations(locations); + verify(() => mockDatabase.locationDao.updateLocations(locations)).called(1); + }); + + test('deleteLocationsByCid', () async { + const cid = 'testCid'; + when(() => mockDatabase.locationDao.deleteLocationsByCid(cid)).thenAnswer((_) async {}); + + await client.deleteLocationsByCid(cid); + verify(() => mockDatabase.locationDao.deleteLocationsByCid(cid)).called(1); + }); + + test('deleteLocationsByMessageIds', () async { + final messageIds = ['testMessageId1', 'testMessageId2']; + when( + () => mockDatabase.locationDao.deleteLocationsByMessageIds(messageIds), + ).thenAnswer((_) async {}); + + await client.deleteLocationsByMessageIds(messageIds); + verify( + () => mockDatabase.locationDao.deleteLocationsByMessageIds(messageIds), + ).called(1); + }); + + group('deleteMessagesFromUser', () { + const userId = 'testUserId'; + const cid = 'testCid'; + + test('calls deleteMessagesByUser on both DAOs with hard delete', () async { + when( + () => mockDatabase.messageDao.deleteMessagesByUser( + cid: cid, + userId: userId, + hardDelete: true, + deletedAt: any(named: 'deletedAt'), + ), + ).thenAnswer((_) async => 1); + + when( + () => mockDatabase.pinnedMessageDao.deleteMessagesByUser( + cid: cid, + userId: userId, + hardDelete: true, + deletedAt: any(named: 'deletedAt'), + ), + ).thenAnswer((_) async => 1); + + await client.deleteMessagesFromUser( + cid: cid, + userId: userId, + hardDelete: true, + ); + + verify( + () => mockDatabase.messageDao.deleteMessagesByUser( + cid: cid, + userId: userId, + hardDelete: true, + deletedAt: any(named: 'deletedAt'), + ), + ).called(1); + + verify( + () => mockDatabase.pinnedMessageDao.deleteMessagesByUser( + cid: cid, + userId: userId, + hardDelete: true, + deletedAt: any(named: 'deletedAt'), + ), + ).called(1); + }); + + test('calls deleteMessagesByUser on both DAOs with soft delete', () async { + final deletedAt = DateTime.now(); + + when( + () => mockDatabase.messageDao.deleteMessagesByUser( + cid: cid, + userId: userId, + hardDelete: false, + deletedAt: deletedAt, + ), + ).thenAnswer((_) async => 1); + + when( + () => mockDatabase.pinnedMessageDao.deleteMessagesByUser( + cid: cid, + userId: userId, + hardDelete: false, + deletedAt: deletedAt, + ), + ).thenAnswer((_) async => 1); + + await client.deleteMessagesFromUser( + cid: cid, + userId: userId, + hardDelete: false, + deletedAt: deletedAt, + ); + + verify( + () => mockDatabase.messageDao.deleteMessagesByUser( + cid: cid, + userId: userId, + hardDelete: false, + deletedAt: deletedAt, + ), + ).called(1); + + verify( + () => mockDatabase.pinnedMessageDao.deleteMessagesByUser( + cid: cid, + userId: userId, + hardDelete: false, + deletedAt: deletedAt, + ), + ).called(1); + }); + + test('calls deleteMessagesByUser without cid when cid is null', () async { + when( + () => mockDatabase.messageDao.deleteMessagesByUser( + userId: userId, + hardDelete: true, + deletedAt: any(named: 'deletedAt'), + ), + ).thenAnswer((_) async => 1); + + when( + () => mockDatabase.pinnedMessageDao.deleteMessagesByUser( + userId: userId, + hardDelete: true, + deletedAt: any(named: 'deletedAt'), + ), + ).thenAnswer((_) async => 1); + + await client.deleteMessagesFromUser( + userId: userId, + hardDelete: true, + ); + + verify( + () => mockDatabase.messageDao.deleteMessagesByUser( + userId: userId, + hardDelete: true, + deletedAt: any(named: 'deletedAt'), + ), + ).called(1); + + verify( + () => mockDatabase.pinnedMessageDao.deleteMessagesByUser( + userId: userId, + hardDelete: true, + deletedAt: any(named: 'deletedAt'), + ), + ).called(1); + }); }); tearDown(() async { diff --git a/pubspec.lock b/pubspec.lock index 9eed33197b..b4283bfbc5 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -5,18 +5,18 @@ packages: dependency: transitive description: name: _fe_analyzer_shared - sha256: e55636ed79578b9abca5fecf9437947798f5ef7456308b5cb85720b793eac92f + sha256: "8d7ff3948166b8ec5da0fbb5962000926b8e02f2ed9b3e51d1738905fbd4c98d" url: "https://pub.dev" source: hosted - version: "82.0.0" + version: "93.0.0" analyzer: dependency: transitive description: name: analyzer - sha256: "13c1e6c6fd460522ea840abec3f677cc226f5fec7872c04ad7b425517ccf54f7" + sha256: de7148ed2fcec579b19f122c1800933dfa028f6d9fd38a152b04b1516cec120b url: "https://pub.dev" source: hosted - version: "7.4.4" + version: "10.0.1" ansi_styles: dependency: transitive description: @@ -61,10 +61,10 @@ packages: dependency: transitive description: name: built_value - sha256: ea90e81dc4a25a043d9bee692d20ed6d1c4a1662a28c03a96417446c093ed6b4 + sha256: "7931c90b84bc573fef103548e354258ae4c9d28d140e41961df6843c5d60d4d8" url: "https://pub.dev" source: hosted - version: "8.9.5" + version: "8.12.3" charcode: dependency: transitive description: @@ -77,18 +77,18 @@ packages: dependency: transitive description: name: checked_yaml - sha256: feb6bed21949061731a7a75fc5d2aa727cf160b91af9a3e464c5e3a32e28b5ff + sha256: "959525d3162f249993882720d52b7e0c833978df229be20702b33d48d91de70f" url: "https://pub.dev" source: hosted - version: "2.0.3" + version: "2.0.4" cli_launcher: dependency: transitive description: name: cli_launcher - sha256: "5e7e0282b79e8642edd6510ee468ae2976d847a0a29b3916e85f5fa1bfe24005" + sha256: "17d2744fb9a254c49ec8eda582536abe714ea0131533e24389843a4256f82eac" url: "https://pub.dev" source: hosted - version: "0.3.1" + version: "0.3.2+1" cli_util: dependency: transitive description: @@ -97,22 +97,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.4.2" - clock: - dependency: transitive - description: - name: clock - sha256: fddb70d9b5277016c77a80201021d40a2247104d9f4aa7bab7157b7e3f05b84b - url: "https://pub.dev" - source: hosted - version: "1.1.2" code_builder: dependency: "direct dev" description: name: code_builder - sha256: "0ec10bf4a89e4c613960bf1e8b42c64127021740fb21640c29c909826a5eea3e" + sha256: "6a6cab2ba4680d6423f34a9b972a4c9a94ebe1b62ecec4e1a1f2cba91fd1319d" url: "https://pub.dev" source: hosted - version: "4.10.1" + version: "4.11.1" collection: dependency: transitive description: @@ -125,10 +117,10 @@ packages: dependency: transitive description: name: conventional_commit - sha256: fad254feb6fb8eace2be18855176b0a4b97e0d50e416ff0fe590d5ba83735d34 + sha256: c40b1b449ce2a63fa2ce852f35e3890b1e182f5951819934c0e4a66254bc0dc3 url: "https://pub.dev" source: hosted - version: "0.6.1" + version: "0.6.1+1" convert: dependency: transitive description: @@ -141,18 +133,18 @@ packages: dependency: transitive description: name: crypto - sha256: "1e445881f28f22d6140f181e07737b22f1e099a5e1ff94b0af2f9e4a463f4855" + sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf url: "https://pub.dev" source: hosted - version: "3.0.6" + version: "3.0.7" dart_style: dependency: "direct dev" description: name: dart_style - sha256: "27eb0ae77836989a3bc541ce55595e8ceee0992807f14511552a898ddd0d88ac" + sha256: "15a7db352c8fc6a4d2bc475ba901c25b39fe7157541da4c16eacce6f8be83e49" url: "https://pub.dev" source: hosted - version: "3.0.1" + version: "3.1.5" file: dependency: transitive description: @@ -189,10 +181,10 @@ packages: dependency: transitive description: name: http - sha256: "2c11f3f94c687ee9bad77c171151672986360b2b001d109814ee7140b2cf261b" + sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412" url: "https://pub.dev" source: hosted - version: "1.4.0" + version: "1.6.0" http_parser: dependency: transitive description: @@ -201,14 +193,6 @@ packages: url: "https://pub.dev" source: hosted version: "4.1.2" - intl: - dependency: transitive - description: - name: intl - sha256: d6f56758b7d3014a48af9701c085700aac781a92a87a62b1333b46d8879661cf - url: "https://pub.dev" - source: hosted - version: "0.19.0" io: dependency: transitive description: @@ -221,42 +205,42 @@ packages: dependency: transitive description: name: json_annotation - sha256: "1ce844379ca14835a50d2f019a3099f419082cfdd231cd86a142af94dd5c6bb1" + sha256: "805fa86df56383000f640384b282ce0cb8431f1a7a2396de92fb66186d8c57df" url: "https://pub.dev" source: hosted - version: "4.9.0" + version: "4.10.0" matcher: dependency: transitive description: name: matcher - sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2 + sha256: "12956d0ad8390bbcc63ca2e1469c0619946ccb52809807067a7020d57e647aa6" url: "https://pub.dev" source: hosted - version: "0.12.17" + version: "0.12.18" melos: dependency: "direct dev" description: name: melos - sha256: "3f3ab3f902843d1e5a1b1a4dd39a4aca8ba1056f2d32fd8995210fa2843f646f" + sha256: "4280dc46bd5b741887cce1e67e5c1a6aaf3c22310035cf5bd33dceeeda62ed22" url: "https://pub.dev" source: hosted - version: "6.3.2" + version: "6.3.3" meta: dependency: transitive description: name: meta - sha256: e3641ec5d63ebf0d9b41bd43201a66e3fc79a65db5f61fc181f04cd27aab950c + sha256: "9f29b9bcc8ee287b1a31e0d01be0eae99a930dbffdaecf04b3f3d82a969f296f" url: "https://pub.dev" source: hosted - version: "1.16.0" + version: "1.18.1" mustache_template: dependency: transitive description: name: mustache_template - sha256: a46e26f91445bfb0b60519be280555b06792460b27b19e2b19ad5b9740df5d1c + sha256: "4326d0002ff58c74b9486990ccbdab08157fca3c996fe9e197aff9d61badf307" url: "https://pub.dev" source: hosted - version: "2.0.0" + version: "2.0.3" package_config: dependency: transitive description: @@ -285,18 +269,18 @@ packages: dependency: transitive description: name: pool - sha256: "20fe868b6314b322ea036ba325e6fc0711a22948856475e2c2b6306e8ab39c2a" + sha256: "978783255c543aa3586a1b3c21f6e9d720eb315376a915872c61ef8b5c20177d" url: "https://pub.dev" source: hosted - version: "1.5.1" + version: "1.5.2" process: dependency: transitive description: name: process - sha256: "107d8be718f120bbba9dcd1e95e3bd325b1b4a4f07db64154635ba03f2567a0d" + sha256: c6248e4526673988586e8c00bb22a49210c258dc91df5227d5da9748ecf79744 url: "https://pub.dev" source: hosted - version: "5.0.3" + version: "5.0.5" prompts: dependency: transitive description: @@ -317,10 +301,10 @@ packages: dependency: transitive description: name: pub_updater - sha256: "54e8dc865349059ebe7f163d6acce7c89eb958b8047e6d6e80ce93b13d7c9e60" + sha256: "739a0161d73a6974c0675b864fb0cf5147305f7b077b7f03a58fa7a9ab3e7e7d" url: "https://pub.dev" source: hosted - version: "0.4.0" + version: "0.5.0" pubspec_parse: dependency: transitive description: @@ -381,10 +365,10 @@ packages: dependency: transitive description: name: test_api - sha256: fb31f383e2ee25fbbfe06b40fe21e1e458d14080e3c67e7ba0acfde4df4e0bbd + sha256: "93167629bfc610f71560ab9312acdda4959de4df6fac7492c89ff0d3886f6636" url: "https://pub.dev" source: hosted - version: "0.7.4" + version: "0.7.9" typed_data: dependency: transitive description: @@ -397,10 +381,10 @@ packages: dependency: transitive description: name: watcher - sha256: "69da27e49efa56a15f8afe8f4438c4ec02eff0a117df1b22ea4aad194fe1c104" + sha256: "1398c9f081a753f9226febe8900fce8f7d0a67163334e1c94a2438339d79d635" url: "https://pub.dev" source: hosted - version: "1.1.1" + version: "1.2.1" web: dependency: transitive description: @@ -421,9 +405,9 @@ packages: dependency: transitive description: name: yaml_edit - sha256: fb38626579fb345ad00e674e2af3a5c9b0cc4b9bfb8fd7f7ff322c7c9e62aef5 + sha256: ec709065bb2c911b336853b67f3732dd13e0336bd065cc2f1061d7610ddf45e3 url: "https://pub.dev" source: hosted - version: "2.2.2" + version: "2.2.3" sdks: - dart: ">=3.6.2 <4.0.0" + dart: ">=3.10.0 <4.0.0" diff --git a/pubspec.yaml b/pubspec.yaml index a65d06f2b8..2ac3430d00 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,7 +1,7 @@ name: stream_chat_flutter_workspace environment: - sdk: ^3.6.2 + sdk: ^3.10.0 dev_dependencies: code_builder: ^4.10.1 diff --git a/sample_app/android/.ruby-version b/sample_app/android/.ruby-version index fd2a01863f..3b47f2e4f8 100644 --- a/sample_app/android/.ruby-version +++ b/sample_app/android/.ruby-version @@ -1 +1 @@ -3.1.0 +3.3.9 diff --git a/sample_app/android/app/build.gradle b/sample_app/android/app/build.gradle index 864d5e177c..13ba100039 100644 --- a/sample_app/android/app/build.gradle +++ b/sample_app/android/app/build.gradle @@ -25,7 +25,7 @@ android { defaultConfig { applicationId "io.getstream.chat.android.flutter.sample" - minSdkVersion Math.max(flutter.minSdkVersion, 23) + minSdkVersion Math.max(flutter.minSdkVersion, 24) targetSdkVersion flutter.targetSdkVersion versionCode flutter.versionCode versionName flutter.versionName diff --git a/sample_app/android/app/src/main/AndroidManifest.xml b/sample_app/android/app/src/main/AndroidManifest.xml index 8ff8f0abb5..28a3985094 100644 --- a/sample_app/android/app/src/main/AndroidManifest.xml +++ b/sample_app/android/app/src/main/AndroidManifest.xml @@ -7,6 +7,18 @@ + + + + + + + + + + + + ???? CFBundleVersion 1.0 - MinimumOSVersion - 12.0 diff --git a/sample_app/ios/Podfile b/sample_app/ios/Podfile index 10f3c9b470..f17bddc919 100644 --- a/sample_app/ios/Podfile +++ b/sample_app/ios/Podfile @@ -1,5 +1,5 @@ # Uncomment this line to define a global platform for your project -platform :ios, '13.0' +platform :ios, '15.0' # CocoaPods analytics sends network stats synchronously affecting flutter build latency. ENV['COCOAPODS_DISABLE_STATS'] = 'true' diff --git a/sample_app/ios/Runner.xcodeproj/project.pbxproj b/sample_app/ios/Runner.xcodeproj/project.pbxproj index 17806dba6c..0e8b5056fb 100644 --- a/sample_app/ios/Runner.xcodeproj/project.pbxproj +++ b/sample_app/ios/Runner.xcodeproj/project.pbxproj @@ -286,6 +286,7 @@ "${BUILT_PRODUCTS_DIR}/flutter_local_notifications/flutter_local_notifications.framework", "${BUILT_PRODUCTS_DIR}/flutter_secure_storage/flutter_secure_storage.framework", "${BUILT_PRODUCTS_DIR}/gal/gal.framework", + "${BUILT_PRODUCTS_DIR}/geolocator_apple/geolocator_apple.framework", "${BUILT_PRODUCTS_DIR}/get_thumbnail_video/get_thumbnail_video.framework", "${BUILT_PRODUCTS_DIR}/image_picker_ios/image_picker_ios.framework", "${BUILT_PRODUCTS_DIR}/just_audio/just_audio.framework", @@ -329,6 +330,7 @@ "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/flutter_local_notifications.framework", "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/flutter_secure_storage.framework", "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/gal.framework", + "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/geolocator_apple.framework", "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/get_thumbnail_video.framework", "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/image_picker_ios.framework", "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/just_audio.framework", @@ -465,7 +467,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 13.0; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; MTL_ENABLE_DEBUG_INFO = NO; SDKROOT = iphoneos; SUPPORTED_PLATFORMS = iphoneos; @@ -490,7 +492,7 @@ "$(PROJECT_DIR)/Flutter", ); INFOPLIST_FILE = Runner/Info.plist; - IPHONEOS_DEPLOYMENT_TARGET = 13.0; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -554,7 +556,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 13.0; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; MTL_ENABLE_DEBUG_INFO = YES; ONLY_ACTIVE_ARCH = YES; SDKROOT = iphoneos; @@ -602,7 +604,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 13.0; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; MTL_ENABLE_DEBUG_INFO = NO; SDKROOT = iphoneos; SUPPORTED_PLATFORMS = iphoneos; @@ -628,7 +630,7 @@ "$(PROJECT_DIR)/Flutter", ); INFOPLIST_FILE = Runner/Info.plist; - IPHONEOS_DEPLOYMENT_TARGET = 13.0; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -663,7 +665,7 @@ "$(PROJECT_DIR)/Flutter", ); INFOPLIST_FILE = Runner/Info.plist; - IPHONEOS_DEPLOYMENT_TARGET = 13.0; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", diff --git a/sample_app/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/sample_app/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme index c53e2b314e..9c12df59c6 100644 --- a/sample_app/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme +++ b/sample_app/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -26,6 +26,7 @@ buildConfiguration = "Debug" selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB" selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB" + customLLDBInitFile = "$(SRCROOT)/Flutter/ephemeral/flutter_lldbinit" shouldUseLaunchSchemeArgsEnv = "YES"> Bool { - if let messageQueue = sharedDefaults?.stringArray(forKey: "messageQueue") { - UserDefaults.standard.setValue(messageQueue, forKey: "flutter.messageQueue") - sharedDefaults?.removeObject(forKey: "messageQueue") - } - - if #available(iOS 10.0, *) { - UNUserNotificationCenter.current().delegate = self - } - - GeneratedPluginRegistrant.register(with: self) - return super.application(application, didFinishLaunchingWithOptions: launchOptions) - } - - override func applicationDidEnterBackground(_ application: UIApplication) { - if let apiKey = UserDefaults.standard.string(forKey: "flutter.KEY_API_KEY") { - sharedDefaults?.setValue(apiKey, forKey: "KEY_API_KEY") - } - - if let token = UserDefaults.standard.string(forKey: "flutter.KEY_TOKEN") { - sharedDefaults?.setValue(token, forKey: "KEY_TOKEN") - } - - if let userId = UserDefaults.standard.string(forKey: "flutter.KEY_USER_ID") { - sharedDefaults?.setValue(userId, forKey: "KEY_USER_ID") - } - } - - override func applicationWillEnterForeground(_ application: UIApplication) { - if let messageQueue = sharedDefaults?.stringArray(forKey: "messageQueue") { - UserDefaults.standard.setValue(messageQueue, forKey: "flutter.messageQueue") - sharedDefaults?.removeObject(forKey: "messageQueue") - } - } + override func application( + _ application: UIApplication, + didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? + ) -> Bool { + GeneratedPluginRegistrant.register(with: self) + // Per flutter_local_notifications' iOS setup guide. Once we own the + // delegate slot, `FlutterAppDelegate` forwards `UNUserNotificationCenter` + // callbacks to registered plugins — so firebase_messaging's foreground + // presentation options and flutter_local_notifications' + // `DarwinNotificationDetails` banner/list flags each take effect for + // their own notifications. + UNUserNotificationCenter.current().delegate = self + return super.application(application, didFinishLaunchingWithOptions: launchOptions) + } } diff --git a/sample_app/ios/Runner/Info.plist b/sample_app/ios/Runner/Info.plist index 0653a44e4d..fea7d151a0 100644 --- a/sample_app/ios/Runner/Info.plist +++ b/sample_app/ios/Runner/Info.plist @@ -27,12 +27,23 @@ ???? CFBundleVersion $(FLUTTER_BUILD_NUMBER) + ITSAppUsesNonExemptEncryption + LSRequiresIPhoneOS + NSAppTransportSecurity + + NSAllowsArbitraryLoads + + NSAppleMusicUsageDescription Used to send message attachments NSCameraUsageDescription Used to send message attachments + NSLocationWhenInUseUsageDescription + We need access to your location to share it in the chat. + NSLocationAlwaysUsageDescription + We need access to your location to share it in the chat. NSMicrophoneUsageDescription Used to send message attachments NSPhotoLibraryUsageDescription @@ -43,6 +54,7 @@ fetch remote-notification + location UILaunchStoryboardName LaunchScreen @@ -65,12 +77,5 @@ UIViewControllerBasedStatusBarAppearance - NSAppTransportSecurity - - NSAllowsArbitraryLoads - - - ITSAppUsesNonExemptEncryption - diff --git a/sample_app/ios/fastlane/Fastfile b/sample_app/ios/fastlane/Fastfile index 20f466bc78..2cdb0391c6 100644 --- a/sample_app/ios/fastlane/Fastfile +++ b/sample_app/ios/fastlane/Fastfile @@ -116,6 +116,29 @@ platform :ios do ) end + # Usage: bundle exec fastlane ios distribute_to_testflight_internal + lane :distribute_to_testflight_internal do + match_me + + current_build_number = latest_testflight_build_number( + api_key: appstore_api_key, + app_identifier: app_identifier + ) + + build_number = (current_build_number || 0).to_i + 1 + build_ipa(export_method: "app-store", build_number: build_number) + + upload_to_testflight( + api_key: appstore_api_key, + distribute_external: false, + notify_external_testers: false, + ipa: "#{root_path}/build/ios/ipa/ChatSample.ipa", + groups: ['Internal Testers'], + changelog: 'Lots of amazing new features to test out!', + skip_waiting_for_build_processing: true, + ) + end + private_lane :appstore_api_key do @appstore_api_key ||= app_store_connect_api_key( key_id: 'MT3PRT8TB7', diff --git a/sample_app/lib/app.dart b/sample_app/lib/app.dart index 7fc8ba93b8..0480f885bd 100644 --- a/sample_app/lib/app.dart +++ b/sample_app/lib/app.dart @@ -2,280 +2,30 @@ import 'dart:async'; -import 'package:firebase_core/firebase_core.dart'; -import 'package:firebase_messaging/firebase_messaging.dart'; -import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; -import 'package:flutter/scheduler.dart' hide Priority; -import 'package:flutter_local_notifications/flutter_local_notifications.dart' - hide Message; -import 'package:flutter_secure_storage/flutter_secure_storage.dart'; +import 'package:flutter/scheduler.dart'; +import 'package:flutter_local_notifications/flutter_local_notifications.dart' hide Message; import 'package:go_router/go_router.dart'; -import 'package:provider/provider.dart'; -import 'package:sample_app/firebase_options.dart'; -import 'package:sample_app/pages/choose_user_page.dart'; +import 'package:sample_app/auth/auth_controller.dart'; +import 'package:sample_app/config/sample_app_config.dart'; +import 'package:sample_app/notification/notification_service.dart'; import 'package:sample_app/pages/splash_screen.dart'; import 'package:sample_app/routes/app_routes.dart'; import 'package:sample_app/routes/routes.dart'; -import 'package:sample_app/state/init_data.dart'; -import 'package:sample_app/utils/app_config.dart'; -import 'package:sample_app/utils/local_notification_observer.dart'; -import 'package:sample_app/utils/localizations.dart'; -import 'package:sentry_flutter/sentry_flutter.dart'; +import 'package:sample_app/widgets/custom_message_actions.dart'; +import 'package:sample_app/widgets/location/location_attachment.dart'; +import 'package:sample_app/widgets/location/location_detail_dialog.dart'; import 'package:stream_chat_flutter/stream_chat_flutter.dart'; import 'package:stream_chat_localizations/stream_chat_localizations.dart'; -import 'package:stream_chat_persistence/stream_chat_persistence.dart'; import 'package:streaming_shared_preferences/streaming_shared_preferences.dart'; -// Define supported notification types -const expectedNotificationTypes = [ - EventType.messageNew, - EventType.messageUpdated, - EventType.reactionNew, - EventType.reactionUpdated, -]; - -const notificationChannelId = 'stream_GetStreamFlutterClient'; -const notificationChannelName = 'Stream Notifications'; -const notificationChannelDescription = 'Notifications for Stream messages'; - -// Define platform constants -const bool kIsIOS = bool.fromEnvironment('dart.io.is_ios'); - -// Initialize FlutterLocalNotificationsPlugin for background messages -final FlutterLocalNotificationsPlugin flutterLocalNotificationsPlugin = - FlutterLocalNotificationsPlugin(); - -/// Constructs callback for background notification handling. +/// Root widget of the sample app: boots prefs + notifications, runs +/// the router, and owns the [StreamChat] ancestor. /// -/// Will be invoked from another Isolate, that's why it's required to -/// initialize everything again: -/// - Firebase -/// - StreamChatClient -/// - StreamChatPersistenceClient -@pragma('vm:entry-point') -Future _onFirebaseBackgroundMessage(RemoteMessage message) async { - debugPrint('[onBackgroundMessage] #firebase; message: ${message.toMap()}'); - final data = message.data; - // ensure that Push Notification was sent by Stream. - if (data['sender'] != 'stream.chat') { - debugPrint('[onBackgroundMessage] #firebase; not sent by Stream'); - return; - } - final eventType = data['type']; - // ensure that Push Notification relates to a supported event type - if (!expectedNotificationTypes.contains(eventType)) { - debugPrint('[onBackgroundMessage] #firebase; unexpected type: $eventType'); - return; - } - // If you're going to use Firebase services in the background, make sure - // you call `initializeApp` before using Firebase services. - await Firebase.initializeApp( - options: DefaultFirebaseOptions.currentPlatform, - ); - // read existing user info. - String? apiKey, userId, token; - if (!kIsWeb) { - const secureStorage = FlutterSecureStorage(); - apiKey = await secureStorage.read(key: kStreamApiKey); - userId = await secureStorage.read(key: kStreamUserId); - token = await secureStorage.read(key: kStreamToken); - } - if (userId == null) { - debugPrint('[onBackgroundMessage] #firebase; user not found'); - return; - } - if (token == null) { - debugPrint('[onBackgroundMessage] #firebase; token not found'); - return; - } - final chatClient = buildStreamChatClient(apiKey ?? kDefaultStreamApiKey); - try { - await chatClient.connectUser( - User(id: userId), - token, - // do not open WS connection - connectWebSocket: false, - ); - // initialize persistence with current user - if (!chatPersistentClient.isConnected) { - await chatPersistentClient.connect(userId); - } - - final messageId = data['message_id']; - if (messageId == null) { - debugPrint('[onBackgroundMessage] #firebase; messageId not found'); - return; - } - final cid = data['cid']; - if (cid == null) { - debugPrint('[onBackgroundMessage] #firebase; cid not found'); - return; - } - // pre-cache the new message using client and persistence. - final response = await chatClient.getMessage(messageId); - await chatPersistentClient.updateMessages(cid, [response.message]); - - final title = data['title']; - final body = data['body']; - - // Show Android notification - if (!kIsWeb && !kIsIOS) { - await _showAndroidNotification( - eventType: eventType, - cid: cid, - messageId: messageId, - title: message.notification?.title ?? title ?? 'Fallback title', - body: message.notification?.body ?? body ?? 'Fallback body', - ); - } - } catch (e, stk) { - debugPrint('[onBackgroundMessage] #firebase; failed: $e; $stk'); - } -} - -/// Shows an Android notification for a background message -Future _showAndroidNotification({ - required String eventType, - required String cid, - required String messageId, - required String title, - required String body, -}) async { - try { - // Create notification channel for Android - await _createNotificationChannel(); - - // Initialize the Android notification channel - const androidNotificationDetails = AndroidNotificationDetails( - notificationChannelId, - notificationChannelName, - channelDescription: notificationChannelDescription, - importance: Importance.high, - priority: Priority.high, - showWhen: true, - enableVibration: true, - playSound: true, - // Using default sound instead of custom sound resource - // sound: RawResourceAndroidNotificationSound('notification_sound'), - // Using default app icon instead of custom icon - // icon: 'ic_notification', - ); - - const notificationDetails = NotificationDetails( - android: androidNotificationDetails, - ); - - // Initialize the plugin with click handler - // Use the default app icon for notifications - const initializationSettingsAndroid = AndroidInitializationSettings( - 'ic_notification_in_app', - ); - const initializationSettings = InitializationSettings( - android: initializationSettingsAndroid, - ); - - // Initialize the plugin with a notification click handler - await flutterLocalNotificationsPlugin.initialize( - initializationSettings, - onDidReceiveNotificationResponse: (NotificationResponse response) async { - debugPrint( - '[onBackgroundMessage] #firebase; notification clicked: ${response.payload}'); - // The payload contains the channel information (channelType:channelId) - // This will be handled when the app is opened - }, - ); - - // Generate a unique notification ID - final notificationId = (eventType + cid + messageId).hashCode; - - // Show the notification - await flutterLocalNotificationsPlugin.show( - notificationId, // Notification ID - title, // Notification title - body, // Notification body - notificationDetails, - payload: cid, - ); - - debugPrint( - '[onBackgroundMessage] #firebase; android notification shown successfully: ID=$notificationId, Title="$title"'); - } catch (e) { - debugPrint( - '[onBackgroundMessage] #firebase; failed to show notification: $e'); - } -} - -/// Creates the notification channel for Android -Future _createNotificationChannel() async { - try { - const channel = AndroidNotificationChannel( - notificationChannelId, - notificationChannelName, - description: notificationChannelDescription, - importance: Importance.high, - enableVibration: true, - playSound: true, - showBadge: true, - ); - - // Create the channel - final androidPlugin = - flutterLocalNotificationsPlugin.resolvePlatformSpecificImplementation< - AndroidFlutterLocalNotificationsPlugin>(); - - if (androidPlugin != null) { - await androidPlugin.createNotificationChannel(channel); - debugPrint( - '[onBackgroundMessage] #firebase; notification channel created'); - } else { - debugPrint( - '[onBackgroundMessage] #firebase; failed to resolve Android plugin'); - } - } catch (e) { - debugPrint( - '[onBackgroundMessage] #firebase; notification channel failed: $e'); - } -} - -final chatPersistentClient = StreamChatPersistenceClient( - logLevel: Level.SEVERE, -); - -void _sampleAppLogHandler(LogRecord record) async { - if (kDebugMode) StreamChatClient.defaultLogHandler(record); - - // report errors to sentry.io - if (record.error != null || record.stackTrace != null) { - await Sentry.captureException( - record.error, - stackTrace: record.stackTrace, - ); - } -} - -StreamChatClient buildStreamChatClient(String apiKey) { - late Level logLevel; - if (kDebugMode) { - logLevel = Level.INFO; - } else { - logLevel = Level.SEVERE; - } - return StreamChatClient( - apiKey, - logLevel: logLevel, - logHandlerFunction: _sampleAppLogHandler, - retryPolicy: RetryPolicy( - maxRetryAttempts: 3, - shouldRetry: (client, attempt, error) { - return error is StreamChatNetworkError && error.isRetriable; - }, - ), - //baseURL: 'http://:3030', - //baseWsUrl: 'ws://:8800', - )..chatPersistenceClient = chatPersistentClient; -} - +/// [StreamChat] stays mounted whenever [AuthController.client] is +/// non-null so auth-gated routes keep their ancestor across +/// logout/login transitions. Before the first connect, the login +/// screens run under a plain [StreamChatTheme]. class StreamChatSampleApp extends StatefulWidget { const StreamChatSampleApp({super.key}); @@ -285,189 +35,121 @@ class StreamChatSampleApp extends StatefulWidget { class _StreamChatSampleAppState extends State with SplashScreenStateMixin, TickerProviderStateMixin { - final InitNotifier _initNotifier = InitNotifier(); + final _localNotification = FlutterLocalNotificationsPlugin(); + late final _notificationService = NotificationService(_localNotification); - final firebaseSubscriptions = >[]; - StreamSubscription? userIdSubscription; + StreamingSharedPreferences? _preferences; - Future _initConnection() async { - String? apiKey, userId, token; + Future _bootstrap() async { + final results = await Future.wait([ + StreamingSharedPreferences.instance, + authController.tryAutoConnect().then((_) => Object()), + ]); + _preferences = results[0] as StreamingSharedPreferences; + } - if (!kIsWeb) { - const secureStorage = FlutterSecureStorage(); - apiKey = await secureStorage.read(key: kStreamApiKey); - userId = await secureStorage.read(key: kStreamUserId); - token = await secureStorage.read(key: kStreamToken); - } - final client = buildStreamChatClient(apiKey ?? kDefaultStreamApiKey); + void _onNotificationTap(NotificationInfo info) { + final notification = info.notification; + debugPrint( + '[notif-tap] type=${notification.type} state=${info.deviceState} ' + 'cid=${notification.cid}', + ); - if (userId != null && token != null) { - await client.connectUser( - User(id: userId), - token, - ); + // Only message notifications carry a channel we can navigate to. + final cid = notification.cid; + if (cid.isEmpty) { + debugPrint('[notif-tap] no cid, skipping navigation'); + return; } - final prefs = await StreamingSharedPreferences.instance; - - return InitData(client, prefs); - } + final parts = cid.split(':'); + if (parts.length != 2) { + debugPrint('[notif-tap] malformed cid=$cid'); + return; + } - Future _initFirebaseMessaging(StreamChatClient client) async { - userIdSubscription?.cancel(); - userIdSubscription = client.state.currentUserStream - .map((it) => it?.id) - .distinct() - .listen((userId) async { - // User logged in - if (userId != null) { - // Requests notification permission. - await FirebaseMessaging.instance.requestPermission(); - // Sets callback for background messages. - FirebaseMessaging.onBackgroundMessage(_onFirebaseBackgroundMessage); - // Sets callback for the notification click event. - firebaseSubscriptions.add(FirebaseMessaging.onMessageOpenedApp - .listen(_onFirebaseMessageOpenedApp(client))); - // Sets callback for foreground messages - firebaseSubscriptions.add(FirebaseMessaging.onMessage - .listen(_onFirebaseForegroundMessage(client))); - // Sets callback for the token refresh event. - firebaseSubscriptions.add(FirebaseMessaging.instance.onTokenRefresh - .listen(_onFirebaseTokenRefresh(client))); + final channelType = parts[0]; + final channelId = parts[1]; - final token = await FirebaseMessaging.instance.getToken(); - debugPrint('[onTokenInit] #firebase; token: $token'); - if (token != null) { - // replace with your push provider, e.g., 'PushProvider.xiaomi' - const pushProvider = PushProvider.firebase; + if (authController.value is! Authenticated) { + debugPrint('[notif-tap] not authenticated, cannot navigate'); + return; + } + final client = authController.client; + if (client == null) { + debugPrint('[notif-tap] no active client, cannot navigate'); + return; + } - // add Token to Stream - await client.addDevice(token, pushProvider); - } - } - // User logged out - else { - firebaseSubscriptions.cancelAll(); - final token = await FirebaseMessaging.instance.getToken(); - if (token != null) { - // remove token from Stream - await client.removeDevice(token); - } - } - }); - } + unawaited(() async { + final channel = client.channel(channelType, id: channelId); - /// Constructs callback for notification click event. - OnRemoteMessage _onFirebaseMessageOpenedApp(StreamChatClient client) { - return (message) async { - debugPrint('[onMessageOpenedApp] #firebase; message: ${message.toMap()}'); - // This callback is getting invoked when the user clicks - // on the notification in case if notification was shown by OS. - final channelCid = (message.data['cid'] as String?) ?? ''; - final parts = channelCid.split(':'); - final channelType = parts[0]; - final channelId = parts[1]; - var channel = client.state.channels[channelCid]; - if (channel == null) { - channel = client.channel( - channelType, - id: channelId, - ); - await channel.watch(); + final ctx = _navigatorKey.currentContext; + if (ctx == null) { + debugPrint('[notif-tap] navigator context not available'); + return; } - // Navigates to Channel page, which is associated with the notification. - GoRouter.of(_navigatorKey.currentContext!).pushNamed( + debugPrint('[notif-tap] navigating to channel=$cid'); + GoRouter.of(ctx).pushNamed( Routes.CHANNEL_PAGE.name, pathParameters: Routes.CHANNEL_PAGE.params(channel), ); - }; - } - - /// Constructs callback for foreground notification handling. - OnRemoteMessage _onFirebaseForegroundMessage(StreamChatClient client) { - return (message) async { - debugPrint( - '[onForegroundMessage] #firebase; message: ${message.toMap()}'); - }; - } - - /// Constructs callback for notification refresh event. - Future Function(String) _onFirebaseTokenRefresh( - StreamChatClient client, - ) { - return (token) async { - debugPrint('[onTokenRefresh] #firebase; token: $token'); - // This callback is getting invoked when the token got refreshed. - await client.addDevice(token, PushProvider.firebase); - }; + }()); } @override void initState() { - final timeOfStartMs = DateTime.now().millisecondsSinceEpoch; - - _initConnection().then( - (initData) { - setState(() { - _initNotifier.initData = initData; - }); + super.initState(); + _notificationService.onNotificationTap = _onNotificationTap; + _notificationService.initialize(); - final now = DateTime.now().millisecondsSinceEpoch; + final timeOfStartMs = DateTime.now().millisecondsSinceEpoch; - if (now - timeOfStartMs > 1500) { - SchedulerBinding.instance.addPostFrameCallback((timeStamp) { - forwardAnimations(); - }); - } else { - Future.delayed(const Duration(milliseconds: 1500)).then((value) { - forwardAnimations(); - }); - } - _initFirebaseMessaging(initData.client); - }, - ); + _bootstrap().then((_) { + if (!mounted) return; + setState(() {}); // preferences are now loaded; rebuild to show the app - super.initState(); + final now = DateTime.now().millisecondsSinceEpoch; + if (now - timeOfStartMs > 1500) { + SchedulerBinding.instance.addPostFrameCallback((_) { + forwardAnimations(); + }); + } else { + Future.delayed(const Duration(milliseconds: 1500)).then((_) { + forwardAnimations(); + }); + } + }); } @override void dispose() { + _notificationService.dispose(); super.dispose(); - userIdSubscription?.cancel(); - firebaseSubscriptions.cancelAll(); - _initNotifier.initData?.client.dispose(); } final GlobalKey _navigatorKey = GlobalKey(); - LocalNotificationObserver? localNotificationObserver; - /// Conditionally sets up the router and adding an observer for the - /// current chat client. - GoRouter _setupRouter() { - if (localNotificationObserver != null) { - localNotificationObserver!.dispose(); - } - localNotificationObserver = LocalNotificationObserver( - _initNotifier.initData!.client, _navigatorKey); + GoRouter? router; - return GoRouter( - refreshListenable: _initNotifier, + /// Sets up the router for the current chat client. + GoRouter _setupRouter() { + return router ??= GoRouter( + refreshListenable: authController, initialLocation: Routes.CHANNEL_LIST_PAGE.path, navigatorKey: _navigatorKey, - observers: [localNotificationObserver!], redirect: (context, state) { - final loggedIn = - _initNotifier.initData?.client.state.currentUser != null; - final loggingIn = state.matchedLocation == Routes.CHOOSE_USER.path || - state.matchedLocation == Routes.ADVANCED_OPTIONS.path; + final authed = authController.value is Authenticated; + final loggingIn = + state.matchedLocation == Routes.CHOOSE_USER.path || state.matchedLocation == Routes.ADVANCED_OPTIONS.path; - if (!loggedIn) { + if (!authed) { return loggingIn ? null : Routes.CHOOSE_USER.path; } // if the user is logged in but still on the login page, send them to // the home page - if (loggedIn && state.matchedLocation == Routes.CHOOSE_USER.path) { + if (authed && state.matchedLocation == Routes.CHOOSE_USER.path) { return Routes.CHANNEL_LIST_PAGE.path; } @@ -482,44 +164,64 @@ class _StreamChatSampleAppState extends State return Stack( alignment: Alignment.center, children: [ - if (_initNotifier.initData != null) - ChangeNotifierProvider.value( - value: _initNotifier, - builder: (context, child) => Builder( + if (_preferences != null) + SampleAppConfig( + preferences: _preferences!, + child: Builder( builder: (context) { - context.watch(); // rebuild on change - return PreferenceBuilder( - preference: _initNotifier.initData!.preferences.getInt( - 'theme', - defaultValue: 0, + final config = context.sampleAppConfig; + return MaterialApp.router( + theme: ThemeData( + brightness: .light, + extensions: [StreamTheme.light()], ), - builder: (context, snapshot) => MaterialApp.router( - theme: ThemeData.light(), - darkTheme: ThemeData.dark(), - themeMode: const { - -1: ThemeMode.dark, - 0: ThemeMode.system, - 1: ThemeMode.light, - }[snapshot], - supportedLocales: const [ - Locale('en'), - Locale('it'), - ], - localizationsDelegates: const [ - AppLocalizationsDelegate(), - GlobalStreamChatLocalizations.delegate, - GlobalMaterialLocalizations.delegate, - GlobalWidgetsLocalizations.delegate, - ], - builder: (context, child) => StreamChat( - client: _initNotifier.initData!.client, - streamChatConfigData: StreamChatConfigurationData( - draftMessagesEnabled: true, - ), - child: child, - ), - routerConfig: _setupRouter(), + darkTheme: ThemeData( + brightness: .dark, + extensions: [StreamTheme.dark()], ), + themeMode: config.themeMode, + locale: config.locale, + supportedLocales: supportedLocales, + localizationsDelegates: const [ + GlobalStreamChatLocalizations.delegate, + GlobalMaterialLocalizations.delegate, + GlobalWidgetsLocalizations.delegate, + ], + builder: (context, child) { + return ListenableBuilder( + listenable: authController, + builder: (context, cachedChild) { + final wrapped = Directionality( + textDirection: config.forceRtl ? .rtl : .ltr, + child: cachedChild ?? const SizedBox.shrink(), + ); + + // StreamChat stays mounted as long as a client + // exists so `StreamChat.of(context)` remains + // valid across logout transitions. + final client = authController.client; + if (client != null) { + return StreamChat( + client: client, + componentBuilders: StreamComponentBuilders( + extensions: streamChatComponentBuilders( + messageItem: customMessageItemBuilder, + ), + ), + streamChatConfigData: config.toStreamChatConfigData(), + child: wrapped, + ); + } + + return StreamChatTheme( + data: StreamChatThemeData(brightness: Theme.of(context).brightness), + child: wrapped, + ); + }, + child: child, + ); + }, + routerConfig: _setupRouter(), ); }, ), @@ -530,13 +232,23 @@ class _StreamChatSampleAppState extends State } } -typedef OnRemoteMessage = Future Function(RemoteMessage); - -extension on List { - void cancelAll() { - for (final subscription in this) { - unawaited(subscription.cancel()); - } - clear(); +extension on SampleAppConfigData { + /// Maps chat-relevant flags to a [StreamChatConfigurationData]. + StreamChatConfigurationData toStreamChatConfigData() { + return StreamChatConfigurationData( + draftMessagesEnabled: draftMessagesEnabled, + enforceUniqueReactions: enforceUniqueReactions, + reactionType: reactionType, + reactionPosition: reactionPosition, + reactionOverlap: reactionOverlap?.value, + attachmentBuilders: [ + if (enableLocationSharing) + LocationAttachmentBuilder( + onAttachmentTap: (context, location) { + showLocationDetailDialog(context: context, location: location); + }, + ), + ], + ); } } diff --git a/sample_app/lib/auth/auth_controller.dart b/sample_app/lib/auth/auth_controller.dart new file mode 100644 index 0000000000..92629d5e1c --- /dev/null +++ b/sample_app/lib/auth/auth_controller.dart @@ -0,0 +1,206 @@ +import 'dart:async'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/scheduler.dart'; +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; +import 'package:sample_app/push/push_provider.dart'; +import 'package:sample_app/push/push_token_manager.dart'; +import 'package:sample_app/utils/app_config.dart'; +import 'package:sentry_flutter/sentry_flutter.dart'; +import 'package:stream_chat_flutter/stream_chat_flutter.dart' hide PushProvider; +import 'package:stream_chat_persistence/stream_chat_persistence.dart'; + +/// Secure-storage keys for the active session. +const kStreamApiKey = 'STREAM_API_KEY'; +const kStreamUserId = 'STREAM_USER_ID'; +const kStreamToken = 'STREAM_TOKEN'; + +// Firebase on both platforms: raw APNs payloads lack the FCM metadata that +// `firebase_messaging.onMessageOpenedApp` needs to fire on tap. +const _kIosPushProvider = PushProvider.firebase(name: 'firebase'); +const _kAndroidPushProvider = PushProvider.firebase(name: 'firebase'); + +/// Shared across every client so reconnecting doesn't need a second +/// SQLite connection. +final _chatPersistenceClient = StreamChatPersistenceClient( + logLevel: Level.SEVERE, +); + +Future _sampleAppLogHandler(LogRecord record) async { + if (kDebugMode) StreamChatClient.defaultLogHandler(record); + + if (record.error != null || record.stackTrace != null) { + await Sentry.captureException( + record.error, + stackTrace: record.stackTrace, + ); + } +} + +StreamChatClient _buildStreamChatClient(String apiKey) { + const logLevel = kDebugMode ? Level.INFO : Level.SEVERE; + return StreamChatClient( + apiKey, + logLevel: logLevel, + logHandlerFunction: _sampleAppLogHandler, + retryPolicy: RetryPolicy( + maxRetryAttempts: 3, + shouldRetry: (client, attempt, error) { + return error is StreamChatNetworkError && error.isRetriable; + }, + ), + //baseURL: 'http://:3030', + //baseWsUrl: 'ws://:8800', + )..chatPersistenceClient = _chatPersistenceClient; +} + +/// Authentication state exposed by [AuthController]. +sealed class AuthState { + const AuthState(); +} + +/// No user is connected; show the login flow. +final class Unauthenticated extends AuthState { + const Unauthenticated(); +} + +/// An [AuthController.connect] call is in flight; show a splash/spinner. +final class Authenticating extends AuthState { + const Authenticating(); +} + +/// A user is connected; show the authenticated app shell. +final class Authenticated extends AuthState { + const Authenticated(this.user); + + /// The connected user. + final OwnUser user; +} + +/// Owns the [StreamChatClient] + [PushTokenManager] lifecycle for the +/// sample app. Use the process-wide [authController] singleton. +/// +/// The underlying [client] is kept alive across [disconnect]/[connect] +/// so the `StreamChat` ancestor stays mounted through the transition — +/// widgets that read `StreamChat.of(context)` crash if it disappears. +class AuthController extends ValueNotifier { + AuthController() : super(const Unauthenticated()); + + StreamChatClient? _client; + + /// The active client, or `null` before the first [connect]. + StreamChatClient? get client => _client; + + String? _activeApiKey; + PushTokenManager? _pushTokenManager; + + /// Restores a previous session from secure storage, if any. + /// + /// No-op on web or when no credentials are stored; failures are + /// swallowed so the user simply lands on the login flow. + Future tryAutoConnect() async { + if (CurrentPlatform.isWeb) return; + if (value is! Unauthenticated) return; + + const secureStorage = FlutterSecureStorage(); + final apiKey = await secureStorage.read(key: kStreamApiKey); + final userId = await secureStorage.read(key: kStreamUserId); + final token = await secureStorage.read(key: kStreamToken); + if (userId == null || token == null) return; + + try { + await connect( + apiKey: apiKey ?? kDefaultStreamApiKey, + user: User(id: userId), + token: token, + persistCredentials: false, + ); + } catch (e, stk) { + debugPrint('[auth] auto-connect failed: $e; $stk'); + } + } + + /// Connects [user] against [apiKey] using the supplied [token]. + /// + /// Builds a new client on the first call; reuses the existing one + /// when [apiKey] matches, or disposes and rebuilds when it differs. + /// On success, credentials are optionally persisted to secure storage + /// and a [PushTokenManager] starts mirroring push tokens. Rethrows + /// any error from `connectUser`. + Future connect({ + required String apiKey, + required User user, + required String token, + bool persistCredentials = true, + }) async { + value = const Authenticating(); + + if (_client != null && _activeApiKey != apiKey) { + await _client!.dispose(); + _client = null; + } + + final client = _client ??= _buildStreamChatClient(apiKey); + _activeApiKey = apiKey; + + try { + final ownUser = await client.connectUser(user, token); + + if (persistCredentials && !CurrentPlatform.isWeb) { + const secureStorage = FlutterSecureStorage(); + await Future.wait([ + secureStorage.write(key: kStreamApiKey, value: apiKey), + secureStorage.write(key: kStreamUserId, value: user.id), + secureStorage.write(key: kStreamToken, value: token), + ]); + } + + _pushTokenManager = PushTokenManager( + client: client, + iosPushProvider: _kIosPushProvider, + androidPushProvider: _kAndroidPushProvider, + )..registerDevice(); + + value = Authenticated(ownUser); + } catch (_) { + value = const Unauthenticated(); + rethrow; + } + } + + /// Disconnects the current user, keeping [client] alive for the next + /// [connect]. No-op when not [Authenticated]. + Future disconnect({bool flushPersistence = true}) async { + if (value is! Authenticated) return; + final client = _client; + if (client == null) return; + + await _pushTokenManager?.unregisterDevice(); + _pushTokenManager?.dispose().ignore(); + _pushTokenManager = null; + + if (!CurrentPlatform.isWeb) { + const secureStorage = FlutterSecureStorage(); + await secureStorage.deleteAll(); + } + + value = const Unauthenticated(); + // Let the router unmount auth-gated pages before `disconnectUser` + // synchronously disposes channel state — otherwise the channel + // list's final rebuild trips `channel.state != null`. + await SchedulerBinding.instance.endOfFrame; + client.disconnectUser(flushChatPersistence: flushPersistence).ignore(); + } + + @override + void dispose() { + _pushTokenManager?.dispose().ignore(); + _pushTokenManager = null; + _client?.dispose().ignore(); + _client = null; + super.dispose(); + } +} + +/// Process-wide [AuthController] singleton. +final authController = AuthController(); diff --git a/sample_app/lib/config/sample_app_config.dart b/sample_app/lib/config/sample_app_config.dart new file mode 100644 index 0000000000..2524ccaf1c --- /dev/null +++ b/sample_app/lib/config/sample_app_config.dart @@ -0,0 +1,331 @@ +import 'package:flutter/material.dart'; +import 'package:stream_chat_flutter/stream_chat_flutter.dart'; +import 'package:stream_chat_localizations/stream_chat_localizations.dart'; +import 'package:streaming_shared_preferences/streaming_shared_preferences.dart'; + +// --------------------------------------------------------------------------- +// Preference keys +// --------------------------------------------------------------------------- + +const _kThemeMode = 'config.themeMode'; +const _kForceRtl = 'config.forceRtl'; +const _kEnableReminderActions = 'config.enableReminderActions'; +const _kEnableDeleteForMe = 'config.enableDeleteForMe'; +const _kEnableMessageInfo = 'config.enableMessageInfo'; +const _kEnableLocationSharing = 'config.enableLocationSharing'; +const _kDraftMessagesEnabled = 'config.draftMessagesEnabled'; +const _kEnforceUniqueReactions = 'config.enforceUniqueReactions'; +const _kReactionType = 'config.reactionType'; +const _kReactionPosition = 'config.reactionPosition'; +const _kReactionOverlap = 'config.reactionOverlap'; +const _kLocale = 'config.locale'; + +const _sentinel = Object(); + +/// Locales supported by the sample app, derived from +/// [kStreamChatSupportedLanguages]. +final supportedLocales = kStreamChatSupportedLanguages.map(Locale.new).toList(); + +/// Reaction overlap preference for the sample app. +/// +/// Mapped to `bool` via [value] when passed to +/// [StreamChatConfigurationData.reactionOverlap]. Use `null` for the SDK's +/// platform-based default (overlap on mobile, no overlap on desktop/web). +enum SampleReactionOverlap { + /// Always overlap the message bubble edge. + overlap(true), + + /// Never overlap the message bubble edge. + noOverlap(false) + ; + + // ignore: avoid_positional_boolean_parameters + const SampleReactionOverlap(this.value); + + /// The boolean value passed to the SDK. + final bool value; +} + +// --------------------------------------------------------------------------- +// SampleAppConfigData +// --------------------------------------------------------------------------- + +/// Immutable configuration data for the sample app. +/// +/// Holds both sample-app-specific flags and chat configuration flags. +@immutable +class SampleAppConfigData { + /// Creates a configuration with sensible defaults. + factory SampleAppConfigData({ + Locale? locale, + ThemeMode themeMode = .system, + bool forceRtl = false, + bool enableReminderActions = false, + bool enableDeleteForMe = false, + bool enableMessageInfo = false, + bool enableLocationSharing = false, + bool draftMessagesEnabled = false, + bool enforceUniqueReactions = false, + StreamReactionsType? reactionType, + StreamReactionsPosition? reactionPosition, + SampleReactionOverlap? reactionOverlap, + }) { + return SampleAppConfigData.raw( + themeMode: themeMode, + locale: locale, + forceRtl: forceRtl, + enableReminderActions: enableReminderActions, + enableDeleteForMe: enableDeleteForMe, + enableMessageInfo: enableMessageInfo, + enableLocationSharing: enableLocationSharing, + draftMessagesEnabled: draftMessagesEnabled, + enforceUniqueReactions: enforceUniqueReactions, + reactionType: reactionType, + reactionPosition: reactionPosition, + reactionOverlap: reactionOverlap, + ); + } + + /// Raw constructor used internally and by persistence. + const SampleAppConfigData.raw({ + required this.themeMode, + required this.locale, + required this.forceRtl, + required this.enableReminderActions, + required this.enableDeleteForMe, + required this.enableMessageInfo, + required this.enableLocationSharing, + required this.draftMessagesEnabled, + required this.enforceUniqueReactions, + required this.reactionType, + required this.reactionPosition, + required this.reactionOverlap, + }); + + /// Loads config from [StreamingSharedPreferences], falling back to defaults. + factory SampleAppConfigData.fromPreferences(StreamingSharedPreferences prefs) { + final localeStr = prefs.getString(_kLocale, defaultValue: '').getValue(); + return SampleAppConfigData.raw( + themeMode: ThemeMode.values[prefs.getInt(_kThemeMode, defaultValue: ThemeMode.system.index).getValue()], + locale: localeStr.isEmpty ? null : Locale(localeStr), + forceRtl: prefs.getBool(_kForceRtl, defaultValue: false).getValue(), + enableReminderActions: prefs.getBool(_kEnableReminderActions, defaultValue: false).getValue(), + enableDeleteForMe: prefs.getBool(_kEnableDeleteForMe, defaultValue: false).getValue(), + enableMessageInfo: prefs.getBool(_kEnableMessageInfo, defaultValue: false).getValue(), + enableLocationSharing: prefs.getBool(_kEnableLocationSharing, defaultValue: false).getValue(), + draftMessagesEnabled: prefs.getBool(_kDraftMessagesEnabled, defaultValue: false).getValue(), + enforceUniqueReactions: prefs.getBool(_kEnforceUniqueReactions, defaultValue: false).getValue(), + reactionType: _intToReactionType(prefs.getInt(_kReactionType, defaultValue: -1).getValue()), + reactionPosition: _intToReactionPosition(prefs.getInt(_kReactionPosition, defaultValue: -1).getValue()), + reactionOverlap: _intToReactionOverlap(prefs.getInt(_kReactionOverlap, defaultValue: -1).getValue()), + ); + } + + // -- Sample-app-only flags ------------------------------------------------ + + /// The theme mode for the app (system, light, dark). + final ThemeMode themeMode; + + /// The locale override for the app. When null, the system locale is used. + final Locale? locale; + + /// Whether to force RTL layout direction. + final bool forceRtl; + + /// Whether reminder actions appear in the message context menu. + final bool enableReminderActions; + + /// Whether the "Delete for Me" action appears in the message context menu. + final bool enableDeleteForMe; + + /// Whether the "Message Info" action appears in the message context menu. + final bool enableMessageInfo; + + /// Whether location sharing (attachment builder + picker) is enabled. + final bool enableLocationSharing; + + // -- Chat config flags ---------------------------------------------------- + + /// Whether draft messages are enabled. + final bool draftMessagesEnabled; + + /// Whether a new reaction replaces the existing one. + final bool enforceUniqueReactions; + + /// The visual type of the reactions display. + final StreamReactionsType? reactionType; + + /// Where reactions appear relative to the message bubble. + final StreamReactionsPosition? reactionPosition; + + /// Whether reactions overlap the message bubble edge. + /// + /// When null, the SDK's platform-based default is used (overlap on + /// mobile, no overlap on desktop and web). + final SampleReactionOverlap? reactionOverlap; + + // -- copyWith ------------------------------------------------------------- + + /// Creates a copy with the given fields replaced. + /// + /// For nullable fields ([locale], [reactionType], [reactionPosition], + /// [reactionOverlap]), pass explicitly as `null` to reset to default/system. + SampleAppConfigData copyWith({ + ThemeMode? themeMode, + Object? locale = _sentinel, + bool? forceRtl, + bool? enableReminderActions, + bool? enableDeleteForMe, + bool? enableMessageInfo, + bool? enableLocationSharing, + bool? draftMessagesEnabled, + bool? enforceUniqueReactions, + Object? reactionType = _sentinel, + Object? reactionPosition = _sentinel, + Object? reactionOverlap = _sentinel, + }) { + return SampleAppConfigData.raw( + themeMode: themeMode ?? this.themeMode, + locale: locale == _sentinel ? this.locale : locale as Locale?, + forceRtl: forceRtl ?? this.forceRtl, + enableReminderActions: enableReminderActions ?? this.enableReminderActions, + enableDeleteForMe: enableDeleteForMe ?? this.enableDeleteForMe, + enableMessageInfo: enableMessageInfo ?? this.enableMessageInfo, + enableLocationSharing: enableLocationSharing ?? this.enableLocationSharing, + draftMessagesEnabled: draftMessagesEnabled ?? this.draftMessagesEnabled, + enforceUniqueReactions: enforceUniqueReactions ?? this.enforceUniqueReactions, + reactionType: reactionType == _sentinel ? this.reactionType : reactionType as StreamReactionsType?, + reactionPosition: reactionPosition == _sentinel + ? this.reactionPosition + : reactionPosition as StreamReactionsPosition?, + reactionOverlap: reactionOverlap == _sentinel ? this.reactionOverlap : reactionOverlap as SampleReactionOverlap?, + ); + } + + // -- Persistence ---------------------------------------------------------- + + /// Persists all fields to [StreamingSharedPreferences]. + void saveToPreferences(StreamingSharedPreferences prefs) { + prefs.setInt(_kThemeMode, themeMode.index); + prefs.setString(_kLocale, locale?.languageCode ?? ''); + prefs.setBool(_kForceRtl, forceRtl); + prefs.setBool(_kEnableReminderActions, enableReminderActions); + prefs.setBool(_kEnableDeleteForMe, enableDeleteForMe); + prefs.setBool(_kEnableMessageInfo, enableMessageInfo); + prefs.setBool(_kEnableLocationSharing, enableLocationSharing); + prefs.setBool(_kDraftMessagesEnabled, draftMessagesEnabled); + prefs.setBool(_kEnforceUniqueReactions, enforceUniqueReactions); + prefs.setInt(_kReactionType, _reactionTypeToInt(reactionType)); + prefs.setInt(_kReactionPosition, _reactionPositionToInt(reactionPosition)); + prefs.setInt(_kReactionOverlap, _reactionOverlapToInt(reactionOverlap)); + } + + static StreamReactionsType? _intToReactionType(int value) { + if (value < 0 || value >= StreamReactionsType.values.length) return null; + return StreamReactionsType.values[value]; + } + + static StreamReactionsPosition? _intToReactionPosition(int value) { + if (value < 0 || value >= StreamReactionsPosition.values.length) return null; + return StreamReactionsPosition.values[value]; + } + + static int _reactionTypeToInt(StreamReactionsType? type) => type?.index ?? -1; + + static int _reactionPositionToInt(StreamReactionsPosition? position) => position?.index ?? -1; + + static SampleReactionOverlap? _intToReactionOverlap(int value) { + if (value < 0 || value >= SampleReactionOverlap.values.length) return null; + return SampleReactionOverlap.values[value]; + } + + static int _reactionOverlapToInt(SampleReactionOverlap? overlap) => overlap?.index ?? -1; +} + +// --------------------------------------------------------------------------- +// SampleAppConfig (StatefulWidget + InheritedWidget) +// --------------------------------------------------------------------------- + +/// Provides [SampleAppConfigData] to the widget tree and manages mutable state. +/// +/// Wraps its child in a private [_SampleAppConfigScope] [InheritedWidget]. +/// Access the config via [SampleAppConfig.of] or the [SampleAppConfigExtension]. +class SampleAppConfig extends StatefulWidget { + /// Creates a sample app config provider. + const SampleAppConfig({ + super.key, + required this.preferences, + this.initialConfig, + required this.child, + }); + + /// The shared preferences instance used for persistence. + final StreamingSharedPreferences preferences; + + /// The initial configuration. If null, defaults are loaded from [preferences]. + final SampleAppConfigData? initialConfig; + + /// The child widget. + final Widget child; + + /// Returns the [SampleAppConfigData] from the nearest ancestor, or defaults. + static SampleAppConfigData of(BuildContext context) { + final scope = context.dependOnInheritedWidgetOfExactType<_SampleAppConfigScope>(); + return scope?.config ?? SampleAppConfigData(); + } + + /// Updates the config in the nearest ancestor [SampleAppConfig]. + static void update(BuildContext context, SampleAppConfigData config) { + context.findAncestorStateOfType<_SampleAppConfigState>()?.update(config); + } + + @override + State createState() => _SampleAppConfigState(); +} + +class _SampleAppConfigState extends State { + late SampleAppConfigData _config; + + @override + void initState() { + super.initState(); + _config = widget.initialConfig ?? SampleAppConfigData.fromPreferences(widget.preferences); + } + + void update(SampleAppConfigData config) { + setState(() => _config = config); + config.saveToPreferences(widget.preferences); + } + + @override + Widget build(BuildContext context) { + return _SampleAppConfigScope( + config: _config, + child: widget.child, + ); + } +} + +class _SampleAppConfigScope extends InheritedWidget { + const _SampleAppConfigScope({ + required this.config, + required super.child, + }); + + final SampleAppConfigData config; + + @override + bool updateShouldNotify(_SampleAppConfigScope oldWidget) { + return config != oldWidget.config; + } +} + +// --------------------------------------------------------------------------- +// BuildContext extension +// --------------------------------------------------------------------------- + +/// Convenient access to [SampleAppConfigData] from any [BuildContext]. +extension SampleAppConfigExtension on BuildContext { + /// Returns the [SampleAppConfigData] from the nearest [SampleAppConfig]. + SampleAppConfigData get sampleAppConfig => SampleAppConfig.of(this); +} diff --git a/sample_app/lib/config/sample_app_config_screen.dart b/sample_app/lib/config/sample_app_config_screen.dart new file mode 100644 index 0000000000..9c6f8b6f44 --- /dev/null +++ b/sample_app/lib/config/sample_app_config_screen.dart @@ -0,0 +1,496 @@ +import 'package:flutter/material.dart'; +import 'package:sample_app/config/sample_app_config.dart'; +import 'package:stream_chat_flutter/stream_chat_flutter.dart'; + +/// A settings screen for configuring sample app feature flags. +class SampleAppConfigScreen extends StatelessWidget { + const SampleAppConfigScreen({super.key}); + + @override + Widget build(BuildContext context) { + final config = context.sampleAppConfig; + final colorScheme = context.streamColorScheme; + final spacing = context.streamSpacing; + final icons = context.streamIcons; + + return Scaffold( + backgroundColor: colorScheme.backgroundApp, + appBar: StreamAppBar(title: const Text('Configuration')), + body: SingleChildScrollView( + padding: EdgeInsets.symmetric(horizontal: spacing.md), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox(height: spacing.xs), + + // ── Appearance ── + const _SectionHeader(title: 'Appearance'), + SizedBox(height: spacing.xs), + _SettingsCard( + children: [ + _SegmentedRow( + title: 'Theme', + value: config.themeMode, + segments: const { + ThemeMode.system: 'System', + ThemeMode.light: 'Light', + ThemeMode.dark: 'Dark', + }, + segmentIcons: const { + ThemeMode.system: Icons.brightness_auto_outlined, + ThemeMode.light: Icons.light_mode_outlined, + ThemeMode.dark: Icons.dark_mode_outlined, + }, + onChanged: (v) => SampleAppConfig.update(context, config.copyWith(themeMode: v)), + ), + _LocaleRow(config: config), + _SwitchRow( + icon: icons.reorder, + title: 'Force RTL', + subtitle: 'Right-to-left layout direction', + value: config.forceRtl, + onChanged: (v) => SampleAppConfig.update(context, config.copyWith(forceRtl: v)), + ), + ], + ), + + SizedBox(height: spacing.xl), + + // ── Features ── + const _SectionHeader(title: 'Features'), + SizedBox(height: spacing.xs), + _SettingsCard( + children: [ + _SwitchRow( + icon: icons.bell, + title: 'Reminders', + subtitle: 'Remind me, Save for later, Edit', + value: config.enableReminderActions, + onChanged: (v) => SampleAppConfig.update(context, config.copyWith(enableReminderActions: v)), + ), + _SwitchRow( + icon: icons.delete, + title: 'Delete for Me', + subtitle: 'Delete message for current user', + value: config.enableDeleteForMe, + onChanged: (v) => SampleAppConfig.update(context, config.copyWith(enableDeleteForMe: v)), + ), + _SwitchRow( + icon: icons.info, + title: 'Message Info', + subtitle: 'Show delivery info sheet', + value: config.enableMessageInfo, + onChanged: (v) => SampleAppConfig.update(context, config.copyWith(enableMessageInfo: v)), + ), + _SwitchRow( + icon: icons.location, + title: 'Location Sharing', + subtitle: 'Attachment builder and picker', + value: config.enableLocationSharing, + onChanged: (v) => SampleAppConfig.update(context, config.copyWith(enableLocationSharing: v)), + ), + ], + ), + + SizedBox(height: spacing.xl), + + // ── Chat ── + const _SectionHeader(title: 'Chat'), + SizedBox(height: spacing.xs), + _SettingsCard( + children: [ + _SwitchRow( + icon: icons.edit, + title: 'Draft Messages', + subtitle: 'Enable draft message saving', + value: config.draftMessagesEnabled, + onChanged: (v) => SampleAppConfig.update(context, config.copyWith(draftMessagesEnabled: v)), + ), + _SwitchRow( + icon: icons.emoji, + title: 'Unique Reactions', + subtitle: 'New reaction replaces existing', + value: config.enforceUniqueReactions, + onChanged: (v) => SampleAppConfig.update(context, config.copyWith(enforceUniqueReactions: v)), + ), + ], + ), + + SizedBox(height: spacing.xl), + + // ── Reactions ── + const _SectionHeader(title: 'Reactions'), + SizedBox(height: spacing.xs), + _SettingsCard( + children: [ + _SegmentedRow( + title: 'Reaction Type', + value: config.reactionType, + segments: const { + null: 'Default', + StreamReactionsType.segmented: 'Segmented', + StreamReactionsType.clustered: 'Clustered', + }, + onChanged: (v) => SampleAppConfig.update(context, config.copyWith(reactionType: v)), + ), + _SegmentedRow( + title: 'Reaction Position', + value: config.reactionPosition, + segments: const { + null: 'Default', + StreamReactionsPosition.header: 'Header', + StreamReactionsPosition.footer: 'Footer', + }, + onChanged: (v) => SampleAppConfig.update(context, config.copyWith(reactionPosition: v)), + ), + _SegmentedRow( + title: 'Reaction Overlap', + value: config.reactionOverlap, + segments: const { + null: 'Default', + SampleReactionOverlap.overlap: 'Overlap', + SampleReactionOverlap.noOverlap: 'No Overlap', + }, + onChanged: (v) => SampleAppConfig.update(context, config.copyWith(reactionOverlap: v)), + ), + ], + ), + + SizedBox(height: spacing.xxl), + ], + ), + ), + ); + } +} + +// --------------------------------------------------------------------------- +// Section header +// --------------------------------------------------------------------------- + +class _SectionHeader extends StatelessWidget { + const _SectionHeader({required this.title}); + + final String title; + + @override + Widget build(BuildContext context) { + final colorScheme = context.streamColorScheme; + final textTheme = context.streamTextTheme; + final spacing = context.streamSpacing; + + return Padding( + padding: EdgeInsets.only(left: spacing.xxs), + child: Text( + title.toUpperCase(), + style: textTheme.captionEmphasis.copyWith( + color: colorScheme.textTertiary, + letterSpacing: 0.8, + ), + ), + ); + } +} + +// --------------------------------------------------------------------------- +// Settings card — canonical DS pattern with foregroundDecoration for borders +// --------------------------------------------------------------------------- + +class _SettingsCard extends StatelessWidget { + const _SettingsCard({required this.children}); + + final List children; + + @override + Widget build(BuildContext context) { + final colorScheme = context.streamColorScheme; + final radius = context.streamRadius; + + return Container( + clipBehavior: Clip.antiAlias, + decoration: BoxDecoration( + color: colorScheme.backgroundSurfaceCard, + borderRadius: BorderRadius.all(radius.lg), + ), + foregroundDecoration: BoxDecoration( + borderRadius: BorderRadius.all(radius.lg), + border: Border.all(color: colorScheme.borderSubtle), + ), + child: Material( + type: MaterialType.transparency, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + for (var i = 0; i < children.length; i++) ...[ + children[i], + if (i < children.length - 1) Divider(height: 1, color: colorScheme.borderSubtle), + ], + ], + ), + ), + ); + } +} + +// --------------------------------------------------------------------------- +// Switch row — plain icon, DS list tile + switch +// --------------------------------------------------------------------------- + +class _SwitchRow extends StatelessWidget { + const _SwitchRow({ + required this.icon, + required this.title, + this.subtitle, + required this.value, + required this.onChanged, + }); + + final IconData icon; + final String title; + final String? subtitle; + final bool value; + final ValueChanged onChanged; + + @override + Widget build(BuildContext context) { + return StreamListTile( + leading: Icon(icon, size: 24), + title: Text(title), + subtitle: subtitle != null ? Text(subtitle!) : null, + trailing: StreamSwitch(value: value, onChanged: onChanged), + onTap: () => onChanged(!value), + ); + } +} + +// --------------------------------------------------------------------------- +// Segmented row +// --------------------------------------------------------------------------- + +class _SegmentedRow extends StatelessWidget { + const _SegmentedRow({ + required this.title, + required this.value, + required this.segments, + required this.onChanged, + this.segmentIcons, + }); + + final String title; + final T value; + final Map segments; + final ValueChanged onChanged; + final Map? segmentIcons; + + @override + Widget build(BuildContext context) { + final colorScheme = context.streamColorScheme; + final textTheme = context.streamTextTheme; + final spacing = context.streamSpacing; + final radius = context.streamRadius; + + return Padding( + padding: EdgeInsets.symmetric(horizontal: spacing.md, vertical: spacing.sm), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + spacing: spacing.xs, + children: [ + Text(title, style: textTheme.bodyDefault.copyWith(color: colorScheme.textPrimary)), + SizedBox( + width: double.infinity, + child: SegmentedButton( + showSelectedIcon: false, + style: ButtonStyle( + textStyle: WidgetStatePropertyAll(textTheme.captionEmphasis), + foregroundColor: WidgetStateProperty.resolveWith((states) { + if (states.contains(WidgetState.selected)) return colorScheme.textOnAccent; + return colorScheme.textPrimary; + }), + backgroundColor: WidgetStateProperty.resolveWith((states) { + if (states.contains(WidgetState.selected)) return colorScheme.accentPrimary; + return colorScheme.backgroundSurfaceSubtle; + }), + side: WidgetStatePropertyAll(BorderSide(color: colorScheme.borderDefault)), + shape: WidgetStatePropertyAll( + RoundedRectangleBorder(borderRadius: BorderRadius.all(radius.md)), + ), + padding: const WidgetStatePropertyAll(EdgeInsets.symmetric(vertical: 10, horizontal: 12)), + ), + segments: [ + for (final entry in segments.entries) + ButtonSegment( + value: entry.key, + label: Text(entry.value), + icon: segmentIcons?[entry.key] != null ? Icon(segmentIcons![entry.key], size: 16) : null, + ), + ], + selected: {value}, + onSelectionChanged: (selected) => onChanged(selected.first), + ), + ), + ], + ), + ); + } +} + +// --------------------------------------------------------------------------- +// Locale row +// --------------------------------------------------------------------------- + +class _LocaleRow extends StatelessWidget { + const _LocaleRow({required this.config}); + + final SampleAppConfigData config; + + static const _localeLabels = { + 'en': 'English', + 'it': 'Italiano', + 'fr': 'Fran\u00e7ais', + 'es': 'Espa\u00f1ol', + 'de': 'Deutsch', + 'pt': 'Portugu\u00eas', + 'ja': '\u65e5\u672c\u8a9e', + 'ko': '\ud55c\uad6d\uc5b4', + 'hi': '\u0939\u093f\u0928\u094d\u0926\u0940', + 'no': 'Norsk', + 'ca': 'Catal\u00e0', + }; + + @override + Widget build(BuildContext context) { + final spacing = context.streamSpacing; + final colorScheme = context.streamColorScheme; + final textTheme = context.streamTextTheme; + final icons = context.streamIcons; + final currentLabel = config.locale == null ? 'System' : _localeLabels[config.locale!.languageCode] ?? 'System'; + + return StreamListTile( + leading: Icon(icons.translate, size: 24), + title: const Text('Language'), + trailing: Row( + mainAxisSize: MainAxisSize.min, + spacing: spacing.xxs, + children: [ + Text(currentLabel, style: textTheme.captionEmphasis.copyWith(color: colorScheme.textTertiary)), + Icon(Icons.chevron_right_outlined, size: 20, color: colorScheme.textTertiary), + ], + ), + onTap: () => _showLocalePicker(context), + ); + } + + void _showLocalePicker(BuildContext context) { + final colorScheme = context.streamColorScheme; + final textTheme = context.streamTextTheme; + final spacing = context.streamSpacing; + final radius = context.streamRadius; + + final items = [ + const _LocaleOption(label: 'System', code: null), + ...supportedLocales.map( + (l) => _LocaleOption( + label: _localeLabels[l.languageCode] ?? l.languageCode, + code: l.languageCode, + ), + ), + ]; + + showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: colorScheme.backgroundSurfaceCard, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: radius.xxl), + ), + builder: (sheetContext) { + return DraggableScrollableSheet( + expand: false, + initialChildSize: 0.5, + maxChildSize: 0.8, + minChildSize: 0.3, + builder: (context, scrollController) => SafeArea( + child: Column( + children: [ + Padding( + padding: EdgeInsets.only(top: spacing.md, bottom: spacing.sm), + child: Container( + width: 32, + height: 4, + decoration: BoxDecoration( + color: colorScheme.borderDefault, + borderRadius: BorderRadius.all(radius.max), + ), + ), + ), + Padding( + padding: EdgeInsets.only(bottom: spacing.sm), + child: Text( + 'Select Language', + style: textTheme.headingSm.copyWith(color: colorScheme.textPrimary), + ), + ), + Divider(height: 1, color: colorScheme.borderSubtle), + Expanded( + child: ListView.separated( + controller: scrollController, + padding: EdgeInsets.symmetric(vertical: spacing.xs), + itemCount: items.length, + separatorBuilder: (_, __) => Divider( + height: 1, + indent: spacing.lg, + endIndent: spacing.lg, + color: colorScheme.borderSubtle, + ), + itemBuilder: (context, index) { + final item = items[index]; + final isSelected = item.code == config.locale?.languageCode; + + return StreamListTile( + title: Text( + item.label, + style: textTheme.bodyDefault.copyWith( + color: isSelected ? colorScheme.accentPrimary : colorScheme.textPrimary, + fontWeight: isSelected ? FontWeight.w600 : FontWeight.normal, + ), + ), + subtitle: switch (item.code) { + final code? => Text( + code, + style: textTheme.captionDefault.copyWith(color: colorScheme.textTertiary), + ), + null => null, + }, + trailing: StreamCheckbox.circular( + value: isSelected, + size: StreamCheckboxSize.sm, + onChanged: (_) { + final locale = item.code != null ? Locale(item.code!) : null; + SampleAppConfig.update(context, config.copyWith(locale: locale)); + Navigator.of(sheetContext).pop(); + }, + ), + onTap: () { + final locale = item.code != null ? Locale(item.code!) : null; + SampleAppConfig.update(context, config.copyWith(locale: locale)); + Navigator.of(sheetContext).pop(); + }, + ); + }, + ), + ), + ], + ), + ), + ); + }, + ); + } +} + +class _LocaleOption { + const _LocaleOption({required this.label, required this.code}); + + final String label; + final String? code; +} diff --git a/sample_app/lib/firebase_options.dart b/sample_app/lib/firebase_options.dart index 683caab1ce..9fd43a439e 100644 --- a/sample_app/lib/firebase_options.dart +++ b/sample_app/lib/firebase_options.dart @@ -1,8 +1,7 @@ // File generated by FlutterFire CLI. // ignore_for_file: lines_longer_than_80_chars, avoid_classes_with_only_static_members import 'package:firebase_core/firebase_core.dart' show FirebaseOptions; -import 'package:flutter/foundation.dart' - show defaultTargetPlatform, kIsWeb, TargetPlatform; +import 'package:flutter/foundation.dart' show defaultTargetPlatform, kIsWeb, TargetPlatform; /// Default [FirebaseOptions] for use with your Firebase apps. /// @@ -70,10 +69,8 @@ class DefaultFirebaseOptions { projectId: 'stream-chat-internal', databaseURL: 'https://stream-chat-internal.firebaseio.com', storageBucket: 'stream-chat-internal.appspot.com', - androidClientId: - '674907137625-0aa50j6b2i35ef9c52lsbk1v16otl492.apps.googleusercontent.com', - iosClientId: - '674907137625-flarfn9cefu4lermgpbc4b8rm8l15ian.apps.googleusercontent.com', + androidClientId: '674907137625-0aa50j6b2i35ef9c52lsbk1v16otl492.apps.googleusercontent.com', + iosClientId: '674907137625-flarfn9cefu4lermgpbc4b8rm8l15ian.apps.googleusercontent.com', iosBundleId: 'io.getstream.flutter', ); @@ -84,10 +81,8 @@ class DefaultFirebaseOptions { projectId: 'stream-chat-internal', databaseURL: 'https://stream-chat-internal.firebaseio.com', storageBucket: 'stream-chat-internal.appspot.com', - androidClientId: - '674907137625-0aa50j6b2i35ef9c52lsbk1v16otl492.apps.googleusercontent.com', - iosClientId: - '674907137625-p3msks3snq0h22l7ekpqcf0frr0vt8mg.apps.googleusercontent.com', + androidClientId: '674907137625-0aa50j6b2i35ef9c52lsbk1v16otl492.apps.googleusercontent.com', + iosClientId: '674907137625-p3msks3snq0h22l7ekpqcf0frr0vt8mg.apps.googleusercontent.com', iosBundleId: 'io.getstream.streamChatV1', ); } diff --git a/sample_app/lib/notification/notification.dart b/sample_app/lib/notification/notification.dart new file mode 100644 index 0000000000..bd9cb0be80 --- /dev/null +++ b/sample_app/lib/notification/notification.dart @@ -0,0 +1,101 @@ +import 'package:sample_app/utils/serializer.dart'; +import 'package:stream_chat_flutter/stream_chat_flutter.dart'; + +/// A push notification delivered by Stream Chat. +/// +/// All Stream Chat push events share a single flat payload shape — a +/// discriminator [type] plus the fields below. Branch on [type] (or the +/// `isMessageNew` / `isReactionNew` / … getters) when you need event-specific +/// behaviour; otherwise just read [cid], [messageId], [title], [body]. +class ChatNotification { + const ChatNotification({ + required this.sender, + required this.type, + required this.cid, + this.messageId, + this.receiverId, + this.title, + this.body, + this.custom = const {}, + }); + + /// Parses a Stream Chat push payload into a [ChatNotification]. + /// + /// Unknown keys are collected into [custom]. Missing required fields + /// default to empty strings so a malformed payload doesn't throw — use + /// [isStreamChat] to filter out non-Stream pushes before acting on them. + factory ChatNotification.fromJson(Map json) { + final processed = Serializer.moveToExtraData(json, _topLevelFields); + return ChatNotification( + sender: processed['sender'] as String? ?? '', + type: processed['type'] as String? ?? '', + cid: processed['cid'] as String? ?? '', + // Stream v2 push templates use `id`; older payloads used `message_id`. + messageId: (processed['id'] ?? processed['message_id']) as String?, + receiverId: processed['receiver_id'] as String?, + title: processed['title'] as String?, + body: processed['body'] as String?, + custom: (processed[Serializer.defaultExtraDataKey] as Map?)?.cast() ?? const {}, + ); + } + + /// Usually `"stream.chat"`. Use [isStreamChat] to filter. + final String sender; + + /// Event type string, e.g. `"message.new"`, `"reaction.new"`, + /// `"notification.reminder_due"`. + final String type; + + /// Channel identifier (`"channel_type:channel_id"`). + final String cid; + + /// ID of the message the event relates to, or `null` if not applicable. + final String? messageId; + + /// Recipient user id as resolved by Stream at send time. + final String? receiverId; + + /// Display title from the APNs/FCM alert payload. + final String? title; + + /// Display body from the APNs/FCM alert payload. + final String? body; + + /// Any payload fields not explicitly modeled above. + final Map custom; + + bool get isStreamChat => sender == 'stream.chat'; + + bool get isMessageNew => type == EventType.messageNew || type == EventType.notificationMessageNew; + + bool get isMessageUpdated => type == EventType.messageUpdated; + + bool get isReactionNew => type == EventType.reactionNew; + + bool get isReminderDue => type == EventType.notificationReminderDue; + + /// Inverse of [fromJson]: emits the push payload shape, with any entries + /// from [custom] flattened back into the root. This way + /// `ChatNotification.fromJson(n.toJson())` round-trips cleanly. + Map toJson() => Serializer.moveFromExtraData({ + 'sender': sender, + 'type': type, + 'cid': cid, + if (messageId != null) 'id': messageId, + if (receiverId != null) 'receiver_id': receiverId, + if (title != null) 'title': title, + if (body != null) 'body': body, + Serializer.defaultExtraDataKey: custom, + }); + + static const _topLevelFields = [ + 'sender', + 'type', + 'cid', + 'id', + 'message_id', + 'receiver_id', + 'title', + 'body', + ]; +} diff --git a/sample_app/lib/notification/notification_background_handler.dart b/sample_app/lib/notification/notification_background_handler.dart new file mode 100644 index 0000000000..5cf3f52db5 --- /dev/null +++ b/sample_app/lib/notification/notification_background_handler.dart @@ -0,0 +1,37 @@ +import 'package:firebase_core/firebase_core.dart'; +import 'package:firebase_messaging/firebase_messaging.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter_local_notifications/flutter_local_notifications.dart' hide Message; +import 'package:sample_app/firebase_options.dart'; +import 'package:sample_app/notification/notification.dart'; +import 'package:sample_app/notification/notification_service.dart'; +import 'package:stream_chat_flutter/stream_chat_flutter.dart'; + +/// Top-level FCM background handler (Android only). +/// +/// Runs in a separate isolate, so Firebase has to be re-initialized +/// from scratch. Renders a local banner for the incoming push. iOS alert +/// pushes are rendered natively by the OS and never reach this code path. +/// +/// Must stay top-level and keep `@pragma('vm:entry-point')` so release +/// tree shaking doesn't strip it. +@pragma('vm:entry-point') +Future onBackgroundMessageHandler(RemoteMessage message) async { + // iOS renders alert pushes natively from `aps.alert`; nothing for us to do. + if (CurrentPlatform.isWeb || !CurrentPlatform.isAndroid) return; + + if (message.data.isEmpty) return; + + final notification = ChatNotification.fromJson(message.data); + if (!notification.isStreamChat) return; + + debugPrint('[notif-bg] type=${notification.type} cid=${notification.cid}'); + + // Background isolate — Firebase must be re-initialized before use. + await Firebase.initializeApp(options: DefaultFirebaseOptions.currentPlatform); + + final notificationService = NotificationService(FlutterLocalNotificationsPlugin()); + if (!await notificationService.initLocalNotifications()) return; + await notificationService.createNotificationChannel(); + await notificationService.showLocalNotification(notification); +} diff --git a/sample_app/lib/notification/notification_service.dart b/sample_app/lib/notification/notification_service.dart new file mode 100644 index 0000000000..cd8556130f --- /dev/null +++ b/sample_app/lib/notification/notification_service.dart @@ -0,0 +1,329 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:collection/collection.dart'; +import 'package:firebase_messaging/firebase_messaging.dart'; +import 'package:flutter/foundation.dart' show debugPrint; +import 'package:flutter_local_notifications/flutter_local_notifications.dart' hide Message; +import 'package:sample_app/notification/notification.dart'; +import 'package:sample_app/notification/notification_background_handler.dart'; +import 'package:stream_chat_flutter/stream_chat_flutter.dart'; + +/// The Android notification-channel id used for all chat notifications. +const notificationChannelId = 'stream_chat_channel'; + +/// The user-facing name of the Android notification channel. +const notificationChannelName = 'Stream Chat Notifications'; + +/// The user-facing description of the Android notification channel. +const notificationChannelDescription = 'Notifications for Stream Chat'; + +/// Orchestrates FCM and `flutter_local_notifications` for the sample +/// app. +/// +/// Foreground pushes are re-rendered as local banners so the app owns +/// the title/body formatting. Background/terminated pushes are rendered +/// by the OS, and taps funnel through [onNotificationTap] regardless of +/// which path delivered them. +class NotificationService { + NotificationService(this._localNotification); + final FlutterLocalNotificationsPlugin _localNotification; + + /// Called once per notification tap. Set before [initialize] so the + /// app-launch notification isn't missed. + set onNotificationTap(OnNotificationTap value) => _onNotificationTap = value; + OnNotificationTap? _onNotificationTap; + + /// Requests permission, registers handlers, and dispatches any + /// pending launch notification. Call once per instance. + Future initialize() async { + _registerMessageHandlers(); + await requestPermission(); + await initLocalNotifications(); + await createNotificationChannel(); + + if (!CurrentPlatform.isWeb && CurrentPlatform.isIos) { + // Foreground presentation is handled entirely by our AppDelegate's + // `willPresent` override; opting out of Firebase's own options keeps + // iOS from double-rendering if the native override is ever removed. + await FirebaseMessaging.instance.setForegroundNotificationPresentationOptions( + alert: false, + badge: false, + sound: false, + ); + } + + final initial = await _getInitialNotification(); + if (initial != null) _dispatchTap(initial, DeviceState.terminated); + debugPrint('[notif] initialize() done'); + } + + /// Requests the OS-level notification permission via Firebase. + /// Returns `true` for full or provisional authorization. + Future requestPermission() async { + final result = await FirebaseMessaging.instance.requestPermission(); + return result.authorizationStatus == .authorized || result.authorizationStatus == .provisional; + } + + var _isLocalNotificationsInitialized = false; + + /// Initializes `flutter_local_notifications`. Idempotent. + Future initLocalNotifications() async { + if (_isLocalNotificationsInitialized) return true; + + const initSettings = InitializationSettings( + iOS: DarwinInitializationSettings( + requestAlertPermission: false, + requestBadgePermission: false, + requestSoundPermission: false, + ), + android: AndroidInitializationSettings('ic_notification_in_app'), + ); + + try { + final result = await _localNotification.initialize( + settings: initSettings, + onDidReceiveNotificationResponse: _onBackgroundLocalMessageTap, + ); + // On iOS the plugin returns null when another component owns the + // UNUserNotificationCenter delegate. Treat null as success — `show()` + // still works through the plugin's own APIs. + _isLocalNotificationsInitialized = result ?? true; + + // iOS: the plugin's internal "permissions requested" flag is only set + // by requestPermissions; without this call `show()` silently no-ops + // even though system-level permissions are granted via Firebase. + if (!CurrentPlatform.isWeb && CurrentPlatform.isIos) { + final iosPlugin = _localNotification + .resolvePlatformSpecificImplementation(); + await iosPlugin?.requestPermissions(alert: true, badge: true, sound: true); + } + } catch (e, stk) { + debugPrint('[notif] initLocalNotifications failed: $e; $stk'); + _isLocalNotificationsInitialized = false; + } + + return _isLocalNotificationsInitialized; + } + + /// Creates the chat notification channel on Android. No-op elsewhere. + Future createNotificationChannel() async { + if (CurrentPlatform.isWeb || !CurrentPlatform.isAndroid) return; + final plugin = _localNotification.resolvePlatformSpecificImplementation(); + if (plugin == null) return; + await plugin.createNotificationChannel( + const AndroidNotificationChannel( + notificationChannelId, + notificationChannelName, + description: notificationChannelDescription, + importance: Importance.high, + ), + ); + } + + /// Renders [notification] as a local banner. The id is derived from + /// the payload so duplicate events replace rather than stack. + /// + /// Notifications are grouped by channel cid: iOS uses `threadIdentifier` + /// to collapse banners automatically; Android needs an explicit summary + /// notification (see [_showAndroidGroupSummary]) on top of `groupKey`. + Future showLocalNotification(ChatNotification notification) async { + final groupKey = notification.cid; + + final androidDetails = AndroidNotificationDetails( + notificationChannelId, + notificationChannelName, + channelDescription: notificationChannelDescription, + importance: Importance.high, + priority: Priority.high, + groupKey: groupKey, + ); + + // `presentAlert` is the pre-iOS 14 flag; on iOS 14+ you must opt in to + // `presentBanner` + `presentList` explicitly or the foreground + // notification is silently dropped. + final iosDetails = DarwinNotificationDetails( + presentAlert: true, + presentBanner: true, + presentList: true, + presentBadge: true, + presentSound: true, + interruptionLevel: InterruptionLevel.active, + threadIdentifier: groupKey, + ); + + final notificationDetails = NotificationDetails(iOS: iosDetails, android: androidDetails); + final payload = jsonEncode({'data': notification.toJson()}); + + // Stable id derived from the event — not `notification.hashCode` — so + // iOS replaces a previous banner for the same message instead of + // stacking a duplicate when onMessage fires twice. + final id = _notificationIdFor(notification); + + debugPrint( + '[notif] showLocalNotification id=$id type=${notification.type} ' + 'cid=${notification.cid} title="${notification.title}" body="${notification.body}"', + ); + + await _localNotification.show( + id: id, + title: notification.title ?? 'New Notification', + body: notification.body ?? 'You have a new notification.', + notificationDetails: notificationDetails, + payload: payload, + ); + + // Android needs an explicit summary notification to visually group the + // children; iOS handles grouping automatically via `threadIdentifier`. + if (!CurrentPlatform.isWeb && CurrentPlatform.isAndroid) { + await _showAndroidGroupSummary(groupKey); + } + } + + /// Posts (or refreshes) the Android summary notification for [groupKey]. + /// + /// Android only collapses children into a group once a summary with the + /// same `groupKey` exists. The summary id is stable per cid so subsequent + /// messages in the same channel update the summary in place. + Future _showAndroidGroupSummary(String groupKey) { + final summaryDetails = AndroidNotificationDetails( + notificationChannelId, + notificationChannelName, + channelDescription: notificationChannelDescription, + importance: Importance.high, + priority: Priority.high, + groupKey: groupKey, + setAsGroupSummary: true, + // Children make the sound; the summary is silent so a new message in + // an already-grouped channel doesn't double-buzz. + groupAlertBehavior: GroupAlertBehavior.children, + ); + + return _localNotification.show( + id: _summaryNotificationIdFor(groupKey), + title: 'New messages', + notificationDetails: NotificationDetails(android: summaryDetails), + ); + } + + static int _notificationIdFor(ChatNotification notification) { + final key = notification.messageId ?? '${notification.type}:${notification.cid}'; + return key.hashCode; + } + + // Different namespace from [_notificationIdFor] so a child notification + // and its group summary can never collide on hashCode. + static int _summaryNotificationIdFor(String groupKey) => 'summary:$groupKey'.hashCode; + + // Initial Notification ----------------------------------------------------- + + /// Returns the Stream Chat notification that launched the app, if + /// any, from either the FCM initial message or a local payload. + Future _getInitialNotification() async { + final results = await Future.wait([_initialFromFcm(), _initialFromLocal()]); + return results.firstWhereOrNull((it) => it != null); + } + + Future _initialFromFcm() async { + final msg = await FirebaseMessaging.instance.getInitialMessage(); + if (msg == null || msg.data.isEmpty) return null; + final n = ChatNotification.fromJson(msg.data); + return n.isStreamChat ? n : null; + } + + Future _initialFromLocal() async { + if (!await initLocalNotifications()) return null; + + final details = await _localNotification.getNotificationAppLaunchDetails(); + if (details == null || !details.didNotificationLaunchApp) return null; + + return _parseLocalPayload(details.notificationResponse?.payload); + } + + // Parses a `flutter_local_notifications` payload (as produced by + // [showLocalNotification]) back into a [ChatNotification]. + static ChatNotification? _parseLocalPayload(String? raw) { + if (raw == null) return null; + final decoded = jsonDecode(raw) as Map; + final data = decoded['data']; + if (data is! Map) return null; + final n = ChatNotification.fromJson(data.cast()); + return n.isStreamChat ? n : null; + } + + // Message Handlers --------------------------------------------------------- + + StreamSubscription? _onMessageSubscription; + StreamSubscription? _onMessageOpenedSubscription; + + void _registerMessageHandlers() { + FirebaseMessaging.onBackgroundMessage(onBackgroundMessageHandler); + _onMessageSubscription = FirebaseMessaging.onMessage.listen(_onForegroundMessage); + _onMessageOpenedSubscription = FirebaseMessaging.onMessageOpenedApp.listen(_onBackgroundMessageTap); + } + + Future _onForegroundMessage(RemoteMessage message) async { + if (CurrentPlatform.isWeb || message.data.isEmpty) return; + + final notification = ChatNotification.fromJson(message.data); + if (!notification.isStreamChat) return; + + debugPrint( + '[notif] onForegroundMessage type=${notification.type} cid=${notification.cid}', + ); + + try { + await showLocalNotification(notification); + } catch (e, stk) { + debugPrint('[notif] showLocalNotification threw: $e; $stk'); + } + } + + void _onBackgroundMessageTap(RemoteMessage message) { + if (message.data.isEmpty) return; + final notification = ChatNotification.fromJson(message.data); + if (!notification.isStreamChat) return; + _dispatchTap(notification, DeviceState.background); + } + + // Fires when the user taps a notification we scheduled ourselves via + // `flutter_local_notifications` (foreground banners). + void _onBackgroundLocalMessageTap(NotificationResponse response) { + final notification = _parseLocalPayload(response.payload); + if (notification == null) return; + _dispatchTap(notification, DeviceState.background); + } + + void _dispatchTap(ChatNotification notification, DeviceState state) { + debugPrint( + '[notif] tap state=$state type=${notification.type} cid=${notification.cid}', + ); + _onNotificationTap?.call(( + deviceState: state, + notification: notification, + )); + } + + /// Cancels every notification currently displayed by the app. + Future clearNotifications() => _localNotification.cancelAll(); + + /// Cancels the FCM subscriptions started in [initialize]. + Future dispose() async { + await _onMessageSubscription?.cancel(); + await _onMessageOpenedSubscription?.cancel(); + _onMessageSubscription = null; + _onMessageOpenedSubscription = null; + } +} + +/// Signature for [NotificationService.onNotificationTap]. +typedef OnNotificationTap = void Function(NotificationInfo info); + +/// The process state of the app when a notification was tapped. +enum DeviceState { foreground, background, terminated } + +/// The payload delivered to [NotificationService.onNotificationTap]. +typedef NotificationInfo = ({ + DeviceState deviceState, + ChatNotification notification, +}); diff --git a/sample_app/lib/pages/advanced_options_page.dart b/sample_app/lib/pages/advanced_options_page.dart index 81ab732c02..d6fc35e73f 100644 --- a/sample_app/lib/pages/advanced_options_page.dart +++ b/sample_app/lib/pages/advanced_options_page.dart @@ -1,12 +1,7 @@ import 'package:flutter/material.dart'; -import 'package:flutter_secure_storage/flutter_secure_storage.dart'; import 'package:go_router/go_router.dart'; -import 'package:provider/provider.dart'; -import 'package:sample_app/app.dart'; -import 'package:sample_app/pages/choose_user_page.dart'; +import 'package:sample_app/auth/auth_controller.dart'; import 'package:sample_app/routes/routes.dart'; -import 'package:sample_app/state/init_data.dart'; -import 'package:sample_app/utils/localizations.dart'; import 'package:sample_app/widgets/stream_version.dart'; import 'package:stream_chat_flutter/stream_chat_flutter.dart'; @@ -72,39 +67,22 @@ class _AdvancedOptionsPageState extends State { ), ); - final client = buildStreamChatClient(apiKey); final router = GoRouter.of(context); - final initNotifier = context.read(); try { - await client.connectUser( - User( + await authController.connect( + apiKey: apiKey, + user: User( id: userId, extraData: { 'name': username, }, ), - userToken, + token: userToken, ); - - const secureStorage = FlutterSecureStorage(); - await Future.wait([ - secureStorage.write( - key: kStreamApiKey, - value: apiKey, - ), - secureStorage.write( - key: kStreamUserId, - value: userId, - ), - secureStorage.write( - key: kStreamToken, - value: userToken, - ), - ]); } catch (e) { debugPrint(e.toString()); - var errorText = AppLocalizations.of(context).errorConnecting; + var errorText = 'Error connecting, retry'; if (e is Map) { errorText = e['message'] ?? errorText; } @@ -116,8 +94,6 @@ class _AdvancedOptionsPageState extends State { return; } loading = false; - initNotifier.initData = initNotifier.initData!.copyWith(client: client); - router.goNamed(Routes.CHOOSE_USER.name); } } @@ -126,23 +102,7 @@ class _AdvancedOptionsPageState extends State { Widget build(BuildContext context) { return Scaffold( backgroundColor: StreamChatTheme.of(context).colorTheme.appBg, - appBar: AppBar( - backgroundColor: StreamChatTheme.of(context).colorTheme.barsBg, - elevation: 1, - centerTitle: true, - title: Text( - AppLocalizations.of(context).advancedOptions, - style: StreamChatTheme.of(context).textTheme.headlineBold.copyWith( - color: StreamChatTheme.of(context).colorTheme.textHighEmphasis), - ), - leading: IconButton( - icon: const StreamSvgIcon(icon: StreamSvgIcons.left), - color: StreamChatTheme.of(context).colorTheme.textHighEmphasis, - onPressed: () { - Navigator.pop(context); - }, - ), - ), + appBar: StreamAppBar(title: const Text('Custom settings')), body: Builder( builder: (context) { return Padding( @@ -164,9 +124,7 @@ class _AdvancedOptionsPageState extends State { validator: (value) { if (value!.isEmpty) { setState(() { - _apiKeyError = AppLocalizations.of(context) - .apiKeyError - .toUpperCase(); + _apiKeyError = 'Please enter the Chat API Key'.toUpperCase(); }); return _apiKeyError; } @@ -174,9 +132,7 @@ class _AdvancedOptionsPageState extends State { }, style: TextStyle( fontSize: 14, - color: StreamChatTheme.of(context) - .colorTheme - .textHighEmphasis, + color: StreamChatTheme.of(context).colorTheme.textHighEmphasis, ), decoration: InputDecoration( errorStyle: const TextStyle(height: 0, fontSize: 0), @@ -185,9 +141,7 @@ class _AdvancedOptionsPageState extends State { fontWeight: FontWeight.bold, color: _apiKeyError != null ? StreamChatTheme.of(context).colorTheme.accentError - : StreamChatTheme.of(context) - .colorTheme - .textLowEmphasis, + : StreamChatTheme.of(context).colorTheme.textLowEmphasis, ), border: UnderlineInputBorder( borderRadius: BorderRadius.circular(8), @@ -196,8 +150,8 @@ class _AdvancedOptionsPageState extends State { fillColor: StreamChatTheme.of(context).colorTheme.inputBg, filled: true, labelText: _apiKeyError != null - ? '${AppLocalizations.of(context).chatApiKey.toUpperCase()}: $_apiKeyError' - : AppLocalizations.of(context).chatApiKey, + ? '${'Chat API Key'.toUpperCase()}: $_apiKeyError' + : 'Chat API Key', ), textInputAction: TextInputAction.next, ), @@ -214,9 +168,7 @@ class _AdvancedOptionsPageState extends State { validator: (value) { if (value!.isEmpty) { setState(() { - _userIdError = AppLocalizations.of(context) - .userIdError - .toUpperCase(); + _userIdError = 'Please enter the User ID'.toUpperCase(); }); return _userIdError; } @@ -224,9 +176,7 @@ class _AdvancedOptionsPageState extends State { }, style: TextStyle( fontSize: 14, - color: StreamChatTheme.of(context) - .colorTheme - .textHighEmphasis, + color: StreamChatTheme.of(context).colorTheme.textHighEmphasis, ), textInputAction: TextInputAction.next, decoration: InputDecoration( @@ -236,9 +186,7 @@ class _AdvancedOptionsPageState extends State { fontSize: 14, color: _userIdError != null ? StreamChatTheme.of(context).colorTheme.accentError - : StreamChatTheme.of(context) - .colorTheme - .textLowEmphasis, + : StreamChatTheme.of(context).colorTheme.textLowEmphasis, ), border: UnderlineInputBorder( borderRadius: BorderRadius.circular(8), @@ -246,9 +194,7 @@ class _AdvancedOptionsPageState extends State { ), fillColor: StreamChatTheme.of(context).colorTheme.inputBg, filled: true, - labelText: _userIdError != null - ? '${AppLocalizations.of(context).userId.toUpperCase()}: $_userIdError' - : AppLocalizations.of(context).userId, + labelText: _userIdError != null ? '${'User ID'.toUpperCase()}: $_userIdError' : 'User ID', ), ), const SizedBox(height: 8), @@ -264,9 +210,7 @@ class _AdvancedOptionsPageState extends State { validator: (value) { if (value!.isEmpty) { setState(() { - _userTokenError = AppLocalizations.of(context) - .userTokenError - .toUpperCase(); + _userTokenError = 'Please enter the user token'.toUpperCase(); }); return _userTokenError; } @@ -274,9 +218,7 @@ class _AdvancedOptionsPageState extends State { }, style: TextStyle( fontSize: 14, - color: StreamChatTheme.of(context) - .colorTheme - .textHighEmphasis, + color: StreamChatTheme.of(context).colorTheme.textHighEmphasis, ), textInputAction: TextInputAction.next, decoration: InputDecoration( @@ -286,9 +228,7 @@ class _AdvancedOptionsPageState extends State { fontSize: 14, color: _userTokenError != null ? StreamChatTheme.of(context).colorTheme.accentError - : StreamChatTheme.of(context) - .colorTheme - .textLowEmphasis, + : StreamChatTheme.of(context).colorTheme.textLowEmphasis, ), border: UnderlineInputBorder( borderRadius: BorderRadius.circular(8), @@ -297,8 +237,8 @@ class _AdvancedOptionsPageState extends State { fillColor: StreamChatTheme.of(context).colorTheme.inputBg, filled: true, labelText: _userTokenError != null - ? '${AppLocalizations.of(context).userToken.toUpperCase()}: $_userTokenError' - : AppLocalizations.of(context).userToken, + ? '${'User Token'.toUpperCase()}: $_userTokenError' + : 'User Token', ), ), const SizedBox(height: 8), @@ -309,9 +249,7 @@ class _AdvancedOptionsPageState extends State { labelStyle: TextStyle( fontSize: 14, fontWeight: FontWeight.bold, - color: StreamChatTheme.of(context) - .colorTheme - .textLowEmphasis, + color: StreamChatTheme.of(context).colorTheme.textLowEmphasis, ), border: UnderlineInputBorder( borderRadius: BorderRadius.circular(8), @@ -319,21 +257,19 @@ class _AdvancedOptionsPageState extends State { ), fillColor: StreamChatTheme.of(context).colorTheme.inputBg, filled: true, - labelText: AppLocalizations.of(context).usernameOptional, + labelText: 'Username (optional)', ), ), const Spacer(), ElevatedButton( style: ButtonStyle( backgroundColor: WidgetStateProperty.all( - Theme.of(context).brightness == Brightness.light - ? StreamChatTheme.of(context) - .colorTheme - .accentPrimary - : Colors.white), + Theme.of(context).brightness == Brightness.light + ? StreamChatTheme.of(context).colorTheme.accentPrimary + : Colors.white, + ), elevation: WidgetStateProperty.all(0), - padding: WidgetStateProperty.all( - const EdgeInsets.symmetric(vertical: 16)), + padding: WidgetStateProperty.all(const EdgeInsets.symmetric(vertical: 16)), shape: WidgetStateProperty.all( RoundedRectangleBorder( borderRadius: BorderRadius.circular(26), @@ -342,13 +278,11 @@ class _AdvancedOptionsPageState extends State { ), onPressed: _login, child: Text( - AppLocalizations.of(context).login, + 'Login', style: TextStyle( fontSize: 16, color: Theme.of(context).brightness != Brightness.light - ? StreamChatTheme.of(context) - .colorTheme - .accentPrimary + ? StreamChatTheme.of(context).colorTheme.accentPrimary : Colors.white, ), ), diff --git a/sample_app/lib/pages/channel_file_display_screen.dart b/sample_app/lib/pages/channel_file_display_screen.dart index 727dd6bb87..5cc931d83a 100644 --- a/sample_app/lib/pages/channel_file_display_screen.dart +++ b/sample_app/lib/pages/channel_file_display_screen.dart @@ -1,158 +1,236 @@ -// ignore_for_file: deprecated_member_use - import 'package:flutter/material.dart'; -import 'package:sample_app/utils/localizations.dart'; import 'package:stream_chat_flutter/stream_chat_flutter.dart'; -import 'package:video_player/video_player.dart'; +/// Lists every file shared in the enclosing channel, grouped by the +/// month each file was sent. +/// +/// Matches Figma frames `8833:437706` (with content) and `8833:437407` +/// (empty). Built on the same [StreamMessageSearchListController] the +/// legacy implementation used, filtered to `attachments.type == 'file'`. class ChannelFileDisplayScreen extends StatefulWidget { - const ChannelFileDisplayScreen({ - super.key, - required this.messageTheme, - }); - final StreamMessageThemeData messageTheme; + /// Creates a [ChannelFileDisplayScreen]. + const ChannelFileDisplayScreen({super.key}); @override - State createState() => - _ChannelFileDisplayScreenState(); + State createState() => _ChannelFileDisplayScreenState(); } class _ChannelFileDisplayScreenState extends State { - final Map controllerCache = {}; - - late final controller = StreamMessageSearchListController( + late final StreamMessageSearchListController _controller = StreamMessageSearchListController( client: StreamChat.of(context).client, - filter: Filter.in_( - 'cid', - [StreamChannel.of(context).channel.cid!], - ), - messageFilter: Filter.in_( - 'attachments.type', - const ['file'], - ), - sort: [ - const SortOption( - 'created_at', - direction: SortOption.ASC, - ), - ], + filter: Filter.in_('cid', [StreamChannel.of(context).channel.cid!]), + messageFilter: Filter.in_('attachments.type', const ['file']), + sort: const [SortOption.desc('created_at')], limit: 20, ); + @override + void initState() { + super.initState(); + _controller.doInitialLoad(); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + @override Widget build(BuildContext context) { + final colorScheme = context.streamColorScheme; return Scaffold( - backgroundColor: StreamChatTheme.of(context).colorTheme.barsBg, - appBar: AppBar( - elevation: 1, - centerTitle: true, - title: Text( - AppLocalizations.of(context).files, - style: TextStyle( - color: StreamChatTheme.of(context).colorTheme.textHighEmphasis, - fontSize: 16, + backgroundColor: colorScheme.backgroundApp, + appBar: StreamAppBar(title: const Text('Files')), + body: ValueListenableBuilder>( + valueListenable: _controller, + builder: (context, value, _) => value.when( + (items, nextPageKey, _) { + // Flatten messages → individual file attachments paired with + // the message timestamp we'll bucket on. + final entries = <_FileEntry>[ + for (final response in items) + for (final attachment in response.message.attachments) + if (attachment.type == 'file') + _FileEntry( + attachment: attachment, + sentAt: response.message.createdAt, + ), + ]; + + if (entries.isEmpty) return const Center(child: _EmptyState()); + + // Pre-build a flat row list — interleave a header row above + // each month bucket so a single ListView.builder can render + // both kinds of rows without a CustomScrollView + slivers. + final rows = _buildRows(entries); + + return LazyLoadScrollView( + onEndOfPage: () async { + if (nextPageKey != null) await _controller.loadMore(nextPageKey); + }, + child: ListView.builder( + itemCount: rows.length, + itemBuilder: (context, index) => rows[index].build(context), + ), + ); + }, + loading: () => const Center(child: StreamScrollViewLoadingWidget()), + error: (_) => Center( + child: StreamScrollViewErrorWidget( + errorTitle: const Text('Failed to load files'), + onRetryPressed: _controller.refresh, + ), ), ), - leading: const StreamBackButton(), - backgroundColor: StreamChatTheme.of(context).colorTheme.barsBg, ), - body: ValueListenableBuilder( - valueListenable: controller, - builder: ( - BuildContext context, - PagedValue value, - Widget? child, - ) { - return value.when( - (items, nextPageKey, error) { - if (items.isEmpty) { - return Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - StreamSvgIcon( - icon: StreamSvgIcons.files, - size: 136, - color: StreamChatTheme.of(context).colorTheme.disabled, - ), - const SizedBox(height: 16), - Text( - AppLocalizations.of(context).noFiles, - style: TextStyle( - fontSize: 14, - color: StreamChatTheme.of(context) - .colorTheme - .textHighEmphasis, - ), - ), - const SizedBox(height: 8), - Text( - AppLocalizations.of(context).filesAppearHere, - textAlign: TextAlign.center, - style: TextStyle( - fontSize: 14, - color: StreamChatTheme.of(context) - .colorTheme - .textHighEmphasis - .withOpacity(0.5), - ), - ), - ], - ), - ); - } - final media = {}; - - for (final item in items) { - item.message.attachments - .where((e) => e.type == 'file') - .forEach((e) { - media[e] = item.message; - }); - } - - return LazyLoadScrollView( - onEndOfPage: () async { - if (nextPageKey != null) { - controller.loadMore(nextPageKey); - } - }, - child: ListView.builder( - itemBuilder: (context, position) { - return Padding( - padding: const EdgeInsets.all(1), - child: Padding( - padding: const EdgeInsets.all(8), - child: StreamFileAttachment( - message: media.values.toList()[position], - file: media.keys.toList()[position], - ), - ), - ); - }, - itemCount: media.length, - ), - ); - }, - loading: () => const Center( - child: CircularProgressIndicator(), - ), - error: (_) => const Offstage(), - ); - }, + ); + } + + List<_Row> _buildRows(List<_FileEntry> entries) { + final rows = <_Row>[]; + DateTime? currentBucket; + for (final entry in entries) { + final bucket = entry.sentAt == null ? null : DateTime(entry.sentAt!.year, entry.sentAt!.month); + if (bucket != currentBucket) { + currentBucket = bucket; + rows.add(_HeaderRow(bucket)); + } + rows.add(_FileRow(entry.attachment)); + } + return rows; + } +} + +/// Single file attachment paired with the message timestamp used to +/// bucket it under a month header. +class _FileEntry { + const _FileEntry({required this.attachment, required this.sentAt}); + + final Attachment attachment; + final DateTime? sentAt; +} + +/// Row shape interface — the [ListView.builder] doesn't care whether it +/// renders a section header or a file row, only that both can build +/// themselves. +sealed class _Row { + Widget build(BuildContext context); +} + +/// Section header above each month bucket — light surface background, +/// bold "Month YYYY" caption (or "Earlier" for entries with no +/// timestamp). +class _HeaderRow implements _Row { + const _HeaderRow(this.bucket); + + final DateTime? bucket; + + @override + Widget build(BuildContext context) { + final spacing = context.streamSpacing; + final colorScheme = context.streamColorScheme; + final textTheme = context.streamTextTheme; + + final label = bucket == null ? 'Earlier' : Jiffy.parseFromDateTime(bucket!).format(pattern: 'MMMM yyyy'); + + return Container( + color: colorScheme.backgroundSurfaceCard, + padding: EdgeInsets.symmetric( + horizontal: spacing.md, + vertical: spacing.xs, + ), + width: double.infinity, + child: Text( + label, + style: textTheme.captionEmphasis.copyWith(color: colorScheme.textPrimary), ), ); } +} + +/// Single file row — leading [StreamFileTypeIcon], filename title, +/// human-readable file size subtitle. Pure preview; tapping doesn't +/// open the file (mirrors the legacy implementation). +class _FileRow implements _Row { + const _FileRow(this.attachment); + + final Attachment attachment; @override - void dispose() { - controller.dispose(); - super.dispose(); + Widget build(BuildContext context) { + return StreamListTile( + leading: StreamFileTypeIcon.fromMimeType( + mimeType: attachment.mimeType, + size: StreamFileTypeIconSize.lg, + ), + title: Text( + attachment.title ?? 'Untitled', + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + subtitle: Text(_humanizeBytes(attachment.fileSize)), + ); } +} + +/// Empty state for [ChannelFileDisplayScreen] — folder icon, "No files" +/// headline, and a textSecondary subtitle ("Share a file to see it +/// here"). +class _EmptyState extends StatelessWidget { + const _EmptyState(); @override - void initState() { - controller.doInitialLoad(); - super.initState(); + Widget build(BuildContext context) { + final spacing = context.streamSpacing; + final colorScheme = context.streamColorScheme; + final textTheme = context.streamTextTheme; + + return Padding( + padding: EdgeInsets.symmetric( + horizontal: spacing.md, + vertical: spacing.xxxl, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + context.streamIcons.folder, + size: 32, + color: colorScheme.textTertiary, + ), + SizedBox(height: spacing.sm), + Text( + 'No files', + style: textTheme.headingSm.copyWith(color: colorScheme.textPrimary), + ), + SizedBox(height: spacing.xxs), + Text( + 'Share a file to see it here', + style: textTheme.captionDefault.copyWith(color: colorScheme.textSecondary), + textAlign: TextAlign.center, + ), + ], + ), + ); + } +} + +/// Pretty-prints a byte count as `B` / `KB` / `MB` / `GB`. Returns an +/// empty string when [bytes] is null so the subtitle row collapses +/// rather than showing a noisy placeholder. +String _humanizeBytes(int? bytes) { + if (bytes == null) return ''; + const units = ['B', 'KB', 'MB', 'GB', 'TB']; + var size = bytes.toDouble(); + var unit = 0; + while (size >= 1024 && unit < units.length - 1) { + size /= 1024; + unit++; } + // Whole-number formatting once we're past kilobytes — matches the + // Figma's "4 MB" / "5 MB" style. Bytes / KB are rare enough in + // practice that the extra precision isn't useful. + final formatted = size >= 10 || unit == 0 ? size.toStringAsFixed(0) : size.toStringAsFixed(1); + return '$formatted ${units[unit]}'; } diff --git a/sample_app/lib/pages/channel_list_page.dart b/sample_app/lib/pages/channel_list_page.dart index b756e231f6..4b7cd032a3 100644 --- a/sample_app/lib/pages/channel_list_page.dart +++ b/sample_app/lib/pages/channel_list_page.dart @@ -2,20 +2,19 @@ import 'dart:async'; -import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_app_badger/flutter_app_badger.dart'; -import 'package:flutter_secure_storage/flutter_secure_storage.dart'; import 'package:go_router/go_router.dart'; +import 'package:sample_app/auth/auth_controller.dart'; +import 'package:sample_app/config/sample_app_config.dart'; +import 'package:sample_app/config/sample_app_config_screen.dart'; import 'package:sample_app/pages/draft_list_page.dart'; import 'package:sample_app/pages/reminders_page.dart'; import 'package:sample_app/pages/thread_list_page.dart'; -import 'package:sample_app/pages/user_mentions_page.dart'; import 'package:sample_app/routes/routes.dart'; -import 'package:sample_app/utils/localizations.dart'; +import 'package:sample_app/utils/shared_location_service.dart'; import 'package:sample_app/widgets/channel_list.dart'; import 'package:stream_chat_flutter/stream_chat_flutter.dart'; -import 'package:streaming_shared_preferences/streaming_shared_preferences.dart'; class ChannelListPage extends StatefulWidget { const ChannelListPage({ @@ -29,121 +28,89 @@ class ChannelListPage extends StatefulWidget { class _ChannelListPageState extends State { int _currentIndex = 0; - bool _isSelected(int index) => _currentIndex == index; + late final _locationService = SharedLocationService( + client: StreamChat.of(context).client, + ); - List get _navBarItems { - return [ - BottomNavigationBarItem( - icon: Stack( - clipBehavior: Clip.none, - children: [ - StreamSvgIcon( - icon: StreamSvgIcons.message, - color: _isSelected(0) - ? StreamChatTheme.of(context).colorTheme.textHighEmphasis - : Colors.grey, - ), - PositionedDirectional( - top: -4, - start: 12, - child: StreamUnreadIndicator(), - ), - ], - ), - label: AppLocalizations.of(context).chats, - ), - BottomNavigationBarItem( - icon: StreamSvgIcon( - icon: StreamSvgIcons.mentions, - color: _isSelected(1) - ? StreamChatTheme.of(context).colorTheme.textHighEmphasis - : Colors.grey, - ), - label: AppLocalizations.of(context).mentions, + @override + Widget build(BuildContext context) { + final user = StreamChat.of(context).currentUser; + if (user == null) return const Offstage(); + + final icons = context.streamIcons; + final textTheme = context.streamTextTheme; + final colorScheme = context.streamColorScheme; + + final config = context.sampleAppConfig; + + final allTabs = <_TabDef>[ + _TabDef( + icon: StreamUnreadIndicator(child: Icon(icons.messageBubble)), + selectedIcon: StreamUnreadIndicator(child: Icon(icons.messageBubbleFill)), + label: 'Chats', + page: const ChannelList(), ), - BottomNavigationBarItem( - icon: Stack( - clipBehavior: Clip.none, - children: [ - Icon( - Icons.message_outlined, - color: _isSelected(2) - ? StreamChatTheme.of(context).colorTheme.textHighEmphasis - : Colors.grey, - ), - PositionedDirectional( - top: -4, - start: 12, - child: StreamUnreadIndicator.threads(), - ), - ], - ), + _TabDef( + icon: StreamUnreadIndicator.threads(child: Icon(icons.thread)), + selectedIcon: StreamUnreadIndicator.threads(child: Icon(icons.threadFill)), label: 'Threads', + page: const ThreadListPage(), ), - BottomNavigationBarItem( - icon: Icon( - Icons.edit_note_rounded, - color: _isSelected(3) - ? StreamChatTheme.of(context).colorTheme.textHighEmphasis - : Colors.grey, - ), + _TabDef( + icon: const Icon(Icons.drafts_outlined), + selectedIcon: const Icon(Icons.drafts_rounded), label: 'Drafts', + page: const DraftListPage(), + enabled: config.draftMessagesEnabled, ), - BottomNavigationBarItem( - icon: Icon( - Icons.bookmark_border_rounded, - color: _isSelected(4) - ? StreamChatTheme.of(context).colorTheme.textHighEmphasis - : Colors.grey, - ), + _TabDef( + icon: const Icon(Icons.bookmark_outline_rounded), + selectedIcon: const Icon(Icons.bookmark_rounded), label: 'Reminders', + page: const RemindersPage(), + enabled: config.enableReminderActions, ), ]; - } - @override - Widget build(BuildContext context) { - final user = StreamChat.of(context).currentUser; - if (user == null) { - return const Offstage(); - } + final enabledTabs = allTabs.where((t) => t.enabled).toList(); + return Scaffold( - backgroundColor: StreamChatTheme.of(context).colorTheme.appBg, + backgroundColor: colorScheme.backgroundApp, appBar: StreamChannelListHeader( - onNewChatButtonTap: () { - GoRouter.of(context).pushNamed(Routes.NEW_CHAT.name); - }, - preNavigationCallback: () => - FocusScope.of(context).requestFocus(FocusNode()), + title: Text(enabledTabs[_currentIndex].label, style: textTheme.headingSm), ), - drawer: LeftDrawer( - user: user, - ), - drawerEdgeDragWidth: 50, - bottomNavigationBar: BottomNavigationBar( - backgroundColor: StreamChatTheme.of(context).colorTheme.barsBg, - currentIndex: _currentIndex, - items: _navBarItems, - selectedLabelStyle: StreamChatTheme.of(context).textTheme.footnoteBold, - unselectedLabelStyle: - StreamChatTheme.of(context).textTheme.footnoteBold, - type: BottomNavigationBarType.fixed, - selectedItemColor: - StreamChatTheme.of(context).colorTheme.textHighEmphasis, - unselectedItemColor: Colors.grey, - onTap: (index) { - setState(() => _currentIndex = index); - }, + drawer: LeftDrawer(user: user), + bottomNavigationBar: DecoratedBox( + decoration: BoxDecoration( + color: colorScheme.backgroundElevation1, + border: Border(top: BorderSide(color: colorScheme.borderSubtle)), + ), + child: StreamBadgeNotificationTheme( + data: const .new(size: .xs), + child: BottomNavigationBar( + elevation: 0, + iconSize: 20, + currentIndex: _currentIndex, + type: BottomNavigationBarType.fixed, + selectedItemColor: colorScheme.textPrimary, + unselectedItemColor: colorScheme.textTertiary, + backgroundColor: Colors.transparent, + selectedLabelStyle: textTheme.metadataEmphasis, + unselectedLabelStyle: textTheme.metadataEmphasis, + onTap: (index) => setState(() => _currentIndex = index), + items: enabledTabs.map((tab) { + return BottomNavigationBarItem( + icon: tab.icon, + activeIcon: tab.selectedIcon, + label: tab.label, + ); + }).toList(), + ), + ), ), body: IndexedStack( index: _currentIndex, - children: const [ - ChannelList(), - UserMentionsPage(), - ThreadListPage(), - DraftListPage(), - RemindersPage(), - ], + children: [for (final tab in enabledTabs) tab.page], ), ); } @@ -152,12 +119,9 @@ class _ChannelListPageState extends State { @override void initState() { - if (!kIsWeb) { - badgeListener = StreamChat.of(context) - .client - .state - .totalUnreadCountStream - .listen((count) { + super.initState(); + if (!CurrentPlatform.isWeb) { + badgeListener = StreamChat.of(context).client.state.totalUnreadCountStream.listen((count) { if (count > 0) { FlutterAppBadger.updateBadgeCount(count); } else { @@ -165,16 +129,34 @@ class _ChannelListPageState extends State { } }); } - super.initState(); + + _locationService.initialize(); } @override void dispose() { badgeListener?.cancel(); + _locationService.dispose(); super.dispose(); } } +class _TabDef { + const _TabDef({ + required this.icon, + required this.selectedIcon, + required this.label, + required this.page, + this.enabled = true, + }); + + final Widget icon; + final Widget selectedIcon; + final String label; + final Widget page; + final bool enabled; +} + class LeftDrawer extends StatelessWidget { const LeftDrawer({ super.key, @@ -185,131 +167,138 @@ class LeftDrawer extends StatelessWidget { @override Widget build(BuildContext context) { + final colorScheme = context.streamColorScheme; + final textTheme = context.streamTextTheme; + final icons = context.streamIcons; + final spacing = context.streamSpacing; + final config = context.sampleAppConfig; + final isDark = Theme.of(context).brightness == Brightness.dark; + return Drawer( - child: ColoredBox( - color: StreamChatTheme.of(context).colorTheme.barsBg, - child: SafeArea( - child: Padding( - padding: EdgeInsets.only( - top: MediaQuery.of(context).viewPadding.top + 8, - ), - child: Column( - children: [ - Padding( - padding: const EdgeInsets.only( - bottom: 20, - left: 8, - ), - child: Row( - children: [ - StreamUserAvatar( - user: user, - showOnlineStatus: false, - constraints: - BoxConstraints.tight(const Size.fromRadius(20)), - ), - Padding( - padding: const EdgeInsets.only(left: 16), - child: Text( - user.name, - style: const TextStyle( - fontSize: 16, - fontWeight: FontWeight.bold, - ), - ), - ), - ], - ), - ), - ListTile( - leading: StreamSvgIcon( - icon: StreamSvgIcons.penWrite, - color: StreamChatTheme.of(context) - .colorTheme - .textHighEmphasis - .withOpacity(.5), + shape: const RoundedRectangleBorder(), + backgroundColor: colorScheme.backgroundElevation1, + child: SafeArea( + child: Column( + children: [ + SizedBox(height: spacing.lg), + + // ── Profile header ── + Padding( + padding: EdgeInsets.symmetric(horizontal: spacing.lg), + child: Column( + children: [ + StreamUserAvatar(size: .xxl, user: user, showOnlineIndicator: false), + SizedBox(height: spacing.sm), + Text( + user.name, + style: textTheme.headingMd.copyWith(color: colorScheme.textPrimary), + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.center, ), - onTap: () { - Navigator.of(context).pop(); - GoRouter.of(context).pushNamed(Routes.NEW_CHAT.name); - }, - title: Text( - AppLocalizations.of(context).newDirectMessage, - style: const TextStyle( - fontSize: 14.5, - ), + SizedBox(height: spacing.xxxs), + Text( + '@${user.id}', + style: textTheme.captionDefault.copyWith(color: colorScheme.textTertiary), + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.center, ), - ), - ListTile( - leading: StreamSvgIcon( - color: StreamChatTheme.of(context) - .colorTheme - .textHighEmphasis - .withOpacity(.5), - icon: StreamSvgIcons.contacts, + ], + ), + ), + + SizedBox(height: spacing.lg), + Divider(height: 1, indent: spacing.md, endIndent: spacing.md, color: colorScheme.borderSubtle), + SizedBox(height: spacing.sm), + + // ── Navigation actions ── + Padding( + padding: EdgeInsets.symmetric(horizontal: spacing.xs), + child: Column( + children: [ + StreamListTile( + contentPadding: EdgeInsets.symmetric(horizontal: spacing.sm, vertical: spacing.xxxs), + leading: Icon(icons.edit, size: 24), + title: const Text('New direct message'), + onTap: () { + Navigator.of(context).pop(); + GoRouter.of(context).pushNamed(Routes.NEW_CHAT.name); + }, ), - onTap: () { - Navigator.of(context).pop(); - GoRouter.of(context).pushNamed(Routes.NEW_GROUP_CHAT.name); - }, - title: Text( - AppLocalizations.of(context).newGroup, - style: const TextStyle( - fontSize: 14.5, - ), + StreamListTile( + contentPadding: EdgeInsets.symmetric(horizontal: spacing.sm, vertical: spacing.xxxs), + leading: Icon(icons.users, size: 24), + title: const Text('New group'), + onTap: () { + Navigator.of(context).pop(); + GoRouter.of(context).pushNamed(Routes.NEW_GROUP_CHAT.name); + }, ), - ), - Expanded( - child: Container( - alignment: Alignment.bottomCenter, - child: ListTile( - onTap: () async { - final client = StreamChat.of(context).client; - final router = GoRouter.of(context); + ], + ), + ), - if (!kIsWeb) { - const secureStorage = FlutterSecureStorage(); - await secureStorage.deleteAll(); - } + SizedBox(height: spacing.xs), + Divider(height: 1, indent: spacing.md, endIndent: spacing.md, color: colorScheme.borderSubtle), + SizedBox(height: spacing.xs), - await client.disconnectUser(flushChatPersistence: true); + // ── Configuration ── + Padding( + padding: EdgeInsets.symmetric(horizontal: spacing.xs), + child: StreamListTile( + contentPadding: EdgeInsets.symmetric(horizontal: spacing.sm, vertical: spacing.xxxs), + leading: const Icon(Icons.tune_outlined, size: 24), + title: const Text('Configuration'), + trailing: Icon(Icons.chevron_right_outlined, size: 20, color: colorScheme.textTertiary), + onTap: () { + Navigator.of(context).pop(); + Navigator.of(context).push( + MaterialPageRoute(builder: (_) => const SampleAppConfigScreen()), + ); + }, + ), + ), - router.goNamed(Routes.CHOOSE_USER.name); - }, - leading: StreamSvgIcon( - icon: StreamSvgIcons.user, - color: StreamChatTheme.of(context) - .colorTheme - .textHighEmphasis - .withOpacity(.5), - ), + const Spacer(), + + // ── Bottom actions ── + Divider(height: 1, indent: spacing.md, endIndent: spacing.md, color: colorScheme.borderSubtle), + Padding( + padding: EdgeInsets.symmetric(horizontal: spacing.xs), + child: Row( + children: [ + Expanded( + child: StreamListTile( + contentPadding: EdgeInsets.symmetric(horizontal: spacing.sm, vertical: spacing.xxxs), + leading: Icon(icons.leave, size: 24, color: colorScheme.accentError), title: Text( - AppLocalizations.of(context).signOut, - style: const TextStyle( - fontSize: 14.5, - ), - ), - trailing: IconButton( - iconSize: 24, - icon: const StreamSvgIcon(icon: StreamSvgIcons.moon), - color: StreamChatTheme.of(context) - .colorTheme - .textLowEmphasis, - onPressed: () async { - final theme = Theme.of(context); - final sp = await StreamingSharedPreferences.instance; - sp.setInt( - 'theme', - theme.brightness == Brightness.dark ? 1 : -1, - ); - }, + 'Sign out', + style: textTheme.bodyDefault.copyWith(color: colorScheme.accentError), ), + titleTextStyle: textTheme.bodyDefault.copyWith(color: colorScheme.accentError), + onTap: () async { + final router = GoRouter.of(context); + await authController.disconnect(); + router.goNamed(Routes.CHOOSE_USER.name); + }, + ), + ), + IconButton( + icon: Icon( + isDark ? Icons.light_mode_outlined : Icons.dark_mode_outlined, + color: colorScheme.textSecondary, ), + onPressed: () { + final newMode = isDark ? ThemeMode.light : ThemeMode.dark; + SampleAppConfig.update(context, config.copyWith(themeMode: newMode)); + }, ), - ), - ], + SizedBox(width: spacing.xxs), + ], + ), ), - ), + + SizedBox(height: spacing.md), + ], ), ), ); diff --git a/sample_app/lib/pages/channel_media_display_screen.dart b/sample_app/lib/pages/channel_media_display_screen.dart index d7b7a472d1..aea698f166 100644 --- a/sample_app/lib/pages/channel_media_display_screen.dart +++ b/sample_app/lib/pages/channel_media_display_screen.dart @@ -1,223 +1,143 @@ -// ignore_for_file: deprecated_member_use - import 'package:flutter/material.dart'; -import 'package:go_router/go_router.dart'; -import 'package:sample_app/routes/routes.dart'; -import 'package:sample_app/utils/localizations.dart'; import 'package:stream_chat_flutter/stream_chat_flutter.dart'; -import 'package:video_player/video_player.dart'; +/// Lists every photo + video shared in the enclosing channel as a 3-up +/// grid. Tapping a tile opens [StreamMediaGalleryPreview]. +/// +/// Matches Figma frames `8833:437788` (grid), `13495:418984` (scrolled), +/// and `8833:437329` (empty). class ChannelMediaDisplayScreen extends StatefulWidget { - const ChannelMediaDisplayScreen({ - super.key, - required this.messageTheme, - }); - final StreamMessageThemeData messageTheme; + /// Creates a [ChannelMediaDisplayScreen]. + const ChannelMediaDisplayScreen({super.key}); @override - State createState() => - _ChannelMediaDisplayScreenState(); + State createState() => _ChannelMediaDisplayScreenState(); } class _ChannelMediaDisplayScreenState extends State { - final Map controllerCache = {}; - - late final controller = StreamMessageSearchListController( + late final StreamMessageSearchListController _controller = StreamMessageSearchListController( client: StreamChat.of(context).client, - filter: Filter.in_( - 'cid', - [StreamChannel.of(context).channel.cid!], - ), - messageFilter: Filter.in_( - 'attachments.type', - const ['image', 'video'], - ), - sort: [ - const SortOption( - 'created_at', - direction: SortOption.ASC, - ), - ], + filter: Filter.in_('cid', [StreamChannel.of(context).channel.cid!]), + messageFilter: Filter.in_('attachments.type', const ['image', 'video']), + sort: const [SortOption.asc('created_at')], limit: 20, ); + @override + void initState() { + super.initState(); + _controller.doInitialLoad(); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + @override Widget build(BuildContext context) { + final colorScheme = context.streamColorScheme; return Scaffold( - backgroundColor: StreamChatTheme.of(context).colorTheme.barsBg, - appBar: AppBar( - elevation: 1, - centerTitle: true, - title: Text( - AppLocalizations.of(context).photosAndVideos, - style: TextStyle( - color: StreamChatTheme.of(context).colorTheme.textHighEmphasis, - fontSize: 16, - ), - ), - leading: const StreamBackButton(), - backgroundColor: StreamChatTheme.of(context).colorTheme.barsBg, - ), - body: ValueListenableBuilder( - valueListenable: controller, - builder: (BuildContext context, - PagedValue value, Widget? child) { - return value.when( - (items, nextPageKey, error) { - if (items.isEmpty) { - return Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - StreamSvgIcon( - icon: StreamSvgIcons.pictures, - size: 136, - color: StreamChatTheme.of(context).colorTheme.disabled, - ), - const SizedBox(height: 16), - Text( - AppLocalizations.of(context).noMedia, - style: TextStyle( - fontSize: 14, - color: StreamChatTheme.of(context) - .colorTheme - .textHighEmphasis, - ), - ), - const SizedBox(height: 8), - Text( - AppLocalizations.of(context) - .photosOrVideosWillAppearHere, - textAlign: TextAlign.center, - style: TextStyle( - fontSize: 14, - color: StreamChatTheme.of(context) - .colorTheme - .textHighEmphasis - .withOpacity(0.5), - ), - ), - ], - ), - ); - } - final media = <_AssetPackage>[]; - - for (final item in value.asSuccess.items) { - item.message.attachments - .where((e) => - (e.type == 'image' || e.type == 'video') && - e.ogScrapeUrl == null) - .forEach((e) { - VideoPlayerController? controller; - if (e.type == 'video') { - final cachedController = controllerCache[e.assetUrl]; + backgroundColor: colorScheme.backgroundApp, + appBar: StreamAppBar(title: Text(context.translations.photosAndVideosLabel)), + body: ValueListenableBuilder>( + valueListenable: _controller, + builder: (context, value, _) => value.when( + (items, nextPageKey, _) { + // Flatten messages → individual image/video attachments. + // Excludes link previews (`ogScrapeUrl != null`) so we don't + // render every shared URL's thumbnail in the grid. + final attachments = [ + for (final response in items) + ...response.message.toMediaGalleryAttachments( + filter: (a) => + (a.type == AttachmentType.image || a.type == AttachmentType.video) && a.ogScrapeUrl == null, + ), + ]; - if (cachedController == null) { - final url = Uri.parse(e.assetUrl!); - controller = VideoPlayerController.networkUrl(url); - controller.initialize(); - controllerCache[e.assetUrl] = controller; - } else { - controller = cachedController; - } - } - media.add(_AssetPackage(e, item.message, controller)); - }); - } + if (attachments.isEmpty) return const Center(child: _EmptyState()); - return LazyLoadScrollView( - onEndOfPage: () async { - if (nextPageKey != null) { - controller.loadMore(nextPageKey); - } - }, - child: GridView.builder( - gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( - crossAxisCount: 3), - itemBuilder: (context, position) { - final channel = StreamChannel.of(context).channel; - return Padding( - padding: const EdgeInsets.all(1), - child: InkWell( - onTap: () { - Navigator.push( - context, - MaterialPageRoute( - builder: (context) => StreamChannel( - channel: channel, - child: StreamFullScreenMedia( - mediaAttachmentPackages: media - .map( - (e) => StreamAttachmentPackage( - attachment: e.attachment, - message: e.message, - ), - ) - .toList(), - startIndex: position, - userName: media[position].message.user!.name, - onShowMessage: (m, c) async { - final router = GoRouter.of(context); - if (channel.state == null) { - await channel.watch(); - } - router.pushNamed( - Routes.CHANNEL_PAGE.name, - pathParameters: - Routes.CHANNEL_PAGE.params(channel), - queryParameters: - Routes.CHANNEL_PAGE.queryParams(m), - ); - }, - ), - ), - ), - ); - }, - child: media[position].attachment.type == 'image' - ? IgnorePointer( - child: StreamImageAttachment( - image: media[position].attachment, - message: media[position].message, - // showTitle: false, - // messageTheme: widget.messageTheme, - ), - ) - : VideoPlayer(media[position].videoPlayer!), - ), - ); - }, - itemCount: media.length, - ), - ); - }, - loading: () => const Center( - child: CircularProgressIndicator(), + return LazyLoadScrollView( + onEndOfPage: () async { + if (nextPageKey != null) await _controller.loadMore(nextPageKey); + }, + child: StreamMediaGallery( + attachments: attachments, + onItemTap: (index) => _openPreview(context, attachments, index), + ), + ); + }, + loading: () => const Center(child: StreamScrollViewLoadingWidget()), + error: (_) => Center( + child: StreamScrollViewErrorWidget( + errorTitle: const Text('Failed to load media'), + onRetryPressed: _controller.refresh, ), - error: (_) => const Offstage(), - ); - }, + ), + ), ), ); } - @override - void dispose() { - controller.dispose(); - super.dispose(); + void _openPreview( + BuildContext context, + List attachments, + int index, + ) { + final channel = StreamChannel.of(context).channel; + Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => StreamChannel( + channel: channel, + child: StreamMediaGalleryPreview( + attachments: attachments, + initialIndex: index, + ), + ), + ), + ); } +} + +/// Empty state for [ChannelMediaDisplayScreen] — image icon, "No photos +/// or videos" headline, and a centered subtitle ("Share a photo or +/// video to see it here"). +class _EmptyState extends StatelessWidget { + const _EmptyState(); @override - void initState() { - controller.doInitialLoad(); - super.initState(); - } -} + Widget build(BuildContext context) { + final spacing = context.streamSpacing; + final colorScheme = context.streamColorScheme; + final textTheme = context.streamTextTheme; -class _AssetPackage { - _AssetPackage(this.attachment, this.message, this.videoPlayer); - Attachment attachment; - Message message; - VideoPlayerController? videoPlayer; + return Padding( + padding: EdgeInsets.symmetric( + horizontal: spacing.md, + vertical: spacing.xxxl, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + context.streamIcons.image, + size: 32, + color: colorScheme.textTertiary, + ), + SizedBox(height: spacing.sm), + Text( + 'No photos or videos', + style: textTheme.headingSm.copyWith(color: colorScheme.textPrimary), + ), + SizedBox(height: spacing.xxs), + Text( + 'Share a photo or video to see it here', + style: textTheme.captionDefault.copyWith(color: colorScheme.textSecondary), + textAlign: TextAlign.center, + ), + ], + ), + ); + } } diff --git a/sample_app/lib/pages/channel_page.dart b/sample_app/lib/pages/channel_page.dart index d1e8e213c8..68662c267f 100644 --- a/sample_app/lib/pages/channel_page.dart +++ b/sample_app/lib/pages/channel_page.dart @@ -1,12 +1,12 @@ -// ignore_for_file: deprecated_member_use +// ignore_for_file: deprecated_member_use, avoid_redundant_argument_values -import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; +import 'package:sample_app/config/sample_app_config.dart'; import 'package:sample_app/pages/thread_page.dart'; import 'package:sample_app/routes/routes.dart'; -import 'package:sample_app/widgets/message_info_sheet.dart'; -import 'package:sample_app/widgets/reminder_dialog.dart'; +import 'package:sample_app/widgets/location/location_picker_dialog.dart'; +import 'package:sample_app/widgets/location/location_picker_option.dart'; import 'package:stream_chat_flutter/stream_chat_flutter.dart'; class ChannelPage extends StatefulWidget { @@ -26,8 +26,7 @@ class ChannelPage extends StatefulWidget { class _ChannelPageState extends State { FocusNode? _focusNode; - final StreamMessageInputController _messageInputController = - StreamMessageInputController(); + final _messageComposerController = StreamMessageComposerController(); @override void initState() { @@ -38,11 +37,19 @@ class _ChannelPageState extends State { @override void dispose() { _focusNode!.dispose(); + _messageComposerController.dispose(); super.dispose(); } void _reply(Message message) { - _messageInputController.quotedMessage = message; + _messageComposerController.quotedMessage = message; + WidgetsBinding.instance.addPostFrameCallback((timeStamp) { + _focusNode!.requestFocus(); + }); + } + + void _editMessage(Message message) { + _messageComposerController.editMessage(message); WidgetsBinding.instance.addPostFrameCallback((timeStamp) { _focusNode!.requestFocus(); }); @@ -54,33 +61,20 @@ class _ChannelPageState extends State { final textTheme = theme.textTheme; final colorTheme = theme.colorTheme; + final channel = StreamChannel.of(context).channel; + final config = channel.config; + return Scaffold( backgroundColor: colorTheme.appBg, appBar: StreamChannelHeader( - showTypingIndicator: false, - onBackPressed: () => GoRouter.of(context).pop(), - onImageTap: () async { - final channel = StreamChannel.of(context).channel; - final router = GoRouter.of(context); + onChannelAvatarPressed: (channel) { + final isOneToOne = channel.isOneToOne; + final currentUserId = StreamChat.of(context).currentUser?.id; - if (channel.memberCount == 2 && channel.isDistinct) { - final currentUser = StreamChat.of(context).currentUser; - final otherUser = channel.state!.members.firstWhereOrNull( - (element) => element.user!.id != currentUser!.id, - ); - if (otherUser != null) { - router.pushNamed( - Routes.CHAT_INFO_SCREEN.name, - pathParameters: Routes.CHAT_INFO_SCREEN.params(channel), - extra: otherUser.user, - ); - } - } else { - GoRouter.of(context).pushNamed( - Routes.GROUP_INFO_SCREEN.name, - pathParameters: Routes.GROUP_INFO_SCREEN.params(channel), - ); - } + final channelMembers = channel.state?.members ?? []; + final otherUser = isOneToOne ? channelMembers.firstWhere((m) => m.userId != currentUserId).user : null; + + _pushChannelInfo(context, channel, otherUser); }, ), body: Column( @@ -92,9 +86,9 @@ class _ChannelPageState extends State { initialScrollIndex: widget.initialScrollIndex, initialAlignment: widget.initialAlignment, highlightInitialMessage: widget.highlightInitialMessage, - //onMessageSwiped: _reply, - messageFilter: defaultFilter, - messageBuilder: customMessageBuilder, + onEditMessageTap: _editMessage, + onReplyTap: _reply, + swipeToReply: true, threadBuilder: (_, parentMessage) { return ThreadPage(parent: parentMessage!); }, @@ -120,188 +114,98 @@ class _ChannelPageState extends State { ], ), ), - StreamMessageInput( - focusNode: _focusNode, - messageInputController: _messageInputController, - onQuotedMessageCleared: _messageInputController.clearQuotedMessage, - enableVoiceRecording: true, + Builder( + builder: (context) { + final appConfig = context.sampleAppConfig; + final locationEnabled = + appConfig.enableLocationSharing && config?.sharedLocations == true && channel.canShareLocation; + + return StreamMessageComposer( + focusNode: _focusNode, + messageComposerController: _messageComposerController, + onQuotedMessageCleared: _messageComposerController.clearQuotedMessage, + enableVoiceRecording: true, + allowedAttachmentPickerTypes: [ + ...AttachmentPickerType.values, + if (locationEnabled) const LocationPickerType(), + ], + onAttachmentPickerResult: (result) { + return _onCustomAttachmentPickerResult(channel, result); + }, + attachmentPickerOptionsBuilder: (context, defaultOptions) => [ + ...defaultOptions, + if (locationEnabled) + TabbedAttachmentPickerOption( + key: 'location-picker', + icon: context.streamIcons.location, + supportedTypes: [const LocationPickerType()], + isEnabled: (value) { + if (value.isEmpty) return true; + return value.extraData['location'] != null; + }, + optionViewBuilder: (context, controller) => LocationPicker( + onLocationPicked: (locationResult) { + if (locationResult == null) return; + + controller.notifyCustomResult( + LocationPicked(location: locationResult), + ); + }, + ), + ), + ], + ); + }, ), ], ), ); } - Widget customMessageBuilder( - BuildContext context, - MessageDetails details, - List messages, - StreamMessageWidget defaultMessageWidget, + bool _onCustomAttachmentPickerResult( + Channel channel, + StreamAttachmentPickerResult result, ) { - final theme = StreamChatTheme.of(context); - final textTheme = theme.textTheme; - final colorTheme = theme.colorTheme; - - final message = details.message; - final reminder = message.reminder; - final channelConfig = StreamChannel.of(context).channel.config; - - final customOptions = [ - if (channelConfig?.userMessageReminders == true) ...[ - if (reminder != null) ...[ - StreamMessageAction( - leading: StreamSvgIcon( - icon: StreamSvgIcons.time, - color: colorTheme.textLowEmphasis, - ), - title: const Text('Edit Reminder'), - onTap: (message) async { - Navigator.of(context).pop(); - - final option = await showDialog( - context: context, - builder: (_) => EditReminderDialog( - isBookmarkReminder: reminder.remindAt == null, - ), - ); - - if (option == null) return; - final client = StreamChat.of(context).client; - final messageId = message.id; - final remindAt = option.remindAt; - - client.updateReminder(messageId, remindAt: remindAt).ignore(); - }, - ), - StreamMessageAction( - leading: StreamSvgIcon( - icon: StreamSvgIcons.checkAll, - color: colorTheme.textLowEmphasis, - ), - title: const Text('Remove from later'), - onTap: (message) { - Navigator.of(context).pop(); - - final client = StreamChat.of(context).client; - final messageId = message.id; - - client.deleteReminder(messageId).ignore(); - }, - ), - ] else ...[ - StreamMessageAction( - leading: StreamSvgIcon( - icon: StreamSvgIcons.time, - color: colorTheme.textLowEmphasis, - ), - title: const Text('Remind me'), - onTap: (message) async { - Navigator.of(context).pop(); - - final reminder = await showDialog( - context: context, - builder: (_) => const CreateReminderDialog(), - ); + if (result is LocationPicked) { + _onShareLocationPicked(channel, result.location).ignore(); + return true; // Notify that the result was handled. + } - if (reminder == null) return; - final client = StreamChat.of(context).client; - final messageId = message.id; - final remindAt = reminder.remindAt; - - client.createReminder(messageId, remindAt: remindAt).ignore(); - }, - ), - StreamMessageAction( - leading: Icon( - Icons.bookmark_border, - color: colorTheme.textLowEmphasis, - ), - title: const Text('Save for later'), - onTap: (message) { - Navigator.of(context).pop(); - - final client = StreamChat.of(context).client; - final messageId = message.id; + return false; // Notify that the result was not handled. + } - client.createReminder(messageId).ignore(); - }, - ), - ], - ], - if (channelConfig?.deliveryEvents == true) - StreamMessageAction( - leading: Icon( - Icons.info_outline_rounded, - color: colorTheme.textLowEmphasis, - ), - title: const Text('Message Info'), - onTap: (message) { - Navigator.of(context).pop(); - MessageInfoSheet.show(context: context, message: message); - }, - ), - ]; + Future _onShareLocationPicked( + Channel channel, + LocationPickerResult result, + ) async { + if (result.endSharingAt case final endSharingAt?) { + return channel.startLiveLocationSharing( + endSharingAt: endSharingAt, + location: result.coordinates, + ); + } + + return channel.sendStaticLocation(location: result.coordinates); + } +} - return Container( - color: reminder != null ? colorTheme.accentPrimary.withOpacity(.1) : null, - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - if (reminder != null) - Align( - alignment: switch (defaultMessageWidget.reverse) { - true => AlignmentDirectional.centerEnd, - false => AlignmentDirectional.centerStart, - }, - child: Padding( - padding: const EdgeInsetsDirectional.fromSTEB(16, 4, 16, 8), - child: Row( - spacing: 4, - mainAxisSize: MainAxisSize.min, - children: [ - Icon( - size: 16, - Icons.bookmark_rounded, - color: colorTheme.accentPrimary, - ), - Text( - 'Saved for later', - style: textTheme.footnote.copyWith( - color: colorTheme.accentPrimary, - ), - ), - ], - ), - ), - ), - defaultMessageWidget.copyWith( - onReplyTap: _reply, - customActions: customOptions, - onShowMessage: (message, channel) => GoRouter.of(context).goNamed( - Routes.CHANNEL_PAGE.name, - pathParameters: Routes.CHANNEL_PAGE.params(channel), - queryParameters: Routes.CHANNEL_PAGE.queryParams(message), - ), - bottomRowBuilderWithDefaultWidget: (_, __, defaultWidget) { - return defaultWidget.copyWith( - deletedBottomRowBuilder: (context, message) { - return const StreamVisibleFootnote(); - }, - ); - }, - ), - // If the message has a reminder, add some space below it. - if (reminder != null) const SizedBox(height: 4), - ], - ), +// Pushes the chat / group info screen depending on whether [user] was +// resolved. 1-1 channels pass the other member here (forwarded as `extra` +// to the chat-info route); group channels pass `null` and route to the +// group info screen. +Future _pushChannelInfo(BuildContext context, Channel channel, User? user) { + final router = GoRouter.of(context); + + if (user != null) { + return router.pushNamed( + Routes.CHAT_INFO_SCREEN.name, + pathParameters: Routes.CHAT_INFO_SCREEN.params(channel), + extra: user, ); } - bool defaultFilter(Message m) { - final currentUser = StreamChat.of(context).currentUser; - final isMyMessage = m.user?.id == currentUser?.id; - final isDeletedOrShadowed = m.isDeleted == true || m.shadowed == true; - if (isDeletedOrShadowed && !isMyMessage) return false; - return true; - } + return router.pushNamed( + Routes.GROUP_INFO_SCREEN.name, + pathParameters: Routes.GROUP_INFO_SCREEN.params(channel), + ); } diff --git a/sample_app/lib/pages/chat_info_screen.dart b/sample_app/lib/pages/chat_info_screen.dart index 6caacbdfc4..268b485021 100644 --- a/sample_app/lib/pages/chat_info_screen.dart +++ b/sample_app/lib/pages/chat_info_screen.dart @@ -1,582 +1,368 @@ -// ignore_for_file: deprecated_member_use - -import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; import 'package:sample_app/pages/channel_file_display_screen.dart'; import 'package:sample_app/pages/channel_media_display_screen.dart'; import 'package:sample_app/pages/pinned_messages_screen.dart'; -import 'package:sample_app/utils/localizations.dart'; import 'package:stream_chat_flutter/stream_chat_flutter.dart'; -/// Detail screen for a 1:1 chat correspondence -class ChatInfoScreen extends StatefulWidget { - const ChatInfoScreen({ - super.key, - required this.messageTheme, - this.user, - }); - - /// User in consideration +/// Detail screen for a 1:1 chat correspondence. +/// +/// Surfaces the other party's avatar, name, online status, plus channel +/// shortcuts (pinned messages, media, files) and conversation actions +/// (mute, block, delete) — see Figma frame `8833:431680`. +class ChatInfoScreen extends StatelessWidget { + /// Creates a [ChatInfoScreen]. + const ChatInfoScreen({super.key, this.user}); + + /// The other user in the conversation. + /// + /// Required at runtime — the screen renders an [Offstage] when null so + /// callers don't need to thread an additional null check. final User? user; - final StreamMessageThemeData messageTheme; - @override - State createState() => _ChatInfoScreenState(); -} - -class _ChatInfoScreenState extends State { - ValueNotifier mutedBool = ValueNotifier(false); + Widget build(BuildContext context) { + final user = this.user; + if (user == null) return const Offstage(); - @override - void initState() { - super.initState(); - mutedBool = ValueNotifier(StreamChannel.of(context).channel.isMuted); - } + final spacing = context.streamSpacing; + final colorScheme = context.streamColorScheme; - @override - Widget build(BuildContext context) { - final channel = StreamChannel.of(context).channel; return Scaffold( - backgroundColor: StreamChatTheme.of(context).colorTheme.appBg, - body: ListView( - children: [ - _buildUserHeader(), - Container( - height: 8, - color: StreamChatTheme.of(context).colorTheme.disabled, + backgroundColor: colorScheme.backgroundApp, + appBar: StreamAppBar(title: const Text('Contact Info')), + // Action / chevron icons share a uniform 20px size — set once at the + // top of the body so individual rows stay style-free. + body: IconTheme.merge( + data: const IconThemeData(size: 20), + child: SingleChildScrollView( + padding: .directional( + top: spacing.xxl, + bottom: spacing.xxxl, + start: spacing.md, + end: spacing.md, ), - _buildOptionListTiles(), - Container( - height: 8, - color: StreamChatTheme.of(context).colorTheme.disabled, + child: Column( + mainAxisSize: .min, + children: [ + _ContactInfoHeader(user: user), + SizedBox(height: spacing.xxl), + const _MediaSection(), + SizedBox(height: spacing.md), + const _ActionsSection(), + ], ), - if (channel.ownCapabilities.contains(PermissionType.deleteChannel)) - _buildDeleteListTile(), - ], + ), ), ); } +} - Widget _buildUserHeader() { - return Material( - color: StreamChatTheme.of(context).colorTheme.appBg, - child: SafeArea( - child: Stack( +/// Hero header — large avatar with optional online indicator, name with an +/// inline mute-state icon, and an online / last-seen subtitle. +class _ContactInfoHeader extends StatelessWidget { + const _ContactInfoHeader({required this.user}); + + final User user; + + @override + Widget build(BuildContext context) { + final spacing = context.streamSpacing; + final colorScheme = context.streamColorScheme; + final textTheme = context.streamTextTheme; + final channel = StreamChannel.of(context).channel; + + return Column( + children: [ + StreamUserAvatar( + user: user, + size: .xxl, + showOnlineIndicator: user.online, + ), + SizedBox(height: spacing.md), + Row( + mainAxisSize: MainAxisSize.min, + spacing: spacing.xxs, children: [ - Column( - children: [ - Padding( - padding: const EdgeInsets.all(16), - child: StreamUserAvatar( - user: widget.user!, - constraints: const BoxConstraints.tightFor( - width: 72, - height: 72, - ), - borderRadius: BorderRadius.circular(36), - showOnlineStatus: false, - ), - ), - Text( - widget.user!.name, - style: const TextStyle( - fontSize: 16, fontWeight: FontWeight.bold), - ), - const SizedBox(height: 7), - _buildConnectedTitleState(), - const SizedBox(height: 15), - StreamOptionListTile( - title: '@${widget.user!.id}', - tileColor: StreamChatTheme.of(context).colorTheme.appBg, - trailing: Padding( - padding: const EdgeInsets.symmetric(horizontal: 8), - child: Text( - widget.user!.name, - style: TextStyle( - color: StreamChatTheme.of(context) - .colorTheme - .textHighEmphasis - .withOpacity(0.5), - fontSize: 16), - ), - ), - onTap: () {}, - ), - ], + Flexible( + child: Text( + user.name, + style: textTheme.headingLg.copyWith(color: colorScheme.textPrimary), + overflow: TextOverflow.ellipsis, + ), ), - const Positioned( - top: 0, - left: 0, - width: 58, - child: StreamBackButton(), + BetterStreamBuilder( + stream: channel.isMutedStream, + initialData: channel.isMuted, + builder: (context, isMuted) { + if (!isMuted) return const SizedBox.shrink(); + return Icon( + context.streamIcons.mute, + color: colorScheme.textTertiary, + ); + }, ), ], ), - ), + SizedBox(height: spacing.xs), + Text( + _onlineLabel(user), + style: textTheme.captionDefault.copyWith(color: colorScheme.textSecondary), + ), + ], ); } - Widget _buildOptionListTiles() { - final channel = StreamChannel.of(context); + String _onlineLabel(User user) { + if (user.online) return 'Online'; + final lastActive = user.lastActive; + if (lastActive == null) return 'Offline'; + return 'Last seen ${Jiffy.parseFromDateTime(lastActive).fromNow()}'; + } +} - return Column( +/// Card grouping the read-only channel-content shortcuts (pinned, media, +/// files). +class _MediaSection extends StatelessWidget { + const _MediaSection(); + + @override + Widget build(BuildContext context) { + final icons = context.streamIcons; + return _Section( children: [ - StreamBuilder( - stream: StreamChannel.of(context).channel.isMutedStream, - builder: (context, snapshot) { - mutedBool.value = snapshot.data; - - return StreamOptionListTile( - tileColor: StreamChatTheme.of(context).colorTheme.appBg, - title: AppLocalizations.of(context).muteUser, - titleTextStyle: StreamChatTheme.of(context).textTheme.body, - leading: Padding( - padding: const EdgeInsets.symmetric(horizontal: 22), - child: StreamSvgIcon( - icon: StreamSvgIcons.mute, - size: 24, - color: StreamChatTheme.of(context) - .colorTheme - .textHighEmphasis - .withOpacity(0.5), - ), - ), - trailing: snapshot.data == null - ? const CircularProgressIndicator() - : ValueListenableBuilder( - valueListenable: mutedBool, - builder: (context, value, _) { - return CupertinoSwitch( - value: value!, - onChanged: (val) { - mutedBool.value = val; - - if (snapshot.data!) { - channel.channel.unmute(); - } else { - channel.channel.mute(); - } - }, - ); - }), - onTap: () {}, - ); - }), - StreamOptionListTile( - title: AppLocalizations.of(context).pinnedMessages, - tileColor: StreamChatTheme.of(context).colorTheme.appBg, - titleTextStyle: StreamChatTheme.of(context).textTheme.body, - leading: Padding( - padding: const EdgeInsets.symmetric(horizontal: 22), - child: StreamSvgIcon( - icon: StreamSvgIcons.pin, - size: 24, - color: StreamChatTheme.of(context) - .colorTheme - .textHighEmphasis - .withOpacity(0.5), - ), - ), - trailing: StreamSvgIcon( - icon: StreamSvgIcons.right, - color: StreamChatTheme.of(context).colorTheme.textLowEmphasis, - ), - onTap: () { - final channel = StreamChannel.of(context).channel; - - Navigator.push( - context, - MaterialPageRoute( - builder: (context) => StreamChannel( - channel: channel, - child: const PinnedMessagesScreen(), - ), - ), - ); - }, + _Tile( + icon: Icon(icons.pin), + label: const Text('Pinned Messages'), + trailing: Icon(icons.chevronRight), + onTap: () => _push(context, const PinnedMessagesScreen()), ), - StreamOptionListTile( - title: AppLocalizations.of(context).photosAndVideos, - tileColor: StreamChatTheme.of(context).colorTheme.appBg, - titleTextStyle: StreamChatTheme.of(context).textTheme.body, - leading: Padding( - padding: const EdgeInsets.symmetric(horizontal: 16), - child: StreamSvgIcon( - icon: StreamSvgIcons.pictures, - size: 36, - color: StreamChatTheme.of(context) - .colorTheme - .textHighEmphasis - .withOpacity(0.5), - ), - ), - trailing: StreamSvgIcon( - icon: StreamSvgIcons.right, - color: StreamChatTheme.of(context).colorTheme.textLowEmphasis, - ), - onTap: () { - final channel = StreamChannel.of(context).channel; - - Navigator.push( - context, - MaterialPageRoute( - builder: (context) => StreamChannel( - channel: channel, - child: ChannelMediaDisplayScreen( - messageTheme: widget.messageTheme, - ), - ), - ), - ); - }, + _Tile( + icon: Icon(icons.image), + label: const Text('Photos & Videos'), + trailing: Icon(icons.chevronRight), + onTap: () => _push(context, const ChannelMediaDisplayScreen()), ), - StreamOptionListTile( - title: AppLocalizations.of(context).files, - tileColor: StreamChatTheme.of(context).colorTheme.appBg, - titleTextStyle: StreamChatTheme.of(context).textTheme.body, - leading: Padding( - padding: const EdgeInsets.symmetric(horizontal: 18), - child: StreamSvgIcon( - icon: StreamSvgIcons.files, - size: 32, - color: StreamChatTheme.of(context) - .colorTheme - .textHighEmphasis - .withOpacity(0.5), - ), - ), - trailing: StreamSvgIcon( - icon: StreamSvgIcons.right, - color: StreamChatTheme.of(context).colorTheme.textLowEmphasis, - ), - onTap: () { - final channel = StreamChannel.of(context).channel; - - Navigator.push( - context, - MaterialPageRoute( - builder: (context) => StreamChannel( - channel: channel, - child: ChannelFileDisplayScreen( - messageTheme: widget.messageTheme, - ), - ), - ), - ); - }, - ), - StreamOptionListTile( - title: AppLocalizations.of(context).sharedGroups, - tileColor: StreamChatTheme.of(context).colorTheme.appBg, - titleTextStyle: StreamChatTheme.of(context).textTheme.body, - leading: Padding( - padding: const EdgeInsets.symmetric(horizontal: 22), - child: StreamSvgIcon( - icon: StreamSvgIcons.group, - size: 24, - color: StreamChatTheme.of(context) - .colorTheme - .textHighEmphasis - .withOpacity(0.5), - ), - ), - trailing: StreamSvgIcon( - icon: StreamSvgIcons.right, - color: StreamChatTheme.of(context).colorTheme.textLowEmphasis, - ), - onTap: () { - Navigator.push( - context, - MaterialPageRoute( - builder: (context) => _SharedGroupsScreen( - StreamChat.of(context).currentUser, widget.user))); - }, + _Tile( + icon: Icon(icons.folder), + label: const Text('Files'), + trailing: Icon(icons.chevronRight), + onTap: () => _push(context, const ChannelFileDisplayScreen()), ), ], ); } - Widget _buildDeleteListTile() { - return StreamOptionListTile( - title: 'Delete Conversation', - tileColor: StreamChatTheme.of(context).colorTheme.appBg, - titleTextStyle: StreamChatTheme.of(context).textTheme.body.copyWith( - color: StreamChatTheme.of(context).colorTheme.accentError, - ), - leading: Padding( - padding: const EdgeInsets.symmetric(horizontal: 22), - child: StreamSvgIcon( - icon: StreamSvgIcons.delete, - size: 24, - color: StreamChatTheme.of(context).colorTheme.accentError, - ), + void _push(BuildContext context, Widget destination) { + final channel = StreamChannel.of(context).channel; + Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => StreamChannel(channel: channel, child: destination), ), - onTap: _showDeleteDialog, - titleColor: StreamChatTheme.of(context).colorTheme.accentError, ); } +} - void _showDeleteDialog() async { - final streamChannel = StreamChannel.of(context); - final res = await showConfirmationBottomSheet( - context, - title: AppLocalizations.of(context).deleteConversationTitle, - okText: AppLocalizations.of(context).delete.toUpperCase(), - question: AppLocalizations.of(context).deleteConversationAreYouSure, - cancelText: AppLocalizations.of(context).cancel.toUpperCase(), - icon: StreamSvgIcon( - icon: StreamSvgIcons.delete, - color: StreamChatTheme.of(context).colorTheme.accentError, - ), - ); - final channel = streamChannel.channel; - if (res == true) { - await channel.delete().then((value) { - Navigator.pop(context); - Navigator.pop(context); - }); - } - } +/// Card grouping the conversation-level actions — mute, block, delete. +class _ActionsSection extends StatelessWidget { + const _ActionsSection(); + + @override + Widget build(BuildContext context) { + final icons = context.streamIcons; + final channel = StreamChannel.of(context).channel; - Widget _buildConnectedTitleState() { - late Text alternativeWidget; - - final otherMember = widget.user; - - if (otherMember != null) { - if (otherMember.online) { - alternativeWidget = Text( - AppLocalizations.of(context).online, - style: TextStyle( - color: StreamChatTheme.of(context) - .colorTheme - .textHighEmphasis - .withOpacity(0.5)), - ); - } else { - alternativeWidget = Text( - '${AppLocalizations.of(context).lastSeen} ${Jiffy.parseFromDateTime(otherMember.lastActive!).fromNow()}', - style: TextStyle( - color: StreamChatTheme.of(context) - .colorTheme - .textHighEmphasis - .withOpacity(0.5)), - ); - } - } - - return Row( - mainAxisAlignment: MainAxisAlignment.center, + return _Section( children: [ - if (widget.user!.online) - Material( - type: MaterialType.circle, - color: StreamChatTheme.of(context).colorTheme.barsBg, - child: Container( - padding: const EdgeInsets.symmetric(horizontal: 8), - constraints: const BoxConstraints.tightFor( - width: 24, - height: 12, - ), - child: Material( - shape: const CircleBorder(), - color: StreamChatTheme.of(context).colorTheme.accentInfo, - ), + BetterStreamBuilder( + stream: channel.isMutedStream, + initialData: channel.isMuted, + builder: (context, isMuted) => _Tile( + icon: Icon(isMuted ? icons.audio : icons.mute), + label: Text(isMuted ? 'Unmute User' : 'Mute User'), + trailing: StreamSwitch( + value: isMuted, + onChanged: (_) { + if (isMuted) { + channel.unmute(); + } else { + channel.mute(); + } + }, ), ), - alternativeWidget, - if (widget.user!.online) - const SizedBox( - width: 24, + ), + _Tile( + icon: Icon(icons.noSign), + label: const Text('Block User'), + onTap: () => _showNotImplementedSnack(context), + ), + if (channel.canDeleteChannel) + _Tile( + icon: Icon(icons.delete), + label: const Text('Delete Conversation'), + destructive: true, + onTap: () => _confirmDelete(context), ), ], ); } + + void _showNotImplementedSnack(BuildContext context) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Blocking users is not implemented in the sample app.')), + ); + } + + Future _confirmDelete(BuildContext context) async { + final navigator = Navigator.of(context); + final channel = StreamChannel.of(context).channel; + + final confirmed = await _showConfirmationDialog( + context: context, + title: 'Delete conversation', + content: 'Are you sure you want to delete this conversation?', + confirmLabel: 'Delete', + ); + if (confirmed != true) return; + + await channel.delete(); + // Pop every screen until we land on the channel list — going back to + // the channel page would crash trying to read state from the now + // deleted channel. + navigator.popUntil((route) => route.isFirst); + } } -class _SharedGroupsScreen extends StatefulWidget { - const _SharedGroupsScreen(this.mainUser, this.otherUser); - final User? mainUser; - final User? otherUser; +/// A rounded section card that visually groups its [children] with a single +/// background colour and clipped ink ripples — matches the Figma's "soft +/// grey card" pattern shared across detail screens. +class _Section extends StatelessWidget { + const _Section({required this.children}); + + final List children; @override - __SharedGroupsScreenState createState() => __SharedGroupsScreenState(); + Widget build(BuildContext context) { + final radius = context.streamRadius; + final spacing = context.streamSpacing; + + final colorScheme = context.streamColorScheme; + + return Material( + color: colorScheme.backgroundSurfaceCard, + shape: RoundedSuperellipseBorder(borderRadius: .all(radius.lg)), + clipBehavior: Clip.antiAlias, + child: Padding( + padding: .symmetric(vertical: spacing.xs, horizontal: spacing.xxs), + child: Column(mainAxisSize: .min, children: children), + ), + ); + } } -class __SharedGroupsScreenState extends State<_SharedGroupsScreen> { +/// A single row inside a [_Section] — leading icon, label, and an optional +/// [trailing] widget. Navigation rows should pass an explicit chevron; +/// action rows that confirm or toggle in place pass [trailing] only when +/// they need a control (e.g. a switch). Setting [destructive] paints both +/// the icon and the label with [StreamColorScheme.accentError] via a local +/// [StreamListTileTheme] override. +class _Tile extends StatelessWidget { + const _Tile({ + required this.icon, + required this.label, + this.onTap, + this.trailing, + this.destructive = false, + }); + + final Widget icon; + final Widget label; + final VoidCallback? onTap; + final Widget? trailing; + final bool destructive; + @override Widget build(BuildContext context) { - final chat = StreamChat.of(context); - - return Scaffold( - backgroundColor: StreamChatTheme.of(context).colorTheme.appBg, - appBar: AppBar( - elevation: 1, - centerTitle: true, - title: Text( - AppLocalizations.of(context).sharedGroups, - style: TextStyle( - color: StreamChatTheme.of(context).colorTheme.textHighEmphasis, - fontSize: 16), - ), - leading: const StreamBackButton(), - backgroundColor: StreamChatTheme.of(context).colorTheme.barsBg, + final spacing = context.streamSpacing; + final colorScheme = context.streamColorScheme; + + return StreamListTileTheme( + data: StreamListTileThemeData( + iconColor: destructive ? .all(colorScheme.accentError) : null, + titleColor: destructive ? .all(colorScheme.accentError) : null, + minTileHeight: 44, // Matches the design's tap target size for action rows + contentPadding: .symmetric(horizontal: spacing.sm), ), - body: StreamBuilder>( - stream: chat.client.queryChannels( - filter: Filter.and([ - Filter.in_('members', [widget.otherUser!.id]), - Filter.in_('members', [widget.mainUser!.id]), - ]), - ), - builder: (context, snapshot) { - if (!snapshot.hasData) { - return const Center( - child: CircularProgressIndicator(), - ); - } - - if (snapshot.data!.isEmpty) { - return Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - StreamSvgIcon( - icon: StreamSvgIcons.message, - size: 136, - color: StreamChatTheme.of(context).colorTheme.disabled, - ), - const SizedBox(height: 16), - Text( - AppLocalizations.of(context).noSharedGroups, - style: TextStyle( - fontSize: 14, - color: StreamChatTheme.of(context) - .colorTheme - .textHighEmphasis, - ), - ), - const SizedBox(height: 8), - Text( - AppLocalizations.of(context).groupSharedWithUserAppearHere, - textAlign: TextAlign.center, - style: TextStyle( - fontSize: 14, - color: StreamChatTheme.of(context) - .colorTheme - .textHighEmphasis - .withOpacity(0.5), - ), - ), - ], - ), - ); - } - - final channels = snapshot.data! - .where((c) => - c.state!.members.any((m) => - m.userId != widget.mainUser!.id && - m.userId != widget.otherUser!.id) || - !c.isDistinct) - .toList(); - - return ListView.builder( - itemCount: channels.length, - itemBuilder: (context, position) { - return StreamChannel( - channel: channels[position], - child: _buildListTile(channels[position]), - ); - }, - ); - }, + child: StreamListTile( + leading: icon, + trailing: trailing, + title: label, + onTap: onTap, ), ); } +} - Widget _buildListTile(Channel channel) { - final extraData = channel.extraData; - final members = channel.state!.members; - - const textStyle = TextStyle(fontSize: 14, fontWeight: FontWeight.bold); - - return SizedBox( - height: 64, - child: LayoutBuilder( - builder: (context, constraints) { - String? title; - if (extraData['name'] == null) { - final otherMembers = members.where((member) => - member.userId != StreamChat.of(context).currentUser!.id); - if (otherMembers.isNotEmpty) { - final maxWidth = constraints.maxWidth; - final maxChars = maxWidth / textStyle.fontSize!; - var currentChars = 0; - final currentMembers = []; - for (final element in otherMembers) { - final newLength = currentChars + element.user!.name.length; - if (newLength < maxChars) { - currentChars = newLength; - currentMembers.add(element); - } - } - - final exceedingMembers = - otherMembers.length - currentMembers.length; - title = - '${currentMembers.map((e) => e.user!.name).join(', ')} ${exceedingMembers > 0 ? '+ $exceedingMembers' : ''}'; - } else { - title = 'No title'; - } - } else { - title = extraData['name']! as String; - } - - return Column( - children: [ - Expanded( - child: Row( - children: [ - Padding( - padding: const EdgeInsets.all(8), - child: StreamChannelAvatar( - channel: channel, - constraints: - const BoxConstraints(maxWidth: 40, maxHeight: 40), - ), - ), - Expanded( - child: Text( - title, - style: textStyle, - )), - Padding( - padding: const EdgeInsets.all(8), - child: Text( - '${channel.memberCount} ${AppLocalizations.of(context).members.toLowerCase()}', - style: TextStyle( - color: StreamChatTheme.of(context) - .colorTheme - .textHighEmphasis - .withOpacity(0.5)), - ), - ) - ], - ), - ), - Container( - height: 1, - color: StreamChatTheme.of(context) - .colorTheme - .textHighEmphasis - .withOpacity(.08), - ), - ], - ); - }, - ), +// Stream-styled confirmation dialog with a destructive primary action. +// +// Mirrors the dialog pattern used by the poll interactor (e.g. +// `showPollEndVoteDialog` / `showPollDeleteOptionDialog`) and the +// SDK-internal `StreamMessageActionConfirmationModal`: a Material +// [AlertDialog] with a ghost secondary cancel and a solid destructive +// confirm. +// +// Resolves to `true` on confirm, `false` on cancel, `null` on dismiss. +Future _showConfirmationDialog({ + required BuildContext context, + required String title, + required String content, + required String confirmLabel, +}) { + return showDialog( + context: context, + builder: (_) => _ConfirmationDialog( + title: title, + content: content, + confirmLabel: confirmLabel, + ), + ); +} + +class _ConfirmationDialog extends StatelessWidget { + const _ConfirmationDialog({ + required this.title, + required this.content, + required this.confirmLabel, + }); + + final String title; + final String content; + final String confirmLabel; + + @override + Widget build(BuildContext context) { + final colorScheme = context.streamColorScheme; + + return AlertDialog( + backgroundColor: colorScheme.backgroundElevation1, + title: Text(title), + content: Text(content), + actions: [ + StreamButton( + type: .ghost, + style: .secondary, + size: .small, + onPressed: () => Navigator.of(context).maybePop(false), + child: Text(context.translations.cancelLabel), + ), + StreamButton( + type: .solid, + style: .destructive, + size: .small, + onPressed: () => Navigator.of(context).maybePop(true), + child: Text(confirmLabel), + ), + ], ); } } diff --git a/sample_app/lib/pages/choose_user_page.dart b/sample_app/lib/pages/choose_user_page.dart index b2aa66391a..2ffb14ac3d 100644 --- a/sample_app/lib/pages/choose_user_page.dart +++ b/sample_app/lib/pages/choose_user_page.dart @@ -1,20 +1,12 @@ -import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; -import 'package:flutter_secure_storage/flutter_secure_storage.dart'; import 'package:flutter_svg/flutter_svg.dart'; import 'package:go_router/go_router.dart'; -import 'package:provider/provider.dart'; +import 'package:sample_app/auth/auth_controller.dart'; import 'package:sample_app/routes/routes.dart'; -import 'package:sample_app/state/init_data.dart'; import 'package:sample_app/utils/app_config.dart'; -import 'package:sample_app/utils/localizations.dart'; import 'package:sample_app/widgets/stream_version.dart'; import 'package:stream_chat_flutter/stream_chat_flutter.dart'; -const kStreamApiKey = 'STREAM_API_KEY'; -const kStreamUserId = 'STREAM_USER_ID'; -const kStreamToken = 'STREAM_TOKEN'; - class ChooseUserPage extends StatelessWidget { const ChooseUserPage({super.key}); @@ -46,12 +38,12 @@ class ChooseUserPage extends StatelessWidget { Padding( padding: const EdgeInsets.only(bottom: 13), child: Text( - AppLocalizations.of(context).welcomeToStreamChat, + 'Welcome to Stream Chat', style: StreamChatTheme.of(context).textTheme.title, ), ), Text( - '${AppLocalizations.of(context).selectUserToTryFlutterSDK}:', + 'Select a user to try the Flutter SDK:', style: StreamChatTheme.of(context).textTheme.body, ), Expanded( @@ -76,16 +68,12 @@ class ChooseUserPage extends StatelessWidget { showDialog( barrierDismissible: false, context: context, - barrierColor: StreamChatTheme.of(context) - .colorTheme - .overlay, + barrierColor: StreamChatTheme.of(context).colorTheme.overlay, builder: (context) => Center( child: Container( decoration: BoxDecoration( borderRadius: BorderRadius.circular(16), - color: StreamChatTheme.of(context) - .colorTheme - .barsBg, + color: StreamChatTheme.of(context).colorTheme.barsBg, ), height: 100, width: 100, @@ -96,93 +84,62 @@ class ChooseUserPage extends StatelessWidget { ), ); - final client = - context.read().initData!.client; - final router = GoRouter.of(context); - await client.connectUser( - user, - token, - ); - - if (!kIsWeb) { - const secureStorage = FlutterSecureStorage(); - secureStorage.write( - key: kStreamApiKey, - value: kDefaultStreamApiKey, - ); - secureStorage.write( - key: kStreamUserId, - value: user.id, - ); - secureStorage.write( - key: kStreamToken, - value: token, + try { + await authController.connect( + apiKey: kDefaultStreamApiKey, + user: user, + token: token, ); + } finally { + // Pop the progress dialog regardless of outcome. + router.pop(); } - // Pop the progress dialog. - router.pop(); + // The router's redirect will forward an + // Authenticated user to the channel list, but + // nudge it along explicitly for snappiness. router.replaceNamed(Routes.CHANNEL_LIST_PAGE.name); }, leading: StreamUserAvatar( + size: .lg, user: user, - constraints: BoxConstraints.tight( - const Size.fromRadius(20), - ), ), title: Text( user.name, - style: - StreamChatTheme.of(context).textTheme.bodyBold, + style: StreamChatTheme.of(context).textTheme.bodyBold, ), subtitle: Text( - AppLocalizations.of(context).streamTestAccount, - style: StreamChatTheme.of(context) - .textTheme - .footnote - .copyWith( - color: StreamChatTheme.of(context) - .colorTheme - .textLowEmphasis, - ), + 'Stream test account', + style: StreamChatTheme.of(context).textTheme.footnote.copyWith( + color: StreamChatTheme.of(context).colorTheme.textLowEmphasis, + ), ), - trailing: StreamSvgIcon( - icon: StreamSvgIcons.arrowRight, - color: StreamChatTheme.of(context) - .colorTheme - .accentPrimary, + trailing: Icon( + context.streamIcons.arrowRight, + color: StreamChatTheme.of(context).colorTheme.accentPrimary, ), ); }), ListTile( - onTap: () => GoRouter.of(context) - .pushNamed(Routes.ADVANCED_OPTIONS.name), + onTap: () => GoRouter.of(context).pushNamed(Routes.ADVANCED_OPTIONS.name), leading: CircleAvatar( - backgroundColor: - StreamChatTheme.of(context).colorTheme.borders, - child: StreamSvgIcon( - icon: StreamSvgIcons.settings, - color: StreamChatTheme.of(context) - .colorTheme - .textHighEmphasis, + backgroundColor: StreamChatTheme.of(context).colorTheme.borders, + child: Icon( + Icons.settings, + color: StreamChatTheme.of(context).colorTheme.textHighEmphasis, ), ), title: Text( - AppLocalizations.of(context).advancedOptions, + 'Advanced Options', style: StreamChatTheme.of(context).textTheme.bodyBold, ), subtitle: Text( - AppLocalizations.of(context).customSettings, - style: StreamChatTheme.of(context) - .textTheme - .footnote - .copyWith( - color: StreamChatTheme.of(context) - .colorTheme - .textLowEmphasis, - ), + 'Custom settings', + style: StreamChatTheme.of(context).textTheme.footnote.copyWith( + color: StreamChatTheme.of(context).colorTheme.textLowEmphasis, + ), ), trailing: SvgPicture.asset( 'assets/icon_arrow_right.svg', diff --git a/sample_app/lib/pages/draft_list_page.dart b/sample_app/lib/pages/draft_list_page.dart index ffdda5fec7..e486ac9d05 100644 --- a/sample_app/lib/pages/draft_list_page.dart +++ b/sample_app/lib/pages/draft_list_page.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_slidable/flutter_slidable.dart'; import 'package:sample_app/pages/channel_page.dart'; import 'package:sample_app/pages/thread_page.dart'; +import 'package:sample_app/widgets/stream_draft_list_view.dart'; import 'package:stream_chat_flutter/stream_chat_flutter.dart'; class DraftListPage extends StatefulWidget { @@ -39,9 +40,9 @@ class _DraftListPageState extends State { children: [ CustomSlidableAction( backgroundColor: Colors.red, - child: const StreamSvgIcon( + child: Icon( + context.streamIcons.delete, size: 24, - icon: StreamSvgIcons.delete, color: Colors.white, ), onPressed: (context) { @@ -71,8 +72,8 @@ class _DraftListPageState extends State { initialMessageId: draft.parentId, child: switch (draft.parentMessage) { final parent? => ThreadPage( - parent: parent.copyWith(draft: draft), - ), + parent: parent.copyWith(draft: draft), + ), _ => const ChannelPage(), }, ); diff --git a/sample_app/lib/pages/group_chat_details_screen.dart b/sample_app/lib/pages/group_chat_details_screen.dart index 5df7ae5075..ee2c3fc0b1 100644 --- a/sample_app/lib/pages/group_chat_details_screen.dart +++ b/sample_app/lib/pages/group_chat_details_screen.dart @@ -4,7 +4,6 @@ import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; import 'package:sample_app/routes/routes.dart'; import 'package:sample_app/state/new_group_chat_state.dart'; -import 'package:sample_app/utils/localizations.dart'; import 'package:stream_chat_flutter/stream_chat_flutter.dart'; class GroupChatDetailsScreen extends StatefulWidget { @@ -19,8 +18,7 @@ class GroupChatDetailsScreen extends StatefulWidget { } class _GroupChatDetailsScreenState extends State { - late final TextEditingController _groupNameController = - TextEditingController()..addListener(_groupNameListener); + late final TextEditingController _groupNameController = TextEditingController()..addListener(_groupNameListener); bool _isGroupNameEmpty = true; @@ -52,98 +50,38 @@ class _GroupChatDetailsScreenState extends State { }, child: Scaffold( backgroundColor: StreamChatTheme.of(context).colorTheme.appBg, - appBar: AppBar( - elevation: 1, - backgroundColor: StreamChatTheme.of(context).colorTheme.barsBg, - leading: const StreamBackButton(), - title: Text( - AppLocalizations.of(context).nameOfGroupChat, - style: TextStyle( - color: StreamChatTheme.of(context).colorTheme.textHighEmphasis, - fontSize: 16, - ), - ), - centerTitle: true, - bottom: PreferredSize( - preferredSize: const Size.fromHeight(kToolbarHeight), - child: Padding( - padding: const EdgeInsets.symmetric(vertical: 18, horizontal: 16), - child: Row( - children: [ - Text( - AppLocalizations.of(context).name.toUpperCase(), - style: TextStyle( - fontSize: 12, - color: StreamChatTheme.of(context) - .colorTheme - .textLowEmphasis, - ), - ), - const SizedBox(width: 16), - Expanded( - child: TextField( - controller: _groupNameController, - decoration: InputDecoration( - isDense: true, - border: InputBorder.none, - focusedBorder: InputBorder.none, - enabledBorder: InputBorder.none, - errorBorder: InputBorder.none, - disabledBorder: InputBorder.none, - contentPadding: EdgeInsets.zero, - hintText: - AppLocalizations.of(context).chooseAGroupChatName, - hintStyle: TextStyle( - fontSize: 14, - color: StreamChatTheme.of(context) - .colorTheme - .textLowEmphasis, - ), - ), - ), - ), - ], - ), - ), + appBar: StreamAppBar( + title: const Text('Name of Group Chat'), + trailing: StreamButton.icon( + icon: Icon(context.streamIcons.checkmark), + onPressed: _isGroupNameEmpty + ? null + : () async { + try { + final groupName = _groupNameController.text; + final client = StreamChat.of(context).client; + final router = GoRouter.of(context); + final channel = client.channel( + 'messaging', + id: const Uuid().v4(), + extraData: { + 'members': [ + client.state.currentUser!.id, + ...widget.groupChatState.users.map((e) => e.id), + ], + 'name': groupName, + }, + ); + await channel.watch(); + router.goNamed( + Routes.CHANNEL_PAGE.name, + pathParameters: Routes.CHANNEL_PAGE.params(channel), + ); + } catch (err) { + _showErrorAlert(); + } + }, ), - actions: [ - StreamNeumorphicButton( - child: IconButton( - iconSize: 24, - padding: EdgeInsets.zero, - color: _isGroupNameEmpty - ? StreamChatTheme.of(context).colorTheme.textLowEmphasis - : StreamChatTheme.of(context).colorTheme.accentPrimary, - icon: const StreamSvgIcon(icon: StreamSvgIcons.check), - onPressed: _isGroupNameEmpty - ? null - : () async { - try { - final groupName = _groupNameController.text; - final client = StreamChat.of(context).client; - final router = GoRouter.of(context); - final channel = client.channel('messaging', - id: const Uuid().v4(), - extraData: { - 'members': [ - client.state.currentUser!.id, - ...widget.groupChatState.users - .map((e) => e.id), - ], - 'name': groupName, - }); - await channel.watch(); - router.goNamed( - Routes.CHANNEL_PAGE.name, - pathParameters: Routes.CHANNEL_PAGE.params(channel), - ); - } catch (err) { - _showErrorAlert(); - } - }, - ), - ), - ], ), body: StreamConnectionStatusBuilder( statusBuilder: (context, status) { @@ -152,14 +90,14 @@ class _GroupChatDetailsScreenState extends State { switch (status) { case ConnectionStatus.connected: - statusString = AppLocalizations.of(context).connected; + statusString = 'Connected'; showStatus = false; break; case ConnectionStatus.connecting: - statusString = AppLocalizations.of(context).reconnecting; + statusString = 'Reconnecting...'; break; case ConnectionStatus.disconnected: - statusString = AppLocalizations.of(context).disconnected; + statusString = 'Disconnected'; break; } return StreamInfoTile( @@ -169,11 +107,44 @@ class _GroupChatDetailsScreenState extends State { message: statusString, child: Column( children: [ + Padding( + padding: const EdgeInsets.symmetric(vertical: 18, horizontal: 16), + child: Row( + children: [ + Text( + 'Name'.toUpperCase(), + style: TextStyle( + fontSize: 12, + color: StreamChatTheme.of(context).colorTheme.textLowEmphasis, + ), + ), + const SizedBox(width: 16), + Expanded( + child: TextField( + controller: _groupNameController, + decoration: InputDecoration( + isDense: true, + border: InputBorder.none, + focusedBorder: InputBorder.none, + enabledBorder: InputBorder.none, + errorBorder: InputBorder.none, + disabledBorder: InputBorder.none, + contentPadding: EdgeInsets.zero, + hintText: 'Choose a group chat name', + hintStyle: TextStyle( + fontSize: 14, + color: StreamChatTheme.of(context).colorTheme.textLowEmphasis, + ), + ), + ), + ), + ], + ), + ), Container( width: double.maxFinite, decoration: BoxDecoration( - gradient: - StreamChatTheme.of(context).colorTheme.bgGradient, + gradient: StreamChatTheme.of(context).colorTheme.bgGradient, ), child: Padding( padding: const EdgeInsets.symmetric( @@ -181,82 +152,69 @@ class _GroupChatDetailsScreenState extends State { horizontal: 8, ), child: Text( - '$_totalUsers ${_totalUsers > 1 ? AppLocalizations.of(context).members : AppLocalizations.of(context).member}', + '$_totalUsers ${_totalUsers > 1 ? 'Members' : 'Member'}', style: TextStyle( - color: StreamChatTheme.of(context) - .colorTheme - .textLowEmphasis, + color: StreamChatTheme.of(context).colorTheme.textLowEmphasis, ), ), ), ), AnimatedBuilder( - animation: widget.groupChatState, - builder: (context, child) { - return Expanded( - child: GestureDetector( - behavior: HitTestBehavior.opaque, - onPanDown: (_) => FocusScope.of(context).unfocus(), - child: ListView.separated( - itemCount: widget.groupChatState.users.length + 1, - separatorBuilder: (_, __) => Container( - height: 1, - color: StreamChatTheme.of(context) - .colorTheme - .borders, - ), - itemBuilder: (_, index) { - if (index == - widget.groupChatState.users.length) { - return Container( - height: 1, - color: StreamChatTheme.of(context) - .colorTheme - .borders, - ); - } - final user = widget.groupChatState.users - .elementAt(index); - return ListTile( - key: ObjectKey(user), - leading: StreamUserAvatar( - user: user, - constraints: const BoxConstraints.tightFor( - width: 40, - height: 40, - ), - ), - title: Text( - user.name, - style: const TextStyle( - fontWeight: FontWeight.bold), - ), - contentPadding: const EdgeInsets.symmetric( - horizontal: 12, - vertical: 8, - ), - trailing: IconButton( - icon: Icon( - Icons.clear_rounded, - color: StreamChatTheme.of(context) - .colorTheme - .textHighEmphasis, - ), - padding: EdgeInsets.zero, - splashRadius: 24, - onPressed: () { - widget.groupChatState.removeUser(user); - if (widget.groupChatState.users.isEmpty) { - GoRouter.of(context).pop(); - } - }, - ), - ); - }, + animation: widget.groupChatState, + builder: (context, child) { + return Expanded( + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onPanDown: (_) => FocusScope.of(context).unfocus(), + child: ListView.separated( + itemCount: widget.groupChatState.users.length + 1, + separatorBuilder: (_, __) => Container( + height: 1, + color: StreamChatTheme.of(context).colorTheme.borders, ), + itemBuilder: (_, index) { + if (index == widget.groupChatState.users.length) { + return Container( + height: 1, + color: StreamChatTheme.of(context).colorTheme.borders, + ); + } + final user = widget.groupChatState.users.elementAt(index); + return ListTile( + key: ObjectKey(user), + leading: StreamUserAvatar( + size: .lg, + user: user, + ), + title: Text( + user.name, + style: const TextStyle(fontWeight: FontWeight.bold), + ), + contentPadding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 8, + ), + trailing: IconButton( + icon: Icon( + Icons.clear_rounded, + color: StreamChatTheme.of(context).colorTheme.textHighEmphasis, + ), + padding: EdgeInsets.zero, + splashRadius: 24, + onPressed: () { + widget.groupChatState.removeUser(user); + if (widget.groupChatState.users.isEmpty) { + GoRouter.of(context).pop(); + } + }, + ), + ); + }, ), - ); - }), + ), + ); + }, + ), ], ), ); @@ -271,10 +229,11 @@ class _GroupChatDetailsScreenState extends State { backgroundColor: StreamChatTheme.of(context).colorTheme.barsBg, context: context, shape: const RoundedRectangleBorder( - borderRadius: BorderRadius.only( - topLeft: Radius.circular(16), - topRight: Radius.circular(16), - )), + borderRadius: BorderRadius.only( + topLeft: Radius.circular(16), + topRight: Radius.circular(16), + ), + ), builder: (context) { return Column( mainAxisSize: MainAxisSize.min, @@ -282,8 +241,8 @@ class _GroupChatDetailsScreenState extends State { const SizedBox( height: 26, ), - StreamSvgIcon( - icon: StreamSvgIcons.error, + Icon( + context.streamIcons.exclamationCircleFill, color: StreamChatTheme.of(context).colorTheme.accentError, size: 24, ), @@ -291,21 +250,18 @@ class _GroupChatDetailsScreenState extends State { height: 26, ), Text( - AppLocalizations.of(context).somethingWentWrongErrorMessage, + 'Something went wrong', style: StreamChatTheme.of(context).textTheme.headlineBold, ), const SizedBox( height: 7, ), - Text(AppLocalizations.of(context).operationCouldNotBeCompleted), + const Text("The operation couldn't be completed."), const SizedBox( height: 36, ), Container( - color: StreamChatTheme.of(context) - .colorTheme - .textHighEmphasis - .withOpacity(.08), + color: StreamChatTheme.of(context).colorTheme.textHighEmphasis.withOpacity(.08), height: 1, ), Row( @@ -314,14 +270,10 @@ class _GroupChatDetailsScreenState extends State { TextButton( onPressed: GoRouter.of(context).pop, child: Text( - AppLocalizations.of(context).ok, - style: StreamChatTheme.of(context) - .textTheme - .bodyBold - .copyWith( - color: StreamChatTheme.of(context) - .colorTheme - .accentPrimary), + 'OK', + style: StreamChatTheme.of( + context, + ).textTheme.bodyBold.copyWith(color: StreamChatTheme.of(context).colorTheme.accentPrimary), ), ), ], diff --git a/sample_app/lib/pages/group_info_screen.dart b/sample_app/lib/pages/group_info_screen.dart index a3ffef7d48..a4b5b28636 100644 --- a/sample_app/lib/pages/group_info_screen.dart +++ b/sample_app/lib/pages/group_info_screen.dart @@ -1,1133 +1,495 @@ -// ignore_for_file: deprecated_member_use - -import 'dart:async'; - -import 'package:collection/collection.dart' show IterableExtension; -import 'package:flutter/cupertino.dart'; +import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; -import 'package:go_router/go_router.dart'; import 'package:sample_app/pages/channel_file_display_screen.dart'; import 'package:sample_app/pages/channel_media_display_screen.dart'; import 'package:sample_app/pages/pinned_messages_screen.dart'; -import 'package:sample_app/routes/routes.dart'; -import 'package:sample_app/utils/localizations.dart'; +import 'package:sample_app/widgets/add_members_sheet.dart'; +import 'package:sample_app/widgets/all_members_sheet.dart'; +import 'package:sample_app/widgets/edit_group_sheet.dart'; import 'package:stream_chat_flutter/stream_chat_flutter.dart'; -class GroupInfoScreen extends StatefulWidget { - const GroupInfoScreen({ - super.key, - required this.messageTheme, - }); - final StreamMessageThemeData messageTheme; +/// Detail screen for a group channel. +/// +/// Surfaces the group avatar, name, member count, plus channel content +/// shortcuts (pinned messages, media, files), a preview of the member list +/// (with _Add_ / _View all_ affordances), and conversation actions +/// (mute, leave) — see Figma frame `8779:381156`. +class GroupInfoScreen extends StatelessWidget { + /// Creates a [GroupInfoScreen]. + const GroupInfoScreen({super.key}); @override - State createState() => _GroupInfoScreenState(); + Widget build(BuildContext context) { + final spacing = context.streamSpacing; + final colorScheme = context.streamColorScheme; + final channel = StreamChannel.of(context).channel; + + return Scaffold( + backgroundColor: colorScheme.backgroundApp, + appBar: StreamAppBar( + title: const Text('Group Info'), + trailing: switch (channel.canUpdateChannel) { + true => StreamButton( + type: .outline, + style: .secondary, + size: .small, + onPressed: () => showEditGroupSheet(context, channel), + child: const Text('Edit'), + ), + false => null, + }, + ), + // Action / chevron icons share a uniform 20px size — set once at the + // top of the body so individual rows stay style-free. + body: IconTheme.merge( + data: const IconThemeData(size: 20), + child: SingleChildScrollView( + padding: .directional( + top: spacing.xxl, + bottom: spacing.xxxl, + start: spacing.md, + end: spacing.md, + ), + child: Column( + mainAxisSize: .min, + children: [ + const _GroupInfoHeader(), + SizedBox(height: spacing.xxl), + const _MediaSection(), + SizedBox(height: spacing.md), + const _MembersSection(), + SizedBox(height: spacing.md), + const _ActionsSection(), + ], + ), + ), + ), + ); + } } -class _GroupInfoScreenState extends State { - late final TextEditingController _nameController = - TextEditingController.fromValue( - TextEditingValue(text: (channel.extraData['name'] as String?) ?? ''), - ); - - late final TextEditingController _searchController = TextEditingController() - ..addListener(_userNameListener); - - String _userNameQuery = ''; - - Timer? _debounce; - Function? modalSetStateCallback; - - final FocusNode _focusNode = FocusNode(); - - bool listExpanded = false; - - late ValueNotifier mutedBool = ValueNotifier(channel.isMuted); - - late ValueNotifier isPinned = ValueNotifier(channel.isPinned); - - late ValueNotifier isArchived = ValueNotifier(channel.isArchived); - - late final channel = StreamChannel.of(context).channel; +/// Hero header — channel avatar group, channel name with optional inline +/// mute state icon, and a "X members · Y online" subtitle driven by +/// [StreamChannelInfo]. +class _GroupInfoHeader extends StatelessWidget { + const _GroupInfoHeader(); - late StreamUserListController _userListController; + @override + Widget build(BuildContext context) { + final spacing = context.streamSpacing; + final colorScheme = context.streamColorScheme; + final textTheme = context.streamTextTheme; + final channel = StreamChannel.of(context).channel; - void _userNameListener() { - if (_searchController.text == _userNameQuery) { - return; - } - if (_debounce?.isActive ?? false) _debounce!.cancel(); - _debounce = Timer(const Duration(milliseconds: 350), () { - if (mounted) { - _userNameQuery = _searchController.text; - _userListController.filter = Filter.and( - [ - if (_searchController.text.isNotEmpty) - Filter.autoComplete('name', _userNameQuery), - Filter.notIn('id', [ - StreamChat.of(context).currentUser!.id, - ...channel.state!.members - .map((e) => e.userId) - .whereType(), - ]), + return Column( + children: [ + StreamChannelAvatar(channel: channel, size: .xxl), + SizedBox(height: spacing.md), + Row( + mainAxisSize: MainAxisSize.min, + spacing: spacing.xxs, + children: [ + Flexible( + child: StreamChannelName( + channel: channel, + textStyle: textTheme.headingLg.copyWith(color: colorScheme.textPrimary), + ), + ), + BetterStreamBuilder( + stream: channel.isMutedStream, + initialData: channel.isMuted, + builder: (context, isMuted) { + if (!isMuted) return const SizedBox.shrink(); + return Icon( + context.streamIcons.mute, + color: colorScheme.textTertiary, + ); + }, + ), ], - ); - _userListController.doInitialLoad(); - } - }); + ), + SizedBox(height: spacing.xs), + StreamChannelInfo( + channel: channel, + showTypingIndicator: false, + textStyle: textTheme.captionDefault.copyWith(color: colorScheme.textSecondary), + ), + ], + ); } +} - @override - void initState() { - super.initState(); - - _nameController.addListener(() { - setState(() {}); - }); - mutedBool = ValueNotifier(channel.isMuted); - } +/// Card grouping the read-only channel-content shortcuts. +class _MediaSection extends StatelessWidget { + const _MediaSection(); @override - void didChangeDependencies() { - _userListController = StreamUserListController( - client: StreamChat.of(context).client, - limit: 25, - filter: Filter.and( - [ - if (_searchController.text.isNotEmpty) - Filter.autoComplete('name', _userNameQuery), - Filter.notIn('id', [ - StreamChat.of(context).currentUser!.id, - ...channel.state!.members - .map((e) => e.userId) - .whereType(), - ]), - ], - ), - sort: [ - const SortOption( - 'name', - direction: 1, + Widget build(BuildContext context) { + final icons = context.streamIcons; + return _Section( + children: [ + _Tile( + icon: Icon(icons.pin), + label: const Text('Pinned Messages'), + trailing: Icon(icons.chevronRight), + onTap: () => _push(context, const PinnedMessagesScreen()), + ), + _Tile( + icon: Icon(icons.image), + label: const Text('Photos & Videos'), + trailing: Icon(icons.chevronRight), + onTap: () => _push(context, const ChannelMediaDisplayScreen()), + ), + _Tile( + icon: Icon(icons.folder), + label: const Text('Files'), + trailing: Icon(icons.chevronRight), + onTap: () => _push(context, const ChannelFileDisplayScreen()), ), ], ); - super.didChangeDependencies(); } - @override - void dispose() { - _nameController.dispose(); - _searchController.dispose(); - _userListController.dispose(); - super.dispose(); + void _push(BuildContext context, Widget destination) { + final channel = StreamChannel.of(context).channel; + Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => StreamChannel(channel: channel, child: destination), + ), + ); } +} - @override - Widget build(BuildContext context) { - return StreamBuilder>( - stream: channel.state!.membersStream, - builder: (context, snapshot) { - if (!snapshot.hasData) { - return ColoredBox( - color: StreamChatTheme.of(context).colorTheme.disabled, - child: const Center(child: CircularProgressIndicator()), - ); - } +/// Members card — header with count and _Add_ affordance, the first +/// [_kPreviewLimit] members, and a _View all_ footer when the channel has +/// more. +const _kPreviewLimit = 5; - return Scaffold( - backgroundColor: StreamChatTheme.of(context).colorTheme.appBg, - appBar: AppBar( - elevation: 1, - toolbarHeight: 56, - backgroundColor: StreamChatTheme.of(context).colorTheme.barsBg, - leading: const StreamBackButton(), - title: Column( - children: [ - StreamBuilder( - stream: channel.state?.channelStateStream, - builder: (context, state) { - if (!state.hasData) { - return Text( - AppLocalizations.of(context).loading, - style: TextStyle( - color: StreamChatTheme.of(context) - .colorTheme - .textHighEmphasis, - fontSize: 16, - ), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ); - } +class _MembersSection extends StatelessWidget { + const _MembersSection(); - return Text( - _getChannelName( - 2 * MediaQuery.of(context).size.width / 3, - members: snapshot.data, - extraData: state.data!.channel!.extraData, - maxFontSize: 16, - )!, - style: TextStyle( - color: StreamChatTheme.of(context) - .colorTheme - .textHighEmphasis, - fontSize: 16, - ), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ); - }), - const SizedBox( - height: 3, - ), - Text( - '${channel.memberCount} ${AppLocalizations.of(context).members}, ${snapshot.data?.where((e) => e.user!.online).length ?? 0} ${AppLocalizations.of(context).online}', - style: TextStyle( - color: StreamChatTheme.of(context) - .colorTheme - .textHighEmphasis - .withOpacity(0.5), - fontSize: 12, - ), - ), - ], - ), - centerTitle: true, - actions: [ - if (channel.ownCapabilities - .contains(PermissionType.updateChannelMembers)) - StreamNeumorphicButton( - child: InkWell( - onTap: () { - _buildAddUserModal(context); - }, - child: Padding( - padding: const EdgeInsets.all(8), - child: StreamSvgIcon( - icon: StreamSvgIcons.userAdd, - color: StreamChatTheme.of(context) - .colorTheme - .accentPrimary), - ), - ), - ), - ], - ), - body: ListView( - children: [ - _buildMembers(snapshot.data!), - Container( - height: 8, - color: StreamChatTheme.of(context).colorTheme.disabled, - ), - if (channel.ownCapabilities - .contains(PermissionType.updateChannel)) - _buildNameTile(), - _buildOptionListTiles(), - ], - ), - ); + @override + Widget build(BuildContext context) { + final channel = StreamChannel.of(context).channel; + final currentUserId = StreamChat.of(context).currentUser?.id; + + return BetterStreamBuilder>( + stream: channel.state!.membersStream, + initialData: channel.state!.members, + builder: (context, members) { + // Sort the current user to the top so the "You" row is always the + // first member rendered, matching the Figma. + final sorted = [...members].sorted((a, b) { + if (a.userId == currentUserId) return -1; + if (b.userId == currentUserId) return 1; + return 0; }); - } - - Widget _buildMembers(List members) { - final groupMembers = members - ..sort((prev, curr) { - if (curr.userId == channel.createdBy?.id) return 1; - return 0; - }); - int groupMembersLength; + final preview = sorted.take(_kPreviewLimit).toList(); + final overflow = sorted.length - preview.length; - if (listExpanded) { - groupMembersLength = groupMembers.length; - } else { - groupMembersLength = groupMembers.length > 6 ? 6 : groupMembers.length; - } + final spacing = context.streamSpacing; + final colorScheme = context.streamColorScheme; - return Column( - children: [ - ListView.builder( - shrinkWrap: true, - physics: const NeverScrollableScrollPhysics(), - itemCount: groupMembersLength, - itemBuilder: (context, index) { - final member = groupMembers[index]; - return Material( - color: StreamChatTheme.of(context).colorTheme.appBg, - child: InkWell( - onTap: () { - final userMember = groupMembers.firstWhereOrNull( - (e) => e.user!.id == StreamChat.of(context).currentUser!.id, - ); - _showUserInfoModal( - member.user, userMember?.userId == channel.createdBy?.id); + return _Section( + children: [ + _MembersHeader(count: members.length), + for (final member in preview) + ChannelMemberTile( + member: member, + isCurrentUser: member.userId == currentUserId, + onTap: switch (member.userId) { + final id? when id != currentUserId => () { + final user = member.user; + if (user != null) openContactDetail(context, user); + }, + _ => null, }, - child: SizedBox( - height: 65, - child: Column( - children: [ - Row( - children: [ - Padding( - padding: const EdgeInsets.symmetric( - horizontal: 8, - vertical: 12, - ), - child: StreamUserAvatar( - user: member.user!, - constraints: const BoxConstraints.tightFor( - height: 40, - width: 40, - ), - ), - ), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Text( - member.user!.name, - style: const TextStyle( - fontWeight: FontWeight.bold), - ), - const SizedBox( - height: 1, - ), - Text( - _getLastSeen(member.user!), - style: TextStyle( - color: StreamChatTheme.of(context) - .colorTheme - .textHighEmphasis - .withOpacity(0.5)), - ), - ], - ), - ), - Padding( - padding: const EdgeInsets.all(8), - child: Text( - member.userId == channel.createdBy?.id - ? AppLocalizations.of(context).owner - : '', - style: TextStyle( - color: StreamChatTheme.of(context) - .colorTheme - .textHighEmphasis - .withOpacity(0.5)), - ), - ), - ], - ), - Container( - height: 1, - color: StreamChatTheme.of(context).colorTheme.disabled, - ), - ], - ), - ), ), - ); - }, - ), - if (groupMembersLength != groupMembers.length) - InkWell( - onTap: () { - setState(() { - listExpanded = true; - }); - }, - child: Material( - color: StreamChatTheme.of(context).colorTheme.appBg, - child: SizedBox( - height: 65, - child: Column( - children: [ - Expanded( - child: Row( - children: [ - Padding( - padding: const EdgeInsets.symmetric( - horizontal: 21, vertical: 12), - child: StreamSvgIcon( - icon: StreamSvgIcons.down, - color: StreamChatTheme.of(context) - .colorTheme - .textLowEmphasis, - ), - ), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Text( - '${members.length - groupMembersLength} ${AppLocalizations.of(context).more}', - style: TextStyle( - color: StreamChatTheme.of(context) - .colorTheme - .textLowEmphasis), - ), - ], - ), - ), - ], - ), - ), - Container( - height: 1, - color: StreamChatTheme.of(context).colorTheme.disabled, - ), - ], - ), + if (overflow > 0) ...[ + SizedBox(height: spacing.sm), + Divider(height: 1, color: colorScheme.borderDefault), + StreamButton( + type: .ghost, + style: .secondary, + size: .small, + onPressed: () => showAllMembersSheet(context, channel), + child: const Text('View all'), ), - ), - ), - ], + ], + ], + ); + }, ); } +} - Widget _buildNameTile() { - final channelName = (channel.extraData['name'] as String?) ?? ''; +/// Header row at the top of [_MembersSection] — shows the total member +/// count on the left and an _Add_ button on the right (when the current +/// user can update the channel). +class _MembersHeader extends StatelessWidget { + const _MembersHeader({required this.count}); - return Material( - color: StreamChatTheme.of(context).colorTheme.appBg, - child: Container( - height: 56, - alignment: Alignment.center, + final int count; + + @override + Widget build(BuildContext context) { + final spacing = context.streamSpacing; + final colorScheme = context.streamColorScheme; + final textTheme = context.streamTextTheme; + final channel = StreamChannel.of(context).channel; + + return Padding( + padding: .symmetric(horizontal: spacing.md), + // Pin a min height so the header stays the same size whether or not + // the _Add_ button is rendered — without it, hiding the button + // (distinct channels, insufficient permissions) collapses the row to + // the title's natural text height. + child: ConstrainedBox( + constraints: const BoxConstraints(minHeight: 48), child: Row( children: [ - Padding( - padding: const EdgeInsets.all(7), - child: Text( - AppLocalizations.of(context).name.toUpperCase(), - style: StreamChatTheme.of(context).textTheme.footnote.copyWith( - color: StreamChatTheme.of(context) - .colorTheme - .textHighEmphasis - .withOpacity(0.5)), - ), - ), - const SizedBox( - width: 7, - ), Expanded( - child: TextField( - enabled: channel.ownCapabilities - .contains(PermissionType.updateChannel), - focusNode: _focusNode, - controller: _nameController, - cursorColor: - StreamChatTheme.of(context).colorTheme.textHighEmphasis, - decoration: InputDecoration.collapsed( - hintText: AppLocalizations.of(context).addAGroupName, - hintStyle: StreamChatTheme.of(context) - .textTheme - .bodyBold - .copyWith( - color: StreamChatTheme.of(context) - .colorTheme - .textHighEmphasis - .withOpacity(0.5))), - style: const TextStyle( - fontWeight: FontWeight.bold, - height: 0.82, - ), + child: Text( + '$count members', + style: textTheme.headingSm.copyWith(color: colorScheme.textPrimary), ), ), - if (channelName != _nameController.text.trim()) - Row( - mainAxisSize: MainAxisSize.min, - children: [ - IconButton( - icon: const StreamSvgIcon(icon: StreamSvgIcons.closeSmall), - onPressed: () { - setState(() { - _nameController.text = _getChannelName( - 2 * MediaQuery.of(context).size.width / 3, - members: channel.state!.members, - extraData: channel.extraData, - maxFontSize: 16, - )!; - _focusNode.unfocus(); - }); - }, - ), - IconButton( - color: StreamChatTheme.of(context).colorTheme.accentPrimary, - icon: const StreamSvgIcon(icon: StreamSvgIcons.check), - onPressed: () { - try { - channel.update({ - 'name': _nameController.text.trim(), - }); - } catch (_) { - setState(() { - _nameController.text = channelName; - _focusNode.unfocus(); - }); - } - }, - ), - ], + // Hide the affordance when the channel is distinct (1:1) — the + // API rejects member changes on those, so showing a tappable + // button that always errors is worse than no button at all. + if (channel.canUpdateChannelMembers && !channel.isDistinct) + StreamButton( + type: .outline, + style: .secondary, + size: .small, + onPressed: () => showAddMembersSheet(context, channel), + child: const Text('Add'), ), ], ), ), ); } +} - Widget _buildOptionListTiles() { - return Column( - children: [ - if (channel.ownCapabilities.contains(PermissionType.muteChannel)) - _GroupInfoToggle( - title: AppLocalizations.of(context).muteGroup, - icon: StreamSvgIcons.mute, - channelStream: channel.isMutedStream, - localNotifier: mutedBool, - onTurnOff: channel.unmute, - onTurnOn: channel.mute, - ), - _GroupInfoToggle( - title: AppLocalizations.of(context).pinGroup, - icon: StreamSvgIcons.pin, - channelStream: channel.isPinnedStream, - localNotifier: isPinned, - onTurnOff: channel.unpin, - onTurnOn: channel.pin, - ), - _GroupInfoToggle( - title: AppLocalizations.of(context).archiveGroup, - icon: StreamSvgIcons.save, - channelStream: channel.isArchivedStream, - localNotifier: isArchived, - onTurnOff: channel.unarchive, - onTurnOn: channel.archive, - ), - _GroupInfoListTile( - title: AppLocalizations.of(context).pinnedMessages, - icon: StreamSvgIcons.pin, - iconSize: 24, - iconPadding: 16, - onTap: () { - final channel = StreamChannel.of(context).channel; - - Navigator.push( - context, - MaterialPageRoute( - builder: (context) => StreamChannel( - channel: channel, - child: const PinnedMessagesScreen(), - ), - ), - ); - }, - ), - _GroupInfoListTile( - title: AppLocalizations.of(context).photosAndVideos, - icon: StreamSvgIcons.pictures, - iconSize: 32, - iconPadding: 12, - onTap: () { - final channel = StreamChannel.of(context).channel; - - Navigator.push( - context, - MaterialPageRoute( - builder: (context) => StreamChannel( - channel: channel, - child: ChannelMediaDisplayScreen( - messageTheme: widget.messageTheme, - ), - ), - ), - ); - }, - ), - _GroupInfoListTile( - title: AppLocalizations.of(context).files, - icon: StreamSvgIcons.files, - iconSize: 32, - iconPadding: 12, - onTap: () { - final channel = StreamChannel.of(context).channel; - - Navigator.push( - context, - MaterialPageRoute( - builder: (context) => StreamChannel( - channel: channel, - child: ChannelFileDisplayScreen( - messageTheme: widget.messageTheme, - ), - ), - ), - ); - }, - ), - if (!channel.isDistinct && - channel.ownCapabilities.contains(PermissionType.leaveChannel)) - StreamOptionListTile( - tileColor: StreamChatTheme.of(context).colorTheme.appBg, - separatorColor: StreamChatTheme.of(context).colorTheme.disabled, - title: AppLocalizations.of(context).leaveGroup, - titleTextStyle: StreamChatTheme.of(context).textTheme.body, - leading: Padding( - padding: const EdgeInsets.symmetric(horizontal: 16), - child: StreamSvgIcon( - icon: StreamSvgIcons.userRemove, - size: 24, - color: StreamChatTheme.of(context) - .colorTheme - .textHighEmphasis - .withOpacity(0.5), - ), - ), - trailing: const SizedBox( - height: 24, - width: 24, - ), - onTap: () async { - final streamChannel = StreamChannel.of(context); - final streamChat = StreamChat.of(context); - final router = GoRouter.of(context); - final res = await showConfirmationBottomSheet( - context, - title: AppLocalizations.of(context).leaveConversation, - okText: AppLocalizations.of(context).leave.toUpperCase(), - question: - AppLocalizations.of(context).leaveConversationAreYouSure, - cancelText: AppLocalizations.of(context).cancel.toUpperCase(), - icon: StreamSvgIcon( - icon: StreamSvgIcons.userRemove, - color: StreamChatTheme.of(context).colorTheme.accentError, - ), - ); - if (res == true) { - final channel = streamChannel.channel; - await channel.removeMembers([streamChat.currentUser!.id]); - router.pop(); - } - }, - ), - ], - ); - } - - void _buildAddUserModal(context) { - showDialog( - useRootNavigator: false, - context: context, - barrierColor: StreamChatTheme.of(context).colorTheme.overlay, - builder: (context) { - return Padding( - padding: const EdgeInsets.only(top: 16, left: 8, right: 8), - child: Material( - borderRadius: const BorderRadius.only( - topLeft: Radius.circular(16), - topRight: Radius.circular(16), - ), - clipBehavior: Clip.antiAlias, - child: Scaffold( - body: Column( - children: [ - Padding( - padding: const EdgeInsets.all(16), - child: _buildTextInputSection(), - ), - Expanded( - child: StreamUserGridView( - controller: _userListController, - onUserTap: (user) async { - _searchController.clear(); - final navigator = Navigator.of(context); +/// Card grouping the conversation-level actions — mute, leave, and (for +/// admins) delete. Mirrors the destructive actions exposed on the +/// channel-list long-press sheet so both surfaces stay in sync. +class _ActionsSection extends StatelessWidget { + const _ActionsSection(); - await channel.addMembers([user.id]); - navigator.pop(); - setState(() {}); - }, - emptyBuilder: (_) { - return LayoutBuilder( - builder: (context, viewportConstraints) { - return SingleChildScrollView( - physics: const AlwaysScrollableScrollPhysics(), - child: ConstrainedBox( - constraints: BoxConstraints( - minHeight: viewportConstraints.maxHeight, - ), - child: Center( - child: Column( - children: [ - Padding( - padding: const EdgeInsets.all(24), - child: StreamSvgIcon( - icon: StreamSvgIcons.search, - size: 96, - color: StreamChatTheme.of(context) - .colorTheme - .textLowEmphasis, - ), - ), - Text(AppLocalizations.of(context) - .noUserMatchesTheseKeywords), - ], - ), - ), - ), - ); - }, - ); - }, - ), - ), - ], - ), - ), - ), - ); - }, - ).whenComplete(() { - _searchController.clear(); - }); - } + @override + Widget build(BuildContext context) { + final icons = context.streamIcons; + final channel = StreamChannel.of(context).channel; - Widget _buildTextInputSection() { - final theme = StreamChatTheme.of(context); - return Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, + return _Section( children: [ - Expanded( - child: SizedBox( - height: 36, - child: TextField( - controller: _searchController, - cursorColor: theme.colorTheme.textHighEmphasis, - autofocus: true, - decoration: InputDecoration( - hintText: AppLocalizations.of(context).search, - hintStyle: theme.textTheme.body.copyWith( - color: theme.colorTheme.textLowEmphasis, - ), - prefixIconConstraints: BoxConstraints.tight(const Size(40, 24)), - prefixIcon: StreamSvgIcon( - icon: StreamSvgIcons.search, - color: theme.colorTheme.textHighEmphasis, - size: 24, - ), - enabledBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(24), - borderSide: BorderSide( - color: theme.colorTheme.borders, - ), - ), - focusedBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(24), - borderSide: BorderSide( - color: theme.colorTheme.borders, - )), - contentPadding: EdgeInsets.zero, - ), + BetterStreamBuilder( + stream: channel.isMutedStream, + initialData: channel.isMuted, + builder: (context, isMuted) => _Tile( + icon: Icon(isMuted ? icons.audio : icons.mute), + label: Text(isMuted ? 'Unmute Group' : 'Mute Group'), + trailing: StreamSwitch( + value: isMuted, + onChanged: (_) { + if (isMuted) { + channel.unmute(); + } else { + channel.mute(); + } + }, ), ), ), - IconButton( - icon: const StreamSvgIcon(icon: StreamSvgIcons.closeSmall), - color: theme.colorTheme.textHighEmphasis, - onPressed: () => Navigator.pop(context), - ) + if (channel.canLeaveChannel) + _Tile( + icon: Icon(icons.leave), + label: const Text('Leave Group'), + destructive: true, + onTap: () => _confirmLeave(context), + ), + if (channel.canDeleteChannel) + _Tile( + icon: Icon(icons.delete), + label: const Text('Delete Group'), + destructive: true, + onTap: () => _confirmDelete(context), + ), ], ); } - void _showUserInfoModal(User? user, bool isUserAdmin) { - final color = StreamChatTheme.of(context).colorTheme.barsBg; + Future _confirmLeave(BuildContext context) async { + final navigator = Navigator.of(context); + final channel = StreamChannel.of(context).channel; + final currentUserId = StreamChat.of(context).currentUser?.id; + if (currentUserId == null) return; - showModalBottomSheet( + final confirmed = await _showConfirmationDialog( context: context, - clipBehavior: Clip.antiAlias, - isScrollControlled: true, - backgroundColor: color, - builder: (context) { - return SafeArea( - child: StreamChannel( - channel: channel, - child: Material( - color: color, - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - const SizedBox( - height: 24, - ), - Center( - child: Text( - user!.name, - style: const TextStyle( - fontSize: 16, - fontWeight: FontWeight.bold, - ), - ), - ), - const SizedBox( - height: 5, - ), - _buildConnectedTitleState(user)!, - Center( - child: Padding( - padding: const EdgeInsets.all(16), - child: StreamUserAvatar( - user: user, - constraints: const BoxConstraints.tightFor( - height: 64, - width: 64, - ), - borderRadius: BorderRadius.circular(32), - ), - ), - ), - if (StreamChat.of(context).currentUser!.id != user.id) - _buildModalListTile( - context, - StreamSvgIcon( - color: StreamChatTheme.of(context) - .colorTheme - .textLowEmphasis, - size: 24, - icon: StreamSvgIcons.user, - ), - AppLocalizations.of(context).viewInfo, - () async { - final client = StreamChat.of(context).client; - final router = GoRouter.of(context); - - final c = client.channel('messaging', extraData: { - 'members': [ - user.id, - StreamChat.of(context).currentUser!.id, - ], - }); - - await c.watch(); - - router.pushNamed( - Routes.CHAT_INFO_SCREEN.name, - pathParameters: Routes.CHAT_INFO_SCREEN.params(c), - extra: user, - ); - }, - ), - if (StreamChat.of(context).currentUser!.id != user.id) - _buildModalListTile( - context, - StreamSvgIcon( - color: StreamChatTheme.of(context) - .colorTheme - .textLowEmphasis, - size: 24, - icon: StreamSvgIcons.message, - ), - AppLocalizations.of(context).message, - () async { - final client = StreamChat.of(context).client; - final router = GoRouter.of(context); - - final c = client.channel('messaging', extraData: { - 'members': [ - user.id, - StreamChat.of(context).currentUser!.id, - ], - }); + title: 'Leave group', + content: 'Are you sure you want to leave this group?', + confirmLabel: 'Leave', + ); + if (confirmed != true) return; - await c.watch(); + await channel.removeMembers([currentUserId]); + // Pop every screen until we land on the channel list — going back to + // the channel page would crash since we're no longer a member. + navigator.popUntil((route) => route.isFirst); + } - router.pushNamed( - Routes.CHANNEL_PAGE.name, - pathParameters: Routes.CHANNEL_PAGE.params(c), - ); - }, - ), - if (!channel.isDistinct && - StreamChat.of(context).currentUser!.id != user.id && - isUserAdmin) - _buildModalListTile( - context, - StreamSvgIcon( - color: StreamChatTheme.of(context) - .colorTheme - .accentError, - size: 24, - icon: StreamSvgIcons.userRemove, - ), - AppLocalizations.of(context).removeFromGroup, () async { - final router = GoRouter.of(context); - final res = await showConfirmationBottomSheet( - context, - title: AppLocalizations.of(context).removeMember, - okText: - AppLocalizations.of(context).remove.toUpperCase(), - question: - AppLocalizations.of(context).removeMemberAreYouSure, - cancelText: - AppLocalizations.of(context).cancel.toUpperCase(), - ); + Future _confirmDelete(BuildContext context) async { + final navigator = Navigator.of(context); + final channel = StreamChannel.of(context).channel; - if (res == true) { - await channel.removeMembers([user.id]); - } - router.pop(); - }, - color: - StreamChatTheme.of(context).colorTheme.accentError), - _buildModalListTile( - context, - StreamSvgIcon( - color: StreamChatTheme.of(context) - .colorTheme - .textLowEmphasis, - size: 24, - icon: StreamSvgIcons.closeSmall, - ), - AppLocalizations.of(context).cancel, - () { - Navigator.pop(context); - }, - ), - ], - ), - ), - ), - ); - }, - shape: const RoundedRectangleBorder( - borderRadius: BorderRadius.only( - topLeft: Radius.circular(16), - topRight: Radius.circular(16), - ), - ), + final confirmed = await _showConfirmationDialog( + context: context, + title: 'Delete group', + content: 'Are you sure you want to delete this group?', + confirmLabel: 'Delete', ); - } + if (confirmed != true) return; - Widget? _buildConnectedTitleState(User? user) { - late Text alternativeWidget; + await channel.delete(); + // Pop every screen until we land on the channel list — going back to + // the channel page would crash trying to read state from the now + // deleted channel. + navigator.popUntil((route) => route.isFirst); + } +} - final otherMember = user; +/// A rounded section card that visually groups its [children] with a single +/// background colour and clipped ink ripples — matches the Figma's "soft +/// grey card" pattern shared across detail screens. +class _Section extends StatelessWidget { + const _Section({required this.children}); - if (otherMember != null) { - if (otherMember.online) { - alternativeWidget = Text( - AppLocalizations.of(context).online, - style: TextStyle( - color: StreamChatTheme.of(context) - .colorTheme - .textHighEmphasis - .withOpacity(0.5)), - ); - } else { - alternativeWidget = Text( - '${AppLocalizations.of(context).lastSeen} ${Jiffy.parseFromDateTime(otherMember.lastActive!).fromNow()}', - style: TextStyle( - color: StreamChatTheme.of(context) - .colorTheme - .textHighEmphasis - .withOpacity(0.5)), - ); - } - } + final List children; - return alternativeWidget; - } + @override + Widget build(BuildContext context) { + final radius = context.streamRadius; + final spacing = context.streamSpacing; - Widget _buildModalListTile( - BuildContext context, Widget leading, String title, VoidCallback onTap, - {Color? color}) { - color ??= StreamChatTheme.of(context).colorTheme.textHighEmphasis; + final colorScheme = context.streamColorScheme; return Material( - color: StreamChatTheme.of(context).colorTheme.barsBg, - child: InkWell( - onTap: onTap, - child: Column( - children: [ - Container( - height: 1, - color: StreamChatTheme.of(context).colorTheme.disabled, - ), - SizedBox( - height: 64, - child: Row( - children: [ - Padding( - padding: const EdgeInsets.all(16), - child: leading, - ), - Expanded( - child: Text( - title, - style: - TextStyle(color: color, fontWeight: FontWeight.bold), - ), - ) - ], - ), - ), - ], - ), + color: colorScheme.backgroundSurfaceCard, + shape: RoundedSuperellipseBorder(borderRadius: .all(radius.lg)), + clipBehavior: Clip.antiAlias, + child: Padding( + padding: .symmetric(vertical: spacing.xs, horizontal: spacing.xxs), + child: Column(mainAxisSize: .min, children: children), ), ); } - - String? _getChannelName( - double width, { - List? members, - required Map extraData, - double? maxFontSize, - }) { - String? title; - final client = StreamChat.of(context); - if (extraData['name'] == null) { - final otherMembers = - members!.where((member) => member.user!.id != client.currentUser!.id); - if (otherMembers.isNotEmpty) { - final maxWidth = width; - final maxChars = maxWidth / maxFontSize!; - var currentChars = 0; - final currentMembers = []; - for (final element in otherMembers) { - final newLength = currentChars + element.user!.name.length; - if (newLength < maxChars) { - currentChars = newLength; - currentMembers.add(element); - } - } - - final exceedingMembers = otherMembers.length - currentMembers.length; - title = - '${currentMembers.map((e) => e.user!.name).join(', ')} ${exceedingMembers > 0 ? '+ $exceedingMembers' : ''}'; - } else { - title = AppLocalizations.of(context).noTitle; - } - } else { - title = extraData['name']; - } - return title; - } - - String _getLastSeen(User user) { - if (user.online) { - return AppLocalizations.of(context).online; - } else { - if (user.lastActive == null) { - return ''; - } - - return '${AppLocalizations.of(context).lastSeen} ${Jiffy.parseFromDateTime(user.lastActive!).fromNow()}'; - } - } } -class _GroupInfoToggle extends StatelessWidget { - const _GroupInfoToggle({ - required this.title, +/// A single row inside a [_Section] — leading icon, label, and an optional +/// [trailing] widget. Navigation rows should pass an explicit chevron; +/// action rows that confirm or toggle in place pass [trailing] only when +/// they need a control (e.g. a switch). Setting [destructive] paints both +/// the icon and the label with [StreamColorScheme.accentError] via a local +/// [StreamListTileTheme] override. +class _Tile extends StatelessWidget { + const _Tile({ required this.icon, - required this.channelStream, - required this.localNotifier, - required this.onTurnOff, - required this.onTurnOn, + required this.label, + this.onTap, + this.trailing, + this.destructive = false, }); - final String title; - final StreamSvgIconData icon; - final Stream channelStream; - final ValueNotifier localNotifier; - final VoidCallback onTurnOff; - final VoidCallback onTurnOn; + final Widget icon; + final Widget label; + final VoidCallback? onTap; + final Widget? trailing; + final bool destructive; @override Widget build(BuildContext context) { - return StreamBuilder( - stream: channelStream, - builder: (context, snapshot) { - localNotifier.value = snapshot.data; - - return StreamOptionListTile( - tileColor: StreamChatTheme.of(context).colorTheme.appBg, - separatorColor: StreamChatTheme.of(context).colorTheme.disabled, - title: title, - titleTextStyle: StreamChatTheme.of(context).textTheme.body, - leading: Padding( - padding: const EdgeInsets.symmetric(horizontal: 16), - child: StreamSvgIcon( - icon: icon, - size: 24, - color: StreamChatTheme.of(context) - .colorTheme - .textHighEmphasis - .withOpacity(0.5), - ), - ), - trailing: snapshot.data == null - ? const CircularProgressIndicator() - : ValueListenableBuilder( - valueListenable: localNotifier, - builder: (context, value, _) { - return CupertinoSwitch( - value: value!, - onChanged: (val) { - localNotifier.value = val; - if (snapshot.data!) { - onTurnOff(); - } else { - onTurnOn(); - } - }, - ); - }), - onTap: () {}, - ); - }); + final spacing = context.streamSpacing; + final colorScheme = context.streamColorScheme; + + return StreamListTileTheme( + data: StreamListTileThemeData( + iconColor: destructive ? .all(colorScheme.accentError) : null, + titleColor: destructive ? .all(colorScheme.accentError) : null, + minTileHeight: 44, // Matches the design's tap target size for action rows + contentPadding: .symmetric(horizontal: spacing.sm), + ), + child: StreamListTile( + leading: icon, + trailing: trailing, + title: label, + onTap: onTap, + ), + ); } } -class _GroupInfoListTile extends StatelessWidget { - const _GroupInfoListTile({ +// Stream-styled confirmation dialog with a destructive primary action. +// +// Mirrors the dialog pattern used by the poll interactor and the +// SDK-internal `StreamMessageActionConfirmationModal` — a Material +// [AlertDialog] with a ghost secondary cancel and a solid destructive +// confirm. Resolves to `true` on confirm, `false` on cancel, `null` on +// dismiss. +Future _showConfirmationDialog({ + required BuildContext context, + required String title, + required String content, + required String confirmLabel, +}) { + return showDialog( + context: context, + builder: (_) => _ConfirmationDialog( + title: title, + content: content, + confirmLabel: confirmLabel, + ), + ); +} + +class _ConfirmationDialog extends StatelessWidget { + const _ConfirmationDialog({ required this.title, - required this.icon, - required this.iconSize, - required this.iconPadding, - required this.onTap, + required this.content, + required this.confirmLabel, }); final String title; - final StreamSvgIconData icon; - final double iconSize; - final double iconPadding; - final VoidCallback onTap; + final String content; + final String confirmLabel; @override Widget build(BuildContext context) { - return StreamOptionListTile( - title: title, - tileColor: StreamChatTheme.of(context).colorTheme.appBg, - titleTextStyle: StreamChatTheme.of(context).textTheme.body, - leading: Padding( - padding: EdgeInsets.symmetric(horizontal: iconPadding), - child: StreamSvgIcon( - icon: icon, - size: iconSize, - color: StreamChatTheme.of(context) - .colorTheme - .textHighEmphasis - .withOpacity(0.5), + final colorScheme = context.streamColorScheme; + + return AlertDialog( + backgroundColor: colorScheme.backgroundElevation1, + title: Text(title), + content: Text(content), + actions: [ + StreamButton( + type: .ghost, + style: .secondary, + size: .small, + onPressed: () => Navigator.of(context).maybePop(false), + child: Text(context.translations.cancelLabel), ), - ), - trailing: StreamSvgIcon( - icon: StreamSvgIcons.right, - color: StreamChatTheme.of(context).colorTheme.textLowEmphasis, - ), - onTap: onTap, + StreamButton( + type: .solid, + style: .destructive, + size: .small, + onPressed: () => Navigator.of(context).maybePop(true), + child: Text(confirmLabel), + ), + ], ); } } diff --git a/sample_app/lib/pages/new_chat_screen.dart b/sample_app/lib/pages/new_chat_screen.dart index dfa5daaaf0..c2957a9443 100644 --- a/sample_app/lib/pages/new_chat_screen.dart +++ b/sample_app/lib/pages/new_chat_screen.dart @@ -5,7 +5,6 @@ import 'dart:async'; import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; import 'package:sample_app/routes/routes.dart'; -import 'package:sample_app/utils/localizations.dart'; import 'package:sample_app/widgets/chips_input_text_field.dart'; import 'package:stream_chat_flutter/stream_chat_flutter.dart'; @@ -17,8 +16,7 @@ class NewChatScreen extends StatefulWidget { } class _NewChatScreenState extends State { - final _chipInputTextFieldStateKey = - GlobalKey>(); + final _chipInputTextFieldStateKey = GlobalKey>(); late TextEditingController _controller; @@ -29,15 +27,11 @@ class _NewChatScreenState extends State { Filter.notEqual('id', StreamChat.of(context).currentUser!.id), ]), sort: [ - const SortOption( - 'name', - direction: 1, - ), + const SortOption.asc(UserSortKey.name), ], ); - ChipInputTextFieldState? get _chipInputTextFieldState => - _chipInputTextFieldStateKey.currentState; + ChipInputTextFieldState? get _chipInputTextFieldState => _chipInputTextFieldStateKey.currentState; String _userNameQuery = ''; @@ -64,8 +58,7 @@ class _NewChatScreenState extends State { }); userListController.filter = Filter.and([ - if (_userNameQuery.isNotEmpty) - Filter.autoComplete('name', _userNameQuery), + if (_userNameQuery.isNotEmpty) Filter.autoComplete('name', _userNameQuery), Filter.notEqual('id', StreamChat.of(context).currentUser!.id), ]); userListController.doInitialLoad(); @@ -94,13 +87,15 @@ class _NewChatScreenState extends State { final res = await chatState.client.queryChannelsOnline( state: false, watch: false, - filter: Filter.raw(value: { - 'members': [ - ..._selectedUsers.map((e) => e.id), - chatState.currentUser!.id, - ], - 'distinct': true, - }), + filter: Filter.raw( + value: { + 'members': [ + ..._selectedUsers.map((e) => e.id), + chatState.currentUser!.id, + ], + 'distinct': true, + }, + ), messageLimit: 0, paginationParams: const PaginationParams( limit: 1, @@ -145,17 +140,7 @@ class _NewChatScreenState extends State { Widget build(BuildContext context) { return Scaffold( backgroundColor: StreamChatTheme.of(context).colorTheme.appBg, - appBar: AppBar( - elevation: 0, - backgroundColor: StreamChatTheme.of(context).colorTheme.barsBg, - leading: const StreamBackButton(), - title: Text( - AppLocalizations.of(context).newChat, - style: StreamChatTheme.of(context).textTheme.headlineBold.copyWith( - color: StreamChatTheme.of(context).colorTheme.textHighEmphasis), - ), - centerTitle: true, - ), + appBar: StreamAppBar(title: const Text('New Chat')), body: StreamConnectionStatusBuilder( statusBuilder: (context, status) { var statusString = ''; @@ -163,14 +148,14 @@ class _NewChatScreenState extends State { switch (status) { case ConnectionStatus.connected: - statusString = AppLocalizations.of(context).connected; + statusString = 'Connected'; showStatus = false; break; case ConnectionStatus.connecting: - statusString = AppLocalizations.of(context).reconnecting; + statusString = 'Reconnecting...'; break; case ConnectionStatus.disconnected: - statusString = AppLocalizations.of(context).disconnected; + statusString = 'Disconnected'; break; } return StreamInfoTile( @@ -188,7 +173,7 @@ class _NewChatScreenState extends State { key: _chipInputTextFieldStateKey, controller: _controller, focusNode: _searchFocusNode, - hint: AppLocalizations.of(context).typeANameHint, + hint: 'Type a name', chipBuilder: (context, user) { return GestureDetector( onTap: () { @@ -200,9 +185,7 @@ class _NewChatScreenState extends State { children: [ Container( decoration: BoxDecoration( - color: StreamChatTheme.of(context) - .colorTheme - .disabled, + color: StreamChatTheme.of(context).colorTheme.disabled, borderRadius: BorderRadius.circular(12), ), padding: const EdgeInsets.only(left: 24), @@ -212,30 +195,23 @@ class _NewChatScreenState extends State { user.name, maxLines: 1, style: TextStyle( - color: StreamChatTheme.of(context) - .colorTheme - .textHighEmphasis, + color: StreamChatTheme.of(context).colorTheme.textHighEmphasis, ), ), ), ), Container( foregroundDecoration: BoxDecoration( - color: StreamChatTheme.of(context) - .colorTheme - .overlay, + color: StreamChatTheme.of(context).colorTheme.overlay, shape: BoxShape.circle, ), child: StreamUserAvatar( - showOnlineStatus: false, + size: .sm, user: user, - constraints: const BoxConstraints.tightFor( - height: 24, - width: 24, - ), + showOnlineIndicator: false, ), ), - const StreamSvgIcon(icon: StreamSvgIcons.close), + Icon(context.streamIcons.xmark), ], ), ); @@ -260,21 +236,17 @@ class _NewChatScreenState extends State { children: [ StreamNeumorphicButton( child: Center( - child: StreamSvgIcon( - color: StreamChatTheme.of(context) - .colorTheme - .accentPrimary, + child: Icon( + context.streamIcons.users, + color: StreamChatTheme.of(context).colorTheme.accentPrimary, size: 24, - icon: StreamSvgIcons.contacts, ), ), ), const SizedBox(width: 8), Text( - AppLocalizations.of(context).createAGroup, - style: StreamChatTheme.of(context) - .textTheme - .bodyBold, + 'Create a Group', + style: StreamChatTheme.of(context).textTheme.bodyBold, ), ], ), @@ -284,8 +256,7 @@ class _NewChatScreenState extends State { Container( width: double.maxFinite, decoration: BoxDecoration( - gradient: - StreamChatTheme.of(context).colorTheme.bgGradient, + gradient: StreamChatTheme.of(context).colorTheme.bgGradient, ), child: Padding( padding: const EdgeInsets.symmetric( @@ -293,17 +264,11 @@ class _NewChatScreenState extends State { horizontal: 8, ), child: Text( - _isSearchActive - ? '${AppLocalizations.of(context).matchesFor} "$_userNameQuery"' - : AppLocalizations.of(context).onThePlatorm, - style: StreamChatTheme.of(context) - .textTheme - .footnote - .copyWith( - color: StreamChatTheme.of(context) - .colorTheme - .textHighEmphasis - .withOpacity(.5))), + _isSearchActive ? 'Matches for "$_userNameQuery"' : 'On the platform', + style: StreamChatTheme.of(context).textTheme.footnote.copyWith( + color: StreamChatTheme.of(context).colorTheme.textHighEmphasis.withOpacity(.5), + ), + ), ), ), Expanded( @@ -323,52 +288,44 @@ class _NewChatScreenState extends State { _chipInputTextFieldState!.removeItem(user); } }, - itemBuilder: ( - context, - users, - index, - defaultWidget, - ) { - return defaultWidget.copyWith( - selected: - _selectedUsers.contains(users[index]), - ); - }, + itemBuilder: + ( + context, + users, + index, + defaultWidget, + ) { + return defaultWidget.copyWith( + selected: _selectedUsers.contains(users[index]), + ); + }, emptyBuilder: (_) { return LayoutBuilder( builder: (context, viewportConstraints) { return SingleChildScrollView( - physics: - const AlwaysScrollableScrollPhysics(), + physics: const AlwaysScrollableScrollPhysics(), child: ConstrainedBox( constraints: BoxConstraints( - minHeight: - viewportConstraints.maxHeight, + minHeight: viewportConstraints.maxHeight, ), child: Center( child: Column( children: [ - const Padding( - padding: EdgeInsets.all(24), - child: StreamSvgIcon( - icon: StreamSvgIcons.search, + Padding( + padding: const EdgeInsets.all(24), + child: Icon( + context.streamIcons.search, size: 96, color: Colors.grey, ), ), Text( - AppLocalizations.of(context) - .noUserMatchesTheseKeywords, - style: StreamChatTheme.of( - context) - .textTheme - .footnote - .copyWith( - color: StreamChatTheme - .of(context) - .colorTheme - .textHighEmphasis - .withOpacity(.5)), + 'No user matches these keywords...', + style: StreamChatTheme.of(context).textTheme.footnote.copyWith( + color: StreamChatTheme.of( + context, + ).colorTheme.textHighEmphasis.withOpacity(.5), + ), ), ], ), @@ -389,20 +346,17 @@ class _NewChatScreenState extends State { return Center( child: Text( - AppLocalizations.of(context).noChatsHereYet, + 'No chats here yet...', style: TextStyle( fontSize: 12, - color: StreamChatTheme.of(context) - .colorTheme - .textHighEmphasis - .withOpacity(.5), + color: StreamChatTheme.of(context).colorTheme.textHighEmphasis.withOpacity(.5), ), ), ); }, ), ), - StreamMessageInput( + StreamMessageComposer( focusNode: _messageInputFocusNode, preMessageSending: (message) async { await channel!.watch(); diff --git a/sample_app/lib/pages/new_group_chat_screen.dart b/sample_app/lib/pages/new_group_chat_screen.dart index d9fd969568..ecc17c61c5 100644 --- a/sample_app/lib/pages/new_group_chat_screen.dart +++ b/sample_app/lib/pages/new_group_chat_screen.dart @@ -4,7 +4,6 @@ import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; import 'package:sample_app/routes/routes.dart'; import 'package:sample_app/state/new_group_chat_state.dart'; -import 'package:sample_app/utils/localizations.dart'; import 'package:sample_app/widgets/search_text_field.dart'; import 'package:stream_chat_flutter/stream_chat_flutter.dart'; @@ -16,8 +15,7 @@ class NewGroupChatScreen extends StatefulWidget { } class _NewGroupChatScreenState extends State { - late final TextEditingController _controller = TextEditingController() - ..addListener(_userNameListener); + late final TextEditingController _controller = TextEditingController()..addListener(_userNameListener); String _userNameQuery = ''; @@ -45,8 +43,7 @@ class _NewGroupChatScreenState extends State { _isSearchActive = _userNameQuery.isNotEmpty; }); userListController.filter = Filter.and([ - if (_userNameQuery.isNotEmpty) - Filter.autoComplete('name', _userNameQuery), + if (_userNameQuery.isNotEmpty) Filter.autoComplete('name', _userNameQuery), Filter.notEqual('id', StreamChat.of(context).currentUser!.id), ]); userListController.doInitialLoad(); @@ -71,31 +68,20 @@ class _NewGroupChatScreenState extends State { final state = groupChatState; return Scaffold( backgroundColor: StreamChatTheme.of(context).colorTheme.appBg, - appBar: AppBar( - elevation: 1, - backgroundColor: StreamChatTheme.of(context).colorTheme.barsBg, - leading: const StreamBackButton(), - title: Text( - AppLocalizations.of(context).addGroupMembers, - style: TextStyle( - color: StreamChatTheme.of(context).colorTheme.textHighEmphasis, - fontSize: 16, + appBar: StreamAppBar( + title: const Text('Add Group Members'), + trailing: switch (state.users.isNotEmpty) { + true => StreamButton.icon( + icon: Icon(context.streamIcons.arrowRight), + onPressed: () async { + GoRouter.of(context).pushNamed( + Routes.NEW_GROUP_CHAT_DETAILS.name, + extra: state, + ); + }, ), - ), - centerTitle: true, - actions: [ - if (state.users.isNotEmpty) - IconButton( - color: StreamChatTheme.of(context).colorTheme.accentPrimary, - icon: const StreamSvgIcon(icon: StreamSvgIcons.arrowRight), - onPressed: () async { - GoRouter.of(context).pushNamed( - Routes.NEW_GROUP_CHAT_DETAILS.name, - extra: state, - ); - }, - ) - ], + false => null, + }, ), body: StreamConnectionStatusBuilder( statusBuilder: (context, status) { @@ -104,14 +90,14 @@ class _NewGroupChatScreenState extends State { switch (status) { case ConnectionStatus.connected: - statusString = AppLocalizations.of(context).connected; + statusString = 'Connected'; showStatus = false; break; case ConnectionStatus.connecting: - statusString = AppLocalizations.of(context).reconnecting; + statusString = 'Reconnecting...'; break; case ConnectionStatus.disconnected: - statusString = AppLocalizations.of(context).disconnected; + statusString = 'Disconnected'; break; } return StreamInfoTile( @@ -121,13 +107,12 @@ class _NewGroupChatScreenState extends State { message: statusString, child: NestedScrollView( floatHeaderSlivers: true, - headerSliverBuilder: - (BuildContext context, bool innerBoxIsScrolled) { + headerSliverBuilder: (BuildContext context, bool innerBoxIsScrolled) { return [ SliverToBoxAdapter( child: SearchTextField( controller: _controller, - hintText: AppLocalizations.of(context).search, + hintText: 'Search', ), ), if (state.users.isNotEmpty) @@ -138,8 +123,7 @@ class _NewGroupChatScreenState extends State { scrollDirection: Axis.horizontal, itemCount: state.users.length, padding: const EdgeInsets.all(8), - separatorBuilder: (_, __) => - const SizedBox(width: 16), + separatorBuilder: (_, __) => const SizedBox(width: 16), itemBuilder: (_, index) { final user = state.users.elementAt(index); return Column( @@ -147,16 +131,8 @@ class _NewGroupChatScreenState extends State { Stack( children: [ StreamUserAvatar( - onlineIndicatorAlignment: - const Alignment(0.9, 0.9), + size: .xl, user: user, - borderRadius: - BorderRadius.circular(32), - constraints: - const BoxConstraints.tightFor( - height: 64, - width: 64, - ), ), Positioned( top: -4, @@ -167,29 +143,20 @@ class _NewGroupChatScreenState extends State { }, child: DecoratedBox( decoration: BoxDecoration( - color: - StreamChatTheme.of(context) - .colorTheme - .appBg, + color: StreamChatTheme.of(context).colorTheme.appBg, shape: BoxShape.circle, border: Border.all( - color: StreamChatTheme.of( - context) - .colorTheme - .appBg, + color: StreamChatTheme.of(context).colorTheme.appBg, ), ), - child: StreamSvgIcon( - color: - StreamChatTheme.of(context) - .colorTheme - .textHighEmphasis, + child: Icon( + context.streamIcons.xmark, + color: StreamChatTheme.of(context).colorTheme.textHighEmphasis, size: 24, - icon: StreamSvgIcons.close, ), ), ), - ) + ), ], ), const SizedBox(height: 4), @@ -213,9 +180,7 @@ class _NewGroupChatScreenState extends State { child: Container( width: double.maxFinite, decoration: BoxDecoration( - gradient: StreamChatTheme.of(context) - .colorTheme - .bgGradient, + gradient: StreamChatTheme.of(context).colorTheme.bgGradient, ), child: Padding( padding: const EdgeInsets.symmetric( @@ -223,13 +188,9 @@ class _NewGroupChatScreenState extends State { horizontal: 8, ), child: Text( - _isSearchActive - ? '${AppLocalizations.of(context).matchesFor} "$_userNameQuery"' - : AppLocalizations.of(context).onThePlatorm, + _isSearchActive ? 'Matches for "$_userNameQuery"' : 'On the platform', style: TextStyle( - color: StreamChatTheme.of(context) - .colorTheme - .textLowEmphasis, + color: StreamChatTheme.of(context).colorTheme.textLowEmphasis, ), ), ), @@ -263,25 +224,17 @@ class _NewGroupChatScreenState extends State { children: [ Padding( padding: const EdgeInsets.all(24), - child: StreamSvgIcon( - icon: StreamSvgIcons.search, + child: Icon( + context.streamIcons.search, size: 96, - color: StreamChatTheme.of(context) - .colorTheme - .textLowEmphasis, + color: StreamChatTheme.of(context).colorTheme.textLowEmphasis, ), ), Text( - AppLocalizations.of(context) - .noUserMatchesTheseKeywords, - style: StreamChatTheme.of(context) - .textTheme - .footnote - .copyWith( - color: StreamChatTheme.of(context) - .colorTheme - .textLowEmphasis, - ), + 'No user matches these keywords...', + style: StreamChatTheme.of(context).textTheme.footnote.copyWith( + color: StreamChatTheme.of(context).colorTheme.textLowEmphasis, + ), ), ], ), @@ -312,8 +265,7 @@ class _HeaderDelegate extends SliverPersistentHeaderDelegate { final double height; @override - Widget build( - BuildContext context, double shrinkOffset, bool overlapsContent) { + Widget build(BuildContext context, double shrinkOffset, bool overlapsContent) { return ColoredBox( color: StreamChatTheme.of(context).colorTheme.barsBg, child: child, diff --git a/sample_app/lib/pages/pinned_messages_screen.dart b/sample_app/lib/pages/pinned_messages_screen.dart index 2bcc792fc6..f2b52a4b16 100644 --- a/sample_app/lib/pages/pinned_messages_screen.dart +++ b/sample_app/lib/pages/pinned_messages_screen.dart @@ -1,12 +1,17 @@ -// ignore_for_file: deprecated_member_use - import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; import 'package:sample_app/routes/routes.dart'; -import 'package:sample_app/utils/localizations.dart'; import 'package:stream_chat_flutter/stream_chat_flutter.dart'; +/// Lists every pinned message in the enclosing channel. +/// +/// Matches Figma frame `8833:437280` (and `8833:437021` for the empty +/// state). The search-list controller is filtered by `pinned: true` on +/// the channel's `cid` — same data source the legacy implementation used, +/// rendered via the SDK's `StreamMessageSearchListView` so the row +/// styling stays in sync with the rest of the app. class PinnedMessagesScreen extends StatefulWidget { + /// Creates a [PinnedMessagesScreen]. const PinnedMessagesScreen({super.key}); @override @@ -14,119 +19,90 @@ class PinnedMessagesScreen extends StatefulWidget { } class _PinnedMessagesScreenState extends State { - late final controller = StreamMessageSearchListController( + late final StreamMessageSearchListController _controller = StreamMessageSearchListController( client: StreamChat.of(context).client, - filter: Filter.in_( - 'cid', - [StreamChannel.of(context).channel.cid!], - ), - messageFilter: Filter.equal( - 'pinned', - true, - ), - sort: [ - const SortOption( - 'created_at', - direction: SortOption.ASC, - ), - ], + filter: Filter.in_('cid', [StreamChannel.of(context).channel.cid!]), + messageFilter: Filter.equal('pinned', true), + sort: const [SortOption.asc('created_at')], limit: 20, ); + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + @override Widget build(BuildContext context) { + final colorScheme = context.streamColorScheme; + return Scaffold( - backgroundColor: StreamChatTheme.of(context).colorTheme.barsBg, - appBar: AppBar( - elevation: 1, - centerTitle: true, - title: Text( - AppLocalizations.of(context).pinnedMessages, - style: TextStyle( - color: StreamChatTheme.of(context).colorTheme.textHighEmphasis, - fontSize: 16, - ), - ), - leading: const StreamBackButton(), - backgroundColor: StreamChatTheme.of(context).colorTheme.barsBg, - ), + backgroundColor: colorScheme.backgroundApp, + appBar: StreamAppBar(title: const Text('Pinned Messages')), body: StreamMessageSearchListView( - controller: controller, - emptyBuilder: (_) { - return Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - StreamSvgIcon( - icon: StreamSvgIcons.pin, - size: 136, - color: StreamChatTheme.of(context).colorTheme.disabled, - ), - const SizedBox(height: 16), - Text( - AppLocalizations.of(context).noPinnedItems, - style: TextStyle( - fontSize: 17, - color: - StreamChatTheme.of(context).colorTheme.textHighEmphasis, - fontWeight: FontWeight.bold, - ), - ), - const SizedBox(height: 8), - RichText( - textAlign: TextAlign.center, - text: TextSpan(children: [ - TextSpan( - text: '${AppLocalizations.of(context).longPressMessage} ', - style: TextStyle( - fontSize: 14, - color: StreamChatTheme.of(context) - .colorTheme - .textHighEmphasis - .withOpacity(0.5), - ), - ), - TextSpan( - text: AppLocalizations.of(context).pinToConversation, - style: TextStyle( - fontSize: 14, - fontWeight: FontWeight.bold, - color: StreamChatTheme.of(context) - .colorTheme - .textHighEmphasis - .withOpacity(0.5), - ), - ), - ]), - ), - ], - ), - ); - }, - onMessageTap: (messageResponse) async { - final client = StreamChat.of(context).client; - final router = GoRouter.of(context); - final message = messageResponse.message; - final channel = client.channel( - messageResponse.channel!.type, - id: messageResponse.channel!.id, - ); - if (channel.state == null) { - await channel.watch(); - } - router.pushNamed( - Routes.CHANNEL_PAGE.name, - pathParameters: Routes.CHANNEL_PAGE.params(channel), - queryParameters: Routes.CHANNEL_PAGE.queryParams(message), - ); - }, + controller: _controller, + emptyBuilder: (_) => const Center(child: _EmptyState()), + onMessageTap: _openMessage, ), ); } + Future _openMessage(GetMessageResponse response) async { + final client = StreamChat.of(context).client; + final router = GoRouter.of(context); + final message = response.message; + final channel = client.channel( + response.channel!.type, + id: response.channel!.id, + ); + if (channel.state == null) await channel.watch(); + router.goNamed( + Routes.CHANNEL_PAGE.name, + pathParameters: Routes.CHANNEL_PAGE.params(channel), + queryParameters: Routes.CHANNEL_PAGE.queryParams(message), + ); + } +} + +/// Empty state for [PinnedMessagesScreen] — pin icon, "No pinned +/// messages" headline, and a centered subtitle that nudges the user +/// toward the long-press flow that creates one. +class _EmptyState extends StatelessWidget { + const _EmptyState(); + @override - void dispose() { - controller.dispose(); - super.dispose(); + Widget build(BuildContext context) { + final spacing = context.streamSpacing; + final colorScheme = context.streamColorScheme; + final textTheme = context.streamTextTheme; + + return Padding( + padding: EdgeInsets.symmetric( + horizontal: spacing.md, + vertical: spacing.xxxl, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + context.streamIcons.pin, + size: 32, + color: colorScheme.textTertiary, + ), + SizedBox(height: spacing.sm), + Text( + 'No pinned messages', + style: textTheme.headingSm.copyWith(color: colorScheme.textPrimary), + ), + SizedBox(height: spacing.xxs), + Text( + 'Long-press a message to pin it to the chat', + style: textTheme.captionDefault.copyWith(color: colorScheme.textSecondary), + textAlign: TextAlign.center, + ), + ], + ), + ); } } diff --git a/sample_app/lib/pages/reminders_page.dart b/sample_app/lib/pages/reminders_page.dart index 18507e9e83..5aaa839be5 100644 --- a/sample_app/lib/pages/reminders_page.dart +++ b/sample_app/lib/pages/reminders_page.dart @@ -15,20 +15,21 @@ class RemindersPage extends StatefulWidget { } class _RemindersPageState extends State { - late final controller = StreamMessageReminderListController( - client: StreamChat.of(context).client, - )..eventListener = (event) { - if (event.type == EventType.connectionRecovered || - event.type == EventType.notificationReminderDue) { - // This will create the query filter with the updated current date - // and time, so that the reminders list is updated with the new - // reminders that are due. - onFilterChanged(_currentFilter); - } - - // Returning false as we also want the controller to handle the event. - return false; - }; + late final controller = + StreamMessageReminderListController( + client: StreamChat.of(context).client, + ) + ..eventListener = (event) { + if (event.type == EventType.connectionRecovered || event.type == EventType.notificationReminderDue) { + // This will create the query filter with the updated current date + // and time, so that the reminders list is updated with the new + // reminders that are due. + onFilterChanged(_currentFilter); + } + + // Returning false as we also want the controller to handle the event. + return false; + }; @override void dispose() { @@ -80,9 +81,9 @@ class _RemindersPageState extends State { children: [ CustomSlidableAction( backgroundColor: theme.colorTheme.inputBg, - child: StreamSvgIcon( + child: Icon( + context.streamIcons.edit, size: 24, - icon: StreamSvgIcons.edit, color: theme.colorTheme.accentPrimary, ), onPressed: (_) async { @@ -104,9 +105,9 @@ class _RemindersPageState extends State { ), CustomSlidableAction( backgroundColor: theme.colorTheme.inputBg, - child: StreamSvgIcon( + child: Icon( + context.streamIcons.delete, size: 24, - icon: StreamSvgIcons.delete, color: theme.colorTheme.accentError, ), onPressed: (context) { @@ -139,22 +140,12 @@ class _RemindersPageState extends State { ), ); }, - emptyBuilder: (context) { - final chatThemeData = StreamChatTheme.of(context); - return Center( - child: StreamScrollViewEmptyWidget( - emptyIcon: Icon( - size: 48, - Icons.bookmark_border_rounded, - color: theme.colorTheme.textLowEmphasis, - ), - emptyTitle: Text( - 'No reminders yet', - style: chatThemeData.textTheme.headline, - ), - ), - ); - }, + emptyBuilder: (context) => const Center( + child: StreamScrollViewEmptyWidget( + emptyIcon: Icon(Icons.bookmark_border_rounded), + emptyTitle: Text('No reminders yet'), + ), + ), loadMoreErrorBuilder: (context, error) => ListTile( onTap: controller.retry, title: Text(error.message), @@ -209,7 +200,8 @@ enum MessageRemindersFilter { overdue('Overdue'), upcoming('Upcoming'), scheduled('Scheduled'), - savedForLater('Saved for later'); + savedForLater('Saved for later') + ; const MessageRemindersFilter(this.label); final String label; @@ -238,12 +230,10 @@ class MessageRemindersFilterSelection extends StatefulWidget { final ValueSetter onSelected; @override - State createState() => - _MessageRemindersFilterSelectionState(); + State createState() => _MessageRemindersFilterSelectionState(); } -class _MessageRemindersFilterSelectionState - extends State { +class _MessageRemindersFilterSelectionState extends State { final _filterKeys = {}; @override diff --git a/sample_app/lib/pages/splash_screen.dart b/sample_app/lib/pages/splash_screen.dart index 0bbdcd9ebe..a7fb8c9269 100644 --- a/sample_app/lib/pages/splash_screen.dart +++ b/sample_app/lib/pages/splash_screen.dart @@ -3,8 +3,7 @@ import 'package:flutter/material.dart'; import 'package:lottie/lottie.dart'; -mixin SplashScreenStateMixin on State - implements TickerProvider { +mixin SplashScreenStateMixin on State implements TickerProvider { late final _animationController = AnimationController( vsync: this, duration: const Duration( @@ -20,29 +19,38 @@ mixin SplashScreenStateMixin on State ), ); - late final _circleAnimation = Tween( - begin: 0, - end: 1000, - ).animate(CurvedAnimation( - parent: _animationController, - curve: Curves.easeInOut, - )); + late final _circleAnimation = + Tween( + begin: 0, + end: 1000, + ).animate( + CurvedAnimation( + parent: _animationController, + curve: Curves.easeInOut, + ), + ); - late final _colorAnimation = ColorTween( - begin: const Color(0xff005FFF), - end: Colors.transparent, - ).animate(CurvedAnimation( - parent: _animationController, - curve: Curves.easeInOut, - )); + late final _colorAnimation = + ColorTween( + begin: const Color(0xff005FFF), + end: Colors.transparent, + ).animate( + CurvedAnimation( + parent: _animationController, + curve: Curves.easeInOut, + ), + ); - late final _scaleAnimation = Tween( - begin: 1, - end: 1.5, - ).animate(CurvedAnimation( - parent: _scaleAnimationController, - curve: Curves.easeInOutCubic, - )); + late final _scaleAnimation = + Tween( + begin: 1, + end: 1.5, + ).animate( + CurvedAnimation( + parent: _scaleAnimationController, + curve: Curves.easeInOutCubic, + ), + ); bool animationCompleted = false; @@ -75,50 +83,46 @@ mixin SplashScreenStateMixin on State } Widget buildAnimation() => Stack( - clipBehavior: Clip.none, - alignment: Alignment.center, - children: [ - AnimatedBuilder( - animation: _scaleAnimation, - builder: (context, child) => - Transform.scale(scale: _scaleAnimation.value, child: child), - child: AnimatedBuilder( - animation: _colorAnimation, - builder: (context, child) { - return DecoratedBox( - decoration: BoxDecoration(color: _colorAnimation.value), - child: Center( - child: !_animationController.isAnimating - ? child - : const SizedBox(), - ), - ); - }, - child: RepaintBoundary( - child: Lottie.asset( - 'assets/floating_boat.json', - alignment: Alignment.center, - ), + clipBehavior: Clip.none, + alignment: Alignment.center, + children: [ + AnimatedBuilder( + animation: _scaleAnimation, + builder: (context, child) => Transform.scale(scale: _scaleAnimation.value, child: child), + child: AnimatedBuilder( + animation: _colorAnimation, + builder: (context, child) { + return DecoratedBox( + decoration: BoxDecoration(color: _colorAnimation.value), + child: Center( + child: !_animationController.isAnimating ? child : const SizedBox(), ), + ); + }, + child: RepaintBoundary( + child: Lottie.asset( + 'assets/floating_boat.json', + alignment: Alignment.center, ), ), - AnimatedBuilder( - animation: _circleAnimation, - builder: (context, snapshot) { - return Transform.scale( - scale: _circleAnimation.value, - child: Container( - width: 1, - height: 1, - decoration: BoxDecoration( - color: Colors.white - .withOpacity(1 - _animationController.value), - shape: BoxShape.circle, - ), - ), - ); - }, - ), - ], - ); + ), + ), + AnimatedBuilder( + animation: _circleAnimation, + builder: (context, snapshot) { + return Transform.scale( + scale: _circleAnimation.value, + child: Container( + width: 1, + height: 1, + decoration: BoxDecoration( + color: Colors.white.withOpacity(1 - _animationController.value), + shape: BoxShape.circle, + ), + ), + ); + }, + ), + ], + ); } diff --git a/sample_app/lib/pages/thread_list_page.dart b/sample_app/lib/pages/thread_list_page.dart index 8dc69ef3df..ef0bd215a3 100644 --- a/sample_app/lib/pages/thread_list_page.dart +++ b/sample_app/lib/pages/thread_list_page.dart @@ -1,6 +1,8 @@ import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; import 'package:sample_app/pages/thread_page.dart'; +import 'package:sample_app/routes/routes.dart'; import 'package:stream_chat_flutter/stream_chat_flutter.dart'; class ThreadListPage extends StatefulWidget { @@ -23,52 +25,62 @@ class _ThreadListPageState extends State { @override Widget build(BuildContext context) { - return Column( - children: [ - ValueListenableBuilder( - valueListenable: controller.unseenThreadIds, - builder: (_, unreadThreads, __) => StreamUnreadThreadsBanner( - unreadThreads: unreadThreads, - onTap: () => controller - .refresh(resetValue: false) - .then((_) => controller.clearUnseenThreadIds()), - ), - ), - Expanded( - child: StreamThreadListView( - controller: controller, - onThreadTap: (thread) async { - final channelCid = thread.channelCid; + return ValueListenableBuilder>( + valueListenable: controller.unseenThreadIds, + builder: (context, unseenThreadIds, child) => StreamUnreadThreadsBanner( + enabled: unseenThreadIds.isNotEmpty, + unreadThreads: unseenThreadIds, + onRefresh: () async { + await controller.refresh(resetValue: false); + controller.clearUnseenThreadIds(); + }, + child: child, + ), + child: StreamThreadListView( + controller: controller, + onThreadTap: (thread) async { + final channelCid = thread.channelCid; - final channel = StreamChat.of(context).client.channel( - channelCid.split(':')[0], - id: channelCid.split(':')[1], - ); + final channel = StreamChat.of(context).client.channel( + channelCid.split(':')[0], + id: channelCid.split(':')[1], + ); - Navigator.of(context).push( - MaterialPageRoute( - builder: (context) { - return StreamChannel( - channel: channel, - initialMessageId: thread.draft?.parentId, - child: BetterStreamBuilder( - stream: channel.state?.messagesStream.map( + Navigator.of(context).push( + MaterialPageRoute( + builder: (context) { + return StreamChannel( + channel: channel, + initialMessageId: thread.draft?.parentId, + child: BetterStreamBuilder( + initialData: thread.parentMessage, + stream: channel.state?.messagesStream + .map( (messages) => messages.firstWhereOrNull( (m) => m.id == thread.parentMessage?.id, ), - ), - builder: (_, parentMessage) { - return ThreadPage(parent: parentMessage); + ) + .where((msg) => msg != null) + .cast(), + builder: (_, parentMessage) { + return ThreadPage( + parent: parentMessage, + onViewInChannelTap: (message) { + GoRouter.of(context).goNamed( + Routes.CHANNEL_PAGE.name, + pathParameters: Routes.CHANNEL_PAGE.params(channel), + queryParameters: {'mid': message.id}, + ); }, - ), - ); - }, - ), - ); - }, - ), - ), - ], + ); + }, + ), + ); + }, + ), + ); + }, + ), ); } } diff --git a/sample_app/lib/pages/thread_page.dart b/sample_app/lib/pages/thread_page.dart index 622b5772ca..48988be1bc 100644 --- a/sample_app/lib/pages/thread_page.dart +++ b/sample_app/lib/pages/thread_page.dart @@ -7,10 +7,12 @@ class ThreadPage extends StatefulWidget { required this.parent, this.initialScrollIndex, this.initialAlignment, + this.onViewInChannelTap, }); final Message parent; final int? initialScrollIndex; final double? initialAlignment; + final void Function(Message message)? onViewInChannelTap; @override State createState() => _ThreadPageState(); @@ -18,12 +20,12 @@ class ThreadPage extends StatefulWidget { class _ThreadPageState extends State { final FocusNode _focusNode = FocusNode(); - late StreamMessageInputController _messageInputController; + late StreamMessageComposerController _messageComposerController; @override void initState() { super.initState(); - _messageInputController = StreamMessageInputController( + _messageComposerController = StreamMessageComposerController( message: Message(parentId: widget.parent.id), ); } @@ -31,11 +33,12 @@ class _ThreadPageState extends State { @override void dispose() { _focusNode.dispose(); + _messageComposerController.dispose(); super.dispose(); } void _reply(Message message) { - _messageInputController.quotedMessage = message; + _messageComposerController.quotedMessage = message; WidgetsBinding.instance.addPostFrameCallback((timeStamp) { _focusNode.requestFocus(); }); @@ -45,9 +48,7 @@ class _ThreadPageState extends State { Widget build(BuildContext context) { return Scaffold( backgroundColor: StreamChatTheme.of(context).colorTheme.appBg, - appBar: StreamThreadHeader( - parent: widget.parent, - ), + appBar: StreamThreadHeader(parent: widget.parent), body: Column( children: [ Expanded( @@ -55,44 +56,21 @@ class _ThreadPageState extends State { parentMessage: widget.parent, initialScrollIndex: widget.initialScrollIndex, initialAlignment: widget.initialAlignment, - //onMessageSwiped: _reply, - messageFilter: defaultFilter, + onReplyTap: _reply, + swipeToReply: true, showScrollToBottom: false, highlightInitialMessage: true, - messageBuilder: (context, details, messages, defaultMessage) { - return defaultMessage.copyWith( - onReplyTap: _reply, - bottomRowBuilderWithDefaultWidget: ( - context, - message, - defaultWidget, - ) { - return defaultWidget.copyWith( - deletedBottomRowBuilder: (context, message) { - return const StreamVisibleFootnote(); - }, - ); - }, - ); - }, + onViewInChannelTap: widget.onViewInChannelTap, ), ), if (widget.parent.type != 'deleted') - StreamMessageInput( + StreamMessageComposer( focusNode: _focusNode, - messageInputController: _messageInputController, + messageComposerController: _messageComposerController, enableVoiceRecording: true, ), ], ), ); } - - bool defaultFilter(Message m) { - final currentUser = StreamChat.of(context).currentUser; - final isMyMessage = m.user?.id == currentUser?.id; - final isDeletedOrShadowed = m.isDeleted == true || m.shadowed == true; - if (isDeletedOrShadowed && !isMyMessage) return false; - return true; - } } diff --git a/sample_app/lib/pages/user_mentions_page.dart b/sample_app/lib/pages/user_mentions_page.dart deleted file mode 100644 index d4bb19e273..0000000000 --- a/sample_app/lib/pages/user_mentions_page.dart +++ /dev/null @@ -1,93 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:go_router/go_router.dart'; -import 'package:sample_app/routes/routes.dart'; -import 'package:sample_app/utils/localizations.dart'; -import 'package:stream_chat_flutter/stream_chat_flutter.dart'; - -class UserMentionsPage extends StatefulWidget { - const UserMentionsPage({super.key}); - - @override - State createState() => _UserMentionsPageState(); -} - -class _UserMentionsPageState extends State { - late final controller = StreamMessageSearchListController( - client: StreamChat.of(context).client, - filter: Filter.in_('members', [StreamChat.of(context).currentUser!.id]), - messageFilter: Filter.custom( - operator: r'$contains', - key: 'mentioned_users.id', - value: StreamChat.of(context).currentUser!.id, - ), - sort: [const SortOption.asc('created_at')], - limit: 20, - ); - @override - Widget build(BuildContext context) { - return StreamMessageSearchListView( - controller: controller, - emptyBuilder: (_) { - return LayoutBuilder( - builder: (context, viewportConstraints) { - return SingleChildScrollView( - physics: const AlwaysScrollableScrollPhysics(), - child: ConstrainedBox( - constraints: BoxConstraints( - minHeight: viewportConstraints.maxHeight, - ), - child: Center( - child: Column( - children: [ - Padding( - padding: const EdgeInsets.all(24), - child: StreamSvgIcon( - icon: StreamSvgIcons.mentions, - size: 96, - color: - StreamChatTheme.of(context).colorTheme.disabled, - ), - ), - Text( - AppLocalizations.of(context).noMentionsExistYet, - style: - StreamChatTheme.of(context).textTheme.body.copyWith( - color: StreamChatTheme.of(context) - .colorTheme - .textLowEmphasis, - ), - ), - ], - ), - ), - ), - ); - }, - ); - }, - onMessageTap: (messageResponse) async { - final client = StreamChat.of(context).client; - final router = GoRouter.of(context); - final message = messageResponse.message; - final channel = client.channel( - messageResponse.channel!.type, - id: messageResponse.channel!.id, - ); - if (channel.state == null) { - await channel.watch(); - } - router.pushNamed( - Routes.CHANNEL_PAGE.name, - pathParameters: Routes.CHANNEL_PAGE.params(channel), - queryParameters: Routes.CHANNEL_PAGE.queryParams(message), - ); - }, - ); - } - - @override - void dispose() { - controller.dispose(); - super.dispose(); - } -} diff --git a/sample_app/lib/push/push_provider.dart b/sample_app/lib/push/push_provider.dart new file mode 100644 index 0000000000..51e9357e93 --- /dev/null +++ b/sample_app/lib/push/push_provider.dart @@ -0,0 +1,93 @@ +import 'package:firebase_messaging/firebase_messaging.dart'; +import 'package:flutter/foundation.dart' show debugPrint; +import 'package:stream_chat_flutter/stream_chat_flutter.dart' as chat; + +/// Signature for a function that produces a [Stream] of push tokens. +typedef TokenStreamProvider = Stream Function(); + +/// A push provider configured against Stream Chat. +/// +/// Pairs the Stream Chat provider [type] and dashboard [name] with a +/// [TokenStreamProvider] that yields the current device token and all +/// future refreshes. +class PushProvider { + /// Firebase Cloud Messaging; works on both iOS and Android. + const PushProvider.firebase({ + required this.name, + TokenStreamProvider tokenStreamProvider = _firebaseTokenProvider, + }) : _tokenStreamProvider = tokenStreamProvider, + type = chat.PushProvider.firebase; + + /// Raw Apple Push Notification service. + /// + /// Note: raw APNs payloads lack FCM metadata, so + /// `firebase_messaging.onMessageOpenedApp` never fires on tap — + /// prefer [PushProvider.firebase] unless integrating with + /// non-Firebase tooling. + const PushProvider.apn({ + required this.name, + TokenStreamProvider tokenStreamProvider = _apnTokenProvider, + }) : _tokenStreamProvider = tokenStreamProvider, + type = chat.PushProvider.apn; + + static Stream _firebaseTokenProvider() async* { + // On iOS, `getToken()` throws if the APNs token isn't registered with + // Apple yet — and registration happens asynchronously after + // `requestPermission()`. Poll briefly before asking for the FCM token. + // https://firebase.google.com/docs/cloud-messaging/flutter/client#access_the_registration_token + // + // If APNs genuinely never arrives (simulator, missing entitlement, + // provisioning issue), skip token emission so we don't crash on the + // guarded `getToken` call — future token refreshes will still fire via + // `onTokenRefresh` if APNs registration eventually succeeds. + if (!await _awaitApnsTokenOnIOS()) { + debugPrint( + '[push] ⚠️ APNs token not available — skipping FCM registration. ' + 'Check: (1) not on simulator, (2) Push Notifications entitlement, ' + '(3) provisioning profile.', + ); + } else { + final initialToken = await FirebaseMessaging.instance.getToken(); + if (initialToken != null && initialToken.isNotEmpty) yield initialToken; + } + + yield* FirebaseMessaging.instance.onTokenRefresh; + } + + static Stream _apnTokenProvider() async* { + final initialToken = await FirebaseMessaging.instance.getAPNSToken(); + if (initialToken != null && initialToken.isNotEmpty) yield initialToken; + + yield* FirebaseMessaging.instance.onTokenRefresh; + } + + /// Polls `getAPNSToken` up to [attempts] times. Always `true` off iOS. + static Future _awaitApnsTokenOnIOS({ + Duration interval = const Duration(milliseconds: 500), + int attempts = 10, + }) async { + if (chat.CurrentPlatform.isWeb || !chat.CurrentPlatform.isIos) return true; + for (var i = 0; i < attempts; i++) { + final apns = await FirebaseMessaging.instance.getAPNSToken(); + if (apns != null && apns.isNotEmpty) return true; + await Future.delayed(interval); + } + return false; + } + + /// The provider name configured in the Stream dashboard. + final String name; + + /// The Stream Chat provider type (FCM or APNs). + final chat.PushProvider type; + + /// Returns the current push token, or throws [TimeoutException] if + /// the token isn't available within [timeout]. + Future getToken({required Duration timeout}) { + return onTokenRefresh.first.timeout(timeout); + } + + /// Emits the current push token (when available), then every refresh. + Stream get onTokenRefresh => _tokenStreamProvider(); + final TokenStreamProvider _tokenStreamProvider; +} diff --git a/sample_app/lib/push/push_token_manager.dart b/sample_app/lib/push/push_token_manager.dart new file mode 100644 index 0000000000..3aceb6573b --- /dev/null +++ b/sample_app/lib/push/push_token_manager.dart @@ -0,0 +1,86 @@ +import 'dart:async'; + +import 'package:flutter/foundation.dart' show debugPrint; +import 'package:sample_app/push/push_provider.dart'; +import 'package:stream_chat_flutter/stream_chat_flutter.dart' hide PushProvider; + +/// Mirrors push tokens to [client] for the current platform. +/// +/// Picks [iosPushProvider] on iOS, [androidPushProvider] on Android, and +/// no-ops on web. All token-retrieval and network errors are logged and +/// swallowed so they never block login/logout. +class PushTokenManager { + PushTokenManager({ + required this.client, + required this.iosPushProvider, + required this.androidPushProvider, + }); + + final StreamChatClient client; + final PushProvider iosPushProvider; + final PushProvider androidPushProvider; + + StreamSubscription? _tokenSubscription; + + Future _onTokenRefresh(String token, PushProvider provider) async { + debugPrint('[push] token received (provider=${provider.name})'); + + try { + await client.addDevice( + token, + provider.type, + pushProviderName: provider.name, + ); + debugPrint('[push] addDevice OK (type=${provider.type.name}, name=${provider.name})'); + } catch (e, stk) { + debugPrint('[push] addDevice failed: $e; $stk'); + } + } + + PushProvider? get _currentPushProvider { + if (CurrentPlatform.isWeb) return null; + if (CurrentPlatform.isIos) return iosPushProvider; + if (CurrentPlatform.isAndroid) return androidPushProvider; + return null; + } + + /// Subscribes to token refreshes and forwards each to [client]. + /// Call once per manager instance. + void registerDevice() { + final pushProvider = _currentPushProvider; + if (pushProvider == null) return; + + debugPrint('[push] registering device (provider=${pushProvider.name})'); + _tokenSubscription = pushProvider.onTokenRefresh.listen( + (token) => _onTokenRefresh(token, pushProvider), + ); + } + + /// Removes the current device token from [client]. Uses a 3s timeout + /// so a flaky network can't hold up logout. + Future unregisterDevice() async { + final pushProvider = _currentPushProvider; + if (pushProvider == null) return; + + final String token; + try { + token = await pushProvider.getToken(timeout: const Duration(seconds: 3)); + } catch (e) { + debugPrint('[push] unregister: failed to get token: $e'); + return; + } + + try { + await client.removeDevice(token); + debugPrint('[push] removeDevice OK'); + } catch (e, stk) { + debugPrint('[push] removeDevice failed: $e; $stk'); + } + } + + /// Cancels the token-refresh subscription. Idempotent. + Future dispose() async { + await _tokenSubscription?.cancel(); + _tokenSubscription = null; + } +} diff --git a/sample_app/lib/routes/app_routes.dart b/sample_app/lib/routes/app_routes.dart index 094060eb44..1c1426baa6 100644 --- a/sample_app/lib/routes/app_routes.dart +++ b/sample_app/lib/routes/app_routes.dart @@ -19,28 +19,28 @@ final appRoutes = [ GoRoute( name: Routes.CHANNEL_LIST_PAGE.name, path: Routes.CHANNEL_LIST_PAGE.path, - builder: (BuildContext context, GoRouterState state) => - const ChannelListPage(), + builder: (BuildContext context, GoRouterState state) => const ChannelListPage(), routes: [ GoRoute( name: Routes.CHANNEL_PAGE.name, path: Routes.CHANNEL_PAGE.path, builder: (context, state) { - final channel = StreamChat.of(context) - .client - .state - .channels[state.pathParameters['cid']]; + final channel = _resolveChannel(context, state); final messageId = state.uri.queryParameters['mid']; final parentId = state.uri.queryParameters['pid']; + // Thread deep-links require the parent message to already be in + // channel state. On cold cids (e.g. notification-tap into an + // unwatched channel) the state is null and we fall through to + // ChannelPage; the user lands on the channel rather than the + // thread, which is a degraded but functional UX. Message? parentMessage; if (parentId != null) { - parentMessage = channel?.state!.messages - .firstWhereOrNull((it) => it.id == parentId); + parentMessage = channel.state?.messages.firstWhereOrNull((it) => it.id == parentId); } return StreamChannel( - channel: channel!, + channel: channel, initialMessageId: messageId, child: Builder( builder: (context) { @@ -58,15 +58,10 @@ final appRoutes = [ name: Routes.CHAT_INFO_SCREEN.name, path: Routes.CHAT_INFO_SCREEN.path, builder: (BuildContext context, GoRouterState state) { - final channel = StreamChat.of(context) - .client - .state - .channels[state.pathParameters['cid']]; return StreamChannel( - channel: channel!, + channel: _resolveChannel(context, state), child: ChatInfoScreen( user: state.extra as User?, - messageTheme: StreamChatTheme.of(context).ownMessageTheme, ), ); }, @@ -75,15 +70,9 @@ final appRoutes = [ name: Routes.GROUP_INFO_SCREEN.name, path: Routes.GROUP_INFO_SCREEN.path, builder: (BuildContext context, GoRouterState state) { - final channel = StreamChat.of(context) - .client - .state - .channels[state.pathParameters['cid']]; return StreamChannel( - channel: channel!, - child: GroupInfoScreen( - messageTheme: StreamChatTheme.of(context).ownMessageTheme, - ), + channel: _resolveChannel(context, state), + child: const GroupInfoScreen(), ); }, ), @@ -116,13 +105,20 @@ final appRoutes = [ GoRoute( name: Routes.CHOOSE_USER.name, path: Routes.CHOOSE_USER.path, - builder: (BuildContext context, GoRouterState state) => - const ChooseUserPage(), + builder: (BuildContext context, GoRouterState state) => const ChooseUserPage(), ), GoRoute( name: Routes.ADVANCED_OPTIONS.name, path: Routes.ADVANCED_OPTIONS.path, - builder: (BuildContext context, GoRouterState state) => - const AdvancedOptionsPage(), + builder: (BuildContext context, GoRouterState state) => const AdvancedOptionsPage(), ), ]; + +// Resolves the channel for a `:cid`-bearing route. Returns the existing +// watched instance from client state when available; otherwise returns +// an unwatched shell — `StreamChannel` will watch it on mount, which is +// what triggers the unread-anchoring / `initialMessageId` behaviour. +Channel _resolveChannel(BuildContext context, GoRouterState state) { + final [type, id] = state.pathParameters['cid']!.split(':'); + return StreamChat.of(context).client.channel(type, id: id); +} diff --git a/sample_app/lib/routes/routes.dart b/sample_app/lib/routes/routes.dart index d60383edd2..ac62189d37 100644 --- a/sample_app/lib/routes/routes.dart +++ b/sample_app/lib/routes/routes.dart @@ -4,24 +4,24 @@ import 'package:stream_chat_flutter/stream_chat_flutter.dart'; /// Application routes abstract class Routes { - static const RouteConfig CHOOSE_USER = - RouteConfig(name: 'choose_user', path: '/users'); - static const RouteConfig ADVANCED_OPTIONS = - RouteConfig(name: 'advanced_options', path: '/options'); - static const ChannelRouteConfig CHANNEL_PAGE = - ChannelRouteConfig(name: 'channel_page', path: 'channel/:cid'); - static const RouteConfig NEW_CHAT = - RouteConfig(name: 'new_chat', path: '/new_chat'); - static const RouteConfig NEW_GROUP_CHAT = - RouteConfig(name: 'new_group_chat', path: '/new_group_chat'); + static const RouteConfig CHOOSE_USER = RouteConfig(name: 'choose_user', path: '/users'); + static const RouteConfig ADVANCED_OPTIONS = RouteConfig(name: 'advanced_options', path: '/options'); + static const ChannelRouteConfig CHANNEL_PAGE = ChannelRouteConfig(name: 'channel_page', path: 'channel/:cid'); + static const RouteConfig NEW_CHAT = RouteConfig(name: 'new_chat', path: '/new_chat'); + static const RouteConfig NEW_GROUP_CHAT = RouteConfig(name: 'new_group_chat', path: '/new_group_chat'); static const RouteConfig NEW_GROUP_CHAT_DETAILS = RouteConfig( - name: 'new_group_chat_details', path: '/new_group_chat_details'); - static const ChannelRouteConfig CHAT_INFO_SCREEN = - ChannelRouteConfig(name: 'chat_info_screen', path: 'chat_info_screen'); - static const ChannelRouteConfig GROUP_INFO_SCREEN = - ChannelRouteConfig(name: 'group_info_screen', path: 'group_info_screen'); - static const RouteConfig CHANNEL_LIST_PAGE = - RouteConfig(name: 'channel_list_page', path: '/channels'); + name: 'new_group_chat_details', + path: '/new_group_chat_details', + ); + static const ChannelRouteConfig CHAT_INFO_SCREEN = ChannelRouteConfig( + name: 'chat_info_screen', + path: 'chat_info_screen', + ); + static const ChannelRouteConfig GROUP_INFO_SCREEN = ChannelRouteConfig( + name: 'group_info_screen', + path: 'group_info_screen', + ); + static const RouteConfig CHANNEL_LIST_PAGE = RouteConfig(name: 'channel_list_page', path: '/channels'); } class RouteConfig { @@ -36,7 +36,7 @@ class ChannelRouteConfig extends RouteConfig { Map params(Channel channel) => {'cid': channel.cid!}; Map queryParams(Message message) => { - 'mid': message.id, - if (message.parentId != null) 'pid': message.parentId! - }; + 'mid': message.id, + if (message.parentId != null) 'pid': message.parentId!, + }; } diff --git a/sample_app/lib/state/init_data.dart b/sample_app/lib/state/init_data.dart deleted file mode 100644 index 7f23896afd..0000000000 --- a/sample_app/lib/state/init_data.dart +++ /dev/null @@ -1,36 +0,0 @@ -import 'package:flutter/widgets.dart'; -import 'package:stream_chat_flutter/stream_chat_flutter.dart'; -import 'package:streaming_shared_preferences/streaming_shared_preferences.dart'; - -/// {@template init_notifier} -/// [ChangeNotifier] to store [InitData] and notify listeners on change. -/// {@endtemplate} -class InitNotifier extends ChangeNotifier { - /// {@macro init_notifier} - InitNotifier(); - - InitData? _initData; - - set initData(InitData? data) { - _initData = data; - notifyListeners(); - } - - InitData? get initData => _initData; -} - -/// {@template init_data} -/// Manages the initialization data for the sample application. -/// -/// Stores a reference to the current [StreamChatClient]. -/// {@endtemplate} -class InitData { - /// {@macro init_data} - InitData(this.client, this.preferences); - - final StreamChatClient client; - final StreamingSharedPreferences preferences; - - InitData copyWith({required StreamChatClient client}) => - InitData(client, preferences); -} diff --git a/sample_app/lib/utils/app_config.dart b/sample_app/lib/utils/app_config.dart index e006cddfee..5bc121badb 100644 --- a/sample_app/lib/utils/app_config.dart +++ b/sample_app/lib/utils/app_config.dart @@ -1,81 +1,68 @@ import 'package:stream_chat_flutter/stream_chat_flutter.dart'; -const sentryDsn = - 'https://6381ef88de4140db8f5e25ab37e0f08c@o1213503.ingest.sentry.io/6352870'; +const sentryDsn = 'https://6381ef88de4140db8f5e25ab37e0f08c@o1213503.ingest.sentry.io/6352870'; const kDefaultStreamApiKey = 'kv7mcsxr24p8'; final defaultUsers = { 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX2lkIjoic2FsdmF0b3JlIn0.pgiJz7sIc7iP29BHKFwe3nLm5-OaR_1l2P-SlgiC9a8': User( - id: 'salvatore', - extraData: const { - 'name': 'Salvatore Giordano', - 'image': - 'https://avatars.githubusercontent.com/u/20601437?s=460&u=3f66c22a7483980624804054ae7f357cf102c784&v=4', - }, - ), - 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX2lkIjoic2FoaWwifQ.WnIUoB5gR2kcAsFhiDvkiD6zdHXZ-VSU2aQWWkhsvfo': - User( + id: 'salvatore', + extraData: const { + 'name': 'Salvatore Giordano', + 'image': + 'https://avatars.githubusercontent.com/u/20601437?s=460&u=3f66c22a7483980624804054ae7f357cf102c784&v=4', + }, + ), + 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX2lkIjoic2FoaWwifQ.WnIUoB5gR2kcAsFhiDvkiD6zdHXZ-VSU2aQWWkhsvfo': User( id: 'sahil', extraData: const { 'name': 'Sahil Kumar', - 'image': - 'https://avatars.githubusercontent.com/u/25670178?s=400&u=30ded3784d8d2310c5748f263fd5e6433c119aa1&v=4', + 'image': 'https://avatars.githubusercontent.com/u/25670178?s=400&u=30ded3784d8d2310c5748f263fd5e6433c119aa1&v=4', }, ), - 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX2lkIjoiYmVuIn0.nAz2sNFGQwY7rl2Og2z3TGHUsdpnN53tOsUglJFvLmg': - User( + 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX2lkIjoiYmVuIn0.nAz2sNFGQwY7rl2Og2z3TGHUsdpnN53tOsUglJFvLmg': User( id: 'ben', extraData: const { 'name': 'Ben Golden', 'image': 'https://avatars.githubusercontent.com/u/1581974?s=400&v=4', }, ), - 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX2lkIjoidGhpZXJyeSJ9.lEq6TrZtHzjoNtf7HHRufUPyGo_pa8vg4_XhEBp4ckY': - User( + 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX2lkIjoidGhpZXJyeSJ9.lEq6TrZtHzjoNtf7HHRufUPyGo_pa8vg4_XhEBp4ckY': User( id: 'thierry', extraData: const { 'name': 'Thierry Schellenbach', - 'image': - 'https://avatars.githubusercontent.com/u/265409?s=400&u=2d0e3bb1820db992066196bff7b004f0eee8e28d&v=4', + 'image': 'https://avatars.githubusercontent.com/u/265409?s=400&u=2d0e3bb1820db992066196bff7b004f0eee8e28d&v=4', }, ), - 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX2lkIjoidG9tbWFzbyJ9.GLSI0ESshERMo2WjUpysD709NEtn1zmGimUN2an7g9o': - User( + 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX2lkIjoidG9tbWFzbyJ9.GLSI0ESshERMo2WjUpysD709NEtn1zmGimUN2an7g9o': User( id: 'tommaso', extraData: const { 'name': 'Tommaso Barbugli', 'image': 'https://avatars.githubusercontent.com/u/88735?s=400&v=4', }, ), - 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX2lkIjoiZGV2ZW4ifQ.z3zI4PqJnNhc-1o-VKcmb6BnnQ0oxFNCRHwEulHqcWc': - User( - id: 'deven', - extraData: const { - 'name': 'Deven Joshi', - 'image': - 'https://avatars.githubusercontent.com/u/26357843?s=400&u=0c61d890866e67bf69f58878be58915e9bfd39ee&v=4', - }, - ), - 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX2lkIjoibmVldmFzaCJ9.3EdHegTxibrz3A9cTiKmpEyawwcCVB8FXnoFzr4eKvw': - User( + // 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX2lkIjoiZGV2ZW4ifQ.z3zI4PqJnNhc-1o-VKcmb6BnnQ0oxFNCRHwEulHqcWc': User( + // id: 'deven', + // extraData: const { + // 'name': 'Deven Joshi', + // 'image': 'https://avatars.githubusercontent.com/u/26357843?s=400&u=0c61d890866e67bf69f58878be58915e9bfd39ee&v=4', + // }, + // ), + 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX2lkIjoibmVldmFzaCJ9.3EdHegTxibrz3A9cTiKmpEyawwcCVB8FXnoFzr4eKvw': User( id: 'neevash', extraData: const { 'name': 'Neevash Ramdial', - 'image': - 'https://avatars.githubusercontent.com/u/25674767?s=400&u=1d7333baf7dd9d143db8bfcdb31a838b89cfff9c&v=4', + 'image': 'https://avatars.githubusercontent.com/u/25674767?s=400&u=1d7333baf7dd9d143db8bfcdb31a838b89cfff9c&v=4', }, ), - 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX2lkIjoicWF0ZXN0MSJ9.fnelU7HcP7QoEEsCGteNlF1fppofzNlrnpDQuIgeKCU': - User( + 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX2lkIjoicWF0ZXN0MSJ9.fnelU7HcP7QoEEsCGteNlF1fppofzNlrnpDQuIgeKCU': User( id: 'qatest1', extraData: const { 'name': 'QA test 1', }, ), - 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX2lkIjoicWF0ZXN0MiJ9.vSCqAEbs2WVmMWsOsa7065Fsjq-rsTih6qsHPynl7XM': - User( + 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX2lkIjoicWF0ZXN0MiJ9.vSCqAEbs2WVmMWsOsa7065Fsjq-rsTih6qsHPynl7XM': User( id: 'qatest2', extraData: const { 'name': 'QA test 2', diff --git a/sample_app/lib/utils/client_extensions.dart b/sample_app/lib/utils/client_extensions.dart new file mode 100644 index 0000000000..4865dc62da --- /dev/null +++ b/sample_app/lib/utils/client_extensions.dart @@ -0,0 +1,25 @@ +import 'package:stream_chat_flutter/stream_chat_flutter.dart'; + +/// Sample-app helpers around `currentUser.mutes` and +/// `currentUser.blockedUserIds` — wrap the verbose +/// `currentUserStream.map(...).distinct()` pattern so the call sites stay +/// readable. +extension ClientUserStateExtensions on StreamChatClient { + /// Whether the current user has muted the user with the given [userId]. + bool isUserMuted(String userId) => state.currentUser?.mutes.any((m) => m.target.id == userId) ?? false; + + /// Reactive variant of [isUserMuted] — emits `true`/`false` as the current + /// user's mute list changes. + Stream userMutedStream(String userId) { + return state.currentUserStream.map((u) => u?.mutes.any((m) => m.target.id == userId) ?? false).distinct(); + } + + /// Whether the current user has blocked the user with the given [userId]. + bool isUserBlocked(String userId) => state.currentUser?.blockedUserIds.contains(userId) ?? false; + + /// Reactive variant of [isUserBlocked] — emits `true`/`false` as the + /// current user's blocked list changes. + Stream userBlockedStream(String userId) { + return state.currentUserStream.map((u) => u?.blockedUserIds.contains(userId) ?? false).distinct(); + } +} diff --git a/sample_app/lib/utils/local_notification_observer.dart b/sample_app/lib/utils/local_notification_observer.dart deleted file mode 100644 index 4b2fc12a69..0000000000 --- a/sample_app/lib/utils/local_notification_observer.dart +++ /dev/null @@ -1,68 +0,0 @@ -import 'dart:async'; - -import 'package:flutter/material.dart'; -import 'package:sample_app/routes/routes.dart'; -import 'package:sample_app/utils/notifications_service.dart' as pn; -import 'package:stream_chat_flutter/stream_chat_flutter.dart'; - -class LocalNotificationObserver extends NavigatorObserver { - LocalNotificationObserver( - StreamChatClient client, - GlobalKey navigatorKey, - ) { - _subscription = client - .on( - EventType.messageNew, - EventType.notificationMessageNew, - ) - .listen((event) { - _handleEvent(event, client, navigatorKey); - }); - } - Route? currentRoute; - late final StreamSubscription _subscription; - - void _handleEvent(Event event, StreamChatClient client, - GlobalKey navigatorKey) { - if (event.message?.user?.id == client.state.currentUser?.id) { - return; - } - final channelId = event.cid; - if (currentRoute?.settings.name == Routes.CHANNEL_PAGE.name) { - final args = currentRoute!.settings.arguments! as Map; - if (args['cid'] == channelId) { - return; - } - } - - pn.showLocalNotification( - event, - client.state.currentUser!.id, - navigatorKey.currentState!.context, - ); - } - - @override - void didPop(Route route, Route? previousRoute) { - currentRoute = route; - } - - @override - void didPush(Route route, Route? previousRoute) { - currentRoute = route; - } - - @override - void didRemove(Route route, Route? previousRoute) { - currentRoute = route; - } - - @override - void didReplace({Route? newRoute, Route? oldRoute}) { - currentRoute = newRoute; - } - - void dispose() { - _subscription.cancel(); - } -} diff --git a/sample_app/lib/utils/localizations.dart b/sample_app/lib/utils/localizations.dart deleted file mode 100644 index 7ce1005291..0000000000 --- a/sample_app/lib/utils/localizations.dart +++ /dev/null @@ -1,569 +0,0 @@ -import 'package:flutter/foundation.dart'; -import 'package:flutter/material.dart'; - -class AppLocalizations { - AppLocalizations(this.locale); - static const _localizedValues = >{ - 'en': { - 'add_a_group_name': 'Add a group name', - 'add_group_members': 'Add Group Members', - 'advanced_options': 'Advanced Options', - 'api_key_error': 'Please enter the Chat API Key', - 'archive_group': 'Archive group', - 'attachment': 'attachment', - 'attachments': 'attachments', - 'cancel': 'Cancel', - 'chat_api_key': 'Chat API Key', - 'chats': 'Chats', - 'choose_a_group_chat_name': 'Choose a group chat name', - 'connected': 'Connected', - 'create_a_group': 'Create a Group', - 'custom_settings': 'Custom settings', - 'delete': 'Delete', - 'delete_conversation_are_you_sure': - 'Are you sure you want to delete this conversation?', - 'delete_conversation_title': 'Delete Conversation', - 'disconnected': 'Disconnected', - 'error_connecting': 'Error connecting, retry', - 'files': 'Files', - 'files_appear_here': 'Files sent in this chat will appear here', - 'group_shared_with_user_appear_here': - 'Group shared with User will appear here.', - 'last_seen': 'Last seen', - 'leave': 'Leave', - 'leave_conversation': 'Leave conversation', - 'leave_conversation_are_you_sure': - 'Are you sure you want to leave this conversation?', - 'leave_group': 'Leave Group', - 'loading': 'Loading...', - 'login': 'Login', - 'long_press_message': 'Long-press an important message and\nchoose', - 'matches_for': 'Matches for', - 'member': 'Member', - 'members': 'Members', - 'mentions': 'Mentions', - 'message': 'Message', - 'message_channel_description': 'Channel used for showing messages', - 'message_channel_name': 'Message channel', - 'more': 'more', - 'mute_group': 'Mute group', - 'mute_user': 'Mute user', - 'name': 'Name', - 'name_of_group_chat': 'Name of Group Chat', - 'new_chat': 'New Chat', - 'new_direct_message': 'New direct message', - 'new_group': 'New group', - 'no_chats_here_yet': 'No chats here yet...', - 'no_files': 'No Files', - 'no_media': 'No Media', - 'no_mentions_exist_yet': 'No mentions exist yet...', - 'no_pinned_items': 'No pinned items', - 'no_results': 'No results...', - 'no_shared_groups': 'No Shared Groups', - 'no_title': 'No title', - 'no_user_matches_these_keywords': 'No user matches these keywords...', - 'ok': 'OK', - 'online': 'Online', - 'on_the_platform': 'On the platform', - 'operation_could_not_be_completed': - "The operation couldn't be completed.", - 'owner': 'Owner', - 'pin_group': 'Pin group', - 'photos_and_videos': 'Photos & Videos', - 'photos_or_videos_will_appear_here': - 'Photos or videos sent in this chat will \nappear here', - 'pinned_messages': 'Pinned Messages', - 'pin_to_conversation': 'Pin to conversation', - 'reconnecting': 'Reconnecting...', - 'remove': 'Remove', - 'remove_from_group': 'Remove From Group', - 'remove_member': 'Remove member', - 'remove_member_are_you_sure': - 'Are you sure you want to remove this member?', - 'search': 'Search', - 'select_user_to_try_flutter_sdk': 'Select a user to try the Flutter SDK', - 'shared_groups': 'Shared Groups', - 'sign_out': 'Sign out', - 'something_went_wrong_error_message': 'Something went wrong', - 'stream_sdk': 'Stream SDK', - 'stream_test_account': 'Stream test account', - 'to': 'To', - 'type_a_name_hint': 'Type a name', - 'user_id': 'User ID', - 'user_id_error': 'Please enter the User ID', - 'username_optional': 'Username (optional)', - 'user_token': 'User Token', - 'user_token_error': 'Please enter the user token', - 'view_info': 'View info', - 'welcome_to_stream_chat': 'Welcome to Stream Chat', - }, - 'it': { - 'add_a_group_name': 'Aggiungi un nome al gruppo', - 'add_group_members': 'Aggiungi un membro', - 'advanced_options': 'Opzioni Avanzate', - 'api_key_error': "Per favore inserisci l'API Key", - 'archive_group': 'Archivia gruppo', - 'attachment': 'allegato', - 'attachments': 'allegati', - 'cancel': 'Annulla', - 'chat_api_key': 'Chat API Key', - 'chats': 'Conversazioni', - 'choose_a_group_chat_name': 'Scegli un nome per il gruppo', - 'connected': 'Connesso', - 'create_a_group': 'Crea un Gruppo', - 'custom_settings': 'Opzioni Personalizzate', - 'delete': 'Cancella', - 'delete_conversation_are_you_sure': - 'Sei sicuro di voler eliminare la conversazione?', - 'delete_conversation_title': 'Elimina Conversazione', - 'disconnected': 'Disconnesso', - 'error_connecting': 'Errore durante la connessione, riprova', - 'files': 'File', - 'files_appear_here': 'I file inviati in questa chat compariranno qui', - 'group_shared_with_user_appear_here': - "I gruppi in comune con quest'utente compariranno qui", - 'last_seen': 'Ultimo accesso', - 'leave': 'Lascia', - 'leave_conversation': 'Lascia conversazione', - 'leave_conversation_are_you_sure': - 'Sei sicuro di voler lasciare questa conversazione?', - 'leave_group': 'Lascia Gruppo', - 'loading': 'Caricamento...', - 'login': 'Login', - 'long_press_message': 'Premi a lungo su un messaggio importante e scegli', - 'matches_for': 'Risultati per', - 'member': 'Membro', - 'members': 'Membri', - 'mentions': 'Menzioni', - 'message': 'Messaggi', - 'message_channel_description': 'Canale usato per mostrare i messaggi', - 'message_channel_name': 'Invia un messaggio al canale', - 'more': 'altro', - 'mute_group': 'Silenzia gruppo', - 'mute_user': 'Silenzia utente', - 'name': 'Nome', - 'name_of_group_chat': 'Nome del gruppo', - 'new_chat': 'Nuova conversazione', - 'new_direct_message': 'Nuovo messaggio diretto', - 'new_group': 'Nuovo gruppo', - 'no_chats_here_yet': 'Ancora nessun messaggio...', - 'no_files': 'Nessun File', - 'no_media': 'Nessun Media', - 'no_mentions_exist_yet': 'Ancora nessuna menzione...', - 'no_pinned_items': 'Nessun messaggio in evidenza', - 'no_results': 'Nessun risultato...', - 'no_shared_groups': 'Nessun gruppo in comune', - 'no_title': 'Nessun titolo', - 'no_user_matches_these_keywords': 'Nessun utente per questa ricerca...', - 'ok': 'OK', - 'online': 'Online', - 'on_the_platform': 'Sulla piattaforma', - 'operation_could_not_be_completed': - "Non é stato possibile completare l'operazione.", - 'owner': 'Proprietario', - 'photos_and_videos': 'Foto & Video', - 'photos_or_videos_will_appear_here': - 'Foto or video inviati in questa chat \ncompariranno qui', - 'pinned_messages': 'Messaggi in evidenza', - 'pin_group': 'Gruppo di evidenziazione', - 'pin_to_conversation': 'Metti in evidenza', - 'reconnecting': 'Riconnessione...', - 'remove': 'Rimuovi', - 'remove_from_group': 'Rimuovi Dal Gruppo', - 'remove_member': 'Rimuovi membro', - 'remove_member_are_you_sure': - 'Sei sicuro di voler rimuovere questo membro?', - 'search': 'Cerca', - 'select_user_to_try_flutter_sdk': - "Seleziona un utente per provare l'SDK Flutter", - 'shared_groups': 'Gruppi in comune', - 'sign_out': 'Sign out', - 'something_went_wrong_error_message': 'Qualcosa é andato storto', - 'stream_sdk': 'Stream SDK', - 'stream_test_account': 'Account di test', - 'to': 'A', - 'type_a_name_hint': 'Scrivi un nome', - 'user_id': 'User ID', - 'user_id_error': "Per favore inserisci l'ID dell'utente", - 'username_optional': 'Username (opzionale)', - 'user_token': 'Token Utente', - 'user_token_error': 'Per favore inserisci il token', - 'view_info': 'Vedi info', - 'welcome_to_stream_chat': 'Benvenuto in Stream Chat', - }, - }; - - final Locale locale; - - String get addAGroupName { - return _localizedValues[locale.languageCode]!['add_a_group_name']!; - } - - String get addGroupMembers { - return _localizedValues[locale.languageCode]!['add_group_members']!; - } - - String get advancedOptions { - return _localizedValues[locale.languageCode]!['advanced_options']!; - } - - String get apiKeyError { - return _localizedValues[locale.languageCode]!['api_key_error']!; - } - - String get archiveGroup { - return _localizedValues[locale.languageCode]!['archive_group']!; - } - - String get attachment { - return _localizedValues[locale.languageCode]!['attachment']!; - } - - String get attachments { - return _localizedValues[locale.languageCode]!['attachments']!; - } - - String get cancel { - return _localizedValues[locale.languageCode]!['cancel']!; - } - - String get chatApiKey { - return _localizedValues[locale.languageCode]!['chat_api_key']!; - } - - String get chats { - return _localizedValues[locale.languageCode]!['chats']!; - } - - String get chooseAGroupChatName { - return _localizedValues[locale.languageCode]!['choose_a_group_chat_name']!; - } - - String get connected { - return _localizedValues[locale.languageCode]!['connected']!; - } - - String get createAGroup { - return _localizedValues[locale.languageCode]!['create_a_group']!; - } - - String get customSettings { - return _localizedValues[locale.languageCode]!['custom_settings']!; - } - - String get delete { - return _localizedValues[locale.languageCode]!['delete']!; - } - - String get deleteConversationAreYouSure { - return _localizedValues[locale.languageCode]![ - 'delete_conversation_are_you_sure']!; - } - - String get deleteConversationTitle { - return _localizedValues[locale.languageCode]!['delete_conversation_title']!; - } - - String get disconnected { - return _localizedValues[locale.languageCode]!['disconnected']!; - } - - String get errorConnecting { - return _localizedValues[locale.languageCode]!['error_connecting']!; - } - - String get files { - return _localizedValues[locale.languageCode]!['files']!; - } - - String get filesAppearHere { - return _localizedValues[locale.languageCode]!['files_appear_here']!; - } - - String get groupSharedWithUserAppearHere { - return _localizedValues[locale.languageCode]![ - 'group_shared_with_user_appear_here']!; - } - - String get lastSeen { - return _localizedValues[locale.languageCode]!['last_seen']!; - } - - String get leave { - return _localizedValues[locale.languageCode]!['leave']!; - } - - String get leaveConversation { - return _localizedValues[locale.languageCode]!['leave_conversation']!; - } - - String get leaveConversationAreYouSure { - return _localizedValues[locale.languageCode]![ - 'leave_conversation_are_you_sure']!; - } - - String get leaveGroup { - return _localizedValues[locale.languageCode]!['leave_group']!; - } - - String get loading { - return _localizedValues[locale.languageCode]!['loading']!; - } - - String get login { - return _localizedValues[locale.languageCode]!['login']!; - } - - String get longPressMessage { - return _localizedValues[locale.languageCode]!['long_press_message']!; - } - - String get matchesFor { - return _localizedValues[locale.languageCode]!['matches_for']!; - } - - String get member { - return _localizedValues[locale.languageCode]!['member']!; - } - - String get members { - return _localizedValues[locale.languageCode]!['members']!; - } - - String get mentions { - return _localizedValues[locale.languageCode]!['mentions']!; - } - - String get message { - return _localizedValues[locale.languageCode]!['message']!; - } - - String get messageChannelDescription { - return _localizedValues[locale.languageCode]![ - 'message_channel_description']!; - } - - String get messageChannelName { - return _localizedValues[locale.languageCode]!['message_channel_name']!; - } - - String get more { - return _localizedValues[locale.languageCode]!['more']!; - } - - String get muteGroup { - return _localizedValues[locale.languageCode]!['mute_group']!; - } - - String get muteUser { - return _localizedValues[locale.languageCode]!['mute_user']!; - } - - String get name { - return _localizedValues[locale.languageCode]!['name']!; - } - - String get nameOfGroupChat { - return _localizedValues[locale.languageCode]!['name_of_group_chat']!; - } - - String get newChat { - return _localizedValues[locale.languageCode]!['new_chat']!; - } - - String get newDirectMessage { - return _localizedValues[locale.languageCode]!['new_direct_message']!; - } - - String get newGroup { - return _localizedValues[locale.languageCode]!['new_group']!; - } - - String get noChatsHereYet { - return _localizedValues[locale.languageCode]!['no_chats_here_yet']!; - } - - String get noFiles { - return _localizedValues[locale.languageCode]!['no_files']!; - } - - String get noMedia { - return _localizedValues[locale.languageCode]!['no_media']!; - } - - String get noMentionsExistYet { - return _localizedValues[locale.languageCode]!['no_mentions_exist_yet']!; - } - - String get noPinnedItems { - return _localizedValues[locale.languageCode]!['no_pinned_items']!; - } - - String get noResults { - return _localizedValues[locale.languageCode]!['no_results']!; - } - - String get noSharedGroups { - return _localizedValues[locale.languageCode]!['no_shared_groups']!; - } - - String get noTitle { - return _localizedValues[locale.languageCode]!['no_title']!; - } - - String get noUserMatchesTheseKeywords { - return _localizedValues[locale.languageCode]![ - 'no_user_matches_these_keywords']!; - } - - String get ok { - return _localizedValues[locale.languageCode]!['ok']!; - } - - String get online { - return _localizedValues[locale.languageCode]!['online']!; - } - - String get onThePlatorm { - return _localizedValues[locale.languageCode]!['on_the_platform']!; - } - - String get operationCouldNotBeCompleted { - return _localizedValues[locale.languageCode]![ - 'operation_could_not_be_completed']!; - } - - String get owner { - return _localizedValues[locale.languageCode]!['owner']!; - } - - String get photosAndVideos { - return _localizedValues[locale.languageCode]!['photos_and_videos']!; - } - - String get photosOrVideosWillAppearHere { - return _localizedValues[locale.languageCode]![ - 'photos_or_videos_will_appear_here']!; - } - - String get pinGroup { - return _localizedValues[locale.languageCode]!['pin_group']!; - } - - String get pinnedMessages { - return _localizedValues[locale.languageCode]!['pinned_messages']!; - } - - String get pinToConversation { - return _localizedValues[locale.languageCode]!['pin_to_conversation']!; - } - - String get reconnecting { - return _localizedValues[locale.languageCode]!['reconnecting']!; - } - - String get remove { - return _localizedValues[locale.languageCode]!['remove']!; - } - - String get removeFromGroup { - return _localizedValues[locale.languageCode]!['remove_from_group']!; - } - - String get removeMember { - return _localizedValues[locale.languageCode]!['remove_member']!; - } - - String get removeMemberAreYouSure { - return _localizedValues[locale.languageCode]![ - 'remove_member_are_you_sure']!; - } - - String get search { - return _localizedValues[locale.languageCode]!['search']!; - } - - String get selectUserToTryFlutterSDK { - return _localizedValues[locale.languageCode]![ - 'select_user_to_try_flutter_sdk']!; - } - - String get sharedGroups { - return _localizedValues[locale.languageCode]!['shared_groups']!; - } - - String get signOut { - return _localizedValues[locale.languageCode]!['sign_out']!; - } - - String get somethingWentWrongErrorMessage { - return _localizedValues[locale.languageCode]![ - 'something_went_wrong_error_message']!; - } - - String get streamSDK { - return _localizedValues[locale.languageCode]!['stream_sdk']!; - } - - String get streamTestAccount { - return _localizedValues[locale.languageCode]!['stream_test_account']!; - } - - String get to { - return _localizedValues[locale.languageCode]!['to']!; - } - - String get typeANameHint { - return _localizedValues[locale.languageCode]!['type_a_name_hint']!; - } - - String get userId { - return _localizedValues[locale.languageCode]!['user_id']!; - } - - String get userIdError { - return _localizedValues[locale.languageCode]!['user_id_error']!; - } - - String get usernameOptional { - return _localizedValues[locale.languageCode]!['username_optional']!; - } - - String get userToken { - return _localizedValues[locale.languageCode]!['user_token']!; - } - - String get userTokenError { - return _localizedValues[locale.languageCode]!['user_token_error']!; - } - - String get viewInfo { - return _localizedValues[locale.languageCode]!['view_info']!; - } - - String get welcomeToStreamChat { - return _localizedValues[locale.languageCode]!['welcome_to_stream_chat']!; - } - - static List languages() => _localizedValues.keys.toList(); - - static AppLocalizations of(BuildContext context) { - return Localizations.of(context, AppLocalizations)!; - } -} - -class AppLocalizationsDelegate extends LocalizationsDelegate { - const AppLocalizationsDelegate(); - - @override - bool isSupported(Locale locale) => - AppLocalizations.languages().contains(locale.languageCode); - - @override - Future load(Locale locale) { - return SynchronousFuture(AppLocalizations(locale)); - } - - @override - bool shouldReload(AppLocalizationsDelegate old) => false; -} diff --git a/sample_app/lib/utils/location_provider.dart b/sample_app/lib/utils/location_provider.dart new file mode 100644 index 0000000000..0b00e8e3dc --- /dev/null +++ b/sample_app/lib/utils/location_provider.dart @@ -0,0 +1,105 @@ +// ignore_for_file: close_sinks + +import 'dart:async'; + +import 'package:geolocator/geolocator.dart'; +import 'package:stream_chat_flutter/stream_chat_flutter.dart'; + +const notificationTitle = 'Live Location Tracking'; +const notificationText = 'Your location is being tracked live.'; + +class LocationProvider { + factory LocationProvider() => _instance; + LocationProvider._(); + + static final LocationProvider _instance = LocationProvider._(); + + Stream get positionStream => _positionStreamController.stream; + final _positionStreamController = StreamController.broadcast(); + + StreamSubscription? _positionSubscription; + + /// Opens the device's app settings page. + /// + /// Returns [true] if the app settings page could be opened, otherwise + /// [false] is returned. + Future openAppSettings() => Geolocator.openAppSettings(); + + /// Opens the device's location settings page. + /// + /// Returns [true] if the location settings page could be opened, otherwise + /// [false] is returned. + Future openLocationSettings() => Geolocator.openLocationSettings(); + + /// Get current static location + Future getCurrentLocation() async { + final hasPermission = await _handlePermission(); + if (!hasPermission) return null; + + return Geolocator.getCurrentPosition(); + } + + /// Start live tracking + Future startTracking({ + int distanceFilter = 10, + LocationAccuracy accuracy = LocationAccuracy.high, + ActivityType activityType = ActivityType.automotiveNavigation, + }) async { + final hasPermission = await _handlePermission(); + if (!hasPermission) return; + + final settings = switch (CurrentPlatform.type) { + PlatformType.android => AndroidSettings( + accuracy: accuracy, + distanceFilter: distanceFilter, + foregroundNotificationConfig: const ForegroundNotificationConfig( + setOngoing: true, + notificationText: notificationText, + notificationTitle: notificationTitle, + notificationIcon: AndroidResource(name: 'ic_notification'), + ), + ), + PlatformType.ios || PlatformType.macOS => AppleSettings( + accuracy: accuracy, + activityType: activityType, + distanceFilter: distanceFilter, + showBackgroundLocationIndicator: true, + pauseLocationUpdatesAutomatically: true, + ), + _ => LocationSettings( + accuracy: accuracy, + distanceFilter: distanceFilter, + ), + }; + + _positionSubscription?.cancel(); // avoid duplicate subscriptions + _positionSubscription = + Geolocator.getPositionStream( + locationSettings: settings, + ).listen( + _positionStreamController.safeAdd, + onError: _positionStreamController.safeAddError, + ); + } + + /// Stop live tracking + void stopTracking() { + _positionSubscription?.cancel(); + _positionSubscription = null; + } + + Future _handlePermission() async { + final serviceEnabled = await Geolocator.isLocationServiceEnabled(); + if (!serviceEnabled) return false; + + var permission = await Geolocator.checkPermission(); + if (permission == LocationPermission.denied) { + permission = await Geolocator.requestPermission(); + } + + return switch (permission) { + LocationPermission.denied || LocationPermission.deniedForever => false, + _ => true, + }; + } +} diff --git a/sample_app/lib/utils/notifications_service.dart b/sample_app/lib/utils/notifications_service.dart deleted file mode 100644 index 9c34099c4f..0000000000 --- a/sample_app/lib/utils/notifications_service.dart +++ /dev/null @@ -1,79 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_local_notifications/flutter_local_notifications.dart' - hide Message; -import 'package:go_router/go_router.dart'; -import 'package:sample_app/routes/routes.dart'; -import 'package:sample_app/utils/localizations.dart'; -import 'package:stream_chat_flutter/stream_chat_flutter.dart'; - -void showLocalNotification( - Event event, - String currentUserId, - BuildContext context, -) async { - // Don't show notification if the event is from the current user. - if (event.user!.id == currentUserId) return; - - // Don't show notification if the event is not a message. - if (![ - EventType.messageNew, - EventType.notificationMessageNew, - ].contains(event.type)) { - return; - } - - // Return if the message is null. - if (event.message == null) return; - - final flutterLocalNotificationsPlugin = FlutterLocalNotificationsPlugin(); - const initializationSettings = InitializationSettings( - iOS: DarwinInitializationSettings(), - android: AndroidInitializationSettings('ic_notification_in_app'), - ); - - final appLocalizations = AppLocalizations.of(context); - - await flutterLocalNotificationsPlugin.initialize( - initializationSettings, - onDidReceiveNotificationResponse: (response) async { - final channelCid = response.payload; - if (channelCid == null) return; - - final channelType = channelCid.split(':')[0]; - final channelId = channelCid.split(':')[1]; - - final client = StreamChat.of(context).client; - final router = GoRouter.of(context); - - final channel = client.channel(channelType, id: channelId); - await channel.watch(); - - router.pushNamed( - Routes.CHANNEL_PAGE.name, - pathParameters: Routes.CHANNEL_PAGE.params(channel), - ); - }, - ); - - await flutterLocalNotificationsPlugin.show( - event.message!.id.hashCode, - event.message!.user!.name, - event.message!.text, - NotificationDetails( - android: AndroidNotificationDetails( - 'message channel', - appLocalizations.messageChannelName, - channelDescription: appLocalizations.messageChannelDescription, - priority: Priority.high, - importance: Importance.high, - ), - iOS: const DarwinNotificationDetails(), - ), - payload: '${event.channelType}:${event.channelId}', - ); -} - -Future cancelLocalNotifications() async { - final flutterLocalNotificationsPlugin = FlutterLocalNotificationsPlugin(); - await flutterLocalNotificationsPlugin.cancelAll(); -} diff --git a/sample_app/lib/utils/serializer.dart b/sample_app/lib/utils/serializer.dart new file mode 100644 index 0000000000..46506fcb53 --- /dev/null +++ b/sample_app/lib/utils/serializer.dart @@ -0,0 +1,60 @@ +// TODO: Use from core once we migrate +/// Helper class for serialization to and from JSON with configurable extra data handling +mixin Serializer { + /// Default key for storing extra/unknown fields + static const defaultExtraDataKey = 'custom'; + + /// Moves unknown JSON keys to a configurable extra data field + /// + /// Takes a JSON map and a set of known top-level fields. + /// Known fields remain at the root level, unknown fields are moved + /// to the specified extra data field. + /// + /// [extraDataKey] defaults to 'custom' but can be customized + static Map moveToExtraData( + Map json, + Iterable knownFields, { + String extraDataKey = defaultExtraDataKey, + }) { + if (json.isEmpty) return {}; + + final result = {}; + final extraData = {}; + + for (final MapEntry(:key, :value) in json.entries) { + if (knownFields.contains(key) || key == extraDataKey) { + result[key] = value; + } else { + extraData[key] = value; + } + } + + if (extraData.isNotEmpty) { + result[extraDataKey] = extraData; + } + + return result; + } + + /// Moves fields from the extra data field back to the root level + /// + /// Takes a JSON map with an optional extra data field and flattens + /// the extra data back to the root level. + /// + /// [extraDataKey] should match the key used in [moveToExtraData] + static Map moveFromExtraData( + Map json, { + String extraDataKey = defaultExtraDataKey, + }) { + if (json.isEmpty) return {}; + + final result = {...json}; + final extraData = result.remove(extraDataKey); + + if (extraData is Map) { + result.addAll(extraData); + } + + return result; + } +} diff --git a/sample_app/lib/utils/shared_location_service.dart b/sample_app/lib/utils/shared_location_service.dart new file mode 100644 index 0000000000..27e04868dd --- /dev/null +++ b/sample_app/lib/utils/shared_location_service.dart @@ -0,0 +1,88 @@ +import 'dart:async'; + +import 'package:geolocator/geolocator.dart'; +import 'package:rxdart/rxdart.dart'; +import 'package:sample_app/utils/location_provider.dart'; +import 'package:stream_chat_flutter/stream_chat_flutter.dart'; + +class SharedLocationService { + SharedLocationService({ + required StreamChatClient client, + LocationProvider? locationProvider, + }) : _client = client, + _locationProvider = locationProvider ?? LocationProvider(); + + final StreamChatClient _client; + final LocationProvider _locationProvider; + + StreamSubscription? _positionSubscription; + StreamSubscription>? _activeLiveLocationsSubscription; + + Future initialize() async { + _activeLiveLocationsSubscription?.cancel(); + _activeLiveLocationsSubscription = _client.state.activeLiveLocationsStream + .distinct((prev, curr) => prev.length == curr.length) + .listen((locations) async { + // If there are no more active locations to update, stop tracking. + if (locations.isEmpty) return _stopTrackingLocation(); + + // Otherwise, start tracking the user's location. + return _startTrackingLocation(); + }); + + return _client.getActiveLiveLocations().ignore(); + } + + Future _startTrackingLocation() async { + if (_positionSubscription != null) return; + + // Start listening to the position stream. + _positionSubscription = _locationProvider.positionStream + .throttleTime(const Duration(seconds: 3)) + .listen(_onPositionUpdate); + + return _locationProvider.startTracking(); + } + + void _stopTrackingLocation() { + _locationProvider.stopTracking(); + + // Stop tracking the user's location + _positionSubscription?.cancel(); + _positionSubscription = null; + } + + void _onPositionUpdate(Position position) { + // Handle location updates, e.g., update the UI or send to server + final activeLiveLocations = _client.state.activeLiveLocations; + if (activeLiveLocations.isEmpty) return _stopTrackingLocation(); + + // Update all active live locations + for (final location in activeLiveLocations) { + // Skip if the location is not live or has expired + if (location.isLive && location.isExpired) continue; + + // Skip if the location does not have a messageId + final messageId = location.messageId; + if (messageId == null) continue; + + // Update the live location with the new position + _client.updateLiveLocation( + messageId: messageId, + createdByDeviceId: location.createdByDeviceId, + location: LocationCoordinates( + latitude: position.latitude, + longitude: position.longitude, + ), + ); + } + } + + /// Clean up resources + Future dispose() async { + _stopTrackingLocation(); + + _activeLiveLocationsSubscription?.cancel(); + _activeLiveLocationsSubscription = null; + } +} diff --git a/sample_app/lib/widgets/add_members_sheet.dart b/sample_app/lib/widgets/add_members_sheet.dart new file mode 100644 index 0000000000..6dbbe76261 --- /dev/null +++ b/sample_app/lib/widgets/add_members_sheet.dart @@ -0,0 +1,222 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:sample_app/widgets/search_text_field.dart'; +import 'package:stream_chat_flutter/stream_chat_flutter.dart'; + +/// {@template showAddMembersSheet} +/// Displays the Add Members bottom sheet for [channel] — Figma frame +/// `9857:114080` (and the `Selected` / `Search` / `No Result` variants). +/// +/// Resolves to `true` when the user picks one or more members and confirms +/// (the [Channel.addMembers] call has settled by then), `false` if they +/// dismiss without adding anyone. +/// {@endtemplate} +Future showAddMembersSheet(BuildContext context, Channel channel) { + return showStreamSheet( + context: context, + isDismissible: true, + builder: (_, scrollController) => StreamChannel( + channel: channel, + child: AddMembersSheet(scrollController: scrollController), + ), + ); +} + +/// {@template addMembersSheet} +/// A bottom sheet that lets the current user search the directory and +/// add one or more users to the enclosing channel. +/// +/// Layout: +/// +/// * [StreamSheetHeader] with the auto-implied close on the leading +/// side and a primary checkmark trailing button — the checkmark stays +/// disabled until at least one user is ticked. +/// * [StreamTextInput] search field. Edits are debounced before they +/// flow into the user-list controller's filter so we don't fire a +/// new query on every keystroke. +/// * Paginated [StreamUserListView] of users that aren't already +/// members of the channel. Rows are custom — avatar + name + +/// [StreamCheckbox.circular] trailing — and tapping anywhere on the +/// row toggles selection. +/// * A custom no-result empty state when the search query yields +/// nothing. +/// {@endtemplate} +class AddMembersSheet extends StatefulWidget { + /// {@macro addMembersSheet} + const AddMembersSheet({super.key, this.scrollController}); + + /// Scroll controller forwarded by [showStreamSheet] — wired into the + /// inner [StreamUserListView] so dragging the list past the top + /// dismisses the sheet. + final ScrollController? scrollController; + + @override + State createState() => _AddMembersSheetState(); +} + +class _AddMembersSheetState extends State { + late final Channel _channel = StreamChannel.of(context).channel; + late final StreamChatClient _client = StreamChat.of(context).client; + late final String? _currentUserId = _client.state.currentUser?.id; + + late final StreamUserListController _userListController = StreamUserListController( + client: _client, + limit: 25, + filter: _filter(query: ''), + sort: const [SortOption.asc(UserSortKey.name)], + ); + + late final TextEditingController _searchController = TextEditingController()..addListener(_onSearchChanged); + + Timer? _debounce; + String _query = ''; + + // Locally tracked selections — channel.addMembers fires only when the + // user taps the checkmark, so toggling rows in/out is purely UI state + // until then. + final Set _selectedIds = {}; + bool _saving = false; + + bool get _canConfirm => _selectedIds.isNotEmpty && !_saving; + + Filter _filter({required String query}) { + final excludedIds = { + if (_currentUserId case final id?) id, + for (final member in _channel.state!.members) + if (member.userId case final id?) id, + }; + return Filter.and([ + if (query.isNotEmpty) Filter.autoComplete('name', query), + Filter.notIn('id', excludedIds.toList()), + ]); + } + + void _onSearchChanged() { + final next = _searchController.text; + if (next == _query) return; + + _debounce?.cancel(); + _debounce = Timer(const Duration(milliseconds: 350), () { + if (!mounted) return; + setState(() => _query = next); + _userListController.filter = _filter(query: next); + _userListController.doInitialLoad(); + }); + } + + @override + void dispose() { + _debounce?.cancel(); + _searchController.dispose(); + _userListController.dispose(); + super.dispose(); + } + + void _toggle(User user) { + setState(() { + if (!_selectedIds.add(user.id)) _selectedIds.remove(user.id); + }); + } + + Future _confirm() async { + if (_selectedIds.isEmpty) return; + final navigator = Navigator.of(context); + final messenger = ScaffoldMessenger.of(context); + + setState(() => _saving = true); + try { + await _channel.addMembers(_selectedIds.toList()); + if (!mounted) return; + navigator.pop(true); + } catch (e) { + messenger.showSnackBar( + SnackBar(content: Text('Failed to add members: $e')), + ); + if (mounted) setState(() => _saving = false); + } + } + + @override + Widget build(BuildContext context) { + final viewInsets = MediaQuery.viewInsetsOf(context); + + final spacing = context.streamSpacing; + + return Column( + children: [ + StreamSheetHeader( + title: const Text('Add Members'), + trailing: StreamButton.icon( + icon: Icon(context.streamIcons.checkmark), + type: .solid, + onPressed: _canConfirm ? _confirm : null, + ), + ), + SearchTextField(controller: _searchController), + Expanded( + child: Padding( + padding: EdgeInsets.only(bottom: viewInsets.bottom), + child: StreamUserListView( + controller: _userListController, + scrollController: widget.scrollController, + separatorBuilder: (_, _, _) => SizedBox(height: spacing.xxs), + itemBuilder: (context, users, index, _) { + final user = users[index]; + return _UserRow( + user: user, + selected: _selectedIds.contains(user.id), + onTap: _saving ? null : () => _toggle(user), + ); + }, + emptyBuilder: (context) => Center( + child: StreamScrollViewEmptyWidget( + emptyIcon: Icon(context.streamIcons.search), + emptyTitle: const Text('No user found'), + ), + ), + ), + ), + ), + ], + ); + } +} + +/// Single user row — leading [StreamUserAvatar], name, trailing circular +/// checkbox. Tapping anywhere on the row toggles selection. +class _UserRow extends StatelessWidget { + const _UserRow({ + required this.user, + required this.selected, + required this.onTap, + }); + + final User user; + final bool selected; + final VoidCallback? onTap; + + @override + Widget build(BuildContext context) { + final spacing = context.streamSpacing; + + return Padding( + padding: .symmetric(horizontal: spacing.xxs), + child: StreamListTileTheme( + data: StreamListTileThemeData( + minTileHeight: 48, // Matches the design's tap target size for action rows + contentPadding: .symmetric(horizontal: spacing.sm), + ), + child: StreamListTile( + leading: StreamUserAvatar(user: user, size: .md), + title: Text(user.name), + trailing: StreamCheckbox.circular( + value: selected, + onChanged: onTap == null ? null : (_) => onTap!(), + ), + onTap: onTap, + ), + ), + ); + } +} diff --git a/sample_app/lib/widgets/all_members_sheet.dart b/sample_app/lib/widgets/all_members_sheet.dart new file mode 100644 index 0000000000..4982d8fe4a --- /dev/null +++ b/sample_app/lib/widgets/all_members_sheet.dart @@ -0,0 +1,454 @@ +import 'package:collection/collection.dart'; +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; +import 'package:sample_app/routes/routes.dart'; +import 'package:sample_app/utils/client_extensions.dart'; +import 'package:sample_app/widgets/add_members_sheet.dart'; +import 'package:stream_chat_flutter/stream_chat_flutter.dart'; + +// --------------------------------------------------------------------------- +// Public sheets + dispatcher +// --------------------------------------------------------------------------- + +/// {@template showAllMembersSheet} +/// Displays a bottom sheet listing every member of [channel] — Figma frame +/// `8833:434949`. Tapping a member stacks a [ContactDetailSheet] over this +/// one. +/// {@endtemplate} +Future showAllMembersSheet(BuildContext context, Channel channel) { + return showStreamSheet( + context: context, + isDismissible: true, + builder: (_, scrollController) => StreamChannel( + channel: channel, + child: AllMembersSheet(scrollController: scrollController), + ), + ); +} + +/// {@template showContactDetailSheet} +/// Displays a compact bottom sheet with quick actions for a single [user] +/// — Figma frame `8833:434317`. +/// +/// Resolves to the [ContactDetailAction] the user picked, or `null` if +/// they dismissed it. For the common case of opening the sheet *and* +/// running the action, prefer [openContactDetail] which combines both. +/// {@endtemplate} +Future showContactDetailSheet({ + required BuildContext context, + required User user, +}) { + return showStreamSheet( + context: context, + isDismissible: true, + builder: (_, _) => ContactDetailSheet(user: user), + ); +} + +/// Opens [showContactDetailSheet] and dispatches the action the user picks. +/// +/// Callers don't have to know what each action means — they just plug this +/// into a member-row's `onTap`. +Future openContactDetail(BuildContext context, User user) async { + final action = await showContactDetailSheet(context: context, user: user); + if (action == null || !context.mounted) return; + await _onContactDetailAction(context, action); +} + +/// {@template contactDetailAction} +/// A sealed class representing the actions a user can pick from a +/// [ContactDetailSheet]. Each action carries the [user] it targets so the +/// dispatcher has everything it needs without re-deriving context. +/// {@endtemplate} +sealed class ContactDetailAction { + /// {@macro contactDetailAction} + const ContactDetailAction({required this.user}); + + /// The user this action targets. + final User user; +} + +/// User tapped _Send Direct Message_. +final class SendDirectMessage extends ContactDetailAction { + /// {@macro contactDetailAction} + const SendDirectMessage({required super.user}); +} + +/// User tapped _Mute User_. +final class MuteUser extends ContactDetailAction { + /// {@macro contactDetailAction} + const MuteUser({required super.user}); +} + +/// User tapped _Unmute User_. +final class UnmuteUser extends ContactDetailAction { + /// {@macro contactDetailAction} + const UnmuteUser({required super.user}); +} + +/// User tapped _Block User_. +final class BlockUser extends ContactDetailAction { + /// {@macro contactDetailAction} + const BlockUser({required super.user}); +} + +// --------------------------------------------------------------------------- +// AllMembersSheet +// --------------------------------------------------------------------------- + +/// {@template allMembersSheet} +/// Bottom-sheet body listing every member of the enclosing channel. +/// +/// Tapping a member opens a stacked [ContactDetailSheet]; the parent sheet +/// stays mounted underneath so users return to the same scroll position +/// after dismissing the detail sheet. +/// {@endtemplate} +class AllMembersSheet extends StatelessWidget { + /// {@macro allMembersSheet} + const AllMembersSheet({super.key, this.scrollController}); + + /// Scroll controller forwarded by [showStreamSheet]; attached to the + /// inner [ListView] so dragging the list past the top dismisses the sheet. + final ScrollController? scrollController; + + @override + Widget build(BuildContext context) { + final spacing = context.streamSpacing; + final channel = StreamChannel.of(context).channel; + final currentUserId = StreamChat.of(context).currentUser?.id; + + return BetterStreamBuilder>( + stream: channel.state!.membersStream, + initialData: channel.state!.members, + builder: (context, members) { + final sorted = [...members].sorted((a, b) { + if (a.userId == currentUserId) return -1; + if (b.userId == currentUserId) return 1; + return 0; + }); + + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + StreamSheetHeader( + title: Text('${members.length} Members'), + trailing: switch (channel.canUpdateChannelMembers && !channel.isDistinct) { + true => StreamButton.icon( + icon: Icon(context.streamIcons.userAdd), + type: .outline, + style: .secondary, + onPressed: () => showAddMembersSheet(context, channel), + ), + false => null, + }, + ), + Expanded( + child: ListView.builder( + itemCount: sorted.length, + controller: scrollController, + padding: .symmetric(horizontal: spacing.xxs), + itemBuilder: (context, index) { + final member = sorted[index]; + return ChannelMemberTile( + member: member, + isCurrentUser: member.userId == currentUserId, + onTap: switch (member.userId) { + final id? when id != currentUserId => () { + final user = member.user; + if (user != null) openContactDetail(context, user); + }, + _ => null, + }, + ); + }, + ), + ), + ], + ); + }, + ); + } +} + +// --------------------------------------------------------------------------- +// ContactDetailSheet +// --------------------------------------------------------------------------- + +/// {@template contactDetailSheet} +/// Compact bottom sheet showing a member's avatar / name / online status +/// followed by quick actions: _Send Direct Message_, _Mute / Unmute User_, +/// _Block User_. +/// +/// Pops the route with one of [ContactDetailAction]'s subtypes when the +/// user picks an action — caller dispatches via [openContactDetail]. +/// {@endtemplate} +class ContactDetailSheet extends StatelessWidget { + /// {@macro contactDetailSheet} + const ContactDetailSheet({super.key, required this.user}); + + /// The member the sheet is showing actions for. + final User user; + + @override + Widget build(BuildContext context) { + final spacing = context.streamSpacing; + final icons = context.streamIcons; + final client = StreamChat.of(context).client; + + void emit(ContactDetailAction action) => Navigator.of(context).pop(action); + + return SafeArea( + child: IconTheme.merge( + data: const IconThemeData(size: 20), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Padding( + padding: EdgeInsets.symmetric(vertical: spacing.xl, horizontal: spacing.sm), + child: _ContactDetailHeader(user: user), + ), + Padding( + padding: EdgeInsets.symmetric(horizontal: spacing.xxs), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + _ActionTile( + icon: Icon(icons.messageBubble), + label: const Text('Send Direct Message'), + onTap: () => emit(SendDirectMessage(user: user)), + ), + // Reactively flip Mute / Unmute as the global mute list + // updates — emits MuteUser when not yet muted. + BetterStreamBuilder( + stream: client.userMutedStream(user.id), + initialData: client.isUserMuted(user.id), + builder: (context, isMuted) => _ActionTile( + icon: Icon(isMuted ? icons.audio : icons.mute), + label: Text(isMuted ? 'Unmute User' : 'Mute User'), + onTap: () => emit(isMuted ? UnmuteUser(user: user) : MuteUser(user: user)), + ), + ), + _ActionTile( + icon: Icon(icons.noSign), + label: const Text('Block User'), + onTap: () => emit(BlockUser(user: user)), + ), + ], + ), + ), + ], + ), + ), + ); + } +} + +/// Compact header for [ContactDetailSheet] — avatar with online indicator +/// on the left, name + online status stacked on the right. +class _ContactDetailHeader extends StatelessWidget { + const _ContactDetailHeader({required this.user}); + + final User user; + + @override + Widget build(BuildContext context) { + final spacing = context.streamSpacing; + final colorScheme = context.streamColorScheme; + final textTheme = context.streamTextTheme; + + return Row( + spacing: spacing.sm, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + StreamUserAvatar(user: user, size: .lg, showOnlineIndicator: user.online), + Expanded( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + spacing: spacing.xxs, + children: [ + Text( + user.name, + style: textTheme.headingSm.copyWith(color: colorScheme.textPrimary), + overflow: TextOverflow.ellipsis, + ), + Text( + _userStatus(user), + style: textTheme.captionDefault.copyWith(color: colorScheme.textSecondary), + overflow: TextOverflow.ellipsis, + ), + ], + ), + ), + ], + ); + } +} + +class _ActionTile extends StatelessWidget { + const _ActionTile({required this.icon, required this.label, this.onTap}); + + final Widget icon; + final Widget label; + final VoidCallback? onTap; + + @override + Widget build(BuildContext context) { + final spacing = context.streamSpacing; + + return StreamListTileTheme( + data: StreamListTileThemeData( + minTileHeight: 44, // Matches the design's tap target size for action rows + contentPadding: .symmetric(horizontal: spacing.sm), + ), + child: StreamListTile( + leading: icon, + title: label, + onTap: onTap, + ), + ); + } +} + +// --------------------------------------------------------------------------- +// ChannelMemberTile (shared) +// --------------------------------------------------------------------------- + +/// {@template channelMemberTile} +/// Single channel-member row — avatar with online indicator, name (with a +/// "You" substitution for the current user), an online / last-seen +/// subtitle, and an _Admin_ trailing label for moderators / owners. +/// +/// Used by both the group-info screen members preview and the +/// [AllMembersSheet]; lifted here so the two surfaces always render +/// identically. +/// {@endtemplate} +class ChannelMemberTile extends StatelessWidget { + /// {@macro channelMemberTile} + const ChannelMemberTile({ + super.key, + required this.member, + required this.isCurrentUser, + this.onTap, + }); + + /// The member being rendered. + final Member member; + + /// Whether [member] is the currently signed-in user. When `true`, the + /// tile shows the literal string "You" in place of the user's name. + final bool isCurrentUser; + + /// Optional tap handler — typically opens [ContactDetailSheet] for the + /// member's user. Pass `null` to make the row non-interactive. + final VoidCallback? onTap; + + @override + Widget build(BuildContext context) { + final colorScheme = context.streamColorScheme; + final textTheme = context.streamTextTheme; + final spacing = context.streamSpacing; + + final user = member.user; + if (user == null) return const SizedBox.shrink(); + + final name = isCurrentUser ? 'You' : user.name; + final isAdmin = const {'admin', 'channel_moderator', 'owner'}.contains(member.channelRole); + + return StreamListTile( + leading: StreamUserAvatar(user: user, size: .md, showOnlineIndicator: user.online), + title: Text(name), + subtitle: Text(_userStatus(user)), + trailing: BetterStreamBuilder( + stream: StreamChat.of(context).client.userMutedStream(user.id), + initialData: StreamChat.of(context).client.isUserMuted(user.id), + builder: (context, isMuted) { + if (!isMuted && !isAdmin) return const SizedBox.shrink(); + return Row( + mainAxisSize: MainAxisSize.min, + spacing: spacing.xxs, + children: [ + if (isMuted) Icon(context.streamIcons.mute, color: colorScheme.textTertiary), + if (isAdmin) + Text( + 'Admin', + style: textTheme.bodyDefault.copyWith(color: colorScheme.textTertiary), + ), + ], + ); + }, + ), + onTap: onTap, + ); + } +} + +// --------------------------------------------------------------------------- +// Internals +// --------------------------------------------------------------------------- + +Future _onContactDetailAction( + BuildContext context, + ContactDetailAction action, +) async => switch (action) { + SendDirectMessage(:final user) => _openDirectChannel(context, user), + MuteUser(:final user) => StreamChat.of(context).client.muteUser(user.id), + UnmuteUser(:final user) => StreamChat.of(context).client.unmuteUser(user.id), + BlockUser(:final user) => StreamChat.of(context).client.blockUser(user.id), +}; + +/// Finds (or creates) a 1-1 distinct messaging channel between the current +/// user and [user] and pushes it via [GoRouter]. Pops any enclosing +/// [StreamSheetRoute] first so the new channel page lands on the regular +/// page stack instead of stacking on top of a still-visible sheet. +Future _openDirectChannel(BuildContext context, User user) async { + final chat = StreamChat.of(context); + final router = GoRouter.of(context); + final currentUser = chat.currentUser; + if (currentUser == null) return; + + final existing = await chat.client.queryChannelsOnline( + state: false, + watch: false, + filter: Filter.raw( + value: { + 'members': [currentUser.id, user.id], + 'distinct': true, + }, + ), + messageLimit: 0, + paginationParams: const PaginationParams(limit: 1), + ); + + Channel channel; + if (existing.isNotEmpty) { + channel = existing.first; + if (channel.state == null) await channel.watch(); + } else { + channel = chat.client.channel( + 'messaging', + extraData: { + 'members': [currentUser.id, user.id], + }, + ); + await channel.watch(); + } + + if (!context.mounted) return; + // Drop the enclosing all-members sheet (if any) so the channel page + // doesn't render on top of a half-open sheet. + if (StreamSheetRoute.hasParentSheet(context)) { + StreamSheetRoute.popSheet(context); + } + router.pushNamed( + Routes.CHANNEL_PAGE.name, + pathParameters: Routes.CHANNEL_PAGE.params(channel), + ); +} + +String _userStatus(User user) { + if (user.online) return 'Online'; + final lastActive = user.lastActive; + if (lastActive == null) return 'Offline'; + return 'Last seen ${Jiffy.parseFromDateTime(lastActive).fromNow()}'; +} diff --git a/sample_app/lib/widgets/channel_detail_sheet.dart b/sample_app/lib/widgets/channel_detail_sheet.dart new file mode 100644 index 0000000000..db01f34510 --- /dev/null +++ b/sample_app/lib/widgets/channel_detail_sheet.dart @@ -0,0 +1,364 @@ +import 'package:flutter/material.dart'; +import 'package:sample_app/utils/client_extensions.dart'; +import 'package:stream_chat_flutter/stream_chat_flutter.dart'; + +/// {@template channelDetailAction} +/// A sealed class that represents the actions a user can pick from a +/// [ChannelDetailSheet]. +/// +/// The sheet pops itself with one of these values when an action is tapped, +/// and callers switch on the returned action to decide what to do. +/// {@endtemplate} +sealed class ChannelDetailAction { + /// {@macro channelDetailAction} + const ChannelDetailAction(); +} + +/// User tapped _View Info_ — caller is expected to push the chat or group +/// info screen depending on whether [user] is set. +/// +/// On 1-1 channels, [user] carries the other member; the caller pushes +/// `ChatInfoScreen` for that user. On group channels, [user] is `null` and +/// the caller pushes `GroupInfoScreen`. +final class ViewChannelInfo extends ChannelDetailAction { + /// {@macro channelDetailAction} + const ViewChannelInfo({this.user}); + + /// The other member of the 1-1 channel, or `null` for group channels. + final User? user; +} + +/// User tapped _Pin Chat_ — caller is expected to invoke [Channel.pin]. +final class PinChannel extends ChannelDetailAction { + /// {@macro channelDetailAction} + const PinChannel(); +} + +/// User tapped _Unpin Chat_ — caller is expected to invoke [Channel.unpin]. +final class UnpinChannel extends ChannelDetailAction { + /// {@macro channelDetailAction} + const UnpinChannel(); +} + +/// User tapped _Mute User_ — caller is expected to invoke +/// [StreamChatClient.muteUser] for [user]. +final class MuteChannelMember extends ChannelDetailAction { + /// {@macro channelDetailAction} + const MuteChannelMember({required this.user}); + + /// The mute target — the other member of the 1-1 channel. + final User user; +} + +/// User tapped _Unmute User_ — caller is expected to invoke +/// [StreamChatClient.unmuteUser] for [user]. +final class UnmuteChannelMember extends ChannelDetailAction { + /// {@macro channelDetailAction} + const UnmuteChannelMember({required this.user}); + + /// The unmute target — the other member of the 1-1 channel. + final User user; +} + +/// User tapped _Block User_ — caller is expected to invoke +/// [StreamChatClient.blockUser] for [user]. +/// +/// Block is one-way from this sheet: blocked users' channels are filtered out +/// of the channel list, so the sheet can never re-open for an already-blocked +/// user. Unblock lives on a different surface (e.g. blocked-users settings). +final class BlockChannelMember extends ChannelDetailAction { + /// {@macro channelDetailAction} + const BlockChannelMember({required this.user}); + + /// The block target — the other member of the 1-1 channel. + final User user; +} + +/// User tapped _Leave Group_ — caller is expected to confirm and remove the +/// current user from the channel members. +final class LeaveChannel extends ChannelDetailAction { + /// {@macro channelDetailAction} + const LeaveChannel(); +} + +/// User tapped _Delete Group_ / _Delete Conversation_ — caller is expected +/// to confirm and invoke [Channel.delete]. +final class DeleteChannel extends ChannelDetailAction { + /// {@macro channelDetailAction} + const DeleteChannel(); +} + +/// {@template showChannelDetailSheet} +/// Displays a [ChannelDetailSheet] for [channel] — the redesigned long-press +/// menu surfaced from the channel list. +/// +/// Resolves to the [ChannelDetailAction] the user picked, or `null` if the +/// sheet was dismissed without selecting one (drag-down, scrim tap, back +/// gesture). Callers should switch on the returned action. +/// +/// Built on top of [showStreamSheet] so it inherits the design system's drag +/// handle, scrim, and drag-to-dismiss interaction. +/// {@endtemplate} +Future showChannelDetailSheet({ + required BuildContext context, + required Channel channel, +}) { + return showStreamSheet( + context: context, + isDismissible: true, + builder: (_, _) => StreamChannel( + channel: channel, + child: ChannelDetailSheet(channel: channel), + ), + ); +} + +/// {@template channelDetailSheet} +/// A bottom sheet that displays detailed information and actions for a +/// [Channel]. +/// +/// Composed of: +/// +/// * A header with the channel avatar, name (with mute / pin state +/// indicators), and member count. +/// * A list of channel actions — _View Info_, _Pin / Unpin Chat_, +/// _Mute / Unmute User_, _Block / Unblock User_ (1-1 only), +/// _Leave Group_, _Delete Group / Conversation_. +/// +/// Tapping an action pops the route with the corresponding +/// [ChannelDetailAction] subtype; the caller is responsible for performing +/// the action. Destructive actions (leave / delete) are styled via a local +/// [StreamListTileTheme] override that swaps the icon and title colors to +/// [StreamColorScheme.accentError]. +/// +/// Designed to be hosted inside a [showStreamSheet] route — see +/// [showChannelDetailSheet] for the convenience entry point. +/// {@endtemplate} +class ChannelDetailSheet extends StatelessWidget { + /// {@macro channelDetailSheet} + const ChannelDetailSheet({super.key, required this.channel}); + + /// The channel whose information and actions are displayed. + final Channel channel; + + @override + Widget build(BuildContext context) { + final icons = context.streamIcons; + final spacing = context.streamSpacing; + + final client = StreamChat.of(context).client; + final currentUserId = client.state.currentUser?.id; + + final isOneToOne = channel.isOneToOne; + final canLeave = !isOneToOne && channel.canLeaveChannel; + final canDelete = channel.canDeleteChannel; + + // For 1-1 channels, mute/block actions target the other member. + final channelMembers = channel.state?.members ?? []; + final otherUser = isOneToOne ? channelMembers.firstWhere((m) => m.userId != currentUserId).user : null; + + void emit(ChannelDetailAction action) => Navigator.of(context).pop(action); + + return SafeArea( + child: IconTheme.merge( + data: const IconThemeData(size: 20), + child: Column( + mainAxisSize: .min, + children: [ + Padding( + padding: .symmetric(horizontal: spacing.sm, vertical: spacing.xl), + child: _ChannelDetailHeader(channel: channel), + ), + Padding( + padding: .symmetric(horizontal: spacing.xxs), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + _ChannelDetailAction( + icon: Icon(icons.info), + label: const Text('View Info'), + onTap: () => emit(ViewChannelInfo(user: otherUser)), + ), + BetterStreamBuilder( + stream: channel.isPinnedStream, + initialData: channel.isPinned, + builder: (context, isPinned) => _ChannelDetailAction( + icon: Icon(isPinned ? icons.unpin : icons.pin), + label: Text(isPinned ? 'Unpin Chat' : 'Pin Chat'), + onTap: () => emit(isPinned ? const UnpinChannel() : const PinChannel()), + ), + ), + if (otherUser != null) ...[ + BetterStreamBuilder( + stream: client.userMutedStream(otherUser.id), + initialData: client.isUserMuted(otherUser.id), + builder: (context, isMuted) => _ChannelDetailAction( + icon: Icon(isMuted ? icons.audio : icons.mute), + label: Text(isMuted ? 'Unmute User' : 'Mute User'), + onTap: () => emit( + isMuted ? UnmuteChannelMember(user: otherUser) : MuteChannelMember(user: otherUser), + ), + ), + ), + _ChannelDetailAction( + icon: Icon(icons.noSign), + label: const Text('Block User'), + onTap: () => emit(BlockChannelMember(user: otherUser)), + ), + ], + if (canLeave) + _ChannelDetailAction( + icon: Icon(icons.leave), + label: const Text('Leave Group'), + destructive: true, + onTap: () => emit(const LeaveChannel()), + ), + if (canDelete) + _ChannelDetailAction( + icon: Icon(icons.delete), + label: Text(isOneToOne ? 'Delete Chat' : 'Delete Group'), + destructive: true, + onTap: () => emit(const DeleteChannel()), + ), + ], + ), + ), + ], + ), + ), + ); + } +} + +/// The header row of the [ChannelDetailSheet] — channel avatar on the left, +/// channel name (with mute / pin state) and member count on the right. +class _ChannelDetailHeader extends StatelessWidget { + const _ChannelDetailHeader({required this.channel}); + + final Channel channel; + + @override + Widget build(BuildContext context) { + final spacing = context.streamSpacing; + final colorScheme = context.streamColorScheme; + final textTheme = context.streamTextTheme; + + return Row( + spacing: spacing.sm, + crossAxisAlignment: .center, + children: [ + StreamChannelAvatar(channel: channel, size: .lg), + Expanded( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + spacing: spacing.xxs, + children: [ + _ChannelDetailHeaderTitle(channel: channel), + StreamChannelInfo( + channel: channel, + showTypingIndicator: false, + textStyle: textTheme.captionDefault.copyWith( + color: colorScheme.textSecondary, + ), + ), + ], + ), + ), + ], + ); + } +} + +/// The title row inside [_ChannelDetailHeader] — channel name followed by +/// optional mute and pin state-indicator icons. +class _ChannelDetailHeaderTitle extends StatelessWidget { + const _ChannelDetailHeaderTitle({required this.channel}); + + final Channel channel; + + @override + Widget build(BuildContext context) { + final spacing = context.streamSpacing; + final colorScheme = context.streamColorScheme; + final textTheme = context.streamTextTheme; + final icons = context.streamIcons; + + return Row( + mainAxisSize: .min, + spacing: spacing.xs, + children: [ + Flexible( + child: StreamChannelName( + channel: channel, + textStyle: textTheme.headingSm.copyWith( + color: colorScheme.textPrimary, + ), + ), + ), + Row( + mainAxisSize: .min, + spacing: spacing.xxs, + children: [ + BetterStreamBuilder( + stream: channel.isMutedStream, + initialData: channel.isMuted, + builder: (context, isMuted) { + if (!isMuted) return const SizedBox.shrink(); + return Icon(icons.mute, color: colorScheme.textPrimary); + }, + ), + BetterStreamBuilder( + stream: channel.isPinnedStream, + initialData: channel.isPinned, + builder: (context, isPinned) { + if (!isPinned) return const SizedBox.shrink(); + return Icon(icons.pin, color: colorScheme.textPrimary); + }, + ), + ], + ), + ], + ); + } +} + +/// A single tappable action row inside the [ChannelDetailSheet]. +/// +/// Wraps [StreamListTile] so all theming (typography, padding, ink effects, +/// disabled / selected state colors) flows from [StreamListTileTheme]. When +/// [destructive] is true, a local [StreamListTileTheme] override paints the +/// icon and title with [StreamColorScheme.accentError]. +class _ChannelDetailAction extends StatelessWidget { + const _ChannelDetailAction({ + required this.icon, + required this.label, + this.onTap, + this.destructive = false, + }); + + final Widget icon; + final Widget label; + final VoidCallback? onTap; + final bool destructive; + + @override + Widget build(BuildContext context) { + final spacing = context.streamSpacing; + final colorScheme = context.streamColorScheme; + + return StreamListTileTheme( + data: StreamListTileThemeData( + iconColor: destructive ? .all(colorScheme.accentError) : null, + titleColor: destructive ? .all(colorScheme.accentError) : null, + minTileHeight: 44, // Matches the design's tap target size for action rows + contentPadding: .symmetric(horizontal: spacing.sm), + ), + child: StreamListTile( + leading: icon, + title: label, + onTap: onTap, + ), + ); + } +} diff --git a/sample_app/lib/widgets/channel_list.dart b/sample_app/lib/widgets/channel_list.dart index c2dab2bb85..c32abfd2e5 100644 --- a/sample_app/lib/widgets/channel_list.dart +++ b/sample_app/lib/widgets/channel_list.dart @@ -4,10 +4,8 @@ import 'package:flutter/material.dart'; import 'package:flutter/rendering.dart'; import 'package:flutter_slidable/flutter_slidable.dart'; import 'package:go_router/go_router.dart'; -import 'package:sample_app/pages/chat_info_screen.dart'; -import 'package:sample_app/pages/group_info_screen.dart'; import 'package:sample_app/routes/routes.dart'; -import 'package:sample_app/utils/localizations.dart'; +import 'package:sample_app/widgets/channel_detail_sheet.dart'; import 'package:sample_app/widgets/search_text_field.dart'; import 'package:stream_chat_flutter/stream_chat_flutter.dart'; @@ -21,8 +19,7 @@ class ChannelList extends StatefulWidget { class _ChannelList extends State { final ScrollController _scrollController = ScrollController(); - late final StreamMessageSearchListController _messageSearchListController = - StreamMessageSearchListController( + late final StreamMessageSearchListController _messageSearchListController = StreamMessageSearchListController( client: StreamChat.of(context).client, filter: Filter.in_('members', [StreamChat.of(context).currentUser!.id]), limit: 5, @@ -33,8 +30,7 @@ class _ChannelList extends State { ], ); - late final TextEditingController _controller = TextEditingController() - ..addListener(_channelQueryListener); + late final TextEditingController _controller = TextEditingController()..addListener(_channelQueryListener); bool _isSearchActive = false; @@ -61,7 +57,7 @@ class _ChannelList extends State { ChannelSortKey.pinnedAt, nullOrdering: NullOrdering.nullsLast, ), - const SortOption.desc(ChannelSortKey.lastMessageAt), + const SortOption.desc(ChannelSortKey.lastUpdated), ], limit: 30, ); @@ -86,8 +82,7 @@ class _ChannelList extends State { }, child: NotificationListener( onNotification: (ScrollNotification scrollInfo) { - if (_scrollController.position.userScrollDirection == - ScrollDirection.reverse) { + if (_scrollController.position.userScrollDirection == ScrollDirection.reverse) { FocusScope.of(context).unfocus(); } return true; @@ -98,8 +93,7 @@ class _ChannelList extends State { SliverToBoxAdapter( child: SearchTextField( controller: _controller, - showCloseButton: _isSearchActive, - hintText: AppLocalizations.of(context).search, + hintText: 'Search', ), ), ], @@ -125,134 +119,149 @@ class _ChannelListDefault extends StatelessWidget { child: StreamChannelListView( controller: channelListController, itemBuilder: (context, channels, index, defaultWidget) { - final chatTheme = StreamChatTheme.of(context); final channel = channels[index]; - final backgroundColor = chatTheme.colorTheme.inputBg; - final canDeleteChannel = channel.canDeleteChannel; + + final icons = context.streamIcons; + final colorScheme = context.streamColorScheme; + return Slidable( groupTag: 'channels-actions', endActionPane: ActionPane( - extentRatio: canDeleteChannel ? 0.40 : 0.20, + extentRatio: 0.4, motion: const BehindMotion(), children: [ CustomSlidableAction( - backgroundColor: backgroundColor, - onPressed: (_) { - showChannelInfoModalBottomSheet( - context: context, - channel: channel, - onViewInfoTap: () { - Navigator.pop(context); - Navigator.push( - context, - MaterialPageRoute( - builder: (context) { - final isOneToOne = channel.memberCount == 2 && - channel.isDistinct; - return StreamChannel( - channel: channel, - child: isOneToOne - ? ChatInfoScreen( - messageTheme: - chatTheme.ownMessageTheme, - user: channel.state!.members - .where((m) => - m.userId != - channel.client.state - .currentUser!.id) - .first - .user, - ) - : GroupInfoScreen( - messageTheme: - chatTheme.ownMessageTheme, - ), - ); - }, - ), - ); - }, - ); - }, - child: const Icon(Icons.more_horiz), + foregroundColor: colorScheme.textPrimary, + backgroundColor: colorScheme.backgroundSurface, + onPressed: (_) => _openChannelDetailSheet(context, channel), + child: Icon(icons.more, size: 20), ), - if (canDeleteChannel) - CustomSlidableAction( - backgroundColor: backgroundColor, - child: StreamSvgIcon( - icon: StreamSvgIcons.delete, - color: chatTheme.colorTheme.accentError, - ), - onPressed: (_) async { - final res = await showConfirmationBottomSheet( - context, - title: 'Delete Conversation', - question: - 'Are you sure you want to delete this conversation?', - okText: 'Delete', - cancelText: 'Cancel', - icon: StreamSvgIcon( - icon: StreamSvgIcons.delete, - color: chatTheme.colorTheme.accentError, - ), - ); - if (res == true) { - await channelListController.deleteChannel(channel); - } + BetterStreamBuilder( + stream: channel.isMutedStream, + initialData: channel.isMuted, + builder: (context, isMuted) => CustomSlidableAction( + foregroundColor: colorScheme.textOnAccent, + backgroundColor: colorScheme.accentPrimary, + onPressed: (_) { + if (isMuted) return channel.unmute().ignore(); + return channel.mute().ignore(); }, - ), - ], - ), - child: ColoredBox( - color: channel.isPinned - ? chatTheme.colorTheme.highlight - : Colors.transparent, - child: defaultWidget, - ), - ); - }, - onChannelTap: (channel) { - GoRouter.of(context).pushNamed( - Routes.CHANNEL_PAGE.name, - pathParameters: Routes.CHANNEL_PAGE.params(channel), - ); - }, - emptyBuilder: (_) { - return Center( - child: Padding( - padding: const EdgeInsets.all(8), - child: StreamScrollViewEmptyWidget( - emptyIcon: StreamSvgIcon( - icon: StreamSvgIcons.message, - size: 148, - color: StreamChatTheme.of(context).colorTheme.disabled, - ), - emptyTitle: TextButton( - onPressed: () { - GoRouter.of(context).pushNamed(Routes.NEW_CHAT.name); - }, - child: Text( - 'Start a chat', - style: StreamChatTheme.of(context) - .textTheme - .bodyBold - .copyWith( - color: StreamChatTheme.of(context) - .colorTheme - .accentPrimary, - ), + child: Icon(isMuted ? icons.audio : icons.mute, size: 20), ), ), - ), + ], ), + child: defaultWidget, ); }, + onChannelTap: (channel) => _openChannelPage(context, channel), + onChannelLongPress: (channel) => _openChannelDetailSheet(context, channel), ), ), ); } } +// Pushes the channel page for [channel] via [GoRouter]. +Future _openChannelPage(BuildContext context, Channel channel) { + return GoRouter.of(context).pushNamed( + Routes.CHANNEL_PAGE.name, + pathParameters: Routes.CHANNEL_PAGE.params(channel), + ); +} + +// Opens the channel detail sheet and dispatches on the user's selection. +// +// The sheet pops itself with a [ChannelDetailAction] subtype; this function +// awaits that result and routes to the matching handler. +Future _openChannelDetailSheet( + BuildContext context, + Channel channel, +) async { + final action = await showChannelDetailSheet(context: context, channel: channel); + + if (action == null || !context.mounted) return; + return _onChannelDetailAction(context, channel, action).ignore(); +} + +// Switches on a [ChannelDetailAction] and dispatches to the per-action +// handler. +Future _onChannelDetailAction( + BuildContext context, + Channel channel, + ChannelDetailAction action, +) async { + final client = StreamChat.of(context).client; + return switch (action) { + ViewChannelInfo(:final user) => _pushChannelInfo(context, channel, user), + PinChannel() => channel.pin(), + UnpinChannel() => channel.unpin(), + MuteChannelMember(:final user) => client.muteUser(user.id), + UnmuteChannelMember(:final user) => client.unmuteUser(user.id), + BlockChannelMember(:final user) => client.blockUser(user.id), + LeaveChannel() => _maybeLeaveChannel(context, channel), + DeleteChannel() => _maybeDeleteChannel(context, channel), + }; +} + +// Pushes the chat / group info screen depending on whether [user] was +// resolved. 1-1 channels pass the other member here (forwarded as `extra` +// to the chat-info route); group channels pass `null` and route to the +// group info screen. +Future _pushChannelInfo(BuildContext context, Channel channel, User? user) { + final router = GoRouter.of(context); + + if (user != null) { + return router.pushNamed( + Routes.CHAT_INFO_SCREEN.name, + pathParameters: Routes.CHAT_INFO_SCREEN.params(channel), + extra: user, + ); + } + + return router.pushNamed( + Routes.GROUP_INFO_SCREEN.name, + pathParameters: Routes.GROUP_INFO_SCREEN.params(channel), + ); +} + +// Shows a confirmation dialog before removing the current user from the +// channel. Leave is only surfaced for group channels in the detail sheet, +// so the copy is group-specific here. +Future _maybeLeaveChannel(BuildContext context, Channel channel) async { + final currentUserId = StreamChat.of(context).currentUser?.id; + if (currentUserId == null) return; + + final confirmed = await _showConfirmationDialog( + context: context, + title: 'Leave group', + content: 'Are you sure you want to leave this group?', + confirmLabel: 'Leave', + ); + + if (confirmed != true) return; + await channel.removeMembers([currentUserId]); +} + +// Shows a confirmation dialog before deleting the channel. On success, pops +// the channel page if currently visible (e.g. when invoked from inside a +// channel route). +Future _maybeDeleteChannel(BuildContext context, Channel channel) async { + final router = GoRouter.of(context); + final subject = channel.isOneToOne ? 'conversation' : 'group'; + + final confirmed = await _showConfirmationDialog( + context: context, + title: 'Delete ${subject.toLowerCase()}', + content: 'Are you sure you want to delete this $subject?', + confirmLabel: 'Delete', + ); + + if (confirmed != true) return; + await channel.delete(); + if (router.canPop()) router.pop(); +} + class _ChannelListSearch extends StatelessWidget { const _ChannelListSearch(this.messageSearchListController); @@ -274,16 +283,16 @@ class _ChannelListSearch extends StatelessWidget { child: Center( child: Column( children: [ - const Padding( - padding: EdgeInsets.all(24), - child: StreamSvgIcon( - icon: StreamSvgIcons.search, + Padding( + padding: const EdgeInsets.all(24), + child: Icon( + context.streamIcons.search, size: 96, color: Colors.grey, ), ), - Text( - AppLocalizations.of(context).noResults, + const Text( + 'No results...', ), ], ), @@ -293,12 +302,7 @@ class _ChannelListSearch extends StatelessWidget { }, ); }, - itemBuilder: ( - context, - messageResponses, - index, - defaultWidget, - ) { + itemBuilder: (context, messageResponses, index, defaultWidget) { final messageResponse = messageResponses[index]; return defaultWidget.copyWith( @@ -325,3 +329,63 @@ class _ChannelListSearch extends StatelessWidget { ); } } + +// Shows a Stream-styled confirmation [AlertDialog] with a destructive +// primary action — used by the leave / delete handlers above. +// +// Resolves to `true` when the user taps confirm, `false` when they tap +// cancel, and `null` if the dialog is dismissed without a choice. +Future _showConfirmationDialog({ + required BuildContext context, + required String title, + required String content, + required String confirmLabel, +}) { + return showDialog( + context: context, + builder: (_) => _ConfirmationDialog( + title: title, + content: content, + confirmLabel: confirmLabel, + ), + ); +} + +class _ConfirmationDialog extends StatelessWidget { + const _ConfirmationDialog({ + required this.title, + required this.content, + required this.confirmLabel, + }); + + final String title; + final String content; + final String confirmLabel; + + @override + Widget build(BuildContext context) { + final colorScheme = context.streamColorScheme; + + return AlertDialog( + backgroundColor: colorScheme.backgroundElevation1, + title: Text(title), + content: Text(content), + actions: [ + StreamButton( + type: .ghost, + style: .secondary, + size: .small, + onPressed: () => Navigator.of(context).maybePop(false), + child: Text(context.translations.cancelLabel), + ), + StreamButton( + type: .solid, + style: .destructive, + size: .small, + onPressed: () => Navigator.of(context).maybePop(true), + child: Text(confirmLabel), + ), + ], + ); + } +} diff --git a/sample_app/lib/widgets/chips_input_text_field.dart b/sample_app/lib/widgets/chips_input_text_field.dart index 45368e0939..ddde1a81f5 100644 --- a/sample_app/lib/widgets/chips_input_text_field.dart +++ b/sample_app/lib/widgets/chips_input_text_field.dart @@ -1,7 +1,6 @@ // ignore_for_file: deprecated_member_use import 'package:flutter/material.dart'; -import 'package:sample_app/utils/localizations.dart'; import 'package:stream_chat_flutter/stream_chat_flutter.dart'; typedef ChipBuilder = Widget Function(BuildContext context, T chip); @@ -76,15 +75,10 @@ class ChipInputTextFieldState extends State> { Padding( padding: const EdgeInsets.symmetric(vertical: 4), child: Text( - '${AppLocalizations.of(context).to.toUpperCase()}:', - style: StreamChatTheme.of(context) - .textTheme - .footnote - .copyWith( - color: StreamChatTheme.of(context) - .colorTheme - .textHighEmphasis - .withOpacity(.5)), + 'TO:', + style: StreamChatTheme.of(context).textTheme.footnote.copyWith( + color: StreamChatTheme.of(context).colorTheme.textHighEmphasis.withOpacity(.5), + ), ), ), const SizedBox(width: 12), @@ -114,14 +108,9 @@ class ChipInputTextFieldState extends State> { disabledBorder: InputBorder.none, contentPadding: const EdgeInsets.only(top: 4), hintText: widget.hint, - hintStyle: StreamChatTheme.of(context) - .textTheme - .body - .copyWith( - color: StreamChatTheme.of(context) - .colorTheme - .textHighEmphasis - .withOpacity(.5)), + hintStyle: StreamChatTheme.of(context).textTheme.body.copyWith( + color: StreamChatTheme.of(context).colorTheme.textHighEmphasis.withOpacity(.5), + ), ), ), ], @@ -132,20 +121,14 @@ class ChipInputTextFieldState extends State> { alignment: Alignment.bottomCenter, child: IconButton( icon: _chips.isEmpty - ? StreamSvgIcon( - icon: StreamSvgIcons.user, - color: StreamChatTheme.of(context) - .colorTheme - .textHighEmphasis - .withOpacity(0.5), + ? Icon( + context.streamIcons.user, + color: StreamChatTheme.of(context).colorTheme.textHighEmphasis.withOpacity(0.5), size: 24, ) - : StreamSvgIcon( - icon: StreamSvgIcons.userAdd, - color: StreamChatTheme.of(context) - .colorTheme - .textHighEmphasis - .withOpacity(0.5), + : Icon( + context.streamIcons.userAdd, + color: StreamChatTheme.of(context).colorTheme.textHighEmphasis.withOpacity(0.5), size: 24, ), onPressed: resumeItemAddition, diff --git a/sample_app/lib/widgets/custom_message_actions.dart b/sample_app/lib/widgets/custom_message_actions.dart new file mode 100644 index 0000000000..593c8123c9 --- /dev/null +++ b/sample_app/lib/widgets/custom_message_actions.dart @@ -0,0 +1,203 @@ +import 'package:flutter/material.dart'; +import 'package:sample_app/config/sample_app_config.dart'; +import 'package:sample_app/widgets/message_info_sheet.dart'; +import 'package:sample_app/widgets/reminder_dialog.dart'; +import 'package:stream_chat_flutter/stream_chat_flutter.dart'; + +/// Custom [StreamComponentBuilder] for [StreamMessageItemProps] that +/// composes app-specific message action customizations via a delegation +/// chain. +/// +/// Delegation chain: +/// ``` +/// customMessageItemBuilder +/// → _ReminderActions (remind me, save for later, edit/remove reminder) +/// → _DeleteForMeAction (delete message for current user only) +/// → _MessageInfoAction (show message delivery info sheet) +/// ``` +Widget customMessageItemBuilder( + BuildContext context, + StreamMessageItemProps props, +) { + return DefaultStreamMessageItem( + props: props.copyWith( + actionsBuilder: (context, defaultActions) { + final message = props.message; + return StreamContextMenuAction.partitioned( + items: [ + ...defaultActions, + ..._ReminderActions.build(context, message), + ..._DeleteForMeAction.build(context, message), + ..._MessageInfoAction.build(context, message), + ], + ); + }, + ), + ); +} + +// --------------------------------------------------------------------------- +// Reminder actions +// --------------------------------------------------------------------------- + +abstract final class _ReminderActions { + static List build( + BuildContext context, + Message message, + ) { + if (!context.sampleAppConfig.enableReminderActions) return const []; + + final icons = context.streamIcons; + final channel = StreamChannel.of(context).channel; + final channelConfig = channel.config; + if (channelConfig?.userMessageReminders != true) return const []; + + final reminder = message.reminder; + if (reminder != null) { + return [ + StreamContextMenuAction( + label: const Text('Edit Reminder'), + leading: Icon(icons.clock), + onTap: () => _editReminder(context, message, reminder), + ), + StreamContextMenuAction( + label: const Text('Remove from later'), + leading: Icon(icons.checkmark), + onTap: () => _removeReminder(context, message), + ), + ]; + } + + return [ + StreamContextMenuAction( + label: const Text('Remind me'), + leading: Icon(icons.bell), + onTap: () => _createReminder(context, message), + ), + StreamContextMenuAction( + label: const Text('Save for later'), + leading: Icon(icons.file), + onTap: () => _createBookmark(context, message), + ), + ]; + } + + static Future _editReminder( + BuildContext context, + Message message, + MessageReminder reminder, + ) async { + final option = await showDialog( + context: context, + builder: (_) => EditReminderDialog( + isBookmarkReminder: reminder.remindAt == null, + ), + ); + + if (option == null) return; + final client = StreamChat.of(context).client; + return client.updateReminder(message.id, remindAt: option.remindAt).ignore(); + } + + static Future _removeReminder( + BuildContext context, + Message message, + ) async { + final client = StreamChat.of(context).client; + return client.deleteReminder(message.id).ignore(); + } + + static Future _createReminder( + BuildContext context, + Message message, + ) async { + final reminder = await showDialog( + context: context, + builder: (_) => const CreateReminderDialog(), + ); + + if (reminder == null) return; + final client = StreamChat.of(context).client; + return client.createReminder(message.id, remindAt: reminder.remindAt).ignore(); + } + + static Future _createBookmark( + BuildContext context, + Message message, + ) async { + final client = StreamChat.of(context).client; + return client.createReminder(message.id).ignore(); + } +} + +// --------------------------------------------------------------------------- +// Delete-for-me action +// --------------------------------------------------------------------------- + +abstract final class _DeleteForMeAction { + static List build( + BuildContext context, + Message message, + ) { + if (!context.sampleAppConfig.enableDeleteForMe) return const []; + + final icons = context.streamIcons; + final channel = StreamChannel.of(context).channel; + final currentUser = StreamChat.of(context).currentUser; + final isSentByCurrentUser = message.user?.id == currentUser?.id; + if (!isSentByCurrentUser || !channel.canDeleteOwnMessage) return const []; + + return [ + StreamContextMenuAction.destructive( + label: const Text('Delete Message for Me'), + leading: Icon(icons.delete), + onTap: () => _confirmAndDelete(context, message), + ), + ]; + } + + static Future _confirmAndDelete( + BuildContext context, + Message message, + ) async { + final confirmed = await showStreamDialog( + context: context, + builder: (context) => const StreamMessageActionConfirmationModal( + isDestructiveAction: true, + title: Text('Delete for me'), + content: Text('Are you sure you want to delete this message for you?'), + cancelActionTitle: Text('Cancel'), + confirmActionTitle: Text('Delete'), + ), + ); + + if (confirmed != true) return; + final channel = StreamChannel.of(context).channel; + return channel.deleteMessageForMe(message).ignore(); + } +} + +// --------------------------------------------------------------------------- +// Message info action +// --------------------------------------------------------------------------- + +abstract final class _MessageInfoAction { + static List build( + BuildContext context, + Message message, + ) { + if (!context.sampleAppConfig.enableMessageInfo) return const []; + + final icons = context.streamIcons; + final channel = StreamChannel.of(context).channel; + if (channel.config?.deliveryEvents != true) return const []; + + return [ + StreamContextMenuAction( + label: const Text('Message Info'), + leading: Icon(icons.info), + onTap: () => MessageInfoSheet.show(context: context, message: message), + ), + ]; + } +} diff --git a/sample_app/lib/widgets/edit_group_sheet.dart b/sample_app/lib/widgets/edit_group_sheet.dart new file mode 100644 index 0000000000..051bb8a192 --- /dev/null +++ b/sample_app/lib/widgets/edit_group_sheet.dart @@ -0,0 +1,534 @@ +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:image_picker/image_picker.dart'; +import 'package:stream_chat_flutter/stream_chat_flutter.dart'; + +/// {@template showEditGroupSheet} +/// Displays the group-edit bottom sheet for [channel] — Figma frame +/// `8833:446261`. Resolves to `true` if the user saved a change, otherwise +/// `false` / `null` on dismiss. +/// {@endtemplate} +Future showEditGroupSheet(BuildContext context, Channel channel) { + return showStreamSheet( + context: context, + isDismissible: true, + builder: (_, _) => StreamChannel( + channel: channel, + child: const EditGroupSheet(), + ), + ); +} + +/// {@template editGroupSheet} +/// A bottom sheet that lets the current user rename the channel and replace +/// (or reset) the channel avatar. +/// +/// The avatar tap (or _Upload_ link) opens [_AvatarPickerSheet], which +/// surfaces _Take Photo_, _Choose Image_, and _Reset Picture_ as the +/// three quick actions. Picked images are uploaded immediately via +/// [StreamChatClient.sendImage] so the URL is settled by the time the user +/// taps the save checkmark. +/// {@endtemplate} +class EditGroupSheet extends StatefulWidget { + /// {@macro editGroupSheet} + const EditGroupSheet({super.key}); + + @override + State createState() => _EditGroupSheetState(); +} + +class _EditGroupSheetState extends State { + late final Channel _channel = StreamChannel.of(context).channel; + late final StreamChatClient _client = StreamChat.of(context).client; + late final TextEditingController _nameController = TextEditingController( + text: _channel.name ?? '', + ); + + // Path to the picked image file on the local device. Drives the + // preview via Image.file so the swap is instant (no CDN round-trip), + // independent of the upload's URL state. Mirrors the LLC's + // sendMessage attachment flow — local file is the source of truth + // until the URL takes over on the server. + String? _pickedPath; + + // CDN URL returned from the standalone upload. Used only to persist + // the avatar on save — the in-sheet preview reads from [_pickedPath]. + String? _imageOverride; + + // True when the user tapped Reset Picture. Persisted as an `image` + // unset only when save is tapped. + bool _imageRemoved = false; + + bool _saving = false; + + // Determinate upload progress in [0, 1], or `null` when no upload is in + // flight. Drives the spinner overlay on the avatar preview and gates the + // save checkmark so users can't persist before the URL has settled. + double? _uploadProgress; + + // Standalone uploads we've kicked off in this session. On save, we + // strip the URL we're about to persist and delete the rest — they + // were superseded by a later pick. On dispose without save, we + // delete every entry so abandoned uploads don't leak on the CDN. + final List _trackedUploads = []; + + String get _name => _nameController.text.trim(); + String get _initialName => (_channel.extraData['name'] as String?) ?? ''; + + bool get _isDirty { + if (_name != _initialName) return true; + if (_pickedPath != null) return true; + if (_imageRemoved) return true; + return false; + } + + // Save is gated on at least one change *and* a non-empty name (the API + // won't accept blanks) *and* no upload in flight (so we never persist a + // stale URL). + bool get _canSave => _isDirty && _name.isNotEmpty && !_saving && _uploadProgress == null; + + @override + void initState() { + super.initState(); + _nameController.addListener(() => setState(() {})); + } + + @override + void dispose() { + _nameController.dispose(); + // Sheet was dismissed without saving — delete every standalone + // upload we held on to. Save() clears the list before pop, so this + // only fires for the abandon case. + for (final url in _trackedUploads) { + _deleteOrphan(url); + } + _trackedUploads.clear(); + super.dispose(); + } + + // Fire-and-forget cleanup of a CDN upload via the standalone delete + // API. Failure to delete just leaks one orphan, which the user can + // survive. + void _deleteOrphan(String url) { + _client.deleteImage(url, _channel.id!, _channel.type).ignore(); + } + + @override + Widget build(BuildContext context) { + final spacing = context.streamSpacing; + // The sheet route consumes the top inset via its own SafeArea, but + // intentionally leaves the bottom inset so descendants can opt in. + // The keyboard inset arrives via viewInsets — pad the body's bottom + // by it so the text input never disappears behind the keyboard. + final viewInsets = MediaQuery.viewInsetsOf(context); + + return SafeArea( + child: Column( + children: [ + StreamSheetHeader( + title: const Text('Edit'), + trailing: StreamButton.icon( + icon: Icon(context.streamIcons.checkmark), + type: .solid, + onPressed: _canSave ? _save : null, + ), + ), + Expanded( + child: Padding( + padding: EdgeInsets.all(spacing.md) + viewInsets, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + _AvatarPreview( + pickedPath: _pickedPath, + imageOverride: _imageOverride, + imageRemoved: _imageRemoved, + uploadProgress: _uploadProgress, + onTap: _openAvatarPicker, + ), + SizedBox(height: spacing.xxl), + StreamTextInput( + controller: _nameController, + autofocus: true, + hintText: 'Group name', + ), + ], + ), + ), + ), + ], + ), + ); + } + + Future _openAvatarPicker() async { + final action = await _showAvatarPickerSheet(context); + if (action == null || !mounted) return; + switch (action) { + case _AvatarPickerAction.takePhoto: + await _pickAndUpload(ImageSource.camera); + case _AvatarPickerAction.chooseImage: + await _pickAndUpload(ImageSource.gallery); + case _AvatarPickerAction.resetPicture: + _resetPicture(); + } + } + + Future _pickAndUpload(ImageSource source) async { + final picker = ImagePicker(); + final picked = await picker.pickImage(source: source); + if (picked == null || !mounted) return; + + final file = File(picked.path); + final size = await file.length(); + final attachmentFile = AttachmentFile( + path: picked.path, + size: size, + name: picked.name, + ); + + // Show the local file in the preview immediately (LLC pattern — + // sendMessage's attachments render via attachment.file.path until + // the upload settles). Clear any prior URL since it's superseded. + setState(() { + _pickedPath = picked.path; + _imageOverride = null; + _imageRemoved = false; + // Start at `0` so the spinner appears as a determinate bar + // immediately; the first onSendProgress callback may not arrive + // for a few hundred ms on slow networks. + _uploadProgress = 0; + }); + + try { + // Standalone upload — returns a CDN URL we can persist on the + // channel without creating a message. + final response = await _client.sendImage( + attachmentFile, + _channel.id!, + _channel.type, + onSendProgress: (count, total) { + if (!mounted) return; + // Fall back to indeterminate (`null`) when the server doesn't + // report a content length — prevents the spinner from claiming + // a fake 0% for the entire upload. + setState(() { + _uploadProgress = total > 0 ? count / total : null; + }); + }, + ); + final url = response.file; + if (url == null || !mounted) return; + _trackedUploads.add(url); + setState(() => _imageOverride = url); + } catch (e) { + if (mounted) { + // Drop the local preview so the user sees the channel revert — + // the snackbar tells them why and they can re-pick. + setState(() => _pickedPath = null); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Upload failed: $e')), + ); + } + } finally { + if (mounted) setState(() => _uploadProgress = null); + } + } + + void _resetPicture() { + setState(() { + _pickedPath = null; + _imageOverride = null; + _imageRemoved = true; + }); + } + + Future _save() async { + final navigator = Navigator.of(context); + final messenger = ScaffoldMessenger.of(context); + + setState(() => _saving = true); + try { + // Single-round-trip persist — name changes, the new avatar URL, + // or an `image` unset for a reset all flow through one + // updatePartial. + final set = {}; + final unset = []; + + if (_name != _initialName) set['name'] = _name; + if (_imageOverride != null) { + set['image'] = _imageOverride; + } else if (_imageRemoved) { + unset.add('image'); + } + + if (set.isNotEmpty || unset.isNotEmpty) { + await _channel.updatePartial(set: set, unset: unset); + } + + // Strip the saved URL from the orphan list so dispose() doesn't + // delete what we just persisted; everything else (a previous pick + // the user replaced before saving) is now genuinely orphaned — + // delete it via the standalone API. + if (_imageOverride case final saved?) _trackedUploads.remove(saved); + for (final url in _trackedUploads) { + _deleteOrphan(url); + } + _trackedUploads.clear(); + + if (!mounted) return; + navigator.pop(true); + } catch (e) { + messenger.showSnackBar(SnackBar(content: Text('Failed to save: $e'))); + if (mounted) setState(() => _saving = false); + } + } +} + +/// The hero avatar block — local picked-file preview, the uploaded +/// CDN URL once it's settled, the session "removed" preview, or the +/// channel's current avatar — with an _Upload_ button below. While an +/// upload is in flight, a translucent overlay + [StreamLoadingSpinner] +/// is layered on top of the avatar — same pattern as +/// `StreamAttachmentUploadStateBuilder` in the SDK. +class _AvatarPreview extends StatelessWidget { + const _AvatarPreview({ + required this.pickedPath, + required this.imageOverride, + required this.imageRemoved, + required this.uploadProgress, + required this.onTap, + }); + + /// Path to the picked image file on the local device. Used as the + /// placeholder for the URL branch so the swap is seamless — while + /// the CDN copy is being fetched, the file shows through. + final String? pickedPath; + + /// CDN URL returned by the standalone upload. Once set, the preview + /// reads from the URL (so memory doesn't hold the file image once we + /// have the canonical CDN copy) — the [pickedPath] keeps acting as + /// the placeholder during the brief network round-trip. + final String? imageOverride; + + /// `true` after the user explicitly tapped Reset Picture. Falls back + /// to the member-group avatar even if the channel still carries an + /// image (it'll be unset on save). + final bool imageRemoved; + + /// Determinate upload progress in [0, 1], or `null` when no upload is + /// in flight. A `null` value while uploading renders as an + /// indeterminate spinner — matches the SDK's fallback when content + /// length is unknown. + final double? uploadProgress; + + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + final spacing = context.streamSpacing; + final colorScheme = context.streamColorScheme; + final channel = StreamChannel.of(context).channel; + final size = StreamAvatarGroupSize.xxl.value; + + Widget? filePlaceholder(String? path) { + if (path == null) return null; + return Image.file( + File(path), + width: size, + height: size, + fit: BoxFit.cover, + ); + } + + final base = switch ((imageOverride, pickedPath, imageRemoved)) { + // Upload finished — render the CDN URL via StreamAvatar; the + // local file (if still around) acts as the placeholder during + // CachedNetworkImage's first fetch so there's no flicker on the + // file→URL swap. + (final url?, final path, _) => StreamAvatar( + imageUrl: url, + size: .xxl, + placeholder: (_) => filePlaceholder(path) ?? _MemberFallbackAvatar(channel: channel), + ), + // Picked but upload not yet settled — render the local file via + // Image.file slotted into StreamAvatar's placeholder so the + // surrounding chrome (size, 1px border, circle clip) matches + // StreamChannelAvatar's image branch pixel-for-pixel. + (null, final path?, _) => StreamAvatar( + imageUrl: null, + size: .xxl, + placeholder: (_) => filePlaceholder(path)!, + ), + // User reset — render the member-group fallback even if the + // channel still carries an image (it'll be unset on save). + (null, null, true) => _MemberFallbackAvatar(channel: channel), + // Untouched — defer to the channel's current avatar; reloads + // automatically off `channel.imageStream` if the image changes + // out from under us. + _ => StreamChannelAvatar(channel: channel, size: .xxl), + }; + + return Column( + children: [ + // Avatar is purely a preview — only the Upload button below + // triggers the picker. Avoids two overlapping hit targets and + // keeps the affordance unambiguous. + Stack( + alignment: Alignment.center, + children: [ + base, + if (uploadProgress != null) + Container( + width: size, + height: size, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: colorScheme.backgroundOverlayLight, + ), + alignment: Alignment.center, + child: StreamLoadingSpinner( + value: uploadProgress, + size: .md, + ), + ), + ], + ), + SizedBox(height: spacing.xs), + StreamButton( + type: .ghost, + style: .primary, + size: .small, + onPressed: onTap, + child: const Text('Upload'), + ), + ], + ); + } +} + +/// Renders the member-group avatar fallback — used to preview the "reset +/// picture" state before save round-trips, and as the placeholder for an +/// in-flight network image. +class _MemberFallbackAvatar extends StatelessWidget { + const _MemberFallbackAvatar({required this.channel}); + + final Channel channel; + + @override + Widget build(BuildContext context) { + return BetterStreamBuilder>( + stream: channel.state!.membersStream, + initialData: channel.state!.members, + builder: (context, members) { + final users = [ + for (final m in members) + if (m.user case final user?) user, + ]; + return StreamUserAvatarGroup(users: users, size: .xxl); + }, + ); + } +} + +// --------------------------------------------------------------------------- +// Avatar picker (stacked) +// --------------------------------------------------------------------------- + +enum _AvatarPickerAction { takePhoto, chooseImage, resetPicture } + +Future<_AvatarPickerAction?> _showAvatarPickerSheet(BuildContext context) { + return showStreamSheet<_AvatarPickerAction>( + context: context, + isDismissible: true, + builder: (_, _) => const _AvatarPickerSheet(), + ); +} + +class _AvatarPickerSheet extends StatelessWidget { + const _AvatarPickerSheet(); + + @override + Widget build(BuildContext context) { + final spacing = context.streamSpacing; + final icons = context.streamIcons; + + void emit(_AvatarPickerAction action) => Navigator.of(context).pop(action); + + return SafeArea( + child: IconTheme.merge( + data: const IconThemeData(size: 20), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + StreamSheetHeader(title: const Text('Edit Group Picture')), + Padding( + padding: EdgeInsets.symmetric(horizontal: spacing.xxs), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + _PickerTile( + icon: Icon(icons.camera), + label: const Text('Take Photo'), + onTap: () => emit(_AvatarPickerAction.takePhoto), + ), + _PickerTile( + icon: Icon(icons.image), + label: const Text('Choose Image'), + onTap: () => emit(_AvatarPickerAction.chooseImage), + ), + _PickerTile( + icon: Icon(icons.delete), + label: const Text('Reset Picture'), + destructive: true, + onTap: () => emit(_AvatarPickerAction.resetPicture), + ), + ], + ), + ), + ], + ), + ), + ); + } +} + +/// A single tappable row in [_AvatarPickerSheet]. Mirrors the `_Tile` shape +/// used by `GroupInfoScreen` / `ChatInfoScreen` — same min tap target and +/// content padding, with a [destructive] flag that flips the icon and +/// label to [StreamColorScheme.accentError] via a local +/// [StreamListTileTheme] override. +class _PickerTile extends StatelessWidget { + const _PickerTile({ + required this.icon, + required this.label, + required this.onTap, + this.destructive = false, + }); + + final Widget icon; + final Widget label; + final VoidCallback onTap; + final bool destructive; + + @override + Widget build(BuildContext context) { + final spacing = context.streamSpacing; + final colorScheme = context.streamColorScheme; + + return StreamListTileTheme( + data: StreamListTileThemeData( + iconColor: destructive ? .all(colorScheme.accentError) : null, + titleColor: destructive ? .all(colorScheme.accentError) : null, + minTileHeight: 44, + contentPadding: .symmetric(horizontal: spacing.sm), + ), + child: StreamListTile( + leading: icon, + title: label, + onTap: onTap, + ), + ); + } +} diff --git a/sample_app/lib/widgets/location/location_attachment.dart b/sample_app/lib/widgets/location/location_attachment.dart new file mode 100644 index 0000000000..79709ae3cd --- /dev/null +++ b/sample_app/lib/widgets/location/location_attachment.dart @@ -0,0 +1,243 @@ +import 'package:flutter/material.dart'; +import 'package:sample_app/widgets/location/location_user_marker.dart'; +import 'package:sample_app/widgets/simple_map_view.dart'; +import 'package:stream_chat_flutter/stream_chat_flutter.dart'; + +const _defaultLocationConstraints = BoxConstraints( + maxWidth: 270, + maxHeight: 180, +); + +/// {@template locationAttachmentBuilder} +/// A builder for creating a location attachment widget. +/// {@endtemplate} +class LocationAttachmentBuilder extends StreamAttachmentWidgetBuilder { + /// {@macro locationAttachmentBuilder} + const LocationAttachmentBuilder({ + this.style, + this.constraints, + this.onAttachmentTap, + }); + + /// The style of the image attachment container. + /// + /// When null, a default style with a rounded rectangle shape and border + /// is used. + final StreamMessageAttachmentStyle? style; + + /// The constraints to apply to the image attachment widget. + final BoxConstraints? constraints; + + /// Optional callback to handle tap events on the attachment. + /// + /// Receives the [BuildContext] from the widget tree where the attachment + /// is rendered, along with the [Location] data. This allows showing + /// dialogs or navigating from the correct context. + final void Function(BuildContext context, Location location)? onAttachmentTap; + + @override + bool canHandle(Message message, _) => message.sharedLocation != null; + + @override + Widget build( + BuildContext context, + Message message, + Map> attachments, + ) { + assert(debugAssertCanHandle(message, attachments), ''); + + final user = message.user; + final location = message.sharedLocation!; + + return StreamMessageAttachment( + style: style, + child: LocationAttachment( + user: user, + sharedLocation: location, + constraints: constraints, + onLocationTap: switch (onAttachmentTap) { + final onTap? => () => onTap(context, location), + _ => null, + }, + ), + ); + } +} + +/// Displays a location attachment with a map view and optional footer. +class LocationAttachment extends StatelessWidget { + /// Creates a new [LocationAttachment]. + const LocationAttachment({ + super.key, + required this.user, + required this.sharedLocation, + this.constraints, + this.onLocationTap, + }); + + /// The user who shared the location. + final User? user; + + /// The shared location data. + final Location sharedLocation; + + /// The constraints to apply to the file attachment widget. + final BoxConstraints? constraints; + + /// Optional callback to handle tap events on the location attachment. + final VoidCallback? onLocationTap; + + @override + Widget build(BuildContext context) { + final currentUser = StreamChat.of(context).currentUser; + final sharedLocationEndAt = sharedLocation.endAt; + + final effectiveConstraints = constraints ?? _defaultLocationConstraints; + + return ConstrainedBox( + constraints: effectiveConstraints, + child: Column( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Expanded( + child: Material( + clipBehavior: Clip.antiAlias, + type: MaterialType.transparency, + child: InkWell( + onTap: onLocationTap, + child: IgnorePointer( + child: SimpleMapView( + markerSize: MarkerSize.lg, + showLocateMeButton: false, + coordinates: sharedLocation.coordinates, + markerBuilder: (_, __, size) => LocationUserMarker( + user: user, + size: size, + sharedLocation: sharedLocation, + ), + ), + ), + ), + ), + ), + if (sharedLocationEndAt != null && currentUser != null) + LocationAttachmentFooter( + currentUser: currentUser, + sharingEndAt: sharedLocationEndAt, + sharedLocation: sharedLocation, + onStopSharingPressed: () { + final client = StreamChat.of(context).client; + + final location = sharedLocation; + final messageId = location.messageId; + if (messageId == null) return; + + client.stopLiveLocation( + messageId: messageId, + createdByDeviceId: location.createdByDeviceId, + ); + }, + ), + ], + ), + ); + } +} + +class LocationAttachmentFooter extends StatelessWidget { + const LocationAttachmentFooter({ + super.key, + required this.currentUser, + required this.sharingEndAt, + required this.sharedLocation, + this.onStopSharingPressed, + }); + + final User currentUser; + final DateTime sharingEndAt; + final Location sharedLocation; + final VoidCallback? onStopSharingPressed; + + @override + Widget build(BuildContext context) { + final theme = StreamChatTheme.of(context); + final textTheme = theme.textTheme; + final colorTheme = theme.colorTheme; + + const maximumSize = Size(double.infinity, 40); + + // If the location sharing has ended, show a message indicating that. + if (sharingEndAt.isBefore(DateTime.now())) { + return SizedBox.fromSize( + size: maximumSize, + child: Row( + spacing: 8, + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.near_me_disabled_rounded, + color: colorTheme.textLowEmphasis, + ), + Text( + 'Live location ended', + style: textTheme.bodyBold.copyWith( + color: colorTheme.textLowEmphasis, + ), + ), + ], + ), + ); + } + + final currentUserId = currentUser.id; + final sharedLocationUserId = sharedLocation.userId; + + // If the shared location is not shared by the current user, show the + // "Live until" duration text. + if (sharedLocationUserId != currentUserId) { + final liveUntil = Jiffy.parseFromDateTime(sharingEndAt.toLocal()); + + return SizedBox.fromSize( + size: maximumSize, + child: Row( + spacing: 8, + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.near_me_rounded, + color: colorTheme.accentPrimary, + ), + Text( + 'Live until ${liveUntil.jm}', + style: textTheme.bodyBold.copyWith( + color: colorTheme.accentPrimary, + ), + ), + ], + ), + ); + } + + // Otherwise, show the "Stop Sharing" button. + final buttonStyle = TextButton.styleFrom( + maximumSize: maximumSize, + textStyle: textTheme.bodyBold, + visualDensity: VisualDensity.compact, + foregroundColor: colorTheme.accentError, + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + ); + + return TextButton.icon( + style: buttonStyle, + onPressed: onStopSharingPressed, + icon: Icon( + Icons.near_me_disabled_rounded, + color: colorTheme.accentError, + ), + label: const Text('Stop Sharing'), + ); + } +} diff --git a/sample_app/lib/widgets/location/location_detail_dialog.dart b/sample_app/lib/widgets/location/location_detail_dialog.dart new file mode 100644 index 0000000000..50afbc4945 --- /dev/null +++ b/sample_app/lib/widgets/location/location_detail_dialog.dart @@ -0,0 +1,309 @@ +import 'package:collection/collection.dart'; +import 'package:flutter/material.dart'; +import 'package:sample_app/widgets/location/location_user_marker.dart'; +import 'package:sample_app/widgets/simple_map_view.dart'; +import 'package:stream_chat_flutter/stream_chat_flutter.dart'; + +Future showLocationDetailDialog({ + required BuildContext context, + required Location location, +}) async { + final navigator = Navigator.of(context); + return navigator.push( + MaterialPageRoute( + fullscreenDialog: true, + builder: (_) => StreamChannel( + channel: StreamChannel.of(context).channel, + child: LocationDetailDialog(sharedLocation: location), + ), + ), + ); +} + +Stream _findLocationMessageStream( + Channel channel, + Location location, +) { + final messageId = location.messageId; + if (messageId == null) return Stream.value(null); + + final channelState = channel.state; + if (channelState == null) return Stream.value(null); + + return channelState.messagesStream.map((messages) { + return messages.firstWhereOrNull((message) => message.id == messageId); + }); +} + +class LocationDetailDialog extends StatelessWidget { + const LocationDetailDialog({ + super.key, + required this.sharedLocation, + }); + + final Location sharedLocation; + + @override + Widget build(BuildContext context) { + final theme = StreamChatTheme.of(context); + final colorTheme = theme.colorTheme; + + final channel = StreamChannel.of(context).channel; + final locationStream = _findLocationMessageStream(channel, sharedLocation); + + return Scaffold( + backgroundColor: colorTheme.appBg, + appBar: StreamAppBar(title: const Text('Shared Location')), + body: BetterStreamBuilder( + stream: locationStream, + errorBuilder: (_, __) => const Center(child: LocationNotFound()), + noDataBuilder: (_) => const Center(child: CircularProgressIndicator()), + builder: (context, message) { + final sharedLocation = message.sharedLocation; + if (sharedLocation == null) { + return const Center(child: LocationNotFound()); + } + + return Stack( + alignment: AlignmentDirectional.bottomCenter, + children: [ + SimpleMapView( + cameraZoom: 16, + markerSize: MarkerSize.xl, + coordinates: sharedLocation.coordinates, + markerBuilder: (_, __, size) => LocationUserMarker( + user: message.user, + size: size, + sharedLocation: sharedLocation, + ), + ), + if (sharedLocation.isLive) + LocationDetailBottomSheet( + sharedLocation: sharedLocation, + onStopSharingPressed: () { + final client = StreamChat.of(context).client; + + final messageId = sharedLocation.messageId; + if (messageId == null) return; + + client.stopLiveLocation( + messageId: messageId, + createdByDeviceId: sharedLocation.createdByDeviceId, + ); + }, + ), + ], + ); + }, + ), + ); + } +} + +class LocationNotFound extends StatelessWidget { + const LocationNotFound({super.key}); + + @override + Widget build(BuildContext context) { + final chatThemeData = StreamChatTheme.of(context); + final colorTheme = chatThemeData.colorTheme; + final textTheme = chatThemeData.textTheme; + + return Column( + spacing: 8, + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + size: 48, + Icons.near_me_disabled_rounded, + color: colorTheme.accentError, + ), + Text( + 'Location not found', + style: textTheme.headline.copyWith( + color: colorTheme.textHighEmphasis, + ), + ), + Text( + 'The location you are looking for is not available.', + style: textTheme.body.copyWith( + color: colorTheme.textLowEmphasis, + ), + ), + ], + ); + } +} + +class LocationDetailBottomSheet extends StatelessWidget { + const LocationDetailBottomSheet({ + super.key, + required this.sharedLocation, + this.onStopSharingPressed, + }); + + final Location sharedLocation; + final VoidCallback? onStopSharingPressed; + + @override + Widget build(BuildContext context) { + final theme = StreamChatTheme.of(context); + final colorTheme = theme.colorTheme; + + return Material( + color: colorTheme.barsBg, + borderRadius: const BorderRadiusDirectional.only( + topEnd: Radius.circular(14), + topStart: Radius.circular(14), + ), + child: SafeArea( + minimum: const EdgeInsets.all(8), + child: LocationDetail( + sharedLocation: sharedLocation, + onStopSharingPressed: onStopSharingPressed, + ), + ), + ); + } +} + +class LocationDetail extends StatelessWidget { + const LocationDetail({ + super.key, + required this.sharedLocation, + this.onStopSharingPressed, + }); + + final Location sharedLocation; + final VoidCallback? onStopSharingPressed; + + @override + Widget build(BuildContext context) { + assert( + sharedLocation.isLive, + 'Footer should only be shown for live locations', + ); + + final theme = StreamChatTheme.of(context); + final textTheme = theme.textTheme; + final colorTheme = theme.colorTheme; + + final updatedAt = sharedLocation.updatedAt; + final sharingEndAt = sharedLocation.endAt!; + const maximumButtonSize = Size(double.infinity, 40); + + if (sharingEndAt.isBefore(DateTime.now())) { + final jiffyUpdatedAt = Jiffy.parseFromDateTime(updatedAt.toLocal()); + + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + SizedBox.fromSize( + size: maximumButtonSize, + child: Row( + spacing: 8, + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.near_me_disabled_rounded, + color: colorTheme.accentError, + ), + Text( + 'Live location ended', + style: textTheme.headlineBold.copyWith( + color: colorTheme.accentError, + ), + ), + ], + ), + ), + Text( + 'Location last updated at ${jiffyUpdatedAt.jm}', + style: textTheme.body.copyWith( + color: colorTheme.textLowEmphasis, + ), + ), + ], + ); + } + + final sharedLocationUserId = sharedLocation.userId; + final currentUserId = StreamChat.of(context).currentUser?.id; + + // If the shared location is not shared by the current user, show the + // "Live until" duration text. + if (sharedLocationUserId != currentUserId) { + final jiffySharingEndAt = Jiffy.parseFromDateTime(sharingEndAt.toLocal()); + + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + SizedBox.fromSize( + size: maximumButtonSize, + child: Row( + spacing: 8, + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.near_me_rounded, + color: colorTheme.accentPrimary, + ), + Text( + 'Live Location', + style: textTheme.headlineBold.copyWith( + color: colorTheme.accentPrimary, + ), + ), + ], + ), + ), + Text( + 'Live until ${jiffySharingEndAt.jm}', + style: textTheme.body.copyWith( + color: colorTheme.textLowEmphasis, + ), + ), + ], + ); + } + + // Otherwise, show the "Stop Sharing" button. + final buttonStyle = TextButton.styleFrom( + maximumSize: maximumButtonSize, + textStyle: textTheme.headlineBold, + visualDensity: VisualDensity.compact, + foregroundColor: colorTheme.accentError, + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + ); + + final jiffySharingEndAt = Jiffy.parseFromDateTime(sharingEndAt.toLocal()); + + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Center( + child: TextButton.icon( + style: buttonStyle, + onPressed: onStopSharingPressed, + icon: Icon( + Icons.near_me_disabled_rounded, + color: colorTheme.accentError, + ), + label: const Text('Stop Sharing'), + ), + ), + Center( + child: Text( + 'Live until ${jiffySharingEndAt.jm}', + style: textTheme.body.copyWith( + color: colorTheme.textLowEmphasis, + ), + ), + ), + ], + ); + } +} diff --git a/sample_app/lib/widgets/location/location_picker_dialog.dart b/sample_app/lib/widgets/location/location_picker_dialog.dart new file mode 100644 index 0000000000..b61914dc70 --- /dev/null +++ b/sample_app/lib/widgets/location/location_picker_dialog.dart @@ -0,0 +1,399 @@ +import 'package:avatar_glow/avatar_glow.dart'; +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:sample_app/utils/location_provider.dart'; +import 'package:sample_app/widgets/location/location_user_marker.dart'; +import 'package:sample_app/widgets/simple_map_view.dart'; +import 'package:stream_chat_flutter/stream_chat_flutter.dart'; + +class LocationPickerResult { + const LocationPickerResult({ + this.endSharingAt, + required this.coordinates, + }); + + final DateTime? endSharingAt; + final LocationCoordinates coordinates; + + @override + bool operator ==(Object other) { + if (identical(this, other)) return true; + return other is LocationPickerResult && + runtimeType == other.runtimeType && + endSharingAt == other.endSharingAt && + coordinates == other.coordinates; + } + + @override + int get hashCode => endSharingAt.hashCode ^ coordinates.hashCode; +} + +Future showLocationPickerDialog({ + required BuildContext context, + bool barrierDismissible = true, + Color? barrierColor, + String? barrierLabel, + bool useSafeArea = true, + bool useRootNavigator = false, + RouteSettings? routeSettings, + Offset? anchorPoint, + EdgeInsets padding = const EdgeInsets.all(16), + TraversalEdgeBehavior? traversalEdgeBehavior, +}) { + final navigator = Navigator.of(context, rootNavigator: useRootNavigator); + return navigator.push( + MaterialPageRoute( + fullscreenDialog: true, + barrierDismissible: barrierDismissible, + builder: (context) => const LocationPickerDialog(), + ), + ); +} + +class LocationPickerDialog extends StatefulWidget { + const LocationPickerDialog({super.key}); + + @override + State createState() => _LocationPickerDialogState(); +} + +class _LocationPickerDialogState extends State with WidgetsBindingObserver { + LocationCoordinates? _currentLocation; + + /// After opening app settings, reload location when the user returns. + bool _retryLocationAfterResume = false; + + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addObserver(this); + } + + @override + void dispose() { + WidgetsBinding.instance.removeObserver(this); + super.dispose(); + } + + @override + void didChangeAppLifecycleState(AppLifecycleState state) { + if (state == AppLifecycleState.resumed && _retryLocationAfterResume) { + setState(() => _retryLocationAfterResume = false); + } + } + + Future _openAppSettingsForPermission() async { + setState(() => _retryLocationAfterResume = true); + + final wasOpened = await LocationProvider().openLocationSettings(); + // If we couldn't open location settings, try opening app settings as a fallback. + if (!wasOpened) await LocationProvider().openAppSettings(); + } + + @override + Widget build(BuildContext context) { + final theme = StreamChatTheme.of(context); + final colorTheme = theme.colorTheme; + + return Scaffold( + backgroundColor: colorTheme.appBg, + appBar: StreamAppBar(title: const Text('Share Location')), + body: Stack( + alignment: AlignmentDirectional.bottomCenter, + children: [ + FutureBuilder( + future: LocationProvider().getCurrentLocation(), + builder: (context, snapshot) { + if (snapshot.connectionState != ConnectionState.done) { + return const Center( + child: CircularProgressIndicator.adaptive(), + ); + } + + final position = snapshot.data; + if (snapshot.hasError || position == null) { + return Center(child: LocationNotFound(onOpenAppSettings: _openAppSettingsForPermission)); + } + + final coordinates = _currentLocation = LocationCoordinates( + latitude: position.latitude, + longitude: position.longitude, + ); + + return SimpleMapView( + cameraZoom: 18, + markerSize: MarkerSize.sm, + coordinates: coordinates, + markerBuilder: (context, _, size) => AvatarGlow( + glowColor: colorTheme.accentPrimary, + child: Material( + elevation: 2, + shape: CircleBorder( + side: BorderSide( + width: 4, + color: colorTheme.barsBg, + ), + ), + child: CircleAvatar( + radius: size.value / 2, + backgroundColor: colorTheme.accentPrimary, + ), + ), + ), + ); + }, + ), + // Location picker options + LocationPickerOptionList( + onOptionSelected: (option) { + final currentLocation = _currentLocation; + if (currentLocation == null) return Navigator.pop(context); + + final result = LocationPickerResult( + endSharingAt: switch (option) { + ShareStaticLocation() => null, + ShareLiveLocation() => option.endSharingAt, + }, + coordinates: currentLocation, + ); + + return Navigator.pop(context, result); + }, + ), + ], + ), + ); + } +} + +class LocationNotFound extends StatelessWidget { + const LocationNotFound({ + super.key, + required this.onOpenAppSettings, + }); + + final VoidCallback onOpenAppSettings; + + @override + Widget build(BuildContext context) { + final spacing = context.streamSpacing; + final textTheme = context.streamTextTheme; + final colorScheme = context.streamColorScheme; + + return Padding( + padding: EdgeInsets.symmetric(horizontal: spacing.lg), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Icon( + size: 32, + context.streamIcons.location, + color: colorScheme.textTertiary, + ), + SizedBox(height: spacing.xs), + Text( + 'Please enable location access in Settings so you can share your position.', + style: textTheme.bodyDefault.copyWith(color: colorScheme.textSecondary), + textAlign: TextAlign.center, + ), + SizedBox(height: spacing.md), + StreamButton( + type: .outline, + style: .secondary, + onPressed: onOpenAppSettings, + child: const Text('Open Settings'), + ), + ], + ), + ); + } +} + +class LocationPickerOptionList extends StatelessWidget { + const LocationPickerOptionList({ + super.key, + required this.onOptionSelected, + }); + + final ValueSetter onOptionSelected; + + @override + Widget build(BuildContext context) { + final theme = StreamChatTheme.of(context); + final colorTheme = theme.colorTheme; + + return Material( + color: colorTheme.barsBg, + borderRadius: const BorderRadiusDirectional.only( + topEnd: Radius.circular(14), + topStart: Radius.circular(14), + ), + child: SafeArea( + child: Padding( + padding: const EdgeInsets.symmetric( + vertical: 14, + horizontal: 14, + ), + child: Column( + spacing: 8, + mainAxisSize: MainAxisSize.min, + children: [ + LocationPickerOptionItem( + icon: const Icon(Icons.share_location_rounded), + title: 'Share Live Location', + subtitle: 'Your location will update in real-time', + onTap: () async { + final duration = await showCupertinoModalPopup( + context: context, + builder: (_) => const LiveLocationDurationDialog(), + ); + + if (duration == null) return; + final endSharingAt = DateTime.timestamp().add(duration); + + return onOptionSelected( + ShareLiveLocation(endSharingAt: endSharingAt), + ); + }, + ), + LocationPickerOptionItem( + icon: const Icon(Icons.my_location), + title: 'Share Static Location', + subtitle: 'Send your current location only', + onTap: () => onOptionSelected(const ShareStaticLocation()), + ), + ], + ), + ), + ), + ); + } +} + +sealed class LocationPickerOption { + const LocationPickerOption(); +} + +final class ShareLiveLocation extends LocationPickerOption { + const ShareLiveLocation({required this.endSharingAt}); + final DateTime endSharingAt; +} + +final class ShareStaticLocation extends LocationPickerOption { + const ShareStaticLocation(); +} + +class LocationPickerOptionItem extends StatelessWidget { + const LocationPickerOptionItem({ + super.key, + required this.icon, + required this.title, + required this.subtitle, + required this.onTap, + }); + + final Widget icon; + final String title; + final String subtitle; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + final theme = StreamChatTheme.of(context); + final textTheme = theme.textTheme; + final colorTheme = theme.colorTheme; + + return OutlinedButton( + onPressed: onTap, + style: OutlinedButton.styleFrom( + backgroundColor: colorTheme.barsBg, + foregroundColor: colorTheme.accentPrimary, + side: BorderSide(color: colorTheme.borders, width: 1.2), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + padding: const EdgeInsets.symmetric(vertical: 10, horizontal: 8), + ), + child: IconTheme( + data: IconTheme.of(context).copyWith( + size: 24, + color: colorTheme.accentPrimary, + ), + child: Row( + spacing: 16, + children: [ + icon, + Expanded( + child: Column( + spacing: 2, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: textTheme.bodyBold.copyWith( + color: colorTheme.textHighEmphasis, + ), + ), + Text( + subtitle, + style: textTheme.footnote.copyWith( + color: colorTheme.textLowEmphasis, + ), + ), + ], + ), + ), + Icon( + context.streamIcons.chevronRight, + size: 24, + color: colorTheme.textLowEmphasis, + ), + ], + ), + ), + ); + } +} + +class LiveLocationDurationDialog extends StatelessWidget { + const LiveLocationDurationDialog({super.key}); + + static const _endAtDurations = [ + Duration(minutes: 15), + Duration(hours: 1), + Duration(hours: 8), + ]; + + @override + Widget build(BuildContext context) { + final theme = StreamChatTheme.of(context); + + return CupertinoTheme( + data: CupertinoTheme.of(context).copyWith( + primaryColor: theme.colorTheme.accentPrimary, + ), + child: CupertinoActionSheet( + title: const Text('Share Live Location'), + message: Text( + 'Select the duration for sharing your live location.', + style: theme.textTheme.footnote.copyWith( + color: theme.colorTheme.textLowEmphasis, + ), + ), + actions: [ + ..._endAtDurations.map((duration) { + final endAt = Jiffy.now().addDuration(duration); + return CupertinoActionSheetAction( + onPressed: () => Navigator.of(context).pop(duration), + child: Text(endAt.fromNow(withPrefixAndSuffix: false)), + ); + }), + ], + cancelButton: CupertinoActionSheetAction( + isDestructiveAction: true, + onPressed: Navigator.of(context).pop, + child: const Text('Cancel'), + ), + ), + ); + } +} diff --git a/sample_app/lib/widgets/location/location_picker_option.dart b/sample_app/lib/widgets/location/location_picker_option.dart new file mode 100644 index 0000000000..896029837f --- /dev/null +++ b/sample_app/lib/widgets/location/location_picker_option.dart @@ -0,0 +1,68 @@ +import 'package:flutter/material.dart'; +import 'package:sample_app/widgets/location/location_picker_dialog.dart'; +import 'package:stream_chat_flutter/stream_chat_flutter.dart'; + +final class LocationPickerType extends CustomAttachmentPickerType { + const LocationPickerType(); +} + +final class LocationPicked extends CustomAttachmentPickerResult { + const LocationPicked({required this.location}); + final LocationPickerResult location; +} + +class LocationPicker extends StatelessWidget { + const LocationPicker({ + super.key, + this.onLocationPicked, + }); + + final ValueSetter? onLocationPicked; + + @override + Widget build(BuildContext context) { + final spacing = context.streamSpacing; + final textTheme = context.streamTextTheme; + final colorScheme = context.streamColorScheme; + + Future openLocationPicker() async { + final result = await runInPermissionRequestLock(() { + return showLocationPickerDialog(context: context); + }); + + onLocationPicked?.call(result); + } + + return OptionDrawer( + child: EndOfFrameCallbackWidget( + child: Center( + child: Column( + crossAxisAlignment: CrossAxisAlignment.center, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + size: 32, + context.streamIcons.location, + color: colorScheme.textTertiary, + ), + SizedBox(height: spacing.xs), + Text( + 'Share your location on the map and choose how to send it.', + style: textTheme.bodyDefault.copyWith(color: colorScheme.textSecondary), + textAlign: TextAlign.center, + ), + SizedBox(height: spacing.md), + StreamButton( + type: .outline, + style: .secondary, + onPressed: openLocationPicker, + child: const Text('Open location'), + ), + ], + ), + ), + onEndOfFrame: (_) => openLocationPicker(), + ), + ); + } +} diff --git a/sample_app/lib/widgets/location/location_user_marker.dart b/sample_app/lib/widgets/location/location_user_marker.dart new file mode 100644 index 0000000000..3c8994ab98 --- /dev/null +++ b/sample_app/lib/widgets/location/location_user_marker.dart @@ -0,0 +1,77 @@ +import 'package:avatar_glow/avatar_glow.dart'; +import 'package:flutter/material.dart'; +import 'package:stream_chat_flutter/stream_chat_flutter.dart'; + +enum MarkerSize { + xs(20), + sm(24), + md(32), + lg(40), + xl(64) + ; + + const MarkerSize(this.value); + + final double value; +} + +class LocationUserMarker extends StatelessWidget { + const LocationUserMarker({ + super.key, + this.user, + this.size = MarkerSize.lg, + required this.sharedLocation, + }); + + final User? user; + final MarkerSize size; + + final Location sharedLocation; + + @override + Widget build(BuildContext context) { + final theme = StreamChatTheme.of(context); + final colorTheme = theme.colorTheme; + + if (user case final user? when sharedLocation.isLive) { + const borderWidth = 4.0; + + final avatar = Material( + shape: const CircleBorder(), + clipBehavior: Clip.antiAlias, + color: colorTheme.overlayDark, + child: Padding( + padding: const EdgeInsets.all(borderWidth), + child: StreamUserAvatar( + size: _avatarSizeForMarkerSize(size), + user: user, + showOnlineIndicator: false, + ), + ), + ); + + if (sharedLocation.isExpired) return avatar; + + return AvatarGlow( + glowColor: colorTheme.accentPrimary, + child: avatar, + ); + } + + return Icon( + size: size.value, + Icons.person_pin, + color: colorTheme.accentPrimary, + ); + } + + StreamAvatarSize _avatarSizeForMarkerSize( + MarkerSize size, + ) => switch (size) { + .xs => StreamAvatarSize.xs, + .sm => StreamAvatarSize.sm, + .md => StreamAvatarSize.md, + .lg => StreamAvatarSize.lg, + .xl => StreamAvatarSize.xl, + }; +} diff --git a/sample_app/lib/widgets/message_info_sheet.dart b/sample_app/lib/widgets/message_info_sheet.dart index 6f0a8cec57..d5d036b75e 100644 --- a/sample_app/lib/widgets/message_info_sheet.dart +++ b/sample_app/lib/widgets/message_info_sheet.dart @@ -158,7 +158,7 @@ class MessageInfoSheet extends StatelessWidget { ), IconButton( iconSize: 32, - icon: const StreamSvgIcon(icon: StreamSvgIcons.close), + icon: Icon(context.streamIcons.xmark), onPressed: Navigator.of(context).maybePop, color: colorTheme.textHighEmphasis, padding: const EdgeInsets.all(4), @@ -251,11 +251,8 @@ class _UserReadTile extends StatelessWidget { children: [ // User avatar StreamUserAvatar( + size: .lg, user: read.user, - constraints: const BoxConstraints.tightFor( - height: 40, - width: 40, - ), ), const SizedBox(width: 12), @@ -271,9 +268,9 @@ class _UserReadTile extends StatelessWidget { ), // Status icon - StreamSvgIcon( + Icon( + context.streamIcons.checks, size: 18, - icon: StreamSvgIcons.checkAll, color: switch (isDelivered) { true => theme.colorTheme.textLowEmphasis, false => theme.colorTheme.accentPrimary, diff --git a/sample_app/lib/widgets/search_text_field.dart b/sample_app/lib/widgets/search_text_field.dart index ec3084e47e..c48f945b9f 100644 --- a/sample_app/lib/widgets/search_text_field.dart +++ b/sample_app/lib/widgets/search_text_field.dart @@ -10,86 +10,37 @@ class SearchTextField extends StatelessWidget { this.onChanged, this.onTap, this.hintText = 'Search', - this.showCloseButton = true, }); + final TextEditingController? controller; final ValueChanged? onChanged; final String hintText; final VoidCallback? onTap; - final bool showCloseButton; @override Widget build(BuildContext context) { - return Container( - height: 36, - decoration: BoxDecoration( - color: StreamChatTheme.of(context).colorTheme.barsBg, - border: Border.all( - color: StreamChatTheme.of(context).colorTheme.borders, - ), - borderRadius: BorderRadius.circular(24), - ), - margin: const EdgeInsets.symmetric( - vertical: 8, - horizontal: 8, + final radius = context.streamRadius; + final spacing = context.streamSpacing; + final colorScheme = context.streamColorScheme; + + return Padding( + padding: .directional( + start: spacing.md, + end: spacing.md, + top: spacing.md, + bottom: spacing.xs, ), - child: Row( - children: [ - Expanded( - child: TextField( - onTap: onTap, - controller: controller, - onChanged: onChanged, - decoration: InputDecoration( - prefixText: ' ', - prefixIconConstraints: BoxConstraints.tight(const Size(40, 24)), - prefixIcon: Padding( - padding: const EdgeInsets.only( - left: 8, - right: 8, - ), - child: StreamSvgIcon( - icon: StreamSvgIcons.search, - color: - StreamChatTheme.of(context).colorTheme.textHighEmphasis, - size: 24, - ), - ), - hintText: hintText, - hintStyle: StreamChatTheme.of(context).textTheme.body.copyWith( - color: StreamChatTheme.of(context) - .colorTheme - .textHighEmphasis - .withOpacity(.5)), - contentPadding: EdgeInsets.zero, - border: OutlineInputBorder( - borderSide: BorderSide.none, - borderRadius: BorderRadius.circular(24), - ), - ), - ), - ), - if (showCloseButton) - Material( - color: Colors.transparent, - child: IconButton( - color: Colors.grey, - padding: EdgeInsets.zero, - icon: const StreamSvgIcon(icon: StreamSvgIcons.closeSmall), - splashRadius: 24, - onPressed: () { - if (controller!.text.isNotEmpty) { - Future.microtask( - () => [ - controller!.clear(), - if (onChanged != null) onChanged!(''), - ], - ); - } - }, - ), - ), - ], + child: StreamTextInput( + hintText: hintText, + onTap: onTap, + controller: controller, + onChanged: onChanged, + textAlignVertical: .center, + leading: Icon(context.streamIcons.search), + style: StreamTextInputStyle( + borderRadius: .all(radius.max), + focusBorder: BorderSide(color: colorScheme.borderDefault), + ), ), ); } diff --git a/sample_app/lib/widgets/simple_map_view.dart b/sample_app/lib/widgets/simple_map_view.dart new file mode 100644 index 0000000000..ab75abcff6 --- /dev/null +++ b/sample_app/lib/widgets/simple_map_view.dart @@ -0,0 +1,154 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_map/flutter_map.dart'; +import 'package:flutter_map_animations/flutter_map_animations.dart'; +import 'package:latlong2/latlong.dart'; +import 'package:sample_app/widgets/location/location_user_marker.dart'; +import 'package:stream_chat_flutter/stream_chat_flutter.dart'; + +typedef MarkerBuilder = + Widget Function( + BuildContext context, + Animation animation, + MarkerSize markerSize, + ); + +class SimpleMapView extends StatefulWidget { + const SimpleMapView({ + super.key, + this.cameraZoom = 15, + this.markerSize = MarkerSize.lg, + required this.coordinates, + this.showLocateMeButton = true, + this.markerBuilder = _defaultMarkerBuilder, + }); + + final double cameraZoom; + + final MarkerSize markerSize; + + final LocationCoordinates coordinates; + + final bool showLocateMeButton; + + final MarkerBuilder markerBuilder; + static Widget _defaultMarkerBuilder(BuildContext context, _, MarkerSize size) { + final theme = StreamChatTheme.of(context); + final iconColor = theme.colorTheme.accentPrimary; + return Icon(size: size.value, Icons.person_pin, color: iconColor); + } + + @override + State createState() => _SimpleMapViewState(); +} + +class _SimpleMapViewState extends State with TickerProviderStateMixin { + late final _mapController = AnimatedMapController(vsync: this); + late final _initialCenter = widget.coordinates.toLatLng(); + + @override + void didUpdateWidget(covariant SimpleMapView oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.coordinates != widget.coordinates) { + _mapController.animateTo( + dest: widget.coordinates.toLatLng(), + zoom: widget.cameraZoom, + curve: Curves.easeInOut, + ); + } + } + + @override + Widget build(BuildContext context) { + final brightness = Theme.of(context).brightness; + const baseMapTemplate = 'https://{s}.basemaps.cartocdn.com'; + const mapTemplate = '$baseMapTemplate/rastertiles/voyager/{z}/{x}/{y}.png'; + const fallbackTemplate = 'https://tile.openstreetmap.org/{z}/{x}/{y}.png'; + + return FlutterMap( + mapController: _mapController.mapController, + options: MapOptions( + keepAlive: true, + initialCenter: _initialCenter, + initialZoom: widget.cameraZoom, + ), + children: [ + TileLayer( + urlTemplate: mapTemplate, + fallbackUrl: fallbackTemplate, + tileBuilder: (context, tile, __) => switch (brightness) { + Brightness.light => tile, + Brightness.dark => darkModeTilesContainerBuilder(context, tile), + }, + userAgentPackageName: switch (CurrentPlatform.type) { + PlatformType.ios => 'io.getstream.flutter', + PlatformType.android => 'io.getstream.chat.android.flutter.sample', + _ => 'unknown', + }, + ), + AnimatedMarkerLayer( + markers: [ + AnimatedMarker( + height: widget.markerSize.value, + width: widget.markerSize.value, + point: widget.coordinates.toLatLng(), + builder: (context, animation) => widget.markerBuilder( + context, + animation, + widget.markerSize, + ), + ), + ], + ), + if (widget.showLocateMeButton) + SimpleMapLocateMeButton( + onPressed: () => _mapController.animateTo( + zoom: widget.cameraZoom, + curve: Curves.easeInOut, + dest: widget.coordinates.toLatLng(), + ), + ), + ], + ); + } + + @override + void dispose() { + _mapController.dispose(); + super.dispose(); + } +} + +class SimpleMapLocateMeButton extends StatelessWidget { + const SimpleMapLocateMeButton({ + super.key, + this.onPressed, + this.alignment = AlignmentDirectional.topEnd, + }); + + final AlignmentGeometry alignment; + final VoidCallback? onPressed; + + @override + Widget build(BuildContext context) { + final theme = StreamChatTheme.of(context); + final colorTheme = theme.colorTheme; + + return Align( + alignment: alignment, + child: Padding( + padding: const EdgeInsets.all(8), + child: FloatingActionButton.small( + onPressed: onPressed, + shape: const CircleBorder(), + foregroundColor: colorTheme.accentPrimary, + backgroundColor: colorTheme.barsBg, + child: const Icon(Icons.near_me_rounded), + ), + ), + ); + } +} + +extension on LocationCoordinates { + LatLng toLatLng() => LatLng(latitude, longitude); +} diff --git a/packages/stream_chat_flutter/lib/src/scroll_view/draft_scroll_view/stream_draft_list_tile.dart b/sample_app/lib/widgets/stream_draft_list_tile.dart similarity index 60% rename from packages/stream_chat_flutter/lib/src/scroll_view/draft_scroll_view/stream_draft_list_tile.dart rename to sample_app/lib/widgets/stream_draft_list_tile.dart index d2311f4a30..adc2993795 100644 --- a/packages/stream_chat_flutter/lib/src/scroll_view/draft_scroll_view/stream_draft_list_tile.dart +++ b/sample_app/lib/widgets/stream_draft_list_tile.dart @@ -1,17 +1,12 @@ import 'package:flutter/material.dart'; -import 'package:stream_chat_flutter/src/misc/timestamp.dart'; import 'package:stream_chat_flutter/stream_chat_flutter.dart'; -/// {@template streamDraftListTile} /// A widget that displays a draft in a list. /// -/// This widget is used in the [StreamDraftListView] to display a draft. -/// /// The widget displays the channel name, the draft message preview, and the /// timestamp. -/// {@endtemplate} class StreamDraftListTile extends StatelessWidget { - /// {@macro streamDraftListTile} + /// Creates a new [StreamDraftListTile]. const StreamDraftListTile({ super.key, required this.draft, @@ -34,25 +29,26 @@ class StreamDraftListTile extends StatelessWidget { @override Widget build(BuildContext context) { - final theme = StreamDraftListTileTheme.of(context); + final chatTheme = StreamChatTheme.of(context); + final colorTheme = chatTheme.colorTheme; return Material( - color: theme.backgroundColor, + color: colorTheme.barsBg, child: InkWell( onTap: onTap, onLongPress: onLongPress, - child: Container( - padding: theme.padding, + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 14, horizontal: 8), child: Column( spacing: 6, mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ if (draft.channel case final channel?) - DraftTitle( + _DraftTitle( channelName: channel.formatName(currentUser: currentUser), ), - DraftMessageContent( + _DraftMessageContent( draft: draft, currentUser: currentUser, ), @@ -64,38 +60,29 @@ class StreamDraftListTile extends StatelessWidget { } } -/// {@template draftTitle} -/// A widget that displays the channel name. -/// {@endtemplate} -class DraftTitle extends StatelessWidget { - /// {@macro draftTitle} - const DraftTitle({ - super.key, - this.channelName, - }); +class _DraftTitle extends StatelessWidget { + const _DraftTitle({this.channelName}); - /// The channel name to display. final String? channelName; @override Widget build(BuildContext context) { - final theme = StreamDraftListTileTheme.of(context); + final chatTheme = StreamChatTheme.of(context); + final style = chatTheme.textTheme.bodyBold.copyWith( + color: chatTheme.colorTheme.textHighEmphasis, + ); return Row( mainAxisSize: MainAxisSize.min, children: [ - Icon( - size: 16, - Icons.edit_note_rounded, - color: theme.draftChannelNameStyle?.color, - ), + Icon(Icons.edit_note_rounded, size: 16, color: style.color), const SizedBox(width: 4), Flexible( child: Text( channelName ?? context.translations.noTitleText, maxLines: 1, overflow: TextOverflow.ellipsis, - style: theme.draftChannelNameStyle, + style: style, ), ), ], @@ -103,39 +90,33 @@ class DraftTitle extends StatelessWidget { } } -/// {@template draftMessageContent} -/// A widget that displays the draft message content. -/// {@endtemplate} -class DraftMessageContent extends StatelessWidget { - /// {@macro draftMessageContent} - const DraftMessageContent({ - super.key, +class _DraftMessageContent extends StatelessWidget { + const _DraftMessageContent({ required this.draft, this.currentUser, }); - /// The draft to display. final Draft draft; - - /// The current user. final User? currentUser; @override Widget build(BuildContext context) { - final theme = StreamDraftListTileTheme.of(context); + final chatTheme = StreamChatTheme.of(context); + final subtleStyle = chatTheme.textTheme.footnote.copyWith( + color: chatTheme.colorTheme.textLowEmphasis, + ); return Row( children: [ Expanded( child: StreamDraftMessagePreviewText( draftMessage: draft.message, - textStyle: theme.draftMessageStyle, + textStyle: subtleStyle, ), ), StreamTimestamp( date: draft.createdAt.toLocal(), - style: theme.draftTimestampStyle, - formatter: theme.draftTimestampFormatter, + style: subtleStyle, ), ], ); diff --git a/sample_app/lib/widgets/stream_draft_list_view.dart b/sample_app/lib/widgets/stream_draft_list_view.dart new file mode 100644 index 0000000000..adaddb4b88 --- /dev/null +++ b/sample_app/lib/widgets/stream_draft_list_view.dart @@ -0,0 +1,87 @@ +import 'package:flutter/material.dart'; +import 'package:sample_app/widgets/stream_draft_list_tile.dart'; +import 'package:stream_chat_flutter/stream_chat_flutter.dart'; + +/// Signature for the item builder that creates the children of the +/// [StreamDraftListView]. +typedef StreamDraftListViewIndexedWidgetBuilder = StreamScrollViewIndexedWidgetBuilder; + +/// A [ListView] that shows a list of [Draft]'s. It uses a +/// [StreamDraftListController] to load the drafts in paginated form. +class StreamDraftListView extends StatelessWidget { + /// Creates a new [StreamDraftListView]. + const StreamDraftListView({ + super.key, + required this.controller, + this.itemBuilder, + this.onDraftTap, + }); + + /// The [StreamDraftListController] used to control the drafts in the list. + final StreamDraftListController controller; + + /// A builder that is called to build items in the [ListView]. + final StreamDraftListViewIndexedWidgetBuilder? itemBuilder; + + /// Called when the user taps a draft tile. + final void Function(Draft)? onDraftTap; + + @override + Widget build(BuildContext context) { + return PagedValueListView( + controller: controller, + separatorBuilder: (_, _, _) => const StreamDraftListSeparator(), + itemBuilder: (context, drafts, index) { + final draft = drafts[index]; + final currentUser = StreamChat.of(context).currentUser; + final onTap = onDraftTap; + + final tile = StreamDraftListTile( + draft: draft, + currentUser: currentUser, + onTap: onTap == null ? null : () => onTap(draft), + ); + + return itemBuilder?.call(context, drafts, index, tile) ?? tile; + }, + emptyBuilder: (context) => Center( + child: StreamScrollViewEmptyWidget( + emptyIcon: const Icon(Icons.drafts_outlined), + emptyTitle: Text(context.translations.emptyMessagesText), + ), + ), + loadMoreErrorBuilder: (context, error) => StreamScrollViewLoadMoreError.list( + onTap: controller.retry, + error: Text(context.translations.loadingMessagesError), + ), + loadMoreIndicatorBuilder: (context) => Center( + child: Padding( + padding: const EdgeInsets.all(16), + child: StreamLoadingSpinner(), + ), + ), + loadingBuilder: (context) => Center( + child: StreamLoadingSpinner(), + ), + errorBuilder: (context, error) => Center( + child: StreamScrollViewErrorWidget( + errorTitle: Text(context.translations.loadingMessagesError), + onRetryPressed: controller.refresh, + ), + ), + ); + } +} + +/// A widget that is used to display a separator between +/// [StreamDraftListTile] items. +class StreamDraftListSeparator extends StatelessWidget { + /// Creates a new instance of [StreamDraftListSeparator]. + const StreamDraftListSeparator({super.key}); + + @override + Widget build(BuildContext context) { + final colorScheme = context.streamColorScheme; + return Divider(height: 1, color: colorScheme.borderSubtle); + } +} diff --git a/sample_app/lib/widgets/stream_version.dart b/sample_app/lib/widgets/stream_version.dart index dfafee4cd3..15abed0195 100644 --- a/sample_app/lib/widgets/stream_version.dart +++ b/sample_app/lib/widgets/stream_version.dart @@ -1,6 +1,5 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; -import 'package:sample_app/utils/localizations.dart'; import 'package:stream_chat_flutter/stream_chat_flutter.dart'; import 'package:yaml/yaml.dart'; @@ -23,11 +22,10 @@ class StreamVersion extends StatelessWidget { final pubspec = snapshot.data!; final yaml = loadYaml(pubspec); - final streamChatDep = - yaml['packages']['stream_chat_flutter']['version']; + final streamChatDep = yaml['packages']['stream_chat_flutter']['version']; return Text( - '${AppLocalizations.of(context).streamSDK} v $streamChatDep', + 'Stream SDK v $streamChatDep', style: TextStyle( fontSize: 14, color: StreamChatTheme.of(context).colorTheme.disabled, diff --git a/sample_app/macos/Flutter/Flutter-Debug.xcconfig b/sample_app/macos/Flutter/Flutter-Debug.xcconfig deleted file mode 100644 index 4b81f9b2d2..0000000000 --- a/sample_app/macos/Flutter/Flutter-Debug.xcconfig +++ /dev/null @@ -1,2 +0,0 @@ -#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig" -#include "ephemeral/Flutter-Generated.xcconfig" diff --git a/sample_app/macos/Flutter/Flutter-Release.xcconfig b/sample_app/macos/Flutter/Flutter-Release.xcconfig deleted file mode 100644 index 5caa9d1579..0000000000 --- a/sample_app/macos/Flutter/Flutter-Release.xcconfig +++ /dev/null @@ -1,2 +0,0 @@ -#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig" -#include "ephemeral/Flutter-Generated.xcconfig" diff --git a/sample_app/macos/Runner/DebugProfile.entitlements b/sample_app/macos/Runner/DebugProfile.entitlements index 0eaccf1418..6bc96b3bc4 100644 --- a/sample_app/macos/Runner/DebugProfile.entitlements +++ b/sample_app/macos/Runner/DebugProfile.entitlements @@ -12,5 +12,7 @@ com.apple.security.network.server + com.apple.security.personal-information.location + diff --git a/sample_app/macos/Runner/Info.plist b/sample_app/macos/Runner/Info.plist index 4789daa6a4..49bb9bb13c 100644 --- a/sample_app/macos/Runner/Info.plist +++ b/sample_app/macos/Runner/Info.plist @@ -28,5 +28,7 @@ MainMenu NSPrincipalClass NSApplication + NSLocationUsageDescription + We need access to your location to share it in the chat. diff --git a/sample_app/macos/Runner/Release.entitlements b/sample_app/macos/Runner/Release.entitlements index a0463869a9..731447a00b 100644 --- a/sample_app/macos/Runner/Release.entitlements +++ b/sample_app/macos/Runner/Release.entitlements @@ -8,5 +8,7 @@ com.apple.security.network.client + com.apple.security.personal-information.location + diff --git a/sample_app/pubspec.yaml b/sample_app/pubspec.yaml index 852164a056..d4b8f9c184 100644 --- a/sample_app/pubspec.yaml +++ b/sample_app/pubspec.yaml @@ -16,27 +16,33 @@ version: 2.2.0 # 2. Add it to the melos.yaml file for future updates. environment: - sdk: ^3.6.2 - flutter: ">=3.27.4" + sdk: ^3.10.0 + flutter: ">=3.38.1" dependencies: + avatar_glow: ^3.0.0 collection: ^1.17.2 - firebase_core: ^3.0.0 - firebase_messaging: ^15.0.0 + firebase_core: ^4.0.0 + firebase_messaging: ^16.0.0 flutter: sdk: flutter flutter_app_badger: ^1.5.0 - flutter_local_notifications: ^18.0.1 + flutter_local_notifications: ^21.0.0 + flutter_map: ^8.1.1 + flutter_map_animations: ^0.9.0 flutter_secure_storage: ^9.2.2 flutter_slidable: ^3.1.1 flutter_svg: ^2.0.10+1 + geolocator: ^13.0.0 go_router: ^14.6.2 + image_picker: ^1.1.2 + latlong2: ^0.9.1 lottie: ^3.1.2 - provider: ^6.0.5 + rxdart: ^0.28.0 sentry_flutter: ^8.3.0 - stream_chat_flutter: ^9.23.0 - stream_chat_localizations: ^9.23.0 - stream_chat_persistence: ^9.23.0 + stream_chat_flutter: ^10.0.0-beta.13 + stream_chat_localizations: ^10.0.0-beta.13 + stream_chat_persistence: ^10.0.0-beta.13 streaming_shared_preferences: ^2.0.0 uuid: ^4.4.0 video_player: ^2.8.7