⬆️ Support latest version of server

This commit is contained in:
LittleSheep 2024-07-23 18:09:41 +08:00
parent 3545a0737d
commit e91b4b0947
26 changed files with 88 additions and 1277 deletions

View File

@ -13,7 +13,7 @@ import 'package:solian/providers/auth.dart';
import 'package:solian/providers/content/attachment.dart';
import 'package:solian/providers/content/call.dart';
import 'package:solian/providers/content/channel.dart';
import 'package:solian/providers/content/feed.dart';
import 'package:solian/providers/content/posts.dart';
import 'package:solian/providers/content/realm.dart';
import 'package:solian/providers/friend.dart';
import 'package:solian/providers/account_status.dart';
@ -97,7 +97,7 @@ class SolianApp extends StatelessWidget {
void _initializeProviders(BuildContext context) async {
Get.lazyPut(() => AuthProvider());
Get.lazyPut(() => FriendProvider());
Get.lazyPut(() => FeedProvider());
Get.lazyPut(() => PostProvider());
Get.lazyPut(() => AttachmentProvider());
Get.lazyPut(() => WebSocketProvider());
Get.lazyPut(() => StatusProvider());

View File

@ -1,98 +0,0 @@
import 'package:solian/models/account.dart';
import 'package:solian/models/feed.dart';
import 'package:solian/models/post.dart';
import 'package:solian/models/realm.dart';
class Article {
int id;
DateTime createdAt;
DateTime updatedAt;
DateTime? deletedAt;
String alias;
String title;
String description;
String content;
List<Tag>? tags;
List<Category>? categories;
List<int>? attachments;
int? realmId;
Realm? realm;
DateTime? publishedAt;
bool? isDraft;
int authorId;
Account author;
PostMetric? metric;
Article({
required this.id,
required this.createdAt,
required this.updatedAt,
required this.deletedAt,
required this.alias,
required this.title,
required this.description,
required this.content,
required this.tags,
required this.categories,
required this.attachments,
required this.realmId,
required this.realm,
required this.publishedAt,
required this.isDraft,
required this.authorId,
required this.author,
required this.metric,
});
factory Article.fromJson(Map<String, dynamic> json) => Article(
id: json['id'],
createdAt: DateTime.parse(json['created_at']),
updatedAt: DateTime.parse(json['updated_at']),
deletedAt: json['deleted_at'] != null
? DateTime.parse(json['deleted_at'])
: null,
alias: json['alias'],
title: json['title'],
description: json['description'],
content: json['content'],
tags: json['tags']?.map((x) => Tag.fromJson(x)).toList().cast<Tag>(),
categories: json['categories']
?.map((x) => Category.fromJson(x))
.toList()
.cast<Category>(),
attachments: json['attachments'] != null
? List<int>.from(json['attachments'])
: null,
realmId: json['realm_id'],
realm: json['realm'] != null ? Realm.fromJson(json['realm']) : null,
publishedAt: json['published_at'] != null
? DateTime.parse(json['published_at'])
: null,
isDraft: json['is_draft'],
authorId: json['author_id'],
author: Account.fromJson(json['author']),
metric:
json['metric'] != null ? PostMetric.fromJson(json['metric']) : null,
);
Map<String, dynamic> toJson() => {
'id': id,
'created_at': createdAt.toIso8601String(),
'updated_at': updatedAt.toIso8601String(),
'deleted_at': deletedAt,
'alias': alias,
'title': title,
'description': description,
'content': content,
'tags': tags,
'categories': categories,
'attachments': attachments,
'realm_id': realmId,
'realm': realm?.toJson(),
'published_at': publishedAt?.toIso8601String(),
'is_draft': isDraft,
'author_id': authorId,
'author': author.toJson(),
'metric': metric?.toJson(),
};
}

View File

@ -1,27 +1,3 @@
class FeedRecord {
String type;
Map<String, dynamic> data;
DateTime createdAt;
FeedRecord({
required this.type,
required this.data,
required this.createdAt,
});
factory FeedRecord.fromJson(Map<String, dynamic> json) => FeedRecord(
type: json['type'],
data: json['data'],
createdAt: DateTime.parse(json['created_at']),
);
Map<String, dynamic> toJson() => {
'type': type,
'data': data,
'created_at': createdAt.toIso8601String(),
};
}
class Tag {
int id;
String alias;

View File

@ -7,12 +7,10 @@ class Post {
DateTime createdAt;
DateTime updatedAt;
DateTime? deletedAt;
String alias;
String content;
dynamic body;
List<Tag>? tags;
List<Category>? categories;
List<Post>? replies;
List<int>? attachments;
int? replyId;
int? repostId;
int? realmId;
@ -30,12 +28,10 @@ class Post {
required this.createdAt,
required this.updatedAt,
required this.deletedAt,
required this.alias,
required this.content,
required this.body,
required this.tags,
required this.categories,
required this.replies,
required this.attachments,
required this.replyId,
required this.repostId,
required this.realmId,
@ -56,17 +52,13 @@ class Post {
deletedAt: json['deleted_at'] != null
? DateTime.parse(json['deleted_at'])
: null,
alias: json['alias'],
content: json['content'],
body: json['body'],
tags: json['tags']?.map((x) => Tag.fromJson(x)).toList().cast<Tag>(),
categories: json['categories']
?.map((x) => Category.fromJson(x))
.toList()
.cast<Category>(),
replies: json['replies'],
attachments: json['attachments'] != null
? List<int>.from(json['attachments'])
: null,
replyId: json['reply_id'],
repostId: json['repost_id'],
realmId: json['realm_id'],
@ -90,12 +82,10 @@ class Post {
'created_at': createdAt.toIso8601String(),
'updated_at': updatedAt.toIso8601String(),
'deleted_at': deletedAt,
'alias': alias,
'content': content,
'body': body,
'tags': tags,
'categories': categories,
'replies': replies,
'attachments': attachments,
'reply_id': replyId,
'repost_id': repostId,
'realm_id': realmId,

View File

@ -2,13 +2,13 @@ import 'package:get/get.dart';
import 'package:solian/providers/auth.dart';
import 'package:solian/services.dart';
class FeedProvider extends GetConnect {
class PostProvider extends GetConnect {
@override
void onInit() {
httpClient.baseUrl = ServiceFinder.buildUrl('interactive', null);
}
Future<Response> listFeed(int page,
Future<Response> listRecommendations(int page,
{int? realm, String? tag, category}) async {
final queries = [
'take=${10}',
@ -17,7 +17,7 @@ class FeedProvider extends GetConnect {
if (category != null) 'category=$category',
if (realm != null) 'realmId=$realm',
];
final resp = await get('/feed?${queries.join('&')}');
final resp = await get('/recommendations?${queries.join('&')}');
if (resp.statusCode != 200) {
throw Exception(resp.body);
}

View File

@ -4,8 +4,6 @@ import 'package:solian/screens/about.dart';
import 'package:solian/screens/account.dart';
import 'package:solian/screens/account/friend.dart';
import 'package:solian/screens/account/personalize.dart';
import 'package:solian/screens/articles/article_detail.dart';
import 'package:solian/screens/articles/article_editor.dart';
import 'package:solian/screens/channel/channel_chat.dart';
import 'package:solian/screens/channel/channel_detail.dart';
import 'package:solian/screens/channel/channel_organize.dart';
@ -65,22 +63,12 @@ abstract class AppRouter {
builder: (context, state) => const DraftBoxScreen(),
),
GoRoute(
path: '/posts/view/:alias',
path: '/posts/view/:id',
name: 'postDetail',
builder: (context, state) => TitleShell(
state: state,
child: PostDetailScreen(
alias: state.pathParameters['alias']!,
),
),
),
GoRoute(
path: '/articles/view/:alias',
name: 'articleDetail',
builder: (context, state) => TitleShell(
state: state,
child: ArticleDetailScreen(
alias: state.pathParameters['alias']!,
id: state.pathParameters['id']!,
),
),
),
@ -97,17 +85,6 @@ abstract class AppRouter {
);
},
),
GoRoute(
path: '/articles/editor',
name: 'articleEditor',
builder: (context, state) {
final arguments = state.extra as ArticlePublishArguments?;
return ArticlePublishScreen(
edit: arguments?.edit,
realm: arguments?.realm,
);
},
)
],
);

View File

@ -1,68 +0,0 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:solian/exts.dart';
import 'package:solian/models/articles.dart';
import 'package:solian/providers/content/feed.dart';
import 'package:solian/widgets/articles/article_item.dart';
import 'package:solian/widgets/sized_container.dart';
class ArticleDetailScreen extends StatefulWidget {
final String alias;
const ArticleDetailScreen({super.key, required this.alias});
@override
State<ArticleDetailScreen> createState() => _ArticleDetailScreenState();
}
class _ArticleDetailScreenState extends State<ArticleDetailScreen> {
Article? item;
Future<Article?> getDetail() async {
final FeedProvider provider = Get.find();
try {
final resp = await provider.getArticle(widget.alias);
item = Article.fromJson(resp.body);
} catch (e) {
context.showErrorDialog(e).then((_) => Navigator.pop(context));
}
return item;
}
@override
Widget build(BuildContext context) {
return Material(
color: Theme.of(context).colorScheme.surface,
child: FutureBuilder(
future: getDetail(),
builder: (context, snapshot) {
if (!snapshot.hasData || snapshot.data == null) {
return const Center(
child: CircularProgressIndicator(),
);
}
return CustomScrollView(
slivers: [
SliverToBoxAdapter(
child: CenteredContainer(
child: ArticleItem(
item: item!,
isClickable: true,
isFullDate: true,
isFullContent: true,
),
),
),
SliverToBoxAdapter(
child: SizedBox(height: MediaQuery.of(context).padding.bottom),
),
],
);
},
),
);
}
}

View File

@ -1,298 +0,0 @@
import 'package:flutter/material.dart';
import 'package:flutter_animate/flutter_animate.dart';
import 'package:get/get.dart';
import 'package:solian/exts.dart';
import 'package:solian/models/articles.dart';
import 'package:solian/models/realm.dart';
import 'package:solian/providers/auth.dart';
import 'package:solian/router.dart';
import 'package:solian/theme.dart';
import 'package:solian/widgets/app_bar_leading.dart';
import 'package:solian/widgets/app_bar_title.dart';
import 'package:solian/widgets/attachments/attachment_publish.dart';
import 'package:solian/widgets/feed/feed_tags_field.dart';
import 'package:textfield_tags/textfield_tags.dart';
import 'package:badges/badges.dart' as badges;
class ArticlePublishArguments {
final Article? edit;
final Realm? realm;
ArticlePublishArguments({this.edit, this.realm});
}
class ArticlePublishScreen extends StatefulWidget {
final Article? edit;
final Realm? realm;
const ArticlePublishScreen({
super.key,
this.edit,
this.realm,
});
@override
State<ArticlePublishScreen> createState() => _ArticlePublishScreenState();
}
class _ArticlePublishScreenState extends State<ArticlePublishScreen> {
final _titleController = TextEditingController();
final _descriptionController = TextEditingController();
final _contentController = TextEditingController();
final _tagsController = StringTagController();
bool _isBusy = false;
List<int> _attachments = List.empty();
bool _isDraft = false;
void showAttachments() {
showModalBottomSheet(
context: context,
isScrollControlled: true,
builder: (context) => AttachmentPublishPopup(
usage: 'i.attachment',
current: _attachments,
onUpdate: (value) {
setState(() => _attachments = value);
},
),
);
}
void applyPost() async {
final AuthProvider auth = Get.find();
if (!await auth.isAuthorized) return;
if (_contentController.value.text.isEmpty) return;
setState(() => _isBusy = true);
final client = auth.configureClient('interactive');
final payload = {
'title': _titleController.value.text,
'description': _descriptionController.value.text,
'content': _contentController.value.text,
'tags': _tagsController.getTags?.map((x) => {'alias': x}).toList() ??
List.empty(),
'attachments': _attachments,
'is_draft': _isDraft,
if (widget.edit != null) 'alias': widget.edit!.alias,
if (widget.realm != null) 'realm': widget.realm!.alias,
};
Response resp;
if (widget.edit != null) {
resp = await client.put('/articles/${widget.edit!.id}', payload);
} else {
resp = await client.post('/articles', payload);
}
if (resp.statusCode != 200) {
context.showErrorDialog(resp.bodyString);
} else {
AppRouter.instance.pop(resp.body);
}
setState(() => _isBusy = false);
}
void syncWidget() {
if (widget.edit != null) {
_titleController.text = widget.edit!.title;
_descriptionController.text = widget.edit!.description;
_contentController.text = widget.edit!.content;
_attachments = widget.edit!.attachments ?? List.empty();
_isDraft = widget.edit!.isDraft ?? false;
}
}
void cancelAction() {
AppRouter.instance.pop();
}
@override
void initState() {
syncWidget();
super.initState();
}
@override
Widget build(BuildContext context) {
final notifyBannerActions = [
TextButton(
onPressed: cancelAction,
child: Text('cancel'.tr),
)
];
return Material(
color: Theme.of(context).colorScheme.surface,
child: Scaffold(
appBar: AppBar(
leading: AppBarLeadingButton.adaptive(context),
title: AppBarTitle('articlePublish'.tr),
centerTitle: false,
toolbarHeight: SolianTheme.toolbarHeight(context),
actions: [
TextButton(
onPressed: _isBusy ? null : () => applyPost(),
child: Text(
_isDraft
? 'draftSave'.tr.toUpperCase()
: 'postAction'.tr.toUpperCase(),
),
)
],
),
body: Stack(
children: [
ListView(
children: [
if (_isBusy) const LinearProgressIndicator().animate().scaleX(),
if (widget.edit != null)
MaterialBanner(
leading: const Icon(Icons.edit),
leadingPadding: const EdgeInsets.only(left: 10, right: 20),
dividerColor: Colors.transparent,
content: Text('postEditingNotify'.tr),
actions: notifyBannerActions,
),
if (widget.realm != null)
MaterialBanner(
leading: const Icon(Icons.group),
leadingPadding: const EdgeInsets.only(left: 10, right: 20),
dividerColor: Colors.transparent,
content: Text(
'postInRealmNotify'
.trParams({'realm': '#${widget.realm!.alias}'}),
),
actions: notifyBannerActions,
),
const Divider(thickness: 0.3, height: 0.3)
.paddingOnly(bottom: 6),
Container(
padding:
const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: TextField(
maxLines: null,
autofocus: true,
autocorrect: true,
controller: _titleController,
decoration: InputDecoration.collapsed(
hintText: 'articleTitlePlaceholder'.tr,
),
onTapOutside: (_) =>
FocusManager.instance.primaryFocus?.unfocus(),
),
),
const Divider(thickness: 0.3, height: 0.3)
.paddingOnly(bottom: 6),
Container(
padding:
const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: TextField(
minLines: 1,
maxLines: 3,
autofocus: true,
autocorrect: true,
keyboardType: TextInputType.multiline,
controller: _descriptionController,
decoration: InputDecoration.collapsed(
hintText: 'articleDescriptionPlaceholder'.tr,
),
onTapOutside: (_) =>
FocusManager.instance.primaryFocus?.unfocus(),
),
),
const Divider(thickness: 0.3, height: 0.3)
.paddingSymmetric(vertical: 6),
Container(
padding:
const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: TextField(
maxLines: null,
autofocus: true,
autocorrect: true,
keyboardType: TextInputType.multiline,
controller: _contentController,
decoration: InputDecoration.collapsed(
hintText: 'articleContentPlaceholder'.tr,
),
onTapOutside: (_) =>
FocusManager.instance.primaryFocus?.unfocus(),
),
),
const SizedBox(height: 120),
],
),
Positioned(
bottom: 0,
left: 0,
right: 0,
child: Material(
elevation: 8,
color: Theme.of(context).colorScheme.surface,
child: Column(
children: [
TagsField(
initialTags:
widget.edit?.tags?.map((x) => x.alias).toList(),
tagsController: _tagsController,
hintText: 'postTagsPlaceholder'.tr,
),
const Divider(thickness: 0.3, height: 0.3),
SizedBox(
height: 56,
child: ListView(
scrollDirection: Axis.horizontal,
children: [
IconButton(
icon: _isDraft
? const Icon(Icons.drive_file_rename_outline)
: const Icon(Icons.public),
color: _isDraft
? Colors.grey.shade600
: Colors.green.shade700,
onPressed: () {
setState(() => _isDraft = !_isDraft);
},
),
IconButton(
icon: badges.Badge(
badgeContent: Text(
_attachments.length.toString(),
style: const TextStyle(color: Colors.white),
),
showBadge: _attachments.isNotEmpty,
position: badges.BadgePosition.topEnd(
top: -12,
end: -8,
),
child: const Icon(Icons.camera_alt),
),
color: Theme.of(context).colorScheme.primary,
onPressed: () => showAttachments(),
),
],
).paddingSymmetric(horizontal: 6, vertical: 8),
),
],
).paddingOnly(bottom: MediaQuery.of(context).padding.bottom),
),
),
],
),
),
);
}
@override
void dispose() {
_titleController.dispose();
_descriptionController.dispose();
_contentController.dispose();
_tagsController.dispose();
super.dispose();
}
}

View File

@ -1,17 +1,13 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:infinite_scroll_pagination/infinite_scroll_pagination.dart';
import 'package:solian/models/articles.dart';
import 'package:solian/models/feed.dart';
import 'package:solian/models/pagination.dart';
import 'package:solian/models/post.dart';
import 'package:solian/providers/content/feed.dart';
import 'package:solian/providers/content/posts.dart';
import 'package:solian/screens/home.dart';
import 'package:solian/theme.dart';
import 'package:solian/widgets/app_bar_leading.dart';
import 'package:solian/widgets/app_bar_title.dart';
import 'package:solian/widgets/articles/article_action.dart';
import 'package:solian/widgets/articles/article_owned_list.dart';
import 'package:solian/widgets/posts/post_action.dart';
import 'package:solian/widgets/posts/post_owned_list.dart';
@ -23,11 +19,11 @@ class DraftBoxScreen extends StatefulWidget {
}
class _DraftBoxScreenState extends State<DraftBoxScreen> {
final PagingController<int, FeedRecord> _pagingController =
final PagingController<int, Post> _pagingController =
PagingController(firstPageKey: 0);
getPosts(int pageKey) async {
final FeedProvider provider = Get.find();
final PostProvider provider = Get.find();
Response resp;
try {
@ -43,7 +39,7 @@ class _DraftBoxScreenState extends State<DraftBoxScreen> {
return;
}
final parsed = result.data?.map((e) => FeedRecord.fromJson(e)).toList();
final parsed = result.data?.map((e) => Post.fromJson(e)).toList();
if (parsed != null && parsed.length >= 10) {
_pagingController.appendPage(parsed, pageKey + parsed.length);
} else if (parsed != null) {
@ -79,21 +75,18 @@ class _DraftBoxScreenState extends State<DraftBoxScreen> {
),
body: RefreshIndicator(
onRefresh: () => Future.sync(() => _pagingController.refresh()),
child: PagedListView<int, FeedRecord>(
child: PagedListView<int, Post>(
pagingController: _pagingController,
builderDelegate: PagedChildBuilderDelegate(
itemBuilder: (context, item, index) {
switch (item.type) {
case 'post':
final data = Post.fromJson(item.data);
return PostOwnedListEntry(
item: data,
item: item,
onTap: () async {
showModalBottomSheet(
useRootNavigator: true,
context: context,
builder: (context) => PostAction(
item: data,
item: item,
noReact: true,
),
).then((value) {
@ -101,23 +94,6 @@ class _DraftBoxScreenState extends State<DraftBoxScreen> {
});
},
).paddingOnly(left: 12, right: 12, bottom: 4);
case 'article':
final data = Article.fromJson(item.data);
return ArticleOwnedListEntry(
item: data,
onTap: () async {
showModalBottomSheet(
useRootNavigator: true,
context: context,
builder: (context) => ArticleAction(item: data),
).then((value) {
if (value != null) _pagingController.refresh();
});
},
).paddingOnly(left: 12, right: 12, bottom: 4);
default:
return const SizedBox();
}
},
),
),

View File

@ -1,11 +1,12 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:infinite_scroll_pagination/infinite_scroll_pagination.dart';
import 'package:solian/models/feed.dart';
import 'package:solian/models/pagination.dart';
import 'package:solian/providers/content/feed.dart';
import 'package:solian/providers/content/posts.dart';
import 'package:solian/widgets/feed/feed_list.dart';
import '../../models/post.dart';
class FeedSearchScreen extends StatefulWidget {
final String? tag;
final String? category;
@ -17,15 +18,15 @@ class FeedSearchScreen extends StatefulWidget {
}
class _FeedSearchScreenState extends State<FeedSearchScreen> {
final PagingController<int, FeedRecord> _pagingController =
final PagingController<int, Post> _pagingController =
PagingController(firstPageKey: 0);
getPosts(int pageKey) async {
final FeedProvider provider = Get.find();
final PostProvider provider = Get.find();
Response resp;
try {
resp = await provider.listFeed(
resp = await provider.listRecommendations(
pageKey,
tag: widget.tag,
category: widget.category,
@ -36,7 +37,7 @@ class _FeedSearchScreenState extends State<FeedSearchScreen> {
}
final PaginationResult result = PaginationResult.fromJson(resp.body);
final parsed = result.data?.map((e) => FeedRecord.fromJson(e)).toList();
final parsed = result.data?.map((e) => Post.fromJson(e)).toList();
if (parsed != null && parsed.length >= 10) {
_pagingController.appendPage(parsed, pageKey + parsed.length);
} else if (parsed != null) {

View File

@ -1,10 +1,10 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:infinite_scroll_pagination/infinite_scroll_pagination.dart';
import 'package:solian/models/feed.dart';
import 'package:solian/models/pagination.dart';
import 'package:solian/models/post.dart';
import 'package:solian/providers/auth.dart';
import 'package:solian/providers/content/feed.dart';
import 'package:solian/providers/content/posts.dart';
import 'package:solian/router.dart';
import 'package:solian/screens/account/notification.dart';
import 'package:solian/theme.dart';
@ -21,22 +21,22 @@ class HomeScreen extends StatefulWidget {
}
class _HomeScreenState extends State<HomeScreen> {
final PagingController<int, FeedRecord> _pagingController =
final PagingController<int, Post> _pagingController =
PagingController(firstPageKey: 0);
getPosts(int pageKey) async {
final FeedProvider provider = Get.find();
final PostProvider provider = Get.find();
Response resp;
try {
resp = await provider.listFeed(pageKey);
resp = await provider.listRecommendations(pageKey);
} catch (e) {
_pagingController.error = e;
return;
}
final PaginationResult result = PaginationResult.fromJson(resp.body);
final parsed = result.data?.map((e) => FeedRecord.fromJson(e)).toList();
final parsed = result.data?.map((e) => Post.fromJson(e)).toList();
if (parsed != null && parsed.length >= 10) {
_pagingController.appendPage(parsed, pageKey + parsed.length);
} else if (parsed != null) {
@ -128,20 +128,6 @@ class FeedCreationButton extends StatelessWidget {
});
},
),
PopupMenuItem(
child: ListTile(
title: Text('articleEditor'.tr),
leading: const Icon(Icons.newspaper),
contentPadding: const EdgeInsets.symmetric(horizontal: 8),
),
onTap: () {
AppRouter.instance.pushNamed('articleEditor').then((val) {
if (val != null && onCreated != null) {
onCreated!();
}
});
},
),
if (!hideDraftBox)
PopupMenuItem(
child: ListTile(

View File

@ -2,15 +2,15 @@ import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:solian/exts.dart';
import 'package:solian/models/post.dart';
import 'package:solian/providers/content/feed.dart';
import 'package:solian/providers/content/posts.dart';
import 'package:solian/widgets/sized_container.dart';
import 'package:solian/widgets/posts/post_item.dart';
import 'package:solian/widgets/posts/post_replies.dart';
class PostDetailScreen extends StatefulWidget {
final String alias;
final String id;
const PostDetailScreen({super.key, required this.alias});
const PostDetailScreen({super.key, required this.id});
@override
State<PostDetailScreen> createState() => _PostDetailScreenState();
@ -20,10 +20,10 @@ class _PostDetailScreenState extends State<PostDetailScreen> {
Post? item;
Future<Post?> getDetail() async {
final FeedProvider provider = Get.find();
final PostProvider provider = Get.find();
try {
final resp = await provider.getPost(widget.alias);
final resp = await provider.getPost(widget.id);
item = Post.fromJson(resp.body);
} catch (e) {
context.showErrorDialog(e).then((_) => Navigator.pop(context));

View File

@ -82,7 +82,6 @@ class _PostPublishScreenState extends State<PostPublishScreen> {
List.empty(),
'attachments': _attachments,
'is_draft': _isDraft,
if (widget.edit != null) 'alias': widget.edit!.alias,
if (widget.reply != null) 'reply_to': widget.reply!.id,
if (widget.repost != null) 'repost_to': widget.repost!.id,
if (widget.realm != null) 'realm': widget.realm!.alias,
@ -90,9 +89,9 @@ class _PostPublishScreenState extends State<PostPublishScreen> {
Response resp;
if (widget.edit != null) {
resp = await client.put('/posts/${widget.edit!.id}', payload);
resp = await client.put('/stories/${widget.edit!.id}', payload);
} else {
resp = await client.post('/posts', payload);
resp = await client.post('/stories', payload);
}
if (resp.statusCode != 200) {
context.showErrorDialog(resp.bodyString);
@ -105,8 +104,8 @@ class _PostPublishScreenState extends State<PostPublishScreen> {
void syncWidget() {
if (widget.edit != null) {
_contentController.text = widget.edit!.content;
_attachments = widget.edit!.attachments ?? List.empty();
_contentController.text = widget.edit!.body['content'];
_attachments = widget.edit!.body['attachments'] ?? List.empty();
_isDraft = widget.edit!.isDraft ?? false;
}
}

View File

@ -8,7 +8,7 @@ import 'package:solian/models/post.dart';
import 'package:solian/models/realm.dart';
import 'package:solian/providers/auth.dart';
import 'package:solian/providers/content/channel.dart';
import 'package:solian/providers/content/feed.dart';
import 'package:solian/providers/content/posts.dart';
import 'package:solian/providers/content/realm.dart';
import 'package:solian/router.dart';
import 'package:solian/screens/channel/channel_organize.dart';
@ -167,7 +167,7 @@ class _RealmPostListWidgetState extends State<RealmPostListWidget> {
PagingController(firstPageKey: 0);
getPosts(int pageKey) async {
final FeedProvider provider = Get.find();
final PostProvider provider = Get.find();
Response resp;
try {

View File

@ -1,157 +0,0 @@
import 'dart:math';
import 'package:flutter/material.dart';
import 'package:flutter_animate/flutter_animate.dart';
import 'package:get/get.dart';
import 'package:solian/exts.dart';
import 'package:solian/models/articles.dart';
import 'package:solian/providers/auth.dart';
import 'package:solian/router.dart';
import 'package:solian/screens/articles/article_editor.dart';
class ArticleAction extends StatefulWidget {
final Article item;
const ArticleAction({super.key, required this.item});
@override
State<ArticleAction> createState() => _ArticleActionState();
}
class _ArticleActionState extends State<ArticleAction> {
bool _isBusy = true;
bool _canModifyContent = false;
void checkAbleToModifyContent() async {
final AuthProvider provider = Get.find();
if (!await provider.isAuthorized) return;
setState(() => _isBusy = true);
final prof = await provider.getProfile();
setState(() {
_canModifyContent = prof.body?['id'] == widget.item.author.externalId;
_isBusy = false;
});
}
@override
void initState() {
super.initState();
checkAbleToModifyContent();
}
@override
Widget build(BuildContext context) {
return SafeArea(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'postActionList'.tr,
style: Theme.of(context).textTheme.headlineSmall,
),
Text(
'#${widget.item.id.toString().padLeft(8, '0')}',
style: Theme.of(context).textTheme.bodySmall,
),
],
).paddingOnly(left: 24, right: 24, top: 32, bottom: 16),
if (_isBusy) const LinearProgressIndicator().animate().scaleX(),
Expanded(
child: ListView(
children: [
if (_canModifyContent)
ListTile(
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
leading: const Icon(Icons.edit),
title: Text('edit'.tr),
onTap: () async {
final value = await AppRouter.instance.pushNamed(
'articleEditor',
extra: ArticlePublishArguments(edit: widget.item),
);
if (value != null) {
Navigator.pop(context, true);
}
},
),
if (_canModifyContent)
ListTile(
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
leading: const Icon(Icons.delete),
title: Text('delete'.tr),
onTap: () async {
final value = await showDialog(
context: context,
builder: (context) =>
ArticleDeletionDialog(item: widget.item),
);
if (value != null) {
Navigator.pop(context, true);
}
},
),
],
),
),
],
),
);
}
}
class ArticleDeletionDialog extends StatefulWidget {
final Article item;
const ArticleDeletionDialog({super.key, required this.item});
@override
State<ArticleDeletionDialog> createState() => _ArticleDeletionDialogState();
}
class _ArticleDeletionDialogState extends State<ArticleDeletionDialog> {
bool _isBusy = false;
void performAction() async {
final AuthProvider auth = Get.find();
if (!await auth.isAuthorized) return;
final client = auth.configureClient('interactive');
setState(() => _isBusy = true);
final resp = await client.delete('/articles/${widget.item.id}');
setState(() => _isBusy = false);
if (resp.statusCode != 200) {
context.showErrorDialog(resp.bodyString);
} else {
Navigator.pop(context, true);
}
}
@override
Widget build(BuildContext context) {
return AlertDialog(
title: Text('postDeletionConfirm'.tr),
content: Text('postDeletionConfirmCaption'.trParams({
'content': widget.item.content
.substring(0, min(widget.item.content.length, 60))
.trim(),
})),
actions: <Widget>[
TextButton(
onPressed: _isBusy ? null : () => Navigator.pop(context),
child: Text('cancel'.tr),
),
TextButton(
onPressed: _isBusy ? null : () => performAction(),
child: Text('confirm'.tr),
),
],
);
}
}

View File

@ -1,210 +0,0 @@
import 'package:flutter/material.dart';
import 'package:get/get_utils/get_utils.dart';
import 'package:intl/intl.dart';
import 'package:solian/models/articles.dart';
import 'package:solian/widgets/account/account_avatar.dart';
import 'package:solian/widgets/account/account_profile_popup.dart';
import 'package:solian/widgets/articles/article_quick_action.dart';
import 'package:solian/widgets/markdown_text_content.dart';
import 'package:solian/widgets/feed/feed_tags.dart';
import 'package:timeago/timeago.dart' show format;
class ArticleItem extends StatefulWidget {
final Article item;
final bool isClickable;
final bool isReactable;
final bool isFullDate;
final bool isFullContent;
final String? overrideAttachmentParent;
const ArticleItem({
super.key,
required this.item,
this.isClickable = false,
this.isReactable = true,
this.isFullDate = false,
this.isFullContent = false,
this.overrideAttachmentParent,
});
@override
State<ArticleItem> createState() => _ArticleItemState();
}
class _ArticleItemState extends State<ArticleItem> {
late final Article item;
@override
void initState() {
item = widget.item;
super.initState();
}
Widget buildDate() {
if (widget.isFullDate) {
return Text(DateFormat('y/M/d H:m').format(item.createdAt.toLocal()));
} else {
return Text(format(item.createdAt.toLocal(), locale: 'en_short'));
}
}
Widget buildHeader() {
return Row(
children: [
Text(
item.author.nick,
style: const TextStyle(fontWeight: FontWeight.bold),
),
buildDate().paddingOnly(left: 4),
],
);
}
Widget buildFooter() {
List<String> labels = List.from(['article'.tr], growable: true);
if (widget.item.createdAt != widget.item.updatedAt) {
labels.add('postEdited'.trParams({
'date': DateFormat('yy/M/d H:m').format(item.updatedAt.toLocal()),
}));
}
if (widget.item.realm != null) {
labels.add('postInRealm'.trParams({
'realm': '#${widget.item.realm!.alias}',
}));
}
List<Widget> widgets = List.empty(growable: true);
if (widget.item.tags?.isNotEmpty ?? false) {
widgets.add(FeedTagsList(tags: widget.item.tags!));
}
if (labels.isNotEmpty) {
widgets.add(Text(
labels.join(' · '),
textAlign: TextAlign.left,
style: TextStyle(
fontSize: 12,
color: Theme.of(context).colorScheme.onSurface.withOpacity(0.75),
),
));
}
if (widgets.isEmpty) {
return const SizedBox();
} else {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: widgets,
).paddingOnly(top: 4);
}
}
@override
Widget build(BuildContext context) {
if (!widget.isFullContent) {
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
AccountAvatar(content: item.author.avatar.toString()),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
buildHeader(),
Text(item.title, style: const TextStyle(fontSize: 15)),
Text(
item.description,
style: TextStyle(
fontSize: 13,
color: Theme.of(context)
.colorScheme
.onSurface
.withOpacity(0.8),
),
),
buildFooter(),
],
).paddingOnly(left: 12),
),
],
).paddingOnly(
top: 10,
bottom: 10,
right: 16,
left: 16,
);
}
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
GestureDetector(
child: AccountAvatar(content: item.author.avatar.toString()),
onTap: () {
showModalBottomSheet(
useRootNavigator: true,
isScrollControlled: true,
backgroundColor: Theme.of(context).colorScheme.surface,
context: context,
builder: (context) => AccountProfilePopup(
account: item.author,
),
);
},
),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
buildHeader(),
Text(item.title),
Text(
item.description,
style: TextStyle(
color: Theme.of(context)
.colorScheme
.onSurface
.withOpacity(0.8),
),
),
],
).paddingOnly(left: 12),
)
],
).paddingOnly(
top: 10,
right: 16,
left: 16,
),
MarkdownTextContent(content: item.content).paddingOnly(
left: 20,
right: 20,
top: 10,
bottom: 8,
),
buildFooter().paddingOnly(left: 20),
if (widget.isReactable)
ArticleQuickAction(
isReactable: widget.isReactable,
item: widget.item,
onReact: (symbol, changes) {
setState(() {
item.metric!.reactionList[symbol] =
(item.metric!.reactionList[symbol] ?? 0) + changes;
});
},
).paddingOnly(
top: 6,
left: 16,
right: 16,
bottom: 10,
)
else
const SizedBox(height: 10),
],
);
}
}

View File

@ -1,84 +0,0 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:infinite_scroll_pagination/infinite_scroll_pagination.dart';
import 'package:solian/models/articles.dart';
import 'package:solian/router.dart';
import 'package:solian/widgets/articles/article_action.dart';
import 'package:solian/widgets/articles/article_item.dart';
import 'package:solian/widgets/sized_container.dart';
class ArticleListWidget extends StatelessWidget {
final bool isShowEmbed;
final bool isClickable;
final bool isNestedClickable;
final PagingController<int, Article> controller;
const ArticleListWidget({
super.key,
required this.controller,
this.isShowEmbed = true,
this.isClickable = true,
this.isNestedClickable = true,
});
@override
Widget build(BuildContext context) {
return PagedSliverList<int, Article>.separated(
addRepaintBoundaries: true,
pagingController: controller,
builderDelegate: PagedChildBuilderDelegate<Article>(
itemBuilder: (context, item, index) {
return CenteredContainer(
child: ArticleListEntryWidget(
isClickable: isClickable,
item: item,
onUpdate: () {
controller.refresh();
},
),
);
},
),
separatorBuilder: (_, __) => const Divider(thickness: 0.3, height: 0.3),
);
}
}
class ArticleListEntryWidget extends StatelessWidget {
final bool isClickable;
final Article item;
final Function onUpdate;
const ArticleListEntryWidget({
super.key,
required this.isClickable,
required this.item,
required this.onUpdate,
});
@override
Widget build(BuildContext context) {
return GestureDetector(
child: ArticleItem(
key: Key('a${item.alias}'),
item: item,
).paddingSymmetric(vertical: 8),
onTap: () {
if (!isClickable) return;
AppRouter.instance.pushNamed(
'articleDetail',
pathParameters: {'alias': item.alias},
);
},
onLongPress: () {
showModalBottomSheet(
useRootNavigator: true,
context: context,
builder: (context) => ArticleAction(item: item),
).then((value) {
if (value != null) onUpdate();
});
},
);
}
}

View File

@ -1,35 +0,0 @@
import 'package:flutter/material.dart';
import 'package:solian/models/articles.dart';
import 'package:solian/widgets/articles/article_item.dart';
class ArticleOwnedListEntry extends StatelessWidget {
final Article item;
final Function onTap;
const ArticleOwnedListEntry({
super.key,
required this.item,
required this.onTap,
});
@override
Widget build(BuildContext context) {
return Card(
child: InkWell(
borderRadius: const BorderRadius.all(Radius.circular(8)),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
ArticleItem(
key: Key('a${item.alias}'),
item: item,
isClickable: false,
isReactable: false,
),
],
),
onTap: () => onTap(),
),
);
}
}

View File

@ -1,126 +0,0 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:solian/exts.dart';
import 'package:solian/models/articles.dart';
import 'package:solian/models/reaction.dart';
import 'package:solian/providers/auth.dart';
import 'package:solian/widgets/posts/post_reaction.dart';
class ArticleQuickAction extends StatefulWidget {
final Article item;
final bool isReactable;
final void Function(String symbol, int num) onReact;
const ArticleQuickAction({
super.key,
required this.item,
this.isReactable = true,
required this.onReact,
});
@override
State<ArticleQuickAction> createState() => _ArticleQuickActionState();
}
class _ArticleQuickActionState extends State<ArticleQuickAction> {
bool _isSubmitting = false;
void showReactMenu() {
showModalBottomSheet(
useRootNavigator: true,
context: context,
builder: (context) => PostReactionPopup(
reactionList: widget.item.metric!.reactionList,
onReact: (key, value) {
doWidgetReact(key, value.attitude);
},
),
);
}
Future<void> doWidgetReact(String symbol, int attitude) async {
if (!widget.isReactable) return;
final AuthProvider auth = Get.find();
if (_isSubmitting) return;
if (!await auth.isAuthorized) return;
final client = auth.configureClient('interactive');
setState(() => _isSubmitting = true);
final resp = await client.post('/articles/${widget.item.alias}/react', {
'symbol': symbol,
'attitude': attitude,
});
if (resp.statusCode == 201) {
widget.onReact(symbol, 1);
context.showSnackbar('reactCompleted'.tr);
} else if (resp.statusCode == 204) {
widget.onReact(symbol, -1);
context.showSnackbar('reactUncompleted'.tr);
} else {
context.showErrorDialog(resp.bodyString);
}
setState(() => _isSubmitting = false);
}
@override
void initState() {
super.initState();
if (!widget.isReactable && widget.item.metric!.reactionList.isEmpty) {
WidgetsBinding.instance.addPostFrameCallback((_) {
widget.onReact('thumb_up', 0);
});
}
}
@override
Widget build(BuildContext context) {
const density = VisualDensity(horizontal: -4, vertical: -3);
return SizedBox(
height: 32,
width: double.infinity,
child: Row(
mainAxisAlignment: MainAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Expanded(
child: ListView(
shrinkWrap: true,
scrollDirection: Axis.horizontal,
children: [
...widget.item.metric!.reactionList.entries.map((x) {
final info = reactions[x.key];
return Padding(
padding: const EdgeInsets.only(right: 8),
child: ActionChip(
avatar: Text(info!.icon),
label: Text(x.value.toString()),
tooltip: ':${x.key}:',
visualDensity: density,
onPressed: _isSubmitting
? null
: () => doWidgetReact(x.key, info.attitude),
),
);
}),
if (widget.isReactable)
ActionChip(
avatar: const Icon(Icons.add_reaction, color: Colors.teal),
label: Text('reactAdd'.tr),
visualDensity: density,
onPressed: () => showReactMenu(),
),
],
),
)
],
),
);
}
}

View File

@ -1,9 +1,6 @@
import 'package:flutter/material.dart';
import 'package:infinite_scroll_pagination/infinite_scroll_pagination.dart';
import 'package:solian/models/articles.dart';
import 'package:solian/models/feed.dart';
import 'package:solian/models/post.dart';
import 'package:solian/widgets/articles/article_list.dart';
import 'package:solian/widgets/sized_container.dart';
import 'package:solian/widgets/posts/post_list.dart';
@ -11,7 +8,7 @@ class FeedListWidget extends StatelessWidget {
final bool isShowEmbed;
final bool isClickable;
final bool isNestedClickable;
final PagingController<int, FeedRecord> controller;
final PagingController<int, Post> controller;
const FeedListWidget({
super.key,
@ -23,38 +20,23 @@ class FeedListWidget extends StatelessWidget {
@override
Widget build(BuildContext context) {
return PagedSliverList<int, FeedRecord>.separated(
return PagedSliverList<int, Post>.separated(
addRepaintBoundaries: true,
pagingController: controller,
builderDelegate: PagedChildBuilderDelegate<FeedRecord>(
builderDelegate: PagedChildBuilderDelegate<Post>(
itemBuilder: (context, item, index) {
return SizedContainer(
child: Builder(
builder: (context) {
switch (item.type) {
case 'post':
final data = Post.fromJson(item.data);
return PostListEntryWidget(
isShowEmbed: isShowEmbed,
isNestedClickable: isNestedClickable,
isClickable: isClickable,
item: data,
item: item,
onUpdate: () {
controller.refresh();
},
);
case 'article':
final data = Article.fromJson(item.data);
return ArticleListEntryWidget(
isClickable: isClickable,
item: data,
onUpdate: () {
controller.refresh();
},
);
default:
return const SizedBox();
}
},
),
);

View File

@ -173,8 +173,8 @@ class _PostDeletionDialogState extends State<PostDeletionDialog> {
return AlertDialog(
title: Text('postDeletionConfirm'.tr),
content: Text('postDeletionConfirmCaption'.trParams({
'content': widget.item.content
.substring(0, min(widget.item.content.length, 60))
'content': widget.item.body['content']
.substring(0, min<int>(widget.item.body['content'].length, 60))
.trim(),
})),
actions: <Widget>[

View File

@ -86,7 +86,7 @@ class _PostItemState extends State<PostItem> {
}
if (widget.item.realm != null) {
labels.add('postInRealm'.trParams({
'realm': '#${widget.item.realm!.alias}',
'realm': '#${widget.item.realm!.id}',
}));
}
@ -141,7 +141,7 @@ class _PostItemState extends State<PostItem> {
child: PostItem(
item: widget.item.replyTo!,
isCompact: true,
overrideAttachmentParent: widget.item.alias,
overrideAttachmentParent: widget.item.id.toString(),
).paddingSymmetric(vertical: 8),
),
],
@ -173,7 +173,7 @@ class _PostItemState extends State<PostItem> {
child: PostItem(
item: widget.item.repostTo!,
isCompact: true,
overrideAttachmentParent: widget.item.alias,
overrideAttachmentParent: widget.item.id.toString(),
).paddingSymmetric(vertical: 8),
),
],
@ -182,7 +182,7 @@ class _PostItemState extends State<PostItem> {
@override
Widget build(BuildContext context) {
final hasAttachment = item.attachments?.isNotEmpty ?? false;
final hasAttachment = item.body['attachments']?.isNotEmpty ?? false;
if (widget.isCompact) {
return Column(
@ -190,7 +190,7 @@ class _PostItemState extends State<PostItem> {
children: [
buildHeader().paddingSymmetric(horizontal: 12),
MarkdownTextContent(
content: item.content,
content: item.body['content'],
isSelectable: widget.isContentSelectable,
).paddingOnly(
left: 16,
@ -199,7 +199,7 @@ class _PostItemState extends State<PostItem> {
bottom: hasAttachment ? 4 : 0,
),
buildFooter().paddingOnly(left: 16),
if (item.attachments?.isNotEmpty ?? false)
if (item.body['attachments']?.isNotEmpty ?? false)
Row(
children: [
Icon(
@ -209,7 +209,7 @@ class _PostItemState extends State<PostItem> {
).paddingOnly(right: 6),
Text(
'postAttachmentTip'.trParams(
{'count': item.attachments!.length.toString()},
{'count': item.body['attachments']!.length.toString()},
),
style: TextStyle(color: _unFocusColor),
)
@ -245,7 +245,7 @@ class _PostItemState extends State<PostItem> {
children: [
buildHeader(),
MarkdownTextContent(
content: item.content,
content: item.body['content'],
isSelectable: widget.isContentSelectable,
).paddingOnly(left: 12, right: 8),
if (widget.item.replyTo != null && widget.isShowEmbed)
@ -256,7 +256,7 @@ class _PostItemState extends State<PostItem> {
AppRouter.instance.pushNamed(
'postDetail',
pathParameters: {
'alias': widget.item.replyTo!.alias,
'id': widget.item.replyTo!.id.toString(),
},
);
},
@ -269,7 +269,7 @@ class _PostItemState extends State<PostItem> {
AppRouter.instance.pushNamed(
'postDetail',
pathParameters: {
'alias': widget.item.repostTo!.alias,
'alias': widget.item.repostTo!.id.toString(),
},
);
},
@ -292,8 +292,8 @@ class _PostItemState extends State<PostItem> {
maxWidth: 640,
),
child: AttachmentList(
parentId: widget.item.alias,
attachmentsId: item.attachments ?? List.empty(),
parentId: widget.item.id.toString(),
attachmentsId: item.body['attachments'].cast<int>() ?? List.empty(),
divided: true,
),
),

View File

@ -66,7 +66,7 @@ class PostListEntryWidget extends StatelessWidget {
Widget build(BuildContext context) {
return GestureDetector(
child: PostItem(
key: Key('p${item.alias}'),
key: Key('p${item.id}'),
item: item,
isShowEmbed: isShowEmbed,
isClickable: isNestedClickable,
@ -75,7 +75,7 @@ class PostListEntryWidget extends StatelessWidget {
if (!isClickable) return;
AppRouter.instance.pushNamed(
'postDetail',
pathParameters: {'alias': item.alias},
pathParameters: {'id': item.id.toString()},
);
},
onLongPress: () {

View File

@ -22,7 +22,7 @@ class PostOwnedListEntry extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
PostItem(
key: Key('p${item.alias}'),
key: Key('p${item.id}'),
item: item,
isShowEmbed: false,
isClickable: false,

View File

@ -53,7 +53,7 @@ class _PostQuickActionState extends State<PostQuickAction> {
setState(() => _isSubmitting = true);
final resp = await client.post('/posts/${widget.item.alias}/react', {
final resp = await client.post('/posts/${widget.item.id}/react', {
'symbol': symbol,
'attitude': attitude,
});

View File

@ -3,7 +3,7 @@ import 'package:get/get.dart';
import 'package:infinite_scroll_pagination/infinite_scroll_pagination.dart';
import 'package:solian/models/pagination.dart';
import 'package:solian/models/post.dart';
import 'package:solian/providers/content/feed.dart';
import 'package:solian/providers/content/posts.dart';
import 'package:solian/widgets/posts/post_list.dart';
class PostReplyList extends StatefulWidget {
@ -23,11 +23,11 @@ class _PostReplyListState extends State<PostReplyList> {
PagingController(firstPageKey: 0);
Future<void> getReplies(int pageKey) async {
final FeedProvider provider = Get.find();
final PostProvider provider = Get.find();
Response resp;
try {
resp = await provider.listPostReplies(widget.item.alias, pageKey);
resp = await provider.listPostReplies(widget.item.id.toString(), pageKey);
} catch (e) {
_pagingController.error = e;
return;