Documentation

Flutter / Dart

The mobile SDK pattern lifted from the AppMint Go reference app.

The AppMint Go mobile app (appmint_go/appmint_mobile/) is the canonical Flutter integration. Its lib/services/ folder is a small, cleanly separated set of files you can copy into any Flutter project and own. This page documents the structure.

File layout

lib/
  config/
    app_config.dart           // storage keys, constants
    environment.dart          // env-resolved baseUrl
  services/
    http_client.dart          // singleton HTTP core, auth + 401 refresh
    storage_service.dart      // flutter_secure_storage + SharedPreferences
    api_service.dart          // auth flows
    repository_service.dart   // generic CRUD
    file_service.dart         // multipart uploads
    chat_service.dart         // domain service per AppEngine module
    ...                       // events, communications, etc.

Each service is a singleton that wraps the shared AppengineHttpClient.

Dependencies

Add to pubspec.yaml:

dependencies:
  http: ^1.0.0
  flutter_secure_storage: ^9.0.0
  shared_preferences: ^2.2.0

flutter_secure_storage keeps tokens in the iOS Keychain / Android Keystore. SharedPreferences is for non-sensitive settings (orgId, last-used email, theme).

HTTP client

One file owns connection setup, logging, the auth header, and 401-driven refresh.

// lib/services/http_client.dart (excerpt)
class AppengineHttpClient {
  static final AppengineHttpClient _instance = AppengineHttpClient._internal();
  factory AppengineHttpClient() => _instance;
  AppengineHttpClient._internal();

  String? _accessToken;
  String? _orgId;
  bool _refreshing = false;

  Future<String?> Function()? onTokenRefresh;
  VoidCallback? onForceLogout;

  String get baseUrl => EnvironmentConfig.appengineEndpoint;

  void setAuth(String accessToken, String orgId) {
    _accessToken = accessToken;
    _orgId = orgId;
  }

  Map<String, String> _buildHeaders({bool requireAuth = true}) {
    final headers = {
      'Content-Type': 'application/json',
      'Accept': 'application/json',
    };
    if (requireAuth) {
      if (_accessToken == null || _orgId == null) {
        throw Exception('Authentication required. Please login first.');
      }
      headers['Authorization'] = 'Bearer $_accessToken';
      headers['orgid'] = _orgId!;
    } else if (_orgId != null) {
      headers['orgid'] = _orgId!;
    }
    return headers;
  }

  Future<dynamic> request(String method, String path, {
    Map<String, dynamic>? body,
    Map<String, String>? queryParams,
    bool requireAuth = true,
  }) async {
    final uri = Uri.parse('$baseUrl$path').replace(queryParameters: queryParams);
    final headers = _buildHeaders(requireAuth: requireAuth);
    var response = await _executeRequest(method, uri, headers, body);

    // 401 -> refresh once, then force logout
    if (response.statusCode == 401 && requireAuth && !_refreshing) {
      _refreshing = true;
      if (onTokenRefresh != null) {
        final newToken = await onTokenRefresh!();
        if (newToken != null && newToken.isNotEmpty) {
          _accessToken = newToken;
          response = await _executeRequest(method, uri, _buildHeaders(), body);
        }
      }
      _refreshing = false;
      if (response.statusCode == 401) {
        onForceLogout?.call();
        throw Exception('Session expired. Please login again.');
      }
    }
    return _handleResponse(response);
  }

  Future<dynamic> get(String path, {Map<String, String>? queryParams, bool requireAuth = true}) =>
    request('GET', path, queryParams: queryParams, requireAuth: requireAuth);

  Future<dynamic> post(String path, {Map<String, dynamic>? body, bool requireAuth = true}) =>
    request('POST', path, body: body, requireAuth: requireAuth);

  Future<dynamic> put(String path, {Map<String, dynamic>? body, bool requireAuth = true}) =>
    request('PUT', path, body: body, requireAuth: requireAuth);

  Future<dynamic> delete(String path, {bool requireAuth = true}) =>
    request('DELETE', path, requireAuth: requireAuth);
}

final appengineHttpClient = AppengineHttpClient();

Two callbacks let an outer auth provider plug in: onTokenRefresh returns a new access token (the provider knows how to use the refresh token), and onForceLogout clears local state and routes to the login screen.

Storage

Tokens go in secure storage; non-sensitive settings go in shared prefs.

// lib/services/storage_service.dart (excerpt)
class StorageService {
  final _secureStorage = const FlutterSecureStorage();
  SharedPreferences? _prefs;

  Future<void> initialize() async {
    _prefs = await SharedPreferences.getInstance();
  }

  Future<void> saveTokens(String accessToken, String refreshToken) async {
    await _secureStorage.write(key: AppConfig.accessTokenKey, value: accessToken);
    await _secureStorage.write(key: AppConfig.refreshTokenKey, value: refreshToken);
  }

  Future<String?> getAccessToken() => _secureStorage.read(key: AppConfig.accessTokenKey);
  Future<String?> getRefreshToken() => _secureStorage.read(key: AppConfig.refreshTokenKey);

  Future<void> clearAll() async {
    await _secureStorage.delete(key: AppConfig.accessTokenKey);
    await _secureStorage.delete(key: AppConfig.refreshTokenKey);
    await _prefs!.clear();
  }
}

Auth flows

Login, register, magic-code, refresh — same shape as the JS client, just Dart.

// lib/services/api_service.dart (excerpt)
class ApiService {
  final StorageService _storage = StorageService();
  final AppengineHttpClient _http = appengineHttpClient;

  Future<void> initialize() async {
    final accessToken = await _storage.getAccessToken();
    final orgId = await _storage.getSetting('orgId');
    if (accessToken != null && orgId != null) {
      _http.setAuth(accessToken, orgId);
    }
  }

  Future<AuthResponse> login(String orgId, String email, String password) async {
    _http.setAuth('', orgId);
    final response = await _http.post(
      '/profile/user/signin',
      body: {'email': email, 'password': password},
      requireAuth: false,
    );
    final auth = AuthResponse.fromJson(response);
    _http.setAuth(auth.accessToken, orgId);
    await _storage.saveTokens(auth.accessToken, auth.refreshToken);
    await _storage.saveUser(auth.user);
    await _storage.saveSetting('orgId', orgId);
    return auth;
  }

  Future<AuthResponse> refreshToken() async {
    final refreshToken = await _storage.getRefreshToken();
    if (refreshToken == null) throw Exception('No refresh token available');
    final response = await _http.post(
      '/profile/user/refresh',
      body: {'refresh_token': refreshToken},
      requireAuth: false,
    );
    final auth = AuthResponse.fromJson(response);
    final orgId = await _storage.getSetting('orgId');
    if (orgId != null) _http.setAuth(auth.accessToken, orgId);
    await _storage.saveTokens(auth.accessToken, auth.refreshToken);
    return auth;
  }

  Future<void> logout() async {
    _http.clearAuth();
    await _storage.clearAll();
  }
}

Wire the refresh callback during app startup:

// lib/main.dart (sketch)
void main() async {
  WidgetsFlutterBinding.ensureInitialized();
  await StorageService().initialize();
  await ApiService().initialize();

  appengineHttpClient.onTokenRefresh = () async {
    try {
      final auth = await ApiService().refreshToken();
      return auth.accessToken;
    } catch (_) {
      return null;
    }
  };
  appengineHttpClient.onForceLogout = () {
    ApiService().logout();
    // navigate to /login
  };

  runApp(const MyApp());
}

Repository service

Generic CRUD over any datatype. Mirrors the JS repository-api.ts.

// lib/services/repository_service.dart (full file)
class RepositoryService {
  static final RepositoryService _instance = RepositoryService._internal();
  factory RepositoryService() => _instance;
  RepositoryService._internal();

  final AppengineHttpClient _http = appengineHttpClient;

  Future<Map<String, dynamic>> findData(String datatype, {
    Map<String, dynamic>? query,
    Map<String, dynamic>? options,
  }) async {
    return await _http.post('/repository/find/$datatype', body: {
      'query': query ?? {},
      'options': options ?? {'pageSize': 1000},
    });
  }

  Future<Map<String, dynamic>> getData(String datatype, String sk) async {
    return await _http.get('/repository/get/$datatype/$sk');
  }

  Future<Map<String, dynamic>> saveData(Map<String, dynamic> item) async {
    final sk = item['sk'];
    if (sk == null || sk.isEmpty) throw Exception('Item must have sk field');
    return await _http.post('/repository/update/$sk', body: item);
  }

  Future<void> deleteData(String datatype, String sk) async {
    await _http.delete('/repository/delete/$datatype/$sk');
  }

  Future<Map<String, dynamic>> search(String datatype, String keyword, {
    Map<String, dynamic>? query,
    Map<String, dynamic>? options,
  }) async {
    return await _http.post('/repository/search/$datatype', body: {
      'keyword': keyword,
      'query': query ?? {},
      'options': options ?? {'pageSize': 100},
    });
  }
}

final repositoryService = RepositoryService();

BaseModel unwrapping

Every list response shape is { data: BaseModel\<T\>[], total, hasNext }. The wrapper has pk, sk, version, plus your fields under data. Unwrap in your model factory:

class LeadModel {
  final String sk;
  final int version;
  final String email;
  final int score;

  LeadModel({required this.sk, required this.version, required this.email, required this.score});

  factory LeadModel.fromJson(Map<String, dynamic> json) {
    final inner = json['data'] as Map<String, dynamic>;
    return LeadModel(
      sk: json['sk'] as String,
      version: (json['version'] as num).toInt(),
      email: inner['email'] as String,
      score: (inner['score'] as num).toInt(),
    );
  }
}

// usage
final res = await repositoryService.findData('lead',
  query: {'data.score': {'\$gte': 70}}, options: {'pageSize': 50});
final list = (res['data'] as List)
  .map((j) => LeadModel.fromJson(j as Map<String, dynamic>))
  .toList();

Note the \$ escape in Dart string maps when sending Mongo operators — '\$gte', '\$in'. Without the escape, Dart treats $gte as string interpolation.

File uploads

Multipart uploads need a manual http.Request because http.post doesn't take multipart. Use the public auth-headers helper:

class FileService {
  final AppengineHttpClient _http = appengineHttpClient;

  Future<Map<String, dynamic>> upload(File file, {String location = ''}) async {
    final uri = Uri.parse('${_http.baseUrl}/repository/file/upload');
    final request = http.MultipartRequest('POST', uri);
    request.headers.addAll(_http.getAuthHeaders());
    request.fields['location'] = location;
    request.files.add(await http.MultipartFile.fromPath('file', file.path));

    final streamed = await request.send();
    final response = await http.Response.fromStream(streamed);
    if (response.statusCode >= 200 && response.statusCode < 300) {
      return jsonDecode(response.body) as Map<String, dynamic>;
    }
    throw Exception('Upload failed: ${response.statusCode}');
  }
}

For image uploads from the camera/gallery, pair with image_picker.

Domain services

Each AppEngine domain gets its own service file: chat_service.dart for WebSocket chat, events_service.dart for the events module, communications_service.dart for inbox messages, etc. They all share AppengineHttpClient and follow the RepositoryService shape — typed methods that delegate to _http.get/post/put/delete.

This pattern means a new domain integration is one file, ~50 lines.

What you don't need

  • No code generation. Models are hand-written fromJson.
  • No state-management coupling. Use Provider / Riverpod / Bloc — the services don't care.
  • No platform channels. Pure Dart over HTTP.

Lifting it to your project

  1. Copy lib/services/{http_client,storage_service,api_service,repository_service,file_service}.dart from appmint_mobile.
  2. Copy lib/config/{app_config,environment}.dart.
  3. Wire onTokenRefresh / onForceLogout in main().
  4. Set EnvironmentConfig.appengineEndpoint to point at your AppEngine instance.
  5. Add domain services as needed.

The whole client is under 1,000 lines. There is no "SDK package" to depend on — and no breaking-change risk because of it.