feat: Stories
This commit is contained in:
parent
a695a4a001
commit
231a2e26f9
13 changed files with 1065 additions and 4 deletions
158
lib/pages/add_story/add_story.dart
Normal file
158
lib/pages/add_story/add_story.dart
Normal file
|
|
@ -0,0 +1,158 @@
|
|||
//@dart=2.12
|
||||
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:adaptive_dialog/adaptive_dialog.dart';
|
||||
import 'package:file_picker_cross/file_picker_cross.dart';
|
||||
import 'package:flutter_gen/gen_l10n/l10n.dart';
|
||||
import 'package:future_loading_dialog/future_loading_dialog.dart';
|
||||
import 'package:image_picker/image_picker.dart';
|
||||
import 'package:matrix/matrix.dart';
|
||||
import 'package:video_player/video_player.dart';
|
||||
import 'package:vrouter/vrouter.dart';
|
||||
|
||||
import 'package:fluffychat/pages/add_story/add_story_view.dart';
|
||||
import 'package:fluffychat/pages/add_story/invite_story_page.dart';
|
||||
import 'package:fluffychat/utils/resize_image.dart';
|
||||
import 'package:fluffychat/utils/string_color.dart';
|
||||
import 'package:fluffychat/widgets/matrix.dart';
|
||||
import '../../utils/matrix_sdk_extensions.dart/client_stories_extension.dart';
|
||||
|
||||
class AddStoryPage extends StatefulWidget {
|
||||
const AddStoryPage({Key? key}) : super(key: key);
|
||||
|
||||
@override
|
||||
AddStoryController createState() => AddStoryController();
|
||||
}
|
||||
|
||||
class AddStoryController extends State<AddStoryPage> {
|
||||
final TextEditingController controller = TextEditingController();
|
||||
late Color backgroundColor;
|
||||
late Color backgroundColorDark;
|
||||
MatrixFile? image;
|
||||
MatrixFile? video;
|
||||
|
||||
VideoPlayerController? videoPlayerController;
|
||||
|
||||
bool get hasMedia => image != null || video != null;
|
||||
|
||||
void updateColors(String text) => hasMedia
|
||||
? null
|
||||
: setState(() {
|
||||
backgroundColor = text.color;
|
||||
backgroundColorDark = text.darkColor;
|
||||
});
|
||||
|
||||
void importMedia() async {
|
||||
final type = await showModalActionSheet<FileTypeCross>(
|
||||
context: context,
|
||||
actions: [
|
||||
SheetAction(
|
||||
label: L10n.of(context)!.pickImage,
|
||||
key: FileTypeCross.image,
|
||||
icon: Icons.photo_album_outlined,
|
||||
),
|
||||
SheetAction(
|
||||
label: L10n.of(context)!.sendVideo,
|
||||
key: FileTypeCross.video,
|
||||
icon: Icons.video_camera_back_outlined,
|
||||
),
|
||||
],
|
||||
);
|
||||
if (type == null) return;
|
||||
final picked = await FilePickerCross.importFromStorage(type: type);
|
||||
final fileName = picked.fileName;
|
||||
if (fileName == null) return;
|
||||
setState(() {
|
||||
image = MatrixFile(bytes: picked.toUint8List(), name: fileName);
|
||||
});
|
||||
}
|
||||
|
||||
void capturePhoto() async {
|
||||
final picked = await ImagePicker().pickImage(
|
||||
source: ImageSource.camera,
|
||||
);
|
||||
if (picked == null) return;
|
||||
final bytes = await picked.readAsBytes();
|
||||
setState(() {
|
||||
image = MatrixFile(bytes: bytes, name: picked.name);
|
||||
});
|
||||
}
|
||||
|
||||
void captureVideo() async {
|
||||
final picked = await ImagePicker().pickVideo(
|
||||
source: ImageSource.camera,
|
||||
);
|
||||
if (picked == null) return;
|
||||
final bytes = await picked.readAsBytes();
|
||||
|
||||
setState(() {
|
||||
video = MatrixFile(bytes: bytes, name: picked.name);
|
||||
videoPlayerController = VideoPlayerController.file(File(picked.path))
|
||||
..setLooping(true);
|
||||
});
|
||||
}
|
||||
|
||||
void postStory() async {
|
||||
final client = Matrix.of(context).client;
|
||||
final storiesRoom = await client.getStoriesRoom(context);
|
||||
|
||||
// Invite contacts if necessary
|
||||
final undecided = await showFutureLoadingDialog(
|
||||
context: context,
|
||||
future: () => client.getUndecidedContactsForStories(storiesRoom),
|
||||
);
|
||||
final result = undecided.result;
|
||||
if (result == null) return;
|
||||
if (result.isNotEmpty) {
|
||||
final created = await showDialog<bool>(
|
||||
context: context,
|
||||
useRootNavigator: false,
|
||||
builder: (context) => InviteStoryPage(storiesRoom: storiesRoom),
|
||||
);
|
||||
if (created != true) return;
|
||||
}
|
||||
|
||||
// Post story
|
||||
final postResult = await showFutureLoadingDialog(
|
||||
context: context,
|
||||
future: () async {
|
||||
if (storiesRoom == null) throw ('Stories room is null');
|
||||
final video = this.video;
|
||||
if (video != null) {
|
||||
await storiesRoom.sendFileEvent(
|
||||
video,
|
||||
extraContent: {'body': controller.text},
|
||||
);
|
||||
return;
|
||||
}
|
||||
var image = this.image;
|
||||
if (image != null) {
|
||||
image = await image.resizeImage();
|
||||
await storiesRoom.sendFileEvent(
|
||||
image,
|
||||
extraContent: {'body': controller.text},
|
||||
);
|
||||
return;
|
||||
}
|
||||
await storiesRoom.sendTextEvent(controller.text);
|
||||
},
|
||||
);
|
||||
if (postResult.error == null) {
|
||||
VRouter.of(context).to('/rooms');
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
final text = Matrix.of(context).client.userID!;
|
||||
backgroundColor = text.color;
|
||||
backgroundColorDark = text.darkColor;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => AddStoryView(this);
|
||||
}
|
||||
115
lib/pages/add_story/add_story_view.dart
Normal file
115
lib/pages/add_story/add_story_view.dart
Normal file
|
|
@ -0,0 +1,115 @@
|
|||
//@dart=2.12
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:flutter_gen/gen_l10n/l10n.dart';
|
||||
import 'package:video_player/video_player.dart';
|
||||
|
||||
import 'package:fluffychat/utils/platform_infos.dart';
|
||||
import 'add_story.dart';
|
||||
|
||||
class AddStoryView extends StatelessWidget {
|
||||
final AddStoryController controller;
|
||||
const AddStoryView(this.controller, {Key? key}) : super(key: key);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final video = controller.videoPlayerController;
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
backgroundColor:
|
||||
Theme.of(context).appBarTheme.backgroundColor?.withOpacity(0.5),
|
||||
title: Text(L10n.of(context)!.addToStory),
|
||||
actions: controller.hasMedia
|
||||
? null
|
||||
: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.photo_outlined),
|
||||
onPressed: controller.importMedia,
|
||||
),
|
||||
if (PlatformInfos.isMobile)
|
||||
IconButton(
|
||||
icon: const Icon(Icons.camera_alt_outlined),
|
||||
onPressed: controller.capturePhoto,
|
||||
),
|
||||
if (PlatformInfos.isMobile)
|
||||
IconButton(
|
||||
icon: const Icon(Icons.video_camera_back_outlined),
|
||||
onPressed: controller.captureVideo,
|
||||
),
|
||||
],
|
||||
),
|
||||
extendBodyBehindAppBar: true,
|
||||
body: Stack(
|
||||
children: [
|
||||
if (video != null)
|
||||
FutureBuilder(
|
||||
future: video.initialize().then((_) => video.play()),
|
||||
builder: (_, __) => Center(child: VideoPlayer(video)),
|
||||
),
|
||||
AnimatedContainer(
|
||||
duration: const Duration(seconds: 2),
|
||||
padding: const EdgeInsets.all(8.0),
|
||||
decoration: BoxDecoration(
|
||||
image: controller.image == null
|
||||
? null
|
||||
: DecorationImage(
|
||||
image: MemoryImage(controller.image!.bytes),
|
||||
fit: BoxFit.cover,
|
||||
opacity: 0.75,
|
||||
),
|
||||
gradient: controller.hasMedia
|
||||
? null
|
||||
: LinearGradient(
|
||||
colors: [
|
||||
controller.backgroundColor,
|
||||
controller.backgroundColorDark,
|
||||
controller.backgroundColor,
|
||||
],
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
),
|
||||
),
|
||||
child: Center(
|
||||
child: TextField(
|
||||
controller: controller.controller,
|
||||
minLines: 1,
|
||||
maxLines: 20,
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
fontSize: 24,
|
||||
color: Colors.white,
|
||||
backgroundColor: !controller.hasMedia ? null : Colors.black,
|
||||
),
|
||||
onChanged: controller.updateColors,
|
||||
decoration: InputDecoration(
|
||||
border: InputBorder.none,
|
||||
hintText:
|
||||
controller.hasMedia ? 'Add description' : 'How are you?',
|
||||
filled: false,
|
||||
hintStyle: TextStyle(
|
||||
color: Colors.white.withOpacity(0.5),
|
||||
backgroundColor: Colors.transparent,
|
||||
),
|
||||
enabledBorder: InputBorder.none,
|
||||
focusedBorder: InputBorder.none,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
floatingActionButtonLocation: FloatingActionButtonLocation.centerFloat,
|
||||
floatingActionButton:
|
||||
controller.controller.text.isEmpty && !controller.hasMedia
|
||||
? null
|
||||
: FloatingActionButton.extended(
|
||||
onPressed: controller.postStory,
|
||||
label: Text(L10n.of(context)!.publish),
|
||||
backgroundColor: Theme.of(context).colorScheme.surface,
|
||||
foregroundColor: Theme.of(context).colorScheme.onSurface,
|
||||
icon: const Icon(Icons.check_circle),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
109
lib/pages/add_story/invite_story_page.dart
Normal file
109
lib/pages/add_story/invite_story_page.dart
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
//@dart=2.12
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:flutter_gen/gen_l10n/l10n.dart';
|
||||
import 'package:future_loading_dialog/future_loading_dialog.dart';
|
||||
import 'package:matrix/matrix.dart';
|
||||
|
||||
import 'package:fluffychat/utils/localized_exception_extension.dart';
|
||||
import 'package:fluffychat/utils/matrix_sdk_extensions.dart/client_stories_extension.dart';
|
||||
import 'package:fluffychat/widgets/avatar.dart';
|
||||
import 'package:fluffychat/widgets/matrix.dart';
|
||||
|
||||
class InviteStoryPage extends StatefulWidget {
|
||||
final Room? storiesRoom;
|
||||
const InviteStoryPage({
|
||||
required this.storiesRoom,
|
||||
Key? key,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
_InviteStoryPageState createState() => _InviteStoryPageState();
|
||||
}
|
||||
|
||||
class _InviteStoryPageState extends State<InviteStoryPage> {
|
||||
Set<String> _undecided = {};
|
||||
final Set<String> _invite = {};
|
||||
|
||||
void _inviteAction() async {
|
||||
final result = await showFutureLoadingDialog(
|
||||
context: context,
|
||||
future: () async {
|
||||
final client = Matrix.of(context).client;
|
||||
final room = await client.getStoriesRoom(context);
|
||||
if (room == null) {
|
||||
await client.createStoriesRoom(_invite.toList());
|
||||
} else {
|
||||
for (final userId in _invite) {
|
||||
room.invite(userId);
|
||||
}
|
||||
}
|
||||
|
||||
_undecided.removeAll(_invite);
|
||||
await client.setStoriesBlockList(_undecided.toList());
|
||||
},
|
||||
);
|
||||
if (result.error != null) return;
|
||||
Navigator.of(context).pop<bool>(true);
|
||||
}
|
||||
|
||||
Future<List<User>>? loadContacts;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
loadContacts ??= Matrix.of(context)
|
||||
.client
|
||||
.getUndecidedContactsForStories(widget.storiesRoom)
|
||||
.then((contacts) {
|
||||
if (contacts.length < 20) {
|
||||
_invite.addAll(contacts.map((u) => u.id));
|
||||
}
|
||||
return contacts;
|
||||
});
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
leading: IconButton(
|
||||
icon: const Icon(Icons.close),
|
||||
onPressed: () => Navigator.of(context).pop<bool>(false),
|
||||
),
|
||||
title: Text(L10n.of(context)!.whoCanSeeMyStories),
|
||||
),
|
||||
body: FutureBuilder<List<User>>(
|
||||
future: loadContacts,
|
||||
builder: (context, snapshot) {
|
||||
final contacts = snapshot.data;
|
||||
if (contacts == null) {
|
||||
final error = snapshot.error;
|
||||
if (error != null) {
|
||||
return Center(child: Text(error.toLocalizedString(context)));
|
||||
}
|
||||
return const Center(child: CircularProgressIndicator.adaptive());
|
||||
}
|
||||
_undecided = contacts.map((u) => u.id).toSet();
|
||||
return ListView.builder(
|
||||
itemCount: contacts.length,
|
||||
itemBuilder: (context, i) => SwitchListTile.adaptive(
|
||||
value: _invite.contains(contacts[i].id),
|
||||
onChanged: (b) => setState(() => b
|
||||
? _invite.add(contacts[i].id)
|
||||
: _invite.remove(contacts[i].id)),
|
||||
secondary: Avatar(
|
||||
mxContent: contacts[i].avatarUrl,
|
||||
name: contacts[i].calcDisplayname(),
|
||||
),
|
||||
title: Text(contacts[i].calcDisplayname()),
|
||||
),
|
||||
);
|
||||
}),
|
||||
floatingActionButtonLocation: FloatingActionButtonLocation.centerFloat,
|
||||
floatingActionButton: FloatingActionButton.extended(
|
||||
onPressed: _inviteAction,
|
||||
label: Text(L10n.of(context)!.publish),
|
||||
backgroundColor: Theme.of(context).colorScheme.surface,
|
||||
foregroundColor: Theme.of(context).colorScheme.onSurface,
|
||||
icon: const Icon(Icons.upload_outlined),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue