refactor(apps): 主题系统迁移至 ColorScheme + 扩展架构并支持 Dark Mode

This commit is contained in:
qzl
2026-03-27 19:07:39 +08:00
parent ecc1ec6ce4
commit ae29a8209b
146 changed files with 4301 additions and 3200 deletions
@@ -0,0 +1,60 @@
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');
}
}
@@ -0,0 +1,98 @@
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);
}
}
@@ -0,0 +1,88 @@
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);
}
}
@@ -0,0 +1,42 @@
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);
}
}
@@ -0,0 +1,40 @@
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');
}
}
@@ -0,0 +1,63 @@
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');
}
}
@@ -0,0 +1,74 @@
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');
}
}
@@ -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,19 @@
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?,
);
}
}
@@ -0,0 +1,36 @@
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);
}
}