Tất cả bài viết

Flutter: ô tự động hoàn thành địa chỉ Việt Nam (suggest + resolve)

Dựng ô nhập địa chỉ Việt Nam trong Flutter: debounce, gọi /v1/suggest gợi ý và /v1/place/resolve lấy toạ độ, proxy giấu API key — kèm code Dart đầy đủ.

Gần như mọi app giao hàng, đặt xe hay dịch vụ tại nhà ở Việt Nam đều cần một ô nhập địa chỉ thông minh: người dùng gõ vài chữ, app gợi ý đúng địa chỉ, và khi chọn xong bạn có ngay toạ độ để đặt ghim hay tính quãng đường. Bài này dựng ô đó trong Flutter, ghép hai endpoint của GoGoDuk — /v1/suggest để gợi ý và /v1/place/resolve để lấy toạ độ chuẩn cho địa chỉ người dùng chọn — với code Dart chạy được.

Nếu bạn làm web React/Next.js, xem bản đối chiếu cùng kiến trúc trong bài Component autocomplete địa chỉ cho React/Next.js. Bài này là phiên bản mobile/Dart của cùng ý tưởng.

Vì sao app mobile Việt Nam cần autocomplete địa chỉ riêng

Địa chỉ Việt Nam viết theo nhiều kiểu: có dấu, không dấu, "Q.1" hay "Quận 1", "P. Bến Nghé", số nhà kèm hẻm. Một ô tìm địa chỉ tốt phải khớp được những biến thể đó và trả về dạng chuẩn. Tự dựng danh sách địa chỉ trong app gần như bất khả thi; gọi một API geocoding tối ưu cho Việt Nam thì gọn hơn nhiều.

Cách nhiều người làm sai là đặt một TextField rồi gọi thẳng API mỗi lần gõ phím. Ba vấn đề hay gặp:

  • Lộ API key. Nhúng X-API-Key thẳng trong app Dart nghĩa là bất kỳ ai decompile APK/IPA hoặc xem traffic đều lấy được key của bạn.
  • Không debounce. Gõ nhanh tạo ra hàng loạt request đua nhau, kết quả nhảy loạn và tốn quota.
  • Không gộp phiên billing. Mỗi lần gõ là một request rời, không nối thành một phiên "gõ → chọn" duy nhất.

Kiến trúc: app → proxy → GoGoDuk

Quy tắc vàng cho mobile: API key không bao giờ nằm trong app. App gọi tới backend proxy của chính bạn; proxy mới gắn X-API-Key và gọi GoGoDuk.

Flutter app
  -> backend proxy của bạn  /address/suggest   (gắn X-API-Key)
       -> GoGoDuk  /v1/suggest        -> predictions
  (người dùng chọn 1 gợi ý)
  -> backend proxy của bạn  /address/resolve    (gắn X-API-Key)
       -> GoGoDuk  /v1/place/resolve  -> toạ độ + địa chỉ chuẩn

Đây cũng đúng kiến trúc bài React dùng — chỉ khác phần client là Dart thay vì JavaScript. Proxy có thể là bất kỳ backend nào bạn đang có (Node, Go, Firebase Cloud Functions…); nó chỉ cần forward query và đính kèm key.

Bước 1: client Dart gọi suggest qua proxy

Endpoint /v1/suggest cần input tối thiểu 2 ký tự, nhận lang (mặc định vi), country (chỉ VN), và tuỳ chọn focus.lat/focus.lon để ưu tiên kết quả gần một vị trí. Mỗi phản hồi trả về mảng predictions, mỗi phần tử có placeId, text, mainText, secondaryText.

import 'dart:convert';
import 'package:http/http.dart' as http;
 
class Prediction {
  final String placeId;
  final String mainText;
  final String secondaryText;
  Prediction({
    required this.placeId,
    required this.mainText,
    required this.secondaryText,
  });
  factory Prediction.fromJson(Map<String, dynamic> j) => Prediction(
        placeId: j['placeId'] as String,
        mainText: (j['mainText'] ?? j['text'] ?? '') as String,
        secondaryText: (j['secondaryText'] ?? '') as String,
      );
}
 
class AddressApi {
  // Proxy CỦA BẠN — KHÔNG trỏ thẳng api.gogoduk.com từ app.
  static const _base = 'https://your-backend.example.com';
 
  static Future<List<Prediction>> suggest(
    String input, {
    String? sessionToken,
  }) async {
    if (input.trim().length < 2) return [];
    final uri = Uri.parse('$_base/address/suggest').replace(
      queryParameters: {
        'input': input,
        'lang': 'vi',
        'country': 'VN',
        if (sessionToken != null) 'sessionToken': sessionToken,
      },
    );
    final res = await http.get(uri);
    if (res.statusCode != 200) return [];
    final data = jsonDecode(res.body) as Map<String, dynamic>;
    final list = (data['predictions'] as List?) ?? [];
    return list
        .map((e) => Prediction.fromJson(e as Map<String, dynamic>))
        .toList();
  }
}

Phía proxy chỉ cần forward các query này tới https://api.gogoduk.com/v1/suggest kèm header X-API-Key. Logic giống hệt route proxy trong bài React — chỉ là ngôn ngữ backend của bạn.

Bước 2: TextField + debounce + dropdown

Trong Flutter, ta bọc một TextField bằng một Timer để debounce ~300ms: chỉ gọi suggest khi người dùng ngừng gõ, tránh bắn request mỗi ký tự. Một sessionToken duy nhất cho mỗi phiên (gõ → chọn) giúp gộp billing.

import 'dart:async';
import 'package:flutter/material.dart';
import 'package:uuid/uuid.dart';
 
class AddressField extends StatefulWidget {
  final void Function(Prediction) onSelected;
  const AddressField({super.key, required this.onSelected});
  @override
  State<AddressField> createState() => _AddressFieldState();
}
 
class _AddressFieldState extends State<AddressField> {
  final _controller = TextEditingController();
  Timer? _debounce;
  List<Prediction> _results = [];
  String _sessionToken = const Uuid().v4();
 
  void _onChanged(String value) {
    _debounce?.cancel();
    _debounce = Timer(const Duration(milliseconds: 300), () async {
      final preds =
          await AddressApi.suggest(value, sessionToken: _sessionToken);
      if (mounted) setState(() => _results = preds);
    });
  }
 
  @override
  void dispose() {
    _debounce?.cancel();
    _controller.dispose();
    super.dispose();
  }
 
  @override
  Widget build(BuildContext context) {
    return Column(
      crossAxisAlignment: CrossAxisAlignment.start,
      children: [
        TextField(
          controller: _controller,
          onChanged: _onChanged,
          decoration: const InputDecoration(
            hintText: 'Nhập địa chỉ giao hàng…',
            border: OutlineInputBorder(),
          ),
        ),
        ..._results.map(
          (p) => ListTile(
            title: Text(p.mainText),
            subtitle: Text(p.secondaryText),
            onTap: () {
              _controller.text = '${p.mainText}, ${p.secondaryText}';
              setState(() => _results = []);
              widget.onSelected(p);
              // Phiên kết thúc khi đã chọn → đổi token cho lần gõ sau.
              _sessionToken = const Uuid().v4();
            },
          ),
        ),
      ],
    );
  }
}

Bước 3: resolve placeId thành toạ độ

predictions chỉ có placeId, chưa có toạ độ. Khi người dùng chọn một gợi ý, gọi /v1/place/resolve với idplaceId đó (truyền cùng sessionToken để đóng phiên billing). Phản hồi resultlat, lon, name, address, và district/city (có thể null).

class ResolvedPlace {
  final double lat;
  final double lon;
  final String address;
  ResolvedPlace({required this.lat, required this.lon, required this.address});
  factory ResolvedPlace.fromJson(Map<String, dynamic> r) => ResolvedPlace(
        lat: (r['lat'] as num).toDouble(),
        lon: (r['lon'] as num).toDouble(),
        address: (r['address'] ?? r['name'] ?? '') as String,
      );
}
 
Future<ResolvedPlace?> resolvePlace(
  String placeId, {
  String? sessionToken,
}) async {
  final uri = Uri.parse('https://your-backend.example.com/address/resolve')
      .replace(queryParameters: {
    'id': placeId,
    'lang': 'vi',
    if (sessionToken != null) 'sessionToken': sessionToken,
  });
  final res = await http.get(uri);
  if (res.statusCode != 200) return null;
  final data = jsonDecode(res.body) as Map<String, dynamic>;
  final result = data['result'] as Map<String, dynamic>?;
  if (result == null) return null;
  return ResolvedPlace.fromJson(result);
}

Bây giờ trong onSelected của AddressField, bạn có toạ độ để đặt ghim trên bản đồ, lưu vào đơn hàng, hay tính phí ship theo quãng đường.

AddressField(
  onSelected: (pred) async {
    final place = await resolvePlace(pred.placeId);
    if (place != null) {
      debugPrint('Toạ độ: ${place.lat}, ${place.lon}');
      // map.move(LatLng(place.lat, place.lon), 16); ...
    }
  },
)

Lưu ý khi đưa lên production

  • Tuyệt đối không nhúng API key trong app. APK/IPA decompile được; key trong binary coi như công khai. Luôn đi qua proxy server-side.
  • Gộp session billing. Dùng một sessionToken cho cả phiên "gõ → chọn", đổi token mới sau khi resolve. Suggest và resolve cùng token được tính là một phiên.
  • Debounce + huỷ request cũ. 250–300ms là hợp lý cho mobile. Nhớ cancel() timer trong dispose() để tránh setState sau khi widget đã unmount.
  • focus.lat/focus.lon. Nếu đã có GPS của người dùng, truyền vào suggest để ưu tiên địa chỉ gần họ — gợi ý chính xác hơn hẳn.
  • Xử lý lỗi & rỗng. Mạng mobile chập chờn; luôn có nhánh cho predictions rỗng và lỗi HTTP, đừng để dropdown treo.

Khi nào dùng SDK, khi nào gọi raw

GoGoDuk có SDK TypeScript bọc sẵn suggestplaceResolve — tiện nếu backend proxy của bạn viết bằng Node/TS. Nhưng phía app Dart thì không gọi GoGoDuk trực tiếp (vì phải giấu key), nên client luôn nói chuyện với proxy của bạn qua HTTP thuần như trên. Nói cách khác: SDK (nếu dùng) nằm ở tầng proxy, còn Flutter chỉ cần http.

Nếu app của bạn cần đi từ toạ độ GPS ra tỉnh/huyện/xã (ví dụ app tài xế), xem thêm reverse geocoding xác định tỉnh/huyện từ toạ độ và bài tổng quan Geocoding là gì. Còn về cách hệ thống autocomplete địa chỉ Việt Nam hoạt động, xem Vietnam Address Autocomplete API.

Kết luận

Một ô autocomplete địa chỉ tốt trong Flutter chỉ cần ba mảnh ghép: debounce để gõ mượt, proxy để giấu key, và cặp suggest + resolve để biến vài chữ người dùng gõ thành một toạ độ chuẩn. Kiến trúc giống hệt bản web, nên nếu team bạn làm cả web lẫn mobile thì cùng một backend proxy phục vụ được cả hai.

Tạo API key miễn phí ở app.gogoduk.com để bắt đầu, đọc chi tiết hai endpoint tại docs suggestdocs place resolve. Có thắc mắc hay cần review kiến trúc? Tham gia cộng đồng dev trên Telegram.

Muốn dùng GoGoDuk?

Miễn phí trọn đời — 100 request/ngày mỗi tài khoản, không cần thẻ tín dụng. Giới hạn cao hơn theo yêu cầu.

Đăng ký →