Existing friends management

This commit is contained in:
LittleSheep 2024-04-25 23:03:16 +08:00
parent 0230ea5c79
commit 5346224f1e
11 changed files with 361 additions and 82 deletions

View File

@ -10,6 +10,7 @@
"signInRiskDetected": "Risk detected, click Next to open a webpage and signin through it to pass security check.", "signInRiskDetected": "Risk detected, click Next to open a webpage and signin through it to pass security check.",
"signUp": "Sign Up", "signUp": "Sign Up",
"signUpCaption": "Create an account on Solarpass and then get the access of entire Solar Networks!", "signUpCaption": "Create an account on Solarpass and then get the access of entire Solar Networks!",
"signOut": "Sign Out",
"poweredBy": "Powered by Project Hydrogen", "poweredBy": "Powered by Project Hydrogen",
"copyright": "Copyright © 2024 Solsynth LLC", "copyright": "Copyright © 2024 Solsynth LLC",
"confirmation": "Confirmation", "confirmation": "Confirmation",
@ -30,6 +31,11 @@
"notifyDone": "You're done!", "notifyDone": "You're done!",
"notifyDoneCaption": "There are no notifications unread for you.", "notifyDoneCaption": "There are no notifications unread for you.",
"notifyListHint": "Pull to refresh, swipe to dismiss", "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", "reaction": "Reaction",
"reactVerb": "React", "reactVerb": "React",
"post": "Post", "post": "Post",

View File

@ -4,12 +4,13 @@
"chat": "聊天", "chat": "聊天",
"account": "账号", "account": "账号",
"riskDetection": "风险监测", "riskDetection": "风险监测",
"signIn": "登", "signIn": "登",
"signInCaption": "登以发表帖子、文章、创建领域、和你的朋友聊天,以及获取更多功能!", "signInCaption": "登以发表帖子、文章、创建领域、和你的朋友聊天,以及获取更多功能!",
"signInRequired": "请先登", "signInRequired": "请先登",
"signInRiskDetected": "检测到风险,点击下一步按钮来打开一个网页,并通过在其上面登来通过安全检查。", "signInRiskDetected": "检测到风险,点击下一步按钮来打开一个网页,并通过在其上面登来通过安全检查。",
"signUp": "注册", "signUp": "注册",
"signUpCaption": "在 Solarpass 注册一个账号以获得整个 Solar Networks 的存取权!", "signUpCaption": "在 Solarpass 注册一个账号以获得整个 Solar Networks 的存取权!",
"signOut": "登出",
"poweredBy": "由 Project Hydrogen 强力驱动", "poweredBy": "由 Project Hydrogen 强力驱动",
"copyright": "2024 Solsynth LLC © 版权所有", "copyright": "2024 Solsynth LLC © 版权所有",
"confirmation": "确认", "confirmation": "确认",
@ -30,6 +31,11 @@
"notifyDone": "所有通知已读!", "notifyDone": "所有通知已读!",
"notifyDoneCaption": "这里没有什么东西可以给你看的了~", "notifyDoneCaption": "这里没有什么东西可以给你看的了~",
"notifyListHint": "下拉以刷新,左滑来已读", "notifyListHint": "下拉以刷新,左滑来已读",
"friend": "好友",
"friendPending": "请求中",
"friendActive": "活跃的好友",
"friendBlocked": "封锁中",
"friendListHint": "左滑来拒绝,右滑来接受",
"reaction": "反应", "reaction": "反应",
"reactVerb": "作出反应", "reactVerb": "作出反应",
"post": "帖子", "post": "帖子",

View File

@ -8,9 +8,9 @@ class Account {
String avatar; String avatar;
String banner; String banner;
String description; String description;
String emailAddress; String? emailAddress;
int powerLevel; int powerLevel;
int externalId; int? externalId;
Account({ Account({
required this.id, required this.id,
@ -22,9 +22,9 @@ class Account {
required this.avatar, required this.avatar,
required this.banner, required this.banner,
required this.description, required this.description,
required this.emailAddress, this.emailAddress,
required this.powerLevel, required this.powerLevel,
required this.externalId, this.externalId,
}); });
factory Account.fromJson(Map<String, dynamic> json) => Account( factory Account.fromJson(Map<String, dynamic> json) => Account(

View File

@ -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<String, dynamic> 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<String, dynamic> 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,
};
}

View File

@ -50,6 +50,11 @@ class NotifyProvider extends ChangeNotifier {
notifyListeners(); notifyListeners();
} }
void clearAt(int index) {
notifications.removeAt(index);
notifyListeners();
}
void clearNonRealtime() { void clearNonRealtime() {
notifications = notifications.where((x) => !x.isRealtime).toList(); notifications = notifications.where((x) => !x.isRealtime).toList();
} }

View File

@ -2,6 +2,7 @@ import 'package:go_router/go_router.dart';
import 'package:solian/models/channel.dart'; import 'package:solian/models/channel.dart';
import 'package:solian/models/post.dart'; import 'package:solian/models/post.dart';
import 'package:solian/screens/account.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/chat.dart';
import 'package:solian/screens/chat/index.dart'; import 'package:solian/screens/chat/index.dart';
import 'package:solian/screens/chat/manage.dart'; import 'package:solian/screens/chat/manage.dart';
@ -20,6 +21,11 @@ final router = GoRouter(
name: 'explore', name: 'explore',
builder: (context, state) => const ExploreScreen(), builder: (context, state) => const ExploreScreen(),
), ),
GoRoute(
path: '/notification',
name: 'notification',
builder: (context, state) => const NotificationScreen(),
),
GoRoute( GoRoute(
path: '/chat', path: '/chat',
name: 'chat', name: 'chat',
@ -66,15 +72,15 @@ final router = GoRouter(
dataset: state.pathParameters['dataset'] as String, dataset: state.pathParameters['dataset'] as String,
), ),
), ),
GoRoute(
path: '/notification',
name: 'notification',
builder: (context, state) => const NotificationScreen(),
),
GoRoute( GoRoute(
path: '/auth/sign-in', path: '/auth/sign-in',
name: 'auth.sign-in', name: 'auth.sign-in',
builder: (context, state) => SignInScreen(), builder: (context, state) => SignInScreen(),
), ),
GoRoute(
path: '/account/friend',
name: 'account.friend',
builder: (context, state) => const FriendScreen(),
),
], ],
); );

View File

@ -1,11 +1,11 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:solian/providers/auth.dart'; import 'package:solian/providers/auth.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import 'package:solian/router.dart'; import 'package:solian/router.dart';
import 'package:solian/utils/service_url.dart'; import 'package:solian/utils/service_url.dart';
import 'package:solian/widgets/common_wrapper.dart'; import 'package:solian/widgets/common_wrapper.dart';
import 'package:url_launcher/url_launcher.dart'; import 'package:url_launcher/url_launcher.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
class AccountScreen extends StatefulWidget { class AccountScreen extends StatefulWidget {
const AccountScreen({super.key}); const AccountScreen({super.key});
@ -40,21 +40,25 @@ class _AccountScreenState extends State<AccountScreen> {
padding: EdgeInsets.symmetric(vertical: 8, horizontal: 24), padding: EdgeInsets.symmetric(vertical: 8, horizontal: 24),
child: NameCard(), child: NameCard(),
), ),
InkWell( ListTile(
child: const Padding( contentPadding: const EdgeInsets.symmetric(horizontal: 34),
padding: EdgeInsets.symmetric(horizontal: 18), leading: const Icon(Icons.diversity_1),
child: ListTile( title: Text(AppLocalizations.of(context)!.friend),
leading: Icon(Icons.logout), onTap: () {
title: Text("Sign out"), router.goNamed('account.friend');
), },
), ),
ListTile(
contentPadding: const EdgeInsets.symmetric(horizontal: 34),
leading: const Icon(Icons.logout),
title: Text(AppLocalizations.of(context)!.signOut),
onTap: () { onTap: () {
auth.signoff(); auth.signoff();
setState(() { setState(() {
isAuthorized = false; isAuthorized = false;
}); });
}, },
) ),
], ],
) )
: Center( : Center(
@ -126,8 +130,7 @@ class NameCard extends StatelessWidget {
children: [ children: [
FutureBuilder( FutureBuilder(
future: renderAvatar(context), future: renderAvatar(context),
builder: builder: (BuildContext context, AsyncSnapshot<Widget> snapshot) {
(BuildContext context, AsyncSnapshot<Widget> snapshot) {
if (snapshot.hasData) { if (snapshot.hasData) {
return snapshot.data!; return snapshot.data!;
} else { } else {
@ -138,8 +141,7 @@ class NameCard extends StatelessWidget {
const SizedBox(width: 20), const SizedBox(width: 20),
FutureBuilder( FutureBuilder(
future: renderLabel(context), future: renderLabel(context),
builder: builder: (BuildContext context, AsyncSnapshot<Column> snapshot) {
(BuildContext context, AsyncSnapshot<Column> snapshot) {
if (snapshot.hasData) { if (snapshot.hasData) {
return snapshot.data!; return snapshot.data!;
} else { } else {
@ -161,12 +163,7 @@ class ActionCard extends StatelessWidget {
final String caption; final String caption;
final Function onTap; final Function onTap;
const ActionCard( const ActionCard({super.key, required this.onTap, required this.title, required this.caption, required this.icon});
{super.key,
required this.onTap,
required this.title,
required this.caption,
required this.icon});
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {

View File

@ -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<FriendScreen> createState() => _FriendScreenState();
}
class _FriendScreenState extends State<FriendScreen> {
bool _isSubmitting = false;
int _currentSideId = 0;
List<Friendship> _friendships = List.empty();
Future<void> fetchFriendships() async {
final auth = context.read<AuthProvider>();
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<dynamic>;
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<void> updateFriendship(Friendship relation, int status) async {
setState(() => _isSubmitting = true);
final otherside = getOtherside(relation);
final auth = context.read<AuthProvider>();
if (!await auth.isAuthorized()) {
setState(() => _isSubmitting = false);
return;
}
var res = await auth.client!.put(
getRequestUri('passport', '/api/users/me/friends/${otherside.id}'),
headers: <String, String>{
'Content-Type': 'application/json',
},
body: jsonEncode(<String, dynamic>{
'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<Friendship> 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,
),
),
),
],
),
),
);
}
}

View File

@ -83,26 +83,21 @@ class _ExploreScreenState extends State<ExploreScreen> {
onRefresh: () => Future.sync( onRefresh: () => Future.sync(
() => _pagingController.refresh(), () => _pagingController.refresh(),
), ),
child: Center( child: PagedListView<int, Post>(
child: Container( pagingController: _pagingController,
constraints: const BoxConstraints(maxWidth: 640), builderDelegate: PagedChildBuilderDelegate<Post>(
child: PagedListView<int, Post>( itemBuilder: (context, item, index) => PostItem(
pagingController: _pagingController, item: item,
builderDelegate: PagedChildBuilderDelegate<Post>( onUpdate: () => _pagingController.refresh(),
itemBuilder: (context, item, index) => PostItem( onTap: () {
item: item, router.pushNamed(
onUpdate: () => _pagingController.refresh(), 'posts.screen',
onTap: () { pathParameters: {
router.pushNamed( 'alias': item.alias,
'posts.screen', 'dataset': '${item.modelType}s',
pathParameters: {
'alias': item.alias,
'dataset': '${item.modelType}s',
},
);
}, },
), );
), },
), ),
), ),
), ),

View File

@ -49,7 +49,7 @@ class _NotificationScreenState extends State<NotificationScreen> {
index: index, index: index,
item: element, item: element,
onDismiss: () => setState(() { onDismiss: () => setState(() {
nty.notifications.removeAt(index); nty.clearAt(index);
}), }),
); );
}, },

View File

@ -23,12 +23,10 @@ class PostScreen extends StatefulWidget {
class _PostScreenState extends State<PostScreen> { class _PostScreenState extends State<PostScreen> {
final _client = http.Client(); final _client = http.Client();
final PagingController<int, Post> _commentPagingController = final PagingController<int, Post> _commentPagingController = PagingController(firstPageKey: 0);
PagingController(firstPageKey: 0);
Future<Post?> fetchPost(BuildContext context) async { Future<Post?> fetchPost(BuildContext context) async {
final uri = getRequestUri( final uri = getRequestUri('interactive', '/api/p/${widget.dataset}/${widget.alias}');
'interactive', '/api/p/${widget.dataset}/${widget.alias}');
final res = await _client.get(uri); final res = await _client.get(uri);
if (res.statusCode != 200) { if (res.statusCode != 200) {
final err = utf8.decode(res.bodyBytes); final err = utf8.decode(res.bodyBytes);
@ -51,32 +49,27 @@ class _PostScreenState extends State<PostScreen> {
future: fetchPost(context), future: fetchPost(context),
builder: (context, snapshot) { builder: (context, snapshot) {
if (snapshot.hasData && snapshot.data != null) { if (snapshot.hasData && snapshot.data != null) {
return Center( return CustomScrollView(
child: Container( slivers: [
constraints: const BoxConstraints(maxWidth: 640), SliverToBoxAdapter(
child: CustomScrollView( child: PostItem(
slivers: [ item: snapshot.data!,
SliverToBoxAdapter( brief: false,
child: PostItem( ripple: false,
item: snapshot.data!, ),
brief: false,
ripple: false,
),
),
SliverToBoxAdapter(
child: CommentListHeader(
related: snapshot.data!,
paging: _commentPagingController,
),
),
CommentList(
related: snapshot.data!,
dataset: widget.dataset,
paging: _commentPagingController,
),
],
), ),
), SliverToBoxAdapter(
child: CommentListHeader(
related: snapshot.data!,
paging: _commentPagingController,
),
),
CommentList(
related: snapshot.data!,
dataset: widget.dataset,
paging: _commentPagingController,
),
],
); );
} else { } else {
return const Center( return const Center(