refactor(apps): 重构数据层目录结构并新增启动预热编排器
This commit is contained in:
+2
-2
@@ -1,5 +1,5 @@
|
||||
import 'package:social_app/core/network/i_api_client.dart';
|
||||
import 'package:social_app/data/repositories/models/schedule_item_model.dart';
|
||||
import 'package:social_app/data/network/i_api_client.dart';
|
||||
import 'package:social_app/features/calendar/data/models/schedule_item_model.dart';
|
||||
|
||||
class CalendarApi {
|
||||
final IApiClient _client;
|
||||
@@ -0,0 +1,287 @@
|
||||
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';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
import '../../../../data/cache/cache_policy.dart';
|
||||
import '../../../../data/cache/cached_repository.dart';
|
||||
import '../../../../data/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: 1),
|
||||
hardTtl: Duration(minutes: 10),
|
||||
minRefreshInterval: Duration(seconds: 30),
|
||||
),
|
||||
encodeValue: _encodeEventList,
|
||||
decodeValue: _decodeEventList,
|
||||
);
|
||||
|
||||
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<ScheduleItemModel> getById(String id) {
|
||||
return getEventById(id);
|
||||
}
|
||||
|
||||
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,
|
||||
}) {
|
||||
return _listByRange(startAt: startAt, endAt: endAt);
|
||||
}
|
||||
|
||||
Future<void> acceptSubscription(String itemId) {
|
||||
return _apiClient.post<void>('$_prefix/$itemId/accept');
|
||||
}
|
||||
|
||||
Future<void> rejectSubscription(String itemId) {
|
||||
return _apiClient.post<void>('$_prefix/$itemId/reject');
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
static Object? _encodeEventList(List<ScheduleItemModel> events) {
|
||||
return events.map(_encodeEvent).toList(growable: false);
|
||||
}
|
||||
|
||||
static List<ScheduleItemModel> _decodeEventList(Object? raw) {
|
||||
if (raw is! List) {
|
||||
throw const FormatException('Invalid cached calendar event list payload');
|
||||
}
|
||||
return raw
|
||||
.whereType<Map>()
|
||||
.map(
|
||||
(item) => ScheduleItemModel.fromJson(Map<String, dynamic>.from(item)),
|
||||
)
|
||||
.toList(growable: false);
|
||||
}
|
||||
|
||||
static Map<String, Object?> _encodeEvent(ScheduleItemModel item) {
|
||||
return <String, Object?>{
|
||||
'id': item.id,
|
||||
'owner_id': item.ownerId,
|
||||
'permission': item.permission,
|
||||
'is_owner': item.isOwner,
|
||||
'title': item.title,
|
||||
'description': item.description,
|
||||
'start_at': item.startAt.toIso8601String(),
|
||||
'end_at': item.endAt?.toIso8601String(),
|
||||
'timezone': item.timezone,
|
||||
'metadata': item.metadata?.toJson(),
|
||||
'source_type': _sourceTypeToApi(item.sourceType),
|
||||
'status': _statusToApi(item.status),
|
||||
'created_at': item.createdAt.toIso8601String(),
|
||||
'updated_at': item.updatedAt.toIso8601String(),
|
||||
};
|
||||
}
|
||||
|
||||
static String _sourceTypeToApi(ScheduleSourceType sourceType) {
|
||||
switch (sourceType) {
|
||||
case ScheduleSourceType.manual:
|
||||
return 'manual';
|
||||
case ScheduleSourceType.imported:
|
||||
return 'imported';
|
||||
case ScheduleSourceType.agentGenerated:
|
||||
return 'agent_generated';
|
||||
}
|
||||
}
|
||||
|
||||
static String _statusToApi(ScheduleStatus status) {
|
||||
switch (status) {
|
||||
case ScheduleStatus.active:
|
||||
return 'active';
|
||||
case ScheduleStatus.archived:
|
||||
return 'archived';
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
import '../../../../data/network/i_api_client.dart';
|
||||
import '../../../../data/cache/cache_store.dart';
|
||||
import '../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));
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user