New moment

This commit is contained in:
2024-04-14 15:58:27 +08:00
parent 07935a4017
commit c693b0dcd7
20 changed files with 655 additions and 33 deletions

View File

@ -7,5 +7,12 @@
"signUp": "Sign Up",
"signUpCaption": "Create an account on Solarpass and then get the access of entire Solar Networks!",
"post": "Post",
"comment": "Comment"
}
"postVerb": "Post",
"comment": "Comment",
"attachment": "Attachment",
"attachmentAdd": "Add new attachment",
"pickPhoto": "Gallery photo",
"newMoment": "Record a moment",
"postIdentityNotify": "You will create this post as",
"postContentPlaceholder": "What's happened?!"
}

View File

@ -7,5 +7,12 @@
"signUp": "注册",
"signUpCaption": "在 Solarpass 注册一个账号以获得整个 Solar Networks 的存取权!",
"post": "帖子",
"comment": "评论"
"postVerb": "发表",
"comment": "评论",
"attachment": "附件",
"attachmentAdd": "附加新附件",
"pickPhoto": "相册照片",
"newMoment": "记录时刻",
"postIdentityNotify": "你将会以该身份发表本帖子",
"postContentPlaceholder": "发生什么事了?!"
}

View File

@ -7,10 +7,10 @@ import 'package:solian/screens/auth.dart';
import 'package:oauth2/oauth2.dart' as oauth2;
import 'package:solian/utils/service_url.dart';
final authClient = AuthProvider();
class AuthProvider {
AuthProvider();
AuthProvider() {
pickClient();
}
final deviceEndpoint =
getRequestUri('passport', '/api/notifications/subscribe');
@ -61,7 +61,8 @@ class AuthProvider {
basicAuth: false,
);
var authorizationUrl = grant.getAuthorizationUrl(redirectUrl, scopes: ["openid"]);
var authorizationUrl =
grant.getAuthorizationUrl(redirectUrl, scopes: ["openid"]);
if (Platform.isAndroid || Platform.isIOS) {
// Use WebView to get authorization url

View File

@ -3,7 +3,7 @@ import 'package:provider/provider.dart';
import 'package:solian/providers/auth.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import 'package:solian/utils/service_url.dart';
import 'package:solian/widgets/wrapper.dart';
import 'package:solian/widgets/common_wrapper.dart';
import 'package:url_launcher/url_launcher.dart';
class AccountScreen extends StatefulWidget {
@ -66,7 +66,7 @@ class _AccountScreenState extends State<AccountScreen> {
caption: AppLocalizations.of(context)!.signInCaption,
onTap: () {
auth.signIn(context).then((_) {
authClient.isAuthorized().then((val) {
auth.isAuthorized().then((val) {
setState(() => isAuthorized = val);
});
});
@ -90,13 +90,15 @@ class _AccountScreenState extends State<AccountScreen> {
class NameCard extends StatelessWidget {
const NameCard({super.key});
Future<Widget> renderAvatar() async {
final profiles = await authClient.getProfiles();
Future<Widget> renderAvatar(BuildContext context) async {
final auth = context.read<AuthProvider>();
final profiles = await auth.getProfiles();
return CircleAvatar(backgroundImage: NetworkImage(profiles["picture"]));
}
Future<Column> renderLabel() async {
final profiles = await authClient.getProfiles();
Future<Column> renderLabel(BuildContext context) async {
final auth = context.read<AuthProvider>();
final profiles = await auth.getProfiles();
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
@ -122,7 +124,7 @@ class NameCard extends StatelessWidget {
child: Row(
children: [
FutureBuilder(
future: renderAvatar(),
future: renderAvatar(context),
builder:
(BuildContext context, AsyncSnapshot<Widget> snapshot) {
if (snapshot.hasData) {
@ -134,7 +136,7 @@ class NameCard extends StatelessWidget {
),
const SizedBox(width: 20),
FutureBuilder(
future: renderLabel(),
future: renderLabel(context),
builder:
(BuildContext context, AsyncSnapshot<Column> snapshot) {
if (snapshot.hasData) {

View File

@ -8,8 +8,8 @@ import 'package:solian/utils/service_url.dart';
import 'package:infinite_scroll_pagination/infinite_scroll_pagination.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import 'package:http/http.dart' as http;
import 'package:solian/widgets/indent_wrapper.dart';
import 'package:solian/widgets/posts/item.dart';
import 'package:solian/widgets/wrapper.dart';
class ExploreScreen extends StatefulWidget {
const ExploreScreen({super.key});
@ -58,7 +58,15 @@ class _ExploreScreenState extends State<ExploreScreen> {
@override
Widget build(BuildContext context) {
return LayoutWrapper(
return IndentWrapper(
noSafeArea: true,
floatingActionButton: FloatingActionButton(
child: const Icon(Icons.edit),
onPressed: () async {
final did = await router.pushNamed("posts.moments.new");
if (did == true) _pagingController.refresh();
},
),
title: AppLocalizations.of(context)!.explore,
child: RefreshIndicator(
onRefresh: () => Future.sync(
@ -75,7 +83,7 @@ class _ExploreScreenState extends State<ExploreScreen> {
itemBuilder: (context, item, index) => GestureDetector(
child: PostItem(item: item),
onTap: () {
router.goNamed(
router.pushNamed(
'posts.screen',
pathParameters: {
'alias': item.alias,

View File

@ -1,19 +1,143 @@
import 'package:flutter/material.dart';
import 'dart:convert';
class NewMomentScreen extends StatelessWidget {
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:solian/models/post.dart';
import 'package:solian/providers/auth.dart';
import 'package:solian/router.dart';
import 'package:solian/utils/service_url.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import 'package:solian/widgets/indent_wrapper.dart';
import 'package:solian/widgets/posts/attachment_editor.dart';
class NewMomentScreen extends StatefulWidget {
const NewMomentScreen({super.key});
@override
Widget build(BuildContext context) {
return Center(
child: Container(
constraints: const BoxConstraints(maxWidth: 640),
child: Column(
children: [
State<NewMomentScreen> createState() => _NewMomentScreenState();
}
],
class _NewMomentScreenState extends State<NewMomentScreen> {
final _textController = TextEditingController();
bool _isSubmitting = false;
List<Attachment> _attachments = List.empty(growable: true);
void viewAttachments(BuildContext context) {
showModalBottomSheet(
context: context,
builder: (context) => AttachmentEditor(
current: _attachments,
onUpdate: (value) => _attachments = value,
),
);
}
Future<void> createPost(BuildContext context) async {
final auth = context.read<AuthProvider>();
if (!await auth.isAuthorized()) return;
setState(() => _isSubmitting = true);
var res = await auth.client!.post(
getRequestUri('interactive', '/api/p/moments'),
headers: <String, String>{
'Content-Type': 'application/json',
},
body: jsonEncode(<String, dynamic>{
'content': _textController.value.text,
'attachments': _attachments,
}),
);
if (res.statusCode != 200) {
var message = utf8.decode(res.bodyBytes);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text("Something went wrong... $message")),
);
} else {
if (router.canPop()) {
router.pop(true);
}
}
setState(() => _isSubmitting = false);
}
@override
Widget build(BuildContext context) {
final auth = context.read<AuthProvider>();
return IndentWrapper(
hideDrawer: true,
title: AppLocalizations.of(context)!.newMoment,
appBarActions: <Widget>[
TextButton(
onPressed: !_isSubmitting ? () => createPost(context) : null,
child: Text(AppLocalizations.of(context)!.postVerb),
),
],
child: Center(
child: Container(
constraints: const BoxConstraints(maxWidth: 640),
child: Column(
children: [
_isSubmitting ? const LinearProgressIndicator() : Container(),
FutureBuilder(
future: auth.getProfiles(),
builder: (context, snapshot) {
if (snapshot.hasData) {
var userinfo = snapshot.data;
return ListTile(
title: Text(userinfo["nick"]),
subtitle: Text(
AppLocalizations.of(context)!.postIdentityNotify,
),
leading: CircleAvatar(
backgroundImage: NetworkImage(userinfo["picture"]),
),
);
} else {
return Container();
}
},
),
const Divider(thickness: 0.3),
Expanded(
child: Container(
padding:
const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: TextField(
maxLines: null,
autofocus: true,
autocorrect: true,
keyboardType: TextInputType.multiline,
controller: _textController,
decoration: InputDecoration.collapsed(
hintText:
AppLocalizations.of(context)!.postContentPlaceholder,
),
),
),
),
Container(
decoration: const BoxDecoration(
border: Border(
top: BorderSide(width: 0.3, color: Color(0xffdedede)),
),
),
child: Row(
children: [
TextButton(
style: TextButton.styleFrom(shape: const CircleBorder()),
child: const Icon(Icons.camera_alt),
onPressed: () => viewAttachments(context),
)
],
),
),
],
),
),
),
);
}
}
}

View File

@ -5,9 +5,9 @@ import 'package:http/http.dart' as http;
import 'package:infinite_scroll_pagination/infinite_scroll_pagination.dart';
import 'package:solian/models/post.dart';
import 'package:solian/utils/service_url.dart';
import 'package:solian/widgets/indent_wrapper.dart';
import 'package:solian/widgets/posts/comment_list.dart';
import 'package:solian/widgets/posts/item.dart';
import 'package:solian/widgets/wrapper.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
class PostScreen extends StatefulWidget {
@ -43,7 +43,8 @@ class _PostScreenState extends State<PostScreen> {
@override
Widget build(BuildContext context) {
return LayoutWrapper(
return IndentWrapper(
hideDrawer: true,
title: AppLocalizations.of(context)!.post,
child: FutureBuilder(
future: fetchPost(context),

View File

@ -12,7 +12,9 @@ class LayoutWrapper extends StatelessWidget {
return Scaffold(
drawer: const SolianNavigationDrawer(),
appBar: AppBar(title: Text(title)),
body: child ?? Container(),
body: SafeArea(
child: child ?? Container(),
),
);
}
}

View File

@ -0,0 +1,25 @@
import 'package:flutter/material.dart';
import 'package:solian/widgets/navigation_drawer.dart';
class IndentWrapper extends StatelessWidget {
final Widget? child;
final Widget? floatingActionButton;
final List<Widget>? appBarActions;
final bool? hideDrawer;
final bool? noSafeArea;
final String title;
const IndentWrapper({super.key, this.child, required this.title, this.floatingActionButton, this.appBarActions, this.hideDrawer, this.noSafeArea});
@override
Widget build(BuildContext context) {
final content = child ?? Container();
return Scaffold(
appBar: AppBar(title: Text(title), actions: appBarActions),
floatingActionButton: floatingActionButton,
drawer: (hideDrawer ?? false) ? null : const SolianNavigationDrawer(),
body: (noSafeArea ?? false) ? content : SafeArea(child: content),
);
}
}

View File

@ -0,0 +1,300 @@
import 'dart:convert';
import 'dart:io';
import 'dart:math' as math;
import 'package:crypto/crypto.dart';
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:http/http.dart';
import 'package:image_picker/image_picker.dart';
import 'package:provider/provider.dart';
import 'package:solian/models/post.dart';
import 'package:solian/providers/auth.dart';
import 'package:solian/utils/service_url.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
class AttachmentEditor extends StatefulWidget {
final List<Attachment> current;
final void Function(List<Attachment> data) onUpdate;
const AttachmentEditor(
{super.key, required this.current, required this.onUpdate});
@override
State<AttachmentEditor> createState() => _AttachmentEditorState();
}
class _AttachmentEditorState extends State<AttachmentEditor> {
final _imagePicker = ImagePicker();
bool isSubmitting = false;
List<Attachment> _attachments = List.empty(growable: true);
void viewAttachMethods(BuildContext context) {
showModalBottomSheet(
context: context,
builder: (context) => AttachmentEditorMethodPopup(
pickImage: () => pickImageToUpload(context),
),
);
}
Future<void> pickImageToUpload(BuildContext context) async {
final auth = context.read<AuthProvider>();
if (!await auth.isAuthorized()) return;
final image = await _imagePicker.pickImage(source: ImageSource.gallery);
if (image == null) return;
setState(() => isSubmitting = true);
final file = File(image.path);
final hashcode = await calculateSha256(file);
if (Navigator.canPop(context)) {
Navigator.pop(context);
}
try {
await uploadAttachment(file, hashcode);
} catch (err) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text("Something went wrong... $err")),
);
} finally {
setState(() => isSubmitting = false);
}
}
Future<void> uploadAttachment(File file, String hashcode) async {
final auth = context.read<AuthProvider>();
final req = MultipartRequest(
'POST',
getRequestUri('interactive', '/api/attachments'),
);
req.files.add(await MultipartFile.fromPath('attachment', file.path));
req.fields['hashcode'] = hashcode;
var res = await auth.client!.send(req);
print(res);
if (res.statusCode == 200) {
var result = Attachment.fromJson(
jsonDecode(utf8.decode(await res.stream.toBytes()))["info"],
);
setState(() => _attachments.add(result));
widget.onUpdate(_attachments);
} else {
throw Exception(utf8.decode(await res.stream.toBytes()));
}
}
Future<void> disposeAttachment(
BuildContext context, Attachment item, int index) async {
final auth = context.read<AuthProvider>();
final req = MultipartRequest(
'DELETE',
getRequestUri('interactive', '/api/attachments/${item.id}'),
);
setState(() => isSubmitting = true);
var res = await auth.client!.send(req);
if (res.statusCode == 200) {
setState(() => _attachments.removeAt(index));
widget.onUpdate(_attachments);
} else {
final err = utf8.decode(await res.stream.toBytes());
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text("Something went wrong... $err")),
);
}
setState(() => isSubmitting = false);
}
Future<String> calculateSha256(File file) async {
final bytes = await file.readAsBytes();
final digest = sha256.convert(bytes);
return digest.toString();
}
String getFileName(Attachment item) {
return item.filename.replaceAll(RegExp(r'\.[^/.]+$'), '');
}
String getFileType(Attachment item) {
switch (item.type) {
case 1:
return 'Photo';
case 2:
return 'Video';
case 3:
return 'Audio';
default:
return 'Others';
}
}
String formatBytes(int bytes, {int decimals = 2}) {
if (bytes == 0) return '0 Bytes';
const k = 1024;
final dm = decimals < 0 ? 0 : decimals;
final sizes = [
'Bytes',
'KiB',
'MiB',
'GiB',
'TiB',
'PiB',
'EiB',
'ZiB',
'YiB'
];
final i = (math.log(bytes) / math.log(k)).floor().toInt();
return '${(bytes / math.pow(k, i)).toStringAsFixed(dm)} ${sizes[i]}';
}
@override
void initState() {
_attachments = widget.current;
super.initState();
}
@override
Widget build(BuildContext context) {
final auth = context.read<AuthProvider>();
return Column(
children: [
Container(
padding: const EdgeInsets.only(left: 8, right: 8, top: 20),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Padding(
padding: const EdgeInsets.symmetric(
horizontal: 8.0,
vertical: 12.0,
),
child: Text(
AppLocalizations.of(context)!.attachment,
style: Theme.of(context).textTheme.headlineSmall,
),
),
FutureBuilder(
future: auth.isAuthorized(),
builder: (context, snapshot) {
if (snapshot.hasData && snapshot.data == true) {
return TextButton(
onPressed: isSubmitting
? null
: () => viewAttachMethods(context),
style: TextButton.styleFrom(shape: const CircleBorder()),
child: const Icon(Icons.add_circle),
);
} else {
return Container();
}
},
),
],
),
),
isSubmitting ? const LinearProgressIndicator() : Container(),
Expanded(
child: ListView.separated(
itemCount: _attachments.length,
itemBuilder: (context, index) {
var element = _attachments[index];
return Container(
padding: const EdgeInsets.only(left: 16, right: 8),
child: Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
getFileName(element),
overflow: TextOverflow.ellipsis,
maxLines: 1,
style: Theme.of(context).textTheme.titleMedium,
),
Text(
"${getFileType(element)} · ${formatBytes(element.filesize)}",
),
],
),
),
TextButton(
style: TextButton.styleFrom(
shape: const CircleBorder(),
foregroundColor: Colors.red,
),
child: const Icon(Icons.delete),
onPressed: () =>
disposeAttachment(context, element, index),
),
],
),
);
},
separatorBuilder: (context, index) => const Divider(),
),
),
],
);
}
}
class AttachmentEditorMethodPopup extends StatelessWidget {
final Function pickImage;
const AttachmentEditorMethodPopup({super.key, required this.pickImage});
@override
Widget build(BuildContext context) {
return Container(
height: 320,
padding: const EdgeInsets.only(left: 8, right: 8, top: 20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.symmetric(
horizontal: 8.0,
vertical: 12.0,
),
child: Text(
AppLocalizations.of(context)!.attachmentAdd,
style: Theme.of(context).textTheme.headlineSmall,
),
),
Expanded(
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceAround,
children: [
InkWell(
borderRadius: BorderRadius.circular(8),
onTap: () => pickImage(),
child: Padding(
padding: const EdgeInsets.all(8),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.add_photo_alternate,
color: Colors.indigo),
const SizedBox(height: 8),
Text(AppLocalizations.of(context)!.pickPhoto),
],
),
),
),
],
),
),
],
),
);
}
}

View File

@ -80,7 +80,7 @@ class _PostItemState extends State<PostItem> {
children: [
...headingParts,
Padding(
padding: const EdgeInsets.symmetric(horizontal: 12),
padding: const EdgeInsets.only(left: 12, right: 12, top: 4),
child: renderContent(),
),
renderAttachments(),