feat: 重构 Reminder Notification 系统并更新应用包名

This commit is contained in:
qzl
2026-03-30 18:36:57 +08:00
parent 9fb2a6857b
commit 91bf3c3f96
90 changed files with 5133 additions and 3017 deletions
@@ -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;