feat(account): 增加账号中心页面与入口

This commit is contained in:
Zhou Weidong committed 2026-10-19 09:50:23 +08:00
1 parent c68f02449f
commit da095115d4
23 files changed
+2343 -60

No files matched your search

+8 -21
View File
@@ -13,9 +13,8 @@ void main() {
String envelope(Object? data, {int code = 0, String message = 'ok'}) =>
jsonEncode({'code': code, 'message': message, 'data': data});
AccountApi apiWith(FakeHttpAdapter adapter) => AccountApi(
ApiClient(baseUrl: 'http://127.0.0.1:8081', adapter: adapter),
);
AccountApi apiWith(FakeHttpAdapter adapter) =>
AccountApi(ApiClient(baseUrl: 'http://127.0.0.1:8081', adapter: adapter));
test('should_get_profile_and_parse_numeric_vip_level', () async {
final adapter = FakeHttpAdapter(
@@ -90,11 +89,7 @@ void main() {
'status': 'active',
'deleted': 0,
},
{
'id': 7,
'deviceId': 'd-android-5678',
'platform': 'ANDROID',
},
{'id': 7, 'deviceId': 'd-android-5678', 'platform': 'ANDROID'},
]),
),
);
@@ -138,14 +133,10 @@ void main() {
test('should_request_deactivation_with_reason_and_parse_schedule', () async {
final adapter = FakeHttpAdapter(
(_) async => FakeHttpResponse(
envelope('2026-11-03T09:30:00'),
),
(_) async => FakeHttpResponse(envelope('2026-11-03T09:30:00')),
);
final scheduledAt = await apiWith(
adapter,
).deactivate(reason: '不再使用');
final scheduledAt = await apiWith(adapter).deactivate(reason: '不再使用');
expect(adapter.requests.single.method, 'POST');
expect(adapter.requests.single.uri.path, '/app/me/deactivate');
@@ -161,18 +152,14 @@ void main() {
await apiWith(adapter).cancelDeactivation();
expect(adapter.requests.single.method, 'POST');
expect(
adapter.requests.single.uri.path,
'/app/me/deactivate/cancel',
);
expect(adapter.requests.single.uri.path, '/app/me/deactivate/cancel');
expect(adapter.requests.single.data, isNull);
});
test('should_keep_backend_message_when_cancel_fails', () async {
final adapter = FakeHttpAdapter(
(_) async => FakeHttpResponse(
envelope(null, code: 100009, message: '没有待处理的注销申请'),
),
(_) async =>
FakeHttpResponse(envelope(null, code: 100009, message: '没有待处理的注销申请')),
);
await expectLater(
+17 -17
View File
@@ -70,28 +70,28 @@ void main() {
expect(copied, contains('n***@example.com'));
});
test('should_clear_session_and_keep_schedule_when_deactivation_succeeds', () async {
final repository = FakeAccountRepository();
var cleared = false;
final logic = AccountDeactivateLogic(
repository,
clearSession: () async => cleared = true,
);
test(
'should_clear_session_and_keep_schedule_when_deactivation_succeeds',
() async {
final repository = FakeAccountRepository();
var cleared = false;
final logic = AccountDeactivateLogic(
repository,
clearSession: () async => cleared = true,
);
final ok = await logic.request('不再使用');
final ok = await logic.request('不再使用');
expect(ok, isTrue);
expect(repository.lastDeactivateReason, '不再使用');
expect(logic.scheduledAt, DateTime(2026, 11, 3, 9, 30));
expect(cleared, isTrue);
});
expect(ok, isTrue);
expect(repository.lastDeactivateReason, '不再使用');
expect(logic.scheduledAt, DateTime(2026, 11, 3, 9, 30));
expect(cleared, isTrue);
},
);
test('should_show_backend_message_when_cancel_deactivation_fails', () async {
final repository = FakeAccountRepository()..failCancel = true;
final logic = AccountDeactivateLogic(
repository,
clearSession: () async {},
);
final logic = AccountDeactivateLogic(repository, clearSession: () async {});
final ok = await logic.cancel();
+147
View File
@@ -0,0 +1,147 @@
// Author: fruit.com.cn
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:fruitshorts/core/data/models/auth_session.dart';
import 'package:fruitshorts/core/data/repositories/account_repository.dart';
import 'package:fruitshorts/core/data/remote/api_client.dart';
import 'package:fruitshorts/core/data/remote/auth_api.dart';
import 'package:fruitshorts/core/data/services/auth_controller.dart';
import 'package:fruitshorts/core/data/services/auth_session_store.dart';
import 'package:fruitshorts/l10n/app_localizations.dart';
import 'package:fruitshorts/pages/account/account_deactivate_page.dart';
import 'package:fruitshorts/pages/account/account_devices_page.dart';
import 'package:fruitshorts/pages/account/account_export_page.dart';
import 'package:fruitshorts/pages/account/account_profile_page.dart';
import 'package:provider/provider.dart';
import 'package:shared_preferences_platform_interface/in_memory_shared_preferences_async.dart';
import 'package:shared_preferences_platform_interface/shared_preferences_async_platform_interface.dart';
import '../support/fake_account_repository.dart';
void main() {
testWidgets('should_fill_profile_and_save_nickname', (tester) async {
final repository = FakeAccountRepository();
final auth = await _signedInAuth();
await _pumpPage(
tester,
AccountProfilePage(),
repository: repository,
auth: auth,
);
final nickname = find.byKey(const Key('account-nickname'));
expect(tester.widget<TextField>(nickname).controller?.text, 'Nadia');
await tester.enterText(nickname, 'Nadia Baru');
await tester.tap(find.byKey(const Key('account-profile-save')));
await tester.pumpAndSettle();
expect(repository.lastUpdate?.nickname, 'Nadia Baru');
});
testWidgets('should_show_device_empty_state_when_list_is_empty', (
tester,
) async {
final repository = FakeAccountRepository();
final auth = await _signedInAuth();
await _pumpPage(
tester,
AccountDevicesPage(),
repository: repository,
auth: auth,
);
expect(find.byKey(const Key('account-devices-empty')), findsOneWidget);
});
testWidgets('should_confirm_before_deactivation_and_show_schedule', (
tester,
) async {
final repository = FakeAccountRepository();
final auth = await _signedInAuth();
await _pumpPage(
tester,
AccountDeactivatePage(),
repository: repository,
auth: auth,
);
await tester.enterText(
find.byKey(const Key('account-deactivate-reason')),
'不再使用',
);
await tester.tap(find.byKey(const Key('account-deactivate-submit')));
await tester.pumpAndSettle();
expect(find.byKey(const Key('account-deactivate-confirm')), findsOneWidget);
await tester.tap(find.byKey(const Key('account-deactivate-confirm')));
await tester.pumpAndSettle();
expect(repository.lastDeactivateReason, '不再使用');
expect(
find.byKey(const Key('account-deactivate-scheduled')),
findsOneWidget,
);
expect(auth.isSignedIn, isFalse);
});
testWidgets('should_retry_export_after_failure', (tester) async {
final repository = FakeAccountRepository()..failExport = true;
final auth = await _signedInAuth();
await _pumpPage(
tester,
AccountExportPage(),
repository: repository,
auth: auth,
);
expect(find.byKey(const Key('account-export-error')), findsOneWidget);
repository.failExport = false;
await tester.tap(find.byKey(const Key('account-export-retry')));
await tester.pumpAndSettle();
expect(find.byKey(const Key('account-export-content')), findsOneWidget);
});
}
Future<AuthController> _signedInAuth() async {
SharedPreferencesAsyncPlatform.instance =
InMemorySharedPreferencesAsync.empty();
await AuthSessionStore().save(
const AuthSession(
accessToken: 'access',
refreshToken: 'refresh',
user: AuthUser(uid: 'U-K19', nickname: 'Nadia'),
),
);
final client = ApiClient(baseUrl: 'http://127.0.0.1:8081');
final auth = AuthController(AuthApi(client), AuthSessionStore());
await auth.load();
return auth;
}
Future<void> _pumpPage(
WidgetTester tester,
Widget page, {
required FakeAccountRepository repository,
required AuthController auth,
}) async {
tester.view.physicalSize = const Size(390, 844);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
await tester.pumpWidget(
MultiProvider(
providers: [
Provider<AccountRepository>.value(value: repository),
ChangeNotifierProvider<AuthController>.value(value: auth),
],
child: MaterialApp(
locale: const Locale('en'),
supportedLocales: AppLocalizations.supportedLocales,
localizationsDelegates: AppLocalizations.localizationsDelegates,
home: Theme(data: ThemeData.dark(), child: page),
),
),
);
await tester.pumpAndSettle();
}
+1 -5
View File
@@ -27,11 +27,7 @@ void main() {
'accessToken': 'access-1',
'refreshToken': 'refresh-1',
'expiresIn': 7200,
'user': {
'uid': 'U-OLD',
'nickname': 'Old',
'avatarUrl': 'old.png',
},
'user': {'uid': 'U-OLD', 'nickname': 'Old', 'avatarUrl': 'old.png'},
},
}),
),
+79
View File
@@ -0,0 +1,79 @@
// Author: fruit.com.cn
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:fruitshorts/app.dart';
import 'package:fruitshorts/core/data/models/auth_session.dart';
import 'package:fruitshorts/core/data/repositories/favorite_repository.dart';
import 'package:fruitshorts/core/data/repositories/playback_repository.dart';
import 'package:fruitshorts/core/data/repositories/watch_history_repository.dart';
import 'package:fruitshorts/core/data/services/auth_session_store.dart';
import 'package:shared_preferences_platform_interface/in_memory_shared_preferences_async.dart';
import 'package:shared_preferences_platform_interface/shared_preferences_async_platform_interface.dart';
import '../support/fake_account_repository.dart';
import '../support/fake_catalog.dart';
import '../support/fake_category.dart';
import '../support/fake_interaction_repositories.dart';
import '../support/fake_notice_repository.dart';
import '../support/widget_player.dart';
void main() {
testWidgets('should_hide_account_entries_when_signed_out', (tester) async {
SharedPreferencesAsyncPlatform.instance =
InMemorySharedPreferencesAsync.empty();
await _pumpApp(tester);
await tester.tap(find.byKey(const Key('tab-my')));
await tester.pumpAndSettle();
expect(find.byKey(const Key('mine-account-card')), findsNothing);
});
testWidgets('should_show_four_account_entries_when_signed_in', (
tester,
) async {
SharedPreferencesAsyncPlatform.instance =
InMemorySharedPreferencesAsync.empty();
await AuthSessionStore().save(
const AuthSession(
accessToken: 'access',
refreshToken: 'refresh',
user: AuthUser(uid: 'U-K19', nickname: 'Nadia'),
),
);
await _pumpApp(tester);
await tester.tap(find.byKey(const Key('tab-my')));
await tester.pumpAndSettle();
expect(find.byKey(const Key('mine-account-card')), findsOneWidget);
expect(find.byKey(const Key('mine-account-profile')), findsOneWidget);
expect(find.byKey(const Key('mine-account-devices')), findsOneWidget);
expect(find.byKey(const Key('mine-account-export')), findsOneWidget);
expect(find.byKey(const Key('mine-account-deactivate')), findsOneWidget);
});
}
Future<void> _pumpApp(WidgetTester tester) async {
tester.view.physicalSize = const Size(390, 844);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
final catalog = FakeCatalogRepository();
await tester.pumpWidget(
LumaDramaApp(
playerFactory: createWidgetPlayer,
catalogRepository: catalog,
categoryRepository: FakeCategoryRepository(catalog: catalog),
watchHistoryRepository: InMemoryWatchHistoryRepository(),
favoriteRepository: InMemoryFavoriteRepository(),
playbackRepository: FakePlaybackRepository(),
commentRepository: FakeCommentRepository(),
interactionRepository: FakeInteractionRepository(),
noticeRepository: FakeNoticeRepository(),
accountRepository: FakeAccountRepository(),
),
);
await tester.pumpAndSettle();
}
+2 -2
View File
@@ -55,8 +55,8 @@ class FakeAccountRepository implements AccountRepository {
);
AccountProfile _profile;
List<AccountDevice> _devices;
AccountExport _export;
final List<AccountDevice> _devices;
final AccountExport _export;
AccountProfileUpdate? lastUpdate;
String? lastDeactivateReason;
int profileCalls = 0;