diff --git a/lib/i18n/app_en.arb b/lib/i18n/app_en.arb index adc1d86..f426eff 100644 --- a/lib/i18n/app_en.arb +++ b/lib/i18n/app_en.arb @@ -10,6 +10,7 @@ "signInRiskDetected": "Risk detected, click Next to open a webpage and signin through it to pass security check.", "signUp": "Sign Up", "signUpCaption": "Create an account on Solarpass and then get the access of entire Solar Networks!", + "signOut": "Sign Out", "poweredBy": "Powered by Project Hydrogen", "copyright": "Copyright © 2024 Solsynth LLC", "confirmation": "Confirmation", @@ -30,6 +31,11 @@ "notifyDone": "You're done!", "notifyDoneCaption": "There are no notifications unread for you.", "notifyListHint": "Pull to refresh, swipe to dismiss", + "friend": "Friend", + "friendPending": "Pending", + "friendActive": "Active", + "friendBlocked": "Blocked", + "friendListHint": "Swipe left to decline, swipe right to approve", "reaction": "Reaction", "reactVerb": "React", "post": "Post", diff --git a/lib/i18n/app_zh.arb b/lib/i18n/app_zh.arb index 033ef4e..1aafd55 100644 --- a/lib/i18n/app_zh.arb +++ b/lib/i18n/app_zh.arb @@ -4,12 +4,13 @@ "chat": "聊天", "account": "账号", "riskDetection": "风险监测", - "signIn": "登陆", - "signInCaption": "登陆以发表帖子、文章、创建领域、和你的朋友聊天,以及获取更多功能!", - "signInRequired": "请先登陆", - "signInRiskDetected": "检测到风险,点击下一步按钮来打开一个网页,并通过在其上面登陆来通过安全检查。", + "signIn": "登录", + "signInCaption": "登录以发表帖子、文章、创建领域、和你的朋友聊天,以及获取更多功能!", + "signInRequired": "请先登录", + "signInRiskDetected": "检测到风险,点击下一步按钮来打开一个网页,并通过在其上面登录来通过安全检查。", "signUp": "注册", "signUpCaption": "在 Solarpass 注册一个账号以获得整个 Solar Networks 的存取权!", + "signOut": "登出", "poweredBy": "由 Project Hydrogen 强力驱动", "copyright": "2024 Solsynth LLC © 版权所有", "confirmation": "确认", @@ -30,6 +31,11 @@ "notifyDone": "所有通知已读!", "notifyDoneCaption": "这里没有什么东西可以给你看的了~", "notifyListHint": "下拉以刷新,左滑来已读", + "friend": "好友", + "friendPending": "请求中", + "friendActive": "活跃的好友", + "friendBlocked": "封锁中", + "friendListHint": "左滑来拒绝,右滑来接受", "reaction": "反应", "reactVerb": "作出反应", "post": "帖子", diff --git a/lib/models/account.dart b/lib/models/account.dart index 349d027..312492e 100644 --- a/lib/models/account.dart +++ b/lib/models/account.dart @@ -8,9 +8,9 @@ class Account { String avatar; String banner; String description; - String emailAddress; + String? emailAddress; int powerLevel; - int externalId; + int? externalId; Account({ required this.id, @@ -22,9 +22,9 @@ class Account { required this.avatar, required this.banner, required this.description, - required this.emailAddress, + this.emailAddress, required this.powerLevel, - required this.externalId, + this.externalId, }); factory Account.fromJson(Map json) => Account( diff --git a/lib/models/friendship.dart b/lib/models/friendship.dart new file mode 100644 index 0000000..56ebc34 --- /dev/null +++ b/lib/models/friendship.dart @@ -0,0 +1,53 @@ +import 'package:solian/models/account.dart'; + +class Friendship { + int id; + DateTime createdAt; + DateTime updatedAt; + DateTime? deletedAt; + int accountId; + int relatedId; + int? blockedBy; + Account account; + Account related; + int status; + + Friendship({ + required this.id, + required this.createdAt, + required this.updatedAt, + this.deletedAt, + required this.accountId, + required this.relatedId, + this.blockedBy, + required this.account, + required this.related, + required this.status, + }); + + factory Friendship.fromJson(Map json) => Friendship( + id: json["id"], + createdAt: DateTime.parse(json["created_at"]), + updatedAt: DateTime.parse(json["updated_at"]), + deletedAt: json["deleted_at"], + accountId: json["account_id"], + relatedId: json["related_id"], + blockedBy: json["blocked_by"], + account: Account.fromJson(json["account"]), + related: Account.fromJson(json["related"]), + status: json["status"], + ); + + Map toJson() => { + "id": id, + "created_at": createdAt.toIso8601String(), + "updated_at": updatedAt.toIso8601String(), + "deleted_at": deletedAt, + "account_id": accountId, + "related_id": relatedId, + "blocked_by": blockedBy, + "account": account.toJson(), + "related": related.toJson(), + "status": status, + }; +} \ No newline at end of file diff --git a/lib/providers/notify.dart b/lib/providers/notify.dart index d2c06cf..dead1a2 100644 --- a/lib/providers/notify.dart +++ b/lib/providers/notify.dart @@ -50,6 +50,11 @@ class NotifyProvider extends ChangeNotifier { notifyListeners(); } + void clearAt(int index) { + notifications.removeAt(index); + notifyListeners(); + } + void clearNonRealtime() { notifications = notifications.where((x) => !x.isRealtime).toList(); } diff --git a/lib/router.dart b/lib/router.dart index 5ab471e..3516a41 100644 --- a/lib/router.dart +++ b/lib/router.dart @@ -2,6 +2,7 @@ import 'package:go_router/go_router.dart'; import 'package:solian/models/channel.dart'; import 'package:solian/models/post.dart'; import 'package:solian/screens/account.dart'; +import 'package:solian/screens/account/friend.dart'; import 'package:solian/screens/chat/chat.dart'; import 'package:solian/screens/chat/index.dart'; import 'package:solian/screens/chat/manage.dart'; @@ -20,6 +21,11 @@ final router = GoRouter( name: 'explore', builder: (context, state) => const ExploreScreen(), ), + GoRoute( + path: '/notification', + name: 'notification', + builder: (context, state) => const NotificationScreen(), + ), GoRoute( path: '/chat', name: 'chat', @@ -66,15 +72,15 @@ final router = GoRouter( dataset: state.pathParameters['dataset'] as String, ), ), - GoRoute( - path: '/notification', - name: 'notification', - builder: (context, state) => const NotificationScreen(), - ), GoRoute( path: '/auth/sign-in', name: 'auth.sign-in', builder: (context, state) => SignInScreen(), ), + GoRoute( + path: '/account/friend', + name: 'account.friend', + builder: (context, state) => const FriendScreen(), + ), ], ); diff --git a/lib/screens/account.dart b/lib/screens/account.dart index e0ad63f..988b172 100644 --- a/lib/screens/account.dart +++ b/lib/screens/account.dart @@ -1,11 +1,11 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import 'package:solian/providers/auth.dart'; -import 'package:flutter_gen/gen_l10n/app_localizations.dart'; import 'package:solian/router.dart'; import 'package:solian/utils/service_url.dart'; import 'package:solian/widgets/common_wrapper.dart'; import 'package:url_launcher/url_launcher.dart'; +import 'package:flutter_gen/gen_l10n/app_localizations.dart'; class AccountScreen extends StatefulWidget { const AccountScreen({super.key}); @@ -40,21 +40,25 @@ class _AccountScreenState extends State { padding: EdgeInsets.symmetric(vertical: 8, horizontal: 24), child: NameCard(), ), - InkWell( - child: const Padding( - padding: EdgeInsets.symmetric(horizontal: 18), - child: ListTile( - leading: Icon(Icons.logout), - title: Text("Sign out"), - ), - ), + ListTile( + contentPadding: const EdgeInsets.symmetric(horizontal: 34), + leading: const Icon(Icons.diversity_1), + title: Text(AppLocalizations.of(context)!.friend), + onTap: () { + router.goNamed('account.friend'); + }, + ), + ListTile( + contentPadding: const EdgeInsets.symmetric(horizontal: 34), + leading: const Icon(Icons.logout), + title: Text(AppLocalizations.of(context)!.signOut), onTap: () { auth.signoff(); setState(() { isAuthorized = false; }); }, - ) + ), ], ) : Center( @@ -126,8 +130,7 @@ class NameCard extends StatelessWidget { children: [ FutureBuilder( future: renderAvatar(context), - builder: - (BuildContext context, AsyncSnapshot snapshot) { + builder: (BuildContext context, AsyncSnapshot snapshot) { if (snapshot.hasData) { return snapshot.data!; } else { @@ -138,8 +141,7 @@ class NameCard extends StatelessWidget { const SizedBox(width: 20), FutureBuilder( future: renderLabel(context), - builder: - (BuildContext context, AsyncSnapshot snapshot) { + builder: (BuildContext context, AsyncSnapshot snapshot) { if (snapshot.hasData) { return snapshot.data!; } else { @@ -161,12 +163,7 @@ class ActionCard extends StatelessWidget { final String caption; final Function onTap; - const ActionCard( - {super.key, - required this.onTap, - required this.title, - required this.caption, - required this.icon}); + const ActionCard({super.key, required this.onTap, required this.title, required this.caption, required this.icon}); @override Widget build(BuildContext context) { diff --git a/lib/screens/account/friend.dart b/lib/screens/account/friend.dart new file mode 100644 index 0000000..c0b1e29 --- /dev/null +++ b/lib/screens/account/friend.dart @@ -0,0 +1,218 @@ +import 'dart:convert'; + +import 'package:flutter/material.dart'; +import 'package:flutter_animate/flutter_animate.dart'; +import 'package:provider/provider.dart'; +import 'package:solian/models/account.dart'; +import 'package:solian/models/friendship.dart'; +import 'package:solian/providers/auth.dart'; +import 'package:solian/utils/service_url.dart'; +import 'package:solian/widgets/indent_wrapper.dart'; +import 'package:flutter_gen/gen_l10n/app_localizations.dart'; + +class FriendScreen extends StatefulWidget { + const FriendScreen({super.key}); + + @override + State createState() => _FriendScreenState(); +} + +class _FriendScreenState extends State { + bool _isSubmitting = false; + + int _currentSideId = 0; + List _friendships = List.empty(); + + Future fetchFriendships() async { + final auth = context.read(); + final prof = await auth.getProfiles(); + if (!await auth.isAuthorized()) return; + + _currentSideId = prof['id']; + + var uri = getRequestUri('passport', '/api/users/me/friends'); + + var res = await auth.client!.get(uri); + if (res.statusCode == 200) { + final result = jsonDecode(utf8.decode(res.bodyBytes)) as List; + setState(() { + _friendships = result.map((x) => Friendship.fromJson(x)).toList(); + }); + } else { + var message = utf8.decode(res.bodyBytes); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text("Something went wrong... $message")), + ); + } + } + + Future updateFriendship(Friendship relation, int status) async { + setState(() => _isSubmitting = true); + + final otherside = getOtherside(relation); + + final auth = context.read(); + if (!await auth.isAuthorized()) { + setState(() => _isSubmitting = false); + return; + } + + var res = await auth.client!.put( + getRequestUri('passport', '/api/users/me/friends/${otherside.id}'), + headers: { + 'Content-Type': 'application/json', + }, + body: jsonEncode({ + 'status': status, + }), + ); + if (res.statusCode == 200) { + await fetchFriendships(); + } else { + var message = utf8.decode(res.bodyBytes); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text("Something went wrong... $message")), + ); + } + + setState(() => _isSubmitting = false); + } + + List filterWithStatus(int status) { + return _friendships.where((x) => x.status == status).toList(); + } + + DismissDirection getDismissDirection(Friendship relation) { + if (relation.status == 2) return DismissDirection.endToStart; + if (relation.status == 1) return DismissDirection.startToEnd; + if (relation.status == 0 && relation.relatedId != _currentSideId) return DismissDirection.startToEnd; + return DismissDirection.horizontal; + } + + Account getOtherside(Friendship relation) { + if (relation.accountId != _currentSideId) { + return relation.account; + } else { + return relation.related; + } + } + + String getAvatarUrl(String uuid) { + return getRequestUri('passport', '/api/avatar/$uuid').toString(); + } + + @override + void initState() { + super.initState(); + + Future.delayed(Duration.zero, () { + fetchFriendships(); + }); + } + + @override + Widget build(BuildContext context) { + Widget friendshipTileBuilder(context, index, status) { + final element = filterWithStatus(status)[index]; + final otherside = getOtherside(element); + + final randomId = DateTime.now().microsecondsSinceEpoch >> 10; + + return Dismissible( + key: Key(randomId.toString()), + background: Container( + color: Colors.red, + padding: const EdgeInsets.symmetric(horizontal: 20), + alignment: Alignment.centerLeft, + child: const Icon(Icons.close, color: Colors.white), + ), + secondaryBackground: Container( + color: Colors.green, + padding: const EdgeInsets.symmetric(horizontal: 20), + alignment: Alignment.centerRight, + child: const Icon(Icons.check, color: Colors.white), + ), + direction: getDismissDirection(element), + child: ListTile( + title: Text(otherside.nick), + subtitle: Text(otherside.name), + leading: CircleAvatar( + backgroundImage: NetworkImage(getAvatarUrl(otherside.avatar)), + ), + ), + onDismissed: (direction) { + if (direction == DismissDirection.startToEnd) { + updateFriendship(element, 2); + } + if (direction == DismissDirection.endToStart) { + updateFriendship(element, 1); + } + }, + ); + } + + return IndentWrapper( + title: AppLocalizations.of(context)!.friend, + child: RefreshIndicator( + onRefresh: () => fetchFriendships(), + child: CustomScrollView( + slivers: [ + SliverToBoxAdapter( + child: _isSubmitting ? const LinearProgressIndicator().animate().scaleX() : Container(), + ), + SliverToBoxAdapter( + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 12), + color: Theme.of(context).colorScheme.surfaceVariant.withOpacity(0.8), + child: Text(AppLocalizations.of(context)!.friendPending), + ), + ), + SliverList.builder( + itemCount: filterWithStatus(0).length, + itemBuilder: (_, __) => friendshipTileBuilder(_, __, 0), + ), + SliverToBoxAdapter( + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 12), + color: Theme.of(context).colorScheme.surfaceVariant.withOpacity(0.8), + child: Text(AppLocalizations.of(context)!.friendActive), + ), + ), + SliverList.builder( + itemCount: filterWithStatus(1).length, + itemBuilder: (_, __) => friendshipTileBuilder(_, __, 1), + ), + SliverToBoxAdapter( + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 12), + color: Theme.of(context).colorScheme.surfaceVariant.withOpacity(0.8), + child: Text(AppLocalizations.of(context)!.friendBlocked), + ), + ), + SliverList.builder( + itemCount: filterWithStatus(2).length, + itemBuilder: (_, __) => friendshipTileBuilder(_, __, 2), + ), + SliverToBoxAdapter( + child: Container( + decoration: BoxDecoration( + border: Border( + top: BorderSide( + color: Theme.of(context).colorScheme.surfaceVariant.withOpacity(0.8), + width: 0.3, + )), + ), + padding: const EdgeInsets.only(top: 16), + child: Text( + AppLocalizations.of(context)!.friendListHint, + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.bodySmall, + ), + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/screens/explore.dart b/lib/screens/explore.dart index 14a4d1f..be6256f 100644 --- a/lib/screens/explore.dart +++ b/lib/screens/explore.dart @@ -83,26 +83,21 @@ class _ExploreScreenState extends State { onRefresh: () => Future.sync( () => _pagingController.refresh(), ), - child: Center( - child: Container( - constraints: const BoxConstraints(maxWidth: 640), - child: PagedListView( - pagingController: _pagingController, - builderDelegate: PagedChildBuilderDelegate( - itemBuilder: (context, item, index) => PostItem( - item: item, - onUpdate: () => _pagingController.refresh(), - onTap: () { - router.pushNamed( - 'posts.screen', - pathParameters: { - 'alias': item.alias, - 'dataset': '${item.modelType}s', - }, - ); + child: PagedListView( + pagingController: _pagingController, + builderDelegate: PagedChildBuilderDelegate( + itemBuilder: (context, item, index) => PostItem( + item: item, + onUpdate: () => _pagingController.refresh(), + onTap: () { + router.pushNamed( + 'posts.screen', + pathParameters: { + 'alias': item.alias, + 'dataset': '${item.modelType}s', }, - ), - ), + ); + }, ), ), ), diff --git a/lib/screens/notification.dart b/lib/screens/notification.dart index 8e2b8fb..c2760f6 100644 --- a/lib/screens/notification.dart +++ b/lib/screens/notification.dart @@ -49,7 +49,7 @@ class _NotificationScreenState extends State { index: index, item: element, onDismiss: () => setState(() { - nty.notifications.removeAt(index); + nty.clearAt(index); }), ); }, diff --git a/lib/screens/posts/screen.dart b/lib/screens/posts/screen.dart index d041e31..0aafb9c 100644 --- a/lib/screens/posts/screen.dart +++ b/lib/screens/posts/screen.dart @@ -23,12 +23,10 @@ class PostScreen extends StatefulWidget { class _PostScreenState extends State { final _client = http.Client(); - final PagingController _commentPagingController = - PagingController(firstPageKey: 0); + final PagingController _commentPagingController = PagingController(firstPageKey: 0); Future fetchPost(BuildContext context) async { - final uri = getRequestUri( - 'interactive', '/api/p/${widget.dataset}/${widget.alias}'); + final uri = getRequestUri('interactive', '/api/p/${widget.dataset}/${widget.alias}'); final res = await _client.get(uri); if (res.statusCode != 200) { final err = utf8.decode(res.bodyBytes); @@ -51,32 +49,27 @@ class _PostScreenState extends State { future: fetchPost(context), builder: (context, snapshot) { if (snapshot.hasData && snapshot.data != null) { - return Center( - child: Container( - constraints: const BoxConstraints(maxWidth: 640), - child: CustomScrollView( - slivers: [ - SliverToBoxAdapter( - child: PostItem( - item: snapshot.data!, - brief: false, - ripple: false, - ), - ), - SliverToBoxAdapter( - child: CommentListHeader( - related: snapshot.data!, - paging: _commentPagingController, - ), - ), - CommentList( - related: snapshot.data!, - dataset: widget.dataset, - paging: _commentPagingController, - ), - ], + return CustomScrollView( + slivers: [ + SliverToBoxAdapter( + child: PostItem( + item: snapshot.data!, + brief: false, + ripple: false, + ), ), - ), + SliverToBoxAdapter( + child: CommentListHeader( + related: snapshot.data!, + paging: _commentPagingController, + ), + ), + CommentList( + related: snapshot.data!, + dataset: widget.dataset, + paging: _commentPagingController, + ), + ], ); } else { return const Center(