feat: 重构 Reminder Notification 系统并更新应用包名
This commit is contained in:
@@ -1,18 +1,24 @@
|
||||
import '../../../../data/cache/cache_policy.dart';
|
||||
import '../../../../data/cache/cached_repository.dart';
|
||||
import '../../../../data/cache/cache_scope.dart';
|
||||
import '../../../../data/network/i_api_client.dart';
|
||||
import '../../../../core/notification/models/reminder_alarm.dart';
|
||||
import '../../../../core/notification/services/reminder_reconcile_service.dart';
|
||||
import '../models/schedule_item_model.dart';
|
||||
|
||||
class CalendarRepository extends CachedRepository<List<ScheduleItemModel>> {
|
||||
final IApiClient _apiClient;
|
||||
final ReminderReconcileService? _reminderReconcileService;
|
||||
static const _prefix = '/api/v1/schedule-items';
|
||||
|
||||
CalendarRepository({
|
||||
required super.store,
|
||||
required IApiClient apiClient,
|
||||
ReminderReconcileService? reminderReconcileService,
|
||||
CachePolicy? policy,
|
||||
super.now,
|
||||
}) : _apiClient = apiClient,
|
||||
_reminderReconcileService = reminderReconcileService,
|
||||
super(
|
||||
policy:
|
||||
policy ??
|
||||
@@ -69,7 +75,9 @@ class CalendarRepository extends CachedRepository<List<ScheduleItemModel>> {
|
||||
if (data == null) {
|
||||
throw StateError('Invalid getEventById response: empty payload');
|
||||
}
|
||||
return ScheduleItemModel.fromJson(data);
|
||||
final event = ScheduleItemModel.fromJson(data);
|
||||
await _reminderReconcileService?.reconcileEvent(_toReminderSnapshot(event));
|
||||
return event;
|
||||
}
|
||||
|
||||
Future<ScheduleItemModel> getById(String id) {
|
||||
@@ -91,11 +99,20 @@ class CalendarRepository extends CachedRepository<List<ScheduleItemModel>> {
|
||||
}
|
||||
|
||||
Future<void> acceptSubscription(String itemId) {
|
||||
return _apiClient.post<void>('$_prefix/$itemId/accept');
|
||||
return _applySubscriptionAction(itemId, accept: true);
|
||||
}
|
||||
|
||||
Future<void> rejectSubscription(String itemId) {
|
||||
return _apiClient.post<void>('$_prefix/$itemId/reject');
|
||||
return _applySubscriptionAction(itemId, accept: false);
|
||||
}
|
||||
|
||||
Future<void> _applySubscriptionAction(
|
||||
String itemId, {
|
||||
required bool accept,
|
||||
}) async {
|
||||
final action = accept ? 'accept' : 'reject';
|
||||
await _apiClient.post<void>('$_prefix/$itemId/$action');
|
||||
await store.clearByPrefix('cache:${CacheScope.token()}:calendar:');
|
||||
}
|
||||
|
||||
Future<List<ScheduleItemModel>> _listByRange({
|
||||
@@ -111,10 +128,28 @@ class CalendarRepository extends CachedRepository<List<ScheduleItemModel>> {
|
||||
if (data == null) {
|
||||
throw StateError('Invalid listByRange response: empty payload');
|
||||
}
|
||||
return data
|
||||
final events = data
|
||||
.whereType<Map<String, dynamic>>()
|
||||
.map(ScheduleItemModel.fromJson)
|
||||
.toList(growable: false);
|
||||
await _reminderReconcileService?.reconcileEvents(
|
||||
events.map(_toReminderSnapshot).toList(growable: false),
|
||||
);
|
||||
return events;
|
||||
}
|
||||
|
||||
ReminderEventSnapshot _toReminderSnapshot(ScheduleItemModel event) {
|
||||
return ReminderEventSnapshot(
|
||||
eventId: event.id,
|
||||
title: event.title,
|
||||
startAt: event.startAt,
|
||||
endAt: event.endAt,
|
||||
timezone: event.timezone,
|
||||
reminderMinutes: event.metadata?.reminderMinutes,
|
||||
location: event.metadata?.location,
|
||||
notes: event.metadata?.notes,
|
||||
isArchived: event.status == ScheduleStatus.archived,
|
||||
);
|
||||
}
|
||||
|
||||
static Object? _encodeEventList(List<ScheduleItemModel> events) {
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import '../../../../data/network/i_api_client.dart';
|
||||
import '../../../../data/cache/cache_store.dart';
|
||||
import '../../../../core/notification/models/reminder_alarm.dart';
|
||||
import '../../../../core/notification/services/reminder_reconcile_service.dart';
|
||||
import '../models/schedule_item_model.dart';
|
||||
|
||||
class CalendarService {
|
||||
@@ -7,12 +9,15 @@ class CalendarService {
|
||||
|
||||
final IApiClient _apiClient;
|
||||
final CacheInvalidator _invalidator;
|
||||
final ReminderReconcileService? _reminderReconcileService;
|
||||
|
||||
CalendarService({
|
||||
required IApiClient apiClient,
|
||||
required CacheInvalidator invalidator,
|
||||
ReminderReconcileService? reminderReconcileService,
|
||||
}) : _apiClient = apiClient,
|
||||
_invalidator = invalidator;
|
||||
_invalidator = invalidator,
|
||||
_reminderReconcileService = reminderReconcileService;
|
||||
|
||||
Future<List<ScheduleItemModel>> getEventsForDay(DateTime date) async {
|
||||
final start = DateTime(date.year, date.month, date.day);
|
||||
@@ -35,10 +40,14 @@ class CalendarService {
|
||||
if (data == null) {
|
||||
throw StateError('Invalid getEventsForRange response: empty payload');
|
||||
}
|
||||
return data
|
||||
final events = data
|
||||
.map((item) => item as Map<String, dynamic>)
|
||||
.map(ScheduleItemModel.fromJson)
|
||||
.toList(growable: false);
|
||||
await _reminderReconcileService?.reconcileEvents(
|
||||
events.map(_toReminderSnapshot).toList(growable: false),
|
||||
);
|
||||
return events;
|
||||
}
|
||||
|
||||
Future<ScheduleItemModel> getEventById(String id) async {
|
||||
@@ -47,7 +56,9 @@ class CalendarService {
|
||||
if (data == null) {
|
||||
throw StateError('Invalid getEventById response: empty payload');
|
||||
}
|
||||
return ScheduleItemModel.fromJson(data);
|
||||
final event = ScheduleItemModel.fromJson(data);
|
||||
await _reminderReconcileService?.reconcileEvent(_toReminderSnapshot(event));
|
||||
return event;
|
||||
}
|
||||
|
||||
Future<ScheduleItemModel> addEvent(ScheduleItemModel event) async {
|
||||
@@ -61,6 +72,9 @@ class CalendarService {
|
||||
}
|
||||
final created = ScheduleItemModel.fromJson(data);
|
||||
_invalidateEventCache(created);
|
||||
await _reminderReconcileService?.reconcileEvent(
|
||||
_toReminderSnapshot(created),
|
||||
);
|
||||
return created;
|
||||
}
|
||||
|
||||
@@ -75,6 +89,9 @@ class CalendarService {
|
||||
}
|
||||
final updated = ScheduleItemModel.fromJson(data);
|
||||
_invalidateEventCache(updated);
|
||||
await _reminderReconcileService?.reconcileEvent(
|
||||
_toReminderSnapshot(updated),
|
||||
);
|
||||
return updated;
|
||||
}
|
||||
|
||||
@@ -84,6 +101,7 @@ class CalendarService {
|
||||
event.copyWith(status: ScheduleStatus.archived),
|
||||
);
|
||||
_invalidateEventCache(updatedEvent);
|
||||
await _reminderReconcileService?.archiveAndCancel(id);
|
||||
return updatedEvent;
|
||||
}
|
||||
|
||||
@@ -91,6 +109,7 @@ class CalendarService {
|
||||
final event = await getEventById(id);
|
||||
_invalidateEventCache(event);
|
||||
await _apiClient.delete<void>('$_prefix/$id');
|
||||
await _reminderReconcileService?.archiveAndCancel(id);
|
||||
}
|
||||
|
||||
void _invalidateEventCache(ScheduleItemModel event) {
|
||||
@@ -109,4 +128,18 @@ class CalendarService {
|
||||
current = current.add(const Duration(days: 1));
|
||||
}
|
||||
}
|
||||
|
||||
ReminderEventSnapshot _toReminderSnapshot(ScheduleItemModel event) {
|
||||
return ReminderEventSnapshot(
|
||||
eventId: event.id,
|
||||
title: event.title,
|
||||
startAt: event.startAt,
|
||||
endAt: event.endAt,
|
||||
timezone: event.timezone,
|
||||
reminderMinutes: event.metadata?.reminderMinutes,
|
||||
location: event.metadata?.location,
|
||||
notes: event.metadata?.notes,
|
||||
isArchived: event.status == ScheduleStatus.archived,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -453,7 +453,7 @@ class _CalendarDayWeekScreenState extends State<CalendarDayWeekScreen>
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
_weekdayLabel(date),
|
||||
_weekdayLabel(context, date),
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
color: isWeekend
|
||||
@@ -492,8 +492,16 @@ class _CalendarDayWeekScreenState extends State<CalendarDayWeekScreen>
|
||||
);
|
||||
}
|
||||
|
||||
String _weekdayLabel(DateTime date) {
|
||||
const labels = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
|
||||
String _weekdayLabel(BuildContext context, DateTime date) {
|
||||
final labels = [
|
||||
context.l10n.calendarMonthWeekdaySunShort,
|
||||
context.l10n.calendarMonthWeekdayMonShort,
|
||||
context.l10n.calendarMonthWeekdayTueShort,
|
||||
context.l10n.calendarMonthWeekdayWedShort,
|
||||
context.l10n.calendarMonthWeekdayThuShort,
|
||||
context.l10n.calendarMonthWeekdayFriShort,
|
||||
context.l10n.calendarMonthWeekdaySatShort,
|
||||
];
|
||||
return labels[date.weekday % 7];
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,237 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
|
||||
import '../../../../app/di/injection.dart';
|
||||
import '../../../../app/router/app_routes.dart';
|
||||
import '../../../../core/l10n/l10n.dart';
|
||||
import '../../../../core/notification/models/reminder_alarm.dart';
|
||||
import '../../../../core/notification/services/reminder_reconcile_service.dart';
|
||||
import '../../../../core/theme/design_tokens.dart';
|
||||
import '../../../../shared/widgets/app_button.dart';
|
||||
import '../../../../shared/widgets/app_loading_indicator.dart';
|
||||
import '../../../../shared/widgets/toast/toast.dart';
|
||||
import '../../../../shared/widgets/toast/toast_type.dart';
|
||||
import '../../data/models/schedule_item_model.dart';
|
||||
import '../../data/services/calendar_service.dart';
|
||||
|
||||
class CalendarReminderAlarmScreen extends StatefulWidget {
|
||||
const CalendarReminderAlarmScreen({super.key, required this.eventId});
|
||||
|
||||
final String eventId;
|
||||
|
||||
@override
|
||||
State<CalendarReminderAlarmScreen> createState() =>
|
||||
_CalendarReminderAlarmScreenState();
|
||||
}
|
||||
|
||||
class _CalendarReminderAlarmScreenState
|
||||
extends State<CalendarReminderAlarmScreen> {
|
||||
late final Future<ScheduleItemModel> _eventFuture;
|
||||
bool _isSubmitting = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_eventFuture = sl<CalendarService>().getEventById(widget.eventId);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
return Scaffold(
|
||||
backgroundColor: colorScheme.surface,
|
||||
appBar: AppBar(
|
||||
backgroundColor: colorScheme.surface,
|
||||
title: Text(context.l10n.notificationChannelName),
|
||||
),
|
||||
body: FutureBuilder<ScheduleItemModel>(
|
||||
future: _eventFuture,
|
||||
builder: (context, snapshot) {
|
||||
if (snapshot.connectionState != ConnectionState.done) {
|
||||
return const Center(child: AppLoadingIndicator());
|
||||
}
|
||||
if (snapshot.hasError || snapshot.data == null) {
|
||||
return Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(AppSpacing.lg),
|
||||
child: Text(
|
||||
context.l10n.errorRequestFailed,
|
||||
style: TextStyle(color: colorScheme.error),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
final event = snapshot.data!;
|
||||
return SafeArea(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(AppSpacing.lg),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
_EventCard(event: event),
|
||||
const Spacer(),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: AppButton(
|
||||
text: context.l10n.notificationSnoozeLater,
|
||||
isOutlined: true,
|
||||
isLoading: _isSubmitting,
|
||||
onPressed: _isSubmitting
|
||||
? null
|
||||
: () => _snoozeEvent(event),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: AppSpacing.md),
|
||||
Expanded(
|
||||
child: AppButton(
|
||||
text: context.l10n.calendarDetailArchiveConfirm,
|
||||
isLoading: _isSubmitting,
|
||||
onPressed: _isSubmitting
|
||||
? null
|
||||
: () => _archiveEvent(event),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _archiveEvent(ScheduleItemModel event) async {
|
||||
setState(() {
|
||||
_isSubmitting = true;
|
||||
});
|
||||
try {
|
||||
await sl<CalendarService>().archiveEvent(event.id);
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
context.go(AppRoutes.calendarEventDetail(event.id));
|
||||
} catch (_) {
|
||||
if (mounted) {
|
||||
Toast.show(
|
||||
context,
|
||||
context.l10n.calendarDetailArchiveFailed,
|
||||
type: ToastType.error,
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_isSubmitting = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _snoozeEvent(ScheduleItemModel event) async {
|
||||
setState(() {
|
||||
_isSubmitting = true;
|
||||
});
|
||||
try {
|
||||
await sl<ReminderReconcileService>().snooze10m(_snapshotFromEvent(event));
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
Toast.show(context, context.l10n.notificationSnoozeMinutes(10));
|
||||
context.go(AppRoutes.calendarEventDetail(event.id));
|
||||
} catch (_) {
|
||||
if (mounted) {
|
||||
Toast.show(
|
||||
context,
|
||||
context.l10n.todoSaveFailed('snooze failed'),
|
||||
type: ToastType.error,
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_isSubmitting = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ReminderEventSnapshot _snapshotFromEvent(ScheduleItemModel event) {
|
||||
return ReminderEventSnapshot(
|
||||
eventId: event.id,
|
||||
title: event.title,
|
||||
startAt: event.startAt,
|
||||
endAt: event.endAt,
|
||||
timezone: event.timezone,
|
||||
reminderMinutes: event.metadata?.reminderMinutes,
|
||||
location: event.metadata?.location,
|
||||
notes: event.metadata?.notes,
|
||||
isArchived: event.status == ScheduleStatus.archived,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _EventCard extends StatelessWidget {
|
||||
const _EventCard({required this.event});
|
||||
|
||||
final ScheduleItemModel event;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final textTheme = Theme.of(context).textTheme;
|
||||
final formatter = DateFormat('MM-dd HH:mm');
|
||||
final endAt = event.endAt;
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.surfaceContainerLow,
|
||||
borderRadius: BorderRadius.circular(AppRadius.lg),
|
||||
border: Border.all(color: colorScheme.outlineVariant),
|
||||
),
|
||||
padding: const EdgeInsets.all(AppSpacing.lg),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
event.title,
|
||||
style: textTheme.titleLarge?.copyWith(
|
||||
color: colorScheme.onSurface,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: AppSpacing.md),
|
||||
Text(
|
||||
endAt == null
|
||||
? formatter.format(event.startAt)
|
||||
: '${formatter.format(event.startAt)} - ${formatter.format(endAt)}',
|
||||
style: textTheme.bodyMedium?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
if ((event.metadata?.location?.isNotEmpty ?? false)) ...[
|
||||
const SizedBox(height: AppSpacing.sm),
|
||||
Text(
|
||||
context.l10n.notificationLocation(event.metadata!.location!),
|
||||
style: textTheme.bodyMedium?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
if ((event.metadata?.notes?.isNotEmpty ?? false)) ...[
|
||||
const SizedBox(height: AppSpacing.sm),
|
||||
Text(
|
||||
context.l10n.notificationNotes(event.metadata!.notes!),
|
||||
style: textTheme.bodyMedium?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import 'dart:async';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
@@ -110,11 +111,13 @@ class ChatBloc extends Cubit<ChatState> implements ChatOrchestrator {
|
||||
AgUiService? service,
|
||||
required ChatApi chatApi,
|
||||
ChatHistoryRepository? historyRepository,
|
||||
Future<void> Function()? onCalendarMutated,
|
||||
Duration recoveryPollInterval = const Duration(milliseconds: 700),
|
||||
Duration recoveryTimeout = const Duration(seconds: 20),
|
||||
}) : _service =
|
||||
service ??
|
||||
AgUiService(chatApi: chatApi, historyRepository: historyRepository),
|
||||
_onCalendarMutated = onCalendarMutated,
|
||||
_recoveryPollInterval = recoveryPollInterval,
|
||||
_recoveryTimeout = recoveryTimeout,
|
||||
super(const ChatState()) {
|
||||
@@ -122,6 +125,7 @@ class ChatBloc extends Cubit<ChatState> implements ChatOrchestrator {
|
||||
}
|
||||
|
||||
final AgUiService _service;
|
||||
final Future<void> Function()? _onCalendarMutated;
|
||||
final Duration _recoveryPollInterval;
|
||||
final Duration _recoveryTimeout;
|
||||
String? _activeUserId;
|
||||
@@ -214,5 +218,33 @@ class ChatBloc extends Cubit<ChatState> implements ChatOrchestrator {
|
||||
_attachmentPreviewCache.clear();
|
||||
_attachmentPreviewInflight.clear();
|
||||
emit(const ChatState());
|
||||
if (normalizedUserId != null && epoch == _sessionEpoch) {
|
||||
try {
|
||||
await _loadHistory();
|
||||
} catch (error) {
|
||||
emit(state.copyWith(error: error.toString()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
bool _shouldRefreshCalendarForTool(ToolCallResultEvent event) {
|
||||
final name = event.toolName.trim().toLowerCase();
|
||||
final status = event.status.trim().toLowerCase();
|
||||
if (name != 'calendar_write') {
|
||||
return false;
|
||||
}
|
||||
return status == 'success' || status == 'partial';
|
||||
}
|
||||
|
||||
Future<void> _refreshCalendarAfterToolMutation() async {
|
||||
final callback = _onCalendarMutated;
|
||||
if (callback == null) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await callback();
|
||||
} catch (error) {
|
||||
emit(state.copyWith(error: error.toString()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -198,6 +198,9 @@ extension _ChatBlocEvents on ChatBloc {
|
||||
}
|
||||
|
||||
void _handleToolCallResult(ToolCallResultEvent event) {
|
||||
if (_shouldRefreshCalendarForTool(event)) {
|
||||
unawaited(_refreshCalendarAfterToolMutation());
|
||||
}
|
||||
emit(
|
||||
state.copyWith(
|
||||
items: state.items.map((item) {
|
||||
|
||||
@@ -83,14 +83,24 @@ class UserBasicInfo {
|
||||
final String id;
|
||||
final String username;
|
||||
final String? avatarUrl;
|
||||
final String? phone;
|
||||
final String? bio;
|
||||
|
||||
UserBasicInfo({required this.id, required this.username, this.avatarUrl});
|
||||
UserBasicInfo({
|
||||
required this.id,
|
||||
required this.username,
|
||||
this.avatarUrl,
|
||||
this.phone,
|
||||
this.bio,
|
||||
});
|
||||
|
||||
factory UserBasicInfo.fromJson(Map<String, dynamic> json) {
|
||||
return UserBasicInfo(
|
||||
id: json['id'] as String,
|
||||
username: json['username'] as String,
|
||||
avatarUrl: json['avatar_url'] as String?,
|
||||
phone: json['phone'] as String?,
|
||||
bio: json['bio'] as String?,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,22 +2,9 @@ import 'dart:io';
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:social_app/data/network/i_api_client.dart';
|
||||
import '../models/user_profile.dart';
|
||||
import 'friends_api.dart';
|
||||
|
||||
class UserBasicInfo {
|
||||
final String id;
|
||||
final String username;
|
||||
final String? avatarUrl;
|
||||
|
||||
UserBasicInfo({required this.id, required this.username, this.avatarUrl});
|
||||
|
||||
factory UserBasicInfo.fromJson(Map<String, dynamic> json) {
|
||||
return UserBasicInfo(
|
||||
id: json['id'] as String,
|
||||
username: json['username'] as String,
|
||||
avatarUrl: json['avatar_url'] as String?,
|
||||
);
|
||||
}
|
||||
}
|
||||
export 'friends_api.dart' show UserBasicInfo;
|
||||
|
||||
class UsersApi {
|
||||
final IApiClient _client;
|
||||
|
||||
@@ -1,207 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import '../../../../core/l10n/l10n.dart';
|
||||
import '../../../../core/theme/design_tokens.dart';
|
||||
import '../../../../shared/widgets/back_title_page_header.dart';
|
||||
import '../../../../shared/widgets/app_input.dart';
|
||||
import '../../../../shared/widgets/link_button.dart';
|
||||
import '../../../../shared/widgets/toast/toast.dart';
|
||||
import '../../../../shared/widgets/toast/toast_type.dart';
|
||||
|
||||
class AddContactScreen extends StatefulWidget {
|
||||
final String? contactId;
|
||||
|
||||
const AddContactScreen({super.key, this.contactId});
|
||||
|
||||
@override
|
||||
State<AddContactScreen> createState() => _AddContactScreenState();
|
||||
}
|
||||
|
||||
class _AddContactScreenState extends State<AddContactScreen> {
|
||||
final _nameController = TextEditingController();
|
||||
final _phoneController = TextEditingController();
|
||||
final _remarkController = TextEditingController();
|
||||
|
||||
bool get isEditing => widget.contactId != null;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_nameController.dispose();
|
||||
_phoneController.dispose();
|
||||
_remarkController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
return Scaffold(
|
||||
backgroundColor: colorScheme.surfaceContainerLow,
|
||||
resizeToAvoidBottomInset: false,
|
||||
body: SafeArea(
|
||||
maintainBottomViewPadding: true,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
BackTitlePageHeader(
|
||||
title: isEditing
|
||||
? context.l10n.contactEditTitle
|
||||
: context.l10n.contactAddTitle,
|
||||
onBack: () => context.pop(),
|
||||
trailing: _buildConfirmButton(),
|
||||
),
|
||||
Expanded(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.fromLTRB(20, 8, 20, 20),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
_buildAvatarSection(),
|
||||
const SizedBox(height: 14),
|
||||
_buildFormCard(),
|
||||
if (isEditing) ...[
|
||||
const SizedBox(height: 14),
|
||||
_buildDeleteRow(),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildConfirmButton() {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
return SizedBox(
|
||||
width: AppSpacing.xxl * 2,
|
||||
height: AppSpacing.xxl * 2,
|
||||
child: TextButton(
|
||||
onPressed: _handleConfirm,
|
||||
style: TextButton.styleFrom(
|
||||
padding: const EdgeInsets.all(AppSpacing.none),
|
||||
backgroundColor: colorScheme.primaryContainer,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(AppRadius.full),
|
||||
side: BorderSide(color: colorScheme.outlineVariant),
|
||||
),
|
||||
),
|
||||
child: Icon(
|
||||
Icons.check,
|
||||
size: AppSpacing.lg,
|
||||
color: colorScheme.primary,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildAvatarSection() {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
return Center(
|
||||
child: Container(
|
||||
width: 72,
|
||||
height: 72,
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.primaryContainer.withValues(alpha: 0.45),
|
||||
borderRadius: BorderRadius.circular(36),
|
||||
border: Border.all(color: colorScheme.surface.withValues(alpha: 0)),
|
||||
),
|
||||
child: Icon(Icons.person_outline, size: 24, color: colorScheme.outline),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildFormCard() {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(14),
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.surface,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(color: colorScheme.outlineVariant),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
AppInput(
|
||||
label: context.l10n.contactNickname,
|
||||
hint: context.l10n.contactNicknameHint,
|
||||
controller: _nameController,
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
AppInput(
|
||||
label: context.l10n.contactPhone,
|
||||
hint: context.l10n.contactPhoneHint,
|
||||
controller: _phoneController,
|
||||
keyboardType: TextInputType.phone,
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
AppInput(
|
||||
label: context.l10n.contactRemark,
|
||||
hint: context.l10n.contactRemarkHint,
|
||||
controller: _remarkController,
|
||||
maxLines: 3,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildDeleteRow() {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: AppSpacing.sm),
|
||||
child: LinkButton(
|
||||
text: context.l10n.contactDelete,
|
||||
onTap: _handleDelete,
|
||||
foregroundColor: colorScheme.error,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _handleConfirm() {
|
||||
final name = _nameController.text.trim();
|
||||
final phone = _phoneController.text.trim();
|
||||
|
||||
if (name.isEmpty || phone.isEmpty) {
|
||||
Toast.show(
|
||||
context,
|
||||
context.l10n.contactFillRequired,
|
||||
type: ToastType.warning,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// TODO: Implement save logic
|
||||
context.pop();
|
||||
}
|
||||
|
||||
void _handleDelete() {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: Text(context.l10n.contactDeleteConfirmTitle),
|
||||
content: Text(context.l10n.contactDeleteConfirmMessage),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: Text(context.l10n.commonCancel),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
Navigator.pop(context);
|
||||
// TODO: Implement delete logic
|
||||
context.pop();
|
||||
},
|
||||
child: Text(
|
||||
context.l10n.commonDelete,
|
||||
style: TextStyle(color: colorScheme.error),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:social_app/core/l10n/l10n.dart';
|
||||
import 'package:social_app/core/theme/design_tokens.dart';
|
||||
import 'package:social_app/shared/widgets/back_title_page_header.dart';
|
||||
import 'package:social_app/features/contacts/data/apis/users_api.dart';
|
||||
|
||||
class ContactDetailScreen extends StatelessWidget {
|
||||
final UserBasicInfo user;
|
||||
|
||||
const ContactDetailScreen({super.key, required this.user});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
return Scaffold(
|
||||
backgroundColor: colorScheme.surfaceContainerLow,
|
||||
resizeToAvoidBottomInset: false,
|
||||
body: SafeArea(
|
||||
maintainBottomViewPadding: true,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
BackTitlePageHeader(
|
||||
title: context.l10n.contactDetailTitle,
|
||||
onBack: () => context.pop(),
|
||||
),
|
||||
Expanded(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.fromLTRB(20, 8, 20, 20),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
_buildAvatarSection(context, colorScheme),
|
||||
const SizedBox(height: 14),
|
||||
_buildInfoCard(context, colorScheme),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildAvatarSection(BuildContext context, ColorScheme colorScheme) {
|
||||
final palette = Theme.of(context).extension<AppColorPalette>()!;
|
||||
final avatarColor = palette
|
||||
.avatarColors[user.id.hashCode.abs() % palette.avatarColors.length];
|
||||
|
||||
return Center(
|
||||
child: Container(
|
||||
width: 80,
|
||||
height: 80,
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.primaryContainer.withValues(alpha: 0.45),
|
||||
borderRadius: BorderRadius.circular(40),
|
||||
border: Border.all(color: colorScheme.surface.withValues(alpha: 0)),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: colorScheme.primary.withValues(alpha: 0.2),
|
||||
blurRadius: 12,
|
||||
offset: const Offset(0, 4),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: user.avatarUrl != null
|
||||
? ClipRRect(
|
||||
borderRadius: BorderRadius.circular(40),
|
||||
child: Image.network(
|
||||
user.avatarUrl!,
|
||||
width: 80,
|
||||
height: 80,
|
||||
fit: BoxFit.cover,
|
||||
errorBuilder: (context, error, stackTrace) =>
|
||||
Icon(Icons.person, size: 32, color: avatarColor),
|
||||
),
|
||||
)
|
||||
: Icon(Icons.person, size: 32, color: avatarColor),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildInfoCard(BuildContext context, ColorScheme colorScheme) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(14),
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.surface,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(color: colorScheme.outlineVariant),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_buildInfoRow(
|
||||
context.l10n.contactDetailUsername,
|
||||
user.username,
|
||||
Icons.person_outline,
|
||||
colorScheme,
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
_buildInfoRow(
|
||||
context.l10n.contactDetailPhone,
|
||||
user.phone ?? context.l10n.commonNone,
|
||||
Icons.phone_outlined,
|
||||
colorScheme,
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
_buildInfoRow(
|
||||
context.l10n.contactDetailBio,
|
||||
user.bio ?? context.l10n.commonNone,
|
||||
Icons.info_outline,
|
||||
colorScheme,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildInfoRow(
|
||||
String label,
|
||||
String value,
|
||||
IconData icon,
|
||||
ColorScheme colorScheme,
|
||||
) {
|
||||
return Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Icon(icon, size: 18, color: colorScheme.onSurfaceVariant),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
value,
|
||||
style: TextStyle(
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: colorScheme.onSurface,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import '../../../../shared/widgets/app_loading_indicator.dart';
|
||||
import '../../../../shared/widgets/toast/index.dart';
|
||||
import '../../../../shared/widgets/app_button.dart';
|
||||
import '../../../../shared/widgets/back_title_page_header.dart';
|
||||
import '../../../../shared/widgets/shared_divider.dart';
|
||||
import '../../../contacts/data/apis/friends_api.dart';
|
||||
import '../../../contacts/data/apis/users_api.dart';
|
||||
|
||||
@@ -448,7 +449,7 @@ class _ContactsScreenState extends State<ContactsScreen> {
|
||||
children: [
|
||||
for (int i = 0; i < _searchResults.length; i++) ...[
|
||||
_buildSearchResultItem(_searchResults[i]),
|
||||
if (i < _searchResults.length - 1) _buildDivider(),
|
||||
if (i < _searchResults.length - 1) SharedDivider(),
|
||||
],
|
||||
],
|
||||
),
|
||||
@@ -620,7 +621,7 @@ class _ContactsScreenState extends State<ContactsScreen> {
|
||||
children: [
|
||||
for (int i = 0; i < requests.length; i++) ...[
|
||||
_buildPendingRequestItem(requests[i]),
|
||||
if (i < requests.length - 1) _buildDivider(),
|
||||
if (i < requests.length - 1) SharedDivider(),
|
||||
],
|
||||
],
|
||||
),
|
||||
@@ -679,7 +680,7 @@ class _ContactsScreenState extends State<ContactsScreen> {
|
||||
children: [
|
||||
for (int i = 0; i < friends.length; i++) ...[
|
||||
_buildContactItem(friends[i]),
|
||||
if (i < friends.length - 1) _buildDivider(),
|
||||
if (i < friends.length - 1) SharedDivider(),
|
||||
],
|
||||
],
|
||||
),
|
||||
@@ -691,7 +692,8 @@ class _ContactsScreenState extends State<ContactsScreen> {
|
||||
final friendInfo = friend.friend;
|
||||
|
||||
return GestureDetector(
|
||||
onTap: () => context.push('/contacts/add?id=${friendInfo.id}'),
|
||||
onTap: () =>
|
||||
context.push('/contacts/${friendInfo.id}', extra: friendInfo),
|
||||
child: Container(
|
||||
height: 70,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 14),
|
||||
@@ -753,15 +755,6 @@ class _ContactsScreenState extends State<ContactsScreen> {
|
||||
: colorScheme.primary.withValues(alpha: opacity);
|
||||
}
|
||||
|
||||
Widget _buildDivider() {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
return Container(
|
||||
height: 1,
|
||||
margin: const EdgeInsets.symmetric(horizontal: 14),
|
||||
color: colorScheme.outlineVariant,
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildAvatar(
|
||||
String? avatarUrl,
|
||||
String userId,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
@@ -12,7 +13,7 @@ import '../../../../app/router/app_route_observer.dart';
|
||||
import '../../../../app/router/app_routes.dart';
|
||||
import '../../../../core/l10n/l10n.dart';
|
||||
import '../../../../core/theme/design_tokens.dart';
|
||||
import '../../../../features/messages/data/repositories/inbox_repository.dart';
|
||||
import '../../../../core/inbox/inbox_sync_store.dart';
|
||||
import '../../../chat/presentation/bloc/chat_bloc.dart';
|
||||
import '../../data/voice_recorder.dart';
|
||||
import '../controllers/home_keyboard_inset_calculator.dart';
|
||||
@@ -73,7 +74,7 @@ class _HomeScreenState extends State<HomeScreen>
|
||||
final ScrollController _scrollController = ScrollController();
|
||||
late final ChatBloc _chatBloc;
|
||||
late final VoiceRecorder _voiceRecorder;
|
||||
late final InboxRepository _inboxRepository;
|
||||
late final InboxSyncStore _inboxSyncStore;
|
||||
late final Future<String> Function(String filePath) _transcribeAudio;
|
||||
late final AnimationController _listeningAnimationController;
|
||||
bool _isRecording = false;
|
||||
@@ -110,7 +111,7 @@ class _HomeScreenState extends State<HomeScreen>
|
||||
_chatBloc = context.read<ChatBloc>();
|
||||
}
|
||||
_voiceRecorder = widget.voiceRecorder ?? RecordVoiceRecorder();
|
||||
_inboxRepository = sl<InboxRepository>();
|
||||
_inboxSyncStore = sl<InboxSyncStore>();
|
||||
_transcribeAudio =
|
||||
widget.onTranscribeAudio ?? _chatBloc.transcribeAudioFile;
|
||||
_listeningAnimationController = AnimationController(
|
||||
@@ -118,24 +119,26 @@ class _HomeScreenState extends State<HomeScreen>
|
||||
duration: const Duration(milliseconds: _rippleDurationMs),
|
||||
);
|
||||
_selectedImages.addAll(widget.initialSelectedImages);
|
||||
if (widget.autoLoadHistory && _chatBloc.state.items.isEmpty) {
|
||||
if (widget.autoLoadHistory &&
|
||||
_chatBloc.state.items.isEmpty &&
|
||||
!_chatBloc.state.isLoadingHistory) {
|
||||
_chatBloc.loadHistory();
|
||||
}
|
||||
_scrollController.addListener(_handleScrollChanged);
|
||||
_previousItemCount = _chatBloc.state.items.length;
|
||||
_previousIsLoadingHistory = _chatBloc.state.isLoadingHistory;
|
||||
_loadUnreadCount();
|
||||
_inboxSyncStore.addListener(_handleInboxStateChanged);
|
||||
unawaited(_inboxSyncStore.ensureStarted());
|
||||
_handleInboxStateChanged();
|
||||
}
|
||||
|
||||
Future<void> _loadUnreadCount() async {
|
||||
try {
|
||||
final messages = await _inboxRepository.getMessages(isRead: false);
|
||||
if (mounted) {
|
||||
setState(() => _unreadCount = messages.length);
|
||||
}
|
||||
} catch (_) {
|
||||
// Ignore errors
|
||||
void _handleInboxStateChanged() {
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
setState(() {
|
||||
_unreadCount = _inboxSyncStore.unreadCount;
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -145,6 +148,7 @@ class _HomeScreenState extends State<HomeScreen>
|
||||
_scrollController.dispose();
|
||||
_listeningAnimationController.dispose();
|
||||
_voiceRecorder.dispose();
|
||||
_inboxSyncStore.removeListener(_handleInboxStateChanged);
|
||||
if (_routeAwareSubscribed) {
|
||||
appRouteObserver.unsubscribe(this);
|
||||
_routeAwareSubscribed = false;
|
||||
@@ -164,6 +168,7 @@ class _HomeScreenState extends State<HomeScreen>
|
||||
|
||||
@override
|
||||
void didPopNext() {
|
||||
unawaited(_inboxSyncStore.refreshSnapshot());
|
||||
_applyViewportDecision(
|
||||
_dispatchViewportEvent(
|
||||
type: ViewportEventType.screenResumedFromSubRoute,
|
||||
|
||||
@@ -49,8 +49,7 @@ class HomeDateDivider extends StatelessWidget {
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final now = DateTime.now();
|
||||
const weekdays = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'];
|
||||
final weekday = weekdays[date.weekday - 1];
|
||||
final weekday = _weekdayLabel(context, date.weekday);
|
||||
final label = date.year == now.year
|
||||
? context.l10n.homeDateLabelNoYear(date.month, date.day, weekday)
|
||||
: context.l10n.homeDateLabelWithYear(
|
||||
@@ -69,6 +68,19 @@ class HomeDateDivider extends StatelessWidget {
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
String _weekdayLabel(BuildContext context, int weekday) {
|
||||
final labels = [
|
||||
context.l10n.calendarWeekdaySun,
|
||||
context.l10n.calendarWeekdayMon,
|
||||
context.l10n.calendarWeekdayTue,
|
||||
context.l10n.calendarWeekdayWed,
|
||||
context.l10n.calendarWeekdayThu,
|
||||
context.l10n.calendarWeekdayFri,
|
||||
context.l10n.calendarWeekdaySat,
|
||||
];
|
||||
return labels[weekday % 7];
|
||||
}
|
||||
}
|
||||
|
||||
class HomeLoadMoreButton extends StatelessWidget {
|
||||
|
||||
@@ -17,6 +17,14 @@ class InboxApi {
|
||||
final response = await _client.patch('$_prefix/$messageId/read');
|
||||
return InboxMessageResponse.fromJson(response.data);
|
||||
}
|
||||
|
||||
Future<Stream<String>> streamEvents({String? lastEventId}) {
|
||||
final headers = <String, String>{'Accept': 'text/event-stream'};
|
||||
if (lastEventId != null && lastEventId.isNotEmpty) {
|
||||
headers['Last-Event-ID'] = lastEventId;
|
||||
}
|
||||
return _client.getSseLines('$_prefix/stream', headers: headers);
|
||||
}
|
||||
}
|
||||
|
||||
class InboxMessageResponse {
|
||||
|
||||
@@ -4,7 +4,10 @@ import '../../../../data/cache/cached_repository.dart';
|
||||
import '../models/inbox_message.dart';
|
||||
|
||||
abstract class InboxRepository {
|
||||
Future<List<InboxMessage>> getMessages({bool? isRead});
|
||||
Future<List<InboxMessage>> getMessages({
|
||||
bool? isRead,
|
||||
bool forceRefresh = false,
|
||||
});
|
||||
Future<InboxMessage> markAsRead(String messageId);
|
||||
}
|
||||
|
||||
@@ -26,9 +29,13 @@ class InboxRepositoryImpl extends CachedRepository<List<InboxMessage>>
|
||||
);
|
||||
|
||||
@override
|
||||
Future<List<InboxMessage>> getMessages({bool? isRead}) async {
|
||||
Future<List<InboxMessage>> getMessages({
|
||||
bool? isRead,
|
||||
bool forceRefresh = false,
|
||||
}) async {
|
||||
return getOrLoad(
|
||||
key: _messagesKey(isRead),
|
||||
forceRefresh: forceRefresh,
|
||||
loadFromRemote: () => _loadMessagesFromRemote(isRead: isRead),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,443 +0,0 @@
|
||||
import 'package:flutter/material.dart' hide BackButton;
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
|
||||
import '../../../../app/di/injection.dart';
|
||||
import '../../../../features/calendar/data/repositories/calendar_repository.dart';
|
||||
import '../../../../features/messages/data/models/inbox_message.dart';
|
||||
import '../../../../features/messages/data/repositories/inbox_repository.dart';
|
||||
import '../../../../features/contacts/data/repositories/user_repository.dart';
|
||||
import '../../../../core/l10n/l10n.dart';
|
||||
import '../../../../shared/widgets/app_loading_indicator.dart';
|
||||
import '../../../../shared/widgets/page_header.dart';
|
||||
import '../../../../shared/widgets/toast/toast.dart';
|
||||
import '../../../../shared/widgets/toast/toast_type.dart';
|
||||
|
||||
class MessageInviteDetailScreen extends StatefulWidget {
|
||||
final String inviteId;
|
||||
|
||||
const MessageInviteDetailScreen({super.key, required this.inviteId});
|
||||
|
||||
@override
|
||||
State<MessageInviteDetailScreen> createState() =>
|
||||
_MessageInviteDetailScreenState();
|
||||
}
|
||||
|
||||
class _MessageInviteDetailScreenState extends State<MessageInviteDetailScreen> {
|
||||
late final InboxRepository _inboxRepository;
|
||||
late final CalendarRepository _calendarRepository;
|
||||
late final UserRepository _userRepository;
|
||||
|
||||
InboxMessage? _message;
|
||||
String? _calendarTitle;
|
||||
String? _senderName;
|
||||
bool _loading = true;
|
||||
bool _submitting = false;
|
||||
String? _error;
|
||||
|
||||
ColorScheme get _colorScheme => Theme.of(context).colorScheme;
|
||||
|
||||
bool get _isPending => _message?.status == InboxMessageStatus.pending;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_inboxRepository = sl<InboxRepository>();
|
||||
_calendarRepository = sl<CalendarRepository>();
|
||||
_userRepository = sl<UserRepository>();
|
||||
_loadDetail();
|
||||
}
|
||||
|
||||
Future<void> _loadDetail() async {
|
||||
setState(() {
|
||||
_loading = true;
|
||||
_error = null;
|
||||
});
|
||||
|
||||
try {
|
||||
final results = await Future.wait([
|
||||
_inboxRepository.getMessages(isRead: false),
|
||||
_inboxRepository.getMessages(isRead: true),
|
||||
]);
|
||||
final messages = [...results[0], ...results[1]];
|
||||
InboxMessage? message;
|
||||
for (final item in messages) {
|
||||
if (item.id == widget.inviteId) {
|
||||
message = item;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (message == null) {
|
||||
throw StateError(L10n.current.messagesInviteDetailNotFound);
|
||||
}
|
||||
|
||||
String? calendarTitle;
|
||||
if (message.scheduleItemId != null) {
|
||||
try {
|
||||
final event = await _calendarRepository.getById(
|
||||
message.scheduleItemId!,
|
||||
);
|
||||
calendarTitle = event.title;
|
||||
} catch (_) {
|
||||
calendarTitle = null;
|
||||
}
|
||||
}
|
||||
|
||||
String? senderName;
|
||||
if (message.senderId != null) {
|
||||
try {
|
||||
final sender = await _userRepository.getById(message.senderId!);
|
||||
senderName = sender.username;
|
||||
} catch (_) {
|
||||
senderName = null;
|
||||
}
|
||||
}
|
||||
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
setState(() {
|
||||
_message = message;
|
||||
_calendarTitle = calendarTitle;
|
||||
_senderName = senderName;
|
||||
_loading = false;
|
||||
});
|
||||
} catch (e) {
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
setState(() {
|
||||
_error = e.toString().replaceFirst('Bad state: ', '');
|
||||
_loading = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _acceptInvite() async {
|
||||
final message = _message;
|
||||
final itemId = message?.scheduleItemId;
|
||||
if (message == null || itemId == null || _submitting) {
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() => _submitting = true);
|
||||
try {
|
||||
await _calendarRepository.acceptSubscription(itemId);
|
||||
await _inboxRepository.markAsRead(message.id);
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
Toast.show(
|
||||
context,
|
||||
context.l10n.messagesInviteAcceptedToast,
|
||||
type: ToastType.success,
|
||||
);
|
||||
await _loadDetail();
|
||||
} catch (_) {
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
Toast.show(
|
||||
context,
|
||||
context.l10n.messagesInviteOperationFailed,
|
||||
type: ToastType.error,
|
||||
);
|
||||
} finally {
|
||||
if (mounted) {
|
||||
setState(() => _submitting = false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _rejectInvite() async {
|
||||
final message = _message;
|
||||
final itemId = message?.scheduleItemId;
|
||||
if (message == null || itemId == null || _submitting) {
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() => _submitting = true);
|
||||
try {
|
||||
await _calendarRepository.rejectSubscription(itemId);
|
||||
await _inboxRepository.markAsRead(message.id);
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
Toast.show(
|
||||
context,
|
||||
context.l10n.messagesInviteRejectedToast,
|
||||
type: ToastType.success,
|
||||
);
|
||||
await _loadDetail();
|
||||
} catch (_) {
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
Toast.show(
|
||||
context,
|
||||
context.l10n.messagesInviteOperationFailed,
|
||||
type: ToastType.error,
|
||||
);
|
||||
} finally {
|
||||
if (mounted) {
|
||||
setState(() => _submitting = false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (_loading) {
|
||||
return const Scaffold(
|
||||
body: SafeArea(child: Center(child: AppLoadingIndicator(size: 22))),
|
||||
);
|
||||
}
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: _colorScheme.surface,
|
||||
body: SafeArea(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
PageHeader(leading: BackButton(onPressed: () => context.pop())),
|
||||
Expanded(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.only(left: 20, right: 20, bottom: 20),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_buildSummaryCard(),
|
||||
const SizedBox(height: 14),
|
||||
_buildCalendarTip(),
|
||||
const SizedBox(height: 14),
|
||||
_buildActionRow(),
|
||||
if (_error != null) ...[
|
||||
const SizedBox(height: 14),
|
||||
Text(
|
||||
_error!,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: _colorScheme.error,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSummaryCard() {
|
||||
final message = _message;
|
||||
final statusText = message == null
|
||||
? context.l10n.commonUnknown
|
||||
: switch (message.status) {
|
||||
InboxMessageStatus.pending => context.l10n.messagesStatusPending,
|
||||
InboxMessageStatus.accepted =>
|
||||
context.l10n.messagesInviteStatusAccepted,
|
||||
InboxMessageStatus.rejected =>
|
||||
context.l10n.messagesInviteStatusRejected,
|
||||
InboxMessageStatus.dismissed =>
|
||||
context.l10n.messagesInviteStatusHandled,
|
||||
};
|
||||
|
||||
final createdAt = message?.createdAt;
|
||||
final createdAtText = createdAt == null
|
||||
? context.l10n.commonUnknown
|
||||
: DateFormat.yMd(context.l10n.localeName).add_Hm().format(createdAt);
|
||||
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(14),
|
||||
decoration: BoxDecoration(
|
||||
color: _colorScheme.surface,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(color: _colorScheme.outlineVariant),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
context.l10n.messagesInviteDetailTitle,
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: _colorScheme.onSurface,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
Text(
|
||||
context.l10n.messagesInviteEvent(
|
||||
_calendarTitle ?? context.l10n.messagesInviteUnnamedEvent,
|
||||
),
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: _colorScheme.onSurface,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
Text(
|
||||
context.l10n.messagesInviteSender(
|
||||
_senderName ?? context.l10n.messagesInviteUnknownUser,
|
||||
),
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.normal,
|
||||
color: _colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
Text(
|
||||
context.l10n.messagesInviteTime(createdAtText),
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.normal,
|
||||
color: _colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
Text(
|
||||
context.l10n.messagesInviteStatus(statusText),
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.normal,
|
||||
color: _colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
Text(
|
||||
context.l10n.messagesInviteId(widget.inviteId),
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.normal,
|
||||
color: _colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildCalendarTip() {
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
|
||||
decoration: BoxDecoration(
|
||||
color: _colorScheme.surfaceContainerLow,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(color: _colorScheme.outlineVariant),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.info_outline,
|
||||
size: 14,
|
||||
color: _colorScheme.onSurfaceVariant,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
context.l10n.messagesInviteTip,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.normal,
|
||||
color: _colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildActionRow() {
|
||||
if (!_isPending) {
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.symmetric(vertical: 10, horizontal: 12),
|
||||
decoration: BoxDecoration(
|
||||
color: _colorScheme.surface,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(color: _colorScheme.outlineVariant),
|
||||
),
|
||||
child: Text(
|
||||
context.l10n.messagesInviteAlreadyHandled,
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: _colorScheme.onSurfaceVariant,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return SizedBox(
|
||||
height: 46,
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: GestureDetector(
|
||||
onTap: _submitting ? null : _rejectInvite,
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: _colorScheme.surface,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(color: _colorScheme.error),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(Icons.close, size: 15, color: _colorScheme.error),
|
||||
const SizedBox(width: 6),
|
||||
Text(
|
||||
context.l10n.messagesReject,
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: _colorScheme.error,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: GestureDetector(
|
||||
onTap: _submitting ? null : _acceptInvite,
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: _colorScheme.primaryContainer,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(color: _colorScheme.primary),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(Icons.check, size: 15, color: _colorScheme.primary),
|
||||
const SizedBox(width: 6),
|
||||
Text(
|
||||
context.l10n.messagesAccept,
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: _colorScheme.primary,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,11 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart' hide BackButton;
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import '../../../../app/di/injection.dart';
|
||||
import '../../../../app/router/app_routes.dart';
|
||||
import '../../../../core/inbox/inbox_sync_store.dart';
|
||||
import '../../../../features/calendar/data/repositories/calendar_repository.dart';
|
||||
import '../../../../features/contacts/data/repositories/friend_repository.dart';
|
||||
import '../../../../features/messages/data/repositories/inbox_repository.dart';
|
||||
import '../../../../features/contacts/data/models/friend_request.dart';
|
||||
@@ -33,11 +36,15 @@ class MessageInviteListScreen extends StatefulWidget {
|
||||
class _MessageInviteListScreenState extends State<MessageInviteListScreen> {
|
||||
late final InboxRepository _inboxRepository;
|
||||
late final FriendRepository _friendRepository;
|
||||
late final CalendarRepository _calendarRepository;
|
||||
late final InboxSyncStore _inboxSyncStore;
|
||||
|
||||
List<MessageWithFriend> _unreadMessages = [];
|
||||
List<MessageWithFriend> _readMessages = [];
|
||||
bool _isLoading = false;
|
||||
bool _isInitialLoading = true;
|
||||
bool _isPullRefreshing = false;
|
||||
bool _isHydrating = false;
|
||||
bool _pendingStoreSync = false;
|
||||
int _activeTabIndex = 0;
|
||||
|
||||
ColorScheme get _colorScheme => Theme.of(context).colorScheme;
|
||||
@@ -47,26 +54,65 @@ class _MessageInviteListScreenState extends State<MessageInviteListScreen> {
|
||||
super.initState();
|
||||
_inboxRepository = sl<InboxRepository>();
|
||||
_friendRepository = sl<FriendRepository>();
|
||||
_loadMessages();
|
||||
_calendarRepository = sl<CalendarRepository>();
|
||||
_inboxSyncStore = sl<InboxSyncStore>();
|
||||
_inboxSyncStore.addListener(_handleInboxStoreChanged);
|
||||
unawaited(_bootstrapInbox());
|
||||
}
|
||||
|
||||
Future<void> _loadMessages({bool showPageLoader = true}) async {
|
||||
if (_isLoading || _isPullRefreshing) {
|
||||
@override
|
||||
void dispose() {
|
||||
_inboxSyncStore.removeListener(_handleInboxStoreChanged);
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _handleInboxStoreChanged() {
|
||||
if (_isInitialLoading || _isPullRefreshing || _isHydrating) {
|
||||
_pendingStoreSync = true;
|
||||
return;
|
||||
}
|
||||
if (mounted) {
|
||||
unawaited(
|
||||
_syncMessagesFromStore(forceSnapshot: false, fromPullRefresh: false),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _bootstrapInbox() async {
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
setState(() {
|
||||
_isInitialLoading = true;
|
||||
});
|
||||
await _inboxSyncStore.ensureStarted();
|
||||
await _syncMessagesFromStore(forceSnapshot: false, fromPullRefresh: false);
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
setState(() {
|
||||
_isInitialLoading = false;
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _syncMessagesFromStore({
|
||||
required bool forceSnapshot,
|
||||
required bool fromPullRefresh,
|
||||
}) async {
|
||||
if (_isHydrating) {
|
||||
_pendingStoreSync = true;
|
||||
return;
|
||||
}
|
||||
_isHydrating = true;
|
||||
if (mounted && fromPullRefresh) {
|
||||
setState(() {
|
||||
_isLoading = showPageLoader;
|
||||
_isPullRefreshing = !showPageLoader;
|
||||
_isPullRefreshing = true;
|
||||
});
|
||||
}
|
||||
try {
|
||||
final results = await Future.wait([
|
||||
_inboxRepository.getMessages(isRead: false),
|
||||
_inboxRepository.getMessages(isRead: true),
|
||||
]);
|
||||
final unreadRaw = results[0];
|
||||
final readRaw = results[1];
|
||||
if (forceSnapshot) {
|
||||
await _inboxSyncStore.refreshSnapshot();
|
||||
}
|
||||
final unreadRaw = _inboxSyncStore.unreadMessages;
|
||||
final readRaw = _inboxSyncStore.readMessages;
|
||||
|
||||
final allMessages = [...unreadRaw, ...readRaw];
|
||||
final friendshipIds = allMessages
|
||||
@@ -90,15 +136,23 @@ class _MessageInviteListScreenState extends State<MessageInviteListScreen> {
|
||||
setState(() {
|
||||
_unreadMessages = unread;
|
||||
_readMessages = read;
|
||||
_isLoading = false;
|
||||
_isPullRefreshing = false;
|
||||
});
|
||||
_isHydrating = false;
|
||||
if (_pendingStoreSync) {
|
||||
_pendingStoreSync = false;
|
||||
await _syncMessagesFromStore(
|
||||
forceSnapshot: false,
|
||||
fromPullRefresh: false,
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_isLoading = false;
|
||||
_isPullRefreshing = false;
|
||||
});
|
||||
_isHydrating = false;
|
||||
_pendingStoreSync = false;
|
||||
Toast.show(
|
||||
context,
|
||||
context.l10n.messagesLoadFailed,
|
||||
@@ -108,7 +162,7 @@ class _MessageInviteListScreenState extends State<MessageInviteListScreen> {
|
||||
}
|
||||
|
||||
Future<void> _onRefreshMessages() async {
|
||||
await _loadMessages(showPageLoader: false);
|
||||
await _syncMessagesFromStore(forceSnapshot: true, fromPullRefresh: true);
|
||||
}
|
||||
|
||||
List<MessageWithFriend> _mapMessagesWithFriend(
|
||||
@@ -128,17 +182,19 @@ class _MessageInviteListScreenState extends State<MessageInviteListScreen> {
|
||||
switch (message.messageType) {
|
||||
case InboxMessageType.calendar:
|
||||
final content = _parseCalendarContent(message.content);
|
||||
if (content == null) return;
|
||||
if (content == null) {
|
||||
_showProtocolErrorToast();
|
||||
return;
|
||||
}
|
||||
|
||||
final type = content['type'] as String?;
|
||||
if (type == 'invite') {
|
||||
context.push(AppRoutes.messageInviteDetail(message.id));
|
||||
} else if (type == 'update') {
|
||||
if (message.scheduleItemId != null) {
|
||||
context.push(
|
||||
AppRoutes.calendarEventDetail(message.scheduleItemId!),
|
||||
);
|
||||
}
|
||||
final isHandled = message.status != InboxMessageStatus.pending;
|
||||
_showCalendarInviteSheet(item, isReadOnly: isHandled);
|
||||
} else if (type == 'updated' || type == 'deleted') {
|
||||
_showCalendarChangeSheet(item);
|
||||
} else {
|
||||
_showProtocolErrorToast();
|
||||
}
|
||||
return;
|
||||
case InboxMessageType.friendRequest:
|
||||
@@ -159,9 +215,127 @@ class _MessageInviteListScreenState extends State<MessageInviteListScreen> {
|
||||
}
|
||||
|
||||
Map<String, dynamic>? _parseCalendarContent(Map<String, dynamic>? content) {
|
||||
if (content == null) {
|
||||
return null;
|
||||
}
|
||||
final type = content['type'];
|
||||
final schemaVersion = content['schema_version'];
|
||||
final item = content['item'];
|
||||
final actor = content['actor'];
|
||||
final summary = content['summary'];
|
||||
if (type is! String || schemaVersion is! int || schemaVersion != 2) {
|
||||
return null;
|
||||
}
|
||||
if (item is! Map<String, dynamic> || actor is! Map<String, dynamic>) {
|
||||
return null;
|
||||
}
|
||||
final itemId = item['id'];
|
||||
final itemTitle = item['title'];
|
||||
final actorId = actor['user_id'];
|
||||
final actorName = actor['username'];
|
||||
if (itemId is! String || itemTitle is! String || itemTitle.trim().isEmpty) {
|
||||
return null;
|
||||
}
|
||||
if (actorId is! String ||
|
||||
actorName is! String ||
|
||||
actorName.trim().isEmpty) {
|
||||
return null;
|
||||
}
|
||||
if (summary is! String || summary.trim().isEmpty) {
|
||||
return null;
|
||||
}
|
||||
if (type == 'updated') {
|
||||
final changes = content['changes'];
|
||||
if (changes is! List) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return content;
|
||||
}
|
||||
|
||||
void _showProtocolErrorToast() {
|
||||
Toast.show(
|
||||
context,
|
||||
context.l10n.messagesProtocolInvalid,
|
||||
type: ToastType.error,
|
||||
);
|
||||
}
|
||||
|
||||
void _showCalendarChangeSheet(MessageWithFriend item) {
|
||||
final message = item.message;
|
||||
final content = _parseCalendarContent(message.content);
|
||||
if (content == null) {
|
||||
_showProtocolErrorToast();
|
||||
return;
|
||||
}
|
||||
final actor = content['actor'] as Map<String, dynamic>;
|
||||
final actorName =
|
||||
actor['username'] as String? ?? context.l10n.messagesUnknownActor;
|
||||
final summary = content['summary'] as String;
|
||||
final type = content['type'] as String;
|
||||
final changes =
|
||||
(content['changes'] as List?)
|
||||
?.whereType<Map<String, dynamic>>()
|
||||
.toList() ??
|
||||
const [];
|
||||
|
||||
final details = <String>[];
|
||||
if (changes.isNotEmpty) {
|
||||
for (final entry in changes) {
|
||||
final label =
|
||||
entry['label'] as String? ?? (entry['field'] as String? ?? '-');
|
||||
final before = entry['display_before'] as String? ?? '-';
|
||||
final after = entry['display_after'] as String? ?? '-';
|
||||
details.add('$label: $before -> $after');
|
||||
}
|
||||
}
|
||||
final description = details.isEmpty
|
||||
? summary
|
||||
: ([summary, ...details].join('\n'));
|
||||
|
||||
showModalBottomSheet<void>(
|
||||
context: context,
|
||||
backgroundColor: _colorScheme.surface.withValues(alpha: 0),
|
||||
isScrollControlled: true,
|
||||
builder: (ctx) => MessageActionSheet(
|
||||
title: type == 'deleted'
|
||||
? context.l10n.messagesCalendarDeletedBy(actorName)
|
||||
: context.l10n.messagesCalendarUpdatedBy(actorName),
|
||||
description: description,
|
||||
isReadOnly: true,
|
||||
icon: type == 'deleted'
|
||||
? Icons.delete_outline
|
||||
: Icons.edit_calendar_outlined,
|
||||
iconColor: type == 'deleted'
|
||||
? _colorScheme.error
|
||||
: _colorScheme.primary,
|
||||
primaryActionText: context.l10n.messagesAcknowledge,
|
||||
onPrimaryAction: () async {
|
||||
await _markMessageAsRead(message);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _markMessageAsRead(InboxMessage message) async {
|
||||
if (message.isRead) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await _inboxRepository.markAsRead(message.id);
|
||||
await _syncMessagesFromStore(forceSnapshot: true, fromPullRefresh: false);
|
||||
} catch (_) {
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
Toast.show(
|
||||
context,
|
||||
context.l10n.messagesActionFailed,
|
||||
type: ToastType.error,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
void _showFriendRequestSheet(
|
||||
MessageWithFriend item, {
|
||||
bool isReadOnly = false,
|
||||
@@ -207,6 +381,98 @@ class _MessageInviteListScreenState extends State<MessageInviteListScreen> {
|
||||
);
|
||||
}
|
||||
|
||||
void _showCalendarInviteSheet(
|
||||
MessageWithFriend item, {
|
||||
bool isReadOnly = false,
|
||||
}) {
|
||||
final message = item.message;
|
||||
final parsed = _parseCalendarContent(message.content);
|
||||
if (parsed == null) {
|
||||
_showProtocolErrorToast();
|
||||
return;
|
||||
}
|
||||
final itemMap = parsed['item'] as Map<String, dynamic>;
|
||||
final actorMap = parsed['actor'] as Map<String, dynamic>;
|
||||
final title = (itemMap['title'] as String).trim();
|
||||
final resolvedTitle = context.l10n.messagesInviteEvent(title);
|
||||
final statusText = isReadOnly ? _calendarStatusLabel(message.status) : null;
|
||||
final actorName =
|
||||
actorMap['username'] as String? ?? context.l10n.messagesUnknownActor;
|
||||
final actorPhone = actorMap['phone'] as String?;
|
||||
final summary = parsed['summary'] as String;
|
||||
final detailLines = <String>[
|
||||
summary,
|
||||
'${context.l10n.messagesCalendarInviteActorLabel}: $actorName${(actorPhone != null && actorPhone.isNotEmpty) ? ' / $actorPhone' : ''}',
|
||||
];
|
||||
final startAt = _formatInviteDateTime(itemMap['start_at']);
|
||||
final endAt = _formatInviteDateTime(itemMap['end_at']);
|
||||
final timezone = itemMap['timezone'] as String?;
|
||||
if (startAt != null) {
|
||||
detailLines.add(
|
||||
'${context.l10n.messagesCalendarInviteTimeLabel}: ${endAt != null ? '$startAt - $endAt' : startAt}${timezone != null && timezone.isNotEmpty ? ' ($timezone)' : ''}',
|
||||
);
|
||||
}
|
||||
final descriptionText = itemMap['description'] as String?;
|
||||
if (descriptionText != null && descriptionText.trim().isNotEmpty) {
|
||||
detailLines.add(
|
||||
'${context.l10n.messagesCalendarInviteDescriptionLabel}: ${descriptionText.trim()}',
|
||||
);
|
||||
}
|
||||
if (isReadOnly) {
|
||||
detailLines.add(context.l10n.messagesInviteAlreadyHandled);
|
||||
}
|
||||
final description = detailLines.join('\n');
|
||||
|
||||
showModalBottomSheet<void>(
|
||||
context: context,
|
||||
backgroundColor: _colorScheme.surface.withValues(alpha: 0),
|
||||
isScrollControlled: true,
|
||||
builder: (ctx) => MessageActionSheet(
|
||||
title: resolvedTitle,
|
||||
description: description,
|
||||
statusText: statusText,
|
||||
isReadOnly: isReadOnly,
|
||||
icon: Icons.event_outlined,
|
||||
iconColor: _colorScheme.primary,
|
||||
onAccept: isReadOnly
|
||||
? null
|
||||
: () async {
|
||||
await _processCalendarInvite(item, accept: true);
|
||||
},
|
||||
onDecline: isReadOnly
|
||||
? null
|
||||
: () async {
|
||||
await _processCalendarInvite(item, accept: false);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
String? _formatInviteDateTime(Object? raw) {
|
||||
if (raw is! String || raw.isEmpty) {
|
||||
return null;
|
||||
}
|
||||
final dt = DateTime.tryParse(raw);
|
||||
if (dt == null) {
|
||||
return null;
|
||||
}
|
||||
final local = dt.toLocal();
|
||||
final month = local.month.toString().padLeft(2, '0');
|
||||
final day = local.day.toString().padLeft(2, '0');
|
||||
final hour = local.hour.toString().padLeft(2, '0');
|
||||
final minute = local.minute.toString().padLeft(2, '0');
|
||||
return '${local.year}-$month-$day $hour:$minute';
|
||||
}
|
||||
|
||||
String _calendarStatusLabel(InboxMessageStatus status) {
|
||||
return switch (status) {
|
||||
InboxMessageStatus.pending => context.l10n.messagesStatusPending,
|
||||
InboxMessageStatus.accepted => context.l10n.messagesInviteStatusAccepted,
|
||||
InboxMessageStatus.rejected => context.l10n.messagesInviteStatusRejected,
|
||||
InboxMessageStatus.dismissed => context.l10n.messagesInviteStatusHandled,
|
||||
};
|
||||
}
|
||||
|
||||
Future<void> _processFriendRequest(
|
||||
MessageWithFriend item, {
|
||||
required bool accept,
|
||||
@@ -243,7 +509,7 @@ class _MessageInviteListScreenState extends State<MessageInviteListScreen> {
|
||||
}
|
||||
}
|
||||
await _inboxRepository.markAsRead(message.id);
|
||||
await _loadMessages();
|
||||
await _syncMessagesFromStore(forceSnapshot: true, fromPullRefresh: false);
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
Toast.show(
|
||||
@@ -255,6 +521,54 @@ class _MessageInviteListScreenState extends State<MessageInviteListScreen> {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _processCalendarInvite(
|
||||
MessageWithFriend item, {
|
||||
required bool accept,
|
||||
}) async {
|
||||
final message = item.message;
|
||||
final scheduleItemId = message.scheduleItemId;
|
||||
if (scheduleItemId == null) {
|
||||
Toast.show(
|
||||
context,
|
||||
context.l10n.messagesInviteOperationFailed,
|
||||
type: ToastType.error,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (accept) {
|
||||
await _calendarRepository.acceptSubscription(scheduleItemId);
|
||||
if (mounted) {
|
||||
Toast.show(
|
||||
context,
|
||||
context.l10n.messagesInviteAcceptedToast,
|
||||
type: ToastType.success,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
await _calendarRepository.rejectSubscription(scheduleItemId);
|
||||
if (mounted) {
|
||||
Toast.show(
|
||||
context,
|
||||
context.l10n.messagesInviteRejectedToast,
|
||||
type: ToastType.success,
|
||||
);
|
||||
}
|
||||
}
|
||||
await _inboxRepository.markAsRead(message.id);
|
||||
await _syncMessagesFromStore(forceSnapshot: true, fromPullRefresh: false);
|
||||
} catch (_) {
|
||||
if (mounted) {
|
||||
Toast.show(
|
||||
context,
|
||||
context.l10n.messagesInviteOperationFailed,
|
||||
type: ToastType.error,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
@@ -264,7 +578,7 @@ class _MessageInviteListScreenState extends State<MessageInviteListScreen> {
|
||||
children: [
|
||||
_buildHeader(context),
|
||||
Expanded(
|
||||
child: _isLoading
|
||||
child: _isInitialLoading
|
||||
? const Center(
|
||||
child: AppLoadingIndicator(
|
||||
size: 22,
|
||||
@@ -479,6 +793,42 @@ class _MessageCard extends StatelessWidget {
|
||||
InboxMessage get message => item.message;
|
||||
FriendRequest? get friendRequest => item.friendRequest;
|
||||
|
||||
Map<String, dynamic>? _parseCalendarContent(Map<String, dynamic>? content) {
|
||||
if (content == null) {
|
||||
return null;
|
||||
}
|
||||
final type = content['type'];
|
||||
final schemaVersion = content['schema_version'];
|
||||
final item = content['item'];
|
||||
final actor = content['actor'];
|
||||
final summary = content['summary'];
|
||||
if (type is! String || schemaVersion is! int || schemaVersion != 2) {
|
||||
return null;
|
||||
}
|
||||
if (item is! Map<String, dynamic> || actor is! Map<String, dynamic>) {
|
||||
return null;
|
||||
}
|
||||
final itemId = item['id'];
|
||||
final itemTitle = item['title'];
|
||||
final actorId = actor['user_id'];
|
||||
final actorName = actor['username'];
|
||||
if (itemId is! String || itemTitle is! String || itemTitle.trim().isEmpty) {
|
||||
return null;
|
||||
}
|
||||
if (actorId is! String ||
|
||||
actorName is! String ||
|
||||
actorName.trim().isEmpty) {
|
||||
return null;
|
||||
}
|
||||
if (summary is! String || summary.trim().isEmpty) {
|
||||
return null;
|
||||
}
|
||||
if (type == 'updated' && content['changes'] is! List) {
|
||||
return null;
|
||||
}
|
||||
return content;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
@@ -555,19 +905,39 @@ class _MessageCard extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
if (message.messageType == InboxMessageType.calendar) {
|
||||
final data = message.content;
|
||||
return data?['title'] as String? ?? L10n.current.messagesCalendarInvite;
|
||||
final data = _parseCalendarContent(message.content);
|
||||
if (data == null) {
|
||||
return L10n.current.messagesProtocolInvalidCardTitle;
|
||||
}
|
||||
final type = data['type'];
|
||||
final item = data['item'];
|
||||
final itemTitle = item is Map<String, dynamic>
|
||||
? item['title'] as String?
|
||||
: null;
|
||||
if (type == 'invite' &&
|
||||
itemTitle != null &&
|
||||
itemTitle.trim().isNotEmpty) {
|
||||
return L10n.current.messagesInviteEvent(itemTitle);
|
||||
}
|
||||
if (type == 'updated' &&
|
||||
itemTitle != null &&
|
||||
itemTitle.trim().isNotEmpty) {
|
||||
return L10n.current.messagesCalendarCardUpdatedWithTitle(itemTitle);
|
||||
}
|
||||
if (type == 'deleted' &&
|
||||
itemTitle != null &&
|
||||
itemTitle.trim().isNotEmpty) {
|
||||
return L10n.current.messagesCalendarCardDeletedWithTitle(itemTitle);
|
||||
}
|
||||
return L10n.current.messagesProtocolInvalidCardTitle;
|
||||
}
|
||||
return L10n.current.messagesSystemMessage;
|
||||
}
|
||||
|
||||
String _content() {
|
||||
if (message.messageType == InboxMessageType.calendar) {
|
||||
Map<String, dynamic>? data;
|
||||
if (message.content != null) {
|
||||
data = message.content;
|
||||
}
|
||||
if (data == null) return L10n.current.messagesTapToView;
|
||||
final data = _parseCalendarContent(message.content);
|
||||
if (data == null) return L10n.current.messagesProtocolInvalidCardDesc;
|
||||
|
||||
final type = data['type'] as String?;
|
||||
if (type == 'invite') {
|
||||
@@ -579,10 +949,12 @@ class _MessageCard extends StatelessWidget {
|
||||
} else if (status == InboxMessageStatus.rejected) {
|
||||
return L10n.current.messagesInviteRejected;
|
||||
}
|
||||
} else if (type == 'update') {
|
||||
} else if (type == 'updated') {
|
||||
return L10n.current.messagesCalendarUpdated;
|
||||
} else if (type == 'deleted') {
|
||||
return L10n.current.messagesCalendarDeleted;
|
||||
}
|
||||
return L10n.current.messagesTapToView;
|
||||
return L10n.current.messagesProtocolInvalidCardDesc;
|
||||
}
|
||||
return message.content?['message'] as String? ??
|
||||
L10n.current.messagesTapToView;
|
||||
|
||||
@@ -12,6 +12,8 @@ class MessageActionSheet extends StatelessWidget {
|
||||
final VoidCallback? onDecline;
|
||||
final IconData? icon;
|
||||
final Color? iconColor;
|
||||
final String? primaryActionText;
|
||||
final VoidCallback? onPrimaryAction;
|
||||
|
||||
const MessageActionSheet({
|
||||
super.key,
|
||||
@@ -23,6 +25,8 @@ class MessageActionSheet extends StatelessWidget {
|
||||
this.onDecline,
|
||||
this.icon,
|
||||
this.iconColor,
|
||||
this.primaryActionText,
|
||||
this.onPrimaryAction,
|
||||
});
|
||||
|
||||
@override
|
||||
@@ -124,6 +128,17 @@ class MessageActionSheet extends StatelessWidget {
|
||||
),
|
||||
],
|
||||
),
|
||||
] else if (primaryActionText != null && onPrimaryAction != null) ...[
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: AppButton(
|
||||
text: primaryActionText!,
|
||||
onPressed: () {
|
||||
Navigator.pop(context);
|
||||
onPrimaryAction?.call();
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
SizedBox(height: MediaQuery.of(context).padding.bottom + 12),
|
||||
],
|
||||
|
||||
@@ -160,7 +160,7 @@ class _EditProfileScreenState extends State<EditProfileScreen> {
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (newUsername.length < 3 || newUsername.length > 30) {
|
||||
if (newUsername.length > 30) {
|
||||
Toast.show(
|
||||
context,
|
||||
context.l10n.settingsEditProfileUsernameLengthInvalid,
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
import 'package:social_app/core/config/env.dart';
|
||||
import 'package:social_app/app/di/injection.dart';
|
||||
import 'package:social_app/app/router/app_routes.dart';
|
||||
import 'package:social_app/app/services/app_prewarm_orchestrator.dart';
|
||||
import 'package:social_app/core/l10n/l10n.dart';
|
||||
import 'package:social_app/core/theme/design_tokens.dart';
|
||||
import 'package:social_app/core/auth/session_controller.dart';
|
||||
import 'package:social_app/data/cache/cache_store.dart';
|
||||
import 'package:social_app/core/inbox/inbox_sync_store.dart';
|
||||
import 'package:social_app/features/contacts/data/models/user_profile.dart';
|
||||
import 'package:social_app/features/contacts/data/repositories/friend_repository.dart';
|
||||
import 'package:social_app/shared/widgets/app_button.dart';
|
||||
@@ -14,6 +18,7 @@ import 'package:social_app/shared/widgets/app_pressable.dart';
|
||||
import 'package:social_app/shared/widgets/destructive_action_sheet.dart';
|
||||
import 'package:social_app/shared/widgets/toast/toast.dart';
|
||||
import 'package:social_app/shared/widgets/toast/toast_type.dart';
|
||||
import 'package:social_app/shared/widgets/shared_divider.dart';
|
||||
import 'package:social_app/core/utils/phone_display_formatter.dart';
|
||||
import 'package:social_app/features/settings/data/apis/settings_api.dart';
|
||||
import 'package:social_app/features/settings/data/apis/automation_jobs_api.dart';
|
||||
@@ -171,21 +176,24 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
Container(
|
||||
width: 64,
|
||||
height: 64,
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(32),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: colorScheme.primary.withValues(alpha: 0.2),
|
||||
blurRadius: 12,
|
||||
offset: const Offset(0, 4),
|
||||
),
|
||||
],
|
||||
GestureDetector(
|
||||
onTap: _onTapEditProfile,
|
||||
child: Container(
|
||||
width: 64,
|
||||
height: 64,
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(32),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: colorScheme.primary.withValues(alpha: 0.2),
|
||||
blurRadius: 12,
|
||||
offset: const Offset(0, 4),
|
||||
),
|
||||
],
|
||||
),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: _buildAvatarImage(_user?.avatarUrl),
|
||||
),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: _buildAvatarImage(_user?.avatarUrl),
|
||||
),
|
||||
const SizedBox(width: AppSpacing.lg),
|
||||
Expanded(
|
||||
@@ -599,19 +607,25 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
title: context.l10n.settingsMenuNotifications,
|
||||
onTap: () {},
|
||||
),
|
||||
_buildDivider(),
|
||||
SharedDivider(),
|
||||
_buildMenuItem(
|
||||
icon: Icons.bookmark,
|
||||
title: context.l10n.memoryTitle,
|
||||
onTap: () => context.push(AppRoutes.settingsMemory),
|
||||
),
|
||||
_buildDivider(),
|
||||
SharedDivider(),
|
||||
_buildMenuItem(
|
||||
icon: Icons.system_update,
|
||||
title: context.l10n.settingsMenuCheckUpdates,
|
||||
trailing: 'v${Env.version}',
|
||||
onTap: _checkForUpdates,
|
||||
),
|
||||
SharedDivider(),
|
||||
_buildMenuItem(
|
||||
icon: Icons.cleaning_services_outlined,
|
||||
title: context.l10n.settingsMenuClearCache,
|
||||
onTap: _clearLocalCache,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
@@ -673,16 +687,6 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildDivider() {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
|
||||
return Container(
|
||||
height: 1,
|
||||
margin: const EdgeInsets.symmetric(horizontal: 14),
|
||||
color: colorScheme.outlineVariant,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _onTapEditProfile() async {
|
||||
final changed = await context.push<bool>(AppRoutes.settingsEditProfile);
|
||||
if (changed == true && mounted) {
|
||||
@@ -769,11 +773,17 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
);
|
||||
|
||||
if (shouldUpdate == true && result.downloadUrl != null && mounted) {
|
||||
Toast.show(
|
||||
context,
|
||||
context.l10n.settingsDownloadLink(result.downloadUrl!),
|
||||
type: ToastType.info,
|
||||
);
|
||||
final uri = Uri.tryParse(result.downloadUrl!);
|
||||
final launched =
|
||||
uri != null &&
|
||||
await launchUrl(uri, mode: LaunchMode.externalApplication);
|
||||
if (!launched && mounted) {
|
||||
Toast.show(
|
||||
context,
|
||||
context.l10n.settingsDownloadLink(result.downloadUrl!),
|
||||
type: ToastType.info,
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
@@ -785,6 +795,46 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _clearLocalCache() async {
|
||||
final confirmed = await showDestructiveActionSheet(
|
||||
context,
|
||||
title: context.l10n.settingsClearCacheTitle,
|
||||
message: context.l10n.settingsClearCacheMessage,
|
||||
confirmText: context.l10n.settingsClearCacheAction,
|
||||
);
|
||||
if (!confirmed || !mounted) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await sl<HybridCacheStore>().clearByPrefix('cache:');
|
||||
|
||||
final userId = _user?.id;
|
||||
if (userId != null && userId.isNotEmpty) {
|
||||
await sl<AppPrewarmOrchestrator>().ensureStartedFor(userId);
|
||||
}
|
||||
await sl<InboxSyncStore>().refreshSnapshot();
|
||||
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
Toast.show(
|
||||
context,
|
||||
context.l10n.settingsClearCacheSuccess,
|
||||
type: ToastType.success,
|
||||
);
|
||||
} catch (_) {
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
Toast.show(
|
||||
context,
|
||||
context.l10n.settingsClearCacheFailed,
|
||||
type: ToastType.error,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Widget _buildLogoutAction() {
|
||||
return SizedBox(
|
||||
width: double.infinity,
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:drag_and_drop_lists/drag_and_drop_lists.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:lucide_icons/lucide_icons.dart';
|
||||
import '../../../../app/di/injection.dart';
|
||||
@@ -398,65 +397,6 @@ class _TodoQuadrantsScreenState extends State<TodoQuadrantsScreen> {
|
||||
Widget _buildDragBoard() {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final palette = Theme.of(context).extension<AppColorPalette>()!;
|
||||
final quadrants = [
|
||||
_QuadrantMeta(
|
||||
value: 1,
|
||||
title: context.l10n.todoQuadrantImportantUrgent,
|
||||
textColor: palette.g1Text,
|
||||
dividerColor: palette.g1Divider,
|
||||
borderColor: palette.g1Border,
|
||||
items: _importantUrgent,
|
||||
),
|
||||
_QuadrantMeta(
|
||||
value: 3,
|
||||
title: context.l10n.todoQuadrantUrgentNotImportant,
|
||||
textColor: palette.g3Text,
|
||||
dividerColor: palette.g3Divider,
|
||||
borderColor: palette.g3Border,
|
||||
items: _urgentNotImportant,
|
||||
),
|
||||
_QuadrantMeta(
|
||||
value: 2,
|
||||
title: context.l10n.todoQuadrantImportantNotUrgent,
|
||||
textColor: palette.g2Text,
|
||||
dividerColor: palette.g2Divider,
|
||||
borderColor: palette.g2Border,
|
||||
items: _importantNotUrgent,
|
||||
),
|
||||
];
|
||||
|
||||
final lists = quadrants
|
||||
.map(
|
||||
(meta) => DragAndDropList(
|
||||
canDrag: false,
|
||||
header: _buildQuadrantHeader(meta),
|
||||
contentsWhenEmpty: _buildEmptyQuadrant(),
|
||||
lastTarget: const SizedBox(height: AppSpacing.lg),
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.surface,
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
border: Border.all(color: meta.borderColor),
|
||||
),
|
||||
children: meta.items
|
||||
.map(
|
||||
(item) => DragAndDropItem(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: AppSpacing.sm,
|
||||
),
|
||||
child: _TodoItemWidget(
|
||||
key: ValueKey(item.id),
|
||||
item: item,
|
||||
onComplete: () => _completeTodo(item),
|
||||
onTap: () => _navigateToDetail(item),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
.toList(growable: false),
|
||||
),
|
||||
)
|
||||
.toList(growable: false);
|
||||
|
||||
return SingleChildScrollView(
|
||||
padding: const EdgeInsets.fromLTRB(
|
||||
@@ -465,36 +405,199 @@ class _TodoQuadrantsScreenState extends State<TodoQuadrantsScreen> {
|
||||
AppSpacing.lg,
|
||||
96,
|
||||
),
|
||||
child: DragAndDropLists(
|
||||
children: lists,
|
||||
onItemReorder: _onItemReorder,
|
||||
onListReorder: (oldListIndex, newListIndex) {},
|
||||
listDivider: const SizedBox(height: AppSpacing.md),
|
||||
itemDivider: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: AppSpacing.md),
|
||||
child: Container(height: 1, color: colorScheme.surfaceContainerHigh),
|
||||
),
|
||||
listPadding: EdgeInsets.zero,
|
||||
itemDecorationWhileDragging: BoxDecoration(
|
||||
color: colorScheme.surface,
|
||||
borderRadius: BorderRadius.circular(AppRadius.md),
|
||||
border: Border.all(color: colorScheme.outlineVariant),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: colorScheme.shadow.withValues(alpha: 0.16),
|
||||
blurRadius: AppRadius.md,
|
||||
offset: const Offset(0, AppSpacing.xs),
|
||||
),
|
||||
],
|
||||
),
|
||||
itemGhost: const SizedBox(height: 42),
|
||||
itemDragOnLongPress: true,
|
||||
lastItemTargetHeight: AppSpacing.xl,
|
||||
disableScrolling: true,
|
||||
child: Column(
|
||||
children: [
|
||||
_buildQuadrant(
|
||||
value: 1,
|
||||
title: context.l10n.todoQuadrantImportantUrgent,
|
||||
textColor: palette.g1Text,
|
||||
dividerColor: palette.g1Divider,
|
||||
borderColor: palette.g1Border,
|
||||
items: _importantUrgent,
|
||||
colorScheme: colorScheme,
|
||||
),
|
||||
const SizedBox(height: AppSpacing.md),
|
||||
_buildQuadrant(
|
||||
value: 3,
|
||||
title: context.l10n.todoQuadrantUrgentNotImportant,
|
||||
textColor: palette.g3Text,
|
||||
dividerColor: palette.g3Divider,
|
||||
borderColor: palette.g3Border,
|
||||
items: _urgentNotImportant,
|
||||
colorScheme: colorScheme,
|
||||
),
|
||||
const SizedBox(height: AppSpacing.md),
|
||||
_buildQuadrant(
|
||||
value: 2,
|
||||
title: context.l10n.todoQuadrantImportantNotUrgent,
|
||||
textColor: palette.g2Text,
|
||||
dividerColor: palette.g2Divider,
|
||||
borderColor: palette.g2Border,
|
||||
items: _importantNotUrgent,
|
||||
colorScheme: colorScheme,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildQuadrant({
|
||||
required int value,
|
||||
required String title,
|
||||
required Color textColor,
|
||||
required Color dividerColor,
|
||||
required Color borderColor,
|
||||
required List<TodoResponse> items,
|
||||
required ColorScheme colorScheme,
|
||||
}) {
|
||||
return DragTarget<_TodoDragInfo>(
|
||||
onWillAcceptWithDetails: (details) => true,
|
||||
onAcceptWithDetails: (details) {
|
||||
final info = details.data;
|
||||
if (info.sourceQuadrant != value) {
|
||||
_onItemReorder(
|
||||
info.sourceIndex,
|
||||
_listIndexByQuadrant(info.sourceQuadrant),
|
||||
0,
|
||||
_listIndexByQuadrant(value),
|
||||
);
|
||||
}
|
||||
},
|
||||
builder: (context, candidateData, rejectedData) {
|
||||
final isHovering = candidateData.isNotEmpty;
|
||||
return AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 150),
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.surface,
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
border: Border.all(
|
||||
color: isHovering ? colorScheme.primary : borderColor,
|
||||
width: isHovering ? 2 : 1,
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
_buildQuadrantHeader(
|
||||
_QuadrantMeta(
|
||||
value: value,
|
||||
title: title,
|
||||
textColor: textColor,
|
||||
dividerColor: dividerColor,
|
||||
borderColor: borderColor,
|
||||
items: items,
|
||||
),
|
||||
),
|
||||
if (items.isEmpty)
|
||||
_buildEmptyContent(colorScheme)
|
||||
else
|
||||
..._buildItemList(items, value, colorScheme),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildDraggableItem(
|
||||
TodoResponse item,
|
||||
int sourceQuadrant,
|
||||
ColorScheme colorScheme,
|
||||
) {
|
||||
final sourceIndex = _sortedQuadrantTodos(
|
||||
sourceQuadrant,
|
||||
).indexWhere((t) => t.id == item.id);
|
||||
final dragInfo = _TodoDragInfo(
|
||||
todoId: item.id,
|
||||
sourceQuadrant: sourceQuadrant,
|
||||
sourceIndex: sourceIndex,
|
||||
);
|
||||
|
||||
return LongPressDraggable<_TodoDragInfo>(
|
||||
data: dragInfo,
|
||||
delay: const Duration(milliseconds: 150),
|
||||
feedback: Material(
|
||||
elevation: 4,
|
||||
borderRadius: BorderRadius.circular(AppRadius.md),
|
||||
child: Container(
|
||||
width: 280,
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: AppSpacing.md,
|
||||
vertical: AppSpacing.sm,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.surface,
|
||||
borderRadius: BorderRadius.circular(AppRadius.md),
|
||||
),
|
||||
child: Text(
|
||||
item.title,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
childWhenDragging: Opacity(
|
||||
opacity: 0.3,
|
||||
child: _TodoItemWidget(
|
||||
key: ValueKey(item.id),
|
||||
item: item,
|
||||
onComplete: () => _completeTodo(item),
|
||||
onTap: () => _navigateToDetail(item),
|
||||
),
|
||||
),
|
||||
child: _TodoItemWidget(
|
||||
key: ValueKey(item.id),
|
||||
item: item,
|
||||
onComplete: () => _completeTodo(item),
|
||||
onTap: () => _navigateToDetail(item),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildEmptyContent(ColorScheme colorScheme) {
|
||||
return SizedBox(
|
||||
height: 60,
|
||||
child: Center(
|
||||
child: Text(
|
||||
context.l10n.todoNoItems,
|
||||
style: TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 13,
|
||||
color: colorScheme.outline,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
List<Widget> _buildItemList(
|
||||
List<TodoResponse> items,
|
||||
int quadrant,
|
||||
ColorScheme colorScheme,
|
||||
) {
|
||||
final result = <Widget>[];
|
||||
for (var i = 0; i < items.length; i++) {
|
||||
result.add(_buildDraggableItem(items[i], quadrant, colorScheme));
|
||||
if (i < items.length - 1) {
|
||||
result.add(
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: AppSpacing.md),
|
||||
child: Container(
|
||||
height: 1,
|
||||
color: colorScheme.surfaceContainerHigh,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
Widget _buildQuadrantHeader(_QuadrantMeta meta) {
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
@@ -533,21 +636,17 @@ class _TodoQuadrantsScreenState extends State<TodoQuadrantsScreen> {
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildEmptyQuadrant() {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
return SizedBox(
|
||||
height: 60,
|
||||
child: Center(
|
||||
child: Text(
|
||||
context.l10n.todoNoItems,
|
||||
style: TextStyle(
|
||||
fontFamily: 'Inter',
|
||||
fontSize: 13,
|
||||
color: colorScheme.outline,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
int _listIndexByQuadrant(int quadrant) {
|
||||
switch (quadrant) {
|
||||
case 1:
|
||||
return 0;
|
||||
case 3:
|
||||
return 1;
|
||||
case 2:
|
||||
return 2;
|
||||
default:
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
Widget _buildBottomDock() {
|
||||
@@ -571,6 +670,18 @@ class _TodoQuadrantsScreenState extends State<TodoQuadrantsScreen> {
|
||||
}
|
||||
}
|
||||
|
||||
class _TodoDragInfo {
|
||||
final String todoId;
|
||||
final int sourceQuadrant;
|
||||
final int sourceIndex;
|
||||
|
||||
const _TodoDragInfo({
|
||||
required this.todoId,
|
||||
required this.sourceQuadrant,
|
||||
required this.sourceIndex,
|
||||
});
|
||||
}
|
||||
|
||||
class _ReorderResult {
|
||||
final List<TodoResponse> todos;
|
||||
final List<TodoResponse> changedTodos;
|
||||
|
||||
Reference in New Issue
Block a user