diff --git a/lib/pages/add_story/add_story.dart b/lib/pages/add_story/add_story.dart index cdf6d0d2..f9dd4e94 100644 --- a/lib/pages/add_story/add_story.dart +++ b/lib/pages/add_story/add_story.dart @@ -1,6 +1,7 @@ -import 'dart:async'; import 'dart:io'; +import 'dart:math'; +import 'package:fluffychat/utils/story_theme_data.dart'; import 'package:flutter/material.dart'; import 'package:file_picker_cross/file_picker_cross.dart'; @@ -41,22 +42,29 @@ class AddStoryController extends State { bool textFieldHasFocus = false; - Timer? _updateColorsCooldown; + BoxFit fit = BoxFit.contain; - void updateColors() { - if (hasText != controller.text.isNotEmpty) { + int alignmentX = 0; + int alignmentY = 0; + + void toggleBoxFit() { + if (fit == BoxFit.contain) { setState(() { - hasText = controller.text.isNotEmpty; + fit = BoxFit.cover; + }); + } else { + setState(() { + fit = BoxFit.contain; + }); + } + } + + void updateHasText(String text) { + if (hasText != text.isNotEmpty) { + setState(() { + hasText = text.isNotEmpty; }); } - _updateColorsCooldown?.cancel(); - _updateColorsCooldown = Timer( - const Duration(seconds: 3), - () => setState(() { - backgroundColor = controller.text.color; - backgroundColorDark = controller.text.darkColor; - }), - ); } void importMedia() async { @@ -65,13 +73,16 @@ class AddStoryController extends State { ); final fileName = picked.fileName; if (fileName == null) return; - final shrinked = await MatrixImageFile.shrink( - bytes: picked.toUint8List(), - name: fileName, - compute: Matrix.of(context).client.runInBackground, + final shrinked = await showFutureLoadingDialog( + context: context, + future: () => MatrixImageFile.shrink( + bytes: picked.toUint8List(), + name: fileName, + compute: Matrix.of(context).client.runInBackground, + ), ); setState(() { - image = shrinked; + image = shrinked.result; }); } @@ -80,14 +91,27 @@ class AddStoryController extends State { source: ImageSource.camera, ); if (picked == null) return; - final bytes = await picked.readAsBytes(); - final shrinked = await MatrixImageFile.shrink( - bytes: bytes, - name: picked.name, - compute: Matrix.of(context).client.runInBackground, - ); + final shrinked = await showFutureLoadingDialog( + context: context, + future: () async { + final bytes = await picked.readAsBytes(); + return await MatrixImageFile.shrink( + bytes: bytes, + name: picked.name, + compute: Matrix.of(context).client.runInBackground, + ); + }); + setState(() { - image = shrinked; + image = shrinked.result; + }); + } + + void updateColor() { + final rand = Random().nextInt(1000).toString(); + setState(() { + backgroundColor = rand.color; + backgroundColorDark = rand.darkColor; }); } @@ -143,7 +167,14 @@ class AddStoryController extends State { final thumbnail = await video.getVideoThumbnail(); await storiesRoom.sendFileEvent( video, - extraContent: {'body': controller.text}, + extraContent: { + 'body': controller.text, + StoryThemeData.contentKey: StoryThemeData( + fit: fit, + alignmentX: alignmentX, + alignmentY: alignmentY, + ).toJson(), + }, thumbnail: thumbnail, ); return; @@ -152,11 +183,28 @@ class AddStoryController extends State { if (image != null) { await storiesRoom.sendFileEvent( image, - extraContent: {'body': controller.text}, + extraContent: { + 'body': controller.text, + StoryThemeData.contentKey: StoryThemeData( + fit: fit, + alignmentX: alignmentX, + alignmentY: alignmentY, + ).toJson(), + }, ); return; } - await storiesRoom.sendTextEvent(controller.text); + await storiesRoom.sendEvent({ + 'msgtype': MessageTypes.Text, + 'body': controller.text, + StoryThemeData.contentKey: StoryThemeData( + color1: backgroundColor, + color2: backgroundColorDark, + fit: fit, + alignmentX: alignmentX, + alignmentY: alignmentY, + ).toJson(), + }); }, ); if (postResult.error == null) { @@ -164,12 +212,40 @@ class AddStoryController extends State { } } + void onVerticalDragUpdate(DragUpdateDetails details) { + final delta = details.primaryDelta; + if (delta == null) return; + if (delta > 0 && alignmentY < 100) { + setState(() { + alignmentY += 1; + }); + } else if (delta < 0 && alignmentY > -100) { + setState(() { + alignmentY -= 1; + }); + } + } + + void onHorizontalDragUpdate(DragUpdateDetails details) { + final delta = details.primaryDelta; + if (delta == null) return; + if (delta > 0 && alignmentX < 100) { + setState(() { + alignmentX += 1; + }); + } else if (delta < 0 && alignmentX > -100) { + setState(() { + alignmentX -= 1; + }); + } + } + @override void initState() { super.initState(); - final text = Matrix.of(context).client.userID!; - backgroundColor = text.color; - backgroundColorDark = text.darkColor; + final rand = Random().nextInt(1000).toString(); + backgroundColor = rand.color; + backgroundColorDark = rand.darkColor; focusNode.addListener(() { if (textFieldHasFocus != focusNode.hasFocus) { setState(() { diff --git a/lib/pages/add_story/add_story_view.dart b/lib/pages/add_story/add_story_view.dart index eda2034f..faecb415 100644 --- a/lib/pages/add_story/add_story_view.dart +++ b/lib/pages/add_story/add_story_view.dart @@ -13,6 +13,7 @@ class AddStoryView extends StatelessWidget { @override Widget build(BuildContext context) { final video = controller.videoPlayerController; + return Scaffold( backgroundColor: Colors.blueGrey.shade900, appBar: AppBar( @@ -36,102 +37,126 @@ class AddStoryView extends StatelessWidget { actions: [ if (controller.hasMedia) IconButton( - icon: const Icon(Icons.delete_outlined), - onPressed: controller.reset, + icon: const Icon(Icons.fullscreen_outlined), + onPressed: controller.toggleBoxFit, ), - ], - ), - extendBodyBehindAppBar: true, - body: Stack( - children: [ - if (video != null) - Padding( - padding: const EdgeInsets.symmetric(vertical: 80.0), - child: FutureBuilder( - future: video.initialize().then((_) => video.play()), - builder: (_, __) => Center(child: VideoPlayer(video)), - ), - ), - AnimatedContainer( - duration: const Duration(seconds: 2), - padding: const EdgeInsets.symmetric( - horizontal: 8.0, - vertical: 80.0, - ), - decoration: BoxDecoration( - image: controller.image == null - ? null - : DecorationImage( - image: MemoryImage(controller.image!.bytes), - fit: BoxFit.contain, - opacity: 0.75, - ), - gradient: controller.hasMedia - ? null - : LinearGradient( - colors: [ - controller.backgroundColorDark, - controller.backgroundColor, - ], - begin: Alignment.topCenter, - end: Alignment.bottomCenter, - ), - ), - child: Center( - child: TextField( - controller: controller.controller, - focusNode: controller.focusNode, - minLines: 1, - maxLines: 15, - maxLength: 500, - autofocus: false, - textAlign: TextAlign.center, - style: TextStyle( - fontSize: 24, - color: Colors.white, - backgroundColor: !controller.hasMedia - ? null - : Colors.black.withOpacity(0.5), - ), - onChanged: (_) => controller.updateColors(), - decoration: InputDecoration( - border: InputBorder.none, - hintText: controller.hasMedia - ? L10n.of(context)!.addDescription - : L10n.of(context)!.whatIsGoingOn, - filled: false, - hintStyle: TextStyle( - color: Colors.white.withOpacity(0.5), - backgroundColor: Colors.transparent, - ), - enabledBorder: InputBorder.none, - focusedBorder: InputBorder.none, - ), - ), + if (!controller.hasMedia) + IconButton( + icon: const Icon(Icons.color_lens_outlined), + onPressed: controller.updateColor, ), + IconButton( + icon: const Icon(Icons.delete_outlined), + onPressed: controller.reset, ), ], ), - floatingActionButton: Column( + extendBodyBehindAppBar: true, + body: GestureDetector( + onVerticalDragUpdate: controller.onVerticalDragUpdate, + onHorizontalDragUpdate: controller.onHorizontalDragUpdate, + child: Stack( + children: [ + if (video != null) + Padding( + padding: const EdgeInsets.symmetric(vertical: 80.0), + child: FutureBuilder( + future: video.initialize().then((_) => video.play()), + builder: (_, __) => Center(child: VideoPlayer(video)), + ), + ), + AnimatedContainer( + duration: const Duration(seconds: 1), + padding: const EdgeInsets.symmetric( + horizontal: 8.0, + vertical: 80.0, + ), + decoration: BoxDecoration( + image: controller.image == null + ? null + : DecorationImage( + image: MemoryImage(controller.image!.bytes), + fit: controller.fit, + opacity: 0.75, + ), + gradient: controller.hasMedia + ? null + : LinearGradient( + colors: [ + controller.backgroundColorDark, + controller.backgroundColor, + ], + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + ), + ), + child: Align( + alignment: Alignment( + controller.alignmentX / 100, + controller.alignmentY / 100, + ), + child: IntrinsicWidth( + child: TextField( + controller: controller.controller, + focusNode: controller.focusNode, + minLines: 1, + maxLines: 15, + maxLength: 500, + autofocus: false, + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 24, + color: Colors.white, + shadows: controller.hasMedia + ? const [ + Shadow( + color: Colors.black, + offset: Offset(5, 5), + blurRadius: 20, + ), + Shadow( + color: Colors.black, + offset: Offset(5, 5), + blurRadius: 20, + ), + Shadow( + color: Colors.black, + offset: Offset(-5, -5), + blurRadius: 20, + ), + Shadow( + color: Colors.black, + offset: Offset(-5, -5), + blurRadius: 20, + ), + ] + : null, + ), + onChanged: controller.updateHasText, + decoration: InputDecoration( + border: InputBorder.none, + hintText: controller.hasMedia + ? L10n.of(context)!.addDescription + : L10n.of(context)!.whatIsGoingOn, + filled: false, + hintStyle: TextStyle( + color: Colors.white.withOpacity(0.5), + backgroundColor: Colors.transparent, + ), + enabledBorder: InputBorder.none, + focusedBorder: InputBorder.none, + ), + ), + ), + ), + ), + ], + ), + ), + floatingActionButton: Row( mainAxisSize: MainAxisSize.min, children: [ - if (!controller.hasMedia && !controller.textFieldHasFocus) ...[ - FloatingActionButton( - onPressed: controller.captureVideo, - backgroundColor: controller.backgroundColorDark, - foregroundColor: Colors.white, - heroTag: null, - child: const Icon(Icons.video_camera_front_outlined), - ), - const SizedBox(height: 16), - FloatingActionButton( - onPressed: controller.capturePhoto, - backgroundColor: controller.backgroundColorDark, - foregroundColor: Colors.white, - heroTag: null, - child: const Icon(Icons.camera_alt_outlined), - ), - const SizedBox(height: 16), + if (!controller.hasMedia) ...[ FloatingActionButton( onPressed: controller.importMedia, backgroundColor: controller.backgroundColorDark, @@ -139,9 +164,25 @@ class AddStoryView extends StatelessWidget { heroTag: null, child: const Icon(Icons.photo_outlined), ), + const SizedBox(width: 16), + FloatingActionButton( + onPressed: controller.capturePhoto, + backgroundColor: controller.backgroundColorDark, + foregroundColor: Colors.white, + heroTag: null, + child: const Icon(Icons.camera_alt_outlined), + ), + const SizedBox(width: 16), + FloatingActionButton( + onPressed: controller.captureVideo, + backgroundColor: controller.backgroundColorDark, + foregroundColor: Colors.white, + heroTag: null, + child: const Icon(Icons.video_camera_front_outlined), + ), ], if (controller.hasMedia || controller.hasText) ...[ - const SizedBox(height: 16), + const SizedBox(width: 16), FloatingActionButton( onPressed: controller.postStory, backgroundColor: Theme.of(context).colorScheme.surface, diff --git a/lib/pages/story/story_page.dart b/lib/pages/story/story_page.dart index 4955e38d..2659012e 100644 --- a/lib/pages/story/story_page.dart +++ b/lib/pages/story/story_page.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'dart:io'; +import 'package:fluffychat/utils/story_theme_data.dart'; import 'package:flutter/material.dart'; import 'package:adaptive_dialog/adaptive_dialog.dart'; @@ -44,6 +45,10 @@ class StoryPageController extends State { Timeline? timeline; Event? get currentEvent => index < events.length ? events[index] : null; + StoryThemeData get storyThemeData => + StoryThemeData.fromJson(currentEvent?.content + .tryGetMap(StoryThemeData.contentKey) ?? + {}); bool replyLoading = false; bool _modalOpened = false; @@ -467,6 +472,14 @@ class StoryPageController extends State { case PopupStoryAction.delete: _delete(); break; + case PopupStoryAction.message: + final roomIdResult = await showFutureLoadingDialog( + context: context, + future: () => currentEvent!.sender.startDirectChat(), + ); + if (roomIdResult.error != null) return; + VRouter.of(context).toSegments(['rooms', roomIdResult.result!]); + break; } } @@ -493,4 +506,5 @@ extension on List { enum PopupStoryAction { report, delete, + message, } diff --git a/lib/pages/story/story_view.dart b/lib/pages/story/story_view.dart index 6f68a804..db4f4bdf 100644 --- a/lib/pages/story/story_view.dart +++ b/lib/pages/story/story_view.dart @@ -19,6 +19,29 @@ class StoryView extends StatelessWidget { final StoryPageController controller; const StoryView(this.controller, {Key? key}) : super(key: key); + static const List textShadows = [ + Shadow( + color: Colors.black, + offset: Offset(5, 5), + blurRadius: 20, + ), + Shadow( + color: Colors.black, + offset: Offset(5, 5), + blurRadius: 20, + ), + Shadow( + color: Colors.black, + offset: Offset(-5, -5), + blurRadius: 20, + ), + Shadow( + color: Colors.black, + offset: Offset(-5, -5), + blurRadius: 20, + ), + ]; + @override Widget build(BuildContext context) { final currentEvent = controller.currentEvent; @@ -85,6 +108,11 @@ class StoryView extends StatelessWidget { value: PopupStoryAction.report, child: Text(L10n.of(context)!.reportMessage), ), + if (!controller.isOwnStory) + PopupMenuItem( + value: PopupStoryAction.message, + child: Text(L10n.of(context)!.sendAMessage), + ), ], ), ], @@ -134,11 +162,12 @@ class StoryView extends StatelessWidget { ); } final event = events[controller.index]; - final backgroundColor = event.content.tryGet('body')?.color ?? + final backgroundColor = controller.storyThemeData.color1 ?? + event.content.tryGet('body')?.color ?? Theme.of(context).primaryColor; - final backgroundColorDark = + final backgroundColorDark = controller.storyThemeData.color2 ?? event.content.tryGet('body')?.darkColor ?? - Theme.of(context).primaryColorDark; + Theme.of(context).primaryColorDark; if (event.messageType == MessageTypes.Text) { controller.loadingModeOff(); } @@ -146,6 +175,11 @@ class StoryView extends StatelessWidget { return GestureDetector( onTapDown: controller.hold, onTapUp: controller.unhold, + onTapCancel: controller.unhold, + onVerticalDragStart: controller.hold, + onVerticalDragEnd: controller.unhold, + onHorizontalDragStart: controller.hold, + onHorizontalDragEnd: controller.unhold, child: Stack( children: [ if (hash is String) @@ -178,29 +212,23 @@ class StoryView extends StatelessWidget { if (event.messageType == MessageTypes.Image || (event.messageType == MessageTypes.Video && !PlatformInfos.isMobile)) - Positioned( - top: 80, - bottom: 64, - left: 0, - right: 0, - child: FutureBuilder( - future: controller.downloadAndDecryptAttachment( - event, event.messageType == MessageTypes.Video), - builder: (context, snapshot) { - final matrixFile = snapshot.data; - if (matrixFile == null) { - controller.loadingModeOn(); - return Container(); - } - controller.loadingModeOff(); - return Center( - child: Image.memory( - matrixFile.bytes, - fit: BoxFit.contain, - ), - ); - }, - ), + FutureBuilder( + future: controller.downloadAndDecryptAttachment( + event, event.messageType == MessageTypes.Video), + builder: (context, snapshot) { + final matrixFile = snapshot.data; + if (matrixFile == null) { + controller.loadingModeOn(); + return Container(); + } + controller.loadingModeOff(); + return Center( + child: Image.memory( + matrixFile.bytes, + fit: controller.storyThemeData.fit, + ), + ); + }, ), AnimatedContainer( duration: const Duration(milliseconds: 200), @@ -220,36 +248,31 @@ class StoryView extends StatelessWidget { ) : null, ), - alignment: Alignment.center, - child: ListView( - shrinkWrap: true, - children: [ - LinkText( - text: controller.loadingMode - ? L10n.of(context)!.loadingPleaseWait - : event.content.tryGet('body') ?? '', - textAlign: TextAlign.center, - onLinkTap: (url) => - UrlLauncher(context, url).launchUrl(), - linkStyle: TextStyle( - fontSize: 24, - color: Colors.blue.shade50, - decoration: TextDecoration.underline, - backgroundColor: - event.messageType == MessageTypes.Text - ? null - : Colors.black, - ), - textStyle: TextStyle( - fontSize: 24, - color: Colors.white, - backgroundColor: - event.messageType == MessageTypes.Text - ? null - : Colors.black, - ), - ), - ], + alignment: Alignment( + controller.storyThemeData.alignmentX.toDouble() / 100, + controller.storyThemeData.alignmentY.toDouble() / 100, + ), + child: LinkText( + text: controller.loadingMode + ? L10n.of(context)!.loadingPleaseWait + : event.content.tryGet('body') ?? '', + textAlign: TextAlign.center, + onLinkTap: (url) => UrlLauncher(context, url).launchUrl(), + linkStyle: TextStyle( + fontSize: 24, + color: Colors.blue.shade50, + decoration: TextDecoration.underline, + shadows: event.messageType == MessageTypes.Text + ? null + : textShadows, + ), + textStyle: TextStyle( + fontSize: 24, + color: Colors.white, + shadows: event.messageType == MessageTypes.Text + ? null + : textShadows, + ), ), ), Positioned( diff --git a/lib/utils/story_theme_data.dart b/lib/utils/story_theme_data.dart new file mode 100644 index 00000000..e7a75568 --- /dev/null +++ b/lib/utils/story_theme_data.dart @@ -0,0 +1,43 @@ +import 'package:flutter/material.dart'; +import 'package:matrix/matrix.dart'; + +class StoryThemeData { + final Color? color1; + final Color? color2; + final BoxFit fit; + final int alignmentX; + final int alignmentY; + + static const String contentKey = 'msc3588.stories.design'; + + const StoryThemeData({ + this.color1, + this.color2, + this.fit = BoxFit.contain, + this.alignmentX = 0, + this.alignmentY = 0, + }); + + factory StoryThemeData.fromJson(Map json) { + final color1Int = json.tryGet('color1'); + final color2Int = json.tryGet('color2'); + final color1 = color1Int == null ? null : Color(color1Int); + final color2 = color2Int == null ? null : Color(color2Int); + return StoryThemeData( + color1: color1, + color2: color2, + fit: + json.tryGet('fit') == 'cover' ? BoxFit.cover : BoxFit.contain, + alignmentX: json.tryGet('alignment_x') ?? 0, + alignmentY: json.tryGet('alignment_y') ?? 0, + ); + } + + Map toJson() => { + if (color1 != null) 'color1': color1?.value, + if (color2 != null) 'color2': color2?.value, + 'fit': fit.name, + 'alignment_x': alignmentX, + 'alignment_y': alignmentY, + }; +}