All posts

Flutter Address Autocomplete for Vietnam (suggest + resolve)

Build a Vietnam address input in Flutter: debounce, call /v1/suggest for predictions and /v1/place/resolve for coordinates, proxy to hide your API key — with full Dart code.

Almost every delivery, ride-hailing, or at-home services app in Vietnam needs a smart address input: the user types a few characters, the app suggests the right address, and once they pick one you immediately have the coordinates to drop a pin or compute a route. This post builds that input in Flutter, pairing two GoGoDuk endpoints — /v1/suggest for predictions and /v1/place/resolve to get clean coordinates for the chosen address — with runnable Dart code.

If you work on the web in React/Next.js, see the same architecture in Address autocomplete component for React/Next.js. This post is the mobile/Dart version of the same idea.

Why Vietnamese mobile apps need their own address autocomplete

Vietnamese addresses are written many ways: with or without diacritics, "Q.1" or "Quận 1", "P. Bến Nghé", house numbers with alleys. A good address input has to match those variants and return a standardized form. Building an address list inside the app is practically impossible; calling a geocoding API tuned for Vietnam is far simpler.

The common mistake is to put a TextField and call the API directly on every keystroke. Three frequent problems:

  • Leaked API key. Embedding X-API-Key directly in the Dart app means anyone who decompiles the APK/IPA or inspects traffic can grab your key.
  • No debounce. Fast typing fires a storm of racing requests, results jump around, and you burn quota.
  • No billing session. Each keystroke is a separate request, never stitched into a single "type → pick" session.

Architecture: app → proxy → GoGoDuk

The golden rule for mobile: the API key never lives in the app. The app calls your own backend proxy; the proxy attaches X-API-Key and calls GoGoDuk.

Flutter app
  -> your backend proxy  /address/suggest   (adds X-API-Key)
       -> GoGoDuk  /v1/suggest        -> predictions
  (user picks a suggestion)
  -> your backend proxy  /address/resolve    (adds X-API-Key)
       -> GoGoDuk  /v1/place/resolve  -> coordinates + clean address

This is the same architecture the React post uses — only the client is Dart instead of JavaScript. The proxy can be any backend you already run (Node, Go, Firebase Cloud Functions…); it just forwards the query and attaches the key.

Step 1: a Dart client calling suggest through the proxy

/v1/suggest needs input of at least 2 characters, accepts lang (default vi), country (only VN), and optional focus.lat/focus.lon to prioritize results near a location. Each response returns a predictions array; every item has 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 {
  // YOUR proxy — do NOT point straight at api.gogoduk.com from the 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();
  }
}

The proxy simply forwards these query params to https://api.gogoduk.com/v1/suggest with an X-API-Key header. The logic is identical to the proxy route in the React post — just in your backend language.

Step 2: TextField + debounce + dropdown

In Flutter we wrap a TextField with a Timer to debounce ~300ms: only call suggest when the user pauses typing, avoiding a request per keystroke. A single sessionToken per session (type → pick) groups 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: 'Enter delivery address…',
            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);
              // Session ends on pick → rotate the token for the next round.
              _sessionToken = const Uuid().v4();
            },
          ),
        ),
      ],
    );
  }
}

Step 3: resolve a placeId into coordinates

predictions only carry a placeId, not coordinates yet. When the user picks a suggestion, call /v1/place/resolve with id set to that placeId (pass the same sessionToken to close the billing session). The result response has lat, lon, name, address, and district/city (may be 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);
}

Now inside AddressField's onSelected you have coordinates to drop a map pin, save to an order, or compute a distance-based shipping fee.

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

Production notes

  • Never embed the API key in the app. APK/IPA can be decompiled; a key in the binary is effectively public. Always go through a server-side proxy.
  • Group the billing session. Use one sessionToken for the whole "type → pick" session, then rotate after resolve. Suggest and resolve with the same token count as one session.
  • Debounce + cancel stale requests. 250–300ms is reasonable on mobile. Remember to cancel() the timer in dispose() to avoid setState after unmount.
  • focus.lat/focus.lon. If you already have the user's GPS, pass it to suggest to prioritize nearby addresses — noticeably better suggestions.
  • Handle errors & empties. Mobile networks are flaky; always branch for empty predictions and HTTP errors so the dropdown never hangs.

When to use the SDK vs raw calls

GoGoDuk ships a TypeScript SDK that wraps suggest and placeResolve — handy if your proxy backend is Node/TS. But the Dart app never calls GoGoDuk directly (it must hide the key), so the client always talks to your proxy over plain HTTP as above. In other words: the SDK (if used) lives in the proxy tier, and Flutter only needs http.

If your app needs to go from GPS coordinates to province/district/ward (e.g. a driver app), see reverse geocoding to identify province/district from coordinates and the overview What is geocoding. For how Vietnamese address autocomplete works under the hood, see Vietnam Address Autocomplete API.

Conclusion

A good address autocomplete in Flutter needs just three pieces: debounce for smooth typing, a proxy to hide the key, and the suggest + resolve pair to turn a few typed characters into clean coordinates. The architecture is identical to the web version, so if your team ships both web and mobile, the same backend proxy serves both.

Create a free API key at app.gogoduk.com to get started, and read the two endpoint references at suggest docs and place resolve docs. Questions, or want an architecture review? Join the dev community on Telegram.

Want to use GoGoDuk?

Free forever — 100 requests/day per account, no credit card. Higher limits on request.

Sign up →