Compare commits

...

5 Commits

15 changed files with 315 additions and 159 deletions

View File

@ -128,6 +128,7 @@
"one": "{} social point", "one": "{} social point",
"other": "{} social points" "other": "{} social points"
}, },
"publisherAffiliatedBy": "Affiliated by {}",
"publisherRunBy": "Run by {}", "publisherRunBy": "Run by {}",
"fieldPublisherBelongToRealm": "Belongs to", "fieldPublisherBelongToRealm": "Belongs to",
"fieldPublisherBelongToRealmUnset": "Unset Publisher Belongs to Realm", "fieldPublisherBelongToRealmUnset": "Unset Publisher Belongs to Realm",
@ -431,5 +432,9 @@
"serviceStatus": "Service Status", "serviceStatus": "Service Status",
"termRelated": "Related Terms", "termRelated": "Related Terms",
"appDetails": "App Details", "appDetails": "App Details",
"postRecommendation": "Highlight Posts" "postRecommendation": "Highlight Posts",
"publisherBlockHint": "Block {}",
"publisherBlockHintDescription": "You are going to block this publisher's maintainer, this will also block publishers that run by the same user.",
"userUnblocked": "{} has been unblocked.",
"userBlocked": "{} has been blocked."
} }

View File

@ -112,6 +112,7 @@
"one": "{} 点社会信用点", "one": "{} 点社会信用点",
"other": "{} 点社会信用点" "other": "{} 点社会信用点"
}, },
"publisherAffiliatedBy": "隶属于 {}",
"publisherRunBy": "由 {} 管理", "publisherRunBy": "由 {} 管理",
"fieldPublisherBelongToRealm": "所属领域", "fieldPublisherBelongToRealm": "所属领域",
"fieldPublisherBelongToRealmUnset": "未设置发布者所属领域", "fieldPublisherBelongToRealmUnset": "未设置发布者所属领域",
@ -429,5 +430,9 @@
"serviceStatus": "服务状态", "serviceStatus": "服务状态",
"termRelated": "相关条款", "termRelated": "相关条款",
"appDetails": "应用程序详情", "appDetails": "应用程序详情",
"postRecommendation": "推荐帖子" "postRecommendation": "推荐帖子",
"publisherBlockHint": "屏蔽 {}",
"publisherBlockHintDescription": "你正要屏蔽此发布者的运营者,该操作也将屏蔽由同一用户运营的发布者。",
"userUnblocked": "已解除屏蔽用户 {}",
"userBlocked": "已屏蔽用户 {}"
} }

View File

@ -3,7 +3,7 @@
archiveVersion = 1; archiveVersion = 1;
classes = { classes = {
}; };
objectVersion = 70; objectVersion = 54;
objects = { objects = {
/* Begin PBXBuildFile section */ /* Begin PBXBuildFile section */

View File

@ -1,6 +1,7 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:surface/providers/sn_network.dart'; import 'package:surface/providers/sn_network.dart';
import 'package:surface/types/account.dart';
class SnRelationshipProvider { class SnRelationshipProvider {
late final SnNetworkProvider _sn; late final SnNetworkProvider _sn;
@ -9,6 +10,15 @@ class SnRelationshipProvider {
_sn = context.read<SnNetworkProvider>(); _sn = context.read<SnNetworkProvider>();
} }
Future<SnRelationship?> getRelationship(int relatedId) async {
try {
final resp = await _sn.client.get('/cgi/id/users/me/relations/$relatedId');
return SnRelationship.fromJson(resp.data);
} catch (err) {
return null;
}
}
Future<void> updateRelationship( Future<void> updateRelationship(
int relatedId, int relatedId,
int status, int status,

View File

@ -58,7 +58,6 @@ final _appRoutes = [
path: '/write/:mode', path: '/write/:mode',
name: 'postEditor', name: 'postEditor',
builder: (context, state) => AppBackground( builder: (context, state) => AppBackground(
isLessOptimization: true,
child: PostEditorScreen( child: PostEditorScreen(
mode: state.pathParameters['mode']!, mode: state.pathParameters['mode']!,
postEditId: int.tryParse( postEditId: int.tryParse(
@ -77,7 +76,6 @@ final _appRoutes = [
path: '/search', path: '/search',
name: 'postSearch', name: 'postSearch',
builder: (context, state) => const AppBackground( builder: (context, state) => const AppBackground(
isLessOptimization: true,
child: PostSearchScreen(), child: PostSearchScreen(),
), ),
), ),
@ -119,7 +117,6 @@ final _appRoutes = [
path: '/:scope/:alias', path: '/:scope/:alias',
name: 'chatRoom', name: 'chatRoom',
builder: (context, state) => AppBackground( builder: (context, state) => AppBackground(
isLessOptimization: true,
child: ChatRoomScreen( child: ChatRoomScreen(
scope: state.pathParameters['scope']!, scope: state.pathParameters['scope']!,
alias: state.pathParameters['alias']!, alias: state.pathParameters['alias']!,
@ -159,7 +156,6 @@ final _appRoutes = [
secondaryAnimation: secondaryAnimation, secondaryAnimation: secondaryAnimation,
fillColor: Colors.transparent, fillColor: Colors.transparent,
child: AppBackground( child: AppBackground(
isLessOptimization: true,
child: child, child: child,
), ),
); );
@ -195,7 +191,6 @@ final _appRoutes = [
secondaryAnimation: secondaryAnimation, secondaryAnimation: secondaryAnimation,
fillColor: Colors.transparent, fillColor: Colors.transparent,
child: AppBackground( child: AppBackground(
isLessOptimization: true,
child: child, child: child,
), ),
); );

View File

@ -39,7 +39,7 @@ class _AbuseReportScreenState extends State<AbuseReportScreen> {
void _showAbuseReportDialog() { void _showAbuseReportDialog() {
showDialog( showDialog(
context: context, context: context,
builder: (context) => _AbuseReportDialog(), builder: (context) => AbuseReportDialog(),
).then((value) { ).then((value) {
if (value == true && mounted) { if (value == true && mounted) {
_fetchReports(); _fetchReports();
@ -91,19 +91,29 @@ class _AbuseReportScreenState extends State<AbuseReportScreen> {
} }
} }
class _AbuseReportDialog extends StatefulWidget { class AbuseReportDialog extends StatefulWidget {
const _AbuseReportDialog({super.key}); final String? resourceLocation;
const AbuseReportDialog({super.key, this.resourceLocation});
@override @override
State<_AbuseReportDialog> createState() => _AbuseReportDialogState(); State<AbuseReportDialog> createState() => _AbuseReportDialogState();
} }
class _AbuseReportDialogState extends State<_AbuseReportDialog> { class _AbuseReportDialogState extends State<AbuseReportDialog> {
bool _isBusy = false; bool _isBusy = false;
final _resourceController = TextEditingController(); final _resourceController = TextEditingController();
final _reasonController = TextEditingController(); final _reasonController = TextEditingController();
@override
void initState() {
super.initState();
if (widget.resourceLocation != null) {
_resourceController.text = widget.resourceLocation!;
}
}
@override @override
dispose() { dispose() {
_resourceController.dispose(); _resourceController.dispose();
@ -144,6 +154,7 @@ class _AbuseReportDialogState extends State<_AbuseReportDialog> {
const Gap(12), const Gap(12),
TextField( TextField(
controller: _resourceController, controller: _resourceController,
readOnly: widget.resourceLocation != null,
maxLength: null, maxLength: null,
decoration: InputDecoration( decoration: InputDecoration(
border: const UnderlineInputBorder(), border: const UnderlineInputBorder(),

View File

@ -3,6 +3,7 @@ import 'package:flutter/material.dart';
import 'package:gap/gap.dart'; import 'package:gap/gap.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import 'package:hive/hive.dart'; import 'package:hive/hive.dart';
import 'package:hive_flutter/hive_flutter.dart';
import 'package:material_symbols_icons/symbols.dart'; import 'package:material_symbols_icons/symbols.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:styled_widget/styled_widget.dart'; import 'package:styled_widget/styled_widget.dart';
@ -118,19 +119,19 @@ class _AuthorizedAccountScreen extends StatelessWidget {
contentPadding: const EdgeInsets.symmetric(horizontal: 24), contentPadding: const EdgeInsets.symmetric(horizontal: 24),
leading: const Icon(Symbols.logout), leading: const Icon(Symbols.logout),
trailing: const Icon(Symbols.chevron_right), trailing: const Icon(Symbols.chevron_right),
onTap: () { onTap: () async {
context final confirm = await context.showConfirmDialog(
.showConfirmDialog(
'accountLogoutConfirmTitle'.tr(), 'accountLogoutConfirmTitle'.tr(),
'accountLogoutConfirm'.tr(), 'accountLogoutConfirm'.tr(),
) );
.then((value) {
if(!context.mounted) return; if (!confirm) return;
if (value) ua.logoutUser(); if (!context.mounted) return;
final ws = context.read<WebSocketProvider>(); ua.logoutUser();
ws.disconnect(); final ws = context.read<WebSocketProvider>();
Hive.deleteFromDisk(); ws.disconnect();
}); await Hive.deleteFromDisk();
await Hive.initFlutter();
}, },
), ),
ListTile( ListTile(

View File

@ -31,7 +31,7 @@ class _PublisherScreenState extends State<PublisherScreen> {
setState(() => _isBusy = true); setState(() => _isBusy = true);
try { try {
final resp = await sn.client.get('/cgi/co/publishers'); final resp = await sn.client.get('/cgi/co/publishers/me');
final List<SnPublisher> out = List<SnPublisher>.from( final List<SnPublisher> out = List<SnPublisher>.from(
resp.data?.map((e) => SnPublisher.fromJson(e)) ?? []); resp.data?.map((e) => SnPublisher.fromJson(e)) ?? []);

View File

@ -145,6 +145,7 @@ class _PostSearchScreenState extends State<PostSearchScreen> {
padding: const WidgetStatePropertyAll( padding: const WidgetStatePropertyAll(
EdgeInsets.symmetric(horizontal: 24), EdgeInsets.symmetric(horizontal: 24),
), ),
onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(),
onChanged: (value) { onChanged: (value) {
_searchTerm = value; _searchTerm = value;
}, },

View File

@ -14,42 +14,54 @@ import 'package:surface/providers/sn_network.dart';
import 'package:surface/providers/user_directory.dart'; import 'package:surface/providers/user_directory.dart';
import 'package:surface/types/account.dart'; import 'package:surface/types/account.dart';
import 'package:surface/types/post.dart'; import 'package:surface/types/post.dart';
import 'package:surface/types/realm.dart';
import 'package:surface/widgets/account/account_image.dart'; import 'package:surface/widgets/account/account_image.dart';
import 'package:surface/widgets/dialog.dart'; import 'package:surface/widgets/dialog.dart';
import 'package:surface/widgets/post/post_item.dart'; import 'package:surface/widgets/post/post_item.dart';
import 'package:surface/widgets/universal_image.dart'; import 'package:surface/widgets/universal_image.dart';
import 'package:very_good_infinite_list/very_good_infinite_list.dart'; import 'package:very_good_infinite_list/very_good_infinite_list.dart';
import '../../providers/relationship.dart';
import '../abuse_report.dart';
class PostPublisherScreen extends StatefulWidget { class PostPublisherScreen extends StatefulWidget {
final String name; final String name;
const PostPublisherScreen({super.key, required this.name}); const PostPublisherScreen({super.key, required this.name});
@override @override
State<PostPublisherScreen> createState() => _PostPublisherScreenState(); State<PostPublisherScreen> createState() => _PostPublisherScreenState();
} }
class _PostPublisherScreenState extends State<PostPublisherScreen> class _PostPublisherScreenState extends State<PostPublisherScreen> with SingleTickerProviderStateMixin {
with SingleTickerProviderStateMixin {
late final ScrollController _scrollController = ScrollController(); late final ScrollController _scrollController = ScrollController();
late final TabController _tabController = late final TabController _tabController = TabController(length: 3, vsync: this);
TabController(length: 3, vsync: this);
SnPublisher? _publisher; SnPublisher? _publisher;
SnAccount? _account; SnAccount? _account;
SnRelationship? _accountRelationship;
SnRealm? _realm;
Future<void> _fetchPublisher() async { Future<void> _fetchPublisher() async {
try { try {
final sn = context.read<SnNetworkProvider>(); final sn = context.read<SnNetworkProvider>();
final ud = context.read<UserDirectoryProvider>(); final ud = context.read<UserDirectoryProvider>();
final rel = context.read<SnRelationshipProvider>();
final resp = await sn.client.get('/cgi/co/publishers/${widget.name}'); final resp = await sn.client.get('/cgi/co/publishers/${widget.name}');
if (!mounted) return; if (!mounted) return;
_publisher = SnPublisher.fromJson(resp.data); _publisher = SnPublisher.fromJson(resp.data);
_account = await ud.getAccount(_publisher?.accountId); _account = await ud.getAccount(_publisher?.accountId);
_accountRelationship = await rel.getRelationship(_account!.id);
if (_publisher?.realmId != null && _publisher!.realmId != 0) {
final resp = await sn.client.get('/cgi/id/realms/${_publisher!.realmId}');
_realm = SnRealm.fromJson(resp.data);
}
} catch (err) { } catch (err) {
if (!mounted) return; if (!mounted) return;
context.showErrorDialog(err).then((_) { context.showErrorDialog(err).then((_) {
if (mounted) Navigator.pop(context); if (mounted) Navigator.pop(context);
}); });
rethrow;
} finally { } finally {
setState(() {}); setState(() {});
} }
@ -114,14 +126,12 @@ class _PostPublisherScreenState extends State<PostPublisherScreen>
double _appBarBlur = 0.0; double _appBarBlur = 0.0;
late final _appBarWidth = MediaQuery.of(context).size.width; late final _appBarWidth = MediaQuery.of(context).size.width;
late final _appBarHeight = late final _appBarHeight = (_appBarWidth * kBannerAspectRatio).roundToDouble();
(_appBarWidth * kBannerAspectRatio).roundToDouble();
void _updateAppBarBlur() { void _updateAppBarBlur() {
if (_scrollController.offset > _appBarHeight) return; if (_scrollController.offset > _appBarHeight) return;
setState(() { setState(() {
_appBarBlur = _appBarBlur = (_scrollController.offset / _appBarHeight * 10).clamp(0.0, 10.0);
(_scrollController.offset / _appBarHeight * 10).clamp(0.0, 10.0);
}); });
} }
@ -156,11 +166,73 @@ class _PostPublisherScreenState extends State<PostPublisherScreen>
} }
} }
bool _isWorking = false;
Future<void> _blockPublisher() async {
if (_isWorking) return;
final confirm = await context.showConfirmDialog(
'publisherBlockHint'.tr(args: ['@${_publisher?.name ?? 'unknown'.tr()}']),
'publisherBlockHintDescription'.tr(),
);
if (!confirm) return;
if (!mounted) return;
setState(() => _isWorking = true);
try {
final sn = context.read<SnNetworkProvider>();
await sn.client.post('/cgi/id/users/me/relations/block', data: {
'related': _account!.name,
});
if (!mounted) return;
context.showSnackbar('userBlocked'.tr(args: ['@${_account?.name ?? 'unknown'.tr()}']));
} catch (err) {
if (!mounted) return;
context.showErrorDialog(err);
} finally {
setState(() => _isWorking = false);
}
}
Future<void> _unblockPublisher() async {
if (_isWorking) return;
setState(() => _isWorking = true);
try {
final sn = context.read<SnNetworkProvider>();
final rel = context.read<SnRelationshipProvider>();
await rel.updateRelationship(_account!.id, 1, _accountRelationship?.permNodes ?? {});
if (!mounted) return;
context.showSnackbar('userUnblocked'.tr(args: ['@${_account?.name ?? 'unknown'.tr()}']));
} catch (err) {
if (!mounted) return;
context.showErrorDialog(err);
} finally {
setState(() => _isWorking = false);
}
}
void _updateFetchType() { void _updateFetchType() {
_posts.clear(); _posts.clear();
_fetchPosts(); _fetchPosts();
} }
void _showAbuseReportDialog() {
showDialog(
context: context,
builder: (context) => AbuseReportDialog(
resourceLocation: 'pub:${_publisher?.name}',
),
).then((value) {
if (value == true && mounted) {
_fetchPosts();
context.showSnackbar('abuseReportSubmitted'.tr());
}
});
}
@override @override
void initState() { void initState() {
super.initState(); super.initState();
@ -215,10 +287,7 @@ class _PostPublisherScreenState extends State<PostPublisherScreen>
text: TextSpan(children: [ text: TextSpan(children: [
TextSpan( TextSpan(
text: _publisher!.nick, text: _publisher!.nick,
style: Theme.of(context) style: Theme.of(context).textTheme.titleLarge!.copyWith(
.textTheme
.titleLarge!
.copyWith(
color: Colors.white, color: Colors.white,
shadows: labelShadows, shadows: labelShadows,
), ),
@ -226,10 +295,7 @@ class _PostPublisherScreenState extends State<PostPublisherScreen>
const TextSpan(text: '\n'), const TextSpan(text: '\n'),
TextSpan( TextSpan(
text: '@${_publisher!.name}', text: '@${_publisher!.name}',
style: Theme.of(context) style: Theme.of(context).textTheme.bodySmall!.copyWith(
.textTheme
.bodySmall!
.copyWith(
color: Colors.white, color: Colors.white,
shadows: labelShadows, shadows: labelShadows,
), ),
@ -241,14 +307,19 @@ class _PostPublisherScreenState extends State<PostPublisherScreen>
? Stack( ? Stack(
fit: StackFit.expand, fit: StackFit.expand,
children: [ children: [
UniversalImage( if (_publisher!.banner.isNotEmpty)
sn.getAttachmentUrl(_publisher!.banner), UniversalImage(
fit: BoxFit.cover, sn.getAttachmentUrl(_publisher!.banner),
height: imageHeight, fit: BoxFit.cover,
width: _appBarWidth, height: imageHeight,
cacheHeight: imageHeight, width: _appBarWidth,
cacheWidth: _appBarWidth, cacheHeight: imageHeight,
), cacheWidth: _appBarWidth,
)
else
Container(
color: Theme.of(context).colorScheme.surfaceContainer,
),
Positioned( Positioned(
top: 0, top: 0,
left: 0, left: 0,
@ -288,14 +359,11 @@ class _PostPublisherScreenState extends State<PostPublisherScreen>
const Gap(16), const Gap(16),
Expanded( Expanded(
child: Column( child: Column(
crossAxisAlignment: crossAxisAlignment: CrossAxisAlignment.start,
CrossAxisAlignment.start,
children: [ children: [
Text( Text(
_publisher!.nick, _publisher!.nick,
style: Theme.of(context) style: Theme.of(context).textTheme.titleMedium,
.textTheme
.titleMedium,
).bold(), ).bold(),
Text('@${_publisher!.name}').fontSize(13), Text('@${_publisher!.name}').fontSize(13),
], ],
@ -306,9 +374,7 @@ class _PostPublisherScreenState extends State<PostPublisherScreen>
style: ButtonStyle( style: ButtonStyle(
elevation: WidgetStatePropertyAll(0), elevation: WidgetStatePropertyAll(0),
), ),
onPressed: _isSubscribing onPressed: _isSubscribing ? null : _toggleSubscription,
? null
: _toggleSubscription,
label: Text('subscribe').tr(), label: Text('subscribe').tr(),
icon: const Icon(Symbols.add), icon: const Icon(Symbols.add),
) )
@ -317,17 +383,54 @@ class _PostPublisherScreenState extends State<PostPublisherScreen>
style: ButtonStyle( style: ButtonStyle(
elevation: WidgetStatePropertyAll(0), elevation: WidgetStatePropertyAll(0),
), ),
onPressed: _isSubscribing onPressed: _isSubscribing ? null : _toggleSubscription,
? null
: _toggleSubscription,
label: Text('unsubscribe').tr(), label: Text('unsubscribe').tr(),
icon: const Icon(Symbols.remove), icon: const Icon(Symbols.remove),
), ),
PopupMenuButton(
padding: EdgeInsets.zero,
style: ButtonStyle(
visualDensity: VisualDensity(horizontal: -4, vertical: -4),
),
itemBuilder: (BuildContext context) => [
PopupMenuItem(
child: Row(
children: [
const Icon(Symbols.flag),
const Gap(16),
Text('report').tr(),
],
),
onTap: () => _showAbuseReportDialog(),
),
if (_accountRelationship?.status != 2)
PopupMenuItem(
onTap: _blockPublisher,
child: Row(
children: [
const Icon(Symbols.block),
const Gap(16),
Text('friendBlock').tr(),
],
),
)
else
PopupMenuItem(
onTap: _unblockPublisher,
child: Row(
children: [
const Icon(Symbols.block),
const Gap(16),
Text('friendUnblock').tr(),
],
),
),
],
),
], ],
).padding(right: 8), ),
const Gap(12), const Gap(12),
Text(_publisher!.description) Text(_publisher!.description).padding(horizontal: 8),
.padding(horizontal: 8),
const Gap(12), const Gap(12),
Column( Column(
children: [ children: [
@ -335,10 +438,8 @@ class _PostPublisherScreenState extends State<PostPublisherScreen>
children: [ children: [
const Icon(Symbols.calendar_add_on), const Icon(Symbols.calendar_add_on),
const Gap(8), const Gap(8),
Text('publisherJoinedAt').tr(args: [ Text('publisherJoinedAt')
DateFormat('y/M/d') .tr(args: [DateFormat('y/M/d').format(_publisher!.createdAt)]),
.format(_publisher!.createdAt)
]),
], ],
), ),
Row( Row(
@ -346,11 +447,30 @@ class _PostPublisherScreenState extends State<PostPublisherScreen>
const Icon(Symbols.trending_up), const Icon(Symbols.trending_up),
const Gap(8), const Gap(8),
Text('publisherSocialPointTotal').plural( Text('publisherSocialPointTotal').plural(
_publisher!.totalUpvote - _publisher!.totalUpvote - _publisher!.totalDownvote,
_publisher!.totalDownvote,
), ),
], ],
), ),
if (_realm != null)
Row(
children: [
const Icon(Symbols.group_work),
const Gap(8),
InkWell(
child: Text('publisherAffiliatedBy').tr(args: [
'@${_realm?.alias ?? 'unknown'}',
]),
onTap: () {
GoRouter.of(context).pushNamed(
'realmDetail',
pathParameters: {'alias': _realm!.alias},
);
},
),
const Gap(8),
AccountImage(content: _realm?.avatar, radius: 8),
],
),
Row( Row(
children: [ children: [
const Icon(Symbols.tools_wrench), const Icon(Symbols.tools_wrench),
@ -369,8 +489,7 @@ class _PostPublisherScreenState extends State<PostPublisherScreen>
}, },
), ),
const Gap(8), const Gap(8),
AccountImage( AccountImage(content: _account?.avatar, radius: 8),
content: _account?.avatar, radius: 8),
], ],
), ),
], ],
@ -447,6 +566,7 @@ class _PublisherPostList extends StatelessWidget {
final void Function() fetchPosts; final void Function() fetchPosts;
final void Function(int index, SnPost data) onChanged; final void Function(int index, SnPost data) onChanged;
final void Function() onDeleted; final void Function() onDeleted;
const _PublisherPostList({ const _PublisherPostList({
super.key, super.key,
required this.isBusy, required this.isBusy,

View File

@ -6,16 +6,15 @@ import 'package:material_symbols_icons/symbols.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:styled_widget/styled_widget.dart'; import 'package:styled_widget/styled_widget.dart';
import 'package:surface/providers/sn_network.dart'; import 'package:surface/providers/sn_network.dart';
import 'package:surface/providers/userinfo.dart';
import 'package:surface/types/realm.dart'; import 'package:surface/types/realm.dart';
import 'package:surface/widgets/account/account_image.dart'; import 'package:surface/widgets/account/account_image.dart';
import 'package:surface/widgets/app_bar_leading.dart'; import 'package:surface/widgets/app_bar_leading.dart';
import 'package:surface/widgets/dialog.dart'; import 'package:surface/widgets/dialog.dart';
import 'package:surface/widgets/loading_indicator.dart'; import 'package:surface/widgets/loading_indicator.dart';
import 'package:surface/widgets/unauthorized_hint.dart';
import 'package:surface/widgets/universal_image.dart'; import 'package:surface/widgets/universal_image.dart';
import '../providers/userinfo.dart';
import '../widgets/unauthorized_hint.dart';
class RealmScreen extends StatefulWidget { class RealmScreen extends StatefulWidget {
const RealmScreen({super.key}); const RealmScreen({super.key});
@ -101,9 +100,7 @@ class _RealmScreenState extends State<RealmScreen> {
title: Text('screenRealm').tr(), title: Text('screenRealm').tr(),
actions: [ actions: [
IconButton( IconButton(
icon: !_isCompactView icon: !_isCompactView ? const Icon(Symbols.view_list) : const Icon(Symbols.view_module),
? const Icon(Symbols.view_list)
: const Icon(Symbols.view_module),
onPressed: () { onPressed: () {
setState(() => _isCompactView = !_isCompactView); setState(() => _isCompactView = !_isCompactView);
}, },
@ -129,8 +126,7 @@ class _RealmScreenState extends State<RealmScreen> {
final realm = _realms![idx]; final realm = _realms![idx];
if (_isCompactView) { if (_isCompactView) {
return ListTile( return ListTile(
contentPadding: contentPadding: const EdgeInsets.symmetric(horizontal: 16),
const EdgeInsets.symmetric(horizontal: 16),
leading: AccountImage( leading: AccountImage(
content: realm.avatar, content: realm.avatar,
fallbackWidget: const Icon(Symbols.group, size: 20), fallbackWidget: const Icon(Symbols.group, size: 20),
@ -201,9 +197,7 @@ class _RealmScreenState extends State<RealmScreen> {
fit: StackFit.expand, fit: StackFit.expand,
children: [ children: [
Container( Container(
color: Theme.of(context) color: Theme.of(context).colorScheme.surfaceContainer,
.colorScheme
.surfaceContainer,
child: (realm.banner?.isEmpty ?? true) child: (realm.banner?.isEmpty ?? true)
? const SizedBox.shrink() ? const SizedBox.shrink()
: AutoResizeUniversalImage( : AutoResizeUniversalImage(
@ -217,8 +211,7 @@ class _RealmScreenState extends State<RealmScreen> {
child: AccountImage( child: AccountImage(
content: realm.avatar, content: realm.avatar,
radius: 24, radius: 24,
fallbackWidget: fallbackWidget: const Icon(Symbols.group, size: 24),
const Icon(Symbols.group, size: 24),
), ),
), ),
], ],
@ -228,10 +221,8 @@ class _RealmScreenState extends State<RealmScreen> {
Column( Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text(realm.name).textStyle( Text(realm.name).textStyle(Theme.of(context).textTheme.titleMedium!),
Theme.of(context).textTheme.titleMedium!), Text(realm.description).textStyle(Theme.of(context).textTheme.bodySmall!),
Text(realm.description).textStyle(
Theme.of(context).textTheme.bodySmall!),
], ],
).padding(horizontal: 24, bottom: 14), ).padding(horizontal: 24, bottom: 14),
], ],

View File

@ -13,8 +13,11 @@ import 'package:surface/widgets/account/account_image.dart';
import 'package:surface/widgets/dialog.dart'; import 'package:surface/widgets/dialog.dart';
import 'package:very_good_infinite_list/very_good_infinite_list.dart'; import 'package:very_good_infinite_list/very_good_infinite_list.dart';
import '../../types/post.dart';
class RealmDetailScreen extends StatefulWidget { class RealmDetailScreen extends StatefulWidget {
final String alias; final String alias;
const RealmDetailScreen({super.key, required this.alias}); const RealmDetailScreen({super.key, required this.alias});
@override @override
@ -32,6 +35,24 @@ class _RealmDetailScreenState extends State<RealmDetailScreen> {
} catch (err) { } catch (err) {
if (!mounted) return; if (!mounted) return;
context.showErrorDialog(err); context.showErrorDialog(err);
rethrow;
} finally {
setState(() {});
}
}
List<SnPublisher>? _publishers;
Future<void> _fetchPublishers() async {
try {
final sn = context.read<SnNetworkProvider>();
final resp = await sn.client.get('/cgi/co/publishers?realm=${widget.alias}');
_publishers = List<SnPublisher>.from(
resp.data?.map((e) => SnPublisher.fromJson(e)) ?? [],
);
} catch (err) {
if (mounted) context.showErrorDialog(err);
rethrow;
} finally { } finally {
setState(() {}); setState(() {});
} }
@ -40,7 +61,9 @@ class _RealmDetailScreenState extends State<RealmDetailScreen> {
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_fetchRealm(); _fetchRealm().then((_) {
_fetchPublishers();
});
} }
@override @override
@ -60,8 +83,7 @@ class _RealmDetailScreenState extends State<RealmDetailScreen> {
// scroll view thinks it has not been scrolled. // scroll view thinks it has not been scrolled.
// This is not necessary if the "headerSliverBuilder" only builds // This is not necessary if the "headerSliverBuilder" only builds
// widgets that do not overlap the next sliver. // widgets that do not overlap the next sliver.
handle: handle: NestedScrollView.sliverOverlapAbsorberHandleFor(context),
NestedScrollView.sliverOverlapAbsorberHandleFor(context),
sliver: SliverAppBar( sliver: SliverAppBar(
title: Text(_realm?.name ?? 'loading'.tr()), title: Text(_realm?.name ?? 'loading'.tr()),
bottom: TabBar( bottom: TabBar(
@ -77,7 +99,7 @@ class _RealmDetailScreenState extends State<RealmDetailScreen> {
}, },
body: TabBarView( body: TabBarView(
children: [ children: [
_RealmDetailHomeWidget(realm: _realm), _RealmDetailHomeWidget(realm: _realm, publishers: _publishers),
_RealmMemberListWidget(realm: _realm), _RealmMemberListWidget(realm: _realm),
_RealmSettingsWidget( _RealmSettingsWidget(
realm: _realm, realm: _realm,
@ -95,7 +117,9 @@ class _RealmDetailScreenState extends State<RealmDetailScreen> {
class _RealmDetailHomeWidget extends StatelessWidget { class _RealmDetailHomeWidget extends StatelessWidget {
final SnRealm? realm; final SnRealm? realm;
const _RealmDetailHomeWidget({super.key, required this.realm}); final List<SnPublisher>? publishers;
const _RealmDetailHomeWidget({super.key, required this.realm, this.publishers});
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@ -118,6 +142,31 @@ class _RealmDetailHomeWidget extends StatelessWidget {
).padding(horizontal: 24), ).padding(horizontal: 24),
const Gap(16), const Gap(16),
const Divider(), const Divider(),
Expanded(
child: ListView.builder(
padding: EdgeInsets.zero,
itemCount: publishers?.length ?? 0,
itemBuilder: (context, idx) {
final ele = publishers![idx];
return ListTile(
contentPadding: const EdgeInsets.symmetric(horizontal: 20),
leading: AccountImage(
content: ele.avatar,
fallbackWidget: const Icon(Symbols.group, size: 24),
),
title: Text(ele.nick),
subtitle: Text('@${ele.name}'),
trailing: const Icon(Symbols.chevron_right),
onTap: () {
GoRouter.of(context).pushNamed(
'postPublisher',
pathParameters: {'name': ele.name},
);
},
);
},
),
),
], ],
); );
} }
@ -125,6 +174,7 @@ class _RealmDetailHomeWidget extends StatelessWidget {
class _RealmMemberListWidget extends StatefulWidget { class _RealmMemberListWidget extends StatefulWidget {
final SnRealm? realm; final SnRealm? realm;
const _RealmMemberListWidget({super.key, this.realm}); const _RealmMemberListWidget({super.key, this.realm});
@override @override
@ -143,12 +193,10 @@ class _RealmMemberListWidgetState extends State<_RealmMemberListWidget> {
try { try {
final ud = context.read<UserDirectoryProvider>(); final ud = context.read<UserDirectoryProvider>();
final sn = context.read<SnNetworkProvider>(); final sn = context.read<SnNetworkProvider>();
final resp = await sn.client.get( final resp = await sn.client.get('/cgi/id/realms/${widget.realm!.alias}/members', queryParameters: {
'/cgi/id/realms/${widget.realm!.alias}/members', 'take': 10,
queryParameters: { 'offset': 0,
'take': 10, });
'offset': 0,
});
final out = List<SnRealmMember>.from( final out = List<SnRealmMember>.from(
resp.data['data']?.map((e) => SnRealmMember.fromJson(e)) ?? [], resp.data['data']?.map((e) => SnRealmMember.fromJson(e)) ?? [],
@ -236,12 +284,10 @@ class _RealmMemberListWidgetState extends State<_RealmMemberListWidget> {
fallbackWidget: const Icon(Symbols.group, size: 24), fallbackWidget: const Icon(Symbols.group, size: 24),
), ),
title: Text( title: Text(
ud.getAccountFromCache(member.accountId)?.nick ?? ud.getAccountFromCache(member.accountId)?.nick ?? 'unknown'.tr(),
'unknown'.tr(),
), ),
subtitle: Text( subtitle: Text(
ud.getAccountFromCache(member.accountId)?.name ?? ud.getAccountFromCache(member.accountId)?.name ?? 'unknown'.tr(),
'unknown'.tr(),
), ),
trailing: IconButton( trailing: IconButton(
icon: const Icon(Symbols.person_remove), icon: const Icon(Symbols.person_remove),
@ -257,6 +303,7 @@ class _RealmMemberListWidgetState extends State<_RealmMemberListWidget> {
class _NewRealmMemberWidget extends StatefulWidget { class _NewRealmMemberWidget extends StatefulWidget {
final SnRealm realm; final SnRealm realm;
const _NewRealmMemberWidget({super.key, required this.realm}); const _NewRealmMemberWidget({super.key, required this.realm});
@override @override
@ -321,8 +368,7 @@ class _NewRealmMemberWidgetState extends State<_NewRealmMemberWidget> {
child: IconButton( child: IconButton(
onPressed: _isBusy ? null : () => _performAction(), onPressed: _isBusy ? null : () => _performAction(),
icon: Icon(Symbols.send), icon: Icon(Symbols.send),
visualDensity: visualDensity: const VisualDensity(horizontal: -4, vertical: -4),
const VisualDensity(horizontal: -4, vertical: -4),
padding: EdgeInsets.zero, padding: EdgeInsets.zero,
), ),
), ),
@ -337,8 +383,8 @@ class _NewRealmMemberWidgetState extends State<_NewRealmMemberWidget> {
class _RealmSettingsWidget extends StatefulWidget { class _RealmSettingsWidget extends StatefulWidget {
final SnRealm? realm; final SnRealm? realm;
final Function() onUpdate; final Function() onUpdate;
const _RealmSettingsWidget(
{super.key, required this.realm, required this.onUpdate}); const _RealmSettingsWidget({super.key, required this.realm, required this.onUpdate});
@override @override
State<_RealmSettingsWidget> createState() => _RealmSettingsWidgetState(); State<_RealmSettingsWidget> createState() => _RealmSettingsWidgetState();
@ -382,6 +428,7 @@ class _RealmSettingsWidgetState extends State<_RealmSettingsWidget> {
return Column( return Column(
children: [ children: [
const Gap(16),
ListTile( ListTile(
leading: const Icon(Symbols.edit), leading: const Icon(Symbols.edit),
trailing: const Icon(Symbols.chevron_right), trailing: const Icon(Symbols.chevron_right),

View File

@ -130,7 +130,7 @@ class _AttachmentListState extends State<AttachmentList> {
} }
return AspectRatio( return AspectRatio(
aspectRatio: widget.data.firstOrNull?.metadata['ratio'] ?? 1, aspectRatio: (widget.data.firstOrNull?.metadata['ratio'] ?? 1).toDouble(),
child: Container( child: Container(
constraints: BoxConstraints(maxHeight: constraints.maxHeight), constraints: BoxConstraints(maxHeight: constraints.maxHeight),
child: ScrollConfiguration( child: ScrollConfiguration(
@ -142,7 +142,7 @@ class _AttachmentListState extends State<AttachmentList> {
return Container( return Container(
constraints: constraints, constraints: constraints,
child: AspectRatio( child: AspectRatio(
aspectRatio: widget.data[idx]?.metadata['ratio'] ?? 1, aspectRatio: (widget.data[idx]?.metadata['ratio'] ?? 1).toDouble(),
child: GestureDetector( child: GestureDetector(
onTap: () { onTap: () {
context.pushTransparentRoute( context.pushTransparentRoute(

View File

@ -99,7 +99,7 @@ extension AppPromptExtension on BuildContext {
if (exception.response != null) { if (exception.response != null) {
content = Text( content = Text(
'$preview\n\n(${exception.response?.statusCode}) ${exception.response?.data}', '$preview\n\n${exception.requestOptions.uri.path}\n(${exception.response?.statusCode}) ${exception.response?.data}',
); );
} else { } else {
content = Text(preview); content = Text(preview);

View File

@ -7,12 +7,11 @@ import 'package:responsive_framework/responsive_framework.dart';
class AppBackground extends StatelessWidget { class AppBackground extends StatelessWidget {
final Widget child; final Widget child;
final bool isLessOptimization;
final bool isRoot; final bool isRoot;
const AppBackground({ const AppBackground({
super.key, super.key,
required this.child, required this.child,
this.isLessOptimization = false,
this.isRoot = false, this.isRoot = false,
}); });
@ -23,52 +22,25 @@ class AppBackground extends StatelessWidget {
) { ) {
final devicePixelRatio = MediaQuery.of(context).devicePixelRatio; final devicePixelRatio = MediaQuery.of(context).devicePixelRatio;
if (isLessOptimization) { final size = MediaQuery.of(context).size;
final size = MediaQuery.of(context).size;
return Container(
color: Theme.of(context).colorScheme.surface,
child: Container(
decoration: BoxDecoration(
backgroundBlendMode: BlendMode.darken,
color: Theme.of(context).colorScheme.surface,
image: DecorationImage(
opacity: 0.2,
image: ResizeImage(
FileImage(imageFile),
width: (size.width * devicePixelRatio).round(),
height: (size.height * devicePixelRatio).round(),
policy: ResizeImagePolicy.fit,
),
fit: BoxFit.cover,
),
),
child: child,
),
);
}
return Container( return Container(
color: Theme.of(context).colorScheme.surface, color: Theme.of(context).colorScheme.surface,
child: LayoutBuilder( child: Container(
builder: (context, constraints) { decoration: BoxDecoration(
return Container( backgroundBlendMode: BlendMode.darken,
decoration: BoxDecoration( color: Theme.of(context).colorScheme.surface,
backgroundBlendMode: BlendMode.darken, image: DecorationImage(
color: Theme.of(context).colorScheme.surface, opacity: 0.2,
image: DecorationImage( image: ResizeImage(
opacity: 0.2, FileImage(imageFile),
image: ResizeImage( width: (size.width * devicePixelRatio).round(),
FileImage(imageFile), height: (size.height * devicePixelRatio).round(),
width: (constraints.maxWidth * devicePixelRatio).round(), policy: ResizeImagePolicy.fit,
height: (constraints.maxHeight * devicePixelRatio).round(),
policy: ResizeImagePolicy.fit,
),
fit: BoxFit.cover,
),
), ),
child: child, fit: BoxFit.cover,
); ),
}, ),
child: child,
), ),
); );
} }
@ -77,11 +49,9 @@ class AppBackground extends StatelessWidget {
Widget build(BuildContext context) { Widget build(BuildContext context) {
return ScaffoldMessenger( return ScaffoldMessenger(
child: FutureBuilder( child: FutureBuilder(
future: future: kIsWeb ? Future.value(null) : getApplicationDocumentsDirectory(),
kIsWeb ? Future.value(null) : getApplicationDocumentsDirectory(),
builder: (context, snapshot) { builder: (context, snapshot) {
if (isRoot || if (isRoot || ResponsiveBreakpoints.of(context).smallerOrEqualTo(MOBILE)) {
ResponsiveBreakpoints.of(context).smallerOrEqualTo(MOBILE)) {
if (snapshot.hasData) { if (snapshot.hasData) {
final path = '${snapshot.data!.path}/app_background_image'; final path = '${snapshot.data!.path}/app_background_image';
final file = File(path); final file = File(path);