feat: make core pages local-first offline
This commit is contained in:
@@ -4,6 +4,8 @@ import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||
import 'package:miaoji_zhang/shared/api/backend_identity.dart';
|
||||
import 'package:miaoji_zhang/shared/services/session_store.dart';
|
||||
|
||||
enum BackendAvailability { unknown, online, offline }
|
||||
|
||||
class ApiClient {
|
||||
ApiClient._();
|
||||
static final ApiClient instance = ApiClient._();
|
||||
@@ -12,6 +14,9 @@ class ApiClient {
|
||||
static const _legacyTokenKey = 'auth_token';
|
||||
static final _tokenKey = 'auth_token_${BackendIdentity.scope}';
|
||||
static final sessionExpired = ValueNotifier<int>(0);
|
||||
static final availability = ValueNotifier<BackendAvailability>(
|
||||
BackendAvailability.unknown,
|
||||
);
|
||||
static bool _handlingUnauthorized = false;
|
||||
|
||||
static const String baseUrl = BackendIdentity.baseUrl;
|
||||
@@ -31,6 +36,10 @@ class ApiClient {
|
||||
)
|
||||
..interceptors.add(
|
||||
InterceptorsWrapper(
|
||||
onResponse: (response, handler) {
|
||||
availability.value = BackendAvailability.online;
|
||||
handler.next(response);
|
||||
},
|
||||
onRequest: (options, handler) async {
|
||||
final token = await _storage.read(key: _tokenKey);
|
||||
if (token != null) {
|
||||
@@ -39,6 +48,11 @@ class ApiClient {
|
||||
handler.next(options);
|
||||
},
|
||||
onError: (error, handler) async {
|
||||
if (isConnectivityError(error)) {
|
||||
availability.value = BackendAvailability.offline;
|
||||
} else if (error.response != null) {
|
||||
availability.value = BackendAvailability.online;
|
||||
}
|
||||
final unauthorized = error.response?.statusCode == 401;
|
||||
final data = error.response?.data;
|
||||
final aiDenied =
|
||||
@@ -74,6 +88,18 @@ class ApiClient {
|
||||
}
|
||||
|
||||
Future<String?> readToken() => _storage.read(key: _tokenKey);
|
||||
|
||||
Future<void> probe() async {
|
||||
try {
|
||||
await dio.get<void>(
|
||||
'/api/public/brand',
|
||||
options: Options(receiveTimeout: const Duration(seconds: 10)),
|
||||
);
|
||||
} catch (_) {
|
||||
// The interceptor owns the reachability state transition.
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> clearToken() async {
|
||||
await _storage.delete(key: _tokenKey);
|
||||
await _storage.delete(key: _legacyTokenKey);
|
||||
@@ -91,8 +117,9 @@ String apiErrorMessage(Object e) {
|
||||
if (e is StateError) return e.message;
|
||||
if (e is DioException) {
|
||||
final data = e.response?.data;
|
||||
if (data is Map && data['message'] != null)
|
||||
if (data is Map && data['message'] != null) {
|
||||
return data['message'] as String;
|
||||
}
|
||||
if (e.type == DioExceptionType.connectionTimeout ||
|
||||
e.type == DioExceptionType.connectionError ||
|
||||
e.type == DioExceptionType.receiveTimeout) {
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:miaoji_zhang/shared/api/api_client.dart';
|
||||
import 'package:miaoji_zhang/shared/api/backend_identity.dart';
|
||||
import 'package:miaoji_zhang/shared/services/current_ledger_store.dart';
|
||||
import 'package:miaoji_zhang/shared/services/local_export_service.dart';
|
||||
import 'package:miaoji_zhang/shared/services/session_store.dart';
|
||||
@@ -19,6 +23,15 @@ class AiCompanion {
|
||||
roastLevel = json['roastLevel'] as int,
|
||||
stickerFrequency = json['stickerFrequency'] as int,
|
||||
proactiveLevel = json['proactiveLevel'] as int;
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'avatarKey': avatarKey,
|
||||
'personaKey': personaKey,
|
||||
'customName': customName,
|
||||
'roastLevel': roastLevel,
|
||||
'stickerFrequency': stickerFrequency,
|
||||
'proactiveLevel': proactiveLevel,
|
||||
};
|
||||
}
|
||||
|
||||
class UserProfile {
|
||||
@@ -49,8 +62,9 @@ class UserProfile {
|
||||
required this.nickname,
|
||||
required this.appMode,
|
||||
required this.onboardingDone,
|
||||
this.aiCompanion,
|
||||
this.aiEnabled = false,
|
||||
}) : aiCompanion = null;
|
||||
});
|
||||
}
|
||||
|
||||
class AuthLoginResult {
|
||||
@@ -120,6 +134,20 @@ class AuthApi {
|
||||
}
|
||||
}
|
||||
|
||||
static Future<AiCompanion?> cachedCompanion() async {
|
||||
final userId = SessionStore.instance.userId;
|
||||
if (userId == null) return null;
|
||||
final value = (await SharedPreferences.getInstance()).getString(
|
||||
_companionCacheKey(userId),
|
||||
);
|
||||
if (value == null) return null;
|
||||
try {
|
||||
return AiCompanion.fromJson(jsonDecode(value) as Map<String, dynamic>);
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
static Future<UserProfile> completeOnboarding({
|
||||
required String appMode,
|
||||
required String avatarKey,
|
||||
@@ -272,13 +300,24 @@ class AuthApi {
|
||||
);
|
||||
}
|
||||
|
||||
static Future<void> _cacheProfile(UserProfile profile) =>
|
||||
SessionStore.instance.activateAccount(
|
||||
userId: profile.userId,
|
||||
username: profile.username,
|
||||
nickname: profile.nickname,
|
||||
appMode: profile.appMode,
|
||||
onboardingDone: profile.onboardingDone,
|
||||
aiEnabled: profile.aiEnabled,
|
||||
static Future<void> _cacheProfile(UserProfile profile) async {
|
||||
await SessionStore.instance.activateAccount(
|
||||
userId: profile.userId,
|
||||
username: profile.username,
|
||||
nickname: profile.nickname,
|
||||
appMode: profile.appMode,
|
||||
onboardingDone: profile.onboardingDone,
|
||||
aiEnabled: profile.aiEnabled,
|
||||
);
|
||||
final companion = profile.aiCompanion;
|
||||
if (companion != null) {
|
||||
await (await SharedPreferences.getInstance()).setString(
|
||||
_companionCacheKey(profile.userId),
|
||||
jsonEncode(companion.toJson()),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
static String _companionCacheKey(int userId) =>
|
||||
'ai_companion_${BackendIdentity.scope}_$userId';
|
||||
}
|
||||
|
||||
@@ -244,6 +244,20 @@ class TxApi {
|
||||
static bool get _queueOfflineChanges =>
|
||||
SessionStore.instance.isAccount && SessionStore.instance.cloudSyncEnabled;
|
||||
|
||||
static MonthSummary monthLocal(int year, int month) => MonthSummary.fromJson(
|
||||
LocalDatabase.instance.monthSummary(year, month, _ledgerId),
|
||||
);
|
||||
|
||||
static Future<MonthSummary> monthRemote(int year, int month) async {
|
||||
final response = await _dio.get(
|
||||
'/api/transactions/month',
|
||||
queryParameters: {'year': year, 'month': month, 'ledgerId': _ledgerId},
|
||||
);
|
||||
final json = response.data as Map<String, dynamic>;
|
||||
LocalDatabase.instance.cacheMonthSummary(json);
|
||||
return MonthSummary.fromJson(json);
|
||||
}
|
||||
|
||||
static Future<MonthSummary> month(int year, int month) async {
|
||||
final session = SessionStore.instance;
|
||||
if (session.shouldUseLocalOnly) {
|
||||
@@ -330,6 +344,29 @@ class TxApi {
|
||||
}
|
||||
}
|
||||
|
||||
static PeriodStats periodStatsLocal(String period, DateTime anchor) =>
|
||||
PeriodStats.fromJson(
|
||||
LocalDatabase.instance.periodStats(period, anchor, _ledgerId),
|
||||
);
|
||||
|
||||
static Future<PeriodStats> periodStatsRemote(
|
||||
String period,
|
||||
DateTime anchor,
|
||||
) async {
|
||||
final response = await _dio.get(
|
||||
'/api/transactions/stats/period',
|
||||
queryParameters: {
|
||||
'period': period,
|
||||
'anchor':
|
||||
'${anchor.year.toString().padLeft(4, '0')}-'
|
||||
'${anchor.month.toString().padLeft(2, '0')}-'
|
||||
'${anchor.day.toString().padLeft(2, '0')}',
|
||||
'ledgerId': _ledgerId,
|
||||
},
|
||||
);
|
||||
return PeriodStats.fromJson(response.data as Map<String, dynamic>);
|
||||
}
|
||||
|
||||
static Future<List<CategoryItem>> categories(String type) async {
|
||||
final session = SessionStore.instance;
|
||||
if (session.shouldUseLocalOnly) {
|
||||
@@ -357,6 +394,24 @@ class TxApi {
|
||||
}
|
||||
}
|
||||
|
||||
static List<CategoryItem> categoriesLocal(String type) => LocalDatabase
|
||||
.instance
|
||||
.categories(type)
|
||||
.map(CategoryItem.fromJson)
|
||||
.toList();
|
||||
|
||||
static Future<List<CategoryItem>> categoriesRemote(String type) async {
|
||||
final response = await _dio.get(
|
||||
'/api/categories',
|
||||
queryParameters: {'type': type},
|
||||
);
|
||||
final values = response.data as List;
|
||||
LocalDatabase.instance.cacheCategories(values, replaceType: type);
|
||||
return values
|
||||
.map((item) => CategoryItem.fromJson(item as Map<String, dynamic>))
|
||||
.toList();
|
||||
}
|
||||
|
||||
static Future<TxItem> create({
|
||||
required int categoryId,
|
||||
required String type,
|
||||
@@ -532,7 +587,9 @@ class TxApi {
|
||||
includeDeleted: true,
|
||||
)?['updatedAt'];
|
||||
final session = SessionStore.instance;
|
||||
if (session.shouldUseLocalOnly || id < 0) {
|
||||
if (session.shouldUseLocalOnly ||
|
||||
ApiClient.availability.value == BackendAvailability.offline ||
|
||||
id < 0) {
|
||||
LocalDatabase.instance.softDeleteTransaction(id);
|
||||
if (_queueOfflineChanges) {
|
||||
LocalDatabase.instance.enqueueSync('transaction', id, 'delete', {
|
||||
@@ -613,7 +670,8 @@ class TxApi {
|
||||
|
||||
static Future<List<TxItem>> recycleBin() async {
|
||||
final session = SessionStore.instance;
|
||||
if (session.shouldUseLocalOnly) {
|
||||
if (session.shouldUseLocalOnly ||
|
||||
ApiClient.availability.value == BackendAvailability.offline) {
|
||||
return LocalDatabase.instance
|
||||
.recycleBin(_ledgerId)
|
||||
.map(TxItem.fromJson)
|
||||
@@ -638,13 +696,32 @@ class TxApi {
|
||||
}
|
||||
}
|
||||
|
||||
static List<TxItem> recycleBinLocal() => LocalDatabase.instance
|
||||
.recycleBin(_ledgerId)
|
||||
.map(TxItem.fromJson)
|
||||
.toList();
|
||||
|
||||
static Future<List<TxItem>> recycleBinRemote() async {
|
||||
final response = await _dio.get(
|
||||
'/api/transactions/recycle-bin',
|
||||
queryParameters: {'ledgerId': _ledgerId},
|
||||
);
|
||||
final values = response.data as List;
|
||||
LocalDatabase.instance.cacheTransactions(values);
|
||||
return values
|
||||
.map((item) => TxItem.fromJson(item as Map<String, dynamic>))
|
||||
.toList();
|
||||
}
|
||||
|
||||
static Future<TxItem> restore(int id) async {
|
||||
final baseUpdatedAt = LocalDatabase.instance.transaction(
|
||||
id,
|
||||
includeDeleted: true,
|
||||
)?['updatedAt'];
|
||||
final session = SessionStore.instance;
|
||||
if (session.shouldUseLocalOnly || id < 0) {
|
||||
if (session.shouldUseLocalOnly ||
|
||||
ApiClient.availability.value == BackendAvailability.offline ||
|
||||
id < 0) {
|
||||
final local = LocalDatabase.instance.restoreTransaction(id);
|
||||
if (_queueOfflineChanges) {
|
||||
LocalDatabase.instance.enqueueSync('transaction', id, 'restore', {
|
||||
@@ -984,6 +1061,18 @@ class BudgetApi {
|
||||
static final _dio = ApiClient.instance.dio;
|
||||
static int get _ledgerId => CurrentLedgerStore.instance.currentId ?? 1;
|
||||
|
||||
static BudgetsData getLocal(int year, int month) => BudgetsData.fromJson(
|
||||
LocalDatabase.instance.budgets(year, month, _ledgerId),
|
||||
);
|
||||
|
||||
static Future<BudgetsData> getRemote(int year, int month) async {
|
||||
final response = await _dio.get(
|
||||
'/api/budgets',
|
||||
queryParameters: {'year': year, 'month': month, 'ledgerId': _ledgerId},
|
||||
);
|
||||
return BudgetsData.fromJson(response.data as Map<String, dynamic>);
|
||||
}
|
||||
|
||||
static Future<BudgetsData> get(int year, int month) async {
|
||||
final session = SessionStore.instance;
|
||||
if (session.shouldUseLocalOnly) {
|
||||
@@ -1294,6 +1383,29 @@ class ReportApi {
|
||||
static final _dio = ApiClient.instance.dio;
|
||||
static int get _ledgerId => CurrentLedgerStore.instance.currentId ?? 1;
|
||||
|
||||
static PeriodReport local(String period, DateTime anchor) =>
|
||||
_localPeriod(period, anchor);
|
||||
|
||||
static Future<PeriodReport> remote(String period, DateTime anchor) async {
|
||||
if (period == 'month') {
|
||||
return PeriodReport.fromMonthly(await monthly(anchor.year, anchor.month));
|
||||
}
|
||||
final path = period == 'week'
|
||||
? '/api/reports/weekly'
|
||||
: '/api/reports/yearly';
|
||||
final query = period == 'week'
|
||||
? {
|
||||
'date':
|
||||
'${anchor.year.toString().padLeft(4, '0')}-'
|
||||
'${anchor.month.toString().padLeft(2, '0')}-'
|
||||
'${anchor.day.toString().padLeft(2, '0')}',
|
||||
'ledgerId': _ledgerId,
|
||||
}
|
||||
: {'year': anchor.year, 'ledgerId': _ledgerId};
|
||||
final response = await _dio.get(path, queryParameters: query);
|
||||
return PeriodReport.fromJson(response.data as Map<String, dynamic>);
|
||||
}
|
||||
|
||||
static Future<PeriodReport> weekly(DateTime date) async {
|
||||
if (SessionStore.instance.shouldUseLocalOnly) {
|
||||
return _localPeriod('week', date);
|
||||
@@ -1595,7 +1707,8 @@ class CategoryApi {
|
||||
'type': type,
|
||||
};
|
||||
final session = SessionStore.instance;
|
||||
if (session.shouldUseLocalOnly) {
|
||||
if (session.shouldUseLocalOnly ||
|
||||
ApiClient.availability.value == BackendAvailability.offline) {
|
||||
final local = LocalDatabase.instance.createCategory(
|
||||
name,
|
||||
iconKey,
|
||||
@@ -1649,7 +1762,9 @@ class CategoryApi {
|
||||
'sortOrder': sortOrder,
|
||||
};
|
||||
final session = SessionStore.instance;
|
||||
if (session.shouldUseLocalOnly || id < 0) {
|
||||
if (session.shouldUseLocalOnly ||
|
||||
ApiClient.availability.value == BackendAvailability.offline ||
|
||||
id < 0) {
|
||||
final local = LocalDatabase.instance.updateCategory(
|
||||
id,
|
||||
name,
|
||||
@@ -1662,16 +1777,31 @@ class CategoryApi {
|
||||
}
|
||||
return CategoryItem.fromJson(local);
|
||||
}
|
||||
final response = await _dio.put('/api/categories/$id', data: payload);
|
||||
final json = response.data as Map<String, dynamic>;
|
||||
LocalDatabase.instance.cacheCategories([json]);
|
||||
return CategoryItem.fromJson(json);
|
||||
try {
|
||||
final response = await _dio.put('/api/categories/$id', data: payload);
|
||||
final json = response.data as Map<String, dynamic>;
|
||||
LocalDatabase.instance.cacheCategories([json]);
|
||||
return CategoryItem.fromJson(json);
|
||||
} catch (error) {
|
||||
if (!isConnectivityError(error) || !session.isAccount) rethrow;
|
||||
final local = LocalDatabase.instance.updateCategory(
|
||||
id,
|
||||
name,
|
||||
iconKey,
|
||||
colorKey,
|
||||
sortOrder,
|
||||
);
|
||||
LocalDatabase.instance.enqueueSync('category', id, 'update', payload);
|
||||
return CategoryItem.fromJson(local);
|
||||
}
|
||||
}
|
||||
|
||||
static Future<void> reorder(String type, List<int> categoryIds) async {
|
||||
LocalDatabase.instance.reorderCategories(type, categoryIds);
|
||||
final session = SessionStore.instance;
|
||||
if (session.shouldUseLocalOnly || categoryIds.any((id) => id < 0)) {
|
||||
if (session.shouldUseLocalOnly ||
|
||||
ApiClient.availability.value == BackendAvailability.offline ||
|
||||
categoryIds.any((id) => id < 0)) {
|
||||
if (session.isAccount && session.cloudSyncEnabled) {
|
||||
LocalDatabase.instance.enqueueSync('category', 0, 'reorder', {
|
||||
'type': type,
|
||||
@@ -1680,15 +1810,25 @@ class CategoryApi {
|
||||
}
|
||||
return;
|
||||
}
|
||||
await _dio.put(
|
||||
'/api/categories/reorder',
|
||||
data: {'type': type, 'categoryIds': categoryIds},
|
||||
);
|
||||
try {
|
||||
await _dio.put(
|
||||
'/api/categories/reorder',
|
||||
data: {'type': type, 'categoryIds': categoryIds},
|
||||
);
|
||||
} catch (error) {
|
||||
if (!isConnectivityError(error) || !session.isAccount) rethrow;
|
||||
LocalDatabase.instance.enqueueSync('category', 0, 'reorder', {
|
||||
'type': type,
|
||||
'categoryIds': categoryIds,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
static Future<void> delete(int id) async {
|
||||
final session = SessionStore.instance;
|
||||
if (session.shouldUseLocalOnly || id < 0) {
|
||||
if (session.shouldUseLocalOnly ||
|
||||
ApiClient.availability.value == BackendAvailability.offline ||
|
||||
id < 0) {
|
||||
LocalDatabase.instance.deleteCategory(id);
|
||||
if (session.isAccount && session.cloudSyncEnabled) {
|
||||
LocalDatabase.instance.enqueueSync('category', id, 'delete', {
|
||||
@@ -1697,7 +1837,13 @@ class CategoryApi {
|
||||
}
|
||||
return;
|
||||
}
|
||||
await _dio.delete('/api/categories/$id');
|
||||
LocalDatabase.instance.deleteCategory(id);
|
||||
try {
|
||||
await _dio.delete('/api/categories/$id');
|
||||
LocalDatabase.instance.deleteCategory(id);
|
||||
} catch (error) {
|
||||
if (!isConnectivityError(error) || !session.isAccount) rethrow;
|
||||
LocalDatabase.instance.deleteCategory(id);
|
||||
LocalDatabase.instance.enqueueSync('category', id, 'delete', {'id': id});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:miaoji_zhang/shared/api/api_client.dart';
|
||||
import 'package:miaoji_zhang/shared/api/backend_identity.dart';
|
||||
import 'package:miaoji_zhang/shared/services/session_store.dart';
|
||||
|
||||
class BrandConfig {
|
||||
@@ -41,6 +45,13 @@ class AvatarItem {
|
||||
defaultName = j['defaultName'] as String,
|
||||
speechTic = j['speechTic'] as String? ?? '',
|
||||
imageUrl = j['imageUrl'] as String?;
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'key': key,
|
||||
'defaultName': defaultName,
|
||||
'speechTic': speechTic,
|
||||
'imageUrl': imageUrl,
|
||||
};
|
||||
}
|
||||
|
||||
class PersonaItem {
|
||||
@@ -50,6 +61,13 @@ class PersonaItem {
|
||||
name = j['name'] as String,
|
||||
description = j['description'] as String? ?? '',
|
||||
sampleLine = j['sampleLine'] as String? ?? '';
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'key': key,
|
||||
'name': name,
|
||||
'description': description,
|
||||
'sampleLine': sampleLine,
|
||||
};
|
||||
}
|
||||
|
||||
class CompanionDisplay {
|
||||
@@ -145,16 +163,59 @@ class PublicConfigApi {
|
||||
|
||||
static Future<List<AvatarItem>> avatars() async {
|
||||
final res = await _dio.get('/api/public/avatars');
|
||||
return (res.data as List)
|
||||
final values = (res.data as List)
|
||||
.map((e) => AvatarItem.fromJson(e as Map<String, dynamic>))
|
||||
.toList();
|
||||
await _cacheCatalog(
|
||||
_avatarCacheKey,
|
||||
values.map((e) => e.toJson()).toList(),
|
||||
);
|
||||
return values;
|
||||
}
|
||||
|
||||
static Future<List<PersonaItem>> personas() async {
|
||||
final res = await _dio.get('/api/public/personas');
|
||||
return (res.data as List)
|
||||
final values = (res.data as List)
|
||||
.map((e) => PersonaItem.fromJson(e as Map<String, dynamic>))
|
||||
.toList();
|
||||
await _cacheCatalog(
|
||||
_personaCacheKey,
|
||||
values.map((e) => e.toJson()).toList(),
|
||||
);
|
||||
return values;
|
||||
}
|
||||
|
||||
static Future<List<AvatarItem>> cachedAvatars() async =>
|
||||
(await _cachedCatalog(_avatarCacheKey)).map(AvatarItem.fromJson).toList();
|
||||
|
||||
static Future<List<PersonaItem>> cachedPersonas() async =>
|
||||
(await _cachedCatalog(
|
||||
_personaCacheKey,
|
||||
)).map(PersonaItem.fromJson).toList();
|
||||
|
||||
static String get _avatarCacheKey =>
|
||||
'public_avatars_${BackendIdentity.scope}';
|
||||
static String get _personaCacheKey =>
|
||||
'public_personas_${BackendIdentity.scope}';
|
||||
|
||||
static Future<void> _cacheCatalog(
|
||||
String key,
|
||||
List<Map<String, dynamic>> values,
|
||||
) async {
|
||||
await (await SharedPreferences.getInstance()).setString(
|
||||
key,
|
||||
jsonEncode(values),
|
||||
);
|
||||
}
|
||||
|
||||
static Future<List<Map<String, dynamic>>> _cachedCatalog(String key) async {
|
||||
final value = (await SharedPreferences.getInstance()).getString(key);
|
||||
if (value == null) return const [];
|
||||
try {
|
||||
return (jsonDecode(value) as List).cast<Map<String, dynamic>>().toList();
|
||||
} catch (_) {
|
||||
return const [];
|
||||
}
|
||||
}
|
||||
|
||||
static String? _normalizeName(String? value) {
|
||||
|
||||
Reference in New Issue
Block a user