From 6007bdff7779c6e5e5a026cf1b9eddf781440d6f Mon Sep 17 00:00:00 2001 From: LittleSheep Date: Mon, 3 Jun 2024 23:36:46 +0800 Subject: [PATCH] :sparkles: Badges --- lib/models/account.dart | 49 +++ lib/providers/account.dart | 6 +- lib/providers/chat.dart | 6 +- lib/providers/content/attachment.dart | 29 +- lib/screens/account.dart | 219 +++++-------- lib/screens/account/personalize.dart | 301 +++++++++--------- lib/translations.dart | 14 +- lib/widgets/account/account_avatar.dart | 55 ++++ lib/widgets/account/account_badge.dart | 62 ++++ lib/widgets/account/account_heading.dart | 105 ++++++ .../account/account_profile_popup.dart | 70 +--- lib/widgets/attachments/attachment_item.dart | 2 +- lib/widgets/posts/post_item.dart | 6 +- test/widget_test.dart | 30 -- 14 files changed, 556 insertions(+), 398 deletions(-) create mode 100644 lib/widgets/account/account_badge.dart create mode 100644 lib/widgets/account/account_heading.dart delete mode 100644 test/widget_test.dart diff --git a/lib/models/account.dart b/lib/models/account.dart index 7ba97da..3363a33 100644 --- a/lib/models/account.dart +++ b/lib/models/account.dart @@ -8,6 +8,7 @@ class Account { dynamic avatar; dynamic banner; String description; + List? badges; String? emailAddress; int? externalId; @@ -21,6 +22,7 @@ class Account { required this.avatar, required this.banner, required this.description, + required this.badges, required this.emailAddress, this.externalId, }); @@ -36,6 +38,10 @@ class Account { banner: json['banner'], description: json['description'], emailAddress: json['email_address'], + badges: json['badges'] + ?.map((e) => AccountBadge.fromJson(e)) + .toList() + .cast(), externalId: json['external_id'], ); @@ -50,6 +56,49 @@ class Account { 'banner': banner, 'description': description, 'email_address': emailAddress, + 'badges': badges?.map((e) => e.toJson()).toList(), 'external_id': externalId, }; } + +class AccountBadge { + int id; + DateTime createdAt; + DateTime updatedAt; + DateTime? deletedAt; + Map? metadata; + String type; + int accountId; + + AccountBadge({ + required this.id, + required this.accountId, + required this.createdAt, + required this.updatedAt, + required this.deletedAt, + required this.metadata, + required this.type, + }); + + factory AccountBadge.fromJson(Map json) => AccountBadge( + id: json["id"], + accountId: json["account_id"], + updatedAt: DateTime.parse(json["updated_at"]), + createdAt: DateTime.parse(json["created_at"]), + deletedAt: json["deleted_at"] != null + ? DateTime.parse(json["deleted_at"]) + : null, + metadata: json["metadata"], + type: json["type"], + ); + + Map toJson() => { + "id": id, + "account_id": accountId, + "created_at": createdAt.toIso8601String(), + "updated_at": updatedAt.toIso8601String(), + "deleted_at": deletedAt?.toIso8601String(), + "metadata": metadata, + "type": type, + }; +} diff --git a/lib/providers/account.dart b/lib/providers/account.dart index 978e590..6801811 100644 --- a/lib/providers/account.dart +++ b/lib/providers/account.dart @@ -36,7 +36,11 @@ class AccountProvider extends GetxController { } void connect({noRetry = false}) async { - if (isConnected.value) return; + if (isConnected.value) { + return; + } else { + disconnect(); + } final AuthProvider auth = Get.find(); if (!await auth.isAuthorized) throw Exception('unauthorized'); diff --git a/lib/providers/chat.dart b/lib/providers/chat.dart index 970f193..5bea7f3 100644 --- a/lib/providers/chat.dart +++ b/lib/providers/chat.dart @@ -17,7 +17,11 @@ class ChatProvider extends GetxController { StreamController stream = StreamController.broadcast(); void connect({noRetry = false}) async { - if (isConnected.value) return; + if (isConnected.value) { + return; + } else { + disconnect(); + } final AuthProvider auth = Get.find(); if (!await auth.isAuthorized) throw Exception('unauthorized'); diff --git a/lib/providers/content/attachment.dart b/lib/providers/content/attachment.dart index 8164c8a..558dd97 100644 --- a/lib/providers/content/attachment.dart +++ b/lib/providers/content/attachment.dart @@ -50,7 +50,10 @@ class AttachmentProvider extends GetConnect { final AuthProvider auth = Get.find(); if (!await auth.isAuthorized) throw Exception('unauthorized'); - final client = GetConnect(maxAuthRetries: 3); + final client = GetConnect( + maxAuthRetries: 3, + timeout: const Duration(minutes: 3), + ); client.httpClient.baseUrl = ServiceFinder.services['paperclip']; client.httpClient.addAuthenticator(auth.requestAuthenticator); @@ -68,21 +71,19 @@ class AttachmentProvider extends GetConnect { if (mimetypeOverrides.keys.contains(fileExt)) { mimetypeOverride = mimetypeOverrides[fileExt]; } - final resp = await client.post( - '/api/attachments', - FormData({ - 'alt': fileAlt, - 'file': filePayload, - 'hash': hash, - 'usage': usage, - if (mimetypeOverride != null) 'mimetype': mimetypeOverride, - 'metadata': jsonEncode({ - if (ratio != null) 'ratio': ratio, - }), + final payload = FormData({ + 'alt': fileAlt, + 'file': filePayload, + 'hash': hash, + 'usage': usage, + if (mimetypeOverride != null) 'mimetype': mimetypeOverride, + 'metadata': jsonEncode({ + if (ratio != null) 'ratio': ratio, }), - ); + }); + final resp = await client.post('/api/attachments', payload); if (resp.statusCode != 200) { - throw Exception('${resp.statusCode}: ${resp.bodyString}'); + throw Exception(resp.bodyString); } return resp; diff --git a/lib/screens/account.dart b/lib/screens/account.dart index 4957a38..847edb3 100644 --- a/lib/screens/account.dart +++ b/lib/screens/account.dart @@ -1,11 +1,11 @@ import 'package:flutter/material.dart'; import 'package:get/get.dart'; +import 'package:solian/models/account.dart'; import 'package:solian/providers/auth.dart'; import 'package:solian/router.dart'; import 'package:solian/screens/auth/signin.dart'; import 'package:solian/screens/auth/signup.dart'; -import 'package:solian/services.dart'; -import 'package:solian/widgets/account/account_avatar.dart'; +import 'package:solian/widgets/account/account_heading.dart'; class AccountScreen extends StatefulWidget { const AccountScreen({super.key}); @@ -30,86 +30,88 @@ class _AccountScreenState extends State { return Material( color: Theme.of(context).colorScheme.surface, - child: FutureBuilder( - future: provider.isAuthorized, - builder: (context, snapshot) { - if (!snapshot.hasData || snapshot.data == false) { - return Center( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - ActionCard( - icon: const Icon(Icons.login, color: Colors.white), - title: 'signin'.tr, - caption: 'signinCaption'.tr, - onTap: () { - showModalBottomSheet( - useRootNavigator: true, - isDismissible: false, - isScrollControlled: true, - context: context, - builder: (context) => const SignInPopup(), - ).then((_) async { - await provider.getProfile(noCache: true); - setState(() {}); - }); - }, - ), - ActionCard( - icon: const Icon(Icons.add, color: Colors.white), - title: 'signup'.tr, - caption: 'signupCaption'.tr, - onTap: () { - showModalBottomSheet( - useRootNavigator: true, - isDismissible: false, - isScrollControlled: true, - context: context, - builder: (context) => const SignUpPopup(), - ).then((_) { - setState(() {}); - }); - }, - ), - ], - ), - ); - } + child: SafeArea( + child: FutureBuilder( + future: provider.isAuthorized, + builder: (context, snapshot) { + if (!snapshot.hasData || snapshot.data == false) { + return Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + ActionCard( + icon: const Icon(Icons.login, color: Colors.white), + title: 'signin'.tr, + caption: 'signinCaption'.tr, + onTap: () { + showModalBottomSheet( + useRootNavigator: true, + isDismissible: false, + isScrollControlled: true, + context: context, + builder: (context) => const SignInPopup(), + ).then((_) async { + await provider.getProfile(noCache: true); + setState(() {}); + }); + }, + ), + ActionCard( + icon: const Icon(Icons.add, color: Colors.white), + title: 'signup'.tr, + caption: 'signupCaption'.tr, + onTap: () { + showModalBottomSheet( + useRootNavigator: true, + isDismissible: false, + isScrollControlled: true, + context: context, + builder: (context) => const SignUpPopup(), + ).then((_) { + setState(() {}); + }); + }, + ), + ], + ), + ); + } - return Column( - children: [ - const AccountNameCard().paddingOnly(bottom: 8), - ...(actionItems.map( - (x) => ListTile( + return Column( + children: [ + const AccountHeading().paddingOnly(bottom: 8), + ...(actionItems.map( + (x) => ListTile( + contentPadding: const EdgeInsets.symmetric(horizontal: 34), + leading: x.$1, + title: Text(x.$2), + onTap: () { + AppRouter.instance + .pushNamed(x.$3) + .then((_) => setState(() {})); + }, + ), + )), + ListTile( contentPadding: const EdgeInsets.symmetric(horizontal: 34), - leading: x.$1, - title: Text(x.$2), + leading: const Icon(Icons.logout), + title: Text('signout'.tr), onTap: () { - AppRouter.instance - .pushNamed(x.$3) - .then((_) => setState(() {})); + provider.signout(); + setState(() {}); }, ), - )), - ListTile( - contentPadding: const EdgeInsets.symmetric(horizontal: 34), - leading: const Icon(Icons.logout), - title: Text('signout'.tr), - onTap: () { - provider.signout(); - setState(() {}); - }, - ), - ], - ); - }, + ], + ); + }, + ), ), ); } } -class AccountNameCard extends StatelessWidget { - const AccountNameCard({super.key}); +class AccountHeading extends StatelessWidget { + const AccountHeading({super.key}); @override Widget build(BuildContext context) { @@ -123,69 +125,16 @@ class AccountNameCard extends StatelessWidget { } final prof = snapshot.data!; - return Material( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - AspectRatio( - aspectRatio: 16 / 7, - child: Container( - color: Theme.of(context).colorScheme.surfaceContainer, - child: Stack( - clipBehavior: Clip.none, - fit: StackFit.expand, - children: [ - if (prof.body['banner'] != null) - Image.network( - '${ServiceFinder.services['paperclip']}/api/attachments/${prof.body['banner']}', - fit: BoxFit.cover, - ), - Positioned( - bottom: -30, - left: 18, - child: AccountAvatar( - content: prof.body['avatar'], - radius: 48, - ), - ), - ], - ), - ), - ), - Row( - crossAxisAlignment: CrossAxisAlignment.baseline, - textBaseline: TextBaseline.alphabetic, - children: [ - Text( - prof.body['nick'], - style: const TextStyle( - fontSize: 17, - fontWeight: FontWeight.bold, - ), - ).paddingOnly(right: 4), - Text( - '@${prof.body['name']}', - style: const TextStyle( - fontSize: 15, - ), - ), - ], - ).paddingOnly(left: 120, top: 8), - SizedBox( - width: double.infinity, - child: Card( - child: ListTile( - title: Text('description'.tr), - subtitle: Text( - prof.body['description']?.isNotEmpty - ? prof.body['description'] - : 'No description yet.', - ), - ), - ), - ).paddingOnly(left: 24, right: 24, top: 8), - ], - ), + return AccountHeadingWidget( + avatar: prof.body['avatar'], + banner: prof.body['banner'], + name: prof.body['name'], + nick: prof.body['nick'], + desc: prof.body['description'], + badges: prof.body['badges'] + ?.map((e) => AccountBadge.fromJson(e)) + .toList() + .cast(), ); }, ); diff --git a/lib/screens/account/personalize.dart b/lib/screens/account/personalize.dart index 3d9b5b5..7048540 100644 --- a/lib/screens/account/personalize.dart +++ b/lib/screens/account/personalize.dart @@ -156,167 +156,164 @@ class _PersonalizeScreenState extends State { @override Widget build(BuildContext context) { + const double padding = 32; + return Material( color: Theme.of(context).colorScheme.surface, - child: Container( - padding: const EdgeInsets.symmetric(horizontal: 32), - child: ListView( - children: [ - if (_isBusy) const LinearProgressIndicator().animate().scaleX(), - const SizedBox(height: 24), - Stack( - children: [ - AccountAvatar(content: _avatar, radius: 40), - Positioned( - bottom: 0, - left: 40, - child: FloatingActionButton.small( - heroTag: const Key('avatar-editor'), - onPressed: () => updateImage('avatar'), - child: const Icon( - Icons.camera, - ), + child: ListView( + children: [ + if (_isBusy) const LinearProgressIndicator().animate().scaleX(), + const SizedBox(height: 24), + Stack( + children: [ + AccountAvatar(content: _avatar, radius: 40), + Positioned( + bottom: 0, + left: 40, + child: FloatingActionButton.small( + heroTag: const Key('avatar-editor'), + onPressed: () => updateImage('avatar'), + child: const Icon( + Icons.camera, ), ), - ], - ), - const SizedBox(height: 16), - Stack( - children: [ - ClipRRect( - borderRadius: const BorderRadius.all(Radius.circular(8)), - child: AspectRatio( - aspectRatio: 16 / 9, - child: Container( - color: Theme.of(context).colorScheme.surfaceContainerHigh, - child: _banner != null - ? Image.network( - '${ServiceFinder.services['paperclip']}/api/attachments/$_banner', - fit: BoxFit.cover, - loadingBuilder: (BuildContext context, - Widget child, - ImageChunkEvent? loadingProgress) { - if (loadingProgress == null) return child; - return Center( - child: CircularProgressIndicator( - value: loadingProgress.expectedTotalBytes != - null - ? loadingProgress - .cumulativeBytesLoaded / - loadingProgress.expectedTotalBytes! - : null, - ), - ); - }, - ) - : Container(), - ), - ), - ), - Positioned( - bottom: 16, - right: 16, - child: FloatingActionButton( - heroTag: const Key('banner-editor'), - onPressed: () => updateImage('banner'), - child: const Icon( - Icons.camera_alt, - ), - ), - ), - ], - ), - const SizedBox(height: 24), - Row( - children: [ - Flexible( - flex: 1, - child: TextField( - readOnly: true, - controller: _usernameController, - decoration: InputDecoration( - border: const OutlineInputBorder(), - labelText: 'username'.tr, - prefixText: '@', - ), - ), - ), - const SizedBox(width: 16), - Flexible( - flex: 1, - child: TextField( - controller: _nicknameController, - decoration: InputDecoration( - border: const OutlineInputBorder(), - labelText: 'nickname'.tr, - ), - ), - ), - ], - ), - const SizedBox(height: 16), - Row( - children: [ - Flexible( - flex: 1, - child: TextField( - controller: _firstNameController, - decoration: InputDecoration( - border: const OutlineInputBorder(), - labelText: 'firstName'.tr, - ), - ), - ), - const SizedBox(width: 16), - Flexible( - flex: 1, - child: TextField( - controller: _lastNameController, - decoration: InputDecoration( - border: const OutlineInputBorder(), - labelText: 'lastName'.tr, - ), - ), - ), - ], - ), - const SizedBox(height: 16), - TextField( - controller: _descriptionController, - keyboardType: TextInputType.multiline, - maxLines: null, - minLines: 3, - decoration: InputDecoration( - border: const OutlineInputBorder(), - labelText: 'description'.tr, ), - ), - const SizedBox(height: 16), - TextField( - controller: _birthdayController, - readOnly: true, - decoration: InputDecoration( - border: const OutlineInputBorder(), - labelText: 'birthday'.tr, + ], + ).paddingSymmetric(horizontal: padding), + const SizedBox(height: 16), + Stack( + children: [ + ClipRRect( + borderRadius: const BorderRadius.all(Radius.circular(8)), + child: AspectRatio( + aspectRatio: 16 / 9, + child: Container( + color: Theme.of(context).colorScheme.surfaceContainerHigh, + child: _banner != null + ? Image.network( + '${ServiceFinder.services['paperclip']}/api/attachments/$_banner', + fit: BoxFit.cover, + loadingBuilder: (BuildContext context, Widget child, + ImageChunkEvent? loadingProgress) { + if (loadingProgress == null) return child; + return Center( + child: CircularProgressIndicator( + value: loadingProgress.expectedTotalBytes != + null + ? loadingProgress.cumulativeBytesLoaded / + loadingProgress.expectedTotalBytes! + : null, + ), + ); + }, + ) + : Container(), + ), + ), ), - onTap: () => selectBirthday(), - ), - const SizedBox(height: 16), - Row( - mainAxisAlignment: MainAxisAlignment.end, - children: [ - TextButton( - onPressed: _isBusy ? null : () => syncWidget(), - child: Text('reset'.tr), + Positioned( + bottom: 16, + right: 16, + child: FloatingActionButton( + heroTag: const Key('banner-editor'), + onPressed: () => updateImage('banner'), + child: const Icon( + Icons.camera_alt, + ), ), - ElevatedButton( - onPressed: _isBusy ? null : () => updatePersonalize(), - child: Text('apply'.tr), + ), + ], + ).paddingSymmetric(horizontal: padding), + const SizedBox(height: 24), + Row( + children: [ + Flexible( + flex: 1, + child: TextField( + readOnly: true, + controller: _usernameController, + decoration: InputDecoration( + border: const OutlineInputBorder(), + labelText: 'username'.tr, + prefixText: '@', + ), ), - ], + ), + const SizedBox(width: 16), + Flexible( + flex: 1, + child: TextField( + controller: _nicknameController, + decoration: InputDecoration( + border: const OutlineInputBorder(), + labelText: 'nickname'.tr, + ), + ), + ), + ], + ).paddingSymmetric(horizontal: padding), + const SizedBox(height: 16), + Row( + children: [ + Flexible( + flex: 1, + child: TextField( + controller: _firstNameController, + decoration: InputDecoration( + border: const OutlineInputBorder(), + labelText: 'firstName'.tr, + ), + ), + ), + const SizedBox(width: 16), + Flexible( + flex: 1, + child: TextField( + controller: _lastNameController, + decoration: InputDecoration( + border: const OutlineInputBorder(), + labelText: 'lastName'.tr, + ), + ), + ), + ], + ).paddingSymmetric(horizontal: padding), + const SizedBox(height: 16), + TextField( + controller: _descriptionController, + keyboardType: TextInputType.multiline, + maxLines: null, + minLines: 3, + decoration: InputDecoration( + border: const OutlineInputBorder(), + labelText: 'description'.tr, ), - ], - ), + ).paddingSymmetric(horizontal: padding), + const SizedBox(height: 16), + TextField( + controller: _birthdayController, + readOnly: true, + decoration: InputDecoration( + border: const OutlineInputBorder(), + labelText: 'birthday'.tr, + ), + onTap: () => selectBirthday(), + ).paddingSymmetric(horizontal: padding), + const SizedBox(height: 16), + Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + TextButton( + onPressed: _isBusy ? null : () => syncWidget(), + child: Text('reset'.tr), + ), + ElevatedButton( + onPressed: _isBusy ? null : () => updatePersonalize(), + child: Text('apply'.tr), + ), + ], + ).paddingSymmetric(horizontal: padding), + ], ), ); } diff --git a/lib/translations.dart b/lib/translations.dart index f978ea2..f0b25c1 100644 --- a/lib/translations.dart +++ b/lib/translations.dart @@ -186,7 +186,12 @@ class SolianMessages extends Translations { 'callParticipantVideoOn': 'Turn On Participant Video', 'callAlreadyOngoing': 'A call is already ongoing', 'sidebarPlaceholder': - 'Your screen is really too large, so we had to leave some space here to prevent the layout from being too messy. In the future, we will add some small widgets here for wealthy users like you to enjoy, but for now, it will stay this way.' + 'Your screen is really too large, so we had to leave some space here to prevent the layout from being too messy. In the future, we will add some small widgets here for wealthy users like you to enjoy, but for now, it will stay this way.', + 'badge': 'Badge', + 'badges': 'Badges', + 'badgeGrantAt': 'Badge awarded on @date', + 'badgeSolsynthStaff': 'Solsynth Staff', + 'badgeSolarOriginalCitizen': 'Solar Network Natives', }, 'zh_CN': { 'hide': '隐藏', @@ -359,7 +364,12 @@ class SolianMessages extends Translations { 'callParticipantVideoOn': '解除静音参与者', 'callAlreadyOngoing': '当前正在进行一则通话', 'sidebarPlaceholder': - '你的屏幕真的太大啦,我们只好空出一块地方好让布局不那么混乱,未来我们会在此处加入一下小挂件来供你这样的富人玩乐,但现在就这样吧。' + '你的屏幕真的太大啦,我们只好空出一块地方好让布局不那么混乱,未来我们会在此处加入一下小挂件来供你这样的富人玩乐,但现在就这样吧。', + 'badge': '徽章', + 'badges': '徽章', + 'badgeGrantAt': '徽章颁发于 @date', + 'badgeSolsynthStaff': 'Solsynth 工作人员', + 'badgeSolarOriginalCitizen': 'Solar Network 原住民', } }; } diff --git a/lib/widgets/account/account_avatar.dart b/lib/widgets/account/account_avatar.dart index 97eaece..26a9913 100644 --- a/lib/widgets/account/account_avatar.dart +++ b/lib/widgets/account/account_avatar.dart @@ -50,3 +50,58 @@ class AccountAvatar extends StatelessWidget { ); } } + +class AccountProfileImage extends StatelessWidget { + final dynamic content; + final BoxFit fit; + + const AccountProfileImage({ + super.key, + required this.content, + this.fit = BoxFit.cover, + }); + + @override + Widget build(BuildContext context) { + bool direct = false; + bool isEmpty = content == null; + if (content is String) { + direct = content.startsWith('http'); + if (!isEmpty) isEmpty = content.isEmpty; + if (!isEmpty) isEmpty = content.endsWith('/api/attachments/0'); + } + + final url = direct + ? content + : '${ServiceFinder.services['paperclip']}/api/attachments/$content'; + + if (PlatformInfo.canCacheImage) { + return CachedNetworkImage( + imageUrl: url, + fit: fit, + progressIndicatorBuilder: (context, url, downloadProgress) => Center( + child: CircularProgressIndicator( + value: downloadProgress.progress, + ), + ), + ); + } else { + return Image.network( + url, + fit: fit, + loadingBuilder: (BuildContext context, Widget child, + ImageChunkEvent? loadingProgress) { + if (loadingProgress == null) return child; + return Center( + child: CircularProgressIndicator( + value: loadingProgress.expectedTotalBytes != null + ? loadingProgress.cumulativeBytesLoaded / + loadingProgress.expectedTotalBytes! + : null, + ), + ); + }, + ); + } + } +} diff --git a/lib/widgets/account/account_badge.dart b/lib/widgets/account/account_badge.dart new file mode 100644 index 0000000..4cc2bd3 --- /dev/null +++ b/lib/widgets/account/account_badge.dart @@ -0,0 +1,62 @@ +import 'package:flutter/material.dart'; +import 'package:font_awesome_flutter/font_awesome_flutter.dart'; +import 'package:get/get.dart'; +import 'package:intl/intl.dart'; +import 'package:solian/models/account.dart'; + +class AccountBadgeWidget extends StatefulWidget { + final AccountBadge item; + + const AccountBadgeWidget({super.key, required this.item}); + + @override + State createState() => _AccountBadgeWidgetState(); +} + +class _AccountBadgeWidgetState extends State { + final Map badges = { + 'solsynth.staff': ( + 'badgeSolsynthStaff'.tr, + const FaIcon( + FontAwesomeIcons.screwdriverWrench, + size: 16, + color: Colors.teal, + ), + ), + 'solar.originalCitizen': ( + 'badgeSolarOriginalCitizen'.tr, + const FaIcon( + FontAwesomeIcons.tent, + size: 16, + color: Colors.orange, + ), + ), + }; + + @override + Widget build(BuildContext context) { + final spec = badges[widget.item.type]; + + if (spec == null) return const SizedBox(); + + return Tooltip( + richMessage: TextSpan( + children: [ + TextSpan(text: '${spec.$1}\n'), + if (widget.item.metadata?['title'] != null) + TextSpan( + text: '${widget.item.metadata?['title']}\n', + style: const TextStyle(fontWeight: FontWeight.bold), + ), + TextSpan( + text: 'badgeGrantAt'.trParams( + {'date': DateFormat.yMEd().format(widget.item.createdAt)}), + ), + ], + ), + child: Chip( + label: spec.$2, + ), + ); + } +} diff --git a/lib/widgets/account/account_heading.dart b/lib/widgets/account/account_heading.dart new file mode 100644 index 0000000..9e022bd --- /dev/null +++ b/lib/widgets/account/account_heading.dart @@ -0,0 +1,105 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:solian/models/account.dart'; +import 'package:solian/widgets/account/account_avatar.dart'; +import 'package:solian/widgets/account/account_badge.dart'; + +class AccountHeadingWidget extends StatelessWidget { + final dynamic avatar; + final dynamic banner; + final String name; + final String nick; + final String? desc; + final List? badges; + + const AccountHeadingWidget({ + super.key, + this.avatar, + this.banner, + required this.name, + required this.nick, + required this.desc, + required this.badges, + }); + + @override + Widget build(BuildContext context) { + return Material( + color: Colors.transparent, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Stack( + clipBehavior: Clip.none, + children: [ + ClipRRect( + borderRadius: const BorderRadius.all(Radius.circular(16)), + child: AspectRatio( + aspectRatio: 16 / 7, + child: Container( + color: Theme.of(context).colorScheme.surfaceContainer, + child: banner != null + ? AccountProfileImage( + content: banner, + fit: BoxFit.cover, + ) + : const SizedBox(), + ), + ), + ).paddingSymmetric(horizontal: 16), + Positioned( + bottom: -30, + left: 32, + child: AccountAvatar(content: avatar, radius: 40), + ), + ], + ), + Row( + crossAxisAlignment: CrossAxisAlignment.baseline, + textBaseline: TextBaseline.alphabetic, + children: [ + Text( + nick, + style: const TextStyle( + fontSize: 17, + fontWeight: FontWeight.bold, + ), + ).paddingOnly(right: 4), + Text( + '@$name', + style: const TextStyle( + fontSize: 15, + ), + ), + ], + ).paddingOnly(left: 116, top: 6), + const SizedBox(height: 4), + if (badges?.isNotEmpty ?? false) + SizedBox( + width: double.infinity, + child: Card( + child: Wrap( + runSpacing: 2, + spacing: 4, + children: badges!.map((e) { + return AccountBadgeWidget(item: e); + }).toList(), + ).paddingSymmetric(horizontal: 8), + ), + ).paddingOnly(left: 16, right: 16), + SizedBox( + width: double.infinity, + child: Card( + child: ListTile( + title: Text('description'.tr), + subtitle: Text( + (desc?.isNotEmpty ?? false) ? desc! : 'No description yet.', + ), + ), + ), + ).paddingOnly(left: 16, right: 16), + ], + ), + ); + } +} diff --git a/lib/widgets/account/account_profile_popup.dart b/lib/widgets/account/account_profile_popup.dart index 5a58001..6da8135 100644 --- a/lib/widgets/account/account_profile_popup.dart +++ b/lib/widgets/account/account_profile_popup.dart @@ -3,7 +3,7 @@ import 'package:get/get.dart'; import 'package:solian/exts.dart'; import 'package:solian/models/account.dart'; import 'package:solian/services.dart'; -import 'package:solian/widgets/account/account_avatar.dart'; +import 'package:solian/widgets/account/account_heading.dart'; class AccountProfilePopup extends StatefulWidget { final Account account; @@ -44,7 +44,7 @@ class _AccountProfilePopupState extends State { @override Widget build(BuildContext context) { - if (_isBusy) { + if (_isBusy || _userinfo == null) { return const Center(child: CircularProgressIndicator()); } @@ -53,64 +53,14 @@ class _AccountProfilePopupState extends State { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - AspectRatio( - aspectRatio: 16 / 7, - child: Container( - color: Theme.of(context).colorScheme.surfaceContainerHigh, - child: Stack( - clipBehavior: Clip.none, - fit: StackFit.expand, - children: [ - if (_userinfo!.banner != null) - Image.network( - '${ServiceFinder.services['paperclip']}/api/attachments/${_userinfo!.banner}', - fit: BoxFit.cover, - ), - Positioned( - bottom: -30, - left: 18, - child: AccountAvatar( - content: widget.account.banner, - radius: 48, - ), - ), - ], - ), - ), - ).paddingOnly(top: 32), - Row( - crossAxisAlignment: CrossAxisAlignment.baseline, - textBaseline: TextBaseline.alphabetic, - children: [ - Text( - _userinfo!.nick, - style: const TextStyle( - fontSize: 17, - fontWeight: FontWeight.bold, - ), - ).paddingOnly(right: 4), - Text( - '@${_userinfo!.name}', - style: const TextStyle( - fontSize: 15, - ), - ), - ], - ).paddingOnly(left: 120, top: 8), - SizedBox( - width: double.infinity, - child: Card( - color: Theme.of(context).colorScheme.surfaceContainerHigh, - child: ListTile( - title: Text('description'.tr), - subtitle: Text( - _userinfo!.description.isNotEmpty - ? widget.account.description - : 'No description yet.', - ), - ), - ), - ).paddingOnly(left: 24, right: 24, top: 8), + AccountHeadingWidget( + avatar: _userinfo!.avatar, + banner: _userinfo!.banner, + name: _userinfo!.name, + nick: _userinfo!.nick, + desc: _userinfo!.description, + badges: _userinfo!.badges, + ).paddingOnly(top: 16), ], ), ); diff --git a/lib/widgets/attachments/attachment_item.dart b/lib/widgets/attachments/attachment_item.dart index dbe4df8..d327617 100644 --- a/lib/widgets/attachments/attachment_item.dart +++ b/lib/widgets/attachments/attachment_item.dart @@ -65,6 +65,7 @@ class _AttachmentItemState extends State { children: [ if (PlatformInfo.canCacheImage) CachedNetworkImage( + fit: widget.fit, imageUrl: '${ServiceFinder.services['paperclip']}/api/attachments/${widget.item.id}', progressIndicatorBuilder: (context, url, downloadProgress) => @@ -73,7 +74,6 @@ class _AttachmentItemState extends State { value: downloadProgress.progress, ), ), - fit: widget.fit, ) else Image.network( diff --git a/lib/widgets/posts/post_item.dart b/lib/widgets/posts/post_item.dart index 16cd380..f8e727e 100644 --- a/lib/widgets/posts/post_item.dart +++ b/lib/widgets/posts/post_item.dart @@ -165,9 +165,11 @@ class _PostItemState extends State { showModalBottomSheet( useRootNavigator: true, isScrollControlled: true, + backgroundColor: Theme.of(context).colorScheme.surface, context: context, - builder: (context) => - AccountProfilePopup(account: item.author), + builder: (context) => AccountProfilePopup( + account: item.author, + ), ); }, ), diff --git a/test/widget_test.dart b/test/widget_test.dart deleted file mode 100644 index e8845fc..0000000 --- a/test/widget_test.dart +++ /dev/null @@ -1,30 +0,0 @@ -// This is a basic Flutter widget test. -// -// To perform an interaction with a widget in your test, use the WidgetTester -// utility in the flutter_test package. For example, you can send tap and scroll -// gestures. You can also use WidgetTester to find child widgets in the widget -// tree, read text, and verify that the values of widget properties are correct. - -import 'package:flutter/material.dart'; -import 'package:flutter_test/flutter_test.dart'; - -import 'package:solian/main.dart'; - -void main() { - testWidgets('Counter increments smoke test', (WidgetTester tester) async { - // Build our app and trigger a frame. - await tester.pumpWidget(const SolianApp()); - - // Verify that our counter starts at 0. - expect(find.text('0'), findsOneWidget); - expect(find.text('1'), findsNothing); - - // Tap the '+' icon and trigger a frame. - await tester.tap(find.byIcon(Icons.add)); - await tester.pump(); - - // Verify that our counter has incremented. - expect(find.text('0'), findsNothing); - expect(find.text('1'), findsOneWidget); - }); -}