feat(reward): 新增客户端奖励中心

This commit is contained in:
Zhou Weidong committed 2026-11-13 13:43:38 +08:00
1 parent 67670d46b1
commit bea08110c9
68 files changed
+3987 -332

No files matched your search

+73
View File
@@ -0,0 +1,73 @@
// Author: fruit.com.cn
import 'package:flutter_test/flutter_test.dart';
import 'package:fruitshorts/core/data/models/reward.dart';
import 'package:fruitshorts/core/data/repositories/reward_repository.dart';
import 'package:fruitshorts/core/data/services/reward_controller.dart';
void main() {
test('should_refresh_balance_when_check_in_succeeds', () async {
final repository = _RewardRepository();
final controller = RewardController(repository);
await controller.load('id');
final result = await controller.checkIn();
expect(result?.rewardCoin, 35);
expect(controller.overview?.coinBalance, 35);
expect(repository.overviewCalls, 2);
});
test('should_mark_claim_in_flight_when_task_is_claimed', () async {
final repository = _RewardRepository();
final controller = RewardController(repository);
final future = controller.claim('task-1');
expect(controller.claimingTaskId, 'task-1');
await future;
expect(controller.claimingTaskId, isNull);
expect(repository.claimedTaskId, 'task-1');
});
}
class _RewardRepository implements RewardRepository {
int overviewCalls = 0;
String? claimedTaskId;
@override
Future<RewardOverview> fetchOverview(String locale) async {
overviewCalls += 1;
return RewardOverview(
serverDate: '2026-11-05',
timezone: 'Asia/Jakarta',
coinBalance: overviewCalls > 1 ? 35 : 0,
checkin: const RewardCheckinState(
taskId: '1',
checkedInToday: false,
streak: 0,
currentDay: 1,
),
);
}
@override
Future<RewardActionResult> checkIn() async {
return const RewardActionResult(
rewardCoin: 35,
coinBalance: 35,
alreadyCompleted: false,
);
}
@override
Future<RewardActionResult> claim(String taskId) async {
claimedTaskId = taskId;
await Future<void>.delayed(Duration.zero);
return const RewardActionResult(
rewardCoin: 20,
coinBalance: 55,
alreadyCompleted: false,
);
}
}
+42
View File
@@ -0,0 +1,42 @@
// Author: fruit.com.cn
import 'package:flutter_test/flutter_test.dart';
import 'package:fruitshorts/core/data/models/reward.dart';
void main() {
test('should_parse_string_numbers_when_reward_overview_is_received', () {
final overview = RewardOverview.fromJson({
'serverDate': '2026-11-05',
'timezone': 'Asia/Jakarta',
'coinBalance': '135',
'checkin': {
'taskId': '9',
'checkedInToday': true,
'streak': 2,
'currentDay': 3,
'days': [
{'dayIndex': 1, 'rewardCoin': '35', 'checked': true},
],
},
'sections': [
{
'code': 'DAILY',
'title': 'Tugas harian',
'tasks': [
{
'id': '18',
'targetValue': '300',
'progressValue': '120',
'rewardCoin': '20',
'status': 'IN_PROGRESS',
},
],
},
],
});
expect(overview.coinBalance, 135);
expect(overview.checkin.days.single.rewardCoin, 35);
expect(overview.sections.single.tasks.single.progress, 0.4);
});
}
+413
View File
@@ -0,0 +1,413 @@
// Author: fruit.com.cn
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.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 'package:fruitshorts/core/data/models/reward.dart';
import 'package:fruitshorts/core/data/remote/api_client.dart';
import 'package:fruitshorts/core/data/remote/auth_api.dart';
import 'package:fruitshorts/core/data/repositories/reward_repository.dart';
import 'package:fruitshorts/core/data/services/auth_controller.dart';
import 'package:fruitshorts/core/data/services/auth_session_store.dart';
import 'package:fruitshorts/core/data/services/reward_controller.dart';
import 'package:fruitshorts/l10n/app_localizations.dart';
import 'package:fruitshorts/pages/reward/reward_page.dart';
void main() {
testWidgets('should_render_tasks_and_switch_to_activities_when_signed_in', (
tester,
) async {
SharedPreferencesAsyncPlatform.instance =
InMemorySharedPreferencesAsync.empty();
final auth = _TestAuthController(signedIn: true);
final rewards = RewardController(_RewardPageRepository());
await tester.pumpWidget(
MultiProvider(
providers: [
ChangeNotifierProvider<AuthController>.value(value: auth),
ChangeNotifierProvider<RewardController>.value(value: rewards),
],
child: MaterialApp(
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: const Scaffold(body: RewardPage(active: true)),
),
),
);
await tester.pump(const Duration(milliseconds: 2));
await tester.pumpAndSettle();
expect(find.text('Reward Center'), findsOneWidget);
expect(find.text('My coins'), findsOneWidget);
expect(find.text('Daily task'), findsOneWidget);
expect(find.text('Check in +10 coins'), findsOneWidget);
expect(find.byKey(const Key('reward-back-button')), findsOneWidget);
expect(find.byKey(const Key('reward-help-button')), findsOneWidget);
expect(find.byKey(const Key('reward-balance')), findsOneWidget);
expect(find.byKey(const Key('reward-checkin-card')), findsOneWidget);
expect(find.byKey(const Key('reward-checkin-action')), findsOneWidget);
expect(find.byKey(const Key('reward-section-DAILY')), findsOneWidget);
expect(find.byKey(const Key('reward-task-card-DAILY')), findsOneWidget);
await tester.tap(find.byKey(const Key('reward-tab-activities')));
await tester.pumpAndSettle();
expect(find.text('Spring event'), findsOneWidget);
expect(find.text('Daily task'), findsNothing);
await tester.tap(find.byKey(const Key('reward-tab-tasks')));
await tester.pumpAndSettle();
expect(find.text('Daily task'), findsOneWidget);
expect(find.text('Spring event'), findsNothing);
expect(
tester.getSize(find.byKey(const Key('reward-tab-indicator-tasks'))).width,
24,
);
auth.dispose();
rewards.dispose();
});
testWidgets('should_show_reward_content_and_gate_check_in_when_signed_out', (
tester,
) async {
SharedPreferencesAsyncPlatform.instance =
InMemorySharedPreferencesAsync.empty();
final auth = _TestAuthController(signedIn: false);
final repository = _RewardPageRepository();
final rewards = RewardController(repository);
await _pumpRewardPage(tester, auth: auth, rewards: rewards);
expect(find.byKey(const Key('auth-gate-panel')), findsNothing);
expect(find.text('Daily task'), findsOneWidget);
expect(find.text('Check in +10 coins'), findsOneWidget);
expect(repository.overviewCalls, 1);
await tester.tap(find.text('Check in +10 coins'));
await tester.pumpAndSettle();
expect(find.text('Sign in to continue'), findsOneWidget);
expect(repository.checkInCalls, 0);
auth.dispose();
rewards.dispose();
});
testWidgets('should_use_reward_dialog_style_when_help_is_opened', (
tester,
) async {
SharedPreferencesAsyncPlatform.instance =
InMemorySharedPreferencesAsync.empty();
final auth = _TestAuthController(signedIn: false);
final rewards = RewardController(_RewardPageRepository());
await _pumpRewardPage(tester, auth: auth, rewards: rewards);
await tester.tap(find.byKey(const Key('reward-help-button')));
await tester.pumpAndSettle();
expect(find.byKey(const Key('reward-help-dialog')), findsOneWidget);
expect(find.text('About rewards'), findsOneWidget);
expect(
find.text(
'Complete check-ins and tasks to earn reward coins. Rewards and task rules are shown on this page.',
),
findsOneWidget,
);
expect(find.byKey(const Key('reward-help-close')), findsOneWidget);
expect(find.byType(AlertDialog), findsNothing);
await tester.tap(find.byKey(const Key('reward-help-close')));
await tester.pumpAndSettle();
expect(find.byKey(const Key('reward-help-dialog')), findsNothing);
auth.dispose();
rewards.dispose();
});
testWidgets(
'should_show_retry_without_reloading_forever_when_overview_fails',
(tester) async {
SharedPreferencesAsyncPlatform.instance =
InMemorySharedPreferencesAsync.empty();
final auth = _TestAuthController(signedIn: false);
final repository = _FailingRewardPageRepository();
final rewards = RewardController(repository);
await tester.pumpWidget(
MultiProvider(
providers: [
ChangeNotifierProvider<AuthController>.value(value: auth),
ChangeNotifierProvider<RewardController>.value(value: rewards),
],
child: MaterialApp(
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: const Scaffold(body: RewardPage(active: true)),
),
),
);
await tester.pump(const Duration(milliseconds: 2));
await tester.pump(const Duration(milliseconds: 2));
expect(repository.overviewCalls, 1);
expect(find.byType(CircularProgressIndicator), findsNothing);
expect(find.byType(FilledButton), findsOneWidget);
await tester.tap(find.byType(FilledButton));
await tester.pump(const Duration(milliseconds: 2));
expect(repository.overviewCalls, 2);
expect(find.byType(CircularProgressIndicator), findsNothing);
auth.dispose();
rewards.dispose();
},
);
testWidgets('should_reload_overview_when_auth_state_changes', (tester) async {
SharedPreferencesAsyncPlatform.instance =
InMemorySharedPreferencesAsync.empty();
final auth = _TestAuthController(signedIn: false);
final repository = _RewardPageRepository();
final rewards = RewardController(repository);
await _pumpRewardPage(tester, auth: auth, rewards: rewards);
expect(repository.overviewCalls, 1);
auth.updateSignedIn(true);
await tester.pump(const Duration(milliseconds: 2));
await tester.pumpAndSettle();
expect(repository.overviewCalls, 2);
auth.dispose();
rewards.dispose();
});
testWidgets('should_place_header_actions_at_edges_when_width_is_compact', (
tester,
) async {
SharedPreferencesAsyncPlatform.instance =
InMemorySharedPreferencesAsync.empty();
await tester.binding.setSurfaceSize(const Size(393, 852));
addTearDown(() => tester.binding.setSurfaceSize(null));
final auth = _TestAuthController(signedIn: false);
final rewards = RewardController(_RewardPageRepository());
await _pumpRewardPage(tester, auth: auth, rewards: rewards);
final back = tester.getCenter(find.byKey(const Key('reward-back-button')));
final help = tester.getCenter(find.byKey(const Key('reward-help-button')));
expect(back.dx, lessThan(40));
expect(help.dx, greaterThan(350));
expect(find.text('Hidden check-in task'), findsNothing);
expect(tester.takeException(), isNull);
auth.dispose();
rewards.dispose();
});
}
Future<void> _pumpRewardPage(
WidgetTester tester, {
required AuthController auth,
required RewardController rewards,
}) async {
await tester.pumpWidget(
MultiProvider(
providers: [
ChangeNotifierProvider<AuthController>.value(value: auth),
ChangeNotifierProvider<RewardController>.value(value: rewards),
],
child: MaterialApp(
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: const Scaffold(body: RewardPage(active: true)),
),
),
);
await tester.pump(const Duration(milliseconds: 2));
await tester.pumpAndSettle();
}
class _TestAuthController extends AuthController {
_TestAuthController({required this.signedIn})
: super(
AuthApi(ApiClient(baseUrl: 'http://127.0.0.1:8081')),
AuthSessionStore(),
);
bool signedIn;
@override
bool get isSignedIn => signedIn;
void updateSignedIn(bool value) {
signedIn = value;
notifyListeners();
}
}
class _RewardPageRepository implements RewardRepository {
int overviewCalls = 0;
int checkInCalls = 0;
@override
Future<RewardOverview> fetchOverview(String locale) async {
overviewCalls += 1;
return const RewardOverview(
serverDate: '2026-11-05',
timezone: 'Asia/Jakarta',
coinBalance: 120,
checkin: RewardCheckinState(
taskId: 'checkin',
checkedInToday: false,
streak: 2,
currentDay: 3,
days: [
RewardCheckinDay(
dayIndex: 1,
rewardCoin: 10,
checked: false,
current: true,
),
RewardCheckinDay(
dayIndex: 2,
rewardCoin: 10,
checked: false,
current: false,
),
RewardCheckinDay(
dayIndex: 3,
rewardCoin: 10,
checked: false,
current: false,
),
RewardCheckinDay(
dayIndex: 4,
rewardCoin: 10,
checked: false,
current: false,
),
RewardCheckinDay(
dayIndex: 5,
rewardCoin: 10,
checked: false,
current: false,
),
RewardCheckinDay(
dayIndex: 6,
rewardCoin: 10,
checked: false,
current: false,
),
RewardCheckinDay(
dayIndex: 7,
rewardCoin: 10,
checked: false,
current: false,
),
],
),
sections: [
RewardSection(
code: 'CHECK_IN',
title: 'Check-in section',
tasks: [
RewardTask(
id: 'checkin-task',
code: 'CHECK_IN',
type: 'DAILY_CHECK_IN',
title: 'Hidden check-in task',
subtitle: '',
ctaText: 'Check in',
actionType: 'NONE',
actionUrl: '',
progressValue: 0,
targetValue: 1,
rewardCoin: 10,
status: 'IN_PROGRESS',
),
],
),
RewardSection(
code: 'DAILY',
title: 'Daily task',
tasks: [
RewardTask(
id: 'watch',
code: 'WATCH',
type: 'WATCH_DURATION',
title: 'Watch a drama',
subtitle: 'Watch for five minutes',
ctaText: '',
actionType: 'OPEN_HOME',
actionUrl: '',
progressValue: 2,
targetValue: 5,
rewardCoin: 20,
status: 'IN_PROGRESS',
),
],
),
],
activities: [
RewardActivity(
id: 'spring',
code: 'SPRING',
title: 'Spring event',
summary: 'Earn extra coins this week',
ctaText: '',
actionType: '',
actionUrl: '',
),
],
);
}
@override
Future<RewardActionResult> checkIn() async {
checkInCalls += 1;
return const RewardActionResult(
rewardCoin: 10,
coinBalance: 130,
alreadyCompleted: false,
);
}
@override
Future<RewardActionResult> claim(String taskId) async {
return const RewardActionResult(
rewardCoin: 20,
coinBalance: 140,
alreadyCompleted: false,
);
}
}
class _FailingRewardPageRepository implements RewardRepository {
int overviewCalls = 0;
@override
Future<RewardOverview> fetchOverview(String locale) async {
overviewCalls += 1;
throw ApiException(500, 'offline');
}
@override
Future<RewardActionResult> checkIn() async {
throw ApiException(401, 'login');
}
@override
Future<RewardActionResult> claim(String taskId) async {
throw ApiException(401, 'login');
}
}
+279
View File
@@ -0,0 +1,279 @@
// Author: fruit.com.cn
// 奖励中心设计图比例与关键布局视觉回归。
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.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 'package:fruitshorts/core/data/models/reward.dart';
import 'package:fruitshorts/core/data/remote/api_client.dart';
import 'package:fruitshorts/core/data/remote/auth_api.dart';
import 'package:fruitshorts/core/data/repositories/reward_repository.dart';
import 'package:fruitshorts/core/data/services/auth_controller.dart';
import 'package:fruitshorts/core/data/services/auth_session_store.dart';
import 'package:fruitshorts/core/data/services/reward_controller.dart';
import 'package:fruitshorts/core/theme/app_theme.dart';
import 'package:fruitshorts/l10n/app_localizations.dart';
import 'package:fruitshorts/pages/reward/reward_page.dart';
import '../support/widget_capture.dart';
const bool _captureEnabled = bool.fromEnvironment('REWARD_CAPTURE');
const Size _screenSize = Size(393, 852);
const Key _captureKey = Key('reward-visual-capture');
const String _capturePath = '/tmp/fruitshorts-reward-before.png';
const String _helpCapturePath = '/tmp/fruitshorts-reward-help-dialog.png';
void main() {
testWidgets('should_render_reward_design_fixture_without_overflow', (
tester,
) async {
SharedPreferencesAsyncPlatform.instance =
InMemorySharedPreferencesAsync.empty();
await tester.binding.setSurfaceSize(_screenSize);
addTearDown(() => tester.binding.setSurfaceSize(null));
if (_captureEnabled) await loadCaptureFonts(tester);
final auth = _VisualAuthController();
final rewards = RewardController(_VisualRewardRepository());
await tester.pumpWidget(_host(auth: auth, rewards: rewards));
await tester.pump(const Duration(milliseconds: 2));
await tester.pumpAndSettle();
if (_captureEnabled) {
await saveWidgetCapture(tester, _captureKey, _capturePath);
}
expect(find.byKey(const Key('reward-checkin-card')), findsOneWidget);
expect(find.byKey(const Key('reward-task-card-INVITE')), findsOneWidget);
expect(find.byKey(const Key('reward-task-card-NEWBIE')), findsOneWidget);
expect(find.text('+0'), findsNothing);
final action = tester.getSize(
find.byKey(const Key('reward-checkin-action')),
);
final actionContent = tester.getSize(
find.byKey(const Key('reward-checkin-action-content')),
);
expect(actionContent.width, lessThan(action.width * 0.8));
expect(_radius(tester, const Key('reward-checkin-card')), 14);
expect(_radius(tester, const Key('reward-task-surface-INVITE')), 14);
final backIcon = tester.widget<Icon>(
find.descendant(
of: find.byKey(const Key('reward-back-button')),
matching: find.byIcon(Icons.arrow_back_ios_new_rounded),
),
);
expect(backIcon.size, 16);
final helpButton = tester.getSize(
find.byKey(const Key('reward-help-icon')),
);
expect(helpButton.width, 24);
expect(helpButton.height, 24);
final helpHitTarget = tester.getSize(
find.byKey(const Key('reward-help-button')),
);
expect(helpHitTarget.width, 48);
expect(helpHitTarget.height, 48);
final taskButton = tester.getSize(
find.byKey(const Key('reward-task-action-invite')),
);
expect(taskButton.height, 36);
expect(taskButton.width, 80);
if (_captureEnabled) {
await tester.tap(find.byKey(const Key('reward-help-button')));
await tester.pumpAndSettle();
await saveWidgetCapture(
tester,
const Key('reward-help-dialog'),
_helpCapturePath,
);
await tester.tap(find.byKey(const Key('reward-help-close')));
await tester.pumpAndSettle();
}
expect(tester.takeException(), isNull);
auth.dispose();
rewards.dispose();
});
}
double _radius(WidgetTester tester, Key key) {
final container = tester.widget<Container>(find.byKey(key));
final decoration = container.decoration! as BoxDecoration;
return decoration.borderRadius!.resolve(TextDirection.ltr).topLeft.x;
}
Widget _host({
required AuthController auth,
required RewardController rewards,
}) {
return MultiProvider(
providers: [
ChangeNotifierProvider<AuthController>.value(value: auth),
ChangeNotifierProvider<RewardController>.value(value: rewards),
],
child: MaterialApp(
theme: AppTheme.dark(),
locale: const Locale('zh'),
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: MediaQuery(
data: const MediaQueryData(
size: _screenSize,
padding: EdgeInsets.only(top: 24, bottom: 24),
),
child: const RepaintBoundary(
key: _captureKey,
child: Scaffold(body: RewardPage(active: true)),
),
),
),
);
}
class _VisualAuthController extends AuthController {
_VisualAuthController()
: super(
AuthApi(ApiClient(baseUrl: 'http://127.0.0.1:8081')),
AuthSessionStore(),
);
@override
bool get isSignedIn => false;
}
class _VisualRewardRepository implements RewardRepository {
@override
Future<RewardOverview> fetchOverview(String locale) async {
return const RewardOverview(
serverDate: '2026-11-09',
timezone: 'Asia/Jakarta',
coinBalance: 0,
checkin: RewardCheckinState(
taskId: 'checkin',
checkedInToday: false,
streak: 0,
currentDay: 1,
days: [
RewardCheckinDay(
dayIndex: 1,
rewardCoin: 55,
checked: false,
current: true,
),
RewardCheckinDay(
dayIndex: 2,
rewardCoin: 35,
checked: false,
current: false,
),
RewardCheckinDay(
dayIndex: 3,
rewardCoin: 35,
checked: false,
current: false,
),
RewardCheckinDay(
dayIndex: 4,
rewardCoin: 35,
checked: false,
current: false,
),
RewardCheckinDay(
dayIndex: 5,
rewardCoin: 35,
checked: false,
current: false,
),
RewardCheckinDay(
dayIndex: 6,
rewardCoin: 55,
checked: false,
current: false,
),
RewardCheckinDay(
dayIndex: 7,
rewardCoin: 55,
checked: false,
current: false,
),
],
),
sections: [
RewardSection(
code: 'INVITE',
title: '邀请任务',
tasks: [
RewardTask(
id: 'invite',
code: 'INVITE_FRIEND',
type: 'INVITE_FRIEND',
title: '领1小时免费看剧',
subtitle: '邀请成功 +1小时',
ctaText: '去完成',
actionType: 'OPEN_URL',
actionUrl: '',
progressValue: 0,
targetValue: 1,
rewardCoin: 0,
status: 'IN_PROGRESS',
),
],
),
RewardSection(
code: 'NEWBIE',
title: '新手专属任务',
tasks: [
RewardTask(
id: 'facebook',
code: 'FOLLOW_FACEBOOK',
type: 'FOLLOW_SOCIAL',
title: '关注 Facebook 账号',
subtitle: '',
ctaText: '去完成',
actionType: 'OPEN_URL',
actionUrl: '',
progressValue: 0,
targetValue: 1,
rewardCoin: 50,
status: 'IN_PROGRESS',
),
RewardTask(
id: 'youtube',
code: 'FOLLOW_YOUTUBE',
type: 'FOLLOW_SOCIAL',
title: '关注 YouTube 账号',
subtitle: '',
ctaText: '去完成',
actionType: 'OPEN_URL',
actionUrl: '',
progressValue: 0,
targetValue: 1,
rewardCoin: 50,
status: 'IN_PROGRESS',
),
],
),
],
);
}
@override
Future<RewardActionResult> checkIn() async {
return const RewardActionResult(
rewardCoin: 55,
coinBalance: 55,
alreadyCompleted: false,
);
}
@override
Future<RewardActionResult> claim(String taskId) async {
return const RewardActionResult(
rewardCoin: 50,
coinBalance: 50,
alreadyCompleted: false,
);
}
}