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

// 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.