app_pigeon
app_pigeon is a Flutter networking and socket layer with two operation modes:
AuthorizedPigeon: token-based HTTP + auth persistence + refresh flow.GhostPigeon: lightweight HTTP + socket client for anonymous/guest/ghost use cases.
The package exposes a shared AppPigeon interface so app code can depend on one contract while switching implementations at runtime. It acts as your reusable API client foundation, instead of rebuilding the same networking layer from scratch for every new REST API project.
Capabilities
- Typed HTTP wrappers over
dio:get,post,put,patch,delete
- Realtime socket API:
socketInit,listen,emit
- Authorized mode auth persistence:
- secure local auth storage via
flutter_secure_storage - multiple account records
- current-account switching
- auth stream updates
- secure local auth storage via
- Authorized mode auto-refresh:
- interceptor-driven refresh flow on unauthorized responses
- refresh request queueing + replay of pending requests
- Ghost mode optional bearer support:
- ghost interceptor can attach bearer if you call
setAuthToken(...)
- ghost interceptor can attach bearer if you call
Install
Add to pubspec.yaml:
dependencies:
app_pigeon: ^<latest>
Exports
package:app_pigeon/app_pigeon.dart exports:
AppPigeon(interface)AuthorizedPigeonGhostPigeonSocketConnetParamXRefreshTokenManagerInterface,RefreshTokenResponsediotypesflutter_secure_storagetypes
Core Types
AppPigeon interface
The shared contract:
Future<Response> get/post/put/patch/delete(...)Future<void> socketInit(SocketConnetParamX param)Stream<dynamic> listen(String channelName)void emit(String eventName, [dynamic data])void dispose()
SocketConnetParamX
class SocketConnetParamX {
final String? token;
final String socketUrl;
final String joinId;
}
token is optional. In authorized mode, if token is null, current stored auth token is used.
Authorized Mode
Use AuthorizedPigeon when your API requires authentication and token refresh.
Setup
import 'package:app_pigeon/app_pigeon.dart';
class MyRefreshTokenManager implements RefreshTokenManagerInterface {
@override
final String url = '/auth/refresh';
@override
Future<bool> shouldRefresh(
DioException err,
ErrorInterceptorHandler handler,
) async {
return err.response?.statusCode == 401;
}
@override
Future<RefreshTokenResponse> refreshToken({
required String refreshToken,
required Dio dio,
}) async {
final res = await dio.post(
url,
data: {'refreshToken': refreshToken},
);
final data = res.data['data'] as Map<String, dynamic>;
return RefreshTokenResponse(
accessToken: data['accessToken'] as String,
refreshToken: data['refreshToken'] as String,
data: data,
);
}
}
final authorized = AuthorizedPigeon(
MyRefreshTokenManager(),
baseUrl: 'https://api.example.com',
);
Save login auth
await authorized.saveNewAuth(
saveAuthParams: SaveNewAuthParams(
uid: userId,
accessToken: accessToken,
refreshToken: refreshToken,
data: userData,
),
);
Listen auth state
authorized.authStream.listen((status) {
if (status is Authenticated) {
// signed in
} else if (status is UnAuthenticated) {
// signed out
}
});
Account operations
final current = await authorized.getCurrentAuthRecord();
final all = await authorized.getAllAuthRecords();
await authorized.switchAccount(uid: 'user_2');
await authorized.logOut();
Socket in authorized mode
await authorized.socketInit(
SocketConnetParamX(
token: null, // use stored token
socketUrl: 'https://socket.example.com',
joinId: 'global_chat',
),
);
authorized.listen('message').listen((event) {
// handle incoming message
});
authorized.emit('message', {'text': 'hello'});
Lifecycle notes
dispose()closes auth storage stream and socket.- If you only want to stop socket and keep auth storage alive, use:
authorized.disconnectSocket()
Ghost Mode
Use GhostPigeon for anonymous/ghost flows where full auth persistence is not needed.
Setup
final ghost = GhostPigeon(
baseUrl: 'https://api.example.com',
);
Optional token
ghost.setAuthToken('optional_token');
If set, ghost interceptor attaches Authorization: Bearer <token> on requests.
HTTP and socket usage
final res = await ghost.post(
'/chat/ghost/register',
data: {'userName': 'ghostfox'},
);
await ghost.socketInit(
SocketConnetParamX(
token: null,
socketUrl: 'https://socket.example.com',
joinId: 'ghost_room',
),
);
ghost.listen('ghost_message').listen((event) {
// handle ghost messages
});
ghost.emit('ghost_message', {
'ghostId': 'ghost_ghostfox',
'text': 'hello from ghost',
});
Lifecycle notes
dispose()closes ghost socket resources.- If you only want to explicitly stop socket listeners/connection, use:
ghost.disconnectSocket()
Dynamic Mode Switching
Common architecture in apps:
- Keep one
AuthorizedPigeon. - Keep one
GhostPigeon. - Expose an app-level active resolver.
- Repositories call the currently active client at runtime.
This pattern lets you switch seamlessly between:
- authenticated identity
- ghost identity
without rebuilding repository objects.
Error Handling Behavior
Authorized interceptor
- Appends access token from current stored auth.
- On configured unauthorized errors:
- starts one refresh request
- queues pending failed requests
- updates auth storage with new tokens
- retries queued requests
- If refresh fails:
- clears current auth
- rejects queued requests
Ghost interceptor
- Attaches optional token if set.
- Does not run refresh flow.
Recommended Backend Contract
For best compatibility, backend should return refreshed tokens as:
{
"data": {
"accessToken": "new_access",
"refreshToken": "new_refresh"
}
}
For ghost identity flows, typical endpoints:
POST /chat/ghost/registerPOST /chat/ghost/loginPOST /chat/ghost/messagesGET /chat/ghost/messages
Minimal End-to-End Example
final authorized = AuthorizedPigeon(
MyRefreshTokenManager(),
baseUrl: 'https://api.example.com',
);
final ghost = GhostPigeon(baseUrl: 'https://api.example.com');
// Use authorized client
await authorized.post('/auth/login', data: {...});
await authorized.saveNewAuth(
saveAuthParams: SaveNewAuthParams(
uid: 'u1',
accessToken: 'a1',
refreshToken: 'r1',
data: {'uid': 'u1'},
),
);
// Later switch to ghost flow
authorized.disconnectSocket();
final ghostSession = await ghost.post(
'/chat/ghost/register',
data: {'userName': 'ghostfox'},
);
await ghost.socketInit(
SocketConnetParamX(
token: null,
socketUrl: 'https://socket.example.com',
joinId: 'ghost_room',
),
);
Example App
This repository includes an example/ app and example backend showing:
- authorized login/signup/refresh
- multi-account switching
- ghost identity flow
- realtime universal chat
Libraries
- app_pigeon
- You can use appPigeon for: