In modern mobile apps, the ability to work offline is no longer a luxury, but a basic necessity. As Chipode, we adopted the offline-first approach in our Sudoku app to provide a seamless experience for our users. In this article, we will explain in detail how we implemented this approach.
What is Offline-First?
Offline-first is a design approach that aims to make the application fully functional without an internet connection. The basic principles of this approach:
1. Local data priority
2. Asynchronous synchronization
3. Conflict resolution
4. Low bandwidth optimization
5. Progressive enhancement
Data Storage Strategy
1- Yerel Depolama Sağlayıcısı
dart
abstract class LocalStorageProvider {
Future saveGame(GameState state);
Future loadGame(String gameId);
Future> getUnsynced();
Future markSynced(String gameId);
}
class HiveStorageProvider implements LocalStorageProvider {
late Box _gameBox;
late Box _syncBox;
Future initialize() async {
Hive.registerAdapter(GameStateAdapter());
Hive.registerAdapter(SyncStatusAdapter());
_gameBox = await Hive.openBox('games');
_syncBox = await Hive.openBox('sync_status');
}
@override
Future saveGame(GameState state) async {
await _gameBox.put(state.id, state);
await _syncBox.put(state.id, SyncStatus(
synced: false,
lastModified: DateTime.now(),
));
}
// Other methods...
}
2- Model Definitions
dart
@HiveType(typeId: 0)
class GameState extends HiveObject {
@HiveField(0)
final String id;
@HiveField(1)
final List> board;
@HiveField(2)
final int score;
@HiveField(3)
final DateTime lastPlayed;
@HiveField(4)
final GameDifficulty difficulty;
// Constructor and methods...
}
@HiveType(typeId: 1)
class SyncStatus extends HiveObject {
@HiveField(0)
final bool synced;
@HiveField(1)
final DateTime lastModified;
@HiveField(2)
final DateTime? lastSyncAttempt;
// Constructors ve methods...
}
Synchronization Mechanism
1- Sync Service
dart
class SyncService {
final LocalStorageProvider _storage;
final GameRepository _repository;
final ConnectivityService _connectivity;
Stream syncGames() async* {
// Bağlantı kontrolü
if (!await _connectivity.isConnected) {
yield SyncState.offline();
return;
}
try {
// Get unsynchronized games
final unsynced = await _storage.getUnsynced();
for (var game in unsynced) {
yield SyncState.syncing(game.id);
// Backend'e gönder
await _repository.saveGame(game);
// Senkronize edildi olarak işaretle
await _storage.markSynced(game.id);
yield SyncState.success(game.id);
}
} catch (e) {
yield SyncState.error(e.toString());
}
}
}
2- Connectivity Service
dart
class ConnectivityService {
final _connectivity = Connectivity();
final _controller = StreamController();
Stream get status => _controller.stream;
ConnectivityService() {
_connectivity.onConnectivityChanged.listen((status) {
_controller.add(_getStatus(status));
});
}
Future get isConnected async {
final status = await _connectivity.checkConnectivity();
return status != ConnectivityResult.none;
}
ConnectivityStatus _getStatus(ConnectivityResult result) {
switch (result) {
case ConnectivityResult.mobile:
return ConnectivityStatus.mobile;
case ConnectivityResult.wifi:
return ConnectivityStatus.wifi;
case ConnectivityResult.none:
return ConnectivityStatus.offline;
default:
return ConnectivityStatus.offline;
}
}
}
Conflict Resolution
1- Merge Strategy
dart
class GameStateMergeStrategy {
GameState merge(GameState local, GameState remote) {
// Timestamp check
if (local.lastModified.isAfter(remote.lastModified)) {
return local;
}
// Score comparison
if (local.score > remote.score) {
return local.copyWith(
synced: true,
lastModified: DateTime.now(),
);
}
return remote;
}
}
UI Integration
1- Offline Indicator Widget
dart
class OfflineIndicator extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Consumer(
builder: (context, service, child) {
return StreamBuilder(
stream: service.status,
builder: (context, snapshot) {
if (snapshot.data == ConnectivityStatus.offline) {
return Container(
color: Colors.red.shade100,
padding: EdgeInsets.symmetric(vertical: 8),
child: Text(
'Offline mode - Your changes will be synchronized when a connection is established',
textAlign: TextAlign.center,
),
);
}
return SizedBox.shrink();
},
);
},
);
}
}
2- Game Screen
dart
class GameScreen extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Scaffold(
body: Column(
children: [
OfflineIndicator(),
Expanded(
child: Consumer(
builder: (context, notifier, child) {
return SudokuBoard(
state: notifier.state,
onCellUpdated: (row, col, value) {
notifier.updateCell(row, col, value);
// Otomatik kayıt
context.read().saveGame(
notifier.state,
);
},
);
},
),
),
SyncStatusIndicator(),
],
),
);
}
}
Performance Optimization
1- Batch Processing
dart
class BatchSyncProcessor {
static const int BATCH_SIZE = 10;
Future processBatch(List states) async {
final batches = states.chunked(BATCH_SIZE);
for (var batch in batches) {
await Future.wait(
batch.map((state) => _syncSingle(state)),
);
}
}
Future _syncSingle(GameState state) async {
// Tek oyun senkronizasyonu
}
}
2- Compression
dart
extension GameStateCompression on GameState {
String toCompressedString() {
final bytes = utf8.encode(jsonEncode(toJson()));
return base64Encode(gzip.encode(bytes));
}
static GameState fromCompressedString(String compressed) {
final bytes = gzip.decode(base64Decode(compressed));
final json = jsonDecode(utf8.decode(bytes));
return GameState.fromJson(json);
}
}
Test Strategy
1- Unit Tests
dart
void main() {
group('Offline Storage Tests', () {
late LocalStorageProvider storage;
setUp(() async {
storage = HiveStorageProvider();
await storage.initialize();
});
test('should save and load game state', () async {
final state = GameState(
id: 'test_game',
board: List.generate(9, (_) => List.filled(9, 0)),
score: 100,
);
await storage.saveGame(state);
final loaded = await storage.loadGame('test_game');
expect(loaded, isNotNull);
expect(loaded?.score, equals(100));
});
test('should handle offline sync queue', () async {
// Test implementation
});
});
}
1- Integration Tests
dart
void main() {
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
testWidgets('should work offline and sync when online',
(WidgetTester tester) async {
await tester.pumpWidget(MyApp());
// Çevrimdışı moda geç
await tester.binding.setNetworkEnabled(false);
// Oyunu oyna
await tester.tap(find.byType(SudokuCell).first);
await tester.enterText(find.byType(NumberPad), '5');
await tester.pump();
// Veri kaydedildi mi kontrol et
final storage = HiveStorageProvider();
final saved = await storage.loadGame('current_game');
expect(saved, isNotNull);
// Çevrimiçi moda geç
await tester.binding.setNetworkEnabled(true);
await tester.pump(Duration(seconds: 1));
// Senkronizasyon kontrolü
expect(find.byType(SyncStatusIndicator), findsOneWidget);
});
}
Results
Chipode Sudoku implementation with offline-first approach:
1. Can work independently of an Internet connection
2. Risk of data loss is minimized
3. The user experience is seamless
4. Bandwidth utilization is optimized
5. Synchronization is transparent and reliable
Future Plans
– Multi-device synchronization
– Differential sync implementation
– Offline analytics integration
– Push notification support





