From 6e445b5e8197ce9f2a4860729c6772d2b491a661 Mon Sep 17 00:00:00 2001 From: aburiro <96950982+aburiro@users.noreply.github.com> Date: Sun, 22 Feb 2026 22:52:43 +0500 Subject: [PATCH] feat: setup embedded chat UI implementation plan and base models --- CONTRIBUTING.md | 311 +++++++++--------- .../rocket_chat_embeddedchat_component.dart | 13 +- .../lib/src/models/message.dart | 76 +++++ .../lib/src/models/models.dart | 4 + .../lib/src/models/room.dart | 79 +++++ .../lib/src/models/user.dart | 48 +++ .../src/services/rocket_chat_api_service.dart | 246 ++++++++++++++ .../lib/src/services/services.dart | 2 + .../lib/src/widgets/chat_input.dart | 186 +++++++++++ .../lib/src/widgets/chat_view.dart | 230 +++++++++++++ .../lib/src/widgets/message_bubble.dart | 112 +++++++ .../lib/src/widgets/widgets.dart | 4 + .../pubspec.yaml | 40 +-- 13 files changed, 1153 insertions(+), 198 deletions(-) create mode 100644 packages/rocket_chat_embeddedchat_component/lib/src/models/message.dart create mode 100644 packages/rocket_chat_embeddedchat_component/lib/src/models/models.dart create mode 100644 packages/rocket_chat_embeddedchat_component/lib/src/models/room.dart create mode 100644 packages/rocket_chat_embeddedchat_component/lib/src/models/user.dart create mode 100644 packages/rocket_chat_embeddedchat_component/lib/src/services/rocket_chat_api_service.dart create mode 100644 packages/rocket_chat_embeddedchat_component/lib/src/services/services.dart create mode 100644 packages/rocket_chat_embeddedchat_component/lib/src/widgets/chat_input.dart create mode 100644 packages/rocket_chat_embeddedchat_component/lib/src/widgets/chat_view.dart create mode 100644 packages/rocket_chat_embeddedchat_component/lib/src/widgets/message_bubble.dart create mode 100644 packages/rocket_chat_embeddedchat_component/lib/src/widgets/widgets.dart diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index cc82ce9..77a6909 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -6,222 +6,221 @@ This document provides guidelines and instructions to make the contribution proc ## Table of Contents +- [What You Can Contribute](#what-you-can-contribute) +- [Contribution Ideas](#contribution-ideas) +- [How to Contribute](#how-to-contribute) +- [Setting Up Development Environment](#setting-up-development-environment) +- [Creating a Professional Pull Request](#creating-a-professional-pull-request) - [Code of Conduct](#code-of-conduct) -- [Getting Started](#getting-started) -- [Setting Up the Development Environment](#setting-up-the-development-environment) -- [Building the Project](#building-the-project) -- [GitHub Actions CI](#github-actions-ci) -- [Project Structure](#project-structure) -- [Bug Reports](#bug-reports) -- [Feature Requests](#feature-requests) -- [Pull Requests](#pull-requests) -- [Coding Guidelines](#coding-guidelines) -- [Testing](#testing) -- [Troubleshooting](#troubleshooting) -- [Community](#community) -## Code of Conduct - -By participating in this project, you agree to abide by the [Rocket.Chat Code of Conduct](https://github.com/RocketChat/Rocket.Chat/blob/develop/CODE_OF_CONDUCT.md). Please read and follow it to ensure a respectful and inclusive environment for all contributors. - -## Getting Started - -1. Fork the repository on GitHub. -2. Clone the forked repository to your local machine. -3. Follow the instructions below to set up the development environment. -4. Identify an issue or feature you want to work on or create a new issue to discuss your ideas. -5. Before starting the implementation, discuss your approach in the issue comments to ensure that your work aligns with the project's direction and goals. - -## Setting Up the Development Environment - -To set up the development environment for the Rocket.Chat Flutter SDK, follow these steps: - -**Local Setup** +--- -Clone the repository: +## What You Can Contribute - ```sh - git clone https://github.com/your-username/Rocket_Chat_FlutterSDK.git - ``` +This is an official **Rocket.Chat Flutter SDK** with a monorepo structure using **Melos**. It contains two main packages: -Congratulations. 🎉. You've successfully cloned our repository, and you are ready to make your first contribution. Before you can start making code changes, there are a few things to configure. +### Packages: +1. **rocket_chat_api** - REST API wrapper for Rocket.Chat +2. **rocket_chat_embeddedchat_component** - Embedded chat UI component -**Melos Setup** +### Current State: +- **rocket_chat_api**: Contains placeholder code (needs full REST API implementation) +- **rocket_chat_embeddedchat_component**: Has basic models and services implemented -RCFlutterSDK uses `Melos` to manage our mono-repository. For those unfamiliar, Melos is used to split up large code bases into separate independently versioned packages. To install Melos, developers can run the following command: +--- -```bash -pub global activate melos -``` - -Once activated, users can now "bootstrap" their local clone by running the following: - -```bash -melos bootstrap -``` +## Contribution Ideas -Bootstrap will automatically fetch and link dependencies for all packages in the repository. It is the Melos equivalent of running `flutter pub get`. +### High Priority: +1. **Complete rocket_chat_api** - Implement REST API wrapper methods: + - Authentication (login, logout, refresh token) + - Channels/Rooms (create, list, info, members) + - Messages (send, delete, update, reactions) + - Users (info, presence, status) + - Upload/Download files -You're all set, Happy coding! +2. **Add WebSocket support** - Real-time messaging via Rocket.Chat WebSocket API -## Building the Project +3. **Add more UI components**: + - Room list view + - User avatar components + - Message reactions UI + - File attachment handling + - Typing indicators + - Message editing + - Thread support -When building a package, you will not receive a standalone output file like an APK or an app bundle, as the package is meant to be used as a dependency in other Flutter projects. **Ensure that the package is built without any errors and can be easily imported and used in other projects** using following command: +4. **Add state management** - Provider/Riverpod/Bloc integration -1. Install the required dependencies: +5. **Add tests** - Unit and widget tests - ```bash - melos bootstrap - ``` +### Medium Priority: +- Error handling improvements +- Offline support +- Caching layer +- Pagination helpers +- Input validation -2. Run test with melos, hit: +--- - ```bash - melos run test - ``` +## How to Contribute -3. Analyze code with melos, hit: +### Step 1: Fork the Repository - ```bash - melos run analyze - ``` +1. Go to [Rocket.Chat Flutter SDK](https://github.com/RocketChat/Rocket.Chat.Flutter.SDK) +2. Click the **Fork** button in the top-right corner +3. Select your GitHub account to create the fork -4. Compile and dry-run publish with Melos, hit: +### Step 2: Clone Your Fork - ```bash - melos run publish - ``` +``` +bash +# Clone your forked repository +git clone https://github.com/aburiro/Rocket.Chat.Flutter.SDK.git -### Build Artifacts +# Navigate to the project directory +cd Rocket.Chat.Flutter.SDK +``` -For the sample apps included in each package, you can build them as you would with any other Flutter application, and you'll get an output file (e.g., APK for Android) after the build process is completed. +### Step 3: Set Up Development Environment -To build a sample package: +``` +bash +# Install Melos globally +pub global activate melos -1. Navigate to the app's root directory +# Bootstrap the project (installs all dependencies) +melos bootstrap - ```sh - cd rocket_chat_api/sample_app - ``` - or - ```sh - cd rocket_chat_embeddedchat_component/sample_app - ``` -2. Get all dependencies +# Verify the setup by running tests +melos run test - ```bash - flutter pub get - ``` +# Analyze code for issues +melos run analyze +``` -3. Run: - ```bash - flutter build - ``` +### Step 4: Create a Feature Branch -Replace `` with the platform you are targeting (e.g., `apk`, `ios`, `web`). -After building, the artifacts for each sample apps will be located in the following directories: +``` +bash +# Create a new branch with a descriptive name +# -- `rocket_chat_embeddedchat_component`: `packages/rocket_chat_embeddedchat_component/sample_app/build/outputs` -- `rocket_chat_api`: `packages/rocket_chat_api/sample_app/build/outputs` +``` -### Error Logs +### Step 5: Make Your Changes -If the build process encounters any errors, check the terminal or debug console in your development environment (e.g., VSCode) for error messages and stack traces. +1. Implement your feature or fix +2. Write tests for your changes +3. Follow the coding guidelines: + - Follow [Effective Dart](https://dart.dev/guides/language/effective-dart) guidelines + - Keep functions and classes focused on single responsibility + - Comment complex logic + - Use meaningful names for variables and functions -## GitHub Actions CI +### Step 6: Commit Your Changes -The project is integrated with GitHub Actions CI, which automatically builds and tests the packages on every push and pull request. The CI configuration can be found in `.github/workflows/ci.yml`. +``` +bash +# Stage your changes +git add . -### CI Logs +# Commit with a descriptive message +git commit -m "Add: Implement authentication API methods -If the CI process fails, logs can be accessed from the "Actions" tab in the GitHub repository. Click on the specific run, and then select the failed job to view the logs. +- Added login, logout, and refreshToken methods +- Added proper error handling +- Added unit tests for authentication" +``` -## Project Structure +### Step 7: Push to Your Fork -- `.github`: GitHub files including issue templates, pull request templates, and GitHub Action scripts. +``` +bash +# Push your changes to your forked repository -- `packages/`: contains all the packages for Rocket Chat. - - `rocket_chat_api/`: contains the core package for Rocket Chat Flutter. - - `lib/`: contains the source code for the package. - - `test/`: contains unit, widget and integration tests for the package. - - `pubspec.yaml`: contains the package dependencies and other metadata. - - `sample_app`: contains the example Flutter application for the rocket_chat_api. +``` - - `rocket_chat_embeddedchat_component/`: contains the Flutter UI package for Rocket Chat. - - `lib/`: contains the source code for the package. - - `test/`: contains unit, widget and integration tests for the package. - - `pubspec.yaml`: contains the package dependencies and other metadata. - - `sample_app`: contains the example Flutter application for the rocket_chat_embeddedchat_component. -- `.gitignore`: Listing of files and file extensions ignored for this project. -- `README`: Project overview and usage guide. -- `LICENSE`: Legal. -- `melos.yaml`: Configuration file used to control [Melos](https://pub.dev/packages/melos), our mono-repo management tool of choice. +--- +## Creating a Professional Pull Request -## Bug Reports +### Step 1: Open Pull Request -When reporting a bug, please include the following information: +1. Go to your forked repository on GitHub +2. Click **Compare & pull request** +3. Or navigate to the original repository and click **New pull request** -- A clear and concise description of the issue. -- Steps to reproduce the problem. -- Any relevant logs or error messages. -- Information about your environment, such as the Flutter and Dart versions, OS, and device (if applicable). -- Possible solutions or workarounds, if you have any. +### Step 2: Write a Professional PR Description -Please create an issue using the "Bug report" template on GitHub. +Use this template: -## Feature Requests +``` +markdown +## Description +[Short description of what this PR does] + +## Type of Change +- [ ] Bug fix (non-breaking change which fixes an issue) +- [ ] New feature (non-breaking change which adds functionality) +- [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected) +- [ ] Documentation update + +## How Has This Been Tested? +[Describe the tests you ran to verify your changes] + +## Checklist: +- [ ] My code follows the style guidelines of this project +- [ ] I have performed a self-review of my code +- [ ] I have commented my code, particularly in hard-to-understand areas +- [ ] I have made corresponding changes to the documentation +- [ ] My changes generate no new warnings +- [ ] I have added tests that prove my fix is effective or that my feature works +- [ ] New and existing unit tests pass locally with my changes +- [ ] Any dependent changes have been merged and published in downstream modules -When requesting a new feature, please include the following information: -- A clear and concise description of the feature. -- An explanation of why the feature is useful and how it aligns with the project's goals. -- Any relevant examples or use cases. -- Possible implementation approaches or considerations, if you have any. +``` -Please create an issue using the "Feature request" template on GitHub. -## Pull Requests +## Setting Up Development Environment -Before submitting a pull request, please make sure that your changes adhere to the following guidelines: +### Prerequisites: +- Flutter SDK (latest stable version) +- Dart SDK +- Git +- A code editor (VS Code recommended) -- The code follows the project's coding guidelines and best practices. -- The code has been tested, and all tests pass. -- The code is well-documented, and any new features or changes are explained in the issue and PR comments. -- The PR is linked to a relevant issue, and the issue is referenced in the PR description. -- The PR title and description clearly describe the changes and their purpose. +### Build Commands: -## Coding Guidelines +``` +bash +# Install dependencies +melos bootstrap -- Follow the [Effective Dart](https://dart.dev/guides/language/effective-dart) guidelines. -- Adhere to the project's code style and linting rules. -- Write clean, maintainable, and modular code. -- Keep functions and classes focused on a single responsibility. -- Comment your code to explain complex or unusual logic. -- Use meaningful names for variables, functions, and classes. +# Run tests +melos run test -## Testing +# Analyze code +melos run analyze -- Write unit, integration, and widget tests for your code. -- Make sure all tests pass before submitting a pull request. -- Update or add new tests if you make changes to existing code or implement new features. -- Follow the project's testing best practices and conventions. +# Build example app +cd packages/rocket_chat_embeddedchat_component/example +flutter build apk +``` -## Troubleshooting +--- -If you encounter any issues during the build process or while using the package, follow these steps to troubleshoot: +## Code of Conduct -1. **Check the build logs**: Make sure to carefully examine the build logs in the terminal or your IDE's debug console for any error messages or warnings. -2. **Verify dependencies**: Double-check your `pubspec.yaml` file to ensure that all dependencies are listed and their versions are compatible. -3. **Flutter doctor**: Run `flutter doctor` to identify any potential issues with your Flutter installation or development environment. -4. **Clean and rebuild**: Sometimes, issues can be resolved by simply cleaning your project's build cache and rebuilding. Run `flutter clean` followed by build instruction to perform a clean rebuild of your project. -5. **Search for known issues**: Check the package's GitHub repository for any reported issues or ongoing discussions related to your problem. -6. **Ask for help**: If you're still having trouble, don't hesitate to reach out to the Rocket.Chat community or create a new issue on the GitHub repository. +By participating in this project, you agree to abide by the [Rocket.Chat Code of Conduct](https://github.com/RocketChat/Rocket.Chat/blob/develop/CODE_OF_CONDUCT.md). Please read and follow it to ensure a respectful and inclusive environment for all contributors. -For more specific guidance on troubleshooting, please provide detailed information about the issue you're facing, including error messages, logs, and any steps you've already taken to resolve the problem. +--- -## Community +## Need Help? -We value the contribution of each member of our community and encourage open and respectful communication. If you have any questions or need assistance, feel free to reach out in the project's communication channels, such as GitHub issues or the Rocket.Chat community. +- Check existing [Issues](https://github.com/RocketChat/Rocket.Chat.Flutter.SDK/issues) +- Check [Discussions](https://github.com/RocketChat/Rocket.Chat.Flutter.SDK/discussions) +- Join Rocket.Chat community Thank you for your interest in contributing to the Rocket.Chat Flutter SDK! Together, we can make this project better and help shape the future of Rocket.Chat and its integrations. diff --git a/packages/rocket_chat_embeddedchat_component/lib/rocket_chat_embeddedchat_component.dart b/packages/rocket_chat_embeddedchat_component/lib/rocket_chat_embeddedchat_component.dart index ec921f3..1b1f9e2 100644 --- a/packages/rocket_chat_embeddedchat_component/lib/rocket_chat_embeddedchat_component.dart +++ b/packages/rocket_chat_embeddedchat_component/lib/rocket_chat_embeddedchat_component.dart @@ -1,7 +1,10 @@ library rocket_chat_embeddedchat_component; -/// A Calculator. -class Calculator { - /// Returns [value] plus 1. - int addOne(int value) => value + 1; -} +// Models +export 'src/models/models.dart'; + +// Services +export 'src/services/services.dart'; + +// Widgets +export 'src/widgets/widgets.dart'; diff --git a/packages/rocket_chat_embeddedchat_component/lib/src/models/message.dart b/packages/rocket_chat_embeddedchat_component/lib/src/models/message.dart new file mode 100644 index 0000000..914683c --- /dev/null +++ b/packages/rocket_chat_embeddedchat_component/lib/src/models/message.dart @@ -0,0 +1,76 @@ +/// Represents a message in Rocket.Chat +class Message { + final String id; + final String roomId; + final String content; + final String senderId; + final String senderName; + final DateTime timestamp; + final MessageType type; + final Map? attachments; + + Message({ + required this.id, + required this.roomId, + required this.content, + required this.senderId, + required this.senderName, + required this.timestamp, + this.type = MessageType.text, + this.attachments, + }); + + factory Message.fromJson(Map json) { + return Message( + id: json['_id'] as String? ?? json['id'] as String? ?? '', + roomId: json['rid'] as String? ?? json['roomId'] as String? ?? '', + content: json['msg'] as String? ?? json['content'] as String? ?? '', + senderId: + json['sender']['_id'] as String? ?? json['senderId'] as String? ?? '', + senderName: json['sender']['name'] as String? ?? + json['sender']['username'] as String? ?? + json['senderName'] as String? ?? + 'Unknown', + timestamp: + DateTime.tryParse(json['ts'] as String? ?? '') ?? DateTime.now(), + type: _parseMessageType(json['t'] as String?), + attachments: json['attachments'] as Map?, + ); + } + + Map toJson() { + return { + '_id': id, + 'rid': roomId, + 'msg': content, + 'sender': { + '_id': senderId, + 'name': senderName, + }, + 'ts': timestamp.toIso8601String(), + 'type': type.name, + }; + } + + static MessageType _parseMessageType(String? type) { + switch (type) { + case 'image': + return MessageType.image; + case 'file': + return MessageType.file; + case 'system': + return MessageType.system; + default: + return MessageType.text; + } + } + + bool get isMine => senderId == 'currentUser'; +} + +enum MessageType { + text, + image, + file, + system, +} diff --git a/packages/rocket_chat_embeddedchat_component/lib/src/models/models.dart b/packages/rocket_chat_embeddedchat_component/lib/src/models/models.dart new file mode 100644 index 0000000..0ff18db --- /dev/null +++ b/packages/rocket_chat_embeddedchat_component/lib/src/models/models.dart @@ -0,0 +1,4 @@ +// Models barrel file +export 'message.dart'; +export 'user.dart'; +export 'room.dart'; diff --git a/packages/rocket_chat_embeddedchat_component/lib/src/models/room.dart b/packages/rocket_chat_embeddedchat_component/lib/src/models/room.dart new file mode 100644 index 0000000..4ae7a8d --- /dev/null +++ b/packages/rocket_chat_embeddedchat_component/lib/src/models/room.dart @@ -0,0 +1,79 @@ +/// Represents a chat room/channel in Rocket.Chat +class Room { + final String id; + final String name; + final String? description; + final RoomType type; + final String? topic; + final int memberCount; + final bool isReadOnly; + final bool isArchived; + final DateTime? lastMessageAt; + final Map? customFields; + + Room({ + required this.id, + required this.name, + this.description, + this.type = RoomType.channel, + this.topic, + this.memberCount = 0, + this.isReadOnly = false, + this.isArchived = false, + this.lastMessageAt, + this.customFields, + }); + + factory Room.fromJson(Map json) { + return Room( + id: json['_id'] as String? ?? json['id'] as String? ?? '', + name: json['name'] as String? ?? '', + description: json['description'] as String?, + type: _parseRoomType(json['t'] as String?), + topic: json['topic'] as String?, + memberCount: json['memberCount'] as int? ?? 0, + isReadOnly: json['ro'] as bool? ?? false, + isArchived: json['archived'] as bool? ?? false, + lastMessageAt: json['lastMessage'] != null + ? DateTime.tryParse(json['lastMessage']['ts'] as String? ?? '') + : null, + customFields: json['customFields'] as Map?, + ); + } + + Map toJson() { + return { + '_id': id, + 'name': name, + 'description': description, + 'type': type.name, + 'topic': topic, + 'memberCount': memberCount, + 'isReadOnly': isReadOnly, + 'isArchived': isArchived, + 'lastMessageAt': lastMessageAt?.toIso8601String(), + }; + } + + static RoomType _parseRoomType(String? type) { + switch (type) { + case 'c': + return RoomType.channel; + case 'p': + return RoomType.privateChannel; + case 'd': + return RoomType.directMessage; + case 'g': + return RoomType.group; + default: + return RoomType.channel; + } + } +} + +enum RoomType { + channel, + privateChannel, + directMessage, + group, +} diff --git a/packages/rocket_chat_embeddedchat_component/lib/src/models/user.dart b/packages/rocket_chat_embeddedchat_component/lib/src/models/user.dart new file mode 100644 index 0000000..166a3e0 --- /dev/null +++ b/packages/rocket_chat_embeddedchat_component/lib/src/models/user.dart @@ -0,0 +1,48 @@ +/// Represents a user in Rocket.Chat +class User { + final String id; + final String username; + final String? name; + final String? email; + final String? avatarUrl; + final bool isOnline; + final DateTime? lastSeen; + + User({ + required this.id, + required this.username, + this.name, + this.email, + this.avatarUrl, + this.isOnline = false, + this.lastSeen, + }); + + factory User.fromJson(Map json) { + return User( + id: json['_id'] as String? ?? json['id'] as String? ?? '', + username: json['username'] as String? ?? '', + name: json['name'] as String?, + email: json['email'] as String?, + avatarUrl: json['avatarUrl'] as String?, + isOnline: json['isOnline'] as bool? ?? json['status'] == 'online', + lastSeen: json['lastSeen'] != null + ? DateTime.tryParse(json['lastSeen'] as String) + : null, + ); + } + + Map toJson() { + return { + '_id': id, + 'username': username, + 'name': name, + 'email': email, + 'avatarUrl': avatarUrl, + 'isOnline': isOnline, + 'lastSeen': lastSeen?.toIso8601String(), + }; + } + + String get displayName => name ?? username; +} diff --git a/packages/rocket_chat_embeddedchat_component/lib/src/services/rocket_chat_api_service.dart b/packages/rocket_chat_embeddedchat_component/lib/src/services/rocket_chat_api_service.dart new file mode 100644 index 0000000..12029df --- /dev/null +++ b/packages/rocket_chat_embeddedchat_component/lib/src/services/rocket_chat_api_service.dart @@ -0,0 +1,246 @@ +import 'dart:convert'; +import 'package:http/http.dart' as http; +import '../models/models.dart'; + +/// Service class for communicating with Rocket.Chat REST API +class RocketChatApiService { + final String baseUrl; + final String? authToken; + final String? userId; + final http.Client _client; + + RocketChatApiService({ + required this.baseUrl, + this.authToken, + this.userId, + http.Client? client, + }) : _client = client ?? http.Client(); + + Map get _headers => { + 'Content-Type': 'application/json', + if (authToken != null) 'X-Auth-Token': authToken!, + if (userId != null) 'X-User-Id': userId!, + }; + + /// Login user with username and password + Future login(String user, String password) async { + try { + final response = await _client.post( + Uri.parse('$baseUrl/api/v1/login'), + headers: {'Content-Type': 'application/json'}, + body: jsonEncode({ + 'user': user, + 'password': password, + }), + ); + + if (response.statusCode == 200) { + final data = jsonDecode(response.body); + if (data['status'] == 'success') { + return AuthResponse.fromJson(data['data']); + } + throw ApiException(data['message'] ?? 'Login failed'); + } + throw ApiException('Login failed with status: ${response.statusCode}'); + } catch (e) { + if (e is ApiException) rethrow; + throw ApiException('Network error: $e'); + } + } + + /// Get room messages + Future> getMessages(String roomId, + {int limit = 50, int offset = 0}) async { + try { + final response = await _client.get( + Uri.parse( + '$baseUrl/api/v1/channels.messages?roomId=$roomId&count=$limit&offset=$offset'), + headers: _headers, + ); + + if (response.statusCode == 200) { + final data = jsonDecode(response.body); + if (data['success'] == true) { + final messages = (data['messages'] as List) + .map((m) => Message.fromJson(m)) + .toList(); + return messages; + } + throw ApiException(data['error'] ?? 'Failed to get messages'); + } + throw ApiException('Failed with status: ${response.statusCode}'); + } catch (e) { + if (e is ApiException) rethrow; + throw ApiException('Network error: $e'); + } + } + + /// Send a message to a room + Future sendMessage(String roomId, String content) async { + try { + final response = await _client.post( + Uri.parse('$baseUrl/api/v1/chat.sendMessage'), + headers: _headers, + body: jsonEncode({ + 'message': { + 'rid': roomId, + 'msg': content, + }, + }), + ); + + if (response.statusCode == 200) { + final data = jsonDecode(response.body); + if (data['success'] == true) { + return Message.fromJson(data['message']); + } + throw ApiException(data['error'] ?? 'Failed to send message'); + } + throw ApiException('Failed with status: ${response.statusCode}'); + } catch (e) { + if (e is ApiException) rethrow; + throw ApiException('Network error: $e'); + } + } + + /// Delete a message + Future deleteMessage(String messageId) async { + try { + final response = await _client.post( + Uri.parse('$baseUrl/api/v1/chat.delete'), + headers: _headers, + body: jsonEncode({ + 'roomId': messageId, + 'msgId': messageId, + }), + ); + + if (response.statusCode == 200) { + final data = jsonDecode(response.body); + return data['success'] == true; + } + return false; + } catch (e) { + throw ApiException('Network error: $e'); + } + } + + /// Get room information + Future getRoom(String roomId) async { + try { + final response = await _client.get( + Uri.parse('$baseUrl/api/v1/channels.info?roomId=$roomId'), + headers: _headers, + ); + + if (response.statusCode == 200) { + final data = jsonDecode(response.body); + if (data['success'] == true) { + return Room.fromJson(data['channel']); + } + throw ApiException(data['error'] ?? 'Failed to get room'); + } + throw ApiException('Failed with status: ${response.statusCode}'); + } catch (e) { + if (e is ApiException) rethrow; + throw ApiException('Network error: $e'); + } + } + + /// Get list of rooms/channels + Future> getRooms({int limit = 50, int offset = 0}) async { + try { + final response = await _client.get( + Uri.parse('$baseUrl/api/v1/rooms.list?count=$limit&offset=$offset'), + headers: _headers, + ); + + if (response.statusCode == 200) { + final data = jsonDecode(response.body); + if (data['success'] == true) { + return (data['rooms'] as List).map((r) => Room.fromJson(r)).toList(); + } + throw ApiException(data['error'] ?? 'Failed to get rooms'); + } + throw ApiException('Failed with status: ${response.statusCode}'); + } catch (e) { + if (e is ApiException) rethrow; + throw ApiException('Network error: $e'); + } + } + + /// Get user info + Future getUserInfo(String userId) async { + try { + final response = await _client.get( + Uri.parse('$baseUrl/api/v1/users.info?userId=$userId'), + headers: _headers, + ); + + if (response.statusCode == 200) { + final data = jsonDecode(response.body); + if (data['success'] == true) { + return User.fromJson(data['user']); + } + throw ApiException(data['error'] ?? 'Failed to get user'); + } + throw ApiException('Failed with status: ${response.statusCode}'); + } catch (e) { + if (e is ApiException) rethrow; + throw ApiException('Network error: $e'); + } + } + + /// Logout user + Future logout() async { + try { + final response = await _client.post( + Uri.parse('$baseUrl/api/v1/logout'), + headers: _headers, + ); + + if (response.statusCode == 200) { + final data = jsonDecode(response.body); + return data['success'] == true; + } + return false; + } catch (e) { + throw ApiException('Network error: $e'); + } + } + + void dispose() { + _client.close(); + } +} + +/// Auth response containing token and user info +class AuthResponse { + final String authToken; + final String userId; + final User user; + + AuthResponse({ + required this.authToken, + required this.userId, + required this.user, + }); + + factory AuthResponse.fromJson(Map json) { + return AuthResponse( + authToken: json['authToken'] as String? ?? json['token'] as String? ?? '', + userId: json['userId'] as String? ?? json['id'] as String? ?? '', + user: User.fromJson(json['user'] as Map? ?? {}), + ); + } +} + +/// Custom exception for API errors +class ApiException implements Exception { + final String message; + + ApiException(this.message); + + @override + String toString() => 'ApiException: $message'; +} diff --git a/packages/rocket_chat_embeddedchat_component/lib/src/services/services.dart b/packages/rocket_chat_embeddedchat_component/lib/src/services/services.dart new file mode 100644 index 0000000..b5d3563 --- /dev/null +++ b/packages/rocket_chat_embeddedchat_component/lib/src/services/services.dart @@ -0,0 +1,2 @@ +// Services barrel file +export 'rocket_chat_api_service.dart'; diff --git a/packages/rocket_chat_embeddedchat_component/lib/src/widgets/chat_input.dart b/packages/rocket_chat_embeddedchat_component/lib/src/widgets/chat_input.dart new file mode 100644 index 0000000..dbb81cb --- /dev/null +++ b/packages/rocket_chat_embeddedchat_component/lib/src/widgets/chat_input.dart @@ -0,0 +1,186 @@ +import 'package:flutter/material.dart'; +import '../models/models.dart'; + +/// Widget for the chat input field with reply support +class ChatInput extends StatefulWidget { + final void Function(String)? onSend; + final Message? repliedMessage; + final VoidCallback? onCancelReply; + + const ChatInput({ + super.key, + this.onSend, + this.repliedMessage, + this.onCancelReply, + }); + + @override + State createState() => _ChatInputState(); +} + +class _ChatInputState extends State { + final TextEditingController _controller = TextEditingController(); + final FocusNode _focusNode = FocusNode(); + bool _canSend = false; + + @override + void initState() { + super.initState(); + _controller.addListener(_onTextChanged); + } + + @override + void didUpdateWidget(ChatInput oldWidget) { + super.didUpdateWidget(oldWidget); + // Trigger rebuild when repliedMessage changes from outside + if (widget.repliedMessage != oldWidget.repliedMessage) { + setState(() {}); + } + } + + @override + void dispose() { + _controller.removeListener(_onTextChanged); + _controller.dispose(); + _focusNode.dispose(); + super.dispose(); + } + + void _onTextChanged() { + final canSend = _controller.text.trim().isNotEmpty; + if (canSend != _canSend) { + setState(() { + _canSend = canSend; + }); + } + } + + void _handleSend() { + final text = _controller.text.trim(); + if (text.isNotEmpty) { + widget.onSend?.call(text); + _controller.clear(); + _focusNode.requestFocus(); + } + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final hasReply = widget.repliedMessage != null; + + return Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: theme.colorScheme.surface, + boxShadow: [ + BoxShadow( + color: theme.shadowColor.withOpacity(0.1), + blurRadius: 4, + offset: const Offset(0, -2), + ), + ], + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + if (hasReply) _buildReplyPreview(theme), + Row( + children: [ + Expanded( + child: TextField( + controller: _controller, + focusNode: _focusNode, + decoration: InputDecoration( + hintText: 'Type a message...', + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(24), + borderSide: BorderSide.none, + ), + filled: true, + fillColor: theme.colorScheme.surfaceContainerHighest, + contentPadding: const EdgeInsets.symmetric( + horizontal: 20, + vertical: 12, + ), + ), + textInputAction: TextInputAction.send, + onSubmitted: (_) => _handleSend(), + maxLines: 4, + minLines: 1, + ), + ), + const SizedBox(width: 8), + AnimatedContainer( + duration: const Duration(milliseconds: 200), + child: IconButton.filled( + onPressed: _canSend ? _handleSend : null, + icon: const Icon(Icons.send), + style: IconButton.styleFrom( + backgroundColor: _canSend + ? theme.colorScheme.primary + : theme.colorScheme.surfaceContainerHighest, + foregroundColor: _canSend + ? theme.colorScheme.onPrimary + : theme.colorScheme.outline, + ), + ), + ), + ], + ), + ], + ), + ); + } + + Widget _buildReplyPreview(ThemeData theme) { + return Container( + margin: const EdgeInsets.only(bottom: 8), + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + decoration: BoxDecoration( + color: theme.colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(8), + border: Border( + left: BorderSide( + color: theme.colorScheme.primary, + width: 3, + ), + ), + ), + child: Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Replying to ${widget.repliedMessage?.senderName ?? "message"}', + style: theme.textTheme.labelSmall?.copyWith( + color: theme.colorScheme.primary, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 2), + Text( + widget.repliedMessage?.content ?? '', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ), + IconButton( + onPressed: widget.onCancelReply, + icon: const Icon(Icons.close), + iconSize: 18, + padding: EdgeInsets.zero, + constraints: const BoxConstraints(), + ), + ], + ), + ); + } +} diff --git a/packages/rocket_chat_embeddedchat_component/lib/src/widgets/chat_view.dart b/packages/rocket_chat_embeddedchat_component/lib/src/widgets/chat_view.dart new file mode 100644 index 0000000..c8fbe3c --- /dev/null +++ b/packages/rocket_chat_embeddedchat_component/lib/src/widgets/chat_view.dart @@ -0,0 +1,230 @@ +import 'package:flutter/material.dart'; +import '../models/models.dart'; +import '../services/services.dart'; +import 'message_bubble.dart'; +import 'chat_input.dart'; + +/// Main chat view widget that displays messages and input +class ChatView extends StatefulWidget { + final String roomId; + final String serverUrl; + final String authToken; + final String userId; + final void Function(String)? onMessageSent; + final void Function(Message)? onMessageReceived; + final Message? repliedMessage; + final void Function(Message?)? onReplyChanged; + + const ChatView({ + super.key, + required this.roomId, + required this.serverUrl, + required this.authToken, + required this.userId, + this.onMessageSent, + this.onMessageReceived, + this.repliedMessage, + this.onReplyChanged, + }); + + @override + State createState() => _ChatViewState(); +} + +class _ChatViewState extends State { + late RocketChatApiService _apiService; + final List _messages = []; + final ScrollController _scrollController = ScrollController(); + bool _isLoading = true; + String? _error; + Message? _repliedMessage; + + @override + void initState() { + super.initState(); + _repliedMessage = widget.repliedMessage; + _apiService = RocketChatApiService( + baseUrl: widget.serverUrl, + authToken: widget.authToken, + userId: widget.userId, + ); + _loadMessages(); + } + + @override + void didUpdateWidget(ChatView oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.repliedMessage != _repliedMessage) { + setState(() { + _repliedMessage = widget.repliedMessage; + }); + } + } + + @override + void dispose() { + _apiService.dispose(); + _scrollController.dispose(); + super.dispose(); + } + + Future _loadMessages() async { + try { + setState(() { + _isLoading = true; + _error = null; + }); + + final messages = await _apiService.getMessages(widget.roomId); + + setState(() { + _messages.clear(); + _messages.addAll(messages.reversed); + _isLoading = false; + }); + + _scrollToBottom(); + } catch (e) { + setState(() { + _error = e.toString(); + _isLoading = false; + }); + } + } + + void _scrollToBottom() { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (_scrollController.hasClients) { + _scrollController.animateTo( + _scrollController.position.maxScrollExtent, + duration: const Duration(milliseconds: 300), + curve: Curves.easeOut, + ); + } + }); + } + + Future _sendMessage(String content) async { + if (content.trim().isEmpty) return; + + try { + final message = await _apiService.sendMessage(widget.roomId, content); + setState(() { + _messages.add(message); + }); + widget.onMessageSent?.call(content); + widget.onReplyChanged?.call(null); + setState(() { + _repliedMessage = null; + }); + _scrollToBottom(); + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Failed to send message: $e')), + ); + } + } + } + + void _handleReply(Message? message) { + setState(() { + _repliedMessage = message; + }); + widget.onReplyChanged?.call(message); + } + + @override + Widget build(BuildContext context) { + return Column( + children: [ + Expanded( + child: _buildMessageList(), + ), + ChatInput( + onSend: _sendMessage, + repliedMessage: _repliedMessage, + onCancelReply: () => _handleReply(null), + ), + ], + ); + } + + Widget _buildMessageList() { + if (_isLoading) { + return const Center( + child: CircularProgressIndicator(), + ); + } + + if (_error != null) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + 'Error loading messages', + style: Theme.of(context).textTheme.titleMedium, + ), + const SizedBox(height: 8), + Text( + _error!, + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.bodySmall, + ), + const SizedBox(height: 16), + ElevatedButton( + onPressed: _loadMessages, + child: const Text('Retry'), + ), + ], + ), + ); + } + + if (_messages.isEmpty) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.chat_bubble_outline, + size: 64, + color: Theme.of(context).colorScheme.outline, + ), + const SizedBox(height: 16), + Text( + 'No messages yet', + style: Theme.of(context).textTheme.titleMedium?.copyWith( + color: Theme.of(context).colorScheme.outline, + ), + ), + const SizedBox(height: 8), + Text( + 'Send a message to start the conversation', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Theme.of(context).colorScheme.outline, + ), + ), + ], + ), + ); + } + + return ListView.builder( + controller: _scrollController, + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + itemCount: _messages.length, + itemBuilder: (context, index) { + final message = _messages[index]; + final isMine = message.senderId == widget.userId; + + return MessageBubble( + message: message, + isMine: isMine, + onReply: () => _handleReply(message), + ); + }, + ); + } +} diff --git a/packages/rocket_chat_embeddedchat_component/lib/src/widgets/message_bubble.dart b/packages/rocket_chat_embeddedchat_component/lib/src/widgets/message_bubble.dart new file mode 100644 index 0000000..2f0c803 --- /dev/null +++ b/packages/rocket_chat_embeddedchat_component/lib/src/widgets/message_bubble.dart @@ -0,0 +1,112 @@ +import 'package:flutter/material.dart'; +import 'package:intl/intl.dart'; +import '../models/models.dart'; + +/// Widget to display a single message bubble +class MessageBubble extends StatelessWidget { + final Message message; + final bool isMine; + final VoidCallback? onReply; + + const MessageBubble({ + super.key, + required this.message, + required this.isMine, + this.onReply, + }); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final dateFormat = DateFormat('HH:mm'); + + return Padding( + padding: const EdgeInsets.symmetric(vertical: 4), + child: Row( + mainAxisAlignment: + isMine ? MainAxisAlignment.end : MainAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + if (!isMine) ...[ + _buildAvatar(theme), + const SizedBox(width: 8), + ], + Flexible( + child: GestureDetector( + onLongPress: onReply, + child: Container( + constraints: BoxConstraints( + maxWidth: MediaQuery.of(context).size.width * 0.75, + ), + padding: + const EdgeInsets.symmetric(horizontal: 16, vertical: 10), + decoration: BoxDecoration( + color: isMine + ? theme.colorScheme.primary + : theme.colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.only( + topLeft: const Radius.circular(18), + topRight: const Radius.circular(18), + bottomLeft: Radius.circular(isMine ? 18 : 4), + bottomRight: Radius.circular(isMine ? 4 : 18), + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (!isMine) + Padding( + padding: const EdgeInsets.only(bottom: 4), + child: Text( + message.senderName, + style: theme.textTheme.labelSmall?.copyWith( + color: theme.colorScheme.primary, + fontWeight: FontWeight.bold, + ), + ), + ), + Text( + message.content, + style: theme.textTheme.bodyMedium?.copyWith( + color: isMine + ? theme.colorScheme.onPrimary + : theme.colorScheme.onSurface, + ), + ), + const SizedBox(height: 4), + Text( + dateFormat.format(message.timestamp), + style: theme.textTheme.labelSmall?.copyWith( + color: isMine + ? theme.colorScheme.onPrimary.withOpacity(0.7) + : theme.colorScheme.outline, + ), + ), + ], + ), + ), + ), + ), + if (isMine) const SizedBox(width: 8), + ], + ), + ); + } + + Widget _buildAvatar(ThemeData theme) { + final initials = message.senderName.isNotEmpty + ? message.senderName.substring(0, 1).toUpperCase() + : '?'; + + return CircleAvatar( + radius: 16, + backgroundColor: theme.colorScheme.secondaryContainer, + child: Text( + initials, + style: theme.textTheme.labelMedium?.copyWith( + color: theme.colorScheme.onSecondaryContainer, + ), + ), + ); + } +} diff --git a/packages/rocket_chat_embeddedchat_component/lib/src/widgets/widgets.dart b/packages/rocket_chat_embeddedchat_component/lib/src/widgets/widgets.dart new file mode 100644 index 0000000..8c89566 --- /dev/null +++ b/packages/rocket_chat_embeddedchat_component/lib/src/widgets/widgets.dart @@ -0,0 +1,4 @@ +// Widgets barrel file +export 'chat_view.dart'; +export 'message_bubble.dart'; +export 'chat_input.dart'; diff --git a/packages/rocket_chat_embeddedchat_component/pubspec.yaml b/packages/rocket_chat_embeddedchat_component/pubspec.yaml index 8fab00a..59c6f0f 100644 --- a/packages/rocket_chat_embeddedchat_component/pubspec.yaml +++ b/packages/rocket_chat_embeddedchat_component/pubspec.yaml @@ -1,6 +1,6 @@ name: rocket_chat_embeddedchat_component homepage: https://github.com/RocketChat/Rocket.Chat.Flutter.SDK -description: A new Flutter package project. +description: A Flutter package that provides a complete embedded chat UI component for Rocket.Chat. version: 0.0.1 environment: @@ -10,46 +10,12 @@ environment: dependencies: flutter: sdk: flutter - melos: ^3.0.1 + http: ^0.13.5 + intl: ^0.18.1 dev_dependencies: flutter_test: sdk: flutter flutter_lints: ^2.0.0 -# For information on the generic Dart part of this file, see the -# following page: https://dart.dev/tools/pub/pubspec - -# The following section is specific to Flutter packages. flutter: - - # To add assets to your package, add an assets section, like this: - # assets: - # - images/a_dot_burr.jpeg - # - images/a_dot_ham.jpeg - # - # For details regarding assets in packages, see - # https://flutter.dev/assets-and-images/#from-packages - # - # An image asset can refer to one or more resolution-specific "variants", see - # https://flutter.dev/assets-and-images/#resolution-aware - - # To add custom fonts to your package, add a fonts section here, - # in this "flutter" section. Each entry in this list should have a - # "family" key with the font family name, and a "fonts" key with a - # list giving the asset and other descriptors for the font. For - # example: - # fonts: - # - family: Schyler - # fonts: - # - asset: fonts/Schyler-Regular.ttf - # - asset: fonts/Schyler-Italic.ttf - # style: italic - # - family: Trajan Pro - # fonts: - # - asset: fonts/TrajanPro.ttf - # - asset: fonts/TrajanPro_Bold.ttf - # weight: 700 - # - # For details regarding fonts in packages, see - # https://flutter.dev/custom-fonts/#from-packages