feat: 增强日历功能并集成 AgentScope 代理服务

This commit is contained in:
qzl
2026-03-11 15:28:29 +08:00
parent e55e445906
commit e20e7d2a02
85 changed files with 5175 additions and 885 deletions
+14
View File
@@ -10,8 +10,11 @@ import '../../features/auth/data/auth_api.dart';
import '../../features/auth/data/auth_repository.dart';
import '../../features/auth/data/auth_repository_impl.dart';
import '../../features/auth/presentation/bloc/auth_bloc.dart';
import '../../features/calendar/data/calendar_api.dart';
import '../../features/calendar/data/services/mock_calendar_service.dart';
import '../../features/calendar/ui/calendar_state_manager.dart';
import '../../features/friends/data/friends_api.dart';
import '../../features/messages/data/inbox_api.dart';
import '../../features/users/data/users_api.dart';
final sl = GetIt.instance;
@@ -45,9 +48,20 @@ Future<void> configureDependencies() async {
final usersApi = UsersApi(apiClient);
sl.registerSingleton<UsersApi>(usersApi);
final calendarApi = CalendarApi(apiClient);
sl.registerSingleton<CalendarApi>(calendarApi);
final calendarService = CalendarService(
apiClient: Env.isMockApi ? null : apiClient,
);
sl.registerSingleton<CalendarService>(calendarService);
final friendsApi = FriendsApi(apiClient);
sl.registerSingleton<FriendsApi>(friendsApi);
final inboxApi = InboxApi(apiClient);
sl.registerSingleton<InboxApi>(inboxApi);
final authRepository = AuthRepositoryImpl(
api: authApi,
tokenStorage: tokenStorage,
+4
View File
@@ -14,11 +14,15 @@ class AppColors {
static const card = Color(0xFFFAFAFA);
static const slate900 = Color(0xFF0F172A);
static const slate800 = Color(0xFF1E293B);
static const slate700 = Color(0xFF334155);
static const slate600 = Color(0xFF475569);
static const slate500 = Color(0xFF64748B);
static const slate400 = Color(0xFF94A3B8);
static const slate300 = Color(0xFFCBD5E1);
static const slate200 = Color(0xFFE2E8F0);
static const slate100 = Color(0xFFF1F5F9);
static const slate50 = Color(0xFFF8FAFC);
static const blue600 = Color(0xFF2563EB);
static const blue500 = Color(0xFF3B82F6);
@@ -182,6 +182,8 @@ class _RegisterVerificationViewState extends State<RegisterVerificationView> {
Widget _buildFormContainer() {
return BlocConsumer<RegisterCubit, RegisterState>(
listener: (context, state) {
if (!mounted) return;
if (state.status == FormzSubmissionStatus.failure &&
state.errorMessage != null) {
Toast.show(context, state.errorMessage!, type: ToastType.error);
@@ -0,0 +1,49 @@
import 'package:social_app/core/api/i_api_client.dart';
import 'models/schedule_item_model.dart';
class CalendarApi {
final IApiClient _client;
static const _prefix = '/api/v1/schedule-items';
CalendarApi(this._client);
Future<List<ScheduleItemModel>> listByRange({
required DateTime startAt,
required DateTime endAt,
}) async {
final response = await _client.get(
'$_prefix?start_at=${Uri.encodeQueryComponent(startAt.toUtc().toIso8601String())}&end_at=${Uri.encodeQueryComponent(endAt.toUtc().toIso8601String())}',
);
final data = response.data;
if (data is! List) {
return const [];
}
return data
.whereType<Map<String, dynamic>>()
.map(ScheduleItemModel.fromJson)
.toList();
}
Future<ScheduleItemModel> getById(String id) async {
final response = await _client.get('$_prefix/$id');
return ScheduleItemModel.fromJson(response.data as Map<String, dynamic>);
}
Future<ScheduleItemModel> create(ScheduleItemModel request) async {
final response = await _client.post(_prefix, data: request.toCreateJson());
return ScheduleItemModel.fromJson(response.data as Map<String, dynamic>);
}
Future<ScheduleItemModel> update(ScheduleItemModel request) async {
final response = await _client.patch(
'$_prefix/${request.id}',
data: request.toUpdateJson(),
);
return ScheduleItemModel.fromJson(response.data as Map<String, dynamic>);
}
Future<void> delete(String id) async {
await _client.delete('$_prefix/$id');
}
}
@@ -15,6 +15,7 @@ class ScheduleItemModel {
final ScheduleSourceType sourceType;
final ScheduleStatus status;
final DateTime createdAt;
final DateTime updatedAt;
ScheduleItemModel({
required this.id,
@@ -27,7 +28,9 @@ class ScheduleItemModel {
this.sourceType = ScheduleSourceType.manual,
this.status = ScheduleStatus.active,
DateTime? createdAt,
}) : createdAt = createdAt ?? DateTime.now();
DateTime? updatedAt,
}) : createdAt = createdAt ?? DateTime.now(),
updatedAt = updatedAt ?? DateTime.now();
ScheduleItemModel copyWith({
String? id,
@@ -40,6 +43,7 @@ class ScheduleItemModel {
ScheduleSourceType? sourceType,
ScheduleStatus? status,
DateTime? createdAt,
DateTime? updatedAt,
}) {
return ScheduleItemModel(
id: id ?? this.id,
@@ -52,45 +56,222 @@ class ScheduleItemModel {
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,
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?) ?? 'UTC',
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: json['created_at'] != null
? DateTime.parse(json['created_at'] as String).toLocal()
: DateTime.now(),
updatedAt: json['updated_at'] != null
? DateTime.parse(json['updated_at'] as String).toLocal()
: DateTime.now(),
);
}
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 List<Attachment>? attachments;
final List<Attachment> attachments;
final int version;
final Map<String, dynamic> raw;
ScheduleMetadata({this.color, this.location, this.notes, this.attachments});
ScheduleMetadata({
this.color,
this.location,
this.notes,
List<Attachment>? attachments,
this.version = 1,
Map<String, dynamic>? raw,
}) : attachments = attachments ?? const [],
raw = raw ?? const {};
ScheduleMetadata copyWith({
String? color,
String? location,
String? notes,
List<Attachment>? attachments,
int? version,
Map<String, dynamic>? raw,
}) {
return ScheduleMetadata(
color: color ?? this.color,
location: location ?? this.location,
notes: notes ?? this.notes,
attachments: attachments ?? this.attachments,
version: version ?? this.version,
raw: raw ?? this.raw,
);
}
factory ScheduleMetadata.fromJson(Map<String, dynamic> json) {
final rawAttachments = json['attachments'];
final attachments = rawAttachments is List
? rawAttachments
.whereType<Map<String, dynamic>>()
.map(Attachment.fromJson)
.toList()
: <Attachment>[];
return ScheduleMetadata(
color: json['color'] as String?,
location: json['location'] as String?,
notes: json['notes'] as String?,
attachments: attachments,
version: (json['version'] as int?) ?? 1,
raw: Map<String, dynamic>.from(json),
);
}
Map<String, dynamic> toJson() {
return {
'color': color,
'location': location,
'notes': notes,
'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'];
final visibleTo = rawVisibleTo is List
? rawVisibleTo.map((item) => item.toString()).toList()
: <String>[];
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?) ?? 'document',
);
}
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':
default:
return ScheduleSourceType.manual;
}
}
ScheduleStatus _statusFromApi(String? raw) {
switch (raw) {
case 'completed':
return ScheduleStatus.completed;
case 'canceled':
return ScheduleStatus.canceled;
case 'archived':
return ScheduleStatus.archived;
case 'active':
default:
return ScheduleStatus.active;
}
}
String _statusToApi(ScheduleStatus status) {
switch (status) {
case ScheduleStatus.active:
return 'active';
case ScheduleStatus.completed:
return 'completed';
case ScheduleStatus.canceled:
return 'canceled';
case ScheduleStatus.archived:
return 'archived';
}
}
const defaultColors = [
@@ -1,4 +1,6 @@
import 'package:social_app/core/api/i_api_client.dart';
import '../calendar_api.dart';
import '../models/schedule_item_model.dart';
class MockCalendarService {
@@ -58,47 +60,70 @@ class MockCalendarService {
class CalendarService {
final IApiClient? _apiClient;
final MockCalendarService _mock = MockCalendarService();
CalendarApi? _calendarApi;
CalendarService({IApiClient? apiClient}) : _apiClient = apiClient;
List<ScheduleItemModel> getEventsForDay(DateTime date) {
if (_apiClient != null) {
throw UnimplementedError('Real API not implemented');
CalendarApi get _api {
final api = _calendarApi;
if (api != null) {
return api;
}
return _mock.getEventsForDay(date);
final client = _apiClient;
if (client == null) {
throw StateError('Real API client not configured');
}
final created = CalendarApi(client);
_calendarApi = created;
return created;
}
List<ScheduleItemModel> getEventsForRange(DateTime start, DateTime end) {
Future<List<ScheduleItemModel>> getEventsForDay(DateTime date) async {
if (_apiClient == null) {
return _mock.getEventsForDay(date);
}
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 {
if (_apiClient != null) {
throw UnimplementedError('Real API not implemented');
return _api.listByRange(startAt: start, endAt: end);
}
return _mock.getEventsForRange(start, end);
}
ScheduleItemModel? getEventById(String id) {
Future<ScheduleItemModel?> getEventById(String id) async {
if (_apiClient != null) {
throw UnimplementedError('Real API not implemented');
return _api.getById(id);
}
return _mock.getEventById(id);
}
void addEvent(ScheduleItemModel event) {
Future<ScheduleItemModel> addEvent(ScheduleItemModel event) async {
if (_apiClient != null) {
throw UnimplementedError('Real API not implemented');
return _api.create(event);
}
_mock.addEvent(event);
return event;
}
void updateEvent(ScheduleItemModel event) {
Future<ScheduleItemModel> updateEvent(ScheduleItemModel event) async {
if (_apiClient != null) {
throw UnimplementedError('Real API not implemented');
return _api.update(event);
}
_mock.updateEvent(event);
return event;
}
void deleteEvent(String id) {
Future<void> deleteEvent(String id) async {
if (_apiClient != null) {
throw UnimplementedError('Real API not implemented');
await _api.delete(id);
return;
}
_mock.deleteEvent(id);
}
@@ -35,6 +35,7 @@ class _CalendarDayWeekScreenState extends State<CalendarDayWeekScreen> {
late List<DateTime> _monthDates;
final ScrollController _dayStripController = ScrollController();
Key _eventsKey = UniqueKey();
List<ScheduleItemModel> _events = const [];
@override
void initState() {
@@ -47,6 +48,7 @@ class _CalendarDayWeekScreenState extends State<CalendarDayWeekScreen> {
_selectedDate = _calendarManager.selectedDate;
_updateMonthDates();
_loadEvents();
WidgetsBinding.instance.addPostFrameCallback((_) {
_scrollToSelectedDate();
@@ -57,6 +59,16 @@ class _CalendarDayWeekScreenState extends State<CalendarDayWeekScreen> {
_monthDates = monthDatesFor(_selectedDate);
}
Future<void> _loadEvents() async {
final events = await sl<CalendarService>().getEventsForDay(_selectedDate);
if (!mounted) {
return;
}
setState(() {
_events = events;
});
}
@override
void dispose() {
_dayStripController.dispose();
@@ -147,6 +159,7 @@ class _CalendarDayWeekScreenState extends State<CalendarDayWeekScreen> {
setState(() {
_eventsKey = UniqueKey();
});
_loadEvents();
},
),
child: Container(
@@ -191,6 +204,7 @@ class _CalendarDayWeekScreenState extends State<CalendarDayWeekScreen> {
_calendarManager.setSelectedDate(date);
_updateMonthDates();
_scrollToSelectedDate(animate: true);
_loadEvents();
},
child: SizedBox(
width: _dayItemWidth,
@@ -267,7 +281,7 @@ class _CalendarDayWeekScreenState extends State<CalendarDayWeekScreen> {
Widget _buildTimelineBoard() {
final now = DateTime.now();
final showCurrent = shouldShowCurrentMarker(_selectedDate, now);
final events = CalendarService().getEventsForDay(_selectedDate);
final events = _events;
final eventColumns = _calculateEventColumns(events);
@@ -1,6 +1,7 @@
import 'package:flutter/material.dart';
import 'package:lucide_icons/lucide_icons.dart';
import 'package:go_router/go_router.dart';
import '../../../../core/di/injection.dart';
import '../../../../core/theme/design_tokens.dart';
import '../../data/services/mock_calendar_service.dart';
import '../../data/models/schedule_item_model.dart';
@@ -18,6 +19,7 @@ class CalendarEventDetailScreen extends StatefulWidget {
class _CalendarEventDetailScreenState extends State<CalendarEventDetailScreen> {
ScheduleItemModel? _event;
bool _loading = true;
@override
void initState() {
@@ -25,17 +27,26 @@ class _CalendarEventDetailScreenState extends State<CalendarEventDetailScreen> {
_loadEvent();
}
void _loadEvent() {
Future<void> _loadEvent() async {
try {
_event = CalendarService().getEventById(widget.eventId);
_event = await sl<CalendarService>().getEventById(widget.eventId);
} catch (e) {
_event = null;
} finally {
_loading = false;
}
if (mounted) {
setState(() {});
}
setState(() {});
}
@override
Widget build(BuildContext context) {
if (_loading) {
return const Scaffold(
body: SafeArea(child: Center(child: CircularProgressIndicator())),
);
}
if (_event == null) {
return Scaffold(
backgroundColor: const Color(0xFFF8FAFC),
@@ -165,6 +176,8 @@ class _CalendarEventDetailScreenState extends State<CalendarEventDetailScreen> {
if (event.metadata?.notes != null) ...[
_buildNotesField(event.metadata!.notes!),
],
const SizedBox(height: 14),
_buildMetadataSection(event.metadata),
],
),
),
@@ -275,8 +288,11 @@ class _CalendarEventDetailScreenState extends State<CalendarEventDetailScreen> {
child: const Text('取消'),
),
TextButton(
onPressed: () {
CalendarService().deleteEvent(widget.eventId);
onPressed: () async {
await sl<CalendarService>().deleteEvent(widget.eventId);
if (!context.mounted) {
return;
}
Navigator.pop(context);
context.pop();
},
@@ -369,6 +385,40 @@ class _CalendarEventDetailScreenState extends State<CalendarEventDetailScreen> {
);
}
Widget _buildMetadataSection(ScheduleMetadata? metadata) {
final raw = metadata?.raw ?? const <String, dynamic>{};
if (raw.isEmpty) {
return _buildDetailField('metadata', '');
}
final rows = <String>[];
raw.forEach((key, value) {
rows.add('$key: $value');
});
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'metadata',
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
color: AppColors.slate400,
),
),
const SizedBox(height: 6),
...rows.map(
(row) => Padding(
padding: const EdgeInsets.only(bottom: 4),
child: Text(
row,
style: const TextStyle(fontSize: 13, color: AppColors.slate700),
),
),
),
],
);
}
Color _parseColor(String? hex) {
if (hex == null || hex.isEmpty) return AppColors.blue600;
try {
@@ -8,6 +8,7 @@ import '../calendar_state_manager.dart';
import '../calendar_time_utils.dart';
import '../widgets/bottom_dock.dart';
import '../widgets/create_event_sheet.dart';
import '../../data/models/schedule_item_model.dart';
import '../../data/services/mock_calendar_service.dart';
class CalendarMonthScreen extends StatefulWidget {
@@ -24,6 +25,7 @@ class _CalendarMonthScreenState extends State<CalendarMonthScreen> {
late DateTime _currentMonth;
late DateTime _selectedDate;
Key _eventsKey = UniqueKey();
final Map<String, List<ScheduleItemModel>> _eventsByDay = {};
@override
void initState() {
@@ -37,6 +39,29 @@ class _CalendarMonthScreenState extends State<CalendarMonthScreen> {
final savedDate = _calendarManager.selectedDate;
_selectedDate = savedDate;
_currentMonth = DateTime(savedDate.year, savedDate.month, 1);
_loadMonthEvents();
}
Future<void> _loadMonthEvents() async {
final start = DateTime(_currentMonth.year, _currentMonth.month, 1);
final end = DateTime(
_currentMonth.year,
_currentMonth.month + 1,
0,
23,
59,
59,
);
final events = await sl<CalendarService>().getEventsForRange(start, end);
if (!mounted) {
return;
}
_eventsByDay.clear();
for (final event in events) {
final key = formatYmd(event.startAt);
_eventsByDay[key] = [...(_eventsByDay[key] ?? const []), event];
}
setState(() {});
}
@override
@@ -102,6 +127,7 @@ class _CalendarMonthScreenState extends State<CalendarMonthScreen> {
setState(() {
_eventsKey = UniqueKey();
});
_loadMonthEvents();
},
),
child: Container(
@@ -280,7 +306,7 @@ class _CalendarMonthScreenState extends State<CalendarMonthScreen> {
}
final date = weekFirstDate.add(Duration(days: index));
final events = CalendarService().getEventsForDay(date);
final events = _eventsByDay[formatYmd(date)] ?? const [];
final displayEvents = events.take(2).toList();
final remainingCount = events.length - 2;
@@ -391,6 +417,7 @@ class _CalendarMonthScreenState extends State<CalendarMonthScreen> {
1,
);
});
_loadMonthEvents();
},
children: List.generate(20, (index) {
return Center(child: Text('${2020 + index}'));
@@ -411,6 +438,7 @@ class _CalendarMonthScreenState extends State<CalendarMonthScreen> {
1,
);
});
_loadMonthEvents();
},
children: List.generate(12, (index) {
return Center(child: Text('${index + 1}'));
@@ -1,6 +1,7 @@
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:lucide_icons/lucide_icons.dart';
import '../../../../core/di/injection.dart';
import '../../../../core/theme/design_tokens.dart';
import '../../data/models/schedule_item_model.dart';
import '../../data/services/mock_calendar_service.dart';
@@ -62,6 +63,8 @@ class _CreateEventSheetState extends State<CreateEventSheet>
DateTime? _endDate;
DateTime? _endTime;
String _selectedColor = '#3B82F6';
bool _saving = false;
List<Attachment> _attachments = const [];
bool get _isEditing => widget.editingEvent != null;
@@ -81,6 +84,9 @@ class _CreateEventSheetState extends State<CreateEventSheet>
_endDate = event.endAt;
_endTime = event.endAt;
_selectedColor = event.metadata?.color ?? '#3B82F6';
_attachments = List<Attachment>.from(
event.metadata?.attachments ?? const [],
);
} else {
final now = widget.initialDate ?? DateTime.now();
_startDate = now;
@@ -198,6 +204,26 @@ class _CreateEventSheetState extends State<CreateEventSheet>
setState(() {
_startDate = date;
_startTime = time;
if (_endDate != null && _endTime != null) {
final endDateTime = DateTime(
_endDate!.year,
_endDate!.month,
_endDate!.day,
_endTime!.hour,
_endTime!.minute,
);
final startDateTime = DateTime(
date.year,
date.month,
date.day,
time.hour,
time.minute,
);
if (endDateTime.isBefore(startDateTime)) {
_endDate = date;
_endTime = time;
}
}
});
}),
const SizedBox(height: 20),
@@ -230,12 +256,289 @@ class _CreateEventSheetState extends State<CreateEventSheet>
const SizedBox(height: 20),
_buildColorPicker(),
const SizedBox(height: 20),
_buildAttachmentsSection(),
const SizedBox(height: 20),
_buildTextField('备注', _notesController, '请输入备注', maxLines: 3),
],
),
);
}
Widget _buildAttachmentsSection() {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Text(
'附件',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: AppColors.slate700,
),
),
InkWell(
onTap: _showAddAttachmentDialog,
borderRadius: BorderRadius.circular(AppRadius.full),
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: AppSpacing.md,
vertical: AppSpacing.xs,
),
decoration: BoxDecoration(
color: AppColors.blue50,
borderRadius: BorderRadius.circular(AppRadius.full),
border: Border.all(color: AppColors.borderQuaternary),
),
child: const Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Icon(LucideIcons.plus, size: 14, color: AppColors.blue600),
SizedBox(width: AppSpacing.xs),
Text(
'添加附件',
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
color: AppColors.blue600,
),
),
],
),
),
),
],
),
const SizedBox(height: AppSpacing.sm),
if (_attachments.isEmpty)
Container(
width: double.infinity,
padding: const EdgeInsets.all(AppSpacing.md),
decoration: BoxDecoration(
color: AppColors.slate50,
borderRadius: BorderRadius.circular(AppRadius.md),
border: Border.all(color: AppColors.borderSecondary),
),
child: const Text(
'暂无附件,点击右上角添加',
style: TextStyle(color: AppColors.slate500, fontSize: 13),
),
),
..._attachments.asMap().entries.map((entry) {
final index = entry.key;
final item = entry.value;
return Container(
margin: const EdgeInsets.only(top: AppSpacing.sm),
padding: const EdgeInsets.all(AppSpacing.md),
decoration: BoxDecoration(
color: AppColors.white,
borderRadius: BorderRadius.circular(AppRadius.md),
border: Border.all(color: AppColors.messageCardBorder),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Expanded(
child: Text(
item.name,
style: const TextStyle(
fontSize: 13,
fontWeight: FontWeight.w600,
color: AppColors.slate800,
),
),
),
Container(
padding: const EdgeInsets.symmetric(
horizontal: AppSpacing.sm,
vertical: AppSpacing.xs,
),
decoration: BoxDecoration(
color: AppColors.surfaceInfo,
borderRadius: BorderRadius.circular(AppRadius.full),
),
child: Text(
item.type,
style: const TextStyle(
fontSize: 11,
fontWeight: FontWeight.w600,
color: AppColors.blue600,
),
),
),
const SizedBox(width: AppSpacing.sm),
GestureDetector(
onTap: () {
setState(() {
final next = List<Attachment>.from(_attachments);
next.removeAt(index);
_attachments = next;
});
},
child: const Icon(
LucideIcons.trash,
size: 16,
color: AppColors.red500,
),
),
],
),
if ((item.url ?? '').isNotEmpty) ...[
const SizedBox(height: AppSpacing.xs),
Text(
'链接: ${item.url}',
style: const TextStyle(
fontSize: 12,
color: AppColors.slate500,
),
),
],
if ((item.note ?? '').isNotEmpty) ...[
const SizedBox(height: AppSpacing.xs),
Text(
'备注: ${item.note}',
style: const TextStyle(
fontSize: 12,
color: AppColors.slate500,
),
),
],
],
),
);
}),
],
);
}
Future<void> _showAddAttachmentDialog() async {
final nameController = TextEditingController();
final urlController = TextEditingController();
final noteController = TextEditingController();
final contentController = TextEditingController();
var type = 'document';
try {
final created = await showModalBottomSheet<Attachment>(
context: context,
isScrollControlled: true,
backgroundColor: Colors.transparent,
builder: (sheetContext) => StatefulBuilder(
builder: (sheetContext, setSheetState) {
return Container(
padding: EdgeInsets.only(
left: AppSpacing.lg,
right: AppSpacing.lg,
top: AppSpacing.lg,
bottom:
MediaQuery.of(sheetContext).viewInsets.bottom +
AppSpacing.lg,
),
decoration: const BoxDecoration(
color: AppColors.white,
borderRadius: BorderRadius.vertical(
top: Radius.circular(AppRadius.xxl),
),
),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'添加附件',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w700,
color: AppColors.slate900,
),
),
const SizedBox(height: AppSpacing.md),
_buildTextField('名称', nameController, '例如:会议纪要.pdf'),
const SizedBox(height: AppSpacing.md),
_buildTextField('链接', urlController, 'https://...'),
const SizedBox(height: AppSpacing.md),
_buildTextField('备注', noteController, '备注信息'),
const SizedBox(height: AppSpacing.md),
_buildTextField('内容', contentController, '提醒内容', maxLines: 2),
const SizedBox(height: AppSpacing.md),
Wrap(
spacing: AppSpacing.sm,
children: ['document', 'reminder'].map((item) {
final selected = item == type;
return ChoiceChip(
label: Text(item),
selected: selected,
onSelected: (_) {
setSheetState(() {
type = item;
});
},
);
}).toList(),
),
const SizedBox(height: AppSpacing.lg),
Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Expanded(
child: OutlinedButton(
onPressed: () => Navigator.pop(sheetContext),
child: const Text('取消'),
),
),
const SizedBox(width: AppSpacing.sm),
Expanded(
child: ElevatedButton(
onPressed: () {
final name = nameController.text.trim();
if (name.isEmpty) {
return;
}
Navigator.pop(
sheetContext,
Attachment(
name: name,
url: urlController.text.trim().isEmpty
? null
: urlController.text.trim(),
note: noteController.text.trim().isEmpty
? null
: noteController.text.trim(),
content: contentController.text.trim().isEmpty
? null
: contentController.text.trim(),
type: type,
),
);
},
child: const Text('确认添加'),
),
),
],
),
],
),
);
},
),
);
if (created != null && mounted) {
setState(() {
_attachments = [..._attachments, created];
});
}
} finally {
nameController.dispose();
urlController.dispose();
noteController.dispose();
contentController.dispose();
}
}
Widget _buildTextField(
String label,
TextEditingController controller,
@@ -295,63 +598,72 @@ class _CreateEventSheetState extends State<CreateEventSheet>
),
),
const SizedBox(height: 8),
Row(
children: [
Expanded(
child: GestureDetector(
onTap: () => _showDatePicker(date, (newDate) {
onChanged(newDate, time);
}),
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 12,
),
decoration: BoxDecoration(
color: const Color(0xFFF1F5F9),
borderRadius: BorderRadius.circular(10),
),
InkWell(
onTap: () async {
final picked = await _pickDateTime(date, time);
if (picked == null) {
return;
}
onChanged(picked.$1, picked.$2);
},
borderRadius: BorderRadius.circular(AppRadius.md),
child: Container(
padding: const EdgeInsets.all(AppSpacing.md),
decoration: BoxDecoration(
color: AppColors.slate50,
borderRadius: BorderRadius.circular(AppRadius.md),
border: Border.all(color: AppColors.borderSecondary),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
const Icon(
LucideIcons.calendar,
size: 16,
color: AppColors.slate600,
),
const SizedBox(width: AppSpacing.sm),
Expanded(
child: Text(
'${date.year}${date.month}${date.day}',
_formatDateTimeLabel(date, time),
style: const TextStyle(
fontSize: 15,
fontWeight: FontWeight.w500,
color: AppColors.slate900,
),
),
),
),
),
const SizedBox(width: 12),
Expanded(
child: GestureDetector(
onTap: () => _showTimePicker(time, (newTime) {
onChanged(date, newTime);
}),
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 12,
),
decoration: BoxDecoration(
color: const Color(0xFFF1F5F9),
borderRadius: BorderRadius.circular(10),
),
child: Text(
'${time.hour.toString().padLeft(2, '0')}:${time.minute.toString().padLeft(2, '0')}',
style: const TextStyle(
fontSize: 15,
color: AppColors.slate900,
),
),
const Icon(
LucideIcons.chevronRight,
size: 16,
color: AppColors.slate400,
),
),
],
),
],
),
),
],
);
}
String _formatDateTimeLabel(DateTime date, DateTime time) {
return '${date.year}${date.month}${date.day}${time.hour.toString().padLeft(2, '0')}:${time.minute.toString().padLeft(2, '0')}';
}
Future<(DateTime, DateTime)?> _pickDateTime(
DateTime date,
DateTime time,
) async {
final result = await showModalBottomSheet<(DateTime, DateTime)>(
context: context,
backgroundColor: Colors.transparent,
isScrollControlled: true,
builder: (context) =>
_DateTimePickerSheet(initialDate: date, initialTime: time),
);
return result;
}
Widget _buildColorPicker() {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
@@ -394,76 +706,11 @@ class _CreateEventSheetState extends State<CreateEventSheet>
);
}
void _showDatePicker(DateTime initial, Function(DateTime) onChanged) {
showModalBottomSheet(
context: context,
builder: (context) => Container(
height: 280,
color: Colors.white,
child: Column(
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('取消'),
),
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('确定'),
),
],
),
Expanded(
child: CupertinoDatePicker(
mode: CupertinoDatePickerMode.date,
initialDateTime: initial,
onDateTimeChanged: onChanged,
),
),
],
),
),
);
}
void _showTimePicker(DateTime initial, Function(DateTime) onChanged) {
showModalBottomSheet(
context: context,
builder: (context) => Container(
height: 280,
color: Colors.white,
child: Column(
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('取消'),
),
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('确定'),
),
],
),
Expanded(
child: CupertinoDatePicker(
mode: CupertinoDatePickerMode.time,
initialDateTime: initial,
onDateTimeChanged: onChanged,
),
),
],
),
),
);
}
void _saveEvent() {
if (_titleController.text.trim().isEmpty) return;
Future<void> _saveEvent() async {
if (_titleController.text.trim().isEmpty || _saving) return;
setState(() {
_saving = true;
});
final startAt = DateTime(
_startDate.year,
@@ -492,6 +739,8 @@ class _CreateEventSheetState extends State<CreateEventSheet>
notes: _notesController.text.trim().isNotEmpty
? _notesController.text.trim()
: null,
attachments: _attachments,
version: widget.editingEvent?.metadata?.version ?? 1,
);
final event = ScheduleItemModel(
@@ -507,14 +756,338 @@ class _CreateEventSheetState extends State<CreateEventSheet>
metadata: metadata,
);
final service = CalendarService();
if (_isEditing) {
service.updateEvent(event);
} else {
service.addEvent(event);
}
try {
final service = sl<CalendarService>();
debugPrint('CalendarService: $service');
debugPrint('Is mock: ${service.runtimeType}');
widget.onSaved?.call();
Navigator.pop(context);
if (_isEditing) {
await service.updateEvent(event);
} else {
await service.addEvent(event);
}
widget.onSaved?.call();
if (mounted) {
Navigator.pop(context);
}
} catch (e, stack) {
debugPrint('Save error: $e');
debugPrint('Stack: $stack');
if (mounted) {
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text('保存失败: $e')));
}
} finally {
if (mounted) {
setState(() {
_saving = false;
});
}
}
}
}
class _DateTimePickerSheet extends StatefulWidget {
final DateTime initialDate;
final DateTime initialTime;
const _DateTimePickerSheet({
required this.initialDate,
required this.initialTime,
});
@override
State<_DateTimePickerSheet> createState() => _DateTimePickerSheetState();
}
class _DateTimePickerSheetState extends State<_DateTimePickerSheet> {
late int _selectedYear;
late int _selectedMonth;
late int _selectedDay;
late int _selectedHour;
late int _selectedMinute;
late FixedExtentScrollController _yearController;
late FixedExtentScrollController _monthController;
late FixedExtentScrollController _dayController;
late FixedExtentScrollController _hourController;
late FixedExtentScrollController _minuteController;
static final int _baseYear = DateTime.now().year;
static final List<int> _years = List.generate(21, (i) => _baseYear - 10 + i);
static final List<int> _months = List.generate(12, (i) => i + 1);
static final List<int> _hours = List.generate(24, (i) => i);
static final List<int> _minutes = List.generate(60, (i) => i);
List<int> _days = [];
@override
void initState() {
super.initState();
_selectedYear = widget.initialDate.year;
_selectedMonth = widget.initialDate.month;
_selectedDay = widget.initialDate.day;
_selectedHour = widget.initialTime.hour;
_selectedMinute = widget.initialTime.minute;
_updateDays();
_yearController = FixedExtentScrollController(
initialItem: _years.indexOf(_selectedYear),
);
_monthController = FixedExtentScrollController(
initialItem: _selectedMonth - 1,
);
_dayController = FixedExtentScrollController(initialItem: _selectedDay - 1);
_hourController = FixedExtentScrollController(initialItem: _selectedHour);
_minuteController = FixedExtentScrollController(
initialItem: _selectedMinute,
);
}
void _updateDays() {
_days = List.generate(
DateTime(_selectedYear, _selectedMonth + 1, 0).day,
(i) => i + 1,
);
}
@override
void dispose() {
_yearController.dispose();
_monthController.dispose();
_dayController.dispose();
_hourController.dispose();
_minuteController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Container(
height: 420,
decoration: const BoxDecoration(
color: AppColors.white,
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
),
child: Column(
children: [
_buildHeader(),
Expanded(
child: Row(
children: [
Expanded(
flex: 3,
child: Column(
children: [
_buildPickerLabel('日期'),
Expanded(
child: Row(
children: [
Expanded(
child: _buildPicker(_years, _yearController, (v) {
setState(() {
_selectedYear = v;
_updateDays();
if (_selectedDay > _days.length) {
_selectedDay = _days.length;
_dayController.jumpToItem(_selectedDay - 1);
}
});
}, (v) => '$v'),
),
const Text(
'',
style: TextStyle(
fontSize: 14,
color: AppColors.slate600,
),
),
Expanded(
child: _buildPicker(_months, _monthController, (
v,
) {
setState(() {
_selectedMonth = v;
_updateDays();
if (_selectedDay > _days.length) {
_selectedDay = _days.length;
_dayController.jumpToItem(_selectedDay - 1);
}
});
}, (v) => '$v'),
),
const Text(
'',
style: TextStyle(
fontSize: 14,
color: AppColors.slate600,
),
),
Expanded(
child: _buildPicker(
_days,
_dayController,
(v) => setState(() => _selectedDay = v),
(v) => '$v',
),
),
const Text(
'',
style: TextStyle(
fontSize: 14,
color: AppColors.slate600,
),
),
],
),
),
],
),
),
Container(width: 1, height: 180, color: AppColors.border),
Expanded(
flex: 2,
child: Column(
children: [
_buildPickerLabel('时间'),
Expanded(
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Expanded(
child: _buildPicker(
_hours,
_hourController,
(v) => setState(() => _selectedHour = v),
(v) => v.toString().padLeft(2, '0'),
itemExtent: 50,
),
),
const Text(
' : ',
style: TextStyle(
fontSize: 20,
fontWeight: FontWeight.bold,
color: AppColors.slate600,
),
),
Expanded(
child: _buildPicker(
_minutes,
_minuteController,
(v) => setState(() => _selectedMinute = v),
(v) => v.toString().padLeft(2, '0'),
itemExtent: 50,
),
),
],
),
),
],
),
),
],
),
),
],
),
);
}
Widget _buildHeader() {
return Container(
height: 56,
padding: const EdgeInsets.symmetric(horizontal: 16),
decoration: const BoxDecoration(
border: Border(bottom: BorderSide(color: AppColors.border)),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
GestureDetector(
onTap: () => Navigator.pop(context),
child: const Text(
'取消',
style: TextStyle(fontSize: 17, color: AppColors.slate600),
),
),
const Text(
'选择时间',
style: TextStyle(
fontSize: 17,
fontWeight: FontWeight.w600,
color: AppColors.slate900,
),
),
GestureDetector(
onTap: () {
Navigator.pop(context, (
DateTime(_selectedYear, _selectedMonth, _selectedDay),
DateTime(2000, 1, 1, _selectedHour, _selectedMinute),
));
},
child: const Text(
'确定',
style: TextStyle(
fontSize: 17,
fontWeight: FontWeight.w600,
color: AppColors.blue600,
),
),
),
],
),
);
}
Widget _buildPickerLabel(String label) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 12),
child: Text(
label,
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: AppColors.slate700,
),
),
);
}
Widget _buildPicker(
List<int> items,
FixedExtentScrollController controller,
ValueChanged<int> onChanged,
String Function(int) formatter, {
double itemExtent = 40,
}) {
return CupertinoPicker(
scrollController: controller,
itemExtent: itemExtent,
magnification: 1.2,
squeeze: 0.8,
useMagnifier: true,
onSelectedItemChanged: (index) => onChanged(items[index]),
selectionOverlay: Container(
decoration: BoxDecoration(
border: Border.symmetric(
horizontal: BorderSide(
color: AppColors.blue100.withValues(alpha: 0.5),
width: 1,
),
),
),
),
children: List<Widget>.generate(items.length, (index) {
return Center(
child: Text(
formatter(items[index]),
style: const TextStyle(fontSize: 18, color: AppColors.slate900),
),
);
}),
);
}
}
@@ -3,6 +3,7 @@ import 'package:go_router/go_router.dart';
import '../../../../core/di/injection.dart';
import '../../../../core/theme/design_tokens.dart';
import '../../../../shared/widgets/toast/index.dart';
import '../../../../shared/widgets/app_button.dart';
import '../../../../shared/widgets/page_header.dart' as widgets;
import '../../../friends/data/friends_api.dart';
import '../../../users/data/models/user_response.dart';
@@ -20,10 +21,12 @@ class _ContactsScreenState extends State<ContactsScreen> {
final _searchFocusNode = FocusNode();
List<FriendResponse> _friends = [];
List<FriendRequestResponse> _pendingRequests = [];
List<UserResponse> _searchResults = [];
bool _isLoading = true;
bool _isSearching = false;
bool _hasSearched = false;
String? _sendingRequestUserId;
final Set<String> _sentRequestIds = {};
Set<String> _friendIds = {};
@@ -37,10 +40,16 @@ class _ContactsScreenState extends State<ContactsScreen> {
try {
final friendsApi = sl<FriendsApi>();
final friends = await friendsApi.getFriends();
final outgoingRequests = await friendsApi.getOutgoingRequests();
final pendingRequests = outgoingRequests
.where((r) => r.status == 'pending')
.toList();
if (mounted) {
setState(() {
_friends = friends;
_friendIds = friends.map((f) => f.friend.id).toSet();
_sentRequestIds.addAll(outgoingRequests.map((r) => r.recipient.id));
_pendingRequests = pendingRequests;
_isLoading = false;
});
}
@@ -98,18 +107,28 @@ class _ContactsScreenState extends State<ContactsScreen> {
}
Future<void> _sendFriendRequest(String targetUserId, String? content) async {
if (_sendingRequestUserId != null) {
return;
}
try {
setState(() {
_sendingRequestUserId = targetUserId;
});
final friendsApi = sl<FriendsApi>();
await friendsApi.sendRequest(targetUserId);
await friendsApi.sendRequest(targetUserId, content: content);
if (mounted) {
setState(() {
_sentRequestIds.add(targetUserId);
_sendingRequestUserId = null;
});
Toast.show(context, '好友请求已发送', type: ToastType.success);
}
} catch (e) {
if (mounted) {
setState(() {
_sendingRequestUserId = null;
});
Toast.show(context, '发送失败,请稍后重试', type: ToastType.error);
}
}
@@ -117,50 +136,121 @@ class _ContactsScreenState extends State<ContactsScreen> {
void _showAddFriendDialog(UserResponse user) {
final controller = TextEditingController();
showDialog(
showModalBottomSheet<void>(
context: context,
builder: (dialogContext) => AlertDialog(
title: const Text('添加好友'),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('${user.username} 发送好友请求'),
const SizedBox(height: 16),
TextField(
controller: controller,
decoration: const InputDecoration(
labelText: '验证消息(可选)',
hintText: '你好,我是...',
border: OutlineInputBorder(),
),
maxLines: 3,
maxLength: 200,
isScrollControlled: true,
backgroundColor: Colors.transparent,
builder: (sheetContext) {
return Padding(
padding: EdgeInsets.only(
bottom: MediaQuery.of(sheetContext).viewInsets.bottom,
),
child: Container(
width: double.infinity,
constraints: BoxConstraints(
maxHeight: MediaQuery.of(sheetContext).size.height * 0.7,
),
padding: const EdgeInsets.fromLTRB(
AppSpacing.xxl,
AppSpacing.lg,
AppSpacing.xxl,
AppSpacing.lg,
),
decoration: const BoxDecoration(
color: AppColors.white,
borderRadius: BorderRadius.vertical(
top: Radius.circular(AppRadius.xxl),
),
),
child: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Center(
child: Container(
width: 40,
height: AppSpacing.xs,
decoration: BoxDecoration(
color: AppColors.slate300,
borderRadius: BorderRadius.circular(AppRadius.full),
),
),
),
const SizedBox(height: AppSpacing.lg),
Text(
'添加 ${user.username}',
style: const TextStyle(
fontSize: 20,
fontWeight: FontWeight.w600,
color: AppColors.slate900,
),
),
const SizedBox(height: AppSpacing.sm),
const Text(
'发送一条验证信息,方便对方确认你的身份',
style: TextStyle(fontSize: 13, color: AppColors.slate500),
),
const SizedBox(height: AppSpacing.lg),
Container(
decoration: BoxDecoration(
color: AppColors.slate50,
borderRadius: BorderRadius.circular(AppRadius.lg),
border: Border.all(color: AppColors.borderSecondary),
),
child: TextField(
controller: controller,
maxLines: 3,
minLines: 2,
maxLength: 200,
decoration: const InputDecoration(
hintText: '你好,我是...',
hintStyle: TextStyle(
fontSize: 13,
color: AppColors.slate400,
),
border: InputBorder.none,
contentPadding: EdgeInsets.all(AppSpacing.lg),
counterStyle: TextStyle(
fontSize: 11,
color: AppColors.slate400,
),
),
),
),
const SizedBox(height: AppSpacing.lg),
Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Expanded(
child: AppButton(
text: '取消',
isOutlined: true,
onPressed: () => Navigator.pop(sheetContext),
),
),
const SizedBox(width: AppSpacing.md),
Expanded(
child: _buildSendButton(
user.id,
controller,
sheetContext,
),
),
],
),
SizedBox(
height:
MediaQuery.of(sheetContext).padding.bottom +
AppSpacing.sm,
),
],
),
),
],
),
actions: [
TextButton(
onPressed: () {
controller.dispose();
Navigator.pop(dialogContext);
},
child: const Text('取消'),
),
FilledButton(
onPressed: () {
Navigator.pop(dialogContext);
_sendFriendRequest(
user.id,
controller.text.isEmpty ? null : controller.text,
);
},
child: const Text('发送'),
),
],
),
).then((_) => controller.dispose());
);
},
);
}
@override
@@ -187,6 +277,12 @@ class _ContactsScreenState extends State<ContactsScreen> {
_buildSearchRow(),
_buildSearchResults(),
const SizedBox(height: 16),
if (_pendingRequests.isNotEmpty) ...[
_buildSectionTitle('新的联系人'),
const SizedBox(height: 8),
_buildPendingRequestCard(_pendingRequests),
const SizedBox(height: 16),
],
_buildSectionTitle('全部联系人'),
const SizedBox(height: 8),
if (_isLoading)
@@ -320,12 +416,7 @@ class _ContactsScreenState extends State<ContactsScreen> {
children: [
for (int i = 0; i < _searchResults.length; i++) ...[
_buildSearchResultItem(_searchResults[i]),
if (i < _searchResults.length - 1)
Container(
height: 1,
margin: const EdgeInsets.symmetric(horizontal: 14),
color: const Color(0xFFEEF2F7),
),
if (i < _searchResults.length - 1) _buildDivider(),
],
],
),
@@ -344,31 +435,7 @@ class _ContactsScreenState extends State<ContactsScreen> {
padding: const EdgeInsets.symmetric(horizontal: 14),
child: Row(
children: [
Container(
width: 42,
height: 42,
decoration: BoxDecoration(
color: _getAvatarBackground(avatarColor),
borderRadius: BorderRadius.circular(21),
border: Border.all(color: _getAvatarBorder(avatarColor)),
),
child: user.avatarUrl != null
? ClipRRect(
borderRadius: BorderRadius.circular(21),
child: Image.network(
user.avatarUrl!,
width: 42,
height: 42,
fit: BoxFit.cover,
errorBuilder: (_, __, ___) => Icon(
Icons.person,
size: 18,
color: _getAvatarColor(user.id),
),
),
)
: Icon(Icons.person, size: 18, color: _getAvatarColor(user.id)),
),
_buildAvatar(user.avatarUrl, user.id, avatarColor),
const SizedBox(width: 12),
Expanded(
child: Column(
@@ -506,6 +573,61 @@ class _ContactsScreenState extends State<ContactsScreen> {
);
}
Widget _buildPendingRequestCard(List<FriendRequestResponse> requests) {
return Container(
decoration: BoxDecoration(
color: AppColors.white,
borderRadius: BorderRadius.circular(16),
border: Border.all(color: const Color(0xFFE3EAF6)),
),
child: Column(
children: [
for (int i = 0; i < requests.length; i++) ...[
_buildPendingRequestItem(requests[i]),
if (i < requests.length - 1) _buildDivider(),
],
],
),
);
}
Widget _buildPendingRequestItem(FriendRequestResponse request) {
final recipient = request.recipient;
final color = _getAvatarColor(recipient.id);
return Container(
height: 70,
padding: const EdgeInsets.symmetric(horizontal: 14),
child: Row(
children: [
_buildAvatar(recipient.avatarUrl, recipient.id, color),
const SizedBox(width: 12),
Expanded(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
recipient.username,
style: const TextStyle(
fontSize: 15,
fontWeight: FontWeight.w600,
color: AppColors.slate900,
),
),
const SizedBox(height: 2),
const Text(
'等待对方确认',
style: TextStyle(fontSize: 12, color: AppColors.slate500),
),
],
),
),
],
),
);
}
Widget _buildContactCard(List<FriendResponse> friends) {
return Container(
decoration: BoxDecoration(
@@ -517,12 +639,7 @@ class _ContactsScreenState extends State<ContactsScreen> {
children: [
for (int i = 0; i < friends.length; i++) ...[
_buildContactItem(friends[i]),
if (i < friends.length - 1)
Container(
height: 1,
margin: const EdgeInsets.symmetric(horizontal: 14),
color: const Color(0xFFEEF2F7),
),
if (i < friends.length - 1) _buildDivider(),
],
],
),
@@ -540,28 +657,7 @@ class _ContactsScreenState extends State<ContactsScreen> {
padding: const EdgeInsets.symmetric(horizontal: 14),
child: Row(
children: [
Container(
width: 42,
height: 42,
decoration: BoxDecoration(
color: _getAvatarBackground(color),
borderRadius: BorderRadius.circular(21),
border: Border.all(color: _getAvatarBorder(color)),
),
child: friendInfo.avatarUrl != null
? ClipRRect(
borderRadius: BorderRadius.circular(21),
child: Image.network(
friendInfo.avatarUrl!,
width: 42,
height: 42,
fit: BoxFit.cover,
errorBuilder: (_, __, ___) =>
Icon(Icons.person, size: 18, color: color),
),
)
: Icon(Icons.person, size: 18, color: color),
),
_buildAvatar(friendInfo.avatarUrl, friendInfo.id, color),
const SizedBox(width: 12),
Expanded(
child: Column(
@@ -614,4 +710,82 @@ class _ContactsScreenState extends State<ContactsScreen> {
if (color == AppColors.violet500) return const Color(0xFFE4E8FA);
return const Color(0xFFDDE8FB);
}
Widget _buildDivider() {
return Container(
height: 1,
margin: const EdgeInsets.symmetric(horizontal: 14),
color: const Color(0xFFEEF2F7),
);
}
Widget _buildAvatar(String? avatarUrl, String userId, Color color) {
return Container(
width: 42,
height: 42,
decoration: BoxDecoration(
color: _getAvatarBackground(color),
borderRadius: BorderRadius.circular(21),
border: Border.all(color: _getAvatarBorder(color)),
),
child: avatarUrl != null
? ClipRRect(
borderRadius: BorderRadius.circular(21),
child: Image.network(
avatarUrl,
width: 42,
height: 42,
fit: BoxFit.cover,
errorBuilder: (context, error, stackTrace) => Icon(
Icons.person,
size: 18,
color: _getAvatarColor(userId),
),
),
)
: Icon(Icons.person, size: 18, color: _getAvatarColor(userId)),
);
}
Widget _buildSendButton(
String userId,
TextEditingController controller,
BuildContext sheetContext,
) {
return SizedBox(
height: 44,
child: ElevatedButton(
onPressed: _sendingRequestUserId == userId
? null
: () async {
final content = controller.text.trim();
Navigator.pop(sheetContext);
await _sendFriendRequest(
userId,
content.isEmpty ? null : content,
);
},
style: ElevatedButton.styleFrom(
backgroundColor: AppColors.blue500,
foregroundColor: AppColors.white,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(AppRadius.sm),
),
),
child: _sendingRequestUserId == userId
? const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(
strokeWidth: 2,
color: AppColors.white,
),
)
: const Text(
'发送',
style: TextStyle(fontSize: 14, fontWeight: FontWeight.w500),
),
),
);
}
}
+49 -18
View File
@@ -12,43 +12,43 @@ class FriendsApi {
return data.map((json) => FriendResponse.fromJson(json)).toList();
}
Future<List<FriendResponse>> getIncomingRequests() async {
final response = await _client.get('$_prefix/requests/inbox');
final List<dynamic> data = response.data;
return data.map((json) => FriendResponse.fromJson(json)).toList();
}
Future<List<FriendResponse>> getOutgoingRequests() async {
Future<List<FriendRequestResponse>> getOutgoingRequests() async {
final response = await _client.get('$_prefix/requests/outgoing');
final List<dynamic> data = response.data;
return data.map((json) => FriendResponse.fromJson(json)).toList();
return data.map((json) => FriendRequestResponse.fromJson(json)).toList();
}
Future<FriendResponse> sendRequest(String targetUserId) async {
final response = await _client.post(
'$_prefix/requests',
data: {'target_user_id': targetUserId},
);
return FriendResponse.fromJson(response.data);
Future<FriendRequestResponse> sendRequest(
String targetUserId, {
String? content,
}) async {
final data = {'target_user_id': targetUserId, 'content': content};
final response = await _client.post('$_prefix/requests', data: data);
return FriendRequestResponse.fromJson(response.data);
}
Future<FriendResponse> acceptRequest(String friendshipId) async {
Future<FriendRequestResponse> acceptRequest(String friendshipId) async {
final response = await _client.post(
'$_prefix/requests/$friendshipId/accept',
);
return FriendResponse.fromJson(response.data);
return FriendRequestResponse.fromJson(response.data);
}
Future<FriendResponse> declineRequest(String friendshipId) async {
Future<FriendRequestResponse> declineRequest(String friendshipId) async {
final response = await _client.post(
'$_prefix/requests/$friendshipId/decline',
);
return FriendResponse.fromJson(response.data);
return FriendRequestResponse.fromJson(response.data);
}
Future<void> removeFriend(String friendshipId) async {
await _client.delete('$_prefix/$friendshipId');
}
Future<FriendRequestResponse> getRequestById(String friendshipId) async {
final response = await _client.get('$_prefix/requests/$friendshipId');
return FriendRequestResponse.fromJson(response.data);
}
}
class FriendResponse {
@@ -94,3 +94,34 @@ class UserBasicInfo {
);
}
}
class FriendRequestResponse {
final String id;
final UserBasicInfo sender;
final UserBasicInfo recipient;
final String? content;
final String status;
final DateTime createdAt;
FriendRequestResponse({
required this.id,
required this.sender,
required this.recipient,
this.content,
required this.status,
required this.createdAt,
});
factory FriendRequestResponse.fromJson(Map<String, dynamic> json) {
return FriendRequestResponse(
id: json['id'] as String,
sender: UserBasicInfo.fromJson(json['sender'] as Map<String, dynamic>),
recipient: UserBasicInfo.fromJson(
json['recipient'] as Map<String, dynamic>,
),
content: json['content'] as String?,
status: json['status'] as String,
createdAt: DateTime.parse(json['created_at'] as String),
);
}
}
@@ -5,10 +5,12 @@ import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:go_router/go_router.dart';
import 'package:lucide_icons/lucide_icons.dart';
import '../../../../core/api/api_exception.dart';
import '../../../../core/di/injection.dart';
import '../../../../core/theme/design_tokens.dart';
import '../../../chat/data/models/chat_list_item.dart';
import '../../../chat/presentation/bloc/chat_bloc.dart';
import '../../../chat/data/tools/route_navigation_tool.dart';
import '../../../messages/data/inbox_api.dart';
import '../../data/voice_recorder.dart';
import '../../../chat/ui/widgets/ui_schema_renderer.dart';
import '../../../../shared/widgets/toast/toast.dart';
@@ -64,11 +66,13 @@ class _HomeScreenState extends State<HomeScreen>
final ScrollController _scrollController = ScrollController();
late final ChatBloc _chatBloc;
late final VoiceRecorder _voiceRecorder;
late final InboxApi _inboxApi;
late final Future<String> Function(String filePath) _transcribeAudio;
late final Future<void> Function(String transcript) _autoSendTranscript;
late final AnimationController _listeningAnimationController;
bool _isRecording = false;
bool _isTranscribing = false;
int _unreadCount = 0;
bool get _hasMessage => _messageController.text.trim().isNotEmpty;
@@ -78,6 +82,7 @@ class _HomeScreenState extends State<HomeScreen>
_messageController.addListener(_onMessageChanged);
_chatBloc = widget.chatBloc ?? ChatBloc();
_voiceRecorder = widget.voiceRecorder ?? RecordVoiceRecorder();
_inboxApi = sl<InboxApi>();
_transcribeAudio =
widget.onTranscribeAudio ?? _chatBloc.transcribeAudioFile;
_autoSendTranscript = widget.onAutoSendTranscript ?? _chatBloc.sendMessage;
@@ -88,6 +93,18 @@ class _HomeScreenState extends State<HomeScreen>
if (widget.autoLoadHistory) {
_chatBloc.loadHistory();
}
_loadUnreadCount();
}
Future<void> _loadUnreadCount() async {
try {
final messages = await _inboxApi.getMessages(isRead: false);
if (mounted) {
setState(() => _unreadCount = messages.length);
}
} catch (_) {
// Ignore errors
}
}
@override
@@ -175,10 +192,45 @@ class _HomeScreenState extends State<HomeScreen>
),
const SizedBox(width: _itemSpacing),
IconButton(
icon: const Icon(
LucideIcons.messageSquare,
size: _iconSize,
color: AppColors.slate900,
icon: Stack(
clipBehavior: Clip.none,
children: [
const Icon(
LucideIcons.messageSquare,
size: _iconSize,
color: AppColors.slate900,
),
if (_unreadCount > 0)
Positioned(
right: -4,
top: -4,
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 4,
vertical: 2,
),
decoration: BoxDecoration(
color: AppColors.red500,
borderRadius: BorderRadius.circular(8),
),
constraints: const BoxConstraints(
minWidth: 16,
minHeight: 16,
),
child: Text(
_unreadCount > 99
? '99+'
: _unreadCount.toString(),
style: const TextStyle(
fontSize: 10,
fontWeight: FontWeight.w600,
color: AppColors.white,
),
textAlign: TextAlign.center,
),
),
),
],
),
onPressed: () => context.push('/messages/invites'),
),
@@ -0,0 +1,110 @@
import 'package:social_app/core/api/i_api_client.dart';
class InboxApi {
final IApiClient _client;
static const _prefix = '/api/v1/inbox/messages';
InboxApi(this._client);
Future<List<InboxMessageResponse>> getMessages({bool? isRead}) async {
final queryParams = isRead != null ? '?is_read=$isRead' : '';
final response = await _client.get('$_prefix$queryParams');
final List<dynamic> data = response.data;
return data.map((json) => InboxMessageResponse.fromJson(json)).toList();
}
Future<InboxMessageResponse> markAsRead(String messageId) async {
final response = await _client.patch('$_prefix/$messageId/read');
return InboxMessageResponse.fromJson(response.data);
}
}
class InboxMessageResponse {
final String id;
final String recipientId;
final String? senderId;
final InboxMessageType messageType;
final String? scheduleItemId;
final String? friendshipId;
final String? content;
final bool isRead;
final InboxMessageStatus status;
final DateTime createdAt;
InboxMessageResponse({
required this.id,
required this.recipientId,
this.senderId,
required this.messageType,
this.scheduleItemId,
this.friendshipId,
this.content,
required this.isRead,
required this.status,
required this.createdAt,
});
factory InboxMessageResponse.fromJson(Map<String, dynamic> json) {
return InboxMessageResponse(
id: json['id'] as String,
recipientId: json['recipient_id'] as String,
senderId: json['sender_id'] as String?,
messageType: InboxMessageType.fromJson(json['message_type'] as String),
scheduleItemId: json['schedule_item_id'] as String?,
friendshipId: json['friendship_id'] as String?,
content: json['content'] as String?,
isRead: json['is_read'] as bool,
status: InboxMessageStatus.fromJson(json['status'] as String),
createdAt: DateTime.parse(json['created_at'] as String),
);
}
InboxMessageResponse copyWith({bool? isRead}) {
return InboxMessageResponse(
id: id,
recipientId: recipientId,
senderId: senderId,
messageType: messageType,
scheduleItemId: scheduleItemId,
friendshipId: friendshipId,
content: content,
isRead: isRead ?? this.isRead,
status: status,
createdAt: createdAt,
);
}
}
enum InboxMessageType {
friendRequest('friend_request'),
calendar('calendar'),
system('system'),
group('group');
final String value;
const InboxMessageType(this.value);
static InboxMessageType fromJson(String json) {
return InboxMessageType.values.firstWhere(
(e) => e.value == json,
orElse: () => InboxMessageType.system,
);
}
}
enum InboxMessageStatus {
pending('pending'),
accepted('accepted'),
rejected('rejected'),
dismissed('dismissed');
final String value;
const InboxMessageStatus(this.value);
static InboxMessageStatus fromJson(String json) {
return InboxMessageStatus.values.firstWhere(
(e) => e.value == json,
orElse: () => InboxMessageStatus.pending,
);
}
}
@@ -1,33 +1,214 @@
import 'dart:convert';
import 'package:flutter/material.dart' hide BackButton;
import 'package:go_router/go_router.dart';
import '../../../../core/di/injection.dart';
import '../../../../core/theme/design_tokens.dart';
import '../../../../shared/widgets/page_header.dart';
import '../../../../shared/widgets/toast/toast.dart';
import '../../../../shared/widgets/toast/toast_type.dart';
import '../../../friends/data/friends_api.dart';
import '../../data/inbox_api.dart';
class MessageInviteListScreen extends StatelessWidget {
class MessageWithFriend {
final InboxMessageResponse message;
final FriendRequestResponse? friendRequest;
const MessageWithFriend({required this.message, this.friendRequest});
}
class MessageInviteListScreen extends StatefulWidget {
const MessageInviteListScreen({super.key});
@override
State<MessageInviteListScreen> createState() =>
_MessageInviteListScreenState();
}
class _MessageInviteListScreenState extends State<MessageInviteListScreen> {
late final InboxApi _inboxApi;
late final FriendsApi _friendsApi;
List<MessageWithFriend> _unreadMessages = [];
List<MessageWithFriend> _readMessages = [];
bool _isLoading = false;
int _activeTabIndex = 0;
@override
void initState() {
super.initState();
_inboxApi = sl<InboxApi>();
_friendsApi = sl<FriendsApi>();
_loadMessages();
}
Future<void> _loadMessages() async {
if (mounted) {
setState(() => _isLoading = true);
}
try {
final unreadRaw = await _inboxApi.getMessages(isRead: false);
final readRaw = await _inboxApi.getMessages(isRead: true);
final unread = await _enrichWithFriendDetails(unreadRaw);
final read = await _enrichWithFriendDetails(readRaw);
if (!mounted) return;
setState(() {
_unreadMessages = unread;
_readMessages = read;
_isLoading = false;
});
} catch (e) {
if (!mounted) return;
setState(() => _isLoading = false);
Toast.show(context, '消息加载失败,请稍后重试', type: ToastType.error);
}
}
Future<List<MessageWithFriend>> _enrichWithFriendDetails(
List<InboxMessageResponse> messages,
) async {
final futures = messages.map(_fetchFriendRequest);
final results = await Future.wait(futures);
final enriched = <MessageWithFriend>[];
for (int i = 0; i < messages.length; i++) {
final message = messages[i];
final friendRequest = results[i];
enriched.add(
MessageWithFriend(message: message, friendRequest: friendRequest),
);
}
return enriched;
}
Future<FriendRequestResponse?> _fetchFriendRequest(
InboxMessageResponse message,
) async {
if (message.messageType != InboxMessageType.friendRequest ||
message.friendshipId == null) {
return null;
}
try {
return await _friendsApi.getRequestById(message.friendshipId!);
} catch (_) {
return null;
}
}
Future<void> _handleMessageTap(MessageWithFriend item) async {
final message = item.message;
switch (message.messageType) {
case InboxMessageType.calendar:
context.push('/messages/invites/${message.id}');
return;
case InboxMessageType.friendRequest:
if (item.friendRequest == null) {
Toast.show(context, '发送者信息加载失败,请下拉重试', type: ToastType.error);
return;
}
if (message.isRead) {
_showFriendRequestReadOnlySheet(item);
} else {
_showFriendRequestActionSheet(item);
}
return;
case InboxMessageType.system:
case InboxMessageType.group:
return;
}
}
void _showFriendRequestReadOnlySheet(MessageWithFriend item) {
showModalBottomSheet<void>(
context: context,
backgroundColor: Colors.transparent,
builder: (ctx) {
return _FriendRequestSheet(
message: item.message,
friendRequest: item.friendRequest!,
isReadOnly: true,
);
},
);
}
void _showFriendRequestActionSheet(MessageWithFriend item) {
showModalBottomSheet<void>(
context: context,
backgroundColor: Colors.transparent,
isScrollControlled: true,
builder: (ctx) {
return _FriendRequestSheet(
message: item.message,
friendRequest: item.friendRequest!,
isReadOnly: false,
onAccept: () async {
Navigator.pop(ctx);
await _processFriendRequest(item, accept: true);
},
onDecline: () async {
Navigator.pop(ctx);
await _processFriendRequest(item, accept: false);
},
);
},
);
}
Future<void> _processFriendRequest(
MessageWithFriend item, {
required bool accept,
}) async {
final message = item.message;
final friendshipId = message.friendshipId;
if (friendshipId == null) {
Toast.show(context, '好友请求数据缺失', type: ToastType.error);
return;
}
try {
if (accept) {
await _friendsApi.acceptRequest(friendshipId);
if (mounted) {
Toast.show(context, '已接受好友请求', type: ToastType.success);
}
} else {
await _friendsApi.declineRequest(friendshipId);
if (mounted) {
Toast.show(context, '已拒绝好友请求', type: ToastType.success);
}
}
await _inboxApi.markAsRead(message.id);
await _loadMessages();
} catch (e) {
if (mounted) {
Toast.show(context, '处理失败,请稍后重试', type: ToastType.error);
}
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: AppColors.messageBg,
backgroundColor: AppColors.background,
body: SafeArea(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
PageHeader(leading: BackButton(onPressed: () => context.pop())),
_buildHeader(context),
Expanded(
child: Padding(
padding: const EdgeInsets.only(left: 20, right: 20, bottom: 20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildRemindTag(),
const SizedBox(height: 12),
_buildInviteCard(context),
const Spacer(),
],
),
),
child: _isLoading
? const Center(
child: CircularProgressIndicator(
color: AppColors.blue500,
),
)
: _activeTabIndex == 0
? _buildMessageList(_unreadMessages, isUnread: true)
: _buildMessageList(_readMessages, isUnread: false),
),
],
),
@@ -35,102 +216,400 @@ class MessageInviteListScreen extends StatelessWidget {
);
}
Widget _buildRemindTag() {
return Container(
height: 24,
padding: const EdgeInsets.symmetric(horizontal: 10),
decoration: BoxDecoration(
color: AppColors.messageTagBg,
borderRadius: BorderRadius.circular(12),
),
child: const Center(
child: Text(
'消息提醒',
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
color: AppColors.blue600,
),
),
Widget _buildHeader(BuildContext context) {
return Padding(
padding: const EdgeInsets.fromLTRB(12, 8, 20, 8),
child: Row(
children: [
BackButton(onPressed: () => context.pop()),
const SizedBox(width: 12),
Expanded(child: _buildTabs()),
const SizedBox(width: 56),
],
),
);
}
Widget _buildInviteCard(BuildContext context) {
Widget _buildTabs() {
return Container(
padding: const EdgeInsets.all(4),
decoration: BoxDecoration(
color: AppColors.slate100,
borderRadius: BorderRadius.circular(10),
),
child: Row(
children: [
Expanded(child: _buildTab(0, '未读', Icons.mark_email_unread_outlined)),
const SizedBox(width: 4),
Expanded(child: _buildTab(1, '已读', Icons.mark_email_read_outlined)),
],
),
);
}
Widget _buildTab(int index, String label, IconData icon) {
final isSelected = _activeTabIndex == index;
return GestureDetector(
onTap: () => context.push('/messages/invites/1'),
onTap: () {
if (_activeTabIndex != index) {
setState(() => _activeTabIndex = index);
}
},
child: Container(
width: double.infinity,
padding: const EdgeInsets.all(14),
padding: const EdgeInsets.symmetric(vertical: 10),
decoration: BoxDecoration(
color: AppColors.messageCardBg,
borderRadius: BorderRadius.circular(16),
border: Border.all(color: AppColors.messageCardBorder),
color: isSelected ? AppColors.white : Colors.transparent,
borderRadius: BorderRadius.circular(8),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Row(
children: [
Container(
width: 44,
height: 44,
decoration: BoxDecoration(
color: AppColors.messageCalendarBg,
borderRadius: BorderRadius.circular(12),
),
child: const Icon(
Icons.calendar_today_outlined,
size: 20,
color: AppColors.blue500,
),
),
],
),
const SizedBox(height: 8),
const Text(
'事件:产品评审会 2026-02-12 14:00',
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w500,
color: AppColors.slate700,
),
),
const SizedBox(height: 8),
const Text(
'邀请人:李文浩',
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.normal,
color: AppColors.slate500,
),
),
const SizedBox(height: 8),
const Text(
'点击查看详情并处理邀请',
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.normal,
color: AppColors.slate400,
),
),
],
Icon(
icon,
size: 16,
color: isSelected ? AppColors.slate900 : AppColors.slate500,
),
const SizedBox(width: 6),
Text(
label,
style: TextStyle(
fontSize: 14,
fontWeight: isSelected ? FontWeight.w600 : FontWeight.w500,
color: isSelected ? AppColors.slate900 : AppColors.slate500,
),
),
const SizedBox(width: 8),
Icon(
Icons.chevron_right,
size: 16,
color: AppColors.messageArrowColor,
),
if (index == 0 && _unreadMessages.isNotEmpty) ...[
const SizedBox(width: 6),
Container(
padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 2),
decoration: BoxDecoration(
color: AppColors.red500,
borderRadius: BorderRadius.circular(8),
),
child: Text(
_unreadMessages.length > 99
? '99+'
: _unreadMessages.length.toString(),
style: const TextStyle(
fontSize: 10,
fontWeight: FontWeight.w600,
color: AppColors.white,
),
),
),
],
],
),
),
);
}
Widget _buildMessageList(
List<MessageWithFriend> messages, {
required bool isUnread,
}) {
return RefreshIndicator(
onRefresh: _loadMessages,
color: AppColors.blue500,
child: ListView.builder(
physics: const AlwaysScrollableScrollPhysics(),
padding: const EdgeInsets.symmetric(horizontal: 20),
itemCount: messages.isEmpty ? 1 : messages.length,
itemBuilder: (context, index) {
if (messages.isEmpty) {
return SizedBox(
height: MediaQuery.sizeOf(context).height * 0.6,
child: _buildEmptyState(isUnread: isUnread),
);
}
final item = messages[index];
return Padding(
padding: const EdgeInsets.only(bottom: 12),
child: _MessageCard(
item: item,
onTap: () => _handleMessageTap(item),
),
);
},
),
);
}
Widget _buildEmptyState({required bool isUnread}) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Container(
width: 80,
height: 80,
decoration: BoxDecoration(
color: AppColors.slate100,
shape: BoxShape.circle,
),
child: Icon(
isUnread ? Icons.notifications_none : Icons.inbox_outlined,
size: 36,
color: AppColors.slate400,
),
),
const SizedBox(height: 16),
Text(
isUnread ? '暂无未读消息' : '暂无已读消息',
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w500,
color: AppColors.slate500,
),
),
const SizedBox(height: 8),
Text(
isUnread ? '有新消息时会在这里显示' : '处理过的消息会显示在这里',
style: const TextStyle(fontSize: 13, color: AppColors.slate400),
),
],
),
);
}
}
class _MessageCard extends StatelessWidget {
final MessageWithFriend item;
final VoidCallback onTap;
const _MessageCard({required this.item, required this.onTap});
InboxMessageResponse get message => item.message;
FriendRequestResponse? get friendRequest => item.friendRequest;
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: onTap,
child: Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: AppColors.white,
borderRadius: BorderRadius.circular(16),
border: Border.all(
color: message.isRead
? AppColors.borderSecondary
: AppColors.blue100,
width: message.isRead ? 1 : 1.5,
),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
width: 48,
height: 48,
decoration: BoxDecoration(
color: AppColors.emerald500.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(14),
),
child: const Icon(
Icons.person_add_outlined,
size: 22,
color: AppColors.emerald500,
),
),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
_title(),
style: const TextStyle(
fontSize: 15,
fontWeight: FontWeight.w600,
color: AppColors.slate900,
),
),
const SizedBox(height: 6),
Text(
_content(),
style: const TextStyle(
fontSize: 13,
color: AppColors.slate500,
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
],
),
),
],
),
),
);
}
String _title() {
if (message.messageType == InboxMessageType.friendRequest) {
if (friendRequest == null) {
return '好友请求信息加载失败';
}
return '${friendRequest!.sender.username} 请求添加您为好友';
}
if (message.messageType == InboxMessageType.calendar) {
try {
final data =
jsonDecode(message.content ?? '{}') as Map<String, dynamic>;
return data['title'] as String? ?? '日历邀请';
} catch (_) {
return '日历邀请';
}
}
return '系统消息';
}
String _content() => message.content ?? '点击查看详情';
}
class _FriendRequestSheet extends StatelessWidget {
final InboxMessageResponse message;
final FriendRequestResponse friendRequest;
final bool isReadOnly;
final VoidCallback? onAccept;
final VoidCallback? onDecline;
const _FriendRequestSheet({
required this.message,
required this.friendRequest,
required this.isReadOnly,
this.onAccept,
this.onDecline,
});
@override
Widget build(BuildContext context) {
final status = friendRequest.status;
final statusText = status == 'accepted'
? '已接受'
: status == 'rejected'
? '已拒绝'
: '已处理';
return Container(
width: double.infinity,
padding: const EdgeInsets.fromLTRB(24, 20, 24, 0),
decoration: const BoxDecoration(
color: AppColors.white,
borderRadius: BorderRadius.vertical(top: Radius.circular(24)),
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 40,
height: 4,
decoration: BoxDecoration(
color: AppColors.slate300,
borderRadius: BorderRadius.circular(2),
),
),
const SizedBox(height: 20),
Container(
width: 72,
height: 72,
decoration: BoxDecoration(
color: AppColors.emerald500.withValues(alpha: 0.1),
shape: BoxShape.circle,
),
child: const Icon(
Icons.person_add_outlined,
size: 32,
color: AppColors.emerald500,
),
),
const SizedBox(height: 16),
Text(
'${friendRequest.sender.username} 请求添加您为好友',
style: const TextStyle(
fontSize: 20,
fontWeight: FontWeight.w600,
color: AppColors.slate900,
),
textAlign: TextAlign.center,
),
if ((message.content ?? '').isNotEmpty) ...[
const SizedBox(height: 8),
Text(
'备注: ${message.content}',
style: const TextStyle(fontSize: 14, color: AppColors.slate500),
),
],
if (isReadOnly) ...[
const SizedBox(height: 16),
Container(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8),
decoration: BoxDecoration(
color: AppColors.slate100,
borderRadius: BorderRadius.circular(20),
),
child: Text(
statusText,
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: AppColors.slate600,
),
),
),
] else ...[
const SizedBox(height: 28),
Row(
children: [
Expanded(
child: OutlinedButton(
onPressed: onDecline,
style: OutlinedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 14),
side: const BorderSide(color: AppColors.slate300),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
child: const Text(
'拒绝',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: AppColors.slate600,
),
),
),
),
const SizedBox(width: 16),
Expanded(
child: ElevatedButton(
onPressed: onAccept,
style: ElevatedButton.styleFrom(
backgroundColor: AppColors.blue500,
elevation: 0,
padding: const EdgeInsets.symmetric(vertical: 14),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
child: const Text(
'接受',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: AppColors.white,
),
),
),
),
],
),
],
SizedBox(height: MediaQuery.of(context).padding.bottom + 12),
],
),
);
}
}
@@ -0,0 +1,132 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:social_app/core/api/mock_api_client.dart';
import 'package:social_app/features/calendar/data/calendar_api.dart';
import 'package:social_app/features/calendar/data/models/schedule_item_model.dart';
void main() {
group('CalendarApi', () {
test('listByRange parses metadata and attachments', () async {
final client = MockApiClient();
client.registerPatternHandler(
RegExp(r'^/api/v1/schedule-items\?.*$'),
'GET',
(_) => [
{
'id': 'evt_1',
'title': '晨会',
'description': '同步',
'start_at': '2026-03-11T01:00:00Z',
'end_at': '2026-03-11T02:00:00Z',
'timezone': 'Asia/Shanghai',
'metadata': {
'color': '#4F46E5',
'location': '会议室A',
'notes': '带电脑',
'attachments': [
{
'name': '议程文档',
'visible_to': ['u1'],
'url': 'https://example.com/a',
'note': '会前阅读',
'content': null,
'type': 'document',
},
],
'version': 1,
'new_field': 'future',
},
'status': 'active',
'source_type': 'manual',
'created_at': '2026-03-10T01:00:00Z',
'updated_at': '2026-03-10T01:30:00Z',
},
],
);
final api = CalendarApi(client);
final result = await api.listByRange(
startAt: DateTime.utc(2026, 3, 1),
endAt: DateTime.utc(2026, 3, 31, 23, 59, 59),
);
expect(result, hasLength(1));
expect(result.first.metadata?.attachments, hasLength(1));
expect(result.first.metadata?.raw['new_field'], 'future');
expect(result.first.startAt.isUtc, isFalse);
});
test('create serializes full metadata', () async {
final client = MockApiClient();
client.registerHandler('/api/v1/schedule-items', 'POST', (request) {
final body = request.data as Map<String, dynamic>;
expect(body['metadata']['version'], 1);
expect(body['metadata']['attachments'], isA<List<dynamic>>());
return {
'id': 'evt_2',
...body,
'status': 'active',
'source_type': 'manual',
'created_at': '2026-03-10T01:00:00Z',
'updated_at': '2026-03-10T01:00:00Z',
};
});
final api = CalendarApi(client);
final created = await api.create(
ScheduleItemModel(
id: 'evt_local',
title: '评审',
startAt: DateTime.utc(2026, 3, 11, 3),
endAt: DateTime.utc(2026, 3, 11, 4),
metadata: ScheduleMetadata(
color: '#F59E0B',
location: '线上',
notes: '准备 demo',
attachments: [Attachment(name: 'PRD', type: 'document')],
version: 1,
),
),
);
expect(created.id, 'evt_2');
expect(created.metadata?.location, '线上');
});
test('update does not send unknown metadata fields', () async {
final client = MockApiClient();
client.registerHandler('/api/v1/schedule-items/evt_3', 'PATCH', (
request,
) {
final body = request.data as Map<String, dynamic>;
final metadata = body['metadata'] as Map<String, dynamic>;
expect(metadata.containsKey('new_field'), isFalse);
return {
'id': 'evt_3',
...body,
'status': 'active',
'source_type': 'manual',
'created_at': '2026-03-10T01:00:00Z',
'updated_at': '2026-03-11T01:00:00Z',
};
});
final api = CalendarApi(client);
final event = ScheduleItemModel(
id: 'evt_3',
title: '同步会',
startAt: DateTime.utc(2026, 3, 11, 1),
metadata: ScheduleMetadata.fromJson({
'color': '#3B82F6',
'location': '会议室B',
'notes': '更新周报',
'attachments': const [],
'version': 1,
'new_field': 'future',
}),
);
final updated = await api.update(event);
expect(updated.id, 'evt_3');
});
});
}
@@ -0,0 +1,47 @@
import 'package:flutter_test/flutter_test.dart';
void main() {
group('时间自动对齐逻辑', () {
test('开始时间改变后,结束时间早于开始时间应自动对齐', () {
DateTime startTime = DateTime(2026, 3, 11, 10, 0);
DateTime endTime = DateTime(2026, 3, 11, 9, 0);
final newStartTime = DateTime(2026, 3, 11, 14, 30);
if (endTime.isBefore(newStartTime)) {
endTime = newStartTime;
}
expect(endTime.hour, 14);
expect(endTime.minute, 30);
});
test('结束时间晚于开始时间则不需要对齐', () {
DateTime startTime = DateTime(2026, 3, 11, 10, 0);
DateTime endTime = DateTime(2026, 3, 11, 12, 0);
final newStartTime = DateTime(2026, 3, 11, 14, 30);
if (endTime.isBefore(newStartTime)) {
endTime = newStartTime;
}
expect(endTime.hour, 14);
expect(endTime.minute, 30);
});
test('结束时间等于开始时间也需要对齐', () {
DateTime startTime = DateTime(2026, 3, 11, 10, 0);
DateTime endTime = DateTime(2026, 3, 11, 10, 0);
final newStartTime = DateTime(2026, 3, 11, 14, 30);
if (endTime.isBefore(newStartTime)) {
endTime = newStartTime;
}
expect(endTime.hour, 14);
expect(endTime.minute, 30);
});
});
}