Skip to main content
Status: ✅ Implemented
Last Updated: 2025-10-18
Version: 2.0.0
Related: Offline-First POS, Hybrid Sync

🎯 Overview

Pure Local Architecture adalah evolusi dari offline-first strategy yang memastikan 100% data read dari Isar dengan background sync menggunakan SWR (Stale-While-Revalidate) pattern.

Key Principles

  1. Always Local First: Semua read operation dari Isar DB (< 10ms)
  2. Background Sync: API calls non-blocking, update Isar silently
  3. No Loading Spinner: UI selalu show data, never wait for API
  4. Offline = Online: Consistent UX regardless of network state

🏗️ Architecture

Flow Diagram


📊 Implementation Status

Data TypeStatusFilePattern
Products✅ Doneproduct_service.dartSWR
Inventory✅ Doneproduct_service.dartSWR
Branches✅ Donebranches_service.dartPure Local + SWR
Transaction History✅ Donetransaction_service.dartPure Local + SWR
New Transactions✅ Donecheckout_bloc.dartOffline-First
Settings✅ Templatesettings_service.dartPure Local + SWR
Reports✅ Templatereports_service.dartPure Local + SWR

💻 Code Implementation

Service Pattern Template

import 'dart:async';
import 'package:connectivity_plus/connectivity_plus.dart';
import 'package:dartz/dartz.dart';
import 'package:injectable/injectable.dart';
import 'package:mstore_mobile/database/isar/isar_db.dart';
import 'package:mstore_mobile/pkg/utils/app_log.dart';
import 'package:mstore_mobile/pkg/utils/failures.dart';

@LazySingleton()
class YourService {
  final YourRepository _repository;
  final IsarDb _isarDb;
  
  /// Pure local read dengan SWR pattern
  /// Selalu return data dari Isar, trigger background refresh jika online
  Future<Either<Failure, YourResponse>> getData() async {
    // 1) SELALU baca dari Isar dulu (instant, no loading)
    final local = await _getDataLocalOnly();
    
    // 2) Trigger background refresh (non-blocking)
    _triggerBackgroundRefresh();
    
    // 3) Return local data immediately
    return local;
  }
  
  /// Background refresh: update Isar dari API secara silent
  void _triggerBackgroundRefresh() {
    unawaited(() async {
      try {
        // Check connectivity
        final results = await Connectivity().checkConnectivity();
        final isOnline = results.any((r) => r != ConnectivityResult.none);
        
        if (!isOnline) {
          AppLog.d('service', 'Skip bg refresh: offline');
          return;
        }
        
        AppLog.d('service', 'Triggering background refresh...');
        final apiResult = await _repository.fetchFromApi();
        
        await apiResult.fold(
          (failure) async {
            AppLog.w('service', 'Background refresh failed: ${failure.message}');
          },
          (data) async {
            await _upsertToIsar(data);
            AppLog.i('service', 'Background refresh success');
          },
        );
      } catch (e) {
        AppLog.e('service', 'Background refresh error', e as Object?);
      }
    }());
  }
  
  /// Read from Isar only (no network)
  Future<Either<Failure, YourResponse>> _getDataLocalOnly() async {
    try {
      final data = await _isarDb.isar.yourEntitys.where().findAll();
      return Right(YourResponse(data: data));
    } catch (e) {
      return Left(CacheFailure(message: 'Gagal membaca data lokal: $e'));
    }
  }
  
  /// Upsert API data to Isar
  Future<void> _upsertToIsar(YourApiData apiData) async {
    await _isarDb.isar.writeTxn(() async {
      final entities = apiData.items.map((item) => YourEntity()
        ..apiId = item.id
        ..name = item.name
        ..updatedAt = DateTime.now()
      ).toList();
      
      await _isarDb.isar.yourEntitys.putAll(entities);
    });
  }
}

Real Implementation Examples

  • Branches Service
  • Transaction History
  • Products & Inventory
// lib/core/branches/branches_service.dart

Future<Either<Failure, BranchResponse>> getMerchantBranches({
  bool forceNetwork = false
}) async {
  // 1) Read from Isar instantly
  final local = await _getMerchantBranchesLocalOnly();
  
  // 2) Trigger background refresh
  _triggerBackgroundRefresh();
  
  // 3) Return local
  return local;
}

void _triggerBackgroundRefresh() {
  unawaited(() async {
    if (!await _isOnline()) return;
    
    final netEither = await repository.getMerchantBranches();
    await netEither.fold(
      (failure) async => AppLog.w('branches', 'Refresh failed'),
      (resp) async => await _upsertBranchesFromApi(resp),
    );
  }());
}

🎨 UI Pattern

No Loading Spinner

JANGAN show loading spinner untuk data refresh!
// ❌ WRONG: Show loading spinner
BlocBuilder<DataBloc, DataState>(
  builder: (context, state) {
    if (state is DataLoading) {
      return CircularProgressIndicator(); // ❌ Bad UX
    }
    return ListView(children: state.data.map(...));
  },
)

// ✅ CORRECT: Always show data
BlocBuilder<DataBloc, DataState>(
  builder: (context, state) {
    // Langsung show data dari Isar
    // Background refresh akan auto-update via stream
    return ListView(children: state.data.map(...));
  },
)

Isar Stream for Auto-Update

StreamBuilder<List<YourEntity>>(
  stream: isarDb.isar.yourEntitys.watchLazy(),
  builder: (context, snapshot) {
    // Trigger fetch (akan return dari Isar + bg refresh)
    context.read<YourBloc>().add(LoadData());
    
    return BlocBuilder<YourBloc, YourState>(
      builder: (context, state) {
        if (state is YourLoaded) {
          return ListView(children: state.data.map(...));
        }
        return EmptyState(); // Jika Isar kosong
      },
    );
  },
)

Pull-to-Refresh

RefreshIndicator(
  onRefresh: () async {
    // Force manual sync
    context.read<YourBloc>().add(RefreshData(force: true));
    await Future.delayed(Duration(milliseconds: 500));
  },
  child: ListView(...),
)

🚀 Benefits

User Experience

⚡ Instant Load

Data muncul < 10ms dari Isar, tidak ada loading spinner

📱 Offline = Online

UX konsisten, app selalu berfungsi tanpa network

🔄 Auto-Update

Background sync silent, user tidak perlu manual refresh

🎯 No Blocking

User bisa langsung interaksi, tidak tunggu API

Developer Experience

🧪 Easy Testing

Mock Isar lebih mudah daripada mock API

🐛 Better Debugging

Data selalu ada di local, mudah inspect

📝 Simple Code

Tidak ada complex fallback logic

🔧 Maintainable

Single source of truth (Isar)

Performance

  • Fast: Isar query < 10ms vs API 100-500ms
  • Reduced API Calls: Background only, tidak setiap screen
  • Battery Friendly: Less network activity
  • Scalable: Isar handle millions of records

📋 Migration Checklist

From API-First to Pure Local

  • Service Layer
    • Tambah _getDataLocalOnly() method
    • Tambah _triggerBackgroundRefresh() method
    • Refactor getData() → pure local + SWR
    • Tambah connectivity check
    • Tambah error handling
  • UI Layer
    • Remove loading spinner untuk refresh
    • Add Isar stream untuk auto-update
    • Show empty state (bukan loading) jika Isar kosong
    • Add pull-to-refresh (optional)
  • Testing
    • Test offline mode (airplane mode)
    • Test online mode (background refresh)
    • Test first launch (empty Isar)
    • Test auto-update (Isar stream)


📚 Code References

Backend (Go)

  • internal/domains/*/service.go - Service layer
  • internal/domains/*/repository.go - Repository layer
  • internal/domains/*/handler.go - HTTP handlers

Mobile (Flutter)

  • lib/core/*/service.dart - Service layer dengan SWR
  • lib/core/*/repository.dart - Repository layer
  • lib/database/isar/entities/*.dart - Isar entities
  • lib/features/*/bloc/*.dart - Bloc state management

🎯 Best Practices

// ✅ CORRECT
Future<Data> getData() async {
  final local = await _getLocalOnly();
  _triggerBackgroundRefresh();
  return local;
}

// ❌ WRONG
Future<Data> getData() async {
  try {
    return await api.fetch(); // Block UI
  } catch (e) {
    return await _getLocalOnly(); // Fallback
  }
}
// ✅ CORRECT
void _triggerBackgroundRefresh() {
  unawaited(() async {
    // Non-blocking async
  }());
}

// ❌ WRONG
Future<void> _triggerBackgroundRefresh() async {
  await api.fetch(); // Blocks caller
}
// ✅ CORRECT
void _triggerBackgroundRefresh() {
  unawaited(() async {
    if (!await _isOnline()) return; // Skip if offline
    await api.fetch();
  }());
}

// ❌ WRONG
void _triggerBackgroundRefresh() {
  unawaited(() async {
    await api.fetch(); // Fail if offline
  }());
}
// ✅ CORRECT
void _triggerBackgroundRefresh() {
  unawaited(() async {
    try {
      await api.fetch();
    } catch (e) {
      AppLog.w('service', 'Refresh failed: $e');
      // Silent fail, tidak block user
    }
  }());
}

// ❌ WRONG
void _triggerBackgroundRefresh() {
  unawaited(() async {
    await api.fetch(); // Throw error ke user
  }());
}

🎓 Summary

Pure Local Architecture + SWR adalah pattern yang memastikan:
  1. 100% data dari Isar (instant load)
  2. Background sync (silent update)
  3. No loading spinner (always show data)
  4. Offline = Online (consistent UX)
Result: App yang super fast, always works, dan auto-update tanpa user intervention.
Dokumentasi ini adalah living document. Update sesuai dengan evolusi implementation.