feat: integrate invite API and improve notification handling
- Add invite code display and binding functionality via API - Fix notification unread count sync on auth state change - Improve notification mark read with server state validation - Add auth state listener to trigger notification refresh - Add YaoCoinConverter for coin-to-yao type conversion - Remove YaoLegend from divination screens (UI cleanup) - Abbreviate relation labels in yao detail view - Add re-register notice to account delete screen - Update 'coins' terminology to 'points' in localization - Fix backend points consumption to only run in CHAT mode - Add HttpxAuthNoiseFilter to suppress auth endpoint logging - Fix notification static_schema import path - Add test coverage for notification bloc error handling - Update AGENTS.md page header rules and image handling - Delete deprecated run-dev.sh script
This commit is contained in:
+16
-1
@@ -53,6 +53,7 @@ class _EryaoAppState extends State<EryaoApp> {
|
||||
List<DivinationResultData> _historyRecords = const <DivinationResultData>[];
|
||||
bool _loadingProfile = false;
|
||||
String? _loadedProfileUserEmail;
|
||||
String? _lastUnreadRefreshedUserId;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@@ -77,9 +78,23 @@ class _EryaoAppState extends State<EryaoApp> {
|
||||
sessionStore: _sessionStore,
|
||||
);
|
||||
_authBloc = AuthBloc(repository: authRepository);
|
||||
_authBloc.addListener(_onAuthStateChanged);
|
||||
_bootstrap();
|
||||
}
|
||||
|
||||
void _onAuthStateChanged() {
|
||||
final state = _authBloc.state;
|
||||
if (state.status == AuthStatus.authenticated && state.user != null) {
|
||||
final userId = state.user!.id;
|
||||
if (_lastUnreadRefreshedUserId != userId) {
|
||||
_lastUnreadRefreshedUserId = userId;
|
||||
_notificationBloc.handleEvent(RefreshUnreadCount());
|
||||
}
|
||||
return;
|
||||
}
|
||||
_lastUnreadRefreshedUserId = null;
|
||||
}
|
||||
|
||||
void _ensureCreditsLoaded(String userEmail) {
|
||||
if (_loadingCredits) {
|
||||
return;
|
||||
@@ -357,6 +372,7 @@ class _EryaoAppState extends State<EryaoApp> {
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_authBloc.removeListener(_onAuthStateChanged);
|
||||
_authBloc.dispose();
|
||||
_notificationBloc.dispose();
|
||||
super.dispose();
|
||||
@@ -427,7 +443,6 @@ class _EryaoAppState extends State<EryaoApp> {
|
||||
_ensureCreditsLoaded(state.user!.email);
|
||||
_ensureHistoryLoaded(state.user!.email);
|
||||
_refreshProfile(userEmail: state.user!.email);
|
||||
_notificationBloc.handleEvent(RefreshUnreadCount());
|
||||
return HomeScreen(
|
||||
account: state.user!.email,
|
||||
sessionStore: _sessionStore,
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import 'divination_params.dart';
|
||||
|
||||
class YaoCoinConverter {
|
||||
const YaoCoinConverter._();
|
||||
|
||||
static YaoType fromHuaCount(int huaCount) {
|
||||
return switch (huaCount) {
|
||||
0 => YaoType.oldYin,
|
||||
1 => YaoType.youngYang,
|
||||
2 => YaoType.youngYin,
|
||||
3 => YaoType.oldYang,
|
||||
_ => throw ArgumentError.value(huaCount, 'huaCount', 'must be 0..3'),
|
||||
};
|
||||
}
|
||||
|
||||
static YaoType fromZiCount(int ziCount) {
|
||||
return fromHuaCount(3 - ziCount);
|
||||
}
|
||||
}
|
||||
@@ -14,7 +14,6 @@ import '../../../../shared/widgets/app_modal_dialog.dart';
|
||||
import '../../../../shared/widgets/gua_icon.dart';
|
||||
import '../../../../shared/widgets/divination/divination_shared_widgets.dart';
|
||||
import '../../../../shared/widgets/divination/divination_terms.dart';
|
||||
import '../../../../shared/widgets/divination/yao_legend.dart';
|
||||
import '../../../../shared/widgets/divination/yao_line_row.dart';
|
||||
import '../../../../shared/widgets/date_time_picker/date_time_picker_bottom_sheet.dart';
|
||||
import '../../../../shared/widgets/toast/toast.dart';
|
||||
@@ -23,6 +22,7 @@ import '../../data/models/divination_backend_models.dart';
|
||||
import '../../data/apis/divination_api.dart';
|
||||
import '../../data/models/divination_params.dart';
|
||||
import '../../data/models/divination_result.dart';
|
||||
import '../../data/models/yao_coin_converter.dart';
|
||||
import '../../data/services/divination_run_service.dart';
|
||||
import 'divination_processing_screen.dart';
|
||||
|
||||
@@ -287,14 +287,8 @@ class _AutoDivinationScreenState extends State<AutoDivinationScreen>
|
||||
final c1 = _random.nextBool();
|
||||
final c2 = _random.nextBool();
|
||||
final c3 = _random.nextBool();
|
||||
final yangCount = [c1, c2, c3].where((v) => v).length;
|
||||
final yao = switch (yangCount) {
|
||||
0 => YaoType.oldYin,
|
||||
1 => YaoType.youngYang,
|
||||
2 => YaoType.youngYin,
|
||||
3 => YaoType.oldYang,
|
||||
_ => YaoType.undetermined,
|
||||
};
|
||||
final ziCount = [c1, c2, c3].where((v) => v).length;
|
||||
final yao = YaoCoinConverter.fromZiCount(ziCount);
|
||||
setState(() {
|
||||
_isSpinning = false;
|
||||
_coin1Yang = c1;
|
||||
@@ -737,7 +731,6 @@ class _HexagramCard extends StatelessWidget {
|
||||
const SizedBox(height: AppSpacing.md),
|
||||
for (int i = 5; i >= 0; i--) _YaoRow(index: i, type: yaoStates[i]),
|
||||
const SizedBox(height: AppSpacing.xs),
|
||||
const Align(alignment: Alignment.centerLeft, child: YaoLegend()),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@@ -10,7 +10,6 @@ import '../../../../shared/theme/app_color_palette.dart';
|
||||
import '../../../../shared/theme/design_tokens.dart';
|
||||
import '../../../../shared/widgets/divination/divination_terms.dart';
|
||||
import '../../../../shared/widgets/divination/yao_glyph.dart';
|
||||
import '../../../../shared/widgets/divination/yao_legend.dart';
|
||||
import '../../../../shared/widgets/toast/toast.dart';
|
||||
import '../../../../shared/widgets/toast/toast_type.dart';
|
||||
import '../../data/apis/divination_api.dart';
|
||||
@@ -926,11 +925,6 @@ class _HexagramDetailCard extends StatelessWidget {
|
||||
showTarget:
|
||||
data.hasChangingYao && idx < data.targetYaoLines.length,
|
||||
),
|
||||
const SizedBox(height: AppSpacing.sm),
|
||||
const Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: YaoLegend(),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -1156,7 +1150,10 @@ class _YaoDetailRow extends StatelessWidget {
|
||||
),
|
||||
SizedBox(
|
||||
width: 28,
|
||||
child: Text(data.relation, textAlign: TextAlign.center),
|
||||
child: Text(
|
||||
_abbreviateRelation(data.relation),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
),
|
||||
SizedBox(
|
||||
width: 18,
|
||||
@@ -1183,4 +1180,15 @@ class _YaoDetailRow extends StatelessWidget {
|
||||
String _changeMark(YaoType type) {
|
||||
return type.changeMark;
|
||||
}
|
||||
|
||||
String _abbreviateRelation(String relation) {
|
||||
return switch (relation) {
|
||||
'子孙' => '孙',
|
||||
'妻财' => '财',
|
||||
'官鬼' => '官',
|
||||
'兄弟' => '兄',
|
||||
'父母' => '父',
|
||||
_ => relation,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,7 +11,6 @@ import '../../../../shared/widgets/app_modal_dialog.dart';
|
||||
import '../../../../shared/widgets/gua_icon.dart';
|
||||
import '../../../../shared/widgets/divination/divination_shared_widgets.dart';
|
||||
import '../../../../shared/widgets/divination/divination_terms.dart';
|
||||
import '../../../../shared/widgets/divination/yao_legend.dart';
|
||||
import '../../../../shared/widgets/divination/yao_line_row.dart';
|
||||
import '../../../../shared/widgets/date_time_picker/date_time_picker_bottom_sheet.dart';
|
||||
import '../../../../shared/widgets/toast/toast.dart';
|
||||
@@ -20,6 +19,7 @@ import '../../data/models/divination_backend_models.dart';
|
||||
import '../../data/apis/divination_api.dart';
|
||||
import '../../data/models/divination_params.dart';
|
||||
import '../../data/models/divination_result.dart';
|
||||
import '../../data/models/yao_coin_converter.dart';
|
||||
import '../../data/services/divination_run_service.dart';
|
||||
import 'divination_processing_screen.dart';
|
||||
|
||||
@@ -524,7 +524,6 @@ class _YaoSelectionCard extends StatelessWidget {
|
||||
);
|
||||
}),
|
||||
const SizedBox(height: AppSpacing.xs),
|
||||
const Align(alignment: Alignment.centerLeft, child: YaoLegend()),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -565,13 +564,7 @@ class _ThreeCoinSelectorDialogState extends State<_ThreeCoinSelectorDialog> {
|
||||
|
||||
YaoType get _currentYaoType {
|
||||
final huaCount = _coinStates.where((isHua) => isHua).length;
|
||||
return switch (huaCount) {
|
||||
0 => YaoType.oldYin,
|
||||
1 => YaoType.youngYang,
|
||||
2 => YaoType.youngYin,
|
||||
3 => YaoType.oldYang,
|
||||
_ => YaoType.undetermined,
|
||||
};
|
||||
return YaoCoinConverter.fromHuaCount(huaCount);
|
||||
}
|
||||
|
||||
void _toggleCoin(int index) {
|
||||
|
||||
@@ -3,6 +3,7 @@ import 'dart:async';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../../../core/auth/session_store.dart';
|
||||
import '../../../../data/network/api_client.dart';
|
||||
import '../../../divination/presentation/screens/divination_screen.dart';
|
||||
import '../../../divination/presentation/screens/divination_result_screen.dart';
|
||||
import '../../../divination/data/apis/divination_api.dart';
|
||||
@@ -11,8 +12,11 @@ import '../../../divination/data/models/divination_result.dart';
|
||||
import '../../../notifications/data/repositories/notification_repository.dart';
|
||||
import '../../../notifications/presentation/bloc/notification_bloc.dart';
|
||||
import '../../../notifications/presentation/screens/notification_center_screen.dart';
|
||||
import '../../../settings/data/apis/invite_api.dart';
|
||||
import '../../../settings/data/models/profile_settings.dart';
|
||||
import '../../../settings/data/repositories/invite_repository.dart';
|
||||
import '../../../settings/presentation/screens/settings_screen.dart';
|
||||
import '../../../../app/di/injection.dart';
|
||||
import '../../../../l10n/app_localizations.dart';
|
||||
import '../../../../shared/theme/app_color_palette.dart';
|
||||
import '../../../../shared/theme/design_tokens.dart';
|
||||
@@ -68,10 +72,18 @@ class HomeScreen extends StatefulWidget {
|
||||
|
||||
class _HomeScreenState extends State<HomeScreen> {
|
||||
MainTab _currentTab = MainTab.home;
|
||||
late final InviteRepository _inviteRepository;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
final inviteApi = InviteApi(
|
||||
apiClient: ApiClient(
|
||||
baseUrl: appDependencies.backendUrl,
|
||||
tokenProvider: widget.sessionStore.getToken,
|
||||
),
|
||||
);
|
||||
_inviteRepository = InviteRepositoryImpl(inviteApi: inviteApi);
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
_tryShowWelcomeDialog();
|
||||
});
|
||||
@@ -120,6 +132,7 @@ class _HomeScreenState extends State<HomeScreen> {
|
||||
account: widget.account,
|
||||
settings: widget.profileSettings,
|
||||
coinBalance: widget.coinBalance,
|
||||
inviteRepository: _inviteRepository,
|
||||
onLocaleChanged: widget.onLocaleChanged,
|
||||
onSettingsChanged: widget.onProfileSettingsChanged,
|
||||
onSaveProfile: widget.onSaveProfile,
|
||||
@@ -209,6 +222,11 @@ class _HomeTab extends StatelessWidget {
|
||||
MaterialPageRoute<void>(
|
||||
builder: (_) => NotificationCenterScreen(
|
||||
repository: notificationRepository,
|
||||
onUnreadCountChanged: () {
|
||||
return notificationBloc.handleEvent(
|
||||
RefreshUnreadCount(),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
@@ -532,6 +550,7 @@ class _ProfileTab extends StatelessWidget {
|
||||
required this.account,
|
||||
required this.settings,
|
||||
required this.coinBalance,
|
||||
required this.inviteRepository,
|
||||
required this.onLocaleChanged,
|
||||
required this.onSettingsChanged,
|
||||
required this.onSaveProfile,
|
||||
@@ -543,6 +562,7 @@ class _ProfileTab extends StatelessWidget {
|
||||
final String account;
|
||||
final ProfileSettingsV1 settings;
|
||||
final int coinBalance;
|
||||
final InviteRepository inviteRepository;
|
||||
final Future<void> Function(String languageTag) onLocaleChanged;
|
||||
final Future<void> Function(ProfileSettingsV1 settings) onSettingsChanged;
|
||||
final Future<ProfileSettingsV1> Function(ProfileSettingsV1 updated)
|
||||
@@ -557,6 +577,7 @@ class _ProfileTab extends StatelessWidget {
|
||||
account: account,
|
||||
settings: settings,
|
||||
coinBalance: coinBalance,
|
||||
inviteRepository: inviteRepository,
|
||||
onInterfaceLanguageChanged: onLocaleChanged,
|
||||
onSettingsChanged: onSettingsChanged,
|
||||
onSaveProfile: onSaveProfile,
|
||||
|
||||
@@ -60,11 +60,20 @@ class NotificationApi {
|
||||
}
|
||||
|
||||
Future<NotificationItem> markRead({required String notificationId}) async {
|
||||
_logger.info(
|
||||
message: 'Mark read request started',
|
||||
extra: {'notification_id': notificationId},
|
||||
);
|
||||
try {
|
||||
final response = await _apiClient.rawDio.patch<Map<String, dynamic>>(
|
||||
'/api/v1/notifications/$notificationId/read',
|
||||
);
|
||||
return parseNotificationItem(response.data!);
|
||||
final item = parseNotificationItem(response.data!);
|
||||
_logger.info(
|
||||
message: 'Mark read request succeeded',
|
||||
extra: {'notification_id': notificationId, 'is_read': item.isRead},
|
||||
);
|
||||
return item;
|
||||
} on DioException catch (error, stackTrace) {
|
||||
_logger.error(
|
||||
message: 'Mark read failed',
|
||||
@@ -76,11 +85,17 @@ class NotificationApi {
|
||||
}
|
||||
|
||||
Future<int> markAllRead() async {
|
||||
_logger.info(message: 'Mark all read request started');
|
||||
try {
|
||||
final response = await _apiClient.rawDio.patch<Map<String, dynamic>>(
|
||||
'/api/v1/notifications/mark-all-read',
|
||||
);
|
||||
return response.data?['updatedCount'] as int? ?? 0;
|
||||
final updatedCount = response.data?['updatedCount'] as int? ?? 0;
|
||||
_logger.info(
|
||||
message: 'Mark all read request succeeded',
|
||||
extra: {'updated_count': updatedCount},
|
||||
);
|
||||
return updatedCount;
|
||||
} on DioException catch (error, stackTrace) {
|
||||
_logger.error(
|
||||
message: 'Mark all read failed',
|
||||
|
||||
@@ -185,58 +185,64 @@ class NotificationBloc extends ChangeNotifier {
|
||||
}
|
||||
|
||||
Future<void> _markRead(String notificationId) async {
|
||||
final previousItems = _state.items;
|
||||
final previousCount = _state.unreadCount;
|
||||
final idx = _state.items.indexWhere((item) => item.id == notificationId);
|
||||
if (idx == -1) return;
|
||||
if (_state.items[idx].isRead) return;
|
||||
|
||||
final wasUnread = !_state.items[idx].isRead;
|
||||
_state = _state.copyWith(
|
||||
items: [
|
||||
..._state.items.sublist(0, idx),
|
||||
_state.items[idx].copyWith(isRead: true),
|
||||
..._state.items.sublist(idx + 1),
|
||||
],
|
||||
unreadCount: wasUnread
|
||||
? (_state.unreadCount > 0 ? _state.unreadCount - 1 : 0)
|
||||
: _state.unreadCount,
|
||||
_logger.info(
|
||||
message: 'Mark notification read started',
|
||||
extra: {'notification_id': notificationId},
|
||||
);
|
||||
notifyListeners();
|
||||
|
||||
try {
|
||||
await _repository.markRead(notificationId: notificationId);
|
||||
final updated = await _repository.markRead(
|
||||
notificationId: notificationId,
|
||||
);
|
||||
final targetIndex = _state.items.indexWhere(
|
||||
(item) => item.id == updated.id,
|
||||
);
|
||||
if (targetIndex == -1) {
|
||||
return;
|
||||
}
|
||||
_state = _state.copyWith(
|
||||
items: [
|
||||
..._state.items.sublist(0, targetIndex),
|
||||
updated,
|
||||
..._state.items.sublist(targetIndex + 1),
|
||||
],
|
||||
unreadCount: _state.unreadCount > 0 ? _state.unreadCount - 1 : 0,
|
||||
);
|
||||
notifyListeners();
|
||||
_logger.info(
|
||||
message: 'Mark notification read succeeded',
|
||||
extra: {'notification_id': notificationId},
|
||||
);
|
||||
} catch (error, stackTrace) {
|
||||
_logger.error(
|
||||
message: 'Mark read failed: ${error.runtimeType}',
|
||||
error: error,
|
||||
stackTrace: stackTrace,
|
||||
);
|
||||
_state = _state.copyWith(
|
||||
items: previousItems,
|
||||
unreadCount: previousCount,
|
||||
);
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _markAllRead() async {
|
||||
final previousItems = _state.items;
|
||||
_state = _state.copyWith(
|
||||
items: _state.items.map((item) => item.copyWith(isRead: true)).toList(),
|
||||
unreadCount: 0,
|
||||
);
|
||||
notifyListeners();
|
||||
_logger.info(message: 'Mark all notifications read started');
|
||||
|
||||
try {
|
||||
await _repository.markAllRead();
|
||||
_state = _state.copyWith(
|
||||
items: _state.items.map((item) => item.copyWith(isRead: true)).toList(),
|
||||
unreadCount: 0,
|
||||
);
|
||||
notifyListeners();
|
||||
_logger.info(message: 'Mark all notifications read succeeded');
|
||||
} catch (error, stackTrace) {
|
||||
_logger.error(
|
||||
message: 'Mark all read failed: ${error.runtimeType}',
|
||||
error: error,
|
||||
stackTrace: stackTrace,
|
||||
);
|
||||
_state = _state.copyWith(items: previousItems);
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+36
-4
@@ -1,6 +1,9 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../../../shared/theme/design_tokens.dart';
|
||||
import '../../../../shared/widgets/notification/notification_detail_bottom_sheet.dart';
|
||||
import '../../data/models/notification_item.dart';
|
||||
import '../../data/models/notification_payload.dart';
|
||||
import '../../data/repositories/notification_repository.dart';
|
||||
@@ -13,12 +16,14 @@ class NotificationCenterScreen extends StatefulWidget {
|
||||
required this.repository,
|
||||
this.onNavigateToRoute,
|
||||
this.onOpenUrl,
|
||||
this.onUnreadCountChanged,
|
||||
});
|
||||
|
||||
final NotificationRepository repository;
|
||||
final void Function(String route, {String? entityId, String? tab})?
|
||||
onNavigateToRoute;
|
||||
final void Function(String url)? onOpenUrl;
|
||||
final Future<void> Function()? onUnreadCountChanged;
|
||||
|
||||
@override
|
||||
State<NotificationCenterScreen> createState() =>
|
||||
@@ -55,6 +60,7 @@ class _NotificationCenterScreenState extends State<NotificationCenterScreen> {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('通知'),
|
||||
centerTitle: true,
|
||||
actions: [
|
||||
if (state.items.any((item) => !item.isRead))
|
||||
TextButton(
|
||||
@@ -136,15 +142,32 @@ class _NotificationCenterScreenState extends State<NotificationCenterScreen> {
|
||||
final item = state.items[index];
|
||||
return NotificationListItem(
|
||||
item: item,
|
||||
onTap: () => _handleNotificationTap(item),
|
||||
onTap: () => _handleNotificationTap(context, item),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
void _handleNotificationTap(NotificationItem item) {
|
||||
Future<void> _handleNotificationTap(
|
||||
BuildContext context,
|
||||
NotificationItem item,
|
||||
) async {
|
||||
final wasUnread = !item.isRead;
|
||||
if (!item.isRead) {
|
||||
_bloc.handleEvent(MarkNotificationRead(notificationId: item.id));
|
||||
await _bloc.handleEvent(MarkNotificationRead(notificationId: item.id));
|
||||
final updatedIndex = _bloc.state.items.indexWhere((n) => n.id == item.id);
|
||||
if (wasUnread &&
|
||||
updatedIndex >= 0 &&
|
||||
_bloc.state.items[updatedIndex].isRead) {
|
||||
await widget.onUnreadCountChanged?.call();
|
||||
}
|
||||
}
|
||||
if (context.mounted) {
|
||||
await showNotificationDetailBottomSheet(
|
||||
context: context,
|
||||
item: item,
|
||||
onMarkRead: () async {},
|
||||
);
|
||||
}
|
||||
_executePayload(item.payload);
|
||||
}
|
||||
@@ -161,6 +184,15 @@ class _NotificationCenterScreenState extends State<NotificationCenterScreen> {
|
||||
}
|
||||
|
||||
void _onMarkAllRead() {
|
||||
_bloc.handleEvent(MarkAllNotificationsRead());
|
||||
unawaited(_markAllRead());
|
||||
}
|
||||
|
||||
Future<void> _markAllRead() async {
|
||||
final unreadBefore = _bloc.state.items.any((item) => !item.isRead);
|
||||
await _bloc.handleEvent(MarkAllNotificationsRead());
|
||||
final unreadAfter = _bloc.state.items.any((item) => !item.isRead);
|
||||
if (unreadBefore && !unreadAfter) {
|
||||
await widget.onUnreadCountChanged?.call();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,73 +18,78 @@ class NotificationListItem extends StatelessWidget {
|
||||
final colors = Theme.of(context).colorScheme;
|
||||
final textTheme = Theme.of(context).textTheme;
|
||||
|
||||
return InkWell(
|
||||
onTap: onTap,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: AppSpacing.lg,
|
||||
vertical: AppSpacing.md,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: item.isRead ? colors.surface : colors.surfaceContainerHighest,
|
||||
border: Border(
|
||||
bottom: BorderSide(
|
||||
color: colors.outlineVariant.withValues(alpha: 0.3),
|
||||
width: 0.5,
|
||||
return IntrinsicHeight(
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: AppSpacing.lg,
|
||||
vertical: AppSpacing.md,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: item.isRead
|
||||
? colors.surface
|
||||
: colors.surfaceContainerHighest,
|
||||
border: Border(
|
||||
bottom: BorderSide(
|
||||
color: colors.outlineVariant.withValues(alpha: 0.3),
|
||||
width: 0.5,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (!item.isRead)
|
||||
Container(
|
||||
margin: const EdgeInsets.only(
|
||||
top: AppSpacing.sm,
|
||||
right: AppSpacing.sm,
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (!item.isRead)
|
||||
Container(
|
||||
margin: const EdgeInsets.only(
|
||||
top: AppSpacing.sm,
|
||||
right: AppSpacing.sm,
|
||||
),
|
||||
width: 8,
|
||||
height: 8,
|
||||
decoration: BoxDecoration(
|
||||
color: colors.primary,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
),
|
||||
width: 8,
|
||||
height: 8,
|
||||
decoration: BoxDecoration(
|
||||
color: colors.primary,
|
||||
shape: BoxShape.circle,
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
item.title,
|
||||
style: textTheme.bodyMedium?.copyWith(
|
||||
fontWeight: item.isRead
|
||||
? FontWeight.normal
|
||||
: FontWeight.w600,
|
||||
color: colors.onSurface,
|
||||
),
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
const SizedBox(height: AppSpacing.xs),
|
||||
Text(
|
||||
item.body,
|
||||
style: textTheme.bodySmall?.copyWith(
|
||||
color: colors.onSurfaceVariant,
|
||||
),
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
const SizedBox(height: AppSpacing.xs),
|
||||
Text(
|
||||
_formatTime(item.createdAt),
|
||||
style: textTheme.labelSmall?.copyWith(
|
||||
color: colors.outline,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
item.title,
|
||||
style: textTheme.bodyMedium?.copyWith(
|
||||
fontWeight: item.isRead
|
||||
? FontWeight.normal
|
||||
: FontWeight.w600,
|
||||
color: colors.onSurface,
|
||||
),
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
const SizedBox(height: AppSpacing.xs),
|
||||
Text(
|
||||
item.body,
|
||||
style: textTheme.bodySmall?.copyWith(
|
||||
color: colors.onSurfaceVariant,
|
||||
),
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
const SizedBox(height: AppSpacing.xs),
|
||||
Text(
|
||||
_formatTime(item.createdAt),
|
||||
style: textTheme.labelSmall?.copyWith(
|
||||
color: colors.outline,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import 'package:dio/dio.dart';
|
||||
|
||||
import '../../../../core/logging/logger.dart';
|
||||
import '../../../../core/network/api_problem.dart';
|
||||
import '../../../../data/network/api_client.dart';
|
||||
import '../models/my_invite_code.dart';
|
||||
|
||||
class InviteApi {
|
||||
InviteApi({required ApiClient apiClient}) : _apiClient = apiClient;
|
||||
|
||||
final ApiClient _apiClient;
|
||||
final Logger _logger = getLogger('features.settings.data.apis');
|
||||
|
||||
Future<MyInviteCode> getMyInviteCode() async {
|
||||
try {
|
||||
final json = await _apiClient.getJson('/api/v1/invite/me');
|
||||
return MyInviteCode(
|
||||
code: json['code'] as String,
|
||||
usedCount: json['used_count'] as int,
|
||||
);
|
||||
} on DioException catch (error, stackTrace) {
|
||||
_logger.error(
|
||||
message: 'Get my invite code failed',
|
||||
error: error,
|
||||
stackTrace: stackTrace,
|
||||
);
|
||||
throw _mapProblem(error);
|
||||
}
|
||||
}
|
||||
|
||||
ApiProblem _mapProblem(DioException error) {
|
||||
final status = error.response?.statusCode ?? 500;
|
||||
final data = error.response?.data;
|
||||
|
||||
if (data is Map<String, dynamic>) {
|
||||
return ApiProblem(
|
||||
status: status,
|
||||
title: (data['title'] as String?) ?? 'Request failed',
|
||||
detail: (data['detail'] as String?) ?? '',
|
||||
code: data['code'] as String?,
|
||||
);
|
||||
}
|
||||
|
||||
return ApiProblem(
|
||||
status: status,
|
||||
title: 'Network error',
|
||||
detail: error.message ?? 'Request failed',
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
class MyInviteCode {
|
||||
const MyInviteCode({required this.code, required this.usedCount});
|
||||
|
||||
final String code;
|
||||
final int usedCount;
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import '../apis/invite_api.dart';
|
||||
import '../models/my_invite_code.dart';
|
||||
|
||||
abstract class InviteRepository {
|
||||
Future<MyInviteCode> getMyInviteCode();
|
||||
}
|
||||
|
||||
class InviteRepositoryImpl implements InviteRepository {
|
||||
InviteRepositoryImpl({required InviteApi inviteApi}) : _inviteApi = inviteApi;
|
||||
|
||||
final InviteApi _inviteApi;
|
||||
|
||||
@override
|
||||
Future<MyInviteCode> getMyInviteCode() {
|
||||
return _inviteApi.getMyInviteCode();
|
||||
}
|
||||
}
|
||||
@@ -208,6 +208,24 @@ class _DeleteConfirmDialogState extends State<_DeleteConfirmDialog> {
|
||||
height: 1.45,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: AppSpacing.sm),
|
||||
Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(AppSpacing.md),
|
||||
decoration: BoxDecoration(
|
||||
color: colors.errorContainer,
|
||||
borderRadius: BorderRadius.circular(AppRadius.md),
|
||||
border: Border.all(color: colors.error.withValues(alpha: 0.35)),
|
||||
),
|
||||
child: Text(
|
||||
l10n.settingsDeleteAccountReRegisterNotice,
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: colors.onErrorContainer,
|
||||
fontWeight: FontWeight.w700,
|
||||
height: 1.35,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: AppSpacing.md),
|
||||
Text(
|
||||
_secondsLeft > 0
|
||||
|
||||
@@ -1,44 +1,125 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
import '../../../../core/logging/logger.dart';
|
||||
import '../../../../l10n/app_localizations.dart';
|
||||
import '../../../../shared/theme/app_color_palette.dart';
|
||||
import '../../../../shared/theme/design_tokens.dart';
|
||||
import '../../../../shared/widgets/toast/toast.dart';
|
||||
import '../../../../shared/widgets/toast/toast_type.dart';
|
||||
import '../../data/repositories/invite_repository.dart';
|
||||
|
||||
class InviteScreen extends StatefulWidget {
|
||||
const InviteScreen({super.key});
|
||||
const InviteScreen({super.key, required this.inviteRepository});
|
||||
|
||||
final InviteRepository inviteRepository;
|
||||
|
||||
@override
|
||||
State<InviteScreen> createState() => _InviteScreenState();
|
||||
}
|
||||
|
||||
class _InviteScreenState extends State<InviteScreen> {
|
||||
final Logger _logger = getLogger('features.settings.invite_screen');
|
||||
final _bindCodeController = TextEditingController();
|
||||
final _formKey = GlobalKey<FormState>();
|
||||
|
||||
bool _isBinding = false;
|
||||
bool _isGenerating = false;
|
||||
bool _isLoading = true;
|
||||
bool _hasError = false;
|
||||
|
||||
// Mock data - will be replaced with API calls
|
||||
final String _myInviteCode = 'ABC123';
|
||||
final int _invitedCount = 3;
|
||||
String? _myInviteCode;
|
||||
int _invitedCount = 0;
|
||||
final bool _hasInviter = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadInviteCode();
|
||||
}
|
||||
|
||||
Future<void> _loadInviteCode() async {
|
||||
setState(() {
|
||||
_isLoading = true;
|
||||
_hasError = false;
|
||||
});
|
||||
try {
|
||||
final result = await widget.inviteRepository.getMyInviteCode();
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_myInviteCode = result.code;
|
||||
_invitedCount = result.usedCount;
|
||||
_isLoading = false;
|
||||
});
|
||||
} catch (error, stackTrace) {
|
||||
_logger.error(
|
||||
message: 'Failed to load invite code',
|
||||
error: error,
|
||||
stackTrace: stackTrace,
|
||||
);
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_hasError = true;
|
||||
_isLoading = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_bindCodeController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
bool get _hasMyInviteCode => _myInviteCode.isNotEmpty;
|
||||
bool get _hasMyInviteCode =>
|
||||
_myInviteCode != null && _myInviteCode!.isNotEmpty;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
final colors = Theme.of(context).colorScheme;
|
||||
|
||||
if (_isLoading) {
|
||||
return Scaffold(
|
||||
backgroundColor: colors.surfaceContainerLow,
|
||||
appBar: AppBar(
|
||||
title: Text(l10n.settingsInviteTitle),
|
||||
centerTitle: true,
|
||||
backgroundColor: colors.surfaceContainerLow,
|
||||
surfaceTintColor: colors.surfaceContainerLow,
|
||||
),
|
||||
body: const Center(child: CircularProgressIndicator()),
|
||||
);
|
||||
}
|
||||
|
||||
if (_hasError) {
|
||||
return Scaffold(
|
||||
backgroundColor: colors.surfaceContainerLow,
|
||||
appBar: AppBar(
|
||||
title: Text(l10n.settingsInviteTitle),
|
||||
centerTitle: true,
|
||||
backgroundColor: colors.surfaceContainerLow,
|
||||
surfaceTintColor: colors.surfaceContainerLow,
|
||||
),
|
||||
body: Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Text(
|
||||
l10n.settingsInviteEmptyTitle,
|
||||
style: Theme.of(context).textTheme.bodyLarge,
|
||||
),
|
||||
const SizedBox(height: AppSpacing.md),
|
||||
FilledButton(
|
||||
onPressed: _loadInviteCode,
|
||||
child: const Text('Retry'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: colors.surfaceContainerLow,
|
||||
appBar: AppBar(
|
||||
@@ -51,7 +132,10 @@ class _InviteScreenState extends State<InviteScreen> {
|
||||
padding: const EdgeInsets.all(AppSpacing.lg),
|
||||
children: [
|
||||
if (_hasMyInviteCode) ...[
|
||||
_InviteCodeCard(inviteCode: _myInviteCode, onCopy: _copyInviteCode),
|
||||
_InviteCodeCard(
|
||||
inviteCode: _myInviteCode!,
|
||||
onCopy: _copyInviteCode,
|
||||
),
|
||||
const SizedBox(height: AppSpacing.lg),
|
||||
_InviteStatsCard(count: _invitedCount),
|
||||
const SizedBox(height: AppSpacing.xl),
|
||||
@@ -79,7 +163,7 @@ class _InviteScreenState extends State<InviteScreen> {
|
||||
|
||||
void _copyInviteCode() {
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
Clipboard.setData(ClipboardData(text: _myInviteCode));
|
||||
Clipboard.setData(ClipboardData(text: _myInviteCode!));
|
||||
Toast.show(
|
||||
context,
|
||||
l10n.settingsInviteCopySuccess,
|
||||
|
||||
@@ -5,6 +5,7 @@ import '../../../../shared/theme/design_tokens.dart';
|
||||
import '../../../../shared/widgets/app_modal_dialog.dart';
|
||||
import '../../../../shared/widgets/gua_icon.dart';
|
||||
import '../../data/models/profile_settings.dart';
|
||||
import '../../data/repositories/invite_repository.dart';
|
||||
import 'account_delete_screen.dart';
|
||||
import '../widgets/settings_section_widgets.dart';
|
||||
import 'coin_center_screen.dart';
|
||||
@@ -19,6 +20,7 @@ class SettingsScreen extends StatefulWidget {
|
||||
required this.account,
|
||||
required this.settings,
|
||||
required this.coinBalance,
|
||||
required this.inviteRepository,
|
||||
required this.onInterfaceLanguageChanged,
|
||||
required this.onSettingsChanged,
|
||||
required this.onUploadAvatar,
|
||||
@@ -30,6 +32,7 @@ class SettingsScreen extends StatefulWidget {
|
||||
final String account;
|
||||
final ProfileSettingsV1 settings;
|
||||
final int coinBalance;
|
||||
final InviteRepository inviteRepository;
|
||||
final Future<void> Function(String languageTag) onInterfaceLanguageChanged;
|
||||
final Future<void> Function(ProfileSettingsV1 settings) onSettingsChanged;
|
||||
final Future<ProfileSettingsV1> Function(String filePath) onUploadAvatar;
|
||||
@@ -179,9 +182,11 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
}
|
||||
|
||||
Future<void> _openInvite() async {
|
||||
await Navigator.of(
|
||||
context,
|
||||
).push<void>(MaterialPageRoute<void>(builder: (_) => const InviteScreen()));
|
||||
await Navigator.of(context).push<void>(
|
||||
MaterialPageRoute<void>(
|
||||
builder: (_) => InviteScreen(inviteRepository: widget.inviteRepository),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _openProfileEdit() async {
|
||||
|
||||
@@ -146,6 +146,7 @@
|
||||
"settingsDeleteAccountSubtitle": "Permanently delete your account and personal data",
|
||||
"settingsDeleteAccountWarningTitle": "Please confirm before deleting",
|
||||
"settingsDeleteAccountWarningBody": "After deletion, related data including profile, history, and points will be permanently removed and cannot be restored.",
|
||||
"settingsDeleteAccountReRegisterNotice": "Important: if you delete and re-register with the same email, consumed points will not be reset or refunded.",
|
||||
"settingsDeleteAccountScopeProfile": "Profile and account information will be deleted",
|
||||
"settingsDeleteAccountScopeHistory": "Divination history records will be deleted",
|
||||
"settingsDeleteAccountScopePoints": "Points account and ledger records will be deleted",
|
||||
@@ -295,7 +296,7 @@
|
||||
"questionTypeSearch": "Search",
|
||||
"questionTypeOther": "Other",
|
||||
"toastPleaseInputQuestion": "Please enter your question",
|
||||
"toastCoinInsufficient": "Insufficient coins",
|
||||
"toastCoinInsufficient": "Insufficient points",
|
||||
"divinationCostDialogTitle": "Confirm divination",
|
||||
"divinationCostDialogBody": "This run costs {cost} credits. Available balance: {balance} credits. Continue?",
|
||||
"@divinationCostDialogBody": {
|
||||
|
||||
@@ -758,6 +758,12 @@ abstract class AppLocalizations {
|
||||
/// **'删除账号后,个人资料、历史记录、点数信息等相关数据将被永久删除,且不可恢复。'**
|
||||
String get settingsDeleteAccountWarningBody;
|
||||
|
||||
/// No description provided for @settingsDeleteAccountReRegisterNotice.
|
||||
///
|
||||
/// In zh, this message translates to:
|
||||
/// **'重要提示:同一邮箱删除后重新注册,已消耗积分不会重置或返还。'**
|
||||
String get settingsDeleteAccountReRegisterNotice;
|
||||
|
||||
/// No description provided for @settingsDeleteAccountScopeProfile.
|
||||
///
|
||||
/// In zh, this message translates to:
|
||||
@@ -1487,7 +1493,7 @@ abstract class AppLocalizations {
|
||||
/// No description provided for @toastCoinInsufficient.
|
||||
///
|
||||
/// In zh, this message translates to:
|
||||
/// **'铜钱不足,无法解卦'**
|
||||
/// **'积分不足,无法解卦'**
|
||||
String get toastCoinInsufficient;
|
||||
|
||||
/// No description provided for @divinationCostDialogTitle.
|
||||
|
||||
@@ -367,6 +367,10 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||
String get settingsDeleteAccountWarningBody =>
|
||||
'After deletion, related data including profile, history, and points will be permanently removed and cannot be restored.';
|
||||
|
||||
@override
|
||||
String get settingsDeleteAccountReRegisterNotice =>
|
||||
'Important: if you delete and re-register with the same email, consumed points will not be reset or refunded.';
|
||||
|
||||
@override
|
||||
String get settingsDeleteAccountScopeProfile =>
|
||||
'Profile and account information will be deleted';
|
||||
@@ -770,7 +774,7 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||
String get toastPleaseInputQuestion => 'Please enter your question';
|
||||
|
||||
@override
|
||||
String get toastCoinInsufficient => 'Insufficient coins';
|
||||
String get toastCoinInsufficient => 'Insufficient points';
|
||||
|
||||
@override
|
||||
String get divinationCostDialogTitle => 'Confirm divination';
|
||||
|
||||
@@ -359,6 +359,10 @@ class AppLocalizationsZh extends AppLocalizations {
|
||||
String get settingsDeleteAccountWarningBody =>
|
||||
'删除账号后,个人资料、历史记录、点数信息等相关数据将被永久删除,且不可恢复。';
|
||||
|
||||
@override
|
||||
String get settingsDeleteAccountReRegisterNotice =>
|
||||
'重要提示:同一邮箱删除后重新注册,已消耗积分不会重置或返还。';
|
||||
|
||||
@override
|
||||
String get settingsDeleteAccountScopeProfile => '个人资料和账号信息会被删除';
|
||||
|
||||
@@ -737,7 +741,7 @@ class AppLocalizationsZh extends AppLocalizations {
|
||||
String get toastPleaseInputQuestion => '请输入您想占卜的问题';
|
||||
|
||||
@override
|
||||
String get toastCoinInsufficient => '铜钱不足,无法解卦';
|
||||
String get toastCoinInsufficient => '积分不足,无法解卦';
|
||||
|
||||
@override
|
||||
String get divinationCostDialogTitle => '确认开始解卦';
|
||||
|
||||
@@ -146,6 +146,7 @@
|
||||
"settingsDeleteAccountSubtitle": "永久删除账号及相关个人数据",
|
||||
"settingsDeleteAccountWarningTitle": "删除前请确认",
|
||||
"settingsDeleteAccountWarningBody": "删除账号后,个人资料、历史记录、点数信息等相关数据将被永久删除,且不可恢复。",
|
||||
"settingsDeleteAccountReRegisterNotice": "重要提示:同一邮箱删除后重新注册,已消耗积分不会重置或返还。",
|
||||
"settingsDeleteAccountScopeProfile": "个人资料和账号信息会被删除",
|
||||
"settingsDeleteAccountScopeHistory": "历史解卦记录会被删除",
|
||||
"settingsDeleteAccountScopePoints": "点数账户与流水记录会被删除",
|
||||
@@ -295,7 +296,7 @@
|
||||
"questionTypeSearch": "寻物",
|
||||
"questionTypeOther": "其他",
|
||||
"toastPleaseInputQuestion": "请输入您想占卜的问题",
|
||||
"toastCoinInsufficient": "铜钱不足,无法解卦",
|
||||
"toastCoinInsufficient": "积分不足,无法解卦",
|
||||
"divinationCostDialogTitle": "确认开始解卦",
|
||||
"divinationCostDialogBody": "本次解卦将消耗 {cost} 点数,当前可用 {balance} 点数。是否继续?",
|
||||
"@divinationCostDialogBody": {
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../../features/notifications/data/models/notification_item.dart';
|
||||
import '../../theme/design_tokens.dart';
|
||||
|
||||
class NotificationDetailBottomSheet extends StatefulWidget {
|
||||
const NotificationDetailBottomSheet({
|
||||
super.key,
|
||||
required this.item,
|
||||
required this.onMarkRead,
|
||||
});
|
||||
|
||||
final NotificationItem item;
|
||||
final Future<void> Function() onMarkRead;
|
||||
|
||||
@override
|
||||
State<NotificationDetailBottomSheet> createState() =>
|
||||
_NotificationDetailBottomSheetState();
|
||||
}
|
||||
|
||||
class _NotificationDetailBottomSheetState
|
||||
extends State<NotificationDetailBottomSheet> {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
widget.onMarkRead();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colors = Theme.of(context).colorScheme;
|
||||
final textTheme = Theme.of(context).textTheme;
|
||||
|
||||
return Container(
|
||||
height: MediaQuery.of(context).size.height * 0.5,
|
||||
decoration: BoxDecoration(
|
||||
color: colors.surface,
|
||||
borderRadius: const BorderRadius.vertical(
|
||||
top: Radius.circular(AppRadius.lg),
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Center(
|
||||
child: Container(
|
||||
margin: const EdgeInsets.only(top: AppSpacing.sm),
|
||||
width: 40,
|
||||
height: 4,
|
||||
decoration: BoxDecoration(
|
||||
color: colors.outlineVariant,
|
||||
borderRadius: BorderRadius.circular(2),
|
||||
),
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(AppSpacing.lg),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
widget.item.title,
|
||||
style: textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
color: colors.onSurface,
|
||||
),
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
icon: Icon(Icons.close, color: colors.onSurfaceVariant),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: AppSpacing.lg),
|
||||
child: Text(
|
||||
_formatTime(widget.item.createdAt),
|
||||
style: textTheme.labelSmall?.copyWith(color: colors.outline),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: AppSpacing.md),
|
||||
Expanded(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.symmetric(horizontal: AppSpacing.lg),
|
||||
child: Text(
|
||||
widget.item.body,
|
||||
style: textTheme.bodyMedium?.copyWith(
|
||||
color: colors.onSurfaceVariant,
|
||||
height: 1.6,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
String _formatTime(DateTime dt) {
|
||||
final now = DateTime.now();
|
||||
final diff = now.difference(dt);
|
||||
if (diff.inMinutes < 1) return '刚刚';
|
||||
if (diff.inHours < 1) return '${diff.inMinutes}分钟前';
|
||||
if (diff.inDays < 1) return '${diff.inHours}小时前';
|
||||
if (diff.inDays < 30) return '${diff.inDays}天前';
|
||||
return '${dt.month}/${dt.day}';
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> showNotificationDetailBottomSheet({
|
||||
required BuildContext context,
|
||||
required NotificationItem item,
|
||||
required Future<void> Function() onMarkRead,
|
||||
}) {
|
||||
return showModalBottomSheet<void>(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
backgroundColor: Colors.transparent,
|
||||
builder: (context) =>
|
||||
NotificationDetailBottomSheet(item: item, onMarkRead: onMarkRead),
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user