diff --git a/.skill-lock.json b/.skill-lock.json index 4102749c2..cbbc8fd4d 100644 --- a/.skill-lock.json +++ b/.skill-lock.json @@ -153,6 +153,15 @@ "skillFolderHash": "c5ce954ef1fca62dd177cd1fd16e2fc81898458a", "installedAt": "2026-02-25T00:51:33.360Z", "updatedAt": "2026-02-25T00:51:33.360Z" + }, + "mobile-offline-support": { + "source": "aj-geddes/useful-ai-prompts", + "sourceType": "github", + "sourceUrl": "https://github.com/aj-geddes/useful-ai-prompts.git", + "skillPath": "skills/mobile-offline-support/SKILL.md", + "skillFolderHash": "05bb4c1a9463fe22342c09422760c5526d2dac06", + "installedAt": "2026-02-25T00:51:58.400Z", + "updatedAt": "2026-02-25T00:51:58.400Z" } }, "dismissed": { diff --git a/memory/memories.db-wal b/memory/memories.db-wal index ff1e848ec..fd67c79fa 100644 Binary files a/memory/memories.db-wal and b/memory/memories.db-wal differ diff --git a/skills/mobile-offline-support/SKILL.md b/skills/mobile-offline-support/SKILL.md new file mode 100644 index 000000000..0074c06e8 --- /dev/null +++ b/skills/mobile-offline-support/SKILL.md @@ -0,0 +1,466 @@ +--- +name: mobile-offline-support +description: Implement offline-first mobile apps with local storage, sync strategies, and conflict resolution. Covers AsyncStorage, Realm, SQLite, and background sync patterns. +--- + +# Mobile Offline Support + +## Overview + +Design offline-first mobile applications that provide seamless user experience regardless of connectivity. + +## When to Use + +- Building apps that work without internet connection +- Implementing seamless sync when connectivity returns +- Handling data conflicts between device and server +- Reducing server load with intelligent caching +- Improving app responsiveness with local storage + +## Instructions + +### 1. **React Native Offline Storage** + +```javascript +import AsyncStorage from '@react-native-async-storage/async-storage'; +import NetInfo from '@react-native-community/netinfo'; + +class StorageManager { + static async saveItems(items) { + try { + await AsyncStorage.setItem( + 'items_cache', + JSON.stringify({ data: items, timestamp: Date.now() }) + ); + } catch (error) { + console.error('Failed to save items:', error); + } + } + + static async getItems() { + try { + const data = await AsyncStorage.getItem('items_cache'); + return data ? JSON.parse(data) : null; + } catch (error) { + console.error('Failed to retrieve items:', error); + return null; + } + } + + static async queueAction(action) { + try { + const queue = await AsyncStorage.getItem('action_queue'); + const actions = queue ? JSON.parse(queue) : []; + actions.push({ ...action, id: Date.now(), attempts: 0 }); + await AsyncStorage.setItem('action_queue', JSON.stringify(actions)); + } catch (error) { + console.error('Failed to queue action:', error); + } + } + + static async getActionQueue() { + try { + const queue = await AsyncStorage.getItem('action_queue'); + return queue ? JSON.parse(queue) : []; + } catch (error) { + return []; + } + } + + static async removeFromQueue(actionId) { + try { + const queue = await AsyncStorage.getItem('action_queue'); + const actions = queue ? JSON.parse(queue) : []; + const filtered = actions.filter(a => a.id !== actionId); + await AsyncStorage.setItem('action_queue', JSON.stringify(filtered)); + } catch (error) { + console.error('Failed to remove from queue:', error); + } + } +} + +class OfflineAPIService { + async fetchItems() { + const isOnline = await this.checkConnectivity(); + + if (isOnline) { + try { + const response = await fetch('https://api.example.com/items'); + const items = await response.json(); + await StorageManager.saveItems(items); + return items; + } catch (error) { + const cached = await StorageManager.getItems(); + return cached?.data || []; + } + } else { + const cached = await StorageManager.getItems(); + return cached?.data || []; + } + } + + async createItem(item) { + const isOnline = await this.checkConnectivity(); + + if (isOnline) { + try { + const response = await fetch('https://api.example.com/items', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(item) + }); + const created = await response.json(); + return { success: true, data: created }; + } catch (error) { + await StorageManager.queueAction({ + type: 'CREATE_ITEM', + payload: item + }); + return { success: false, queued: true }; + } + } else { + await StorageManager.queueAction({ + type: 'CREATE_ITEM', + payload: item + }); + return { success: false, queued: true }; + } + } + + async syncQueue() { + const queue = await StorageManager.getActionQueue(); + + for (const action of queue) { + try { + await this.executeAction(action); + await StorageManager.removeFromQueue(action.id); + } catch (error) { + action.attempts = (action.attempts || 0) + 1; + if (action.attempts > 3) { + await StorageManager.removeFromQueue(action.id); + } + } + } + } + + private async executeAction(action) { + switch (action.type) { + case 'CREATE_ITEM': + return fetch('https://api.example.com/items', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(action.payload) + }); + default: + return Promise.reject(new Error('Unknown action type')); + } + } + + async checkConnectivity() { + const state = await NetInfo.fetch(); + return state.isConnected ?? false; + } +} + +export function OfflineListScreen() { + const [items, setItems] = useState([]); + const [isOnline, setIsOnline] = useState(true); + const [syncing, setSyncing] = useState(false); + const apiService = new OfflineAPIService(); + + useFocusEffect( + useCallback(() => { + loadItems(); + const unsubscribe = NetInfo.addEventListener(state => { + setIsOnline(state.isConnected ?? false); + if (state.isConnected) { + syncQueue(); + } + }); + + return unsubscribe; + }, []) + ); + + const loadItems = async () => { + const items = await apiService.fetchItems(); + setItems(items); + }; + + const syncQueue = async () => { + setSyncing(true); + await apiService.syncQueue(); + await loadItems(); + setSyncing(false); + }; + + return ( + + {!isOnline && Offline Mode} + {syncing && } + } + keyExtractor={item => item.id} + /> + + ); +} +``` + +### 2. **iOS Core Data Implementation** + +```swift +import CoreData + +class PersistenceController { + static let shared = PersistenceController() + + let container: NSPersistentContainer + + init(inMemory: Bool = false) { + container = NSPersistentContainer(name: "MyApp") + + if inMemory { + container.persistentStoreDescriptions.first?.url = URL(fileURLWithPath: "/dev/null") + } + + container.loadPersistentStores { _, error in + if let error = error as NSError? { + print("Core Data load error: \(error)") + } + } + + container.viewContext.automaticallyMergesChangesFromParent = true + } + + func save(_ context: NSManagedObjectContext = PersistenceController.shared.container.viewContext) { + if context.hasChanges { + do { + try context.save() + } catch { + print("Save error: \(error)") + } + } + } +} + +// Core Data Models +@NSManaged class ItemEntity: NSManagedObject { + @NSManaged var id: String + @NSManaged var title: String + @NSManaged var description: String? + @NSManaged var isSynced: Bool +} + +@NSManaged class ActionQueueEntity: NSManagedObject { + @NSManaged var id: UUID + @NSManaged var type: String + @NSManaged var payload: Data? + @NSManaged var createdAt: Date +} + +class OfflineSyncManager: NSObject, ObservableObject { + @Published var isOnline = true + @Published var isSyncing = false + + private let networkMonitor = NWPathMonitor() + private let persistenceController = PersistenceController.shared + + override init() { + super.init() + setupNetworkMonitoring() + } + + private func setupNetworkMonitoring() { + networkMonitor.pathUpdateHandler = { [weak self] path in + DispatchQueue.main.async { + self?.isOnline = path.status == .satisfied + if path.status == .satisfied { + self?.syncWithServer() + } + } + } + + let queue = DispatchQueue(label: "NetworkMonitor") + networkMonitor.start(queue: queue) + } + + func saveItem(_ item: Item) { + let context = persistenceController.container.viewContext + let entity = ItemEntity(context: context) + entity.id = item.id + entity.title = item.title + entity.isSynced = false + + persistenceController.save(context) + + if isOnline { + syncItem(item) + } + } + + func syncWithServer() { + isSyncing = true + let context = persistenceController.container.viewContext + let request: NSFetchRequest = ActionQueueEntity.fetchRequest() + + do { + let pendingActions = try context.fetch(request) + for action in pendingActions { + context.delete(action) + } + persistenceController.save(context) + } catch { + print("Sync error: \(error)") + } + + isSyncing = false + } +} +``` + +### 3. **Android Room Database** + +```kotlin +@Entity(tableName = "items") +data class ItemEntity( + @PrimaryKey val id: String, + val title: String, + val description: String?, + val isSynced: Boolean = false +) + +@Entity(tableName = "action_queue") +data class ActionQueueEntity( + @PrimaryKey val id: Long = System.currentTimeMillis(), + val type: String, + val payload: String, + val createdAt: Long = System.currentTimeMillis() +) + +@Dao +interface ItemDao { + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertItem(item: ItemEntity) + + @Query("SELECT * FROM items") + fun getAllItems(): Flow> + + @Update + suspend fun updateItem(item: ItemEntity) +} + +@Dao +interface ActionQueueDao { + @Insert + suspend fun insertAction(action: ActionQueueEntity) + + @Query("SELECT * FROM action_queue ORDER BY createdAt ASC") + suspend fun getAllActions(): List + + @Delete + suspend fun deleteAction(action: ActionQueueEntity) +} + +@Database(entities = [ItemEntity::class, ActionQueueEntity::class], version = 1) +abstract class AppDatabase : RoomDatabase() { + abstract fun itemDao(): ItemDao + abstract fun actionQueueDao(): ActionQueueDao +} + +@HiltViewModel +class OfflineItemsViewModel @Inject constructor( + private val itemDao: ItemDao, + private val actionQueueDao: ActionQueueDao, + private val connectivityManager: ConnectivityManager +) : ViewModel() { + private val _items = MutableStateFlow>(emptyList()) + val items: StateFlow> = _items.asStateFlow() + + init { + viewModelScope.launch { + itemDao.getAllItems().collect { entities -> + _items.value = entities.map { it.toItem() } + } + } + observeNetworkConnectivity() + } + + fun saveItem(item: Item) { + viewModelScope.launch { + val entity = item.toEntity() + itemDao.insertItem(entity) + + if (isNetworkAvailable()) { + syncItem(item) + } else { + actionQueueDao.insertAction( + ActionQueueEntity( + type = "CREATE_ITEM", + payload = Json.encodeToString(item) + ) + ) + } + } + } + + private fun observeNetworkConnectivity() { + val networkRequest = NetworkRequest.Builder() + .addCapability(NET_CAPABILITY_INTERNET) + .build() + + connectivityManager.registerNetworkCallback( + networkRequest, + object : ConnectivityManager.NetworkCallback() { + override fun onAvailable(network: Network) { + viewModelScope.launch { syncQueue() } + } + } + ) + } + + private suspend fun syncQueue() { + val queue = actionQueueDao.getAllActions() + for (action in queue) { + try { + actionQueueDao.deleteAction(action) + } catch (e: Exception) { + println("Sync error: ${e.message}") + } + } + } + + private fun isNetworkAvailable(): Boolean { + val activeNetwork = connectivityManager.activeNetwork ?: return false + val capabilities = connectivityManager.getNetworkCapabilities(activeNetwork) ?: return false + return capabilities.hasCapability(NET_CAPABILITY_INTERNET) + } +} +``` + +## Best Practices + +### ✅ DO +- Implement robust local storage +- Use automatic sync when online +- Provide visual feedback for offline status +- Queue actions for later sync +- Handle conflicts gracefully +- Cache frequently accessed data +- Implement proper error recovery +- Test offline scenarios thoroughly +- Use compression for large data +- Monitor storage usage + +### ❌ DON'T +- Assume constant connectivity +- Sync large files frequently +- Ignore storage limitations +- Force unnecessary syncing +- Lose data on offline mode +- Store sensitive data unencrypted +- Accumulate infinite queue items +- Ignore sync failures silently +- Sync in tight loops +- Deploy without offline testing