Add network management features and sync functionality; introduce NetworkBanner component
This commit is contained in:
216
src/utils/LocalStore.jsx
Normal file
216
src/utils/LocalStore.jsx
Normal file
@@ -0,0 +1,216 @@
|
||||
import { CapacitorSQLite } from '@capacitor-community/sqlite'
|
||||
import { Capacitor } from '@capacitor/core'
|
||||
|
||||
const CACHE_TABLE = 'offline_cache'
|
||||
const QUEUE_TABLE = 'offline_request_queue'
|
||||
const OFFLINE_TASK = 'offlineTasks' // For storing offline tasks
|
||||
|
||||
class LocalStore {
|
||||
constructor() {
|
||||
this.db = null
|
||||
this.useLocalStorage = !Capacitor.isNativePlatform()
|
||||
}
|
||||
|
||||
async initDatabase() {
|
||||
if (this.useLocalStorage) return null
|
||||
if (this.db) return this.db
|
||||
|
||||
const db = await CapacitorSQLite.createConnection({
|
||||
database: 'offline_data',
|
||||
version: 1,
|
||||
})
|
||||
await db.open()
|
||||
|
||||
// Create tables if they don't exist
|
||||
await db.execute(`
|
||||
CREATE TABLE IF NOT EXISTS ${CACHE_TABLE} (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT,
|
||||
timestamp INTEGER
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS ${QUEUE_TABLE} (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
url TEXT,
|
||||
requestBody TEXT
|
||||
);
|
||||
`)
|
||||
|
||||
this.db = db
|
||||
return db
|
||||
}
|
||||
|
||||
async saveToCache(key, data) {
|
||||
const timestamp = Date.now() // Current timestamp in milliseconds
|
||||
|
||||
if (this.useLocalStorage) {
|
||||
localStorage.setItem(key, JSON.stringify({ value: data, timestamp }))
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
const db = await this.initDatabase()
|
||||
await db.run(
|
||||
`
|
||||
INSERT OR REPLACE INTO ${CACHE_TABLE} (key, value, timestamp)
|
||||
VALUES (?, ?, ?);
|
||||
`,
|
||||
[key, JSON.stringify(data), timestamp],
|
||||
)
|
||||
}
|
||||
|
||||
// async saveTemporaryTask(task) {
|
||||
// const baseURL = apiManager.getApiURL()
|
||||
// const fullURL = `${baseURL}/chores/${task.tempId}`
|
||||
// const options = {
|
||||
// method: 'GET',
|
||||
// headers: HEADERS(),
|
||||
// url: fullURL,
|
||||
// }
|
||||
// const respond = { res: task }
|
||||
// const requestId = murmurhash.v3(JSON.stringify({ fullURL, options }))
|
||||
|
||||
// if (this.useLocalStorage) {
|
||||
// this.saveToCache(requestId, respond)
|
||||
// return
|
||||
// }
|
||||
// const db = await this.initDatabase()
|
||||
|
||||
// await db.run(
|
||||
// `
|
||||
// INSERT INTO ${CACHE_TABLE} (url, requestBody)
|
||||
// VALUES (?, ?);
|
||||
// `,
|
||||
// [
|
||||
// requestId,
|
||||
// JSON.stringify({
|
||||
// url: fullURL,
|
||||
// options: { method: 'GET', headers: HEADERS() },
|
||||
// }),
|
||||
// ],
|
||||
// )
|
||||
// console.log('Saved temporary task to queue:', task)
|
||||
// return
|
||||
// }
|
||||
|
||||
async getFromCache(key, ttl = 0) {
|
||||
const now = Date.now()
|
||||
|
||||
if (this.useLocalStorage) {
|
||||
const cachedItem = localStorage.getItem(key)
|
||||
if (!cachedItem) return null
|
||||
|
||||
const { value, timestamp } = JSON.parse(cachedItem)
|
||||
if (ttl > 0 && now - timestamp > ttl) {
|
||||
localStorage.removeItem(key) // Remove expired item
|
||||
return null
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
const db = await this.initDatabase()
|
||||
const result = await db.query(
|
||||
`
|
||||
SELECT value, timestamp FROM ${CACHE_TABLE} WHERE key = ?;
|
||||
`,
|
||||
[key],
|
||||
)
|
||||
|
||||
if (result.values.length === 0) return null
|
||||
|
||||
const { value, timestamp } = result.values[0]
|
||||
if (ttl > 0 && now - timestamp > ttl) {
|
||||
// Remove expired item
|
||||
await db.run(`DELETE FROM ${CACHE_TABLE} WHERE key = ?;`, [key])
|
||||
return null
|
||||
}
|
||||
|
||||
return JSON.parse(value)
|
||||
}
|
||||
|
||||
async cleanExpiredCache(ttl) {
|
||||
const now = Date.now()
|
||||
|
||||
if (this.useLocalStorage) {
|
||||
const keys = Object.keys(localStorage)
|
||||
for (const key of keys) {
|
||||
const cachedItem = localStorage.getItem(key)
|
||||
if (!cachedItem) continue
|
||||
|
||||
const { timestamp } = JSON.parse(cachedItem)
|
||||
if (now - timestamp > ttl) {
|
||||
localStorage.removeItem(key) // Remove expired item
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
const db = await this.initDatabase()
|
||||
await db.run(
|
||||
`
|
||||
DELETE FROM ${CACHE_TABLE} WHERE ? - timestamp > ?;
|
||||
`,
|
||||
[now, ttl],
|
||||
)
|
||||
}
|
||||
|
||||
async queueRequest(requestId, requestBody) {
|
||||
if (this.useLocalStorage) {
|
||||
const queue = JSON.parse(localStorage.getItem(QUEUE_TABLE)) || []
|
||||
queue.push({ requestId, requestBody })
|
||||
localStorage.setItem(QUEUE_TABLE, JSON.stringify(queue))
|
||||
return
|
||||
}
|
||||
|
||||
const db = await this.initDatabase()
|
||||
await db.run(
|
||||
`
|
||||
INSERT INTO ${QUEUE_TABLE} (url, requestBody)
|
||||
VALUES (?, ?);
|
||||
`,
|
||||
[requestId, JSON.stringify(requestBody)],
|
||||
)
|
||||
}
|
||||
|
||||
async syncQueuedRequests() {
|
||||
var queueSize = 0
|
||||
if (this.useLocalStorage) {
|
||||
const queue = JSON.parse(localStorage.getItem(QUEUE_TABLE)) || []
|
||||
console.log('LocalStore: queue: ', queue)
|
||||
queueSize = queue.length
|
||||
for (const request of queue) {
|
||||
try {
|
||||
await fetch(request.requestBody.url, request.requestBody.options)
|
||||
console.log('LocalStore: Synced request:', request)
|
||||
} catch (error) {
|
||||
console.error('LocalStore: Failed to sync request:', request, error)
|
||||
}
|
||||
}
|
||||
|
||||
// Clear the queue after syncing
|
||||
localStorage.removeItem(QUEUE_TABLE)
|
||||
localStorage.removeItem(OFFLINE_TASK)
|
||||
return queueSize > 0
|
||||
}
|
||||
|
||||
const db = await this.initDatabase()
|
||||
const result = await db.query(`SELECT * FROM ${QUEUE_TABLE};`)
|
||||
queueSize = result.values.length
|
||||
for (const request of result.values) {
|
||||
try {
|
||||
await fetch(
|
||||
request.requestBody.url,
|
||||
JSON.parse(request.requestBody.options),
|
||||
)
|
||||
console.log('Synced request:', request)
|
||||
} catch (error) {
|
||||
console.error('Failed to sync request:', request, error)
|
||||
}
|
||||
}
|
||||
|
||||
// Clear the queue after syncing
|
||||
await db.run(`DELETE FROM ${QUEUE_TABLE};`)
|
||||
return queueSize > 0
|
||||
}
|
||||
}
|
||||
|
||||
export const localStore = new LocalStore()
|
||||
33
src/utils/SyncManager.jsx
Normal file
33
src/utils/SyncManager.jsx
Normal file
@@ -0,0 +1,33 @@
|
||||
import { CreateChore, SaveChore } from './Fetcher'
|
||||
import { localStore } from './LocalStore'
|
||||
|
||||
class SyncManager {
|
||||
async syncTasks() {
|
||||
console.log('SYNCMANAGER: Starting sync process for offline tasks.')
|
||||
const offlineTasks = (await localStore.getFromCache('offlineTasks')) || []
|
||||
for (const task of offlineTasks) {
|
||||
// if task.needSync then it's need to be created:
|
||||
var resp
|
||||
if (task.needSync) {
|
||||
resp = await CreateChore(task)
|
||||
} else {
|
||||
resp = await SaveChore(task)
|
||||
}
|
||||
if (!resp.ok) {
|
||||
console.log(
|
||||
`SYNCMANAGER: Failed to sync task with id: ${task.id}. Error: ${resp.statusText}`,
|
||||
)
|
||||
} else {
|
||||
console.log(
|
||||
`SYNCMANAGER: Successfully synced task with id: ${task.id}.`,
|
||||
)
|
||||
console.log(`SYNCMANAGER: Response:`, resp)
|
||||
}
|
||||
}
|
||||
// Clear the offline tasks cache after syncing
|
||||
await localStore.saveToCache('offlineTasks', [])
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
export const syncManager = new SyncManager()
|
||||
@@ -1,28 +1,36 @@
|
||||
import Cookies from 'js-cookie'
|
||||
import { API_URL } from '../Config'
|
||||
import { RefreshToken } from './Fetcher'
|
||||
|
||||
import { Network } from '@capacitor/network'
|
||||
import { Preferences } from '@capacitor/preferences'
|
||||
import Cookies from 'js-cookie'
|
||||
import murmurhash from 'murmurhash'
|
||||
import { API_URL } from '../Config'
|
||||
import { networkManager } from '../hooks/NetworkManager'
|
||||
import { RefreshToken } from './Fetcher'
|
||||
import { localStore } from './LocalStore'
|
||||
|
||||
class ApiManager {
|
||||
constructor() {
|
||||
this.customServerURL = `${API_URL}/api/v1`
|
||||
this.initialized = false
|
||||
}
|
||||
|
||||
async init() {
|
||||
if (this.initialized) {
|
||||
return
|
||||
}
|
||||
|
||||
const { value: serverURL } = await Preferences.get({
|
||||
key: 'customServerUrl',
|
||||
})
|
||||
|
||||
this.customServerURL = `${serverURL || API_URL}/api/v1`
|
||||
await localStore.initDatabase()
|
||||
this.initialized = true
|
||||
}
|
||||
|
||||
getApiURL() {
|
||||
return this.customServerURL
|
||||
}
|
||||
|
||||
updateApiURL(url) {
|
||||
this.customServerURL = url
|
||||
this.init()
|
||||
@@ -31,23 +39,51 @@ class ApiManager {
|
||||
|
||||
export const apiManager = new ApiManager()
|
||||
|
||||
export function Fetch(url, options) {
|
||||
export async function Fetch(url, options) {
|
||||
if (!isTokenValid()) {
|
||||
// store current location in cookie
|
||||
Cookies.set('ca_redirect', window.location.pathname)
|
||||
// Assuming you have a function isTokenValid() that checks token validity
|
||||
window.location.href = '/login' // Redirect to login page
|
||||
// return Promise.reject("Token is not valid");
|
||||
window.location.href = '/login'
|
||||
}
|
||||
|
||||
if (!options) {
|
||||
options = {}
|
||||
}
|
||||
options.headers = { ...options.headers, ...HEADERS() }
|
||||
|
||||
const baseURL = apiManager.getApiURL()
|
||||
|
||||
const fullURL = `${baseURL}${url}`
|
||||
return fetch(fullURL, options)
|
||||
|
||||
const networkStatus = await Network.getStatus()
|
||||
|
||||
if (!networkStatus.connected) {
|
||||
return handleOfflineRequest(fullURL, options)
|
||||
}
|
||||
|
||||
// Online: Perform the fetch
|
||||
try {
|
||||
const response = await fetch(fullURL, options)
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.clone().json()
|
||||
const optionsHash = murmurhash.v3(JSON.stringify(options))
|
||||
await localStore.saveToCache(fullURL + optionsHash, data)
|
||||
networkManager.setOnline()
|
||||
} else if (
|
||||
response.status === 503 ||
|
||||
response.type === 'opaque' ||
|
||||
response.status === 0
|
||||
) {
|
||||
networkManager.setOffline()
|
||||
return handleOfflineRequest(fullURL, options)
|
||||
}
|
||||
// return promise that resolves to response object:
|
||||
return Promise.resolve(response)
|
||||
} catch (error) {
|
||||
networkManager.setOffline()
|
||||
console.error('Fetch error:', error)
|
||||
// throw error
|
||||
return handleOfflineRequest(fullURL, options)
|
||||
}
|
||||
}
|
||||
|
||||
export const HEADERS = () => {
|
||||
@@ -61,14 +97,13 @@ export const isTokenValid = () => {
|
||||
const expiration = localStorage.getItem('ca_expiration')
|
||||
const token = localStorage.getItem('ca_token')
|
||||
|
||||
if (localStorage.getItem('ca_token')) {
|
||||
if (token) {
|
||||
const now = new Date()
|
||||
const expire = new Date(expiration)
|
||||
if (now < expire) {
|
||||
if (now.getTime() + 24 * 60 * 60 * 1000 > expire.getTime()) {
|
||||
refreshAccessToken()
|
||||
}
|
||||
|
||||
return true
|
||||
} else {
|
||||
localStorage.removeItem('ca_token')
|
||||
@@ -92,3 +127,38 @@ export const refreshAccessToken = () => {
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async function handleOfflineRequest(url, options) {
|
||||
// if get request then attempt to fetch from cache otherewise queue it :
|
||||
if (options.method === 'GET') {
|
||||
return attemptFetchFromCache(url, options)
|
||||
} else {
|
||||
// Queue the request for later processing
|
||||
const requestId = murmurhash.v3(JSON.stringify({ url, options }))
|
||||
await localStore.queueRequest(requestId, { url, options })
|
||||
console.log('Request queued for later processing:', requestId)
|
||||
return Promise.reject(new Error('Offline and request queued: ' + requestId))
|
||||
}
|
||||
}
|
||||
async function attemptFetchFromCache(url, options) {
|
||||
const optionsHash = murmurhash.v3(JSON.stringify(options))
|
||||
const cachedData = await localStore.getFromCache(url + optionsHash)
|
||||
networkManager.setOffline()
|
||||
|
||||
if (cachedData) {
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => cachedData,
|
||||
})
|
||||
} else {
|
||||
return Promise.reject(
|
||||
new Error(
|
||||
'No cached data found for URL: ' +
|
||||
url +
|
||||
' with options hash: ' +
|
||||
optionsHash,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user