Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
c1c5a36
refactor(sdk): extract shared helpers to dedupe field/screen/row/util…
omprakashk22 May 14, 2026
2705b56
fix(sync): initial-sync offset pagination, fast-fail boot probes, and…
omprakashk22 May 15, 2026
811710d
fix(sdk): close initial-sync schema gap; expose push/pull/form hooks
omprakashk22 May 17, 2026
a2a7d50
Merge remote-tracking branch 'origin/om/offline_improvement' into om/…
omprakashk22 May 17, 2026
1d15d0e
Merge branch 'om/offline_improvement' into om/logic_correction
omprakashk22 May 18, 2026
2e0642f
Merge branch 'om/offline_improvement' of github.com:dhwani-ris/frappe…
omprakashk22 May 18, 2026
eb00b5b
Merge remote-tracking branch 'origin/develop' into om/logic_correction
omprakashk22 May 29, 2026
0f60ca4
Merge branch 'om/offline_improvement' into om/logic_correction
omprakashk22 May 29, 2026
4e09e50
chore: untrack local dev tooling (.mcp.json, devtools_options.yaml, s…
omprakashk22 May 29, 2026
aeefde0
feat(sync): offline-sync hardening — push/pull race, sync modes, resu…
omprakashk22 Jun 4, 2026
f59af58
chore(logging): replace print() with sdkLog across services and UI
omprakashk22 Jun 4, 2026
b36f6b9
Merge branch 'om/offline_improvement' into om/logic_correction
omprakashk22 Jun 4, 2026
c7fbcf0
fix(sync): drop SyncStateNotifier writes after close
omprakashk22 Jun 8, 2026
01376ab
refactor(ui): extract FieldNormalizer from FrappeFormBuilder
omprakashk22 Jun 8, 2026
18fa463
refactor(example): use SDK getters instead of rebuilding services
omprakashk22 Jun 8, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -89,4 +89,8 @@ test/.test_coverage.dart

# FVM Version Cache
.fvm/
/docs
/docs
# Local dev tooling (not part of the package)
.mcp.json
devtools_options.yaml
/scripts/
74 changes: 13 additions & 61 deletions example/lib/main.dart
Original file line number Diff line number Diff line change
Expand Up @@ -247,34 +247,11 @@ class _HomeScreenState extends State<HomeScreen> {
return;
}

// Initialize services if not already done
_metaService ??= MetaService(_authService!.client!, _database!);
_repository ??= OfflineRepository(_database!);
_syncService ??= SyncService(
_authService!.client!,
_repository!,
_database!,
);
if (_linkOptionService == null) {
final metaSvc = _metaService!;
final syncSvc = _syncService!;
final rawDb = _database!.rawDatabase;
Future<DocTypeMeta> metaFn(String dt) => metaSvc.getMeta(dt);
final resolver = UnifiedResolver(
db: rawDb,
metaDao: DoctypeMetaDao(rawDb),
isOnline: () => true,
backgroundFetch: (doctype, _) async {
try {
await syncSvc.pullSync(doctype: doctype);
} catch (_) {}
},
metaResolver: metaFn,
);
_linkOptionService = LinkOptionService(resolver, metaFn);
}

// Initial metadata + data sync for mobile forms
// Services are wired once by sdk.initialize() in _initialize() and exposed
// via sdk.meta / sdk.repository / sdk.sync / sdk.linkOptions. Do NOT rebuild
// them here — a hand-rolled graph diverges from the SDK's wiring (offline
// mode, connectivity-aware resolver, push runner) and would run alongside
// the SDK's own instances. Just run the post-login data sync.
await _initialMetaAndDataSync();

setState(() {
Expand Down Expand Up @@ -388,43 +365,18 @@ class _HomeScreenState extends State<HomeScreen> {
);
}

// Initialize services if not already done (should happen in _handleLoginSuccess, but double-check)
// Services are wired once by sdk.initialize() in _initialize(). If any is
// missing (initialize() failed), surface that rather than rebuilding a
// divergent service graph.
if (_metaService == null ||
_repository == null ||
_syncService == null ||
_linkOptionService == null) {
if (_database != null && _authService!.client != null) {
_metaService = MetaService(_authService!.client!, _database!);
_repository = OfflineRepository(_database!);
_syncService = SyncService(
_authService!.client!,
_repository!,
_database!,
getMobileUuid: () => _authService!.getOrCreateMobileUuid(),
);
final metaSvc = _metaService!;
final syncSvc = _syncService!;
final rawDb = _database!.rawDatabase;
Future<DocTypeMeta> metaFn(String dt) => metaSvc.getMeta(dt);
final resolver = UnifiedResolver(
db: rawDb,
metaDao: DoctypeMetaDao(rawDb),
isOnline: () => true,
backgroundFetch: (doctype, _) async {
try {
await syncSvc.pullSync(doctype: doctype);
} catch (_) {}
},
metaResolver: metaFn,
);
_linkOptionService = LinkOptionService(resolver, metaFn);
} else {
return const Scaffold(
body: Center(
child: Text('Services not initialized. Please restart the app.'),
),
);
}
return const Scaffold(
body: Center(
child: Text('Services not initialized. Please restart the app.'),
),
);
}

if (_appConfig == null) {
Expand Down
3 changes: 3 additions & 0 deletions lib/frappe_mobile_sdk.dart
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,8 @@ export 'src/ui/widgets/fields/button_field.dart';
export 'src/ui/widgets/fields/numeric_field.dart';
export 'src/ui/widgets/fields/link_field.dart';
export 'src/ui/widgets/fields/phone_field.dart';
export 'src/ui/widgets/fields/child_table_field.dart'
show ChildTableFormBuilder;

// Constants
export 'src/constants/field_types.dart';
Expand All @@ -121,6 +123,7 @@ export 'src/services/sync_controller.dart'
export 'src/sync/sync_state.dart'
show SyncState, DoctypeSyncState, QueueSummary, SyncErrorSummary;
export 'src/sync/sync_state_notifier.dart' show SyncStateNotifier;
export 'src/sync/push_engine.dart' show PayloadTransformerFn;
export 'src/ui/widgets/sync_status_bar.dart' show SyncStatusBar;
export 'src/ui/widgets/document_list_filter_chip.dart'
show DocumentListFilterChip, DocumentListFilter, DocumentListFilterCounts;
Expand Down
6 changes: 2 additions & 4 deletions lib/src/api/attachment_service.dart
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@

import 'dart:io';
import 'rest_helper.dart';
import 'utils.dart';

class AttachmentService {
final RestHelper _restHelper;
Expand Down Expand Up @@ -37,9 +38,6 @@ class AttachmentService {
fields: fields,
);

if (response is Map<String, dynamic> && response.containsKey('message')) {
return response['message'] as Map<String, dynamic>;
}
return response as Map<String, dynamic>;
return unwrapMessage<Map<String, dynamic>>(response);
}
}
4 changes: 2 additions & 2 deletions lib/src/api/auth.dart
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
// For license information, please see license.txt

import 'rest_helper.dart';
import '../utils/sdk_log.dart';

abstract class SessionStorage {
Future<void> saveSession(String sid);
Expand Down Expand Up @@ -41,8 +42,7 @@ class AuthService {
try {
await _restHelper.post('/api/method/mobile_auth.logout');
} catch (e, st) {
// ignore: avoid_print
print(
sdkLog(
'AuthApi: server logout failed (continuing local cleanup) — $e\n$st',
);
} finally {
Expand Down
28 changes: 28 additions & 0 deletions lib/src/api/doctype_service.dart
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import 'dart:math' as math;

import 'package:flutter/foundation.dart';

import '../sync/sync_details.dart';
import 'exceptions.dart';
import 'rest_helper.dart';
import 'utils.dart';
Expand Down Expand Up @@ -187,6 +188,33 @@ class DoctypeService {
];
}

/// Pre-flight manifest (#49). Posts the INCREMENTAL doctypes about to be
/// pulled, each with its own `since` watermark, and returns per-doctype
/// change info. Returns null on ANY failure (network, 404, missing endpoint,
/// malformed body) so the caller falls back to a full pull.
Future<SyncDetailsResponse?> getSyncDetails(
List<Map<String, String>> doctypeSince,
) async {
if (doctypeSince.isEmpty) return null;
try {
final response = await _restHelper.post(
'/api/method/mobile_sync.sync_details',
body: {'doctypes': doctypeSince},
);
final dynamic message = response is Map<String, dynamic>
? response['message']
: response;
if (message is! Map) return null;
return SyncDetailsResponse.fromJson(Map<String, dynamic>.from(message));
} catch (e, st) {
debugPrint(
'DoctypeService.getSyncDetails failed — falling back to full pull — '
'$e\n$st',
);
return null;
}
}

/// Pages through `frappe.client.get_list` for names, then bulk-fetches
/// full documents (parents + child rows) via the server-side
/// `mobile_sync.get_docs_with_children` endpoint. Used by the pull
Expand Down
16 changes: 4 additions & 12 deletions lib/src/api/document_service.dart
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@

import 'dart:convert';
import 'rest_helper.dart';
import 'utils.dart';

class DocumentService {
final RestHelper _restHelper;
Expand All @@ -19,19 +20,13 @@ class DocumentService {
'/api/method/frappe.client.insert',
body: {'doc': jsonEncode(data..['doctype'] = doctype)},
);
if (response is Map<String, dynamic> && response.containsKey('message')) {
return response['message'] as Map<String, dynamic>;
}
return response as Map<String, dynamic>;
return unwrapMessage<Map<String, dynamic>>(response);
} else {
final response = await _restHelper.post(
'/api/resource/$doctype',
body: data,
);
if (response is Map<String, dynamic> && response.containsKey('data')) {
return response['data'] as Map<String, dynamic>;
}
return response as Map<String, dynamic>;
return unwrapData<Map<String, dynamic>>(response);
}
}

Expand All @@ -44,10 +39,7 @@ class DocumentService {
'/api/resource/$doctype/$name',
body: data,
);
if (response is Map<String, dynamic> && response.containsKey('data')) {
return response['data'] as Map<String, dynamic>;
}
return response as Map<String, dynamic>;
return unwrapData<Map<String, dynamic>>(response);
}

Future<void> deleteDocument(String doctype, String name) async {
Expand Down
94 changes: 42 additions & 52 deletions lib/src/api/oauth2_helper.dart
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,41 @@ import 'dart:math';
import 'package:crypto/crypto.dart';
import 'package:http/http.dart' as http;

/// Builds a Frappe `api/method/<path>` URI from [baseUrl], normalising
/// the trailing slash so `baseUrl` with or without a trailing `/`
/// produces the same URI. Single source of truth for OAuth endpoint URL
/// construction.
Uri _oauthUri(String baseUrl, String path) {
final root = baseUrl.endsWith('/') ? baseUrl : '$baseUrl/';
return Uri.parse('${root}api/method/$path');
}

/// Performs a form-encoded `application/x-www-form-urlencoded` POST to
/// [uri], throws `Exception('$errorLabel failed: ...')` on non-200, and
/// returns the decoded JSON map. Shared by [OAuth2Helper.exchangeCodeForToken]
/// and [OAuth2Helper.refreshToken] which previously had byte-for-byte
/// identical http.post calls.
Future<Map<String, dynamic>> _postFormEncoded(
Uri uri,
Map<String, String> body,
String errorLabel,
) async {
final response = await http.post(
uri,
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'Accept': 'application/json',
},
body: body.keys.map((k) => '$k=${Uri.encodeComponent(body[k]!)}').join('&'),
);
if (response.statusCode != 200) {
throw Exception(
'$errorLabel failed: ${response.statusCode} ${response.body}',
);
}
return jsonDecode(response.body) as Map<String, dynamic>;
}

/// PKCE code verifier and challenge pair (RFC 7636).
class PkcePair {
final String codeVerifier;
Expand Down Expand Up @@ -81,10 +116,7 @@ class OAuth2Helper {
String codeChallengeMethod = 'S256',
String responseType = 'code',
}) {
final path = baseUrl.endsWith('/') ? baseUrl : '$baseUrl/';
final uri = Uri.parse(
'${path}api/method/frappe.integrations.oauth2.authorize',
);
final uri = _oauthUri(baseUrl, 'frappe.integrations.oauth2.authorize');
final q = <String, String>{
'client_id': clientId,
'redirect_uri': redirectUri,
Expand All @@ -111,10 +143,7 @@ class OAuth2Helper {
String? codeVerifier,
String? clientSecret,
}) async {
final path = baseUrl.endsWith('/') ? baseUrl : '$baseUrl/';
final uri = Uri.parse(
'${path}api/method/frappe.integrations.oauth2.get_token',
);
final uri = _oauthUri(baseUrl, 'frappe.integrations.oauth2.get_token');
final body = <String, String>{
'grant_type': 'authorization_code',
'code': code,
Expand All @@ -127,22 +156,7 @@ class OAuth2Helper {
if (clientSecret != null && clientSecret.isNotEmpty) {
body['client_secret'] = clientSecret;
}
final response = await http.post(
uri,
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'Accept': 'application/json',
},
body: body.keys
.map((k) => '$k=${Uri.encodeComponent(body[k]!)}')
.join('&'),
);
if (response.statusCode != 200) {
throw Exception(
'OAuth token exchange failed: ${response.statusCode} ${response.body}',
);
}
final json = jsonDecode(response.body) as Map<String, dynamic>;
final json = await _postFormEncoded(uri, body, 'OAuth token exchange');
if (json['error'] != null) {
throw Exception(
'OAuth error: ${json['error']} - ${json['error_description'] ?? ''}',
Expand All @@ -161,10 +175,7 @@ class OAuth2Helper {
required String refreshToken,
String? clientSecret,
}) async {
final path = baseUrl.endsWith('/') ? baseUrl : '$baseUrl/';
final uri = Uri.parse(
'${path}api/method/frappe.integrations.oauth2.get_token',
);
final uri = _oauthUri(baseUrl, 'frappe.integrations.oauth2.get_token');
final body = <String, String>{
'grant_type': 'refresh_token',
'refresh_token': refreshToken,
Expand All @@ -173,22 +184,7 @@ class OAuth2Helper {
if (clientSecret != null && clientSecret.isNotEmpty) {
body['client_secret'] = clientSecret;
}
final response = await http.post(
uri,
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'Accept': 'application/json',
},
body: body.keys
.map((k) => '$k=${Uri.encodeComponent(body[k]!)}')
.join('&'),
);
if (response.statusCode != 200) {
throw Exception(
'OAuth refresh failed: ${response.statusCode} ${response.body}',
);
}
final json = jsonDecode(response.body) as Map<String, dynamic>;
final json = await _postFormEncoded(uri, body, 'OAuth refresh');
return OAuth2TokenResponse.fromJson(json);
}

Expand All @@ -197,10 +193,7 @@ class OAuth2Helper {
required String baseUrl,
required String accessToken,
}) async {
final path = baseUrl.endsWith('/') ? baseUrl : '$baseUrl/';
final uri = Uri.parse(
'${path}api/method/frappe.integrations.oauth2.openid_profile',
);
final uri = _oauthUri(baseUrl, 'frappe.integrations.oauth2.openid_profile');
final response = await http.get(
uri,
headers: {'Authorization': 'Bearer $accessToken'},
Expand All @@ -218,10 +211,7 @@ class OAuth2Helper {
required String baseUrl,
required String token,
}) async {
final path = baseUrl.endsWith('/') ? baseUrl : '$baseUrl/';
final uri = Uri.parse(
'${path}api/method/frappe.integrations.oauth2.revoke_token',
);
final uri = _oauthUri(baseUrl, 'frappe.integrations.oauth2.revoke_token');
await http.post(
uri,
headers: {'Content-Type': 'application/x-www-form-urlencoded'},
Expand Down
Loading
Loading