forked from zulip/zulip-flutter
-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
poll: Support read-only poll widget UI
The UI follows the webapp until we get a new design. The dark theme colors were tentatively picked. The `TextStyle`s are the same for both light and dark theme. All the styling are based on values taken from the webapp. References: - light theme: https://github.com/zulip/zulip/blob/2011e0df760cea52c31914e7b77d9b4e38e9ee74/web/styles/widgets.css#L138-L185 https://github.com/zulip/zulip/blob/2011e0df760cea52c31914e7b77d9b4e38e9ee74/web/styles/dark_theme.css#L358 - dark theme: https://github.com/zulip/zulip/blob/2011e0df760cea52c31914e7b77d9b4e38e9ee74/web/styles/dark_theme.css#L966-L987 Signed-off-by: Zixuan James Li <[email protected]>
- Loading branch information
Showing
4 changed files
with
261 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,116 @@ | ||
import 'package:flutter/material.dart'; | ||
import 'package:flutter_gen/gen_l10n/zulip_localizations.dart'; | ||
|
||
import '../api/model/submessage.dart'; | ||
import 'content.dart'; | ||
import 'store.dart'; | ||
import 'text.dart'; | ||
|
||
class PollWidget extends StatefulWidget { | ||
const PollWidget({super.key, required this.poll}); | ||
|
||
final Poll poll; | ||
|
||
@override | ||
State<PollWidget> createState() => _PollWidgetState(); | ||
} | ||
|
||
class _PollWidgetState extends State<PollWidget> { | ||
@override | ||
void initState() { | ||
super.initState(); | ||
widget.poll.addListener(_modelChanged); | ||
} | ||
|
||
@override | ||
void didUpdateWidget(covariant PollWidget oldWidget) { | ||
super.didUpdateWidget(oldWidget); | ||
if (widget.poll != oldWidget.poll) { | ||
oldWidget.poll.removeListener(_modelChanged); | ||
widget.poll.addListener(_modelChanged); | ||
} | ||
} | ||
|
||
@override | ||
void dispose() { | ||
widget.poll.removeListener(_modelChanged); | ||
super.dispose(); | ||
} | ||
|
||
void _modelChanged() { | ||
setState(() { | ||
// The actual state lives in the [Poll] model. | ||
// This method was called because that just changed. | ||
}); | ||
} | ||
|
||
@override | ||
Widget build(BuildContext context) { | ||
final zulipLocalizations = ZulipLocalizations.of(context); | ||
final theme = ContentTheme.of(context); | ||
final store = PerAccountStoreWidget.of(context); | ||
|
||
final textStyleBold = const TextStyle(fontSize: 18) | ||
.merge(weightVariableTextStyle(context, wght: 600)); | ||
final textStyleVoterNames = TextStyle( | ||
fontSize: 16, color: theme.colorPollNames); | ||
|
||
Text question = (widget.poll.question.isNotEmpty) | ||
? Text(widget.poll.question, style: textStyleBold) | ||
: Text(zulipLocalizations.pollWidgetQuestionMissing, | ||
style: textStyleBold.copyWith(fontStyle: FontStyle.italic)); | ||
|
||
Widget buildOptionItem(PollOption option) { | ||
// TODO(i18n): List formatting, like you can do in JavaScript: | ||
// new Intl.ListFormat('ja').format(['Chris', 'Greg', 'Alya', 'Zixuan']) | ||
// // 'Chris、Greg、Alya、Zixuan' | ||
final voterNames = option.voters | ||
.map((userId) => | ||
store.users[userId]?.fullName ?? zulipLocalizations.unknownUserName) | ||
.join(', '); | ||
|
||
return Padding( | ||
padding: const EdgeInsets.only(bottom: 5), | ||
child: Row( | ||
spacing: 5, | ||
crossAxisAlignment: CrossAxisAlignment.baseline, | ||
textBaseline: localizedTextBaseline(context), | ||
children: [ | ||
ConstrainedBox( | ||
constraints: const BoxConstraints(minWidth: 25), | ||
child: Container( | ||
height: 25, | ||
padding: const EdgeInsets.symmetric(horizontal: 4), | ||
decoration: BoxDecoration( | ||
color: theme.colorPollVoteCountBackground, | ||
border: Border.all(color: theme.colorPollVoteCountBorder), | ||
borderRadius: BorderRadius.circular(3)), | ||
child: Center( | ||
child: Text(option.voters.length.toString(), | ||
textAlign: TextAlign.center, | ||
style: textStyleBold.copyWith( | ||
color: theme.colorPollVoteCountText, fontSize: 13))))), | ||
Expanded( | ||
child: Wrap( | ||
spacing: 5, | ||
children: [ | ||
Text(option.text, style: textStyleBold.copyWith(fontSize: 16)), | ||
if (voterNames.isNotEmpty) | ||
// TODO(i18n): Localize parenthesis characters. | ||
Text('($voterNames)', style: textStyleVoterNames), | ||
])), | ||
])); | ||
} | ||
|
||
return Column( | ||
crossAxisAlignment: CrossAxisAlignment.start, | ||
children: [ | ||
Padding(padding: const EdgeInsets.only(bottom: 6), child: question), | ||
if (widget.poll.options.isEmpty) | ||
Text(zulipLocalizations.pollWidgetOptionsMissing, | ||
style: textStyleVoterNames.copyWith(fontStyle: FontStyle.italic)), | ||
for (final option in widget.poll.options) | ||
buildOptionItem(option), | ||
]); | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,109 @@ | ||
import 'package:checks/checks.dart'; | ||
import 'package:flutter/widgets.dart'; | ||
import 'package:flutter_checks/flutter_checks.dart'; | ||
import 'package:flutter_test/flutter_test.dart'; | ||
import 'package:zulip/api/model/events.dart'; | ||
import 'package:zulip/api/model/model.dart'; | ||
import 'package:zulip/api/model/submessage.dart'; | ||
import 'package:zulip/model/store.dart'; | ||
import 'package:zulip/widgets/poll.dart'; | ||
|
||
import '../example_data.dart' as eg; | ||
import '../model/binding.dart'; | ||
import '../model/test_store.dart'; | ||
import 'test_app.dart'; | ||
|
||
void main() { | ||
TestZulipBinding.ensureInitialized(); | ||
|
||
late PerAccountStore store; | ||
|
||
Future<void> preparePollWidget( | ||
WidgetTester tester, | ||
SubmessageData? submessageContent, { | ||
Iterable<User>? users, | ||
Iterable<(User, int)> voterIdxPairs = const [], | ||
}) async { | ||
addTearDown(testBinding.reset); | ||
await testBinding.globalStore.add(eg.selfAccount, eg.initialSnapshot()); | ||
store = await testBinding.globalStore.perAccount(eg.selfAccount.id); | ||
await store.addUsers(users ?? [eg.selfUser, eg.otherUser]); | ||
|
||
Message message = eg.streamMessage( | ||
sender: eg.selfUser, | ||
submessages: [eg.submessage(content: submessageContent)]); | ||
await store.handleEvent(MessageEvent(id: 0, message: message)); | ||
await tester.pumpWidget(TestZulipApp(accountId: eg.selfAccount.id, | ||
child: PollWidget(poll: message.poll!))); | ||
await tester.pump(); | ||
|
||
for (final (voter, idx) in voterIdxPairs) { | ||
await store.handleEvent(eg.submessageEvent(message.id, voter.userId, | ||
content: PollVoteEventSubmessage( | ||
key: PollEventSubmessage.optionKey(senderId: null, idx: idx), | ||
op: PollVoteOp.add))); | ||
} | ||
await tester.pump(); | ||
} | ||
|
||
Finder findInPoll(Finder matching) => | ||
find.descendant(of: find.byType(PollWidget), matching: matching); | ||
|
||
Finder findTextAtRow(String text, {required int index}) => | ||
find.descendant( | ||
of: findInPoll(find.byType(Row)).at(index), matching: find.text(text)); | ||
|
||
testWidgets('smoke', (tester) async { | ||
await preparePollWidget(tester, | ||
eg.pollWidgetData(question: 'favorite letter', options: ['A', 'B', 'C']), | ||
voterIdxPairs: [ | ||
(eg.selfUser, 0), | ||
(eg.selfUser, 1), | ||
(eg.otherUser, 1), | ||
]); | ||
|
||
check(findInPoll(find.text('favorite letter'))).findsOne(); | ||
|
||
check(findTextAtRow('A', index: 0)).findsOne(); | ||
check(findTextAtRow('1', index: 0)).findsOne(); | ||
check(findTextAtRow('(${eg.selfUser.fullName})', index: 0)).findsOne(); | ||
|
||
check(findTextAtRow('B', index: 1)).findsOne(); | ||
check(findTextAtRow('2', index: 1)).findsOne(); | ||
check(findTextAtRow( | ||
'(${eg.selfUser.fullName}, ${eg.otherUser.fullName})', index: 1)).findsOne(); | ||
|
||
check(findTextAtRow('C', index: 2)).findsOne(); | ||
check(findTextAtRow('0', index: 2)).findsOne(); | ||
}); | ||
|
||
final pollWidgetData = eg.pollWidgetData(question: 'poll', options: ['A', 'B']); | ||
|
||
testWidgets('a lot of voters', (tester) async { | ||
final users = List.generate(100, (i) => eg.user(fullName: 'user#$i')); | ||
await preparePollWidget(tester, pollWidgetData, | ||
users: users, voterIdxPairs: users.map((user) => (user, 0))); | ||
|
||
final allUserNames = '(${users.map((user) => user.fullName).join(', ')})'; | ||
check(findTextAtRow(allUserNames, index: 0)).findsOne(); | ||
check(findTextAtRow('100', index: 0)).findsOne(); | ||
}); | ||
|
||
testWidgets('show unknown voter', (tester) async { | ||
await preparePollWidget(tester, pollWidgetData, | ||
users: [eg.selfUser], voterIdxPairs: [(eg.thirdUser, 1)]); | ||
check(findInPoll(find.text('((unknown user))'))).findsOne(); | ||
}); | ||
|
||
testWidgets('poll title missing', (tester) async { | ||
await preparePollWidget(tester, eg.pollWidgetData( | ||
question: '', options: ['A'])); | ||
check(findInPoll(find.text('No question.'))).findsOne(); | ||
}); | ||
|
||
testWidgets('poll options missing', (tester) async { | ||
await preparePollWidget(tester, eg.pollWidgetData( | ||
question: 'title', options: [])); | ||
check(findInPoll(find.text('This poll has no options yet.'))).findsOne(); | ||
}); | ||
} |