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
- Copy
lib/services/{http_client,storage_service,api_service,repository_service,file_service}.dartfromappmint_mobile. - Copy
lib/config/{app_config,environment}.dart. - Wire
onTokenRefresh/onForceLogoutinmain(). - Set
EnvironmentConfig.appengineEndpointto point at your AppEngine instance. - 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.