refactor(apps): 重构数据层目录结构并新增启动预热编排器
This commit is contained in:
Vendored
-6
@@ -1,6 +0,0 @@
|
||||
class CacheEntry<T> {
|
||||
final T value;
|
||||
final DateTime fetchedAt;
|
||||
|
||||
const CacheEntry({required this.value, required this.fetchedAt});
|
||||
}
|
||||
-27
@@ -1,27 +0,0 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'hybrid_cache_store.dart';
|
||||
|
||||
class CacheInvalidator {
|
||||
final HybridCacheStore? _store;
|
||||
final Set<String> _invalidated = <String>{};
|
||||
|
||||
CacheInvalidator({HybridCacheStore? store}) : _store = store;
|
||||
|
||||
void invalidate(String key) {
|
||||
_invalidated.add(key);
|
||||
final store = _store;
|
||||
if (store != null) {
|
||||
unawaited(store.remove(key));
|
||||
}
|
||||
}
|
||||
|
||||
void invalidateCalendarDay(DateTime date) {
|
||||
final month = '${date.year}-${date.month.toString().padLeft(2, '0')}';
|
||||
final day = '$month-${date.day.toString().padLeft(2, '0')}';
|
||||
invalidate('calendar:day:$day');
|
||||
invalidate('calendar:month:$month');
|
||||
}
|
||||
|
||||
bool wasInvalidated(String key) => _invalidated.contains(key);
|
||||
}
|
||||
Vendored
+337
@@ -1,5 +1,342 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
class CacheEntry<T> {
|
||||
const CacheEntry({required this.value, required this.fetchedAt});
|
||||
|
||||
final T value;
|
||||
final DateTime fetchedAt;
|
||||
}
|
||||
|
||||
abstract class CacheStore {
|
||||
Future<T?> read<T>(String key);
|
||||
Future<void> write<T>(String key, T value);
|
||||
Future<void> remove(String key);
|
||||
}
|
||||
|
||||
class MemoryCacheStore implements CacheStore {
|
||||
final Map<String, Object?> _values = <String, Object?>{};
|
||||
|
||||
@override
|
||||
Future<T?> read<T>(String key) async {
|
||||
final value = _values[key];
|
||||
if (value is T) {
|
||||
return value;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> write<T>(String key, T value) async {
|
||||
_values[key] = value;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> remove(String key) async {
|
||||
_values.remove(key);
|
||||
}
|
||||
}
|
||||
|
||||
class SharedPrefsCacheStore implements CacheStore {
|
||||
SharedPrefsCacheStore({required SharedPreferences prefs}) : _prefs = prefs;
|
||||
|
||||
final SharedPreferences _prefs;
|
||||
|
||||
@override
|
||||
Future<T?> read<T>(String key) async {
|
||||
final raw = _prefs.getString(key);
|
||||
if (raw == null) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return CacheCodec.decode<T>(raw);
|
||||
} catch (_) {
|
||||
await _prefs.remove(key);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> write<T>(String key, T value) async {
|
||||
final encoded = CacheCodec.encode<T>(value);
|
||||
await _prefs.setString(key, encoded);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> remove(String key) {
|
||||
return _prefs.remove(key);
|
||||
}
|
||||
}
|
||||
|
||||
class PersistentCacheStore implements CacheStore {
|
||||
SharedPreferences? _prefs;
|
||||
Future<SharedPreferences>? _prefsFuture;
|
||||
final Map<String, Object?> _fallbackValues = <String, Object?>{};
|
||||
|
||||
PersistentCacheStore({SharedPreferences? prefs}) : _prefs = prefs;
|
||||
|
||||
Future<SharedPreferences?> _getPrefs() {
|
||||
if (_prefs != null) {
|
||||
return Future<SharedPreferences?>.value(_prefs);
|
||||
}
|
||||
final inFlight = _prefsFuture;
|
||||
if (inFlight != null) {
|
||||
return inFlight.then<SharedPreferences?>((prefs) => prefs);
|
||||
}
|
||||
final created = SharedPreferences.getInstance();
|
||||
_prefsFuture = created;
|
||||
return created
|
||||
.then<SharedPreferences?>((prefs) {
|
||||
_prefs = prefs;
|
||||
return prefs;
|
||||
})
|
||||
.catchError((_) {
|
||||
_prefsFuture = null;
|
||||
return null;
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Future<T?> read<T>(String key) async {
|
||||
final prefs = await _getPrefs();
|
||||
if (prefs == null) {
|
||||
final value = _fallbackValues[key];
|
||||
if (value is T) {
|
||||
return value;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
final store = SharedPrefsCacheStore(prefs: prefs);
|
||||
return store.read<T>(key);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> write<T>(String key, T value) async {
|
||||
final prefs = await _getPrefs();
|
||||
if (prefs == null) {
|
||||
_fallbackValues[key] = value;
|
||||
return;
|
||||
}
|
||||
final store = SharedPrefsCacheStore(prefs: prefs);
|
||||
await store.write<T>(key, value);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> remove(String key) async {
|
||||
final prefs = await _getPrefs();
|
||||
if (prefs == null) {
|
||||
_fallbackValues.remove(key);
|
||||
return;
|
||||
}
|
||||
final store = SharedPrefsCacheStore(prefs: prefs);
|
||||
await store.remove(key);
|
||||
}
|
||||
}
|
||||
|
||||
class HybridCacheStore {
|
||||
final CacheStore memory;
|
||||
final CacheStore persistent;
|
||||
final Map<String, Future<dynamic>> _inflight = <String, Future<dynamic>>{};
|
||||
|
||||
HybridCacheStore({required this.memory, required this.persistent});
|
||||
|
||||
Future<T?> read<T>(String key) async {
|
||||
final memoryValue = await memory.read<T>(key);
|
||||
if (memoryValue != null) {
|
||||
return memoryValue;
|
||||
}
|
||||
final persistentValue = await persistent.read<T>(key);
|
||||
if (persistentValue != null) {
|
||||
await memory.write(key, persistentValue);
|
||||
}
|
||||
return persistentValue;
|
||||
}
|
||||
|
||||
Future<void> write<T>(String key, T value) async {
|
||||
await memory.write<T>(key, value);
|
||||
await persistent.write<T>(key, value);
|
||||
}
|
||||
|
||||
Future<void> remove(String key) async {
|
||||
await memory.remove(key);
|
||||
await persistent.remove(key);
|
||||
}
|
||||
|
||||
Future<T> getOrLoad<T>(String key, {required Future<T> Function() loader}) {
|
||||
final running = _inflight[key];
|
||||
if (running != null) {
|
||||
return running.then((value) => value as T);
|
||||
}
|
||||
|
||||
final future = () async {
|
||||
final cached = await read<T>(key);
|
||||
if (cached != null) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
final loaded = await loader();
|
||||
await write<T>(key, loaded);
|
||||
return loaded;
|
||||
}();
|
||||
|
||||
_inflight[key] = future;
|
||||
return future.whenComplete(() {
|
||||
_inflight.remove(key);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
class CacheInvalidator {
|
||||
final HybridCacheStore? _store;
|
||||
|
||||
CacheInvalidator({HybridCacheStore? store}) : _store = store;
|
||||
|
||||
void invalidate(String key) {
|
||||
final store = _store;
|
||||
if (store != null) {
|
||||
unawaited(store.remove(key));
|
||||
}
|
||||
}
|
||||
|
||||
void invalidateCalendarDay(DateTime date) {
|
||||
final month = '${date.year}-${date.month.toString().padLeft(2, '0')}';
|
||||
final day = '$month-${date.day.toString().padLeft(2, '0')}';
|
||||
invalidate('calendar:day:$day');
|
||||
invalidate('calendar:month:$month');
|
||||
}
|
||||
}
|
||||
|
||||
class CacheCodec {
|
||||
static String encode<T>(T value) {
|
||||
final payload = <String, Object?>{'type': '$T'};
|
||||
if (value is CacheEntry) {
|
||||
payload['entryType'] = '${value.value.runtimeType}';
|
||||
payload['fetchedAt'] = value.fetchedAt.toIso8601String();
|
||||
payload['value'] = _encodeValue(value.value);
|
||||
} else {
|
||||
payload['value'] = _encodeValue(value);
|
||||
}
|
||||
return jsonEncode(payload);
|
||||
}
|
||||
|
||||
static T decode<T>(String raw) {
|
||||
final decoded = jsonDecode(raw);
|
||||
if (decoded is! Map<String, dynamic>) {
|
||||
throw const FormatException('Invalid cache payload');
|
||||
}
|
||||
|
||||
final value = decoded['value'];
|
||||
|
||||
if (T.toString().startsWith('CacheEntry<')) {
|
||||
final fetchedAtRaw = decoded['fetchedAt'];
|
||||
final fetchedAt = DateTime.parse(fetchedAtRaw as String);
|
||||
final entryType = (decoded['entryType'] as String?) ?? 'Object?';
|
||||
final decodedValue = _decodeValue(entryType, value);
|
||||
|
||||
switch (entryType) {
|
||||
case 'String':
|
||||
return CacheEntry<String>(
|
||||
value: decodedValue as String,
|
||||
fetchedAt: fetchedAt,
|
||||
)
|
||||
as T;
|
||||
case 'int':
|
||||
return CacheEntry<int>(
|
||||
value: decodedValue as int,
|
||||
fetchedAt: fetchedAt,
|
||||
)
|
||||
as T;
|
||||
case 'double':
|
||||
return CacheEntry<double>(
|
||||
value: decodedValue as double,
|
||||
fetchedAt: fetchedAt,
|
||||
)
|
||||
as T;
|
||||
case 'bool':
|
||||
return CacheEntry<bool>(
|
||||
value: decodedValue as bool,
|
||||
fetchedAt: fetchedAt,
|
||||
)
|
||||
as T;
|
||||
default:
|
||||
return CacheEntry<Object?>(value: decodedValue, fetchedAt: fetchedAt)
|
||||
as T;
|
||||
}
|
||||
}
|
||||
|
||||
final type = (decoded['type'] as String?) ?? '$T';
|
||||
return _decodeValue(type, value) as T;
|
||||
}
|
||||
|
||||
static Object? _encodeValue(Object? value) {
|
||||
if (value == null || value is String || value is num || value is bool) {
|
||||
return value;
|
||||
}
|
||||
if (value is DateTime) {
|
||||
return value.toIso8601String();
|
||||
}
|
||||
if (value is List) {
|
||||
return value.map(_encodeValue).toList();
|
||||
}
|
||||
if (value is Map) {
|
||||
return value.map((k, v) => MapEntry(k.toString(), _encodeValue(v)));
|
||||
}
|
||||
|
||||
throw StateError('Unsupported cached value type: ${value.runtimeType}');
|
||||
}
|
||||
|
||||
static Object? _decodeValue(String type, Object? raw) {
|
||||
switch (type) {
|
||||
case 'String':
|
||||
return raw as String? ?? '';
|
||||
case 'int':
|
||||
if (raw is int) {
|
||||
return raw;
|
||||
}
|
||||
if (raw is num) {
|
||||
return raw.toInt();
|
||||
}
|
||||
throw const FormatException('Invalid int cache payload');
|
||||
case 'double':
|
||||
if (raw is double) {
|
||||
return raw;
|
||||
}
|
||||
if (raw is num) {
|
||||
return raw.toDouble();
|
||||
}
|
||||
throw const FormatException('Invalid double cache payload');
|
||||
case 'bool':
|
||||
return raw as bool? ?? false;
|
||||
case 'DateTime':
|
||||
return DateTime.parse(raw as String);
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
final listType = _extractListType(type);
|
||||
if (listType != null) {
|
||||
if (raw is! List) {
|
||||
throw FormatException('Invalid list cache payload for type $type');
|
||||
}
|
||||
return raw
|
||||
.map((item) => _decodeValue(listType, item))
|
||||
.toList(growable: false);
|
||||
}
|
||||
|
||||
if (raw is Map) {
|
||||
return Map<String, dynamic>.from(raw);
|
||||
}
|
||||
|
||||
return raw;
|
||||
}
|
||||
|
||||
static String? _extractListType(String type) {
|
||||
final listMatch = RegExp(r'^List<(.+)>$').firstMatch(type);
|
||||
if (listMatch == null) {
|
||||
return null;
|
||||
}
|
||||
return listMatch.group(1);
|
||||
}
|
||||
}
|
||||
|
||||
+31
-6
@@ -1,20 +1,29 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'cache_entry.dart';
|
||||
import 'cache_policy.dart';
|
||||
import 'hybrid_cache_store.dart';
|
||||
import 'cache_store.dart';
|
||||
|
||||
abstract class CachedRepository<T> {
|
||||
final HybridCacheStore store;
|
||||
final CachePolicy policy;
|
||||
final DateTime Function() now;
|
||||
final Object? Function(T value) encodeValue;
|
||||
final T Function(Object? raw) decodeValue;
|
||||
final Map<String, Future<void>> _refreshInFlight = <String, Future<void>>{};
|
||||
|
||||
CachedRepository({
|
||||
required this.store,
|
||||
required this.policy,
|
||||
DateTime Function()? now,
|
||||
}) : now = now ?? DateTime.now;
|
||||
Object? Function(T value)? encodeValue,
|
||||
T Function(Object? raw)? decodeValue,
|
||||
}) : now = now ?? DateTime.now,
|
||||
encodeValue = encodeValue ?? _defaultEncode,
|
||||
decodeValue = decodeValue ?? _defaultDecode;
|
||||
|
||||
static Object? _defaultEncode<T>(T value) => value;
|
||||
|
||||
static T _defaultDecode<T>(Object? raw) => raw as T;
|
||||
|
||||
Future<T> getOrLoad({
|
||||
required String key,
|
||||
@@ -58,16 +67,32 @@ abstract class CachedRepository<T> {
|
||||
}
|
||||
|
||||
Future<CacheEntry<T>?> readCacheEntry(String key) {
|
||||
return store.read<CacheEntry<T>>(key);
|
||||
return _readDecodedEntry(key);
|
||||
}
|
||||
|
||||
Future<void> writeCacheEntry(String key, T value) {
|
||||
return store.write<CacheEntry<T>>(
|
||||
return store.write<CacheEntry<Object?>>(
|
||||
key,
|
||||
CacheEntry<T>(value: value, fetchedAt: now()),
|
||||
CacheEntry<Object?>(value: encodeValue(value), fetchedAt: now()),
|
||||
);
|
||||
}
|
||||
|
||||
Future<CacheEntry<T>?> _readDecodedEntry(String key) async {
|
||||
final entry = await store.read<CacheEntry<Object?>>(key);
|
||||
if (entry == null) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return CacheEntry<T>(
|
||||
value: decodeValue(entry.value),
|
||||
fetchedAt: entry.fetchedAt,
|
||||
);
|
||||
} catch (_) {
|
||||
await store.remove(key);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> removeCacheKey(String key) {
|
||||
return store.remove(key);
|
||||
}
|
||||
|
||||
-55
@@ -1,55 +0,0 @@
|
||||
import 'memory_cache_store.dart';
|
||||
import 'persistent_cache_store.dart';
|
||||
|
||||
class HybridCacheStore {
|
||||
final MemoryCacheStore memory;
|
||||
final PersistentCacheStore persistent;
|
||||
final Map<String, Future<dynamic>> _inflight = <String, Future<dynamic>>{};
|
||||
|
||||
HybridCacheStore({required this.memory, required this.persistent});
|
||||
|
||||
Future<T?> read<T>(String key) async {
|
||||
final memoryValue = await memory.read<T>(key);
|
||||
if (memoryValue != null) {
|
||||
return memoryValue;
|
||||
}
|
||||
final persistentValue = await persistent.read<T>(key);
|
||||
if (persistentValue != null) {
|
||||
await memory.write(key, persistentValue);
|
||||
}
|
||||
return persistentValue;
|
||||
}
|
||||
|
||||
Future<void> write<T>(String key, T value) async {
|
||||
await memory.write<T>(key, value);
|
||||
await persistent.write<T>(key, value);
|
||||
}
|
||||
|
||||
Future<void> remove(String key) async {
|
||||
await memory.remove(key);
|
||||
await persistent.remove(key);
|
||||
}
|
||||
|
||||
Future<T> getOrLoad<T>(String key, {required Future<T> Function() loader}) {
|
||||
final running = _inflight[key];
|
||||
if (running != null) {
|
||||
return running.then((value) => value as T);
|
||||
}
|
||||
|
||||
final future = () async {
|
||||
final cached = await read<T>(key);
|
||||
if (cached != null) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
final loaded = await loader();
|
||||
await write<T>(key, loaded);
|
||||
return loaded;
|
||||
}();
|
||||
|
||||
_inflight[key] = future;
|
||||
return future.whenComplete(() {
|
||||
_inflight.remove(key);
|
||||
});
|
||||
}
|
||||
}
|
||||
-24
@@ -1,24 +0,0 @@
|
||||
import 'cache_store.dart';
|
||||
|
||||
class MemoryCacheStore implements CacheStore {
|
||||
final Map<String, Object?> _values = <String, Object?>{};
|
||||
|
||||
@override
|
||||
Future<T?> read<T>(String key) async {
|
||||
final value = _values[key];
|
||||
if (value is T) {
|
||||
return value;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> write<T>(String key, T value) async {
|
||||
_values[key] = value;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> remove(String key) async {
|
||||
_values.remove(key);
|
||||
}
|
||||
}
|
||||
-24
@@ -1,24 +0,0 @@
|
||||
import 'cache_store.dart';
|
||||
|
||||
class PersistentCacheStore implements CacheStore {
|
||||
final Map<String, Object?> _values = <String, Object?>{};
|
||||
|
||||
@override
|
||||
Future<T?> read<T>(String key) async {
|
||||
final value = _values[key];
|
||||
if (value is T) {
|
||||
return value;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> write<T>(String key, T value) async {
|
||||
_values[key] = value;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> remove(String key) async {
|
||||
_values.remove(key);
|
||||
}
|
||||
}
|
||||
@@ -1,182 +0,0 @@
|
||||
class ReminderPayload {
|
||||
final String eventId;
|
||||
final String title;
|
||||
final DateTime startAt;
|
||||
final DateTime? endAt;
|
||||
final String timezone;
|
||||
final String? location;
|
||||
final String? notes;
|
||||
final String? color;
|
||||
final ReminderPayloadMode mode;
|
||||
final List<String> aggregateIds;
|
||||
final int? fireTimeBucket;
|
||||
final int version;
|
||||
|
||||
const ReminderPayload({
|
||||
required this.eventId,
|
||||
required this.title,
|
||||
required this.startAt,
|
||||
required this.timezone,
|
||||
this.endAt,
|
||||
this.location,
|
||||
this.notes,
|
||||
this.color,
|
||||
this.mode = ReminderPayloadMode.single,
|
||||
this.aggregateIds = const [],
|
||||
this.fireTimeBucket,
|
||||
this.version = 1,
|
||||
});
|
||||
|
||||
ReminderPayload copyWith({
|
||||
String? eventId,
|
||||
String? title,
|
||||
DateTime? startAt,
|
||||
DateTime? endAt,
|
||||
String? timezone,
|
||||
String? location,
|
||||
String? notes,
|
||||
String? color,
|
||||
ReminderPayloadMode? mode,
|
||||
List<String>? aggregateIds,
|
||||
int? fireTimeBucket,
|
||||
int? version,
|
||||
}) {
|
||||
return ReminderPayload(
|
||||
eventId: eventId ?? this.eventId,
|
||||
title: title ?? this.title,
|
||||
startAt: startAt ?? this.startAt,
|
||||
endAt: endAt ?? this.endAt,
|
||||
timezone: timezone ?? this.timezone,
|
||||
location: location ?? this.location,
|
||||
notes: notes ?? this.notes,
|
||||
color: color ?? this.color,
|
||||
mode: mode ?? this.mode,
|
||||
aggregateIds: aggregateIds ?? this.aggregateIds,
|
||||
fireTimeBucket: fireTimeBucket ?? this.fireTimeBucket,
|
||||
version: version ?? this.version,
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'eventId': eventId,
|
||||
'title': title,
|
||||
'startAt': startAt.toIso8601String(),
|
||||
'endAt': endAt?.toIso8601String(),
|
||||
'timezone': timezone,
|
||||
'location': location,
|
||||
'notes': notes,
|
||||
'color': color,
|
||||
'mode': mode.value,
|
||||
'aggregateIds': aggregateIds,
|
||||
'fireTimeBucket': fireTimeBucket,
|
||||
'version': version,
|
||||
};
|
||||
}
|
||||
|
||||
factory ReminderPayload.fromJson(Map<String, dynamic> json) {
|
||||
final eventId = (json['eventId'] as String?) ?? '';
|
||||
if (eventId.isEmpty) {
|
||||
throw const FormatException('eventId is required');
|
||||
}
|
||||
|
||||
final startAtRaw = json['startAt'] as String?;
|
||||
if (startAtRaw == null || startAtRaw.isEmpty) {
|
||||
throw const FormatException('startAt is required');
|
||||
}
|
||||
final parsedStartAt = DateTime.parse(startAtRaw);
|
||||
|
||||
final mode = ReminderPayloadMode.fromValue(
|
||||
(json['mode'] as String?) ?? 'single',
|
||||
);
|
||||
final aggregateIds = (json['aggregateIds'] as List<dynamic>? ?? const [])
|
||||
.map((item) => item.toString())
|
||||
.toList();
|
||||
if (mode == ReminderPayloadMode.aggregate && aggregateIds.length < 2) {
|
||||
throw const FormatException('aggregateIds must contain at least 2 items');
|
||||
}
|
||||
|
||||
return ReminderPayload(
|
||||
eventId: eventId,
|
||||
title: (json['title'] as String?) ?? '',
|
||||
startAt: parsedStartAt,
|
||||
endAt: json['endAt'] != null
|
||||
? DateTime.parse(json['endAt'] as String)
|
||||
: null,
|
||||
timezone: (json['timezone'] as String?) ?? 'UTC',
|
||||
location: json['location'] as String?,
|
||||
notes: json['notes'] as String?,
|
||||
color: json['color'] as String?,
|
||||
mode: mode,
|
||||
aggregateIds: aggregateIds,
|
||||
fireTimeBucket: json['fireTimeBucket'] as int?,
|
||||
version: (json['version'] as int?) ?? 1,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
if (identical(this, other)) {
|
||||
return true;
|
||||
}
|
||||
return other is ReminderPayload &&
|
||||
other.eventId == eventId &&
|
||||
other.title == title &&
|
||||
other.startAt == startAt &&
|
||||
other.endAt == endAt &&
|
||||
other.timezone == timezone &&
|
||||
other.location == location &&
|
||||
other.notes == notes &&
|
||||
other.color == color &&
|
||||
other.mode == mode &&
|
||||
_listEquals(other.aggregateIds, aggregateIds) &&
|
||||
other.fireTimeBucket == fireTimeBucket &&
|
||||
other.version == version;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode {
|
||||
return Object.hash(
|
||||
eventId,
|
||||
title,
|
||||
startAt,
|
||||
endAt,
|
||||
timezone,
|
||||
location,
|
||||
notes,
|
||||
color,
|
||||
mode,
|
||||
Object.hashAll(aggregateIds),
|
||||
fireTimeBucket,
|
||||
version,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
enum ReminderPayloadMode {
|
||||
single('single'),
|
||||
aggregate('aggregate');
|
||||
|
||||
const ReminderPayloadMode(this.value);
|
||||
|
||||
final String value;
|
||||
|
||||
static ReminderPayloadMode fromValue(String raw) {
|
||||
return ReminderPayloadMode.values.firstWhere(
|
||||
(item) => item.value == raw,
|
||||
orElse: () => ReminderPayloadMode.single,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
bool _listEquals(List<String> left, List<String> right) {
|
||||
if (left.length != right.length) {
|
||||
return false;
|
||||
}
|
||||
for (var i = 0; i < left.length; i++) {
|
||||
if (left[i] != right[i]) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
class UserProfile {
|
||||
final String id;
|
||||
final String username;
|
||||
final String? phone;
|
||||
final String? avatarUrl;
|
||||
final String? bio;
|
||||
|
||||
const UserProfile({
|
||||
required this.id,
|
||||
required this.username,
|
||||
this.phone,
|
||||
this.avatarUrl,
|
||||
this.bio,
|
||||
});
|
||||
|
||||
factory UserProfile.fromJson(Map<String, dynamic> json) {
|
||||
return UserProfile(
|
||||
id: json['id'] as String,
|
||||
username: json['username'] as String,
|
||||
phone: json['phone'] as String?,
|
||||
avatarUrl: json['avatar_url'] as String?,
|
||||
bio: json['bio'] as String?,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class UserUpdateRequest {
|
||||
final String? username;
|
||||
final String? avatarUrl;
|
||||
final String? bio;
|
||||
|
||||
const UserUpdateRequest({this.username, this.avatarUrl, this.bio});
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
if (username != null) 'username': username,
|
||||
if (avatarUrl != null) 'avatar_url': avatarUrl,
|
||||
if (bio != null) 'bio': bio,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
import 'dart:convert';
|
||||
import 'package:dio/dio.dart';
|
||||
import 'api_exception.dart';
|
||||
import 'api_interceptor.dart';
|
||||
import 'i_api_client.dart';
|
||||
import '../storage/token_storage.dart';
|
||||
|
||||
class ApiClient implements IApiClient {
|
||||
final Dio _dio;
|
||||
final TokenStorage _tokenStorage;
|
||||
final ApiInterceptor _interceptor;
|
||||
|
||||
factory ApiClient({
|
||||
required String baseUrl,
|
||||
required TokenStorage tokenStorage,
|
||||
Dio? dio,
|
||||
}) {
|
||||
final effectiveDio =
|
||||
dio ??
|
||||
Dio(
|
||||
BaseOptions(
|
||||
baseUrl: baseUrl,
|
||||
connectTimeout: const Duration(seconds: 10),
|
||||
receiveTimeout: const Duration(seconds: 20),
|
||||
sendTimeout: const Duration(seconds: 20),
|
||||
),
|
||||
);
|
||||
final interceptor = ApiInterceptor(
|
||||
tokenStorage: tokenStorage,
|
||||
dio: effectiveDio,
|
||||
);
|
||||
effectiveDio.interceptors.add(interceptor);
|
||||
return ApiClient._(
|
||||
dio: effectiveDio,
|
||||
tokenStorage: tokenStorage,
|
||||
interceptor: interceptor,
|
||||
);
|
||||
}
|
||||
|
||||
ApiClient._({
|
||||
required Dio dio,
|
||||
required TokenStorage tokenStorage,
|
||||
required ApiInterceptor interceptor,
|
||||
}) : _dio = dio,
|
||||
_tokenStorage = tokenStorage,
|
||||
_interceptor = interceptor;
|
||||
|
||||
Dio get dio => _dio;
|
||||
|
||||
void resetInterceptor() {
|
||||
_interceptor.reset();
|
||||
}
|
||||
|
||||
void setRefreshCallback(Future<bool> Function(String) refresh) {
|
||||
_interceptor.onTokenRefresh = () async {
|
||||
final token = await _tokenStorage.getRefreshToken();
|
||||
if (token == null) return false;
|
||||
return refresh(token);
|
||||
};
|
||||
}
|
||||
|
||||
void setAuthFailureCallback(Future<void> Function() onAuthFailure) {
|
||||
_interceptor.onAuthFailure = onAuthFailure;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Response<T>> get<T>(String path, {Options? options}) async {
|
||||
try {
|
||||
return await _dio.get<T>(path, options: options);
|
||||
} on DioException catch (e) {
|
||||
throw ApiException.fromDioError(e);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Response<T>> post<T>(
|
||||
String path, {
|
||||
dynamic data,
|
||||
Options? options,
|
||||
}) async {
|
||||
try {
|
||||
return await _dio.post<T>(path, data: data, options: options);
|
||||
} on DioException catch (e) {
|
||||
throw ApiException.fromDioError(e);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Response<T>> patch<T>(
|
||||
String path, {
|
||||
dynamic data,
|
||||
Options? options,
|
||||
}) async {
|
||||
try {
|
||||
return await _dio.patch<T>(path, data: data, options: options);
|
||||
} on DioException catch (e) {
|
||||
throw ApiException.fromDioError(e);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Response<T>> put<T>(
|
||||
String path, {
|
||||
dynamic data,
|
||||
Options? options,
|
||||
}) async {
|
||||
try {
|
||||
return await _dio.put<T>(path, data: data, options: options);
|
||||
} on DioException catch (e) {
|
||||
throw ApiException.fromDioError(e);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Response<T>> delete<T>(
|
||||
String path, {
|
||||
dynamic data,
|
||||
Options? options,
|
||||
}) async {
|
||||
try {
|
||||
return await _dio.delete<T>(path, data: data, options: options);
|
||||
} on DioException catch (e) {
|
||||
throw ApiException.fromDioError(e);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Stream<String>> getSseLines(
|
||||
String path, {
|
||||
Map<String, String>? headers,
|
||||
}) async {
|
||||
try {
|
||||
final response = await _dio.get<ResponseBody>(
|
||||
path,
|
||||
options: Options(responseType: ResponseType.stream, headers: headers),
|
||||
);
|
||||
final responseBody = response.data;
|
||||
if (responseBody == null) {
|
||||
return const Stream<String>.empty();
|
||||
}
|
||||
return responseBody.stream
|
||||
.cast<List<int>>()
|
||||
.transform(utf8.decoder)
|
||||
.transform(const LineSplitter());
|
||||
} on DioException catch (e) {
|
||||
throw ApiException.fromDioError(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:dio/dio.dart';
|
||||
import '../../core/l10n/l10n.dart';
|
||||
import 'error_code_mapper.dart';
|
||||
|
||||
abstract class ApiException implements Exception {
|
||||
final String message;
|
||||
final int? statusCode;
|
||||
final String? errorCode;
|
||||
final Map<String, dynamic>? errorParams;
|
||||
|
||||
const ApiException(
|
||||
this.message, {
|
||||
this.statusCode,
|
||||
this.errorCode,
|
||||
this.errorParams,
|
||||
});
|
||||
|
||||
@override
|
||||
String toString() => message;
|
||||
|
||||
factory ApiException.fromDioError(Object error) {
|
||||
if (error is ApiException) return error;
|
||||
if (error is DioException) {
|
||||
final response = error.response;
|
||||
final statusCode = response?.statusCode;
|
||||
final data = response?.data;
|
||||
|
||||
String detail;
|
||||
String? errorCode;
|
||||
Map<String, dynamic>? errorParams;
|
||||
final decodedData = _normalizeErrorData(data);
|
||||
|
||||
if (decodedData is Map<String, dynamic>) {
|
||||
detail =
|
||||
(decodedData['detail'] ??
|
||||
decodedData['message'] ??
|
||||
decodedData['error'])
|
||||
?.toString() ??
|
||||
L10n.current.errorRequestFailed;
|
||||
final code = decodedData['code'];
|
||||
if (code is String && code.trim().isNotEmpty) {
|
||||
errorCode = code;
|
||||
}
|
||||
final params = decodedData['params'];
|
||||
if (params is Map<String, dynamic>) {
|
||||
errorParams = params;
|
||||
} else if (params is Map) {
|
||||
errorParams = params.map(
|
||||
(key, value) => MapEntry(key.toString(), value),
|
||||
);
|
||||
}
|
||||
} else {
|
||||
detail = _networkErrorMessage(error);
|
||||
}
|
||||
|
||||
final localized = _localizeError(
|
||||
detail,
|
||||
statusCode,
|
||||
errorCode: errorCode,
|
||||
errorParams: errorParams,
|
||||
);
|
||||
|
||||
if (statusCode == 401) {
|
||||
return UnauthorizedException(message: localized, errorCode: errorCode);
|
||||
}
|
||||
if (statusCode == 422) {
|
||||
return ValidationException(
|
||||
localized,
|
||||
errors: data,
|
||||
statusCode: statusCode,
|
||||
errorCode: errorCode,
|
||||
errorParams: errorParams,
|
||||
);
|
||||
}
|
||||
return ServerException(
|
||||
localized,
|
||||
statusCode: statusCode,
|
||||
errorCode: errorCode,
|
||||
errorParams: errorParams,
|
||||
);
|
||||
}
|
||||
return ServerException(L10n.current.errorNetwork);
|
||||
}
|
||||
|
||||
static Map<String, dynamic>? _normalizeErrorData(dynamic data) {
|
||||
if (data is Map<String, dynamic>) {
|
||||
return data;
|
||||
}
|
||||
if (data is Map) {
|
||||
return data.map((key, value) => MapEntry(key.toString(), value));
|
||||
}
|
||||
if (data is String && data.trim().isNotEmpty) {
|
||||
try {
|
||||
final decoded = jsonDecode(data);
|
||||
if (decoded is Map<String, dynamic>) {
|
||||
return decoded;
|
||||
}
|
||||
if (decoded is Map) {
|
||||
return decoded.map((key, value) => MapEntry(key.toString(), value));
|
||||
}
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
static String _localizeError(
|
||||
String detail,
|
||||
int? statusCode, {
|
||||
String? errorCode,
|
||||
Map<String, dynamic>? errorParams,
|
||||
}) {
|
||||
final mapped = resolveErrorCodeMessage(errorCode, params: errorParams);
|
||||
if (mapped != null && mapped.isNotEmpty) {
|
||||
return mapped;
|
||||
}
|
||||
if (statusCode == 403) {
|
||||
return L10n.current.errorForbidden;
|
||||
}
|
||||
if (statusCode == 404) {
|
||||
return L10n.current.errorNotFound;
|
||||
}
|
||||
if (statusCode == 429) {
|
||||
return L10n.current.errorTooManyRequests;
|
||||
}
|
||||
if (statusCode != null && statusCode >= 500) {
|
||||
return L10n.current.errorServer;
|
||||
}
|
||||
return L10n.current.errorGenericSafe;
|
||||
}
|
||||
|
||||
static String _networkErrorMessage(DioException error) {
|
||||
if (error.type == DioExceptionType.connectionTimeout ||
|
||||
error.type == DioExceptionType.sendTimeout ||
|
||||
error.type == DioExceptionType.receiveTimeout) {
|
||||
return L10n.current.errorNetworkTimeout;
|
||||
}
|
||||
|
||||
if (error.type == DioExceptionType.connectionError ||
|
||||
error.type == DioExceptionType.unknown) {
|
||||
return L10n.current.errorNetworkUnavailable;
|
||||
}
|
||||
|
||||
return L10n.current.errorRequestFailed;
|
||||
}
|
||||
}
|
||||
|
||||
class ServerException extends ApiException {
|
||||
const ServerException(
|
||||
super.message, {
|
||||
super.statusCode,
|
||||
super.errorCode,
|
||||
super.errorParams,
|
||||
});
|
||||
}
|
||||
|
||||
class UnauthorizedException extends ApiException {
|
||||
UnauthorizedException({String? message, String? errorCode})
|
||||
: super(
|
||||
message ?? L10n.current.errorReLogin,
|
||||
statusCode: 401,
|
||||
errorCode: errorCode,
|
||||
);
|
||||
}
|
||||
|
||||
class ValidationException extends ApiException {
|
||||
final Map<String, dynamic>? errors;
|
||||
const ValidationException(
|
||||
super.message, {
|
||||
this.errors,
|
||||
super.statusCode,
|
||||
super.errorCode,
|
||||
super.errorParams,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
import 'package:dio/dio.dart';
|
||||
import '../storage/token_storage.dart';
|
||||
|
||||
class ApiInterceptor extends Interceptor {
|
||||
final TokenStorage tokenStorage;
|
||||
final Dio dio;
|
||||
final Duration refreshFailureCooldown;
|
||||
Future<bool> Function()? onTokenRefresh;
|
||||
Future<void> Function()? onAuthFailure;
|
||||
Future<bool>? _refreshFuture;
|
||||
Future<void>? _authFailureFuture;
|
||||
DateTime? _refreshBlockedUntil;
|
||||
|
||||
static const _retriedRequestKey = '_auth_retry_once';
|
||||
static const _refreshPathSuffix = '/api/v1/auth/sessions/refresh';
|
||||
|
||||
ApiInterceptor({
|
||||
required this.tokenStorage,
|
||||
required this.dio,
|
||||
this.refreshFailureCooldown = const Duration(seconds: 5),
|
||||
this.onTokenRefresh,
|
||||
});
|
||||
|
||||
@override
|
||||
void onRequest(
|
||||
RequestOptions options,
|
||||
RequestInterceptorHandler handler,
|
||||
) async {
|
||||
final token = await tokenStorage.getAccessToken();
|
||||
if (token != null) {
|
||||
options.headers['Authorization'] = 'Bearer $token';
|
||||
}
|
||||
handler.next(options);
|
||||
}
|
||||
|
||||
@override
|
||||
void onError(DioException err, ErrorInterceptorHandler handler) async {
|
||||
final requestOptions = err.requestOptions;
|
||||
final isUnauthorized = err.response?.statusCode == 401;
|
||||
final shouldHandleUnauthorized =
|
||||
isUnauthorized && _isAuthenticatedRequest(requestOptions);
|
||||
|
||||
if (err.response?.statusCode == 401 &&
|
||||
onTokenRefresh != null &&
|
||||
!_shouldSkipRefresh(requestOptions)) {
|
||||
final refreshed = await _refreshTokenSingleflight();
|
||||
if (refreshed) {
|
||||
final token = await tokenStorage.getAccessToken();
|
||||
if (token != null) {
|
||||
final retryHeaders = Map<String, dynamic>.from(requestOptions.headers)
|
||||
..['Authorization'] = 'Bearer $token';
|
||||
final retryExtra = Map<String, dynamic>.from(requestOptions.extra)
|
||||
..[_retriedRequestKey] = true;
|
||||
final retryOptions = requestOptions.copyWith(
|
||||
headers: retryHeaders,
|
||||
extra: retryExtra,
|
||||
);
|
||||
try {
|
||||
final response = await dio.fetch(retryOptions);
|
||||
handler.resolve(response);
|
||||
return;
|
||||
} on DioException {
|
||||
// Retry failed, proceed with original error.
|
||||
}
|
||||
}
|
||||
} else if (shouldHandleUnauthorized) {
|
||||
await _notifyAuthFailureSingleflight();
|
||||
}
|
||||
} else if (shouldHandleUnauthorized && _shouldSkipRefresh(requestOptions)) {
|
||||
await _notifyAuthFailureSingleflight();
|
||||
}
|
||||
handler.next(err);
|
||||
}
|
||||
|
||||
bool _isAuthenticatedRequest(RequestOptions options) {
|
||||
return options.headers['Authorization'] != null;
|
||||
}
|
||||
|
||||
Future<void> _notifyAuthFailureSingleflight() {
|
||||
final existing = _authFailureFuture;
|
||||
if (existing != null) {
|
||||
return existing;
|
||||
}
|
||||
final callback = onAuthFailure;
|
||||
if (callback == null) {
|
||||
return Future<void>.value();
|
||||
}
|
||||
|
||||
final future = callback().whenComplete(() {
|
||||
_authFailureFuture = null;
|
||||
});
|
||||
_authFailureFuture = future;
|
||||
return future;
|
||||
}
|
||||
|
||||
bool _shouldSkipRefresh(RequestOptions options) {
|
||||
final blockedUntil = _refreshBlockedUntil;
|
||||
if (blockedUntil != null && DateTime.now().isBefore(blockedUntil)) {
|
||||
return true;
|
||||
}
|
||||
return _normalizePath(options.path) == _refreshPathSuffix ||
|
||||
options.extra[_retriedRequestKey] == true;
|
||||
}
|
||||
|
||||
String _normalizePath(String rawPath) {
|
||||
final parsed = Uri.tryParse(rawPath);
|
||||
if (parsed == null) {
|
||||
return rawPath.replaceFirst(RegExp(r'/+$'), '');
|
||||
}
|
||||
final normalized = parsed.path.replaceFirst(RegExp(r'/+$'), '');
|
||||
return normalized.isEmpty ? '/' : normalized;
|
||||
}
|
||||
|
||||
Future<bool> _refreshTokenSingleflight() {
|
||||
final inflight = _refreshFuture;
|
||||
if (inflight != null) {
|
||||
return inflight;
|
||||
}
|
||||
final future = onTokenRefresh!().catchError((_) => false).whenComplete(() {
|
||||
_refreshFuture = null;
|
||||
});
|
||||
_refreshFuture = future;
|
||||
return future.then((refreshed) {
|
||||
if (refreshed) {
|
||||
_refreshBlockedUntil = null;
|
||||
} else {
|
||||
_refreshBlockedUntil = DateTime.now().add(refreshFailureCooldown);
|
||||
}
|
||||
return refreshed;
|
||||
});
|
||||
}
|
||||
|
||||
void reset() {
|
||||
_refreshFuture = null;
|
||||
_authFailureFuture = null;
|
||||
_refreshBlockedUntil = null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,244 @@
|
||||
import '../../core/l10n/l10n.dart';
|
||||
|
||||
String? mapErrorCodeToL10nKey(
|
||||
String? errorCode, {
|
||||
Map<String, dynamic>? params,
|
||||
}) {
|
||||
if (errorCode == null || errorCode.isEmpty) {
|
||||
return null;
|
||||
}
|
||||
|
||||
switch (errorCode) {
|
||||
case 'AGENT_RUN_INPUT_INVALID':
|
||||
return 'errorGenericSafe';
|
||||
case 'AGENT_RUN_MESSAGES_INVALID':
|
||||
return 'errorGenericSafe';
|
||||
case 'AGENT_INVALID_LAST_EVENT_ID':
|
||||
return 'errorAgentInvalidLastEventId';
|
||||
case 'AGENT_SSE_CONNECTION_LIMIT':
|
||||
return 'errorAgentSseConnectionLimit';
|
||||
case 'AGENT_ATTACHMENT_EMPTY':
|
||||
return 'errorAgentAttachmentEmpty';
|
||||
case 'AGENT_ATTACHMENT_TOO_LARGE':
|
||||
return 'errorAgentAttachmentTooLarge';
|
||||
case 'AGENT_AUDIO_UNSUPPORTED_FORMAT':
|
||||
return 'errorAgentAudioUnsupportedFormat';
|
||||
case 'AGENT_AUDIO_TOO_LARGE':
|
||||
return 'errorAgentAudioTooLarge';
|
||||
case 'AGENT_AUDIO_EMPTY':
|
||||
return 'errorAgentAudioEmpty';
|
||||
case 'AGENT_ASR_UNAVAILABLE':
|
||||
return 'errorAgentAsrUnavailable';
|
||||
case 'AGENT_FORBIDDEN':
|
||||
return 'errorForbidden';
|
||||
case 'AGENT_PAYLOAD_INVALID':
|
||||
return 'errorGenericSafe';
|
||||
case 'AGENT_ATTACHMENTS_TOO_MANY':
|
||||
return 'errorGenericSafe';
|
||||
case 'AGENT_SIGNED_IMAGE_URL_INVALID':
|
||||
return 'errorGenericSafe';
|
||||
case 'AGENT_ATTACHMENT_STORAGE_UNAVAILABLE':
|
||||
return 'errorServer';
|
||||
case 'AGENT_ATTACHMENT_UNSUPPORTED_TYPE':
|
||||
return 'errorGenericSafe';
|
||||
case 'AGENT_ATTACHMENT_UPLOAD_FAILED':
|
||||
return 'errorGenericSafe';
|
||||
case 'AGENT_ATTACHMENT_BUCKET_INVALID':
|
||||
return 'errorGenericSafe';
|
||||
case 'AGENT_ATTACHMENT_PATH_SCOPE_INVALID':
|
||||
return 'errorGenericSafe';
|
||||
case 'AGENT_SIGNED_URL_GENERATION_FAILED':
|
||||
return 'errorGenericSafe';
|
||||
case 'AGENT_SESSION_ID_INVALID':
|
||||
return 'errorGenericSafe';
|
||||
case 'AGENT_SESSION_NOT_FOUND':
|
||||
return 'errorNotFound';
|
||||
case 'AGENT_USER_ID_INVALID':
|
||||
return 'errorGenericSafe';
|
||||
case 'INVALID_BINARY_URL_HOST':
|
||||
return 'errorAgentInvalidBinaryUrl';
|
||||
case 'INVALID_BINARY_URL_BUCKET':
|
||||
return 'errorAgentInvalidBinaryUrl';
|
||||
case 'INVALID_BINARY_URL_PATH_SCOPE':
|
||||
return 'errorAgentInvalidBinaryUrl';
|
||||
case 'AUTH_SERVICE_UNAVAILABLE':
|
||||
return 'errorServer';
|
||||
case 'AUTH_TOO_MANY_REQUESTS':
|
||||
return 'errorTooManyRequests';
|
||||
case 'AUTH_VERIFICATION_CODE_INVALID':
|
||||
return 'errorGenericSafe';
|
||||
case 'AUTH_REFRESH_TOKEN_INVALID':
|
||||
return 'errorReLogin';
|
||||
case 'AUTH_REFRESH_TOKEN_MISSING':
|
||||
return 'errorReLogin';
|
||||
case 'AUTH_USER_NOT_FOUND':
|
||||
return 'errorNotFound';
|
||||
case 'AUTH_UNAUTHORIZED':
|
||||
return 'errorReLogin';
|
||||
case 'JWT_VERIFIER_NOT_CONFIGURED':
|
||||
return 'errorServer';
|
||||
case 'AUTOMATION_JOB_LIMIT_EXCEEDED':
|
||||
return 'errorGenericSafe';
|
||||
case 'AUTOMATION_SYSTEM_JOB_MODIFICATION_FORBIDDEN':
|
||||
return 'errorForbidden';
|
||||
case 'AUTOMATION_JOB_NOT_FOUND':
|
||||
return 'errorNotFound';
|
||||
case 'AUTOMATION_JOB_STORE_UNAVAILABLE':
|
||||
return 'errorServer';
|
||||
case 'NOT_FOUND':
|
||||
return 'errorNotFound';
|
||||
case 'LOOKUP_FAILED':
|
||||
return 'errorServer';
|
||||
case 'INTERNAL_ERROR':
|
||||
return 'errorServer';
|
||||
case 'MISSING_RUNTIME_ARGS':
|
||||
return 'errorGenericSafe';
|
||||
case 'TOOL_PENDING_APPROVAL':
|
||||
return 'errorGenericSafe';
|
||||
case 'TOOL_REJECTED':
|
||||
return 'errorForbidden';
|
||||
case 'USER_STORE_UNAVAILABLE':
|
||||
return 'errorServer';
|
||||
case 'USER_NOT_FOUND':
|
||||
return 'errorNotFound';
|
||||
case 'USER_UPDATE_FIELDS_EMPTY':
|
||||
return 'errorGenericSafe';
|
||||
case 'USER_AVATAR_UNSUPPORTED_TYPE':
|
||||
return 'errorGenericSafe';
|
||||
case 'USER_AVATAR_TOO_LARGE':
|
||||
return 'errorGenericSafe';
|
||||
case 'USER_AVATAR_EMPTY':
|
||||
return 'errorGenericSafe';
|
||||
case 'USER_AVATAR_UPLOAD_FAILED':
|
||||
return 'errorGenericSafe';
|
||||
case 'USER_AUTH_LOOKUP_UNAVAILABLE':
|
||||
return 'errorServer';
|
||||
case 'TODO_SERVICE_UNAVAILABLE':
|
||||
return 'errorServer';
|
||||
case 'TODO_NOT_FOUND':
|
||||
return 'errorNotFound';
|
||||
case 'TODO_ACCESS_FORBIDDEN':
|
||||
return 'errorForbidden';
|
||||
case 'TODO_REORDER_DUPLICATE_ID':
|
||||
return 'errorGenericSafe';
|
||||
case 'TODO_STATUS_INVALID':
|
||||
return 'errorGenericSafe';
|
||||
case 'TODO_PRIORITY_INVALID':
|
||||
return 'errorGenericSafe';
|
||||
case 'SCHEDULE_ITEM_INVALID_TIME_RANGE':
|
||||
return 'errorGenericSafe';
|
||||
case 'SCHEDULE_ITEM_STORE_UNAVAILABLE':
|
||||
return 'errorServer';
|
||||
case 'SCHEDULE_ITEM_NOT_FOUND':
|
||||
return 'errorNotFound';
|
||||
case 'SCHEDULE_ITEM_START_AT_TIMEZONE_REQUIRED':
|
||||
return 'errorGenericSafe';
|
||||
case 'SCHEDULE_ITEM_PAGE_INVALID':
|
||||
return 'errorGenericSafe';
|
||||
case 'SCHEDULE_ITEM_PAGE_SIZE_INVALID':
|
||||
return 'errorGenericSafe';
|
||||
case 'SCHEDULE_ITEM_SHARE_FORBIDDEN':
|
||||
return 'errorForbidden';
|
||||
case 'SCHEDULE_ITEM_SHARE_PERMISSION_EXCEEDED':
|
||||
return 'errorGenericSafe';
|
||||
case 'SCHEDULE_ITEM_SUBSCRIPTION_ALREADY_ACTIVE':
|
||||
return 'errorGenericSafe';
|
||||
case 'SCHEDULE_ITEM_INVITE_ALREADY_SUBSCRIBED':
|
||||
return 'errorGenericSafe';
|
||||
case 'SCHEDULE_ITEM_INVITE_ALREADY_PENDING':
|
||||
return 'errorGenericSafe';
|
||||
case 'SCHEDULE_ITEM_AUTH_LOOKUP_UNAVAILABLE':
|
||||
return 'errorServer';
|
||||
case 'SCHEDULE_ITEM_PENDING_INVITE_NOT_FOUND':
|
||||
return 'errorNotFound';
|
||||
case 'SCHEDULE_ITEM_ACCEPT_SUBSCRIPTION_FAILED':
|
||||
return 'errorGenericSafe';
|
||||
case 'SCHEDULE_ITEM_REJECT_SUBSCRIPTION_FAILED':
|
||||
return 'errorGenericSafe';
|
||||
case 'SCHEDULE_ITEM_DATETIME_TIMEZONE_REQUIRED':
|
||||
return 'errorGenericSafe';
|
||||
case 'SCHEDULE_ITEM_DATETIME_REQUIRED':
|
||||
return 'errorGenericSafe';
|
||||
case 'INBOX_MESSAGE_NOT_FOUND':
|
||||
return 'errorNotFound';
|
||||
case 'INBOX_MESSAGE_STORE_UNAVAILABLE':
|
||||
return 'errorServer';
|
||||
case 'MEMORIES_USER_NOT_FOUND':
|
||||
return 'errorNotFound';
|
||||
case 'MEMORIES_WORK_NOT_FOUND':
|
||||
return 'errorNotFound';
|
||||
case 'MEMORIES_SERVICE_UNAVAILABLE':
|
||||
return 'errorServer';
|
||||
case 'FRIEND_REQUEST_SELF_NOT_ALLOWED':
|
||||
return 'errorGenericSafe';
|
||||
case 'FRIEND_ALREADY_ACCEPTED':
|
||||
return 'errorGenericSafe';
|
||||
case 'FRIEND_REQUEST_BLOCKED':
|
||||
return 'errorGenericSafe';
|
||||
case 'FRIEND_REQUEST_ALREADY_SENT':
|
||||
return 'errorGenericSafe';
|
||||
case 'FRIENDSHIP_SERVICE_UNAVAILABLE':
|
||||
return 'errorServer';
|
||||
case 'FRIEND_REQUEST_NOT_FOUND':
|
||||
return 'errorNotFound';
|
||||
case 'FRIEND_REQUEST_FORBIDDEN':
|
||||
return 'errorForbidden';
|
||||
case 'FRIEND_REQUEST_NOT_PENDING':
|
||||
return 'errorGenericSafe';
|
||||
case 'FRIEND_INBOX_MESSAGE_NOT_FOUND':
|
||||
return 'errorNotFound';
|
||||
case 'FRIENDSHIP_DATA_INVALID':
|
||||
return 'errorGenericSafe';
|
||||
case 'FRIENDSHIP_NOT_FOUND':
|
||||
return 'errorNotFound';
|
||||
case 'FRIENDSHIP_REMOVE_REQUIRES_ACCEPTED':
|
||||
return 'errorGenericSafe';
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
String? resolveErrorCodeMessage(
|
||||
String? errorCode, {
|
||||
Map<String, dynamic>? params,
|
||||
}) {
|
||||
final key = mapErrorCodeToL10nKey(errorCode, params: params);
|
||||
if (key == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
switch (key) {
|
||||
case 'errorAgentSseConnectionLimit':
|
||||
return L10n.current.errorAgentSseConnectionLimit;
|
||||
case 'errorAgentAttachmentEmpty':
|
||||
return L10n.current.errorAgentAttachmentEmpty;
|
||||
case 'errorAgentAttachmentTooLarge':
|
||||
return L10n.current.errorAgentAttachmentTooLarge;
|
||||
case 'errorAgentAudioEmpty':
|
||||
return L10n.current.errorAgentAudioEmpty;
|
||||
case 'errorAgentAudioTooLarge':
|
||||
return L10n.current.errorAgentAudioTooLarge;
|
||||
case 'errorAgentAudioUnsupportedFormat':
|
||||
return L10n.current.errorAgentAudioUnsupportedFormat;
|
||||
case 'errorAgentAsrUnavailable':
|
||||
return L10n.current.errorAgentAsrUnavailable;
|
||||
case 'errorAgentInvalidLastEventId':
|
||||
return L10n.current.errorAgentInvalidLastEventId;
|
||||
case 'errorAgentInvalidBinaryUrl':
|
||||
return L10n.current.errorAgentInvalidBinaryUrl;
|
||||
case 'errorForbidden':
|
||||
return L10n.current.errorForbidden;
|
||||
case 'errorNotFound':
|
||||
return L10n.current.errorNotFound;
|
||||
case 'errorTooManyRequests':
|
||||
return L10n.current.errorTooManyRequests;
|
||||
case 'errorServer':
|
||||
return L10n.current.errorServer;
|
||||
case 'errorGenericSafe':
|
||||
return L10n.current.errorGenericSafe;
|
||||
case 'errorReLogin':
|
||||
return L10n.current.errorReLogin;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import 'package:dio/dio.dart';
|
||||
|
||||
abstract class IApiClient {
|
||||
Future<Response<T>> get<T>(String path, {Options? options});
|
||||
Future<Response<T>> post<T>(String path, {dynamic data, Options? options});
|
||||
Future<Response<T>> put<T>(String path, {dynamic data, Options? options});
|
||||
Future<Response<T>> patch<T>(String path, {dynamic data, Options? options});
|
||||
Future<Response<T>> delete<T>(String path, {dynamic data, Options? options});
|
||||
Future<Stream<String>> getSseLines(
|
||||
String path, {
|
||||
Map<String, String>? headers,
|
||||
});
|
||||
}
|
||||
@@ -1,60 +0,0 @@
|
||||
import '../../core/network/i_api_client.dart';
|
||||
import 'models/calendar_event.dart';
|
||||
|
||||
abstract class CalendarEventRepository {
|
||||
Future<List<CalendarEvent>> listByRange({
|
||||
required DateTime startAt,
|
||||
required DateTime endAt,
|
||||
});
|
||||
|
||||
Future<CalendarEvent> getById(String id);
|
||||
Future<void> acceptSubscription(String itemId);
|
||||
Future<void> rejectSubscription(String itemId);
|
||||
}
|
||||
|
||||
class CalendarEventRepositoryImpl implements CalendarEventRepository {
|
||||
final IApiClient _apiClient;
|
||||
static const _prefix = '/api/v1/schedule-items';
|
||||
|
||||
CalendarEventRepositoryImpl(this._apiClient);
|
||||
|
||||
@override
|
||||
Future<List<CalendarEvent>> listByRange({
|
||||
required DateTime startAt,
|
||||
required DateTime endAt,
|
||||
}) async {
|
||||
final start = Uri.encodeQueryComponent(startAt.toUtc().toIso8601String());
|
||||
final end = Uri.encodeQueryComponent(endAt.toUtc().toIso8601String());
|
||||
final response = await _apiClient.get<List<dynamic>>(
|
||||
'$_prefix?start_at=$start&end_at=$end',
|
||||
);
|
||||
final data = response.data;
|
||||
if (data == null) {
|
||||
throw StateError('Invalid listByRange response: empty payload');
|
||||
}
|
||||
return data
|
||||
.whereType<Map<String, dynamic>>()
|
||||
.map(CalendarEvent.fromJson)
|
||||
.toList(growable: false);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<CalendarEvent> getById(String id) async {
|
||||
final response = await _apiClient.get<Map<String, dynamic>>('$_prefix/$id');
|
||||
final event = response.data;
|
||||
if (event == null) {
|
||||
throw StateError('Invalid getById response: empty payload');
|
||||
}
|
||||
return CalendarEvent.fromJson(event);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> acceptSubscription(String itemId) {
|
||||
return _apiClient.post<void>('$_prefix/$itemId/accept');
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> rejectSubscription(String itemId) {
|
||||
return _apiClient.post<void>('$_prefix/$itemId/reject');
|
||||
}
|
||||
}
|
||||
@@ -1,98 +0,0 @@
|
||||
import '../cache/cache_policy.dart';
|
||||
import '../cache/cached_repository.dart';
|
||||
import '../../core/network/i_api_client.dart';
|
||||
import 'models/schedule_item_model.dart';
|
||||
|
||||
class CalendarRepository extends CachedRepository<List<ScheduleItemModel>> {
|
||||
final IApiClient _apiClient;
|
||||
static const _prefix = '/api/v1/schedule-items';
|
||||
|
||||
CalendarRepository({
|
||||
required super.store,
|
||||
required IApiClient apiClient,
|
||||
CachePolicy? policy,
|
||||
super.now,
|
||||
}) : _apiClient = apiClient,
|
||||
super(
|
||||
policy:
|
||||
policy ??
|
||||
const CachePolicy(
|
||||
softTtl: Duration(minutes: 2),
|
||||
hardTtl: Duration(minutes: 30),
|
||||
minRefreshInterval: Duration(minutes: 1),
|
||||
),
|
||||
);
|
||||
|
||||
static String dayKey(DateTime date) {
|
||||
final day =
|
||||
'${date.year}-${date.month.toString().padLeft(2, '0')}-${date.day.toString().padLeft(2, '0')}';
|
||||
return 'calendar:day:$day';
|
||||
}
|
||||
|
||||
static String monthKey(DateTime date) {
|
||||
return 'calendar:month:${date.year}-${date.month.toString().padLeft(2, '0')}';
|
||||
}
|
||||
|
||||
Future<List<ScheduleItemModel>> getDayEvents(
|
||||
DateTime date, {
|
||||
bool forceRefresh = false,
|
||||
}) async {
|
||||
final key = dayKey(date);
|
||||
final start = DateTime(date.year, date.month, date.day);
|
||||
final end = DateTime(date.year, date.month, date.day, 23, 59, 59);
|
||||
return getOrLoad(
|
||||
key: key,
|
||||
forceRefresh: forceRefresh,
|
||||
loadFromRemote: () => _listByRange(startAt: start, endAt: end),
|
||||
);
|
||||
}
|
||||
|
||||
Future<List<ScheduleItemModel>> getMonthEvents(
|
||||
DateTime monthStart, {
|
||||
bool forceRefresh = false,
|
||||
}) async {
|
||||
final key = monthKey(monthStart);
|
||||
final start = DateTime(monthStart.year, monthStart.month, 1);
|
||||
final end = DateTime(monthStart.year, monthStart.month + 1, 0, 23, 59, 59);
|
||||
return getOrLoad(
|
||||
key: key,
|
||||
forceRefresh: forceRefresh,
|
||||
loadFromRemote: () => _listByRange(startAt: start, endAt: end),
|
||||
);
|
||||
}
|
||||
|
||||
Future<ScheduleItemModel> getEventById(String id) async {
|
||||
final response = await _apiClient.get<Map<String, dynamic>>('$_prefix/$id');
|
||||
final data = response.data;
|
||||
if (data == null) {
|
||||
throw StateError('Invalid getEventById response: empty payload');
|
||||
}
|
||||
return ScheduleItemModel.fromJson(data);
|
||||
}
|
||||
|
||||
Future<List<ScheduleItemModel>> listEventsByRange({
|
||||
required DateTime start,
|
||||
required DateTime end,
|
||||
}) {
|
||||
return _listByRange(startAt: start, endAt: end);
|
||||
}
|
||||
|
||||
Future<List<ScheduleItemModel>> _listByRange({
|
||||
required DateTime startAt,
|
||||
required DateTime endAt,
|
||||
}) async {
|
||||
final start = Uri.encodeQueryComponent(startAt.toUtc().toIso8601String());
|
||||
final end = Uri.encodeQueryComponent(endAt.toUtc().toIso8601String());
|
||||
final response = await _apiClient.get<List<dynamic>>(
|
||||
'$_prefix?start_at=$start&end_at=$end',
|
||||
);
|
||||
final data = response.data;
|
||||
if (data == null) {
|
||||
throw StateError('Invalid listByRange response: empty payload');
|
||||
}
|
||||
return data
|
||||
.whereType<Map<String, dynamic>>()
|
||||
.map(ScheduleItemModel.fromJson)
|
||||
.toList(growable: false);
|
||||
}
|
||||
}
|
||||
@@ -1,88 +0,0 @@
|
||||
import '../../core/network/i_api_client.dart';
|
||||
import 'models/friend_request.dart';
|
||||
|
||||
abstract class FriendRepository {
|
||||
Future<List<FriendUser>> getFriends();
|
||||
Future<FriendRequest> getRequestById(String friendshipId);
|
||||
Future<Map<String, FriendRequest>> getRequestsByIds(
|
||||
List<String> friendshipIds,
|
||||
);
|
||||
Future<FriendRequest> acceptRequest(String friendshipId);
|
||||
Future<FriendRequest> declineRequest(String friendshipId);
|
||||
}
|
||||
|
||||
class FriendRepositoryImpl implements FriendRepository {
|
||||
final IApiClient _apiClient;
|
||||
static const _prefix = '/api/v1/friends';
|
||||
|
||||
FriendRepositoryImpl(this._apiClient);
|
||||
|
||||
@override
|
||||
Future<List<FriendUser>> getFriends() async {
|
||||
final response = await _apiClient.get<List<dynamic>>(_prefix);
|
||||
final data = response.data;
|
||||
if (data == null) {
|
||||
throw StateError('Invalid getFriends response: empty payload');
|
||||
}
|
||||
return data
|
||||
.map((item) => item as Map<String, dynamic>)
|
||||
.map(
|
||||
(item) => FriendUser.fromJson(item['friend'] as Map<String, dynamic>),
|
||||
)
|
||||
.toList(growable: false);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<FriendRequest> getRequestById(String friendshipId) async {
|
||||
final response = await _apiClient.get<Map<String, dynamic>>(
|
||||
'$_prefix/requests/$friendshipId',
|
||||
);
|
||||
final data = response.data;
|
||||
if (data == null) {
|
||||
throw StateError('Invalid getRequestById response: empty payload');
|
||||
}
|
||||
return FriendRequest.fromJson(data);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Map<String, FriendRequest>> getRequestsByIds(
|
||||
List<String> friendshipIds,
|
||||
) async {
|
||||
if (friendshipIds.isEmpty) {
|
||||
return const <String, FriendRequest>{};
|
||||
}
|
||||
|
||||
final pairs = await Future.wait(
|
||||
friendshipIds.map((id) async {
|
||||
final request = await getRequestById(id);
|
||||
return MapEntry(id, request);
|
||||
}),
|
||||
);
|
||||
|
||||
return Map<String, FriendRequest>.fromEntries(pairs);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<FriendRequest> acceptRequest(String friendshipId) async {
|
||||
final response = await _apiClient.post<Map<String, dynamic>>(
|
||||
'$_prefix/requests/$friendshipId/accept',
|
||||
);
|
||||
final data = response.data;
|
||||
if (data == null) {
|
||||
throw StateError('Invalid acceptRequest response: empty payload');
|
||||
}
|
||||
return FriendRequest.fromJson(data);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<FriendRequest> declineRequest(String friendshipId) async {
|
||||
final response = await _apiClient.post<Map<String, dynamic>>(
|
||||
'$_prefix/requests/$friendshipId/decline',
|
||||
);
|
||||
final data = response.data;
|
||||
if (data == null) {
|
||||
throw StateError('Invalid declineRequest response: empty payload');
|
||||
}
|
||||
return FriendRequest.fromJson(data);
|
||||
}
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
import '../../core/network/i_api_client.dart';
|
||||
import 'models/inbox_message.dart';
|
||||
|
||||
abstract class InboxRepository {
|
||||
Future<List<InboxMessage>> getMessages({bool? isRead});
|
||||
Future<InboxMessage> markAsRead(String messageId);
|
||||
}
|
||||
|
||||
class InboxRepositoryImpl implements InboxRepository {
|
||||
final IApiClient _apiClient;
|
||||
static const _prefix = '/api/v1/inbox/messages';
|
||||
|
||||
InboxRepositoryImpl(this._apiClient);
|
||||
|
||||
@override
|
||||
Future<List<InboxMessage>> getMessages({bool? isRead}) async {
|
||||
final queryParams = isRead != null ? '?is_read=$isRead' : '';
|
||||
final response = await _apiClient.get<List<dynamic>>(
|
||||
'$_prefix$queryParams',
|
||||
);
|
||||
final data = response.data;
|
||||
if (data == null) {
|
||||
throw StateError('Invalid getMessages response: empty payload');
|
||||
}
|
||||
return data
|
||||
.whereType<Map<String, dynamic>>()
|
||||
.map(InboxMessage.fromJson)
|
||||
.toList(growable: false);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<InboxMessage> markAsRead(String messageId) async {
|
||||
final response = await _apiClient.patch<Map<String, dynamic>>(
|
||||
'$_prefix/$messageId/read',
|
||||
);
|
||||
final data = response.data;
|
||||
if (data == null) {
|
||||
throw StateError('Invalid markAsRead response: empty payload');
|
||||
}
|
||||
return InboxMessage.fromJson(data);
|
||||
}
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
enum CalendarEventStatus { active, archived }
|
||||
|
||||
class CalendarEvent {
|
||||
final String id;
|
||||
final String title;
|
||||
final DateTime startAt;
|
||||
final DateTime? endAt;
|
||||
final CalendarEventStatus status;
|
||||
|
||||
const CalendarEvent({
|
||||
required this.id,
|
||||
required this.title,
|
||||
required this.startAt,
|
||||
required this.endAt,
|
||||
required this.status,
|
||||
});
|
||||
|
||||
factory CalendarEvent.fromJson(Map<String, dynamic> json) {
|
||||
return CalendarEvent(
|
||||
id: json['id'] as String,
|
||||
title: json['title'] as String,
|
||||
startAt: DateTime.parse(json['start_at'] as String).toLocal(),
|
||||
endAt: json['end_at'] == null
|
||||
? null
|
||||
: DateTime.parse(json['end_at'] as String).toLocal(),
|
||||
status: _calendarEventStatusFromApi(json['status'] as String),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
CalendarEventStatus _calendarEventStatusFromApi(String raw) {
|
||||
switch (raw) {
|
||||
case 'active':
|
||||
return CalendarEventStatus.active;
|
||||
case 'archived':
|
||||
return CalendarEventStatus.archived;
|
||||
default:
|
||||
throw StateError('Unsupported calendar event status: $raw');
|
||||
}
|
||||
}
|
||||
@@ -1,63 +0,0 @@
|
||||
enum FriendRequestStatus { pending, accepted, rejected }
|
||||
|
||||
class FriendUser {
|
||||
final String id;
|
||||
final String username;
|
||||
final String? avatarUrl;
|
||||
|
||||
const FriendUser({
|
||||
required this.id,
|
||||
required this.username,
|
||||
required this.avatarUrl,
|
||||
});
|
||||
|
||||
factory FriendUser.fromJson(Map<String, dynamic> json) {
|
||||
return FriendUser(
|
||||
id: json['id'] as String,
|
||||
username: json['username'] as String,
|
||||
avatarUrl: json['avatar_url'] as String?,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class FriendRequest {
|
||||
final String id;
|
||||
final FriendUser sender;
|
||||
final FriendUser recipient;
|
||||
final String? content;
|
||||
final FriendRequestStatus status;
|
||||
final DateTime createdAt;
|
||||
|
||||
const FriendRequest({
|
||||
required this.id,
|
||||
required this.sender,
|
||||
required this.recipient,
|
||||
required this.content,
|
||||
required this.status,
|
||||
required this.createdAt,
|
||||
});
|
||||
|
||||
factory FriendRequest.fromJson(Map<String, dynamic> json) {
|
||||
return FriendRequest(
|
||||
id: json['id'] as String,
|
||||
sender: FriendUser.fromJson(json['sender'] as Map<String, dynamic>),
|
||||
recipient: FriendUser.fromJson(json['recipient'] as Map<String, dynamic>),
|
||||
content: json['content'] as String?,
|
||||
status: _friendRequestStatusFromApi(json['status'] as String),
|
||||
createdAt: DateTime.parse(json['created_at'] as String),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
FriendRequestStatus _friendRequestStatusFromApi(String raw) {
|
||||
switch (raw) {
|
||||
case 'pending':
|
||||
return FriendRequestStatus.pending;
|
||||
case 'accepted':
|
||||
return FriendRequestStatus.accepted;
|
||||
case 'rejected':
|
||||
return FriendRequestStatus.rejected;
|
||||
default:
|
||||
throw StateError('Unsupported friend request status: $raw');
|
||||
}
|
||||
}
|
||||
@@ -1,74 +0,0 @@
|
||||
enum InboxMessageType { friendRequest, calendar, system, group }
|
||||
|
||||
enum InboxMessageStatus { pending, accepted, rejected, dismissed }
|
||||
|
||||
class InboxMessage {
|
||||
final String id;
|
||||
final String recipientId;
|
||||
final String? senderId;
|
||||
final InboxMessageType messageType;
|
||||
final String? scheduleItemId;
|
||||
final String? friendshipId;
|
||||
final Map<String, dynamic>? content;
|
||||
final bool isRead;
|
||||
final InboxMessageStatus status;
|
||||
final DateTime createdAt;
|
||||
|
||||
const InboxMessage({
|
||||
required this.id,
|
||||
required this.recipientId,
|
||||
required this.senderId,
|
||||
required this.messageType,
|
||||
required this.scheduleItemId,
|
||||
required this.friendshipId,
|
||||
required this.content,
|
||||
required this.isRead,
|
||||
required this.status,
|
||||
required this.createdAt,
|
||||
});
|
||||
|
||||
factory InboxMessage.fromJson(Map<String, dynamic> json) {
|
||||
return InboxMessage(
|
||||
id: json['id'] as String,
|
||||
recipientId: json['recipient_id'] as String,
|
||||
senderId: json['sender_id'] as String?,
|
||||
messageType: _messageTypeFromApi(json['message_type'] as String),
|
||||
scheduleItemId: json['schedule_item_id'] as String?,
|
||||
friendshipId: json['friendship_id'] as String?,
|
||||
content: json['content'] as Map<String, dynamic>?,
|
||||
isRead: json['is_read'] as bool,
|
||||
status: _messageStatusFromApi(json['status'] as String),
|
||||
createdAt: DateTime.parse(json['created_at'] as String),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
InboxMessageType _messageTypeFromApi(String raw) {
|
||||
switch (raw) {
|
||||
case 'friend_request':
|
||||
return InboxMessageType.friendRequest;
|
||||
case 'calendar':
|
||||
return InboxMessageType.calendar;
|
||||
case 'system':
|
||||
return InboxMessageType.system;
|
||||
case 'group':
|
||||
return InboxMessageType.group;
|
||||
default:
|
||||
throw StateError('Unsupported inbox message type: $raw');
|
||||
}
|
||||
}
|
||||
|
||||
InboxMessageStatus _messageStatusFromApi(String raw) {
|
||||
switch (raw) {
|
||||
case 'pending':
|
||||
return InboxMessageStatus.pending;
|
||||
case 'accepted':
|
||||
return InboxMessageStatus.accepted;
|
||||
case 'rejected':
|
||||
return InboxMessageStatus.rejected;
|
||||
case 'dismissed':
|
||||
return InboxMessageStatus.dismissed;
|
||||
default:
|
||||
throw StateError('Unsupported inbox message status: $raw');
|
||||
}
|
||||
}
|
||||
@@ -1,287 +0,0 @@
|
||||
enum ScheduleSourceType { manual, imported, agentGenerated }
|
||||
|
||||
enum ScheduleStatus { active, archived }
|
||||
|
||||
class ScheduleItemModel {
|
||||
final String id;
|
||||
final String ownerId;
|
||||
final int permission;
|
||||
final bool isOwner;
|
||||
final String title;
|
||||
final String? description;
|
||||
final DateTime startAt;
|
||||
final DateTime? endAt;
|
||||
final String timezone;
|
||||
final ScheduleMetadata? metadata;
|
||||
final ScheduleSourceType sourceType;
|
||||
final ScheduleStatus status;
|
||||
final DateTime createdAt;
|
||||
final DateTime updatedAt;
|
||||
|
||||
static const int permissionView = 1;
|
||||
static const int permissionInvite = 2;
|
||||
static const int permissionEdit = 4;
|
||||
|
||||
bool get canEdit => isOwner || (permission & permissionEdit) != 0;
|
||||
bool get canInvite => isOwner || (permission & permissionInvite) != 0;
|
||||
bool get canDelete => isOwner;
|
||||
|
||||
ScheduleItemModel({
|
||||
required this.id,
|
||||
required this.ownerId,
|
||||
this.permission = 1,
|
||||
this.isOwner = false,
|
||||
required this.title,
|
||||
this.description,
|
||||
required this.startAt,
|
||||
this.endAt,
|
||||
this.timezone = 'Asia/Shanghai',
|
||||
this.metadata,
|
||||
this.sourceType = ScheduleSourceType.manual,
|
||||
this.status = ScheduleStatus.active,
|
||||
DateTime? createdAt,
|
||||
DateTime? updatedAt,
|
||||
}) : createdAt = createdAt ?? DateTime.now(),
|
||||
updatedAt = updatedAt ?? DateTime.now();
|
||||
|
||||
ScheduleItemModel copyWith({
|
||||
String? id,
|
||||
String? ownerId,
|
||||
int? permission,
|
||||
bool? isOwner,
|
||||
String? title,
|
||||
String? description,
|
||||
DateTime? startAt,
|
||||
DateTime? endAt,
|
||||
String? timezone,
|
||||
ScheduleMetadata? metadata,
|
||||
ScheduleSourceType? sourceType,
|
||||
ScheduleStatus? status,
|
||||
DateTime? createdAt,
|
||||
DateTime? updatedAt,
|
||||
}) {
|
||||
return ScheduleItemModel(
|
||||
id: id ?? this.id,
|
||||
ownerId: ownerId ?? this.ownerId,
|
||||
permission: permission ?? this.permission,
|
||||
isOwner: isOwner ?? this.isOwner,
|
||||
title: title ?? this.title,
|
||||
description: description ?? this.description,
|
||||
startAt: startAt ?? this.startAt,
|
||||
endAt: endAt ?? this.endAt,
|
||||
timezone: timezone ?? this.timezone,
|
||||
metadata: metadata ?? this.metadata,
|
||||
sourceType: sourceType ?? this.sourceType,
|
||||
status: status ?? this.status,
|
||||
createdAt: createdAt ?? this.createdAt,
|
||||
updatedAt: updatedAt ?? this.updatedAt,
|
||||
);
|
||||
}
|
||||
|
||||
factory ScheduleItemModel.fromJson(Map<String, dynamic> json) {
|
||||
return ScheduleItemModel(
|
||||
id: json['id'] as String,
|
||||
ownerId: json['owner_id'] as String,
|
||||
permission: json['permission'] as int,
|
||||
isOwner: json['is_owner'] as bool,
|
||||
title: json['title'] as String,
|
||||
description: json['description'] as String?,
|
||||
startAt: DateTime.parse(json['start_at'] as String).toLocal(),
|
||||
endAt: json['end_at'] != null
|
||||
? DateTime.parse(json['end_at'] as String).toLocal()
|
||||
: null,
|
||||
timezone: json['timezone'] as String,
|
||||
metadata: json['metadata'] is Map<String, dynamic>
|
||||
? ScheduleMetadata.fromJson(json['metadata'] as Map<String, dynamic>)
|
||||
: null,
|
||||
sourceType: _sourceTypeFromApi(json['source_type'] as String),
|
||||
status: _statusFromApi(json['status'] as String),
|
||||
createdAt: DateTime.parse(json['created_at'] as String).toLocal(),
|
||||
updatedAt: DateTime.parse(json['updated_at'] as String).toLocal(),
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toCreateJson() {
|
||||
return {
|
||||
'title': title,
|
||||
'description': description,
|
||||
'start_at': startAt.toUtc().toIso8601String(),
|
||||
'end_at': endAt?.toUtc().toIso8601String(),
|
||||
'timezone': timezone,
|
||||
'metadata': metadata?.toJson(),
|
||||
};
|
||||
}
|
||||
|
||||
Map<String, dynamic> toUpdateJson() {
|
||||
return {
|
||||
'title': title,
|
||||
'description': description,
|
||||
'start_at': startAt.toUtc().toIso8601String(),
|
||||
'end_at': endAt?.toUtc().toIso8601String(),
|
||||
'timezone': timezone,
|
||||
'metadata': metadata?.toJson(),
|
||||
'status': _statusToApi(status),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
class ScheduleMetadata {
|
||||
final String? color;
|
||||
final String? location;
|
||||
final String? notes;
|
||||
final int? reminderMinutes;
|
||||
final List<Attachment> attachments;
|
||||
final int version;
|
||||
final Map<String, dynamic> raw;
|
||||
|
||||
ScheduleMetadata({
|
||||
this.color,
|
||||
this.location,
|
||||
this.notes,
|
||||
this.reminderMinutes,
|
||||
List<Attachment>? attachments,
|
||||
this.version = 1,
|
||||
Map<String, dynamic>? raw,
|
||||
}) : attachments = attachments ?? const [],
|
||||
raw = raw ?? const {};
|
||||
|
||||
ScheduleMetadata copyWith({
|
||||
String? color,
|
||||
String? location,
|
||||
String? notes,
|
||||
int? reminderMinutes,
|
||||
List<Attachment>? attachments,
|
||||
int? version,
|
||||
Map<String, dynamic>? raw,
|
||||
}) {
|
||||
return ScheduleMetadata(
|
||||
color: color ?? this.color,
|
||||
location: location ?? this.location,
|
||||
notes: notes ?? this.notes,
|
||||
reminderMinutes: reminderMinutes ?? this.reminderMinutes,
|
||||
attachments: attachments ?? this.attachments,
|
||||
version: version ?? this.version,
|
||||
raw: raw ?? this.raw,
|
||||
);
|
||||
}
|
||||
|
||||
factory ScheduleMetadata.fromJson(Map<String, dynamic> json) {
|
||||
final rawAttachments = json['attachments'] as List<dynamic>;
|
||||
final attachments = rawAttachments
|
||||
.map((item) => Attachment.fromJson(item as Map<String, dynamic>))
|
||||
.toList(growable: false);
|
||||
return ScheduleMetadata(
|
||||
color: json['color'] as String?,
|
||||
location: json['location'] as String?,
|
||||
notes: json['notes'] as String?,
|
||||
reminderMinutes: json['reminder_minutes'] as int?,
|
||||
attachments: attachments,
|
||||
version: json['version'] as int,
|
||||
raw: Map<String, dynamic>.from(json),
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'color': color,
|
||||
'location': location,
|
||||
'notes': notes,
|
||||
'reminder_minutes': reminderMinutes,
|
||||
'attachments': attachments.map((item) => item.toJson()).toList(),
|
||||
'version': version,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
class Attachment {
|
||||
final String name;
|
||||
final List<String> visibleTo;
|
||||
final String? url;
|
||||
final String? note;
|
||||
final String? content;
|
||||
final String type;
|
||||
|
||||
Attachment({
|
||||
required this.name,
|
||||
this.visibleTo = const [],
|
||||
this.url,
|
||||
this.note,
|
||||
this.content,
|
||||
this.type = 'document',
|
||||
});
|
||||
|
||||
Attachment copyWith({
|
||||
String? name,
|
||||
List<String>? visibleTo,
|
||||
String? url,
|
||||
String? note,
|
||||
String? content,
|
||||
String? type,
|
||||
}) {
|
||||
return Attachment(
|
||||
name: name ?? this.name,
|
||||
visibleTo: visibleTo ?? this.visibleTo,
|
||||
url: url ?? this.url,
|
||||
note: note ?? this.note,
|
||||
content: content ?? this.content,
|
||||
type: type ?? this.type,
|
||||
);
|
||||
}
|
||||
|
||||
factory Attachment.fromJson(Map<String, dynamic> json) {
|
||||
final rawVisibleTo = json['visible_to'] as List<dynamic>;
|
||||
final visibleTo = rawVisibleTo.map((item) => item.toString()).toList();
|
||||
return Attachment(
|
||||
name: json['name'] as String,
|
||||
visibleTo: visibleTo,
|
||||
url: json['url'] as String?,
|
||||
note: json['note'] as String?,
|
||||
content: json['content'] as String?,
|
||||
type: json['type'] as String,
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'name': name,
|
||||
'visible_to': visibleTo,
|
||||
'url': url,
|
||||
'note': note,
|
||||
'content': content,
|
||||
'type': type,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
ScheduleSourceType _sourceTypeFromApi(String raw) {
|
||||
switch (raw) {
|
||||
case 'imported':
|
||||
return ScheduleSourceType.imported;
|
||||
case 'agent_generated':
|
||||
return ScheduleSourceType.agentGenerated;
|
||||
case 'manual':
|
||||
return ScheduleSourceType.manual;
|
||||
default:
|
||||
throw StateError('Unsupported schedule source type: $raw');
|
||||
}
|
||||
}
|
||||
|
||||
ScheduleStatus _statusFromApi(String raw) {
|
||||
switch (raw) {
|
||||
case 'archived':
|
||||
return ScheduleStatus.archived;
|
||||
case 'active':
|
||||
return ScheduleStatus.active;
|
||||
default:
|
||||
throw StateError('Unsupported schedule status: $raw');
|
||||
}
|
||||
}
|
||||
|
||||
String _statusToApi(ScheduleStatus status) {
|
||||
switch (status) {
|
||||
case ScheduleStatus.active:
|
||||
return 'active';
|
||||
case ScheduleStatus.archived:
|
||||
return 'archived';
|
||||
}
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
class UserSummary {
|
||||
final String id;
|
||||
final String username;
|
||||
final String? avatarUrl;
|
||||
|
||||
const UserSummary({
|
||||
required this.id,
|
||||
required this.username,
|
||||
required this.avatarUrl,
|
||||
});
|
||||
|
||||
factory UserSummary.fromJson(Map<String, dynamic> json) {
|
||||
return UserSummary(
|
||||
id: json['id'] as String,
|
||||
username: json['username'] as String,
|
||||
avatarUrl: json['avatar_url'] as String?,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
import '../../core/network/i_api_client.dart';
|
||||
import 'models/user_summary.dart';
|
||||
|
||||
abstract class UserRepository {
|
||||
Future<UserSummary> getById(String userId);
|
||||
Future<UserSummary> getMe();
|
||||
}
|
||||
|
||||
class UserRepositoryImpl implements UserRepository {
|
||||
final IApiClient _apiClient;
|
||||
static const _prefix = '/api/v1/users';
|
||||
|
||||
UserRepositoryImpl(this._apiClient);
|
||||
|
||||
@override
|
||||
Future<UserSummary> getById(String userId) async {
|
||||
final response = await _apiClient.get<Map<String, dynamic>>(
|
||||
'$_prefix/$userId',
|
||||
);
|
||||
final user = response.data;
|
||||
if (user == null) {
|
||||
throw StateError('Invalid getById response: empty payload');
|
||||
}
|
||||
return UserSummary.fromJson(user);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<UserSummary> getMe() async {
|
||||
final response = await _apiClient.get<Map<String, dynamic>>('$_prefix/me');
|
||||
final user = response.data;
|
||||
if (user == null) {
|
||||
throw StateError('Invalid getMe response: empty payload');
|
||||
}
|
||||
return UserSummary.fromJson(user);
|
||||
}
|
||||
}
|
||||
@@ -1,112 +0,0 @@
|
||||
import '../../core/network/i_api_client.dart';
|
||||
import '../cache/cache_invalidator.dart';
|
||||
import '../repositories/models/schedule_item_model.dart';
|
||||
|
||||
class CalendarService {
|
||||
static const _prefix = '/api/v1/schedule-items';
|
||||
|
||||
final IApiClient _apiClient;
|
||||
final CacheInvalidator _invalidator;
|
||||
|
||||
CalendarService({
|
||||
required IApiClient apiClient,
|
||||
required CacheInvalidator invalidator,
|
||||
}) : _apiClient = apiClient,
|
||||
_invalidator = invalidator;
|
||||
|
||||
Future<List<ScheduleItemModel>> getEventsForDay(DateTime date) async {
|
||||
final start = DateTime(date.year, date.month, date.day);
|
||||
final end = DateTime(date.year, date.month, date.day, 23, 59, 59);
|
||||
return getEventsForRange(start, end);
|
||||
}
|
||||
|
||||
Future<List<ScheduleItemModel>> getEventsForRange(
|
||||
DateTime start,
|
||||
DateTime end,
|
||||
) async {
|
||||
final startParam = Uri.encodeQueryComponent(
|
||||
start.toUtc().toIso8601String(),
|
||||
);
|
||||
final endParam = Uri.encodeQueryComponent(end.toUtc().toIso8601String());
|
||||
final response = await _apiClient.get<List<dynamic>>(
|
||||
'$_prefix?start_at=$startParam&end_at=$endParam',
|
||||
);
|
||||
final data = response.data;
|
||||
if (data == null) {
|
||||
throw StateError('Invalid getEventsForRange response: empty payload');
|
||||
}
|
||||
return data
|
||||
.map((item) => item as Map<String, dynamic>)
|
||||
.map(ScheduleItemModel.fromJson)
|
||||
.toList(growable: false);
|
||||
}
|
||||
|
||||
Future<ScheduleItemModel> getEventById(String id) async {
|
||||
final response = await _apiClient.get<Map<String, dynamic>>('$_prefix/$id');
|
||||
final data = response.data;
|
||||
if (data == null) {
|
||||
throw StateError('Invalid getEventById response: empty payload');
|
||||
}
|
||||
return ScheduleItemModel.fromJson(data);
|
||||
}
|
||||
|
||||
Future<ScheduleItemModel> addEvent(ScheduleItemModel event) async {
|
||||
final response = await _apiClient.post<Map<String, dynamic>>(
|
||||
_prefix,
|
||||
data: event.toCreateJson(),
|
||||
);
|
||||
final data = response.data;
|
||||
if (data == null) {
|
||||
throw StateError('Invalid addEvent response: empty payload');
|
||||
}
|
||||
final created = ScheduleItemModel.fromJson(data);
|
||||
_invalidateEventCache(created);
|
||||
return created;
|
||||
}
|
||||
|
||||
Future<ScheduleItemModel> updateEvent(ScheduleItemModel event) async {
|
||||
final response = await _apiClient.patch<Map<String, dynamic>>(
|
||||
'$_prefix/${event.id}',
|
||||
data: event.toUpdateJson(),
|
||||
);
|
||||
final data = response.data;
|
||||
if (data == null) {
|
||||
throw StateError('Invalid updateEvent response: empty payload');
|
||||
}
|
||||
final updated = ScheduleItemModel.fromJson(data);
|
||||
_invalidateEventCache(updated);
|
||||
return updated;
|
||||
}
|
||||
|
||||
Future<ScheduleItemModel> archiveEvent(String id) async {
|
||||
final event = await getEventById(id);
|
||||
final updatedEvent = await updateEvent(
|
||||
event.copyWith(status: ScheduleStatus.archived),
|
||||
);
|
||||
_invalidateEventCache(updatedEvent);
|
||||
return updatedEvent;
|
||||
}
|
||||
|
||||
Future<void> deleteEvent(String id) async {
|
||||
final event = await getEventById(id);
|
||||
_invalidateEventCache(event);
|
||||
await _apiClient.delete<void>('$_prefix/$id');
|
||||
}
|
||||
|
||||
void _invalidateEventCache(ScheduleItemModel event) {
|
||||
var current = DateTime(
|
||||
event.startAt.year,
|
||||
event.startAt.month,
|
||||
event.startAt.day,
|
||||
);
|
||||
final end = DateTime(
|
||||
event.endAt?.year ?? event.startAt.year,
|
||||
event.endAt?.month ?? event.startAt.month,
|
||||
event.endAt?.day ?? event.startAt.day,
|
||||
);
|
||||
while (!current.isAfter(end)) {
|
||||
_invalidator.invalidateCalendarDay(current);
|
||||
current = current.add(const Duration(days: 1));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
import '../../core/storage/app_preferences.dart';
|
||||
import '../models/reminder_payload.dart';
|
||||
|
||||
class IOSNotificationPayloadBridge {
|
||||
final AppPreferences _prefs;
|
||||
|
||||
IOSNotificationPayloadBridge(this._prefs);
|
||||
|
||||
ReminderPayload? getPendingPayload() {
|
||||
return _prefs.pendingNotificationPayload;
|
||||
}
|
||||
|
||||
Future<void> setPendingPayload(ReminderPayload payload) {
|
||||
return _prefs.setPendingNotificationPayload(payload);
|
||||
}
|
||||
|
||||
Future<void> clearPendingPayload() {
|
||||
return _prefs.clearPendingNotificationPayload();
|
||||
}
|
||||
}
|
||||
@@ -1,307 +0,0 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
|
||||
import 'package:timezone/data/latest.dart' as tz_data;
|
||||
import 'package:timezone/timezone.dart' as tz;
|
||||
|
||||
import '../../core/l10n/l10n.dart';
|
||||
import '../models/reminder_payload.dart';
|
||||
import '../repositories/models/schedule_item_model.dart';
|
||||
import 'reminder_notification_callbacks.dart';
|
||||
|
||||
class LocalNotificationService {
|
||||
final FlutterLocalNotificationsPlugin _plugin;
|
||||
bool _initialized = false;
|
||||
|
||||
LocalNotificationService({FlutterLocalNotificationsPlugin? plugin})
|
||||
: _plugin = plugin ?? FlutterLocalNotificationsPlugin();
|
||||
|
||||
Future<void> initialize() async {
|
||||
if (_initialized) {
|
||||
return;
|
||||
}
|
||||
tz_data.initializeTimeZones();
|
||||
|
||||
const android = AndroidInitializationSettings('@mipmap/ic_launcher');
|
||||
const ios = DarwinInitializationSettings(
|
||||
requestAlertPermission: false,
|
||||
requestBadgePermission: false,
|
||||
requestSoundPermission: false,
|
||||
);
|
||||
final settings = InitializationSettings(android: android, iOS: ios);
|
||||
|
||||
await _plugin.initialize(
|
||||
settings,
|
||||
onDidReceiveNotificationResponse:
|
||||
ReminderNotificationCallbacks.onForegroundResponse,
|
||||
onDidReceiveBackgroundNotificationResponse:
|
||||
reminderNotificationTapBackground,
|
||||
);
|
||||
|
||||
final androidImpl = _plugin
|
||||
.resolvePlatformSpecificImplementation<
|
||||
AndroidFlutterLocalNotificationsPlugin
|
||||
>();
|
||||
await androidImpl?.requestNotificationsPermission();
|
||||
await androidImpl?.requestExactAlarmsPermission();
|
||||
await androidImpl?.requestFullScreenIntentPermission();
|
||||
|
||||
final iosImpl = _plugin
|
||||
.resolvePlatformSpecificImplementation<
|
||||
IOSFlutterLocalNotificationsPlugin
|
||||
>();
|
||||
await iosImpl?.requestPermissions(alert: true, badge: true, sound: true);
|
||||
|
||||
_initialized = true;
|
||||
}
|
||||
|
||||
Future<void> upsertEventReminder(ScheduleItemModel event) async {
|
||||
await initialize();
|
||||
if (event.status != ScheduleStatus.active ||
|
||||
event.metadata?.reminderMinutes == null) {
|
||||
await cancelEventReminder(event.id);
|
||||
return;
|
||||
}
|
||||
|
||||
final now = DateTime.now();
|
||||
final reminderMinutes = event.metadata?.reminderMinutes ?? 0;
|
||||
final fireAt = event.startAt.subtract(Duration(minutes: reminderMinutes));
|
||||
if (fireAt.isBefore(now)) {
|
||||
await cancelEventReminder(event.id);
|
||||
return;
|
||||
}
|
||||
|
||||
await cancelEventReminder(event.id);
|
||||
await _scheduleRemindersFrom(event: event, firstFireAt: fireAt);
|
||||
}
|
||||
|
||||
Future<void> scheduleReminderAt(
|
||||
ScheduleItemModel event,
|
||||
DateTime fireAt,
|
||||
) async {
|
||||
await initialize();
|
||||
await cancelEventReminder(event.id);
|
||||
await _scheduleRemindersFrom(event: event, firstFireAt: fireAt);
|
||||
}
|
||||
|
||||
Future<void> cancelEventReminder(String eventId) async {
|
||||
await initialize();
|
||||
|
||||
final pending = await _plugin.pendingNotificationRequests();
|
||||
for (final request in pending) {
|
||||
final payload = _decodePayload(request.payload);
|
||||
if (payload == null) {
|
||||
continue;
|
||||
}
|
||||
if (payload.eventId == eventId ||
|
||||
payload.aggregateIds.contains(eventId)) {
|
||||
await _plugin.cancel(request.id);
|
||||
}
|
||||
}
|
||||
|
||||
await _plugin.cancel(_notificationIdForEvent(eventId));
|
||||
}
|
||||
|
||||
Future<void> rebuildUpcomingReminders(
|
||||
Iterable<ScheduleItemModel> events,
|
||||
) async {
|
||||
await initialize();
|
||||
for (final event in events) {
|
||||
await upsertEventReminder(event);
|
||||
}
|
||||
}
|
||||
|
||||
int _notificationIdForEvent(String eventId) {
|
||||
return eventId.hashCode & 0x7fffffff;
|
||||
}
|
||||
|
||||
int _notificationIdForEventCycle(
|
||||
String eventId,
|
||||
DateTime fireAt,
|
||||
ReminderPayloadMode mode,
|
||||
) {
|
||||
final cycleMinute =
|
||||
fireAt.millisecondsSinceEpoch ~/
|
||||
const Duration(minutes: 1).inMilliseconds;
|
||||
return '$eventId|$cycleMinute|${mode.value}'.hashCode & 0x7fffffff;
|
||||
}
|
||||
|
||||
Future<AndroidScheduleMode> _resolveAndroidScheduleMode() async {
|
||||
final androidImpl = _plugin
|
||||
.resolvePlatformSpecificImplementation<
|
||||
AndroidFlutterLocalNotificationsPlugin
|
||||
>();
|
||||
if (androidImpl == null) {
|
||||
return AndroidScheduleMode.exactAllowWhileIdle;
|
||||
}
|
||||
|
||||
final canScheduleExact =
|
||||
await androidImpl.canScheduleExactNotifications() ?? false;
|
||||
return canScheduleExact
|
||||
? AndroidScheduleMode.exactAllowWhileIdle
|
||||
: AndroidScheduleMode.inexactAllowWhileIdle;
|
||||
}
|
||||
|
||||
NotificationDetails _buildNotificationDetails(DateTime fireAt) {
|
||||
return NotificationDetails(
|
||||
android: AndroidNotificationDetails(
|
||||
'calendar_alarm_channel_v2',
|
||||
L10n.current.notificationChannelName,
|
||||
channelDescription: L10n.current.notificationChannelDescription,
|
||||
importance: Importance.max,
|
||||
priority: Priority.max,
|
||||
category: AndroidNotificationCategory.alarm,
|
||||
audioAttributesUsage: AudioAttributesUsage.alarm,
|
||||
fullScreenIntent: true,
|
||||
playSound: true,
|
||||
enableVibration: true,
|
||||
vibrationPattern: Int64List.fromList([0, 1000, 500, 1200]),
|
||||
timeoutAfter: 30000,
|
||||
autoCancel: true,
|
||||
groupKey: _getGroupKey(fireAt),
|
||||
),
|
||||
iOS: DarwinNotificationDetails(
|
||||
presentAlert: true,
|
||||
presentSound: true,
|
||||
presentBadge: true,
|
||||
threadIdentifier: _getThreadIdentifier(fireAt),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
String _getThreadIdentifier(DateTime fireAt) {
|
||||
final bucket =
|
||||
fireAt.millisecondsSinceEpoch ~/
|
||||
const Duration(minutes: 1).inMilliseconds;
|
||||
return 'calendar_reminder_$bucket';
|
||||
}
|
||||
|
||||
String _getGroupKey(DateTime fireAt) {
|
||||
final bucket =
|
||||
fireAt.millisecondsSinceEpoch ~/
|
||||
const Duration(minutes: 1).inMilliseconds;
|
||||
return 'com.socialapp.calendar.$bucket';
|
||||
}
|
||||
|
||||
Future<void> _scheduleSingleReminder({
|
||||
required ScheduleItemModel event,
|
||||
required DateTime fireAt,
|
||||
}) async {
|
||||
final notificationId = _notificationIdForEventCycle(
|
||||
event.id,
|
||||
fireAt,
|
||||
ReminderPayloadMode.single,
|
||||
);
|
||||
final payload = ReminderPayload(
|
||||
eventId: event.id,
|
||||
title: event.title,
|
||||
startAt: event.startAt,
|
||||
endAt: event.endAt,
|
||||
timezone: event.timezone,
|
||||
location: event.metadata?.location,
|
||||
notes: event.metadata?.notes,
|
||||
color: event.metadata?.color,
|
||||
mode: ReminderPayloadMode.single,
|
||||
fireTimeBucket:
|
||||
fireAt.millisecondsSinceEpoch ~/
|
||||
const Duration(minutes: 1).inMilliseconds,
|
||||
version: 1,
|
||||
);
|
||||
|
||||
final details = _buildNotificationDetails(fireAt);
|
||||
final scheduledAt = tz.TZDateTime.from(fireAt, tz.local);
|
||||
final mode = await _resolveAndroidScheduleMode();
|
||||
|
||||
try {
|
||||
await _plugin.zonedSchedule(
|
||||
notificationId,
|
||||
event.title,
|
||||
_buildReminderBody(event, event.metadata?.reminderMinutes ?? 0),
|
||||
scheduledAt,
|
||||
details,
|
||||
payload: jsonEncode(payload.toJson()),
|
||||
androidScheduleMode: mode,
|
||||
uiLocalNotificationDateInterpretation:
|
||||
UILocalNotificationDateInterpretation.absoluteTime,
|
||||
);
|
||||
} catch (_) {
|
||||
await _plugin.zonedSchedule(
|
||||
notificationId,
|
||||
event.title,
|
||||
_buildReminderBody(event, event.metadata?.reminderMinutes ?? 0),
|
||||
scheduledAt,
|
||||
details,
|
||||
payload: jsonEncode(payload.toJson()),
|
||||
androidScheduleMode: AndroidScheduleMode.inexactAllowWhileIdle,
|
||||
uiLocalNotificationDateInterpretation:
|
||||
UILocalNotificationDateInterpretation.absoluteTime,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _scheduleRemindersFrom({
|
||||
required ScheduleItemModel event,
|
||||
required DateTime firstFireAt,
|
||||
}) async {
|
||||
final endAt = event.endAt;
|
||||
var cursor = firstFireAt;
|
||||
if (endAt == null) {
|
||||
await _scheduleSingleReminder(event: event, fireAt: cursor);
|
||||
return;
|
||||
}
|
||||
|
||||
while (cursor.isBefore(endAt)) {
|
||||
await _scheduleSingleReminder(event: event, fireAt: cursor);
|
||||
cursor = cursor.add(const Duration(minutes: 10));
|
||||
}
|
||||
}
|
||||
|
||||
ReminderPayload? _decodePayload(String? raw) {
|
||||
if (raw == null || raw.isEmpty) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return ReminderPayload.fromJson(jsonDecode(raw) as Map<String, dynamic>);
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
String _buildReminderBody(ScheduleItemModel event, int reminderMinutes) {
|
||||
final when = reminderMinutes == 0
|
||||
? L10n.current.notificationStartsNow
|
||||
: L10n.current.notificationStartsInMinutes(reminderMinutes);
|
||||
final location = event.metadata?.location;
|
||||
final notes = event.metadata?.notes;
|
||||
final buffer = StringBuffer(when);
|
||||
if (location != null && location.isNotEmpty) {
|
||||
buffer.write('\n${L10n.current.notificationLocation(location)}');
|
||||
}
|
||||
if (notes != null && notes.isNotEmpty) {
|
||||
final preview = notes.length > 30
|
||||
? '${notes.substring(0, 30)}...'
|
||||
: notes;
|
||||
buffer.write('\n${L10n.current.notificationNotes(preview)}');
|
||||
}
|
||||
return buffer.toString();
|
||||
}
|
||||
|
||||
Future<void> handleNotificationResponse(NotificationResponse response) async {
|
||||
final payloadRaw = response.payload;
|
||||
if (payloadRaw == null || payloadRaw.isEmpty) {
|
||||
return;
|
||||
}
|
||||
ReminderPayload payload;
|
||||
try {
|
||||
payload = ReminderPayload.fromJson(
|
||||
Map<String, dynamic>.from(jsonDecode(payloadRaw) as Map),
|
||||
);
|
||||
} catch (_) {
|
||||
debugPrint('failed to handle reminder notification response');
|
||||
return;
|
||||
}
|
||||
|
||||
ReminderNotificationCallbacks.onNotificationPayloadReceived?.call(payload);
|
||||
}
|
||||
}
|
||||
@@ -1,146 +0,0 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
import '../../core/storage/app_preferences.dart';
|
||||
import '../models/reminder_payload.dart';
|
||||
|
||||
typedef ReminderNotificationResponseHandler =
|
||||
Future<void> Function(NotificationResponse response);
|
||||
|
||||
class ReminderNotificationCallbacks {
|
||||
static ReminderNotificationResponseHandler? _responseHandler;
|
||||
static Future<void> _pendingStorageLock = Future<void>.value();
|
||||
static void Function(ReminderPayload)? onNotificationPayloadReceived;
|
||||
static AppPreferences? _appPreferences;
|
||||
|
||||
static Future<AppPreferences> _resolvePrefs() async {
|
||||
final prefs = _appPreferences;
|
||||
if (prefs != null) {
|
||||
return prefs;
|
||||
}
|
||||
final sharedPreferences = await SharedPreferences.getInstance();
|
||||
final fallback = AppPreferences(sharedPreferences);
|
||||
_appPreferences = fallback;
|
||||
return fallback;
|
||||
}
|
||||
|
||||
static void setAppPreferences(AppPreferences prefs) {
|
||||
_appPreferences = prefs;
|
||||
}
|
||||
|
||||
@visibleForTesting
|
||||
static Future<void> resetForTest() async {
|
||||
_responseHandler = null;
|
||||
_pendingStorageLock = Future<void>.value();
|
||||
final prefs = await _resolvePrefs();
|
||||
await prefs.clearPendingNotifications();
|
||||
}
|
||||
|
||||
static Future<void> bindResponseHandler(
|
||||
ReminderNotificationResponseHandler handler,
|
||||
) async {
|
||||
_responseHandler = handler;
|
||||
await _drainPendingResponses();
|
||||
}
|
||||
|
||||
static Future<void> onForegroundResponse(
|
||||
NotificationResponse response,
|
||||
) async {
|
||||
final handler = _responseHandler;
|
||||
if (handler == null) {
|
||||
await _enqueuePendingResponse(response);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await handler(response);
|
||||
} catch (_) {
|
||||
await _enqueuePendingResponse(response);
|
||||
}
|
||||
}
|
||||
|
||||
static Future<void> onBackgroundResponse(
|
||||
NotificationResponse response,
|
||||
) async {
|
||||
final handler = _responseHandler;
|
||||
if (handler == null) {
|
||||
await _enqueuePendingResponse(response);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await handler(response);
|
||||
} catch (_) {
|
||||
await _enqueuePendingResponse(response);
|
||||
}
|
||||
}
|
||||
|
||||
static Future<T> _withPendingStorageLock<T>(Future<T> Function() operation) {
|
||||
final completer = Completer<void>();
|
||||
final waitForTurn = _pendingStorageLock;
|
||||
_pendingStorageLock = waitForTurn.then((_) => completer.future);
|
||||
|
||||
return waitForTurn.then((_) => operation()).whenComplete(() {
|
||||
if (!completer.isCompleted) {
|
||||
completer.complete();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
static Future<void> _enqueuePendingResponse(
|
||||
NotificationResponse response,
|
||||
) async {
|
||||
final prefs = await _resolvePrefs();
|
||||
await _withPendingStorageLock(() async {
|
||||
final current = prefs.pendingNotifications;
|
||||
await prefs.setPendingNotifications([
|
||||
...current,
|
||||
NotificationResponse(
|
||||
id: response.id,
|
||||
actionId: response.actionId,
|
||||
payload: response.payload,
|
||||
input: response.input,
|
||||
notificationResponseType: response.notificationResponseType,
|
||||
),
|
||||
]);
|
||||
});
|
||||
}
|
||||
|
||||
static Future<void> _drainPendingResponses() async {
|
||||
final handler = _responseHandler;
|
||||
if (handler == null) {
|
||||
return;
|
||||
}
|
||||
final prefs = await _resolvePrefs();
|
||||
await _withPendingStorageLock(() async {
|
||||
final pending = prefs.pendingNotifications;
|
||||
if (pending.isEmpty) {
|
||||
return;
|
||||
}
|
||||
|
||||
final remaining = <NotificationResponse>[];
|
||||
for (final response in pending) {
|
||||
try {
|
||||
await handler(response);
|
||||
} catch (_) {
|
||||
remaining.add(response);
|
||||
}
|
||||
}
|
||||
|
||||
if (remaining.isEmpty) {
|
||||
await prefs.clearPendingNotifications();
|
||||
return;
|
||||
}
|
||||
|
||||
await prefs.setPendingNotifications(remaining);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@pragma('vm:entry-point')
|
||||
Future<void> reminderNotificationTapBackground(
|
||||
NotificationResponse response,
|
||||
) async {
|
||||
await ReminderNotificationCallbacks.onBackgroundResponse(response);
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
abstract class TokenStorage {
|
||||
Future<String?> getAccessToken();
|
||||
Future<String?> getRefreshToken();
|
||||
Future<void> saveTokens({required String access, required String refresh});
|
||||
Future<void> clear();
|
||||
}
|
||||
|
||||
class SecureTokenStorage implements TokenStorage {
|
||||
static const _accessTokenKey = 'access_token';
|
||||
static const _refreshTokenKey = 'refresh_token';
|
||||
|
||||
final dynamic _storage;
|
||||
|
||||
SecureTokenStorage([this._storage]);
|
||||
|
||||
@override
|
||||
Future<String?> getAccessToken() async {
|
||||
return _storage?.read(key: _accessTokenKey);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<String?> getRefreshToken() async {
|
||||
return _storage?.read(key: _refreshTokenKey);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> saveTokens({
|
||||
required String access,
|
||||
required String refresh,
|
||||
}) async {
|
||||
await _storage?.write(key: _accessTokenKey, value: access);
|
||||
await _storage?.write(key: _refreshTokenKey, value: refresh);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> clear() async {
|
||||
await _storage?.delete(key: _accessTokenKey);
|
||||
await _storage?.delete(key: _refreshTokenKey);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user