DEV Community

Nadim Chowdhury
Nadim Chowdhury

Posted on

Building a Modern Minimalist Chat App with Flutter: A Complete Guide

Look, I'll be honest with you. When I first decided to build a chat app with Flutter, I thought it would be straightforward. Just throw in some message bubbles, add a text field, and call it a day, right? Wrong. So wrong.

After countless hours of refactoring, restructuring, and yes, a few "why did I even start this" moments, I finally figured out what actually works. So grab your coffee (you'll need it), and let me walk you through building a chat app that doesn't just work, but looks clean and scales beautifully.

Why Flutter for a Chat App?

Before we dive into the technical stuff, let's talk about why Flutter makes sense here. Real-time updates, smooth animations, and that buttery 60fps scrolling through messages? Flutter handles it like a champ. Plus, you're building for iOS and Android simultaneously. That's just smart.

The Architecture: Clean Architecture (Because Your Future Self Will Thank You)

Here's the thing about chat apps: they get messy fast. You've got authentication, real-time messaging, media uploads, push notifications, and a bunch of other stuff. Without proper architecture, you'll end up with a spaghetti code nightmare.

I went with Clean Architecture mixed with BLoC pattern. Yeah, I know it sounds fancy, but it's actually pretty logical once you get it.

The Three Layers That'll Save Your Sanity

1. Presentation Layer - Your UI and state management live here. This is what users actually see and interact with.

2. Domain Layer - The business logic. Think of this as the brain of your app. It doesn't care about Flutter widgets or Firebase. It just knows what a "message" is and what you can do with it.

3. Data Layer - This talks to Firebase, local storage, APIs, whatever. It's the layer that gets its hands dirty with actual data fetching and storing.

The Folder Structure (Finally, Something Concrete)

Alright, let's get into the actual structure. This is what's worked for me:

lib/
├── core/
│   ├── constants/
│   │   ├── app_colors.dart
│   │   ├── app_strings.dart
│   │   └── app_dimensions.dart
│   ├── error/
│   │   ├── exceptions.dart
│   │   └── failures.dart
│   ├── network/
│   │   └── network_info.dart
│   ├── usecases/
│   │   └── usecase.dart
│   └── utils/
│       ├── date_formatter.dart
│       └── validators.dart
│
├── features/
│   ├── auth/
│   │   ├── data/
│   │   │   ├── datasources/
│   │   │   │   ├── auth_remote_datasource.dart
│   │   │   │   └── auth_local_datasource.dart
│   │   │   ├── models/
│   │   │   │   └── user_model.dart
│   │   │   └── repositories/
│   │   │       └── auth_repository_impl.dart
│   │   ├── domain/
│   │   │   ├── entities/
│   │   │   │   └── user_entity.dart
│   │   │   ├── repositories/
│   │   │   │   └── auth_repository.dart
│   │   │   └── usecases/
│   │   │       ├── login_usecase.dart
│   │   │       ├── signup_usecase.dart
│   │   │       └── logout_usecase.dart
│   │   └── presentation/
│   │       ├── bloc/
│   │       │   ├── auth_bloc.dart
│   │       │   ├── auth_event.dart
│   │       │   └── auth_state.dart
│   │       ├── pages/
│   │       │   ├── login_page.dart
│   │       │   └── signup_page.dart
│   │       └── widgets/
│   │           ├── custom_text_field.dart
│   │           └── auth_button.dart
│   │
│   ├── chat/
│   │   ├── data/
│   │   │   ├── datasources/
│   │   │   │   ├── chat_remote_datasource.dart
│   │   │   │   └── chat_local_datasource.dart
│   │   │   ├── models/
│   │   │   │   ├── message_model.dart
│   │   │   │   └── conversation_model.dart
│   │   │   └── repositories/
│   │   │       └── chat_repository_impl.dart
│   │   ├── domain/
│   │   │   ├── entities/
│   │   │   │   ├── message_entity.dart
│   │   │   │   └── conversation_entity.dart
│   │   │   ├── repositories/
│   │   │   │   └── chat_repository.dart
│   │   │   └── usecases/
│   │   │       ├── send_message_usecase.dart
│   │   │       ├── get_messages_usecase.dart
│   │   │       └── delete_message_usecase.dart
│   │   └── presentation/
│   │       ├── bloc/
│   │       │   ├── chat_bloc.dart
│   │       │   ├── chat_event.dart
│   │       │   └── chat_state.dart
│   │       ├── pages/
│   │       │   ├── conversations_page.dart
│   │       │   └── chat_page.dart
│   │       └── widgets/
│   │           ├── message_bubble.dart
│   │           ├── message_input.dart
│   │           └── conversation_tile.dart
│   │
│   ├── profile/
│   │   └── ... (similar structure)
│   │
│   └── media/
│       └── ... (for handling images, videos, files)
│
├── config/
│   ├── routes/
│   │   └── app_routes.dart
│   └── theme/
│       ├── app_theme.dart
│       └── text_styles.dart
│
└── main.dart
Enter fullscreen mode Exit fullscreen mode

Yeah, it looks like a lot. But trust me, this structure has saved me countless times when I needed to find something or add a new feature.

The Design System: Keep It Simple, Stupid

For a minimalist UI, you need consistency. Here's what I did:

Color Palette (Less is More)

I stuck to just a few colors. Seriously, you don't need 47 shades of blue.

// core/constants/app_colors.dart
class AppColors {
  // Primary colors
  static const primary = Color(0xFF6C63FF);
  static const primaryDark = Color(0xFF5A52E0);

  // Neutral colors
  static const background = Color(0xFFF8F9FA);
  static const surface = Color(0xFFFFFFFF);
  static const textPrimary = Color(0xFF2D3436);
  static const textSecondary = Color(0xFF636E72);
  static const divider = Color(0xFFE8EAED);

  // Functional colors
  static const success = Color(0xFF00B894);
  static const error = Color(0xFFFF6B6B);
  static const online = Color(0xFF00B894);
}
Enter fullscreen mode Exit fullscreen mode

Typography (Readable = Good)

Don't get fancy with fonts. Pick one good font family (I went with Inter) and stick with it.

// config/theme/text_styles.dart
class AppTextStyles {
  static const _baseFont = 'Inter';

  static const h1 = TextStyle(
    fontFamily: _baseFont,
    fontSize: 32,
    fontWeight: FontWeight.w700,
    height: 1.25,
  );

  static const h2 = TextStyle(
    fontFamily: _baseFont,
    fontSize: 24,
    fontWeight: FontWeight.w600,
    height: 1.3,
  );

  static const body = TextStyle(
    fontFamily: _baseFont,
    fontSize: 16,
    fontWeight: FontWeight.w400,
    height: 1.5,
  );

  static const caption = TextStyle(
    fontFamily: _baseFont,
    fontSize: 12,
    fontWeight: FontWeight.w400,
    height: 1.4,
  );
}
Enter fullscreen mode Exit fullscreen mode

The Message Bubble: Where Design Meets Function

Let me show you a message bubble that actually looks good and performs well:

// features/chat/presentation/widgets/message_bubble.dart
class MessageBubble extends StatelessWidget {
  final Message message;
  final bool isMe;

  const MessageBubble({
    Key? key,
    required this.message,
    required this.isMe,
  }) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return Align(
      alignment: isMe ? Alignment.centerRight : Alignment.centerLeft,
      child: Container(
        margin: EdgeInsets.only(
          top: 4,
          bottom: 4,
          left: isMe ? 64 : 16,
          right: isMe ? 16 : 64,
        ),
        padding: const EdgeInsets.symmetric(
          horizontal: 16,
          vertical: 10,
        ),
        decoration: BoxDecoration(
          color: isMe ? AppColors.primary : AppColors.surface,
          borderRadius: BorderRadius.only(
            topLeft: const Radius.circular(20),
            topRight: const Radius.circular(20),
            bottomLeft: Radius.circular(isMe ? 20 : 4),
            bottomRight: Radius.circular(isMe ? 4 : 20),
          ),
          boxShadow: [
            BoxShadow(
              color: Colors.black.withOpacity(0.05),
              blurRadius: 5,
              offset: const Offset(0, 2),
            ),
          ],
        ),
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.start,
          children: [
            Text(
              message.text,
              style: AppTextStyles.body.copyWith(
                color: isMe ? Colors.white : AppColors.textPrimary,
              ),
            ),
            const SizedBox(height: 4),
            Text(
              _formatTime(message.timestamp),
              style: AppTextStyles.caption.copyWith(
                color: isMe 
                  ? Colors.white.withOpacity(0.7) 
                  : AppColors.textSecondary,
              ),
            ),
          ],
        ),
      ),
    );
  }

  String _formatTime(DateTime time) {
    return '${time.hour}:${time.minute.toString().padLeft(2, '0')}';
  }
}
Enter fullscreen mode Exit fullscreen mode

State Management: BLoC for the Win

I tried Provider first. Then Riverpod. But for a chat app with complex state, BLoC just made more sense. Here's a simplified chat BLoC:

// features/chat/presentation/bloc/chat_bloc.dart
class ChatBloc extends Bloc<ChatEvent, ChatState> {
  final GetMessagesUseCase getMessages;
  final SendMessageUseCase sendMessage;

  StreamSubscription? _messagesSubscription;

  ChatBloc({
    required this.getMessages,
    required this.sendMessage,
  }) : super(ChatInitial()) {
    on<LoadMessages>(_onLoadMessages);
    on<SendMessage>(_onSendMessage);
    on<MessagesUpdated>(_onMessagesUpdated);
  }

  Future<void> _onLoadMessages(
    LoadMessages event,
    Emitter<ChatState> emit,
  ) async {
    emit(ChatLoading());

    final result = await getMessages(event.conversationId);

    result.fold(
      (failure) => emit(ChatError(failure.message)),
      (messagesStream) {
        _messagesSubscription = messagesStream.listen(
          (messages) => add(MessagesUpdated(messages)),
        );
      },
    );
  }

  Future<void> _onSendMessage(
    SendMessage event,
    Emitter<ChatState> emit,
  ) async {
    await sendMessage(SendMessageParams(
      conversationId: event.conversationId,
      text: event.text,
    ));
  }

  void _onMessagesUpdated(
    MessagesUpdated event,
    Emitter<ChatState> emit,
  ) {
    emit(ChatLoaded(event.messages));
  }

  @override
  Future<void> close() {
    _messagesSubscription?.cancel();
    return super.close();
  }
}
Enter fullscreen mode Exit fullscreen mode

The Database Layer: Firebase Firestore Setup

For real-time messaging, Firestore is honestly hard to beat. Here's how I structured it:

Collections Structure:

users/
  {userId}/
    - name: string
    - photoUrl: string
    - status: string (online/offline)
    - lastSeen: timestamp

conversations/
  {conversationId}/
    - participants: array<userId>
    - lastMessage: string
    - lastMessageTime: timestamp
    - createdAt: timestamp

    messages/ (subcollection)
      {messageId}/
        - senderId: string
        - text: string
        - timestamp: timestamp
        - read: boolean
        - type: string (text/image/file)
Enter fullscreen mode Exit fullscreen mode

And here's the data source implementation:

// features/chat/data/datasources/chat_remote_datasource.dart
class ChatRemoteDataSourceImpl implements ChatRemoteDataSource {
  final FirebaseFirestore firestore;

  ChatRemoteDataSourceImpl(this.firestore);

  @override
  Stream<List<MessageModel>> getMessages(String conversationId) {
    return firestore
        .collection('conversations')
        .doc(conversationId)
        .collection('messages')
        .orderBy('timestamp', descending: true)
        .limit(50)
        .snapshots()
        .map((snapshot) {
      return snapshot.docs
          .map((doc) => MessageModel.fromFirestore(doc))
          .toList();
    });
  }

  @override
  Future<void> sendMessage(MessageModel message, String conversationId) async {
    final batch = firestore.batch();

    // Add message
    final messageRef = firestore
        .collection('conversations')
        .doc(conversationId)
        .collection('messages')
        .doc();

    batch.set(messageRef, message.toFirestore());

    // Update conversation's last message
    final conversationRef = firestore
        .collection('conversations')
        .doc(conversationId);

    batch.update(conversationRef, {
      'lastMessage': message.text,
      'lastMessageTime': message.timestamp,
    });

    await batch.commit();
  }
}
Enter fullscreen mode Exit fullscreen mode

The Input Field: Smooth as Butter

The message input needs to feel good. Here's one that actually works:

// features/chat/presentation/widgets/message_input.dart
class MessageInput extends StatefulWidget {
  final Function(String) onSendMessage;

  const MessageInput({Key? key, required this.onSendMessage}) : super(key: key);

  @override
  State<MessageInput> createState() => _MessageInputState();
}

class _MessageInputState extends State<MessageInput> {
  final _controller = TextEditingController();
  bool _hasText = false;

  @override
  void initState() {
    super.initState();
    _controller.addListener(_onTextChanged);
  }

  void _onTextChanged() {
    setState(() {
      _hasText = _controller.text.trim().isNotEmpty;
    });
  }

  void _handleSend() {
    if (_hasText) {
      widget.onSendMessage(_controller.text.trim());
      _controller.clear();
    }
  }

  @override
  Widget build(BuildContext context) {
    return Container(
      padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
      decoration: BoxDecoration(
        color: AppColors.surface,
        boxShadow: [
          BoxShadow(
            color: Colors.black.withOpacity(0.05),
            blurRadius: 10,
            offset: const Offset(0, -2),
          ),
        ],
      ),
      child: Row(
        children: [
          Expanded(
            child: Container(
              padding: const EdgeInsets.symmetric(horizontal: 16),
              decoration: BoxDecoration(
                color: AppColors.background,
                borderRadius: BorderRadius.circular(24),
              ),
              child: TextField(
                controller: _controller,
                decoration: const InputDecoration(
                  hintText: 'Type a message...',
                  border: InputBorder.none,
                ),
                maxLines: null,
                textCapitalization: TextCapitalization.sentences,
                onSubmitted: (_) => _handleSend(),
              ),
            ),
          ),
          const SizedBox(width: 12),
          GestureDetector(
            onTap: _hasText ? _handleSend : null,
            child: Container(
              width: 48,
              height: 48,
              decoration: BoxDecoration(
                color: _hasText ? AppColors.primary : AppColors.divider,
                shape: BoxShape.circle,
              ),
              child: Icon(
                Icons.send_rounded,
                color: _hasText ? Colors.white : AppColors.textSecondary,
                size: 20,
              ),
            ),
          ),
        ],
      ),
    );
  }

  @override
  void dispose() {
    _controller.dispose();
    super.dispose();
  }
}
Enter fullscreen mode Exit fullscreen mode

Performance Tips That Actually Matter

Here's what I learned the hard way:

1. Pagination is Not Optional - Don't load all messages at once. Load 20-30 at a time and fetch more as users scroll up.

2. Image Optimization - Use cached_network_image package and compress images before uploading. Your users on slow connections will thank you.

3. Debounce Typing Indicators - If you're showing "User is typing...", debounce that indicator. You don't want to spam Firestore every single keystroke.

Timer? _typingTimer;

void _onTyping() {
  _typingTimer?.cancel();
  // Send typing started
  chatRepository.setTypingStatus(conversationId, true);

  _typingTimer = Timer(const Duration(seconds: 2), () {
    // Send typing stopped
    chatRepository.setTypingStatus(conversationId, false);
  });
}
Enter fullscreen mode Exit fullscreen mode

4. Use Keys for List Items - Flutter needs to know which message is which when you're adding new ones to a list. Use unique keys.

ListView.builder(
  itemBuilder: (context, index) {
    final message = messages[index];
    return MessageBubble(
      key: ValueKey(message.id), // This is important!
      message: message,
      isMe: message.senderId == currentUserId,
    );
  },
)
Enter fullscreen mode Exit fullscreen mode

Dependency Injection: GetIt Saves the Day

Managing dependencies can get messy. I use GetIt:

// main.dart
final getIt = GetIt.instance;

void setupDependencies() {
  // External
  getIt.registerLazySingleton(() => FirebaseFirestore.instance);
  getIt.registerLazySingleton(() => FirebaseAuth.instance);

  // Data sources
  getIt.registerLazySingleton<ChatRemoteDataSource>(
    () => ChatRemoteDataSourceImpl(getIt()),
  );

  // Repositories
  getIt.registerLazySingleton<ChatRepository>(
    () => ChatRepositoryImpl(remoteDataSource: getIt()),
  );

  // Use cases
  getIt.registerLazySingleton(() => GetMessagesUseCase(getIt()));
  getIt.registerLazySingleton(() => SendMessageUseCase(getIt()));

  // BLoC
  getIt.registerFactory(
    () => ChatBloc(
      getMessages: getIt(),
      sendMessage: getIt(),
    ),
  );
}
Enter fullscreen mode Exit fullscreen mode

Testing: Because Bugs Are Not Features

I know, I know. Testing isn't sexy. But when your chat app crashes at 2 AM and users are mad, you'll wish you had tests.

Here's a simple unit test for the SendMessageUseCase:

// test/features/chat/domain/usecases/send_message_test.dart
void main() {
  late SendMessageUseCase usecase;
  late MockChatRepository mockRepository;

  setUp(() {
    mockRepository = MockChatRepository();
    usecase = SendMessageUseCase(mockRepository);
  });

  test('should send message through repository', () async {
    // arrange
    when(mockRepository.sendMessage(any, any))
        .thenAnswer((_) async => Right(null));

    final params = SendMessageParams(
      conversationId: 'conv123',
      text: 'Hello!',
    );

    // act
    final result = await usecase(params);

    // assert
    expect(result, Right(null));
    verify(mockRepository.sendMessage(any, params.conversationId));
    verifyNoMoreInteractions(mockRepository);
  });
}
Enter fullscreen mode Exit fullscreen mode

The Things I Wish Someone Told Me

Don't Overthink the UI - Seriously. WhatsApp's UI hasn't changed much in years because it works. Start simple, get feedback, iterate.

Handle Offline Mode - People lose connection. Cache messages locally using Hive or sqflite. Let them see old messages even when offline.

Error States Matter - Show meaningful error messages. "Something went wrong" helps no one. "Couldn't send message. Check your connection." is better.

Empty States Too - That first conversation screen shouldn't just be blank. Add a nice illustration and some encouraging text.

Animations Should Be Quick - 300ms max for most animations. Anything longer feels sluggish.

Wrapping Up

Building a chat app is genuinely challenging, but it's also incredibly rewarding when you see people actually using it to communicate. The architecture I've laid out here isn't perfect (nothing is), but it's solid enough to build on and won't fall apart when you add features.

Start with the basics: authentication, send a message, receive a message. Get that working smoothly. Then add the fancy stuff like read receipts, typing indicators, and media sharing.

And remember, the best code is code that works and that you can understand when you come back to it six months later. Keep it clean, keep it simple, and you'll be fine.

Now go build something cool. And maybe grab that second cup of coffee.


👇 If you enjoyed reading this, you can support my work by buying me a coffee
Buy Me A Coffee

Top comments (0)