feat: 增强日历功能并集成 AgentScope 代理服务
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user