Skip to content

Commit af0e08e

Browse files
committed
feat(profile): support setting secondary title
Add support for setting secondary titles.
1 parent 7483e25 commit af0e08e

File tree

11 files changed

+464
-1
lines changed

11 files changed

+464
-1
lines changed
Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
import 'dart:async';
2+
3+
import 'package:bloc/bloc.dart';
4+
import 'package:dart_mappable/dart_mappable.dart';
5+
import 'package:fpdart/fpdart.dart';
6+
import 'package:tsdm_client/exceptions/exceptions.dart';
7+
import 'package:tsdm_client/features/profile/models/secondary_title.dart';
8+
import 'package:tsdm_client/features/profile/repository/my_titles_repository.dart';
9+
import 'package:tsdm_client/utils/logger.dart';
10+
11+
part 'my_titles_cubit.mapper.dart';
12+
13+
/// Loading status.
14+
enum MyTitlesStatus {
15+
/// Initial state.
16+
initial,
17+
18+
/// Loading data.
19+
loadingTitles,
20+
21+
/// Switching current title.
22+
switchingTitle,
23+
24+
/// Action performed successfully.
25+
success,
26+
27+
/// Action is failed.
28+
failure,
29+
}
30+
31+
/// The state of bloc.
32+
@MappableClass()
33+
final class MyTitlesState with MyTitlesStateMappable {
34+
/// Status.
35+
const MyTitlesState({
36+
this.status = MyTitlesStatus.initial,
37+
this.titles = const [],
38+
this.currentTitleId,
39+
});
40+
41+
/// Current status.
42+
final MyTitlesStatus status;
43+
44+
/// All available secondary titles for current user.
45+
final List<SecondaryTitle> titles;
46+
47+
/// The id of current secondary title.
48+
///
49+
/// May not present if no title specified.
50+
final int? currentTitleId;
51+
}
52+
53+
/// Cubit of my titles page.
54+
final class MyTitlesCubit extends Cubit<MyTitlesState> with LoggerMixin {
55+
/// Constructor.
56+
MyTitlesCubit(this._repo) : super(const MyTitlesState());
57+
58+
final MyTitlesRepository _repo;
59+
60+
/// Fetch info about all secondary titles for current user.
61+
Future<void> fetchAvailableSecondaryTitles() async {
62+
emit(state.copyWith(status: MyTitlesStatus.loadingTitles));
63+
switch (await _repo.fetchSecondaryTitles().run()) {
64+
case Right(:final value):
65+
emit(state.copyWith(status: MyTitlesStatus.success, titles: value));
66+
case Left(:final value):
67+
error('failed to load available secondary titles: $value');
68+
emit(state.copyWith(status: MyTitlesStatus.failure));
69+
}
70+
}
71+
72+
/// Set the secondary title to the one specified by [id].
73+
Future<void> setSecondaryTitle(int id) async {
74+
final activatedIdx = state.titles.indexWhere((v) => v.id == id);
75+
if (activatedIdx < 0) {
76+
return;
77+
}
78+
emit(state.copyWith(status: MyTitlesStatus.switchingTitle));
79+
switch (await _repo.setSecondaryTitle(id).run()) {
80+
case Right():
81+
final s = <SecondaryTitle>[];
82+
for (final (idx, o) in state.titles.indexed) {
83+
s.add(o.copyWith(activated: idx == activatedIdx));
84+
}
85+
emit(state.copyWith(status: MyTitlesStatus.success, titles: s));
86+
case Left<AppException, void>(:final value):
87+
error('failed to switch secondary title: $value');
88+
emit(state.copyWith(status: MyTitlesStatus.failure));
89+
}
90+
}
91+
92+
/// Unset the user specified secondary title.
93+
Future<void> unsetSecondaryTitle() async {
94+
emit(state.copyWith(status: MyTitlesStatus.switchingTitle));
95+
switch (await _repo.unsetSecondaryTitle().run()) {
96+
case Right():
97+
final s = <SecondaryTitle>[];
98+
for (final o in state.titles) {
99+
s.add(o.copyWith(activated: false));
100+
}
101+
emit(state.copyWith(status: MyTitlesStatus.success, titles: s));
102+
case Left<AppException, void>(:final value):
103+
error('failed to switch secondary title: $value');
104+
emit(state.copyWith(status: MyTitlesStatus.failure));
105+
}
106+
}
107+
}
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
import 'package:dart_mappable/dart_mappable.dart';
2+
import 'package:tsdm_client/extensions/string.dart';
3+
import 'package:tsdm_client/extensions/universal_html.dart';
4+
import 'package:tsdm_client/instance.dart';
5+
import 'package:universal_html/html.dart' as uh;
6+
7+
part 'secondary_title.mapper.dart';
8+
9+
/// An available secondary title.
10+
///
11+
/// Some fields in the original page are ignore:
12+
///
13+
/// * Title description.
14+
/// * Expiration.
15+
@MappableClass()
16+
final class SecondaryTitle with SecondaryTitleMappable {
17+
/// Constructor.
18+
const SecondaryTitle({required this.id, required this.name, required this.imageUrl, required this.activated});
19+
20+
/// Parse the secondary title info in tr node.
21+
/// <tr>
22+
/// <td>${ID}</td>
23+
/// <td>${NAME}</td>
24+
/// <td></td>
25+
/// <td><img src=${IMAGE_URL}></td>
26+
/// <td>${EXPIRATION}</td>
27+
/// <td><a href="url_to_activate">activate_the_title</a></td>
28+
/// </tr>
29+
static SecondaryTitle? fromTr(uh.Element element) {
30+
final tds = element.querySelectorAll('td');
31+
if (tds.length != 6) {
32+
talker.error('failed to build secondary title: invalid td count: ${tds.length}');
33+
return null;
34+
}
35+
36+
final id = tds.first.innerText.trim().parseToInt();
37+
final name = tds[1].innerText.trim();
38+
final imageUrl = tds[3].querySelector('img')?.imageUrl();
39+
if (id == null || name.isEmpty || imageUrl == null) {
40+
talker.error('invalid secondary title data: id=$id, name=$name, imageUrl=$imageUrl');
41+
return null;
42+
}
43+
44+
return SecondaryTitle(id: id, name: name, imageUrl: imageUrl, activated: false);
45+
}
46+
47+
/// Title id.
48+
final int id;
49+
50+
/// Title name.
51+
final String name;
52+
53+
/// Title image url.
54+
final String imageUrl;
55+
56+
/// Using current secondary title or not.
57+
final bool activated;
58+
}
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import 'package:tsdm_client/constants/url.dart';
2+
import 'package:tsdm_client/exceptions/exceptions.dart';
3+
import 'package:tsdm_client/extensions/string.dart';
4+
import 'package:tsdm_client/features/profile/models/secondary_title.dart';
5+
import 'package:tsdm_client/instance.dart';
6+
import 'package:tsdm_client/shared/providers/net_client_provider/net_client_provider.dart';
7+
import 'package:universal_html/parsing.dart';
8+
9+
/// The repository for user titles.
10+
final class MyTitlesRepository {
11+
/// Fetch all available secondary titles for current user.
12+
AsyncEither<List<SecondaryTitle>> fetchSecondaryTitles() => getIt
13+
.get<NetClientProvider>()
14+
.get('$baseUrl/plugin.php?id=tsdmtitle:tsdmtitle')
15+
.mapHttp((v) => parseHtmlDocument(v.data as String))
16+
.map((doc) {
17+
final allAvailableTitles = doc
18+
.querySelectorAll('div#ct_shell > div > table:nth-child(2) > tbody > tr')
19+
.skip(1) // Skip table header.
20+
.map(SecondaryTitle.fromTr)
21+
.whereType<SecondaryTitle>()
22+
.toList();
23+
final currentTitleId = doc
24+
.querySelector('div#ct_shell > div > table:nth-child(3) > tbody > tr:nth-child(2) > td')
25+
?.innerText
26+
.parseToInt();
27+
final idx = allAvailableTitles.indexWhere((v) => v.id == currentTitleId);
28+
if (idx >= 0) {
29+
allAvailableTitles[idx] = allAvailableTitles[idx].copyWith(activated: true);
30+
}
31+
return allAvailableTitles;
32+
});
33+
34+
/// Switch to the secondary title specified by [id].
35+
AsyncVoidEither setSecondaryTitle(int id) => getIt.get<NetClientProvider>().get(
36+
'$baseUrl/plugin.php?id=tsdmtitle:tsdmtitle&action=setTitle&setTitleId=$id',
37+
);
38+
39+
/// Unset user specified secondary title, set to default one.
40+
AsyncVoidEither unsetSecondaryTitle() => getIt.get<NetClientProvider>().get(
41+
'$baseUrl/plugin.php?id=tsdmtitle:tsdmtitle&action=setTitle&setTitleId=0',
42+
);
43+
}
Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
import 'dart:async';
2+
3+
import 'package:collection/collection.dart';
4+
import 'package:flutter/material.dart';
5+
import 'package:flutter_bloc/flutter_bloc.dart';
6+
import 'package:tsdm_client/constants/layout.dart';
7+
import 'package:tsdm_client/constants/url.dart';
8+
import 'package:tsdm_client/extensions/build_context.dart';
9+
import 'package:tsdm_client/features/profile/bloc/my_titles_cubit.dart';
10+
import 'package:tsdm_client/features/profile/repository/my_titles_repository.dart';
11+
import 'package:tsdm_client/features/profile/widgets/secondary_title_card.dart';
12+
import 'package:tsdm_client/i18n/strings.g.dart';
13+
import 'package:tsdm_client/utils/retry_button.dart';
14+
import 'package:tsdm_client/utils/show_toast.dart';
15+
import 'package:tsdm_client/widgets/indicator.dart';
16+
import 'package:tsdm_client/widgets/single_line_text.dart';
17+
18+
/// Page showing current user's titles.
19+
///
20+
/// With link to visit title shop.
21+
class MyTitlesPage extends StatefulWidget {
22+
/// Constructor.
23+
const MyTitlesPage({super.key});
24+
25+
@override
26+
State<MyTitlesPage> createState() => _MyTitlesPageState();
27+
}
28+
29+
class _MyTitlesPageState extends State<MyTitlesPage> {
30+
@override
31+
Widget build(BuildContext context) {
32+
final tr = context.t.myTitlesPage;
33+
return MultiBlocProvider(
34+
providers: [
35+
RepositoryProvider(
36+
create: (_) => MyTitlesRepository(),
37+
),
38+
BlocProvider(
39+
create: (context) {
40+
final cubit = MyTitlesCubit(context.repo());
41+
unawaited(cubit.fetchAvailableSecondaryTitles());
42+
return cubit;
43+
},
44+
),
45+
],
46+
child: BlocConsumer<MyTitlesCubit, MyTitlesState>(
47+
listener: (context, state) {
48+
if (state.status == MyTitlesStatus.failure) {
49+
showSnackBar(context: context, message: context.t.general.failedToLoad);
50+
}
51+
},
52+
builder: (context, state) {
53+
final currentTitleName = state.titles.firstWhereOrNull((v) => v.activated)?.name;
54+
final body = switch (state.status) {
55+
MyTitlesStatus.initial || MyTitlesStatus.loadingTitles => const CenteredCircularIndicator(),
56+
MyTitlesStatus.failure when state.titles.isEmpty => buildRetryButton(
57+
context,
58+
() async => context.read<MyTitlesCubit>().fetchAvailableSecondaryTitles(),
59+
),
60+
MyTitlesStatus.switchingTitle || MyTitlesStatus.success || MyTitlesStatus.failure => ScrollConfiguration(
61+
behavior: ScrollConfiguration.of(context).copyWith(scrollbars: false),
62+
child: SingleChildScrollView(
63+
child: Wrap(
64+
spacing: 8,
65+
runSpacing: 8,
66+
children: [
67+
ConstrainedBox(
68+
constraints: const BoxConstraints(minHeight: 48),
69+
child: Row(
70+
children: [
71+
sizedBoxW12H12,
72+
Icon(
73+
Icons.lightbulb_outline,
74+
color: Theme.of(context).colorScheme.onSecondaryContainer,
75+
),
76+
sizedBoxW4H4,
77+
Expanded(
78+
child: SingleLineText(
79+
currentTitleName == null ? tr.noneActivated : tr.activated(name: currentTitleName),
80+
style: Theme.of(context).textTheme.labelLarge?.copyWith(
81+
color: Theme.of(context).colorScheme.onSecondaryContainer,
82+
),
83+
),
84+
),
85+
if (currentTitleName != null) ...[
86+
sizedBoxW8H8,
87+
TextButton(
88+
child: Text(tr.unset),
89+
onPressed: () async => context.read<MyTitlesCubit>().unsetSecondaryTitle(),
90+
),
91+
],
92+
],
93+
),
94+
),
95+
...state.titles.map(SecondaryTitleCard.new),
96+
],
97+
),
98+
),
99+
),
100+
};
101+
102+
return Scaffold(
103+
appBar: AppBar(
104+
title: Text(tr.title),
105+
actions: [
106+
IconButton(
107+
icon: const Icon(Icons.shopping_bag_outlined),
108+
tooltip: tr.openTitleShop,
109+
onPressed: () async =>
110+
context.dispatchAsUrl('$baseUrl/plugin.php?id=tsdmtitle:tsdmtitle&action=shop'),
111+
),
112+
],
113+
),
114+
body: body,
115+
);
116+
},
117+
),
118+
);
119+
}
120+
}

lib/features/profile/view/profile_page.dart

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -83,7 +83,15 @@ const List<int> _checkinNextLevelExp = [
8383
300 - 250,
8484
];
8585

86-
enum _ProfileActions { viewNotification, checkin, viewPoints, switchUserGroup, logout, editAvatar }
86+
enum _ProfileActions {
87+
viewNotification,
88+
checkin,
89+
viewPoints,
90+
switchUserGroup,
91+
switchTitle,
92+
logout,
93+
editAvatar,
94+
}
8795

8896
/// Page of user profile.
8997
class ProfilePage extends StatefulWidget {
@@ -159,6 +167,11 @@ class _ProfilePageState extends State<ProfilePage> {
159167
return;
160168
}
161169
await context.pushNamed(ScreenPaths.switchUserGroup);
170+
case _ProfileActions.switchTitle:
171+
if (logout) {
172+
return;
173+
}
174+
await context.pushNamed(ScreenPaths.switchTitle);
162175
case _ProfileActions.logout:
163176
final logout = await showQuestionDialog(
164177
context: context,
@@ -224,6 +237,16 @@ class _ProfilePageState extends State<ProfilePage> {
224237
],
225238
),
226239
),
240+
PopupMenuItem(
241+
value: _ProfileActions.switchTitle,
242+
child: Row(
243+
children: [
244+
const Icon(Symbols.badge),
245+
sizedBoxPopupMenuItemIconSpacing,
246+
Text(context.t.myTitlesPage.title),
247+
],
248+
),
249+
),
227250
PopupMenuItem(
228251
enabled: !logout,
229252
value: _ProfileActions.logout,

0 commit comments

Comments
 (0)