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:
qzl
2026-04-13 14:52:22 +08:00
parent da947f9f08
commit 1e22f27de2
52 changed files with 1419 additions and 307 deletions
+20
View File
@@ -45,6 +45,26 @@ This file governs `apps/**` (Flutter). Keep rules strict, short, and reusable.
- `AppTheme.light` / `AppTheme.dark` provide complete `ColorScheme` (light + dark). `MaterialApp` wires them via `theme:` / `darkTheme:`.
- If a semantic slot is missing from `ColorScheme`, add it to `AppTheme` — do not bypass `colorScheme` with hardcoded values.
### Page Header (Must)
All sub-pages (sub-page = any page that is not a home Tab page) `AppBar` must follow:
- **`centerTitle: true`** — title must be horizontally centered; never left-aligned.
- **`backgroundColor`** and **`surfaceTintColor`** should match the page background to avoid visual seams.
- Example:
```dart
appBar: AppBar(
title: Text('Notifications'),
centerTitle: true,
backgroundColor: colors.surfaceContainerLow,
surfaceTintColor: colors.surfaceContainerLow,
actions: [...],
),
```
- When a repeated pattern emerges, extract a reusable component into `shared/widgets/` instead of building `AppBar` independently in each page.
## Divination Terminology (Must)
- Divination domain terminology must use fixed Chinese terms in code contracts, protocol fields, and UI semantic labels.
+16 -1
View File
@@ -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();
}
}
@@ -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 {
+2 -1
View File
@@ -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": {
+7 -1
View File
@@ -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.
+5 -1
View File
@@ -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';
+5 -1
View File
@@ -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 => '确认开始解卦';
+2 -1
View File
@@ -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),
);
}
@@ -10,6 +10,8 @@ class _FakeNotificationRepository implements NotificationRepository {
final List<NotificationItem> items = [];
int unreadCount = 0;
int markAllReadCallCount = 0;
bool failMarkRead = false;
bool failMarkAllRead = false;
@override
Future<NotificationListResult> listNotifications({
@@ -28,6 +30,9 @@ class _FakeNotificationRepository implements NotificationRepository {
@override
Future<NotificationItem> markRead({required String notificationId}) async {
if (failMarkRead) {
throw Exception('Mark read failed');
}
final idx = items.indexWhere((i) => i.id == notificationId);
if (idx == -1) {
throw Exception('Not found');
@@ -39,6 +44,9 @@ class _FakeNotificationRepository implements NotificationRepository {
@override
Future<int> markAllRead() async {
if (failMarkAllRead) {
throw Exception('Mark all read failed');
}
markAllReadCallCount++;
final count = unreadCount;
for (int i = 0; i < items.length; i++) {
@@ -99,6 +107,21 @@ void main() {
expect(bloc.state.unreadCount, 0);
});
test(
'MarkNotificationRead does not update state when request fails',
() async {
fakeRepo.items.add(makeItem(id: 'n1', isRead: false));
fakeRepo.unreadCount = 1;
fakeRepo.failMarkRead = true;
await bloc.handleEvent(LoadNotifications());
await bloc.handleEvent(RefreshUnreadCount());
await bloc.handleEvent(MarkNotificationRead(notificationId: 'n1'));
expect(bloc.state.items.first.isRead, false);
expect(bloc.state.unreadCount, 1);
},
);
test('MarkAllNotificationsRead marks all as read', () async {
fakeRepo.items.addAll([
makeItem(id: 'n1', isRead: false),
@@ -112,6 +135,24 @@ void main() {
expect(bloc.state.items.every((i) => i.isRead), true);
});
test(
'MarkAllNotificationsRead does not update state when request fails',
() async {
fakeRepo.items.addAll([
makeItem(id: 'n1', isRead: false),
makeItem(id: 'n2', isRead: false),
]);
fakeRepo.unreadCount = 2;
fakeRepo.failMarkAllRead = true;
await bloc.handleEvent(LoadNotifications());
await bloc.handleEvent(RefreshUnreadCount());
await bloc.handleEvent(MarkAllNotificationsRead());
expect(bloc.state.unreadCount, 2);
expect(bloc.state.items.every((i) => !i.isRead), true);
},
);
test(
'NotificationCreatedEvent adds item and increments unreadCount',
() async {
-43
View File
@@ -1,43 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "$0")/.." && pwd)"
BACKEND_URL=""
DEVICE_ARGS=()
usage() {
cat <<EOF
Usage:
$0 [--backend-url http://host:port] [flutter run args...]
Examples:
$0
$0 --backend-url http://192.168.1.100:5775
$0 --backend-url http://10.0.2.2:5775 -d emulator-5554
EOF
}
while [[ $# -gt 0 ]]; do
case "$1" in
--backend-url)
BACKEND_URL="$2"
shift 2
;;
-h|--help)
usage
exit 0
;;
*)
DEVICE_ARGS+=("$1")
shift
;;
esac
done
cd "$ROOT_DIR"
if [[ -n "$BACKEND_URL" ]]; then
flutter run --dart-define="BACKEND_URL=$BACKEND_URL" "${DEVICE_ARGS[@]}"
else
flutter run "${DEVICE_ARGS[@]}"
fi