From bb6106f1eba19b75593e899809519645947a9d71 Mon Sep 17 00:00:00 2001 From: Freek van de Ven Date: Sun, 15 Oct 2023 13:55:06 +0200 Subject: [PATCH 01/16] feat: timeline post models --- .../lib/flutter_timeline.dart | 5 +- .../lib/src/model/timeline_post.dart | 57 +++++++++++++++++++ .../lib/src/model/timeline_reaction.dart | 27 +++++++++ 3 files changed, 88 insertions(+), 1 deletion(-) create mode 100644 packages/flutter_timeline_interface/lib/src/model/timeline_post.dart create mode 100644 packages/flutter_timeline_interface/lib/src/model/timeline_reaction.dart diff --git a/packages/flutter_timeline/lib/flutter_timeline.dart b/packages/flutter_timeline/lib/flutter_timeline.dart index 12061b8..cdc1254 100644 --- a/packages/flutter_timeline/lib/flutter_timeline.dart +++ b/packages/flutter_timeline/lib/flutter_timeline.dart @@ -1,5 +1,8 @@ // SPDX-FileCopyrightText: 2023 Iconica // // SPDX-License-Identifier: BSD-3-Clause - +/// Flutter Timeline library library flutter_timeline; + +export 'package:flutter_timeline_interface/flutter_timeline_interface.dart'; +export 'package:flutter_timeline_view/flutter_timeline_view.dart'; diff --git a/packages/flutter_timeline_interface/lib/src/model/timeline_post.dart b/packages/flutter_timeline_interface/lib/src/model/timeline_post.dart new file mode 100644 index 0000000..cbad1fc --- /dev/null +++ b/packages/flutter_timeline_interface/lib/src/model/timeline_post.dart @@ -0,0 +1,57 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_timeline_interface/src/model/timeline_reaction.dart'; + +/// A post of the timeline. +@immutable +class TimelinePost { + const TimelinePost({ + required this.id, + required this.creatorId, + required this.title, + required this.category, + required this.content, + required this.likes, + required this.reaction, + required this.createdAt, + required this.reactionEnabled, + this.likedBy, + this.reactions, + this.imageUrl, + }); + + /// The unique identifier of the post. + final String id; + + /// The unique identifier of the creator of the post. + final String creatorId; + + /// The title of the post. + final String title; + + /// The category of the post on which can be filtered. + final String category; + + /// The url of the image of the post. + final String? imageUrl; + + /// The content of the post. + final String content; + + /// The number of likes of the post. + final int likes; + + /// The list of users who liked the post. If null it isn't loaded yet. + final List? likedBy; + + /// The number of reaction of the post. + final int reaction; + + /// The list of reactions of the post. If null it isn't loaded yet. + final List? reactions; + + /// Post creation date. + final DateTime createdAt; + + /// If reacting is enabled on the post. + final bool reactionEnabled; +} diff --git a/packages/flutter_timeline_interface/lib/src/model/timeline_reaction.dart b/packages/flutter_timeline_interface/lib/src/model/timeline_reaction.dart new file mode 100644 index 0000000..7294449 --- /dev/null +++ b/packages/flutter_timeline_interface/lib/src/model/timeline_reaction.dart @@ -0,0 +1,27 @@ +import 'package:flutter/material.dart'; + +@immutable +class TimelinePostReaction { + const TimelinePostReaction({ + required this.id, + required this.postId, + required this.creatorId, + required this.reaction, + required this.createdAt, + }); + + /// The unique identifier of the reaction. + final String id; + + /// The unique identifier of the post on which the reaction is. + final String postId; + + /// The unique identifier of the creator of the reaction. + final String creatorId; + + /// The reactiontext + final String reaction; + + /// Reaction creation date. + final DateTime createdAt; +} From 2c861015cc628e5c17b8bdae74667da4dd5500d5 Mon Sep 17 00:00:00 2001 From: Freek van de Ven Date: Mon, 16 Oct 2023 07:35:39 +0200 Subject: [PATCH 02/16] feat: add example app --- .gitignore | 8 ++++ melos.yaml | 4 +- .../flutter_timeline_view/example/.gitignore | 44 +++++++++++++++++++ .../example/analysis_options.yaml | 28 ++++++++++++ .../example/lib/main.dart | 44 +++++++++++++++++++ .../example/pubspec.yaml | 21 +++++++++ .../example/test/widget_test.dart | 14 ++++++ 7 files changed, 161 insertions(+), 2 deletions(-) create mode 100644 packages/flutter_timeline_view/example/.gitignore create mode 100644 packages/flutter_timeline_view/example/analysis_options.yaml create mode 100644 packages/flutter_timeline_view/example/lib/main.dart create mode 100644 packages/flutter_timeline_view/example/pubspec.yaml create mode 100644 packages/flutter_timeline_view/example/test/widget_test.dart diff --git a/.gitignore b/.gitignore index 8131980..a4ee6d8 100644 --- a/.gitignore +++ b/.gitignore @@ -43,3 +43,11 @@ packages/flutter_timeline_interface/pubspec.lock packages/flutter_timeline_view/pubspec.lock pubspec_overrides.yaml + +**/example/android +**/example/ios +**/example/linux +**/example/macos +**/example/windows +**/example/web +**/example/README.md \ No newline at end of file diff --git a/melos.yaml b/melos.yaml index a8bf405..74e2f94 100644 --- a/melos.yaml +++ b/melos.yaml @@ -35,9 +35,9 @@ scripts: description: Run `flutter analyze` for all packages. format: - run: melos exec flutter format . --fix + run: melos exec dart format . description: Run `flutter format` for all packages. format-check: - run: melos exec flutter format . --set-exit-if-changed + run: melos exec dart format . --set-exit-if-changed description: Run `flutter format` checks for all packages. diff --git a/packages/flutter_timeline_view/example/.gitignore b/packages/flutter_timeline_view/example/.gitignore new file mode 100644 index 0000000..24476c5 --- /dev/null +++ b/packages/flutter_timeline_view/example/.gitignore @@ -0,0 +1,44 @@ +# Miscellaneous +*.class +*.log +*.pyc +*.swp +.DS_Store +.atom/ +.buildlog/ +.history +.svn/ +migrate_working_dir/ + +# IntelliJ related +*.iml +*.ipr +*.iws +.idea/ + +# The .vscode folder contains launch configuration and tasks you configure in +# VS Code which you may wish to be included in version control, so this line +# is commented out by default. +#.vscode/ + +# Flutter/Dart/Pub related +**/doc/api/ +**/ios/Flutter/.last_build_id +.dart_tool/ +.flutter-plugins +.flutter-plugins-dependencies +.packages +.pub-cache/ +.pub/ +/build/ + +# Symbolication related +app.*.symbols + +# Obfuscation related +app.*.map.json + +# Android Studio will place build artifacts here +/android/app/debug +/android/app/profile +/android/app/release diff --git a/packages/flutter_timeline_view/example/analysis_options.yaml b/packages/flutter_timeline_view/example/analysis_options.yaml new file mode 100644 index 0000000..0d29021 --- /dev/null +++ b/packages/flutter_timeline_view/example/analysis_options.yaml @@ -0,0 +1,28 @@ +# This file configures the analyzer, which statically analyzes Dart code to +# check for errors, warnings, and lints. +# +# The issues identified by the analyzer are surfaced in the UI of Dart-enabled +# IDEs (https://dart.dev/tools#ides-and-editors). The analyzer can also be +# invoked from the command line by running `flutter analyze`. + +# The following line activates a set of recommended lints for Flutter apps, +# packages, and plugins designed to encourage good coding practices. +include: package:flutter_lints/flutter.yaml + +linter: + # The lint rules applied to this project can be customized in the + # section below to disable rules from the `package:flutter_lints/flutter.yaml` + # included above or to enable additional rules. A list of all available lints + # and their documentation is published at https://dart.dev/lints. + # + # Instead of disabling a lint rule for the entire project in the + # section below, it can also be suppressed for a single line of code + # or a specific dart file by using the `// ignore: name_of_lint` and + # `// ignore_for_file: name_of_lint` syntax on the line or in the file + # producing the lint. + rules: + # avoid_print: false # Uncomment to disable the `avoid_print` rule + # prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule + +# Additional information about this file can be found at +# https://dart.dev/guides/language/analysis-options diff --git a/packages/flutter_timeline_view/example/lib/main.dart b/packages/flutter_timeline_view/example/lib/main.dart new file mode 100644 index 0000000..13f6381 --- /dev/null +++ b/packages/flutter_timeline_view/example/lib/main.dart @@ -0,0 +1,44 @@ +import 'package:flutter/material.dart'; + +void main() { + runApp(const MyApp()); +} + +class MyApp extends StatelessWidget { + const MyApp({super.key}); + @override + Widget build(BuildContext context) { + return MaterialApp( + title: 'Flutter Timeline Example', + theme: ThemeData( + colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple), + useMaterial3: true, + ), + home: const MyHomePage(), + ); + } +} + +class MyHomePage extends StatelessWidget { + const MyHomePage({super.key,}); + + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + backgroundColor: Theme.of(context).colorScheme.inversePrimary, + ), + body: const Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + 'You have pushed the button this many times:', + ), + ], + ), + ), + ); + } +} diff --git a/packages/flutter_timeline_view/example/pubspec.yaml b/packages/flutter_timeline_view/example/pubspec.yaml new file mode 100644 index 0000000..b8097d1 --- /dev/null +++ b/packages/flutter_timeline_view/example/pubspec.yaml @@ -0,0 +1,21 @@ +name: example +description: Flutter timeline example +publish_to: 'none' # Remove this line if you wish to publish to pub.dev + +version: 1.0.0+1 + +environment: + sdk: '>=3.1.3 <4.0.0' + +dependencies: + flutter: + sdk: flutter + cupertino_icons: ^1.0.2 + +dev_dependencies: + flutter_test: + sdk: flutter + flutter_lints: ^2.0.0 + +flutter: + uses-material-design: true diff --git a/packages/flutter_timeline_view/example/test/widget_test.dart b/packages/flutter_timeline_view/example/test/widget_test.dart new file mode 100644 index 0000000..73b773e --- /dev/null +++ b/packages/flutter_timeline_view/example/test/widget_test.dart @@ -0,0 +1,14 @@ +// This is a basic Flutter widget test. +// +// To perform an interaction with a widget in your test, use the WidgetTester +// utility in the flutter_test package. For example, you can send tap and scroll +// gestures. You can also use WidgetTester to find child widgets in the widget +// tree, read text, and verify that the values of widget properties are correct. + +import 'package:flutter_test/flutter_test.dart'; + +void main() { + test('blank test', () { + expect(true, isTrue); + }); +} From c3e251e3187e9718a6f6ed806397053fd39d5897 Mon Sep 17 00:00:00 2001 From: Freek van de Ven Date: Thu, 2 Nov 2023 10:32:46 +0100 Subject: [PATCH 03/16] feat: timeline screens --- .../lib/flutter_timeline_interface.dart | 5 ++- .../example/lib/main.dart | 5 +-- .../lib/flutter_timeline_view.dart | 6 +++- .../timeline_post_creation_screen.dart | 8 +++++ .../lib/src/screens/timeline_post_screen.dart | 15 ++++++++ .../lib/src/screens/timeline_screen.dart | 36 +++++++++++++++++++ 6 files changed, 71 insertions(+), 4 deletions(-) create mode 100644 packages/flutter_timeline_view/lib/src/screens/timeline_post_creation_screen.dart create mode 100644 packages/flutter_timeline_view/lib/src/screens/timeline_post_screen.dart create mode 100644 packages/flutter_timeline_view/lib/src/screens/timeline_screen.dart diff --git a/packages/flutter_timeline_interface/lib/flutter_timeline_interface.dart b/packages/flutter_timeline_interface/lib/flutter_timeline_interface.dart index 70c4091..acac140 100644 --- a/packages/flutter_timeline_interface/lib/flutter_timeline_interface.dart +++ b/packages/flutter_timeline_interface/lib/flutter_timeline_interface.dart @@ -1,5 +1,8 @@ // SPDX-FileCopyrightText: 2023 Iconica // // SPDX-License-Identifier: BSD-3-Clause - +/// library flutter_timeline_interface; + +export 'src/model/timeline_post.dart'; +export 'src/model/timeline_reaction.dart'; diff --git a/packages/flutter_timeline_view/example/lib/main.dart b/packages/flutter_timeline_view/example/lib/main.dart index 13f6381..7fbc6ad 100644 --- a/packages/flutter_timeline_view/example/lib/main.dart +++ b/packages/flutter_timeline_view/example/lib/main.dart @@ -20,8 +20,9 @@ class MyApp extends StatelessWidget { } class MyHomePage extends StatelessWidget { - const MyHomePage({super.key,}); - + const MyHomePage({ + super.key, + }); @override Widget build(BuildContext context) { diff --git a/packages/flutter_timeline_view/lib/flutter_timeline_view.dart b/packages/flutter_timeline_view/lib/flutter_timeline_view.dart index 3ea67db..954aaef 100644 --- a/packages/flutter_timeline_view/lib/flutter_timeline_view.dart +++ b/packages/flutter_timeline_view/lib/flutter_timeline_view.dart @@ -1,5 +1,9 @@ // SPDX-FileCopyrightText: 2023 Iconica // // SPDX-License-Identifier: BSD-3-Clause - +/// library flutter_timeline_view; + +export 'src/screens/timeline_post_creation_screen.dart'; +export 'src/screens/timeline_post_screen.dart'; +export 'src/screens/timeline_screen.dart'; diff --git a/packages/flutter_timeline_view/lib/src/screens/timeline_post_creation_screen.dart b/packages/flutter_timeline_view/lib/src/screens/timeline_post_creation_screen.dart new file mode 100644 index 0000000..37bd25e --- /dev/null +++ b/packages/flutter_timeline_view/lib/src/screens/timeline_post_creation_screen.dart @@ -0,0 +1,8 @@ +import 'package:flutter/material.dart'; + +class TimelinePostCreationScreen extends StatelessWidget { + const TimelinePostCreationScreen({super.key}); + + @override + Widget build(BuildContext context) => const Placeholder(); +} diff --git a/packages/flutter_timeline_view/lib/src/screens/timeline_post_screen.dart b/packages/flutter_timeline_view/lib/src/screens/timeline_post_screen.dart new file mode 100644 index 0000000..d633ddd --- /dev/null +++ b/packages/flutter_timeline_view/lib/src/screens/timeline_post_screen.dart @@ -0,0 +1,15 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_timeline_interface/flutter_timeline_interface.dart'; + +class TimelinePostScreen extends StatelessWidget { + const TimelinePostScreen({ + required this.post, + super.key, + }); + + + final TimelinePost post; + + @override + Widget build(BuildContext context) => const Placeholder(); +} diff --git a/packages/flutter_timeline_view/lib/src/screens/timeline_screen.dart b/packages/flutter_timeline_view/lib/src/screens/timeline_screen.dart new file mode 100644 index 0000000..4d8a566 --- /dev/null +++ b/packages/flutter_timeline_view/lib/src/screens/timeline_screen.dart @@ -0,0 +1,36 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_timeline_interface/flutter_timeline_interface.dart'; + +class TimelineScreen extends StatefulWidget { + const TimelineScreen({ + required this.posts, + this.controller, + this.timelineCategoryFilter, + this.timelinePostHeight = 100.0, + super.key, + }); + + final ScrollController? controller; + + final String? timelineCategoryFilter; + + final double timelinePostHeight; + + final List posts; + + @override + State createState() => _TimelineScreenState(); +} + +class _TimelineScreenState extends State { + late ScrollController controller; + + @override + void initState() { + super.initState(); + controller = widget.controller ?? ScrollController(); + } + + @override + Widget build(BuildContext context) => const Placeholder(); +} From 56d92d69f688e8020e073c846d0203905e1331e6 Mon Sep 17 00:00:00 2001 From: Freek van de Ven Date: Sat, 18 Nov 2023 13:21:42 +0100 Subject: [PATCH 04/16] feat: add timeline creation screen --- .../lib/flutter_timeline_firebase.dart | 1 + .../lib/flutter_timeline_view.dart | 2 + .../lib/src/config/timeline_options.dart | 50 ++++ .../lib/src/config/timeline_translations.dart | 40 +++ .../timeline_post_creation_screen.dart | 237 +++++++++++++++++- .../lib/src/screens/timeline_post_screen.dart | 4 + .../lib/src/screens/timeline_screen.dart | 4 + .../lib/src/widgets/dotted_container.dart | 65 +++++ packages/flutter_timeline_view/pubspec.yaml | 7 + 9 files changed, 406 insertions(+), 4 deletions(-) create mode 100644 packages/flutter_timeline_view/lib/src/config/timeline_options.dart create mode 100644 packages/flutter_timeline_view/lib/src/config/timeline_translations.dart create mode 100644 packages/flutter_timeline_view/lib/src/widgets/dotted_container.dart diff --git a/packages/flutter_timeline_firebase/lib/flutter_timeline_firebase.dart b/packages/flutter_timeline_firebase/lib/flutter_timeline_firebase.dart index 71f31b5..34125a0 100644 --- a/packages/flutter_timeline_firebase/lib/flutter_timeline_firebase.dart +++ b/packages/flutter_timeline_firebase/lib/flutter_timeline_firebase.dart @@ -2,4 +2,5 @@ // // SPDX-License-Identifier: BSD-3-Clause +/// library flutter_timeline_firebase; diff --git a/packages/flutter_timeline_view/lib/flutter_timeline_view.dart b/packages/flutter_timeline_view/lib/flutter_timeline_view.dart index 954aaef..fcd9330 100644 --- a/packages/flutter_timeline_view/lib/flutter_timeline_view.dart +++ b/packages/flutter_timeline_view/lib/flutter_timeline_view.dart @@ -4,6 +4,8 @@ /// library flutter_timeline_view; +export 'src/config/timeline_options.dart'; +export 'src/config/timeline_translations.dart'; export 'src/screens/timeline_post_creation_screen.dart'; export 'src/screens/timeline_post_screen.dart'; export 'src/screens/timeline_screen.dart'; diff --git a/packages/flutter_timeline_view/lib/src/config/timeline_options.dart b/packages/flutter_timeline_view/lib/src/config/timeline_options.dart new file mode 100644 index 0000000..8cd1f6d --- /dev/null +++ b/packages/flutter_timeline_view/lib/src/config/timeline_options.dart @@ -0,0 +1,50 @@ +// SPDX-FileCopyrightText: 2023 Iconica +// +// SPDX-License-Identifier: BSD-3-Clause +import 'package:flutter/material.dart'; +import 'package:flutter_image_picker/flutter_image_picker.dart'; +import 'package:flutter_timeline_view/src/config/timeline_translations.dart'; +import 'package:intl/intl.dart'; + +@immutable +class TimelineOptions { + const TimelineOptions({ + this.translations = const TimelineTranslations(), + this.imagePickerConfig = const ImagePickerConfig(), + this.imagePickerTheme = const ImagePickerTheme(), + this.dateformat, + this.buttonBuilder, + this.textInputBuilder, + }); + + /// The format to display the post time in + final DateFormat? dateformat; + + final TimelineTranslations translations; + + final ButtonBuilder? buttonBuilder; + + final TextInputBuilder? textInputBuilder; + + /// ImagePickerTheme can be used to change the UI of the + /// Image Picker Widget to change the text/icons to your liking. + final ImagePickerTheme imagePickerTheme; + + /// ImagePickerConfig can be used to define the + /// size and quality for the uploaded image. + final ImagePickerConfig imagePickerConfig; +} + +typedef ButtonBuilder = Widget Function( + BuildContext context, + VoidCallback onPressed, + String text, { + bool enabled, +}); + + +typedef TextInputBuilder = Widget Function( + TextEditingController controller, + Widget? suffixIcon, + String hintText, +); diff --git a/packages/flutter_timeline_view/lib/src/config/timeline_translations.dart b/packages/flutter_timeline_view/lib/src/config/timeline_translations.dart new file mode 100644 index 0000000..25c04c0 --- /dev/null +++ b/packages/flutter_timeline_view/lib/src/config/timeline_translations.dart @@ -0,0 +1,40 @@ +// SPDX-FileCopyrightText: 2023 Iconica +// +// SPDX-License-Identifier: BSD-3-Clause + +import 'package:flutter/material.dart'; + +@immutable +class TimelineTranslations { + const TimelineTranslations({ + this.title = 'Title', + this.content = 'Content', + this.contentDescription = 'What do you want to share?', + this.uploadImage = 'Upload image', + this.uploadImageDescription = 'Upload an image to your message (optional)', + this.allowComments = 'Are people allowed to comment?', + this.allowCommentsDescription = + 'Indicate whether people are allowed to respond', + this.checkPost = 'Check post overview', + this.deletePost = 'Delete post', + this.viewPost = 'View post', + this.likesTitle = 'Likes', + this.commentsTitle = 'Comments', + this.writeComment = 'Write your comment here...', + }); + + final String title; + final String content; + final String contentDescription; + final String uploadImage; + final String uploadImageDescription; + final String allowComments; + final String allowCommentsDescription; + final String checkPost; + + final String deletePost; + final String viewPost; + final String likesTitle; + final String commentsTitle; + final String writeComment; +} diff --git a/packages/flutter_timeline_view/lib/src/screens/timeline_post_creation_screen.dart b/packages/flutter_timeline_view/lib/src/screens/timeline_post_creation_screen.dart index 37bd25e..dcd806a 100644 --- a/packages/flutter_timeline_view/lib/src/screens/timeline_post_creation_screen.dart +++ b/packages/flutter_timeline_view/lib/src/screens/timeline_post_creation_screen.dart @@ -1,8 +1,237 @@ -import 'package:flutter/material.dart'; +// SPDX-FileCopyrightText: 2023 Iconica +// +// SPDX-License-Identifier: BSD-3-Clause -class TimelinePostCreationScreen extends StatelessWidget { - const TimelinePostCreationScreen({super.key}); +import 'dart:typed_data'; + +import 'package:flutter/material.dart'; +import 'package:flutter_image_picker/flutter_image_picker.dart'; +import 'package:flutter_timeline_view/src/config/timeline_options.dart'; +import 'package:flutter_timeline_view/src/widgets/dotted_container.dart'; + +class TimelinePostCreationScreen extends StatefulWidget { + const TimelinePostCreationScreen({ + required this.options, + this.padding = const EdgeInsets.symmetric(vertical: 24, horizontal: 16), + super.key, + }); + + /// The options for the timeline + final TimelineOptions options; + + /// The padding around the screen + final EdgeInsets padding; @override - Widget build(BuildContext context) => const Placeholder(); + State createState() => + _TimelinePostCreationScreenState(); +} + +class _TimelinePostCreationScreenState + extends State { + TextEditingController titleController = TextEditingController(); + TextEditingController contentController = TextEditingController(); + Uint8List? image; + bool editingDone = false; + bool allowComments = false; + + @override + void initState() { + super.initState(); + titleController.addListener(checkIfEditingDone); + contentController.addListener(checkIfEditingDone); + } + + @override + void dispose() { + titleController.dispose(); + contentController.dispose(); + super.dispose(); + } + + void checkIfEditingDone() { + setState(() { + editingDone = titleController.text.isNotEmpty && + contentController.text.isNotEmpty && + image != null; + }); + } + + @override + Widget build(BuildContext context) { + var theme = Theme.of(context); + return Padding( + padding: widget.padding, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + widget.options.translations.title, + style: theme.textTheme.displaySmall, + ), + widget.options.textInputBuilder?.call( + titleController, + null, + '', + ) ?? + TextField( + controller: titleController, + ), + const SizedBox(height: 16), + Text( + widget.options.translations.content, + style: theme.textTheme.displaySmall, + ), + const SizedBox(height: 4), + Text( + widget.options.translations.contentDescription, + style: theme.textTheme.bodyMedium, + ), + // input field for the content + SizedBox( + height: 100, + child: TextField( + controller: contentController, + expands: true, + maxLines: null, + minLines: null, + ), + ), + const SizedBox( + height: 16, + ), + // input field for the content + Text( + widget.options.translations.uploadImage, + style: theme.textTheme.displaySmall, + ), + Text( + widget.options.translations.uploadImageDescription, + style: theme.textTheme.bodyMedium, + ), + // image picker field + const SizedBox( + height: 8, + ), + Stack( + children: [ + GestureDetector( + onTap: () async { + // open a dialog to choose between camera and gallery + var result = await showModalBottomSheet( + context: context, + builder: (context) => Container( + padding: const EdgeInsets.all(8.0), + color: Colors.black, + child: ImagePicker( + imagePickerConfig: widget.options.imagePickerConfig, + imagePickerTheme: widget.options.imagePickerTheme, + ), + ), + ); + if (result != null) { + setState(() { + image = result; + }); + } + checkIfEditingDone(); + }, + child: image != null + ? ClipRRect( + borderRadius: BorderRadius.circular(8.0), + child: Image.memory( + image!, + width: double.infinity, + height: 150.0, + fit: BoxFit.cover, + // give it a rounded border + ), + ) + : CustomPaint( + painter: DashedBorderPainter( + color: theme.textTheme.displayMedium?.color ?? + Colors.white, + dashLength: 4.0, + dashWidth: 1.5, + space: 4, + ), + child: const SizedBox( + width: double.infinity, + height: 150.0, + child: Icon( + Icons.image, + size: 32, + ), + ), + ), + ), + // if an image is selected, show a delete button + if (image != null) ...[ + Positioned( + top: 8, + right: 8, + child: GestureDetector( + onTap: () { + setState(() { + image = null; + }); + checkIfEditingDone(); + }, + child: Container( + decoration: BoxDecoration( + color: Colors.black.withOpacity(0.5), + borderRadius: BorderRadius.circular(8.0), + ), + child: const Icon( + Icons.delete, + color: Colors.white, + ), + ), + ), + ), + ], + ], + ), + + const SizedBox(height: 16), + + Text( + widget.options.translations.commentsTitle, + style: theme.textTheme.displaySmall, + ), + Text( + widget.options.translations.allowCommentsDescription, + style: theme.textTheme.bodyMedium, + ), + // radio buttons for yes or no + Switch( + value: allowComments, + onChanged: (newValue) { + setState(() { + allowComments = newValue; + }); + }, + ), + const Spacer(), + Align( + alignment: Alignment.bottomCenter, + child: (widget.options.buttonBuilder != null) + ? widget.options.buttonBuilder!( + context, + () {}, + widget.options.translations.checkPost, + enabled: editingDone, + ) + : ElevatedButton( + onPressed: editingDone ? () {} : null, + child: Text( + widget.options.translations.checkPost, + style: theme.textTheme.bodyMedium, + ), + ), + ), + ], + ), + ); + } } diff --git a/packages/flutter_timeline_view/lib/src/screens/timeline_post_screen.dart b/packages/flutter_timeline_view/lib/src/screens/timeline_post_screen.dart index d633ddd..6e1bcd6 100644 --- a/packages/flutter_timeline_view/lib/src/screens/timeline_post_screen.dart +++ b/packages/flutter_timeline_view/lib/src/screens/timeline_post_screen.dart @@ -1,3 +1,7 @@ +// SPDX-FileCopyrightText: 2023 Iconica +// +// SPDX-License-Identifier: BSD-3-Clause + import 'package:flutter/material.dart'; import 'package:flutter_timeline_interface/flutter_timeline_interface.dart'; diff --git a/packages/flutter_timeline_view/lib/src/screens/timeline_screen.dart b/packages/flutter_timeline_view/lib/src/screens/timeline_screen.dart index 4d8a566..434e53c 100644 --- a/packages/flutter_timeline_view/lib/src/screens/timeline_screen.dart +++ b/packages/flutter_timeline_view/lib/src/screens/timeline_screen.dart @@ -1,3 +1,7 @@ +// SPDX-FileCopyrightText: 2023 Iconica +// +// SPDX-License-Identifier: BSD-3-Clause + import 'package:flutter/material.dart'; import 'package:flutter_timeline_interface/flutter_timeline_interface.dart'; diff --git a/packages/flutter_timeline_view/lib/src/widgets/dotted_container.dart b/packages/flutter_timeline_view/lib/src/widgets/dotted_container.dart new file mode 100644 index 0000000..cde436b --- /dev/null +++ b/packages/flutter_timeline_view/lib/src/widgets/dotted_container.dart @@ -0,0 +1,65 @@ +// SPDX-FileCopyrightText: 2023 Iconica +// +// SPDX-License-Identifier: BSD-3-Clause + +import 'package:flutter/material.dart'; + +class DashedBorderPainter extends CustomPainter { + DashedBorderPainter({ + this.color = Colors.black, + this.dashWidth = 2.0, + this.dashLength = 6.0, + this.space = 3.0, + }); + final Color color; + final double dashWidth; + final double dashLength; + final double space; + + @override + void paint(Canvas canvas, Size size) { + var paint = Paint() + ..color = color + ..strokeWidth = dashWidth; + + var x = 0.0; + var y = 0.0; + + // Top border + while (x < size.width) { + canvas.drawLine(Offset(x, 0), Offset(x + dashLength, 0), paint); + x += dashLength + space; + } + + // Right border + while (y < size.height) { + canvas.drawLine( + Offset(size.width, y), + Offset(size.width, y + dashLength), + paint, + ); + y += dashLength + space; + } + + x = size.width; + // Bottom border + while (x > 0) { + canvas.drawLine( + Offset(x, size.height), + Offset(x - dashLength, size.height), + paint, + ); + x -= dashLength + space; + } + + y = size.height; + // Left border + while (y > 0) { + canvas.drawLine(Offset(0, y), Offset(0, y - dashLength), paint); + y -= dashLength + space; + } + } + + @override + bool shouldRepaint(CustomPainter oldDelegate) => false; +} diff --git a/packages/flutter_timeline_view/pubspec.yaml b/packages/flutter_timeline_view/pubspec.yaml index 470fe67..5a69928 100644 --- a/packages/flutter_timeline_view/pubspec.yaml +++ b/packages/flutter_timeline_view/pubspec.yaml @@ -14,11 +14,18 @@ environment: dependencies: flutter: sdk: flutter + intl: any + cached_network_image: ^3.2.2 + flutter_timeline_interface: git: url: https://github.com/Iconica-Development/flutter_timeline.git path: packages/flutter_timeline_interface ref: 0.0.1 + flutter_image_picker: + git: + url: https://github.com/Iconica-Development/flutter_image_picker + ref: 1.0.4 dev_dependencies: flutter_lints: ^2.0.0 From 55e4e50798c47fd83b9a2d165cceb10cb86ad334 Mon Sep 17 00:00:00 2001 From: Freek van de Ven Date: Sun, 19 Nov 2023 16:14:26 +0100 Subject: [PATCH 05/16] feat: update component CI --- .github/{workflows => }/dependabot.yml | 0 .github/workflows/melos-component-ci.yml | 12 ++++++++++++ melos.yaml | 16 ++++++++-------- 3 files changed, 20 insertions(+), 8 deletions(-) rename .github/{workflows => }/dependabot.yml (100%) create mode 100644 .github/workflows/melos-component-ci.yml diff --git a/.github/workflows/dependabot.yml b/.github/dependabot.yml similarity index 100% rename from .github/workflows/dependabot.yml rename to .github/dependabot.yml diff --git a/.github/workflows/melos-component-ci.yml b/.github/workflows/melos-component-ci.yml new file mode 100644 index 0000000..869bed9 --- /dev/null +++ b/.github/workflows/melos-component-ci.yml @@ -0,0 +1,12 @@ +name: Iconica Standard Melos CI Workflow +# Workflow Caller version: 1.0.0 + +on: + pull_request: + workflow_dispatch: + +jobs: + call-global-iconica-workflow: + uses: Iconica-Development/.github/.github/workflows/melos-ci.yml@master + secrets: inherit + permissions: write-all \ No newline at end of file diff --git a/melos.yaml b/melos.yaml index 74e2f94..7e2d1ee 100644 --- a/melos.yaml +++ b/melos.yaml @@ -13,31 +13,31 @@ command: scripts: lint:all: - run: melos run analyze && melos run format + run: dart run melos run analyze && dart run melos run format-check description: Run all static analysis checks. get: run: | - melos exec -c 1 -- "flutter pub get" - melos exec --scope="*example*" -c 1 -- "flutter pub get" + dart run melos exec -c 1 -- "flutter pub get" + dart run melos exec --scope="*example*" -c 1 -- "flutter pub get" upgrade: - run: melos exec -c 1 -- "flutter pub upgrade" + run: dart run melos exec -c 1 -- "flutter pub upgrade" create: # run create in the example folder of flutter_timeline_view - run: melos exec --scope="*example*" -c 1 -- "flutter create ." + run: dart run melos exec --scope="*example*" -c 1 -- "flutter create ." analyze: run: | - melos exec -c 1 -- \ + dart run melos exec -c 1 -- \ flutter analyze --fatal-infos description: Run `flutter analyze` for all packages. format: - run: melos exec dart format . + run: dart run melos exec dart format . description: Run `flutter format` for all packages. format-check: - run: melos exec dart format . --set-exit-if-changed + run: dart run melos exec dart format . --set-exit-if-changed description: Run `flutter format` checks for all packages. From c33d8cb8930e7c19885a78da6cbe1ae9d5aae063 Mon Sep 17 00:00:00 2001 From: Freek van de Ven Date: Sun, 19 Nov 2023 18:47:05 +0100 Subject: [PATCH 06/16] feat: add timelineoverview cards --- .../lib/flutter_timeline_interface.dart | 1 + .../lib/src/model/timeline_post.dart | 9 ++ .../lib/src/model/timeline_poster.dart | 34 +++++ .../lib/src/model/timeline_reaction.dart | 4 + .../lib/src/config/timeline_options.dart | 10 +- .../lib/src/screens/timeline_post_screen.dart | 1 - .../lib/src/screens/timeline_screen.dart | 27 +++- .../lib/src/widgets/timeline_post_widget.dart | 116 ++++++++++++++++++ 8 files changed, 199 insertions(+), 3 deletions(-) create mode 100644 packages/flutter_timeline_interface/lib/src/model/timeline_poster.dart create mode 100644 packages/flutter_timeline_view/lib/src/widgets/timeline_post_widget.dart diff --git a/packages/flutter_timeline_interface/lib/flutter_timeline_interface.dart b/packages/flutter_timeline_interface/lib/flutter_timeline_interface.dart index acac140..c52ef3a 100644 --- a/packages/flutter_timeline_interface/lib/flutter_timeline_interface.dart +++ b/packages/flutter_timeline_interface/lib/flutter_timeline_interface.dart @@ -5,4 +5,5 @@ library flutter_timeline_interface; export 'src/model/timeline_post.dart'; +export 'src/model/timeline_poster.dart'; export 'src/model/timeline_reaction.dart'; diff --git a/packages/flutter_timeline_interface/lib/src/model/timeline_post.dart b/packages/flutter_timeline_interface/lib/src/model/timeline_post.dart index cbad1fc..d8cfe13 100644 --- a/packages/flutter_timeline_interface/lib/src/model/timeline_post.dart +++ b/packages/flutter_timeline_interface/lib/src/model/timeline_post.dart @@ -1,4 +1,9 @@ +// SPDX-FileCopyrightText: 2023 Iconica +// +// SPDX-License-Identifier: BSD-3-Clause + import 'package:flutter/material.dart'; +import 'package:flutter_timeline_interface/src/model/timeline_poster.dart'; import 'package:flutter_timeline_interface/src/model/timeline_reaction.dart'; /// A post of the timeline. @@ -14,6 +19,7 @@ class TimelinePost { required this.reaction, required this.createdAt, required this.reactionEnabled, + this.creator, this.likedBy, this.reactions, this.imageUrl, @@ -25,6 +31,9 @@ class TimelinePost { /// The unique identifier of the creator of the post. final String creatorId; + /// The creator of the post. If null it isn't loaded yet. + final TimelinePosterUserModel? creator; + /// The title of the post. final String title; diff --git a/packages/flutter_timeline_interface/lib/src/model/timeline_poster.dart b/packages/flutter_timeline_interface/lib/src/model/timeline_poster.dart new file mode 100644 index 0000000..534810a --- /dev/null +++ b/packages/flutter_timeline_interface/lib/src/model/timeline_poster.dart @@ -0,0 +1,34 @@ +// SPDX-FileCopyrightText: 2023 Iconica +// +// SPDX-License-Identifier: BSD-3-Clause + +import 'package:flutter/material.dart'; + +@immutable +class TimelinePosterUserModel { + const TimelinePosterUserModel({ + required this.id, + this.firstName, + this.lastName, + this.imageUrl, + }); + + final String id; + final String? firstName; + final String? lastName; + final String? imageUrl; + + String? get fullName { + var fullName = ''; + + if (firstName != null && lastName != null) { + fullName += '$firstName $lastName'; + } else if (firstName != null) { + fullName += firstName!; + } else if (lastName != null) { + fullName += lastName!; + } + + return fullName == '' ? null : fullName; + } +} diff --git a/packages/flutter_timeline_interface/lib/src/model/timeline_reaction.dart b/packages/flutter_timeline_interface/lib/src/model/timeline_reaction.dart index 7294449..1df669c 100644 --- a/packages/flutter_timeline_interface/lib/src/model/timeline_reaction.dart +++ b/packages/flutter_timeline_interface/lib/src/model/timeline_reaction.dart @@ -1,3 +1,7 @@ +// SPDX-FileCopyrightText: 2023 Iconica +// +// SPDX-License-Identifier: BSD-3-Clause + import 'package:flutter/material.dart'; @immutable diff --git a/packages/flutter_timeline_view/lib/src/config/timeline_options.dart b/packages/flutter_timeline_view/lib/src/config/timeline_options.dart index 8cd1f6d..e57d4b3 100644 --- a/packages/flutter_timeline_view/lib/src/config/timeline_options.dart +++ b/packages/flutter_timeline_view/lib/src/config/timeline_options.dart @@ -3,6 +3,7 @@ // SPDX-License-Identifier: BSD-3-Clause import 'package:flutter/material.dart'; import 'package:flutter_image_picker/flutter_image_picker.dart'; +import 'package:flutter_timeline_interface/flutter_timeline_interface.dart'; import 'package:flutter_timeline_view/src/config/timeline_translations.dart'; import 'package:intl/intl.dart'; @@ -15,6 +16,7 @@ class TimelineOptions { this.dateformat, this.buttonBuilder, this.textInputBuilder, + this.userAvatarBuilder, }); /// The format to display the post time in @@ -26,6 +28,8 @@ class TimelineOptions { final TextInputBuilder? textInputBuilder; + final UserAvatarBuilder? userAvatarBuilder; + /// ImagePickerTheme can be used to change the UI of the /// Image Picker Widget to change the text/icons to your liking. final ImagePickerTheme imagePickerTheme; @@ -42,9 +46,13 @@ typedef ButtonBuilder = Widget Function( bool enabled, }); - typedef TextInputBuilder = Widget Function( TextEditingController controller, Widget? suffixIcon, String hintText, ); + +typedef UserAvatarBuilder = Widget Function( + TimelinePosterUserModel user, + double size, +); diff --git a/packages/flutter_timeline_view/lib/src/screens/timeline_post_screen.dart b/packages/flutter_timeline_view/lib/src/screens/timeline_post_screen.dart index 6e1bcd6..f2d75ec 100644 --- a/packages/flutter_timeline_view/lib/src/screens/timeline_post_screen.dart +++ b/packages/flutter_timeline_view/lib/src/screens/timeline_post_screen.dart @@ -11,7 +11,6 @@ class TimelinePostScreen extends StatelessWidget { super.key, }); - final TimelinePost post; @override diff --git a/packages/flutter_timeline_view/lib/src/screens/timeline_screen.dart b/packages/flutter_timeline_view/lib/src/screens/timeline_screen.dart index 434e53c..d0be49b 100644 --- a/packages/flutter_timeline_view/lib/src/screens/timeline_screen.dart +++ b/packages/flutter_timeline_view/lib/src/screens/timeline_screen.dart @@ -4,16 +4,22 @@ import 'package:flutter/material.dart'; import 'package:flutter_timeline_interface/flutter_timeline_interface.dart'; +import 'package:flutter_timeline_view/src/config/timeline_options.dart'; +import 'package:flutter_timeline_view/src/widgets/timeline_post_widget.dart'; class TimelineScreen extends StatefulWidget { const TimelineScreen({ + required this.options, required this.posts, + required this.onPostTap, this.controller, this.timelineCategoryFilter, this.timelinePostHeight = 100.0, super.key, }); + final TimelineOptions options; + final ScrollController? controller; final String? timelineCategoryFilter; @@ -22,6 +28,8 @@ class TimelineScreen extends StatefulWidget { final List posts; + final Function(TimelinePost) onPostTap; + @override State createState() => _TimelineScreenState(); } @@ -36,5 +44,22 @@ class _TimelineScreenState extends State { } @override - Widget build(BuildContext context) => const Placeholder(); + Widget build(BuildContext context) => SingleChildScrollView( + child: Column( + children: [ + for (var post in widget.posts) + if (widget.timelineCategoryFilter == null || + post.category == widget.timelineCategoryFilter) + Padding( + padding: const EdgeInsets.symmetric(vertical: 8.0), + child: TimelinePostWidget( + options: widget.options, + post: post, + height: widget.timelinePostHeight, + onTap: () => widget.onPostTap.call(post), + ), + ), + ], + ), + ); } diff --git a/packages/flutter_timeline_view/lib/src/widgets/timeline_post_widget.dart b/packages/flutter_timeline_view/lib/src/widgets/timeline_post_widget.dart new file mode 100644 index 0000000..9571519 --- /dev/null +++ b/packages/flutter_timeline_view/lib/src/widgets/timeline_post_widget.dart @@ -0,0 +1,116 @@ +import 'package:cached_network_image/cached_network_image.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_timeline_interface/flutter_timeline_interface.dart'; +import 'package:flutter_timeline_view/src/config/timeline_options.dart'; + +class TimelinePostWidget extends StatelessWidget { + const TimelinePostWidget({ + required this.options, + required this.post, + required this.height, + this.onTap, + super.key, + }); + + final TimelineOptions options; + + final TimelinePost post; + final double height; + final VoidCallback? onTap; + + @override + Widget build(BuildContext context) { + var theme = Theme.of(context); + return InkWell( + onTap: onTap, + child: SizedBox( + height: height, + width: double.infinity, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (post.creator != null) + Row( + children: [ + if (post.creator!.imageUrl != null) ...[ + options.userAvatarBuilder?.call( + post.creator!, + 40, + ) ?? + CircleAvatar( + radius: 20, + backgroundImage: CachedNetworkImageProvider( + post.creator!.imageUrl!, + ), + ), + ], + const SizedBox(width: 10), + if (post.creator!.fullName != null) ...[ + Text( + post.creator!.fullName!, + style: theme.textTheme.titleMedium, + ), + ], + + // three small dots at the end + const Spacer(), + const Icon(Icons.more_horiz), + ], + ), + const SizedBox(height: 8), + // image of the post + if (post.imageUrl != null) ...[ + Flexible( + child: CachedNetworkImage( + imageUrl: post.imageUrl!, + width: double.infinity, + fit: BoxFit.fitWidth, + ), + ), + ], + // post information + Row( + children: [ + // like icon + IconButton( + onPressed: () {}, + icon: const Icon(Icons.thumb_up_rounded), + ), + // comment icon + IconButton( + onPressed: () {}, + icon: const Icon( + Icons.chat_bubble_outline_rounded, + ), + ), + ], + ), + Text( + '${post.likes} ${options.translations.likesTitle}', + style: theme.textTheme.titleSmall, + ), + Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Text( + post.creator?.fullName ?? '', + style: theme.textTheme.titleSmall, + ), + const SizedBox(width: 8), + Text( + post.content, + style: theme.textTheme.bodyMedium, + overflow: TextOverflow.fade, + ), + ], + ), + Text( + options.translations.viewPost, + style: theme.textTheme.bodySmall, + ), + ], + ), + ), + ); + } +} From fb8ca56a876f1bd0dede7b435e933396e8e190b4 Mon Sep 17 00:00:00 2001 From: Freek van de Ven Date: Sun, 19 Nov 2023 23:11:53 +0100 Subject: [PATCH 07/16] feat: add timeline post detail screen --- .../lib/src/model/timeline_reaction.dart | 5 + .../lib/src/config/timeline_options.dart | 11 +- .../lib/src/config/timeline_theme.dart | 14 ++ .../lib/src/config/timeline_translations.dart | 2 + .../timeline_post_creation_screen.dart | 14 +- .../lib/src/screens/timeline_post_screen.dart | 171 +++++++++++++++++- .../lib/src/widgets/dotted_container.dart | 65 ------- .../lib/src/widgets/reaction_bottom.dart | 76 ++++++++ .../lib/src/widgets/timeline_post_widget.dart | 2 +- packages/flutter_timeline_view/pubspec.yaml | 3 +- 10 files changed, 285 insertions(+), 78 deletions(-) create mode 100644 packages/flutter_timeline_view/lib/src/config/timeline_theme.dart delete mode 100644 packages/flutter_timeline_view/lib/src/widgets/dotted_container.dart create mode 100644 packages/flutter_timeline_view/lib/src/widgets/reaction_bottom.dart diff --git a/packages/flutter_timeline_interface/lib/src/model/timeline_reaction.dart b/packages/flutter_timeline_interface/lib/src/model/timeline_reaction.dart index 1df669c..868e8d6 100644 --- a/packages/flutter_timeline_interface/lib/src/model/timeline_reaction.dart +++ b/packages/flutter_timeline_interface/lib/src/model/timeline_reaction.dart @@ -3,6 +3,7 @@ // SPDX-License-Identifier: BSD-3-Clause import 'package:flutter/material.dart'; +import 'package:flutter_timeline_interface/src/model/timeline_poster.dart'; @immutable class TimelinePostReaction { @@ -12,6 +13,7 @@ class TimelinePostReaction { required this.creatorId, required this.reaction, required this.createdAt, + this.creator, }); /// The unique identifier of the reaction. @@ -23,6 +25,9 @@ class TimelinePostReaction { /// The unique identifier of the creator of the reaction. final String creatorId; + /// The creator of the post. If null it isn't loaded yet. + final TimelinePosterUserModel? creator; + /// The reactiontext final String reaction; diff --git a/packages/flutter_timeline_view/lib/src/config/timeline_options.dart b/packages/flutter_timeline_view/lib/src/config/timeline_options.dart index e57d4b3..5649978 100644 --- a/packages/flutter_timeline_view/lib/src/config/timeline_options.dart +++ b/packages/flutter_timeline_view/lib/src/config/timeline_options.dart @@ -4,24 +4,33 @@ import 'package:flutter/material.dart'; import 'package:flutter_image_picker/flutter_image_picker.dart'; import 'package:flutter_timeline_interface/flutter_timeline_interface.dart'; +import 'package:flutter_timeline_view/src/config/timeline_theme.dart'; import 'package:flutter_timeline_view/src/config/timeline_translations.dart'; import 'package:intl/intl.dart'; @immutable class TimelineOptions { const TimelineOptions({ + this.theme = const TimelineTheme(), this.translations = const TimelineTranslations(), this.imagePickerConfig = const ImagePickerConfig(), this.imagePickerTheme = const ImagePickerTheme(), this.dateformat, + this.timeFormat, this.buttonBuilder, this.textInputBuilder, this.userAvatarBuilder, }); - /// The format to display the post time in + /// Theming options for the timeline + final TimelineTheme theme; + + /// The format to display the post date in final DateFormat? dateformat; + /// The format to display the post time in + final DateFormat? timeFormat; + final TimelineTranslations translations; final ButtonBuilder? buttonBuilder; diff --git a/packages/flutter_timeline_view/lib/src/config/timeline_theme.dart b/packages/flutter_timeline_view/lib/src/config/timeline_theme.dart new file mode 100644 index 0000000..57d8235 --- /dev/null +++ b/packages/flutter_timeline_view/lib/src/config/timeline_theme.dart @@ -0,0 +1,14 @@ +// SPDX-FileCopyrightText: 2023 Iconica +// +// SPDX-License-Identifier: BSD-3-Clause + +import 'package:flutter/material.dart'; + +@immutable +class TimelineTheme { + const TimelineTheme({ + this.iconColor, + }); + + final Color? iconColor; +} diff --git a/packages/flutter_timeline_view/lib/src/config/timeline_translations.dart b/packages/flutter_timeline_view/lib/src/config/timeline_translations.dart index 25c04c0..df33ff7 100644 --- a/packages/flutter_timeline_view/lib/src/config/timeline_translations.dart +++ b/packages/flutter_timeline_view/lib/src/config/timeline_translations.dart @@ -21,6 +21,7 @@ class TimelineTranslations { this.likesTitle = 'Likes', this.commentsTitle = 'Comments', this.writeComment = 'Write your comment here...', + this.postAt = 'at', }); final String title; @@ -31,6 +32,7 @@ class TimelineTranslations { final String allowComments; final String allowCommentsDescription; final String checkPost; + final String postAt; final String deletePost; final String viewPost; diff --git a/packages/flutter_timeline_view/lib/src/screens/timeline_post_creation_screen.dart b/packages/flutter_timeline_view/lib/src/screens/timeline_post_creation_screen.dart index dcd806a..b4a88ea 100644 --- a/packages/flutter_timeline_view/lib/src/screens/timeline_post_creation_screen.dart +++ b/packages/flutter_timeline_view/lib/src/screens/timeline_post_creation_screen.dart @@ -4,10 +4,10 @@ import 'dart:typed_data'; +import 'package:dotted_border/dotted_border.dart'; import 'package:flutter/material.dart'; import 'package:flutter_image_picker/flutter_image_picker.dart'; import 'package:flutter_timeline_view/src/config/timeline_options.dart'; -import 'package:flutter_timeline_view/src/widgets/dotted_container.dart'; class TimelinePostCreationScreen extends StatefulWidget { const TimelinePostCreationScreen({ @@ -147,14 +147,10 @@ class _TimelinePostCreationScreenState // give it a rounded border ), ) - : CustomPaint( - painter: DashedBorderPainter( - color: theme.textTheme.displayMedium?.color ?? - Colors.white, - dashLength: 4.0, - dashWidth: 1.5, - space: 4, - ), + : DottedBorder( + radius: const Radius.circular(8.0), + color: theme.textTheme.displayMedium?.color ?? + Colors.white, child: const SizedBox( width: double.infinity, height: 150.0, diff --git a/packages/flutter_timeline_view/lib/src/screens/timeline_post_screen.dart b/packages/flutter_timeline_view/lib/src/screens/timeline_post_screen.dart index f2d75ec..cf4a953 100644 --- a/packages/flutter_timeline_view/lib/src/screens/timeline_post_screen.dart +++ b/packages/flutter_timeline_view/lib/src/screens/timeline_post_screen.dart @@ -2,17 +2,186 @@ // // SPDX-License-Identifier: BSD-3-Clause +import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/material.dart'; import 'package:flutter_timeline_interface/flutter_timeline_interface.dart'; +import 'package:flutter_timeline_view/src/config/timeline_options.dart'; +import 'package:flutter_timeline_view/src/widgets/reaction_bottom.dart'; +import 'package:intl/intl.dart'; class TimelinePostScreen extends StatelessWidget { const TimelinePostScreen({ + required this.options, required this.post, + this.padding = const EdgeInsets.symmetric(vertical: 24, horizontal: 16), super.key, }); + final TimelineOptions options; + final TimelinePost post; + /// The padding around the screen + final EdgeInsets padding; + @override - Widget build(BuildContext context) => const Placeholder(); + Widget build(BuildContext context) { + var theme = Theme.of(context); + var dateFormat = options.dateformat ?? + DateFormat('dd/MM/yyyy', Localizations.localeOf(context).languageCode); + var timeFormat = options.timeFormat ?? DateFormat('HH:mm'); + return Stack( + children: [ + SingleChildScrollView( + child: Padding( + padding: padding, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (post.creator != null) + Row( + children: [ + if (post.creator!.imageUrl != null) ...[ + options.userAvatarBuilder?.call( + post.creator!, + 40, + ) ?? + CircleAvatar( + radius: 20, + backgroundImage: CachedNetworkImageProvider( + post.creator!.imageUrl!, + ), + ), + ], + const SizedBox(width: 10), + if (post.creator!.fullName != null) ...[ + Text( + post.creator!.fullName!, + style: theme.textTheme.titleMedium, + ), + ], + + // three small dots at the end + const Spacer(), + const Icon(Icons.more_horiz), + ], + ), + const SizedBox(height: 8), + // image of the post + if (post.imageUrl != null) ...[ + CachedNetworkImage( + imageUrl: post.imageUrl!, + width: double.infinity, + fit: BoxFit.fitHeight, + ), + ], + // post information + Row( + children: [ + // like icon + IconButton( + onPressed: () {}, + icon: const Icon(Icons.thumb_up_rounded), + ), + // comment icon + IconButton( + onPressed: () {}, + icon: const Icon( + Icons.chat_bubble_outline_rounded, + ), + ), + ], + ), + Text( + '${post.likes} ${options.translations.likesTitle}', + style: theme.textTheme.titleSmall, + ), + Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Text( + post.creator?.fullName ?? '', + style: theme.textTheme.titleSmall, + ), + const SizedBox(width: 8), + Text( + post.title, + style: theme.textTheme.bodyMedium, + overflow: TextOverflow.fade, + ), + ], + ), + Text( + post.content, + style: theme.textTheme.bodyMedium, + ), + const SizedBox(height: 4), + Text( + '${dateFormat.format(post.createdAt)} ' + '${options.translations.postAt} ' + '${timeFormat.format(post.createdAt)}', + style: theme.textTheme.bodySmall, + ), + const SizedBox(height: 12), + + Text( + options.translations.commentsTitle, + style: theme.textTheme.displaySmall, + ), + for (var reaction + in post.reactions ?? []) ...[ + const SizedBox(height: 8), + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (reaction.creator?.imageUrl != null && + reaction.creator!.imageUrl!.isNotEmpty) ...[ + options.userAvatarBuilder?.call( + reaction.creator!, + 25, + ) ?? + CircleAvatar( + radius: 20, + backgroundImage: CachedNetworkImageProvider( + reaction.creator!.imageUrl!, + ), + ), + ], + const SizedBox(width: 10), + if (reaction.creator?.fullName != null) ...[ + Text( + reaction.creator!.fullName!, + style: theme.textTheme.titleSmall, + ), + ], + const SizedBox(width: 10), + Expanded( + child: Text( + reaction.reaction, + style: theme.textTheme.bodyMedium, + // text should go to new line + softWrap: true, + ), + ), + ], + ), + const SizedBox(height: 100), + ], + ], + ), + ), + ), + Align( + alignment: Alignment.bottomCenter, + child: ReactionBottom( + messageInputBuilder: options.textInputBuilder!, + onPressSelectImage: () async {}, + onReactionSubmit: (reaction) async {}, + translations: options.translations, + iconColor: options.theme.iconColor, + ), + ), + ], + ); + } } diff --git a/packages/flutter_timeline_view/lib/src/widgets/dotted_container.dart b/packages/flutter_timeline_view/lib/src/widgets/dotted_container.dart deleted file mode 100644 index cde436b..0000000 --- a/packages/flutter_timeline_view/lib/src/widgets/dotted_container.dart +++ /dev/null @@ -1,65 +0,0 @@ -// SPDX-FileCopyrightText: 2023 Iconica -// -// SPDX-License-Identifier: BSD-3-Clause - -import 'package:flutter/material.dart'; - -class DashedBorderPainter extends CustomPainter { - DashedBorderPainter({ - this.color = Colors.black, - this.dashWidth = 2.0, - this.dashLength = 6.0, - this.space = 3.0, - }); - final Color color; - final double dashWidth; - final double dashLength; - final double space; - - @override - void paint(Canvas canvas, Size size) { - var paint = Paint() - ..color = color - ..strokeWidth = dashWidth; - - var x = 0.0; - var y = 0.0; - - // Top border - while (x < size.width) { - canvas.drawLine(Offset(x, 0), Offset(x + dashLength, 0), paint); - x += dashLength + space; - } - - // Right border - while (y < size.height) { - canvas.drawLine( - Offset(size.width, y), - Offset(size.width, y + dashLength), - paint, - ); - y += dashLength + space; - } - - x = size.width; - // Bottom border - while (x > 0) { - canvas.drawLine( - Offset(x, size.height), - Offset(x - dashLength, size.height), - paint, - ); - x -= dashLength + space; - } - - y = size.height; - // Left border - while (y > 0) { - canvas.drawLine(Offset(0, y), Offset(0, y - dashLength), paint); - y -= dashLength + space; - } - } - - @override - bool shouldRepaint(CustomPainter oldDelegate) => false; -} diff --git a/packages/flutter_timeline_view/lib/src/widgets/reaction_bottom.dart b/packages/flutter_timeline_view/lib/src/widgets/reaction_bottom.dart new file mode 100644 index 0000000..9895112 --- /dev/null +++ b/packages/flutter_timeline_view/lib/src/widgets/reaction_bottom.dart @@ -0,0 +1,76 @@ +// SPDX-FileCopyrightText: 2023 Iconica +// +// SPDX-License-Identifier: BSD-3-Clause + +import 'package:flutter/material.dart'; +import 'package:flutter_timeline_view/src/config/timeline_options.dart'; +import 'package:flutter_timeline_view/src/config/timeline_translations.dart'; + +class ReactionBottom extends StatefulWidget { + const ReactionBottom({ + required this.onReactionSubmit, + required this.messageInputBuilder, + required this.translations, + this.onPressSelectImage, + this.iconColor, + super.key, + }); + + final Future Function(String text) onReactionSubmit; + final TextInputBuilder messageInputBuilder; + final VoidCallback? onPressSelectImage; + final TimelineTranslations translations; + final Color? iconColor; + + @override + State createState() => _ReactionBottomState(); +} + +class _ReactionBottomState extends State { + final TextEditingController _textEditingController = TextEditingController(); + + @override + Widget build(BuildContext context) => Container( + color: Theme.of(context).colorScheme.background, + child: Container( + margin: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 8, + ), + height: 45, + child: widget.messageInputBuilder( + _textEditingController, + Padding( + padding: const EdgeInsets.only(right: 15.0), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + IconButton( + onPressed: widget.onPressSelectImage, + icon: Icon( + Icons.image, + color: widget.iconColor, + ), + ), + IconButton( + onPressed: () async { + var value = _textEditingController.text; + + if (value.isNotEmpty) { + await widget.onReactionSubmit(value); + _textEditingController.clear(); + } + }, + icon: Icon( + Icons.send, + color: widget.iconColor, + ), + ), + ], + ), + ), + widget.translations.writeComment, + ), + ), + ); +} diff --git a/packages/flutter_timeline_view/lib/src/widgets/timeline_post_widget.dart b/packages/flutter_timeline_view/lib/src/widgets/timeline_post_widget.dart index 9571519..277bec3 100644 --- a/packages/flutter_timeline_view/lib/src/widgets/timeline_post_widget.dart +++ b/packages/flutter_timeline_view/lib/src/widgets/timeline_post_widget.dart @@ -98,7 +98,7 @@ class TimelinePostWidget extends StatelessWidget { ), const SizedBox(width: 8), Text( - post.content, + post.title, style: theme.textTheme.bodyMedium, overflow: TextOverflow.fade, ), diff --git a/packages/flutter_timeline_view/pubspec.yaml b/packages/flutter_timeline_view/pubspec.yaml index 5a69928..956c698 100644 --- a/packages/flutter_timeline_view/pubspec.yaml +++ b/packages/flutter_timeline_view/pubspec.yaml @@ -16,7 +16,8 @@ dependencies: sdk: flutter intl: any cached_network_image: ^3.2.2 - + dotted_border: ^2.1.0 + flutter_timeline_interface: git: url: https://github.com/Iconica-Development/flutter_timeline.git From d24731412fa4032636daaed8ce132204a4ad3fd0 Mon Sep 17 00:00:00 2001 From: Freek van de Ven Date: Mon, 20 Nov 2023 07:52:47 +0100 Subject: [PATCH 08/16] feat: add methods in timeline service interface --- packages/flutter_timeline_firebase/pubspec.yaml | 5 +++++ .../lib/src/model/timeline_reaction.dart | 10 +++++++--- .../lib/src/services/timeline_service.dart | 17 +++++++++++++++++ .../lib/src/services/user_service.dart | 5 +++++ .../lib/src/screens/timeline_post_screen.dart | 3 ++- 5 files changed, 36 insertions(+), 4 deletions(-) create mode 100644 packages/flutter_timeline_interface/lib/src/services/timeline_service.dart create mode 100644 packages/flutter_timeline_interface/lib/src/services/user_service.dart diff --git a/packages/flutter_timeline_firebase/pubspec.yaml b/packages/flutter_timeline_firebase/pubspec.yaml index 5bca45a..e352092 100644 --- a/packages/flutter_timeline_firebase/pubspec.yaml +++ b/packages/flutter_timeline_firebase/pubspec.yaml @@ -14,6 +14,11 @@ environment: dependencies: flutter: sdk: flutter + cloud_firestore: ^4.13.1 + firebase_core: ^2.22.0 + firebase_storage: ^11.5.1 + uuid: ^4.2.1 + flutter_timeline_interface: git: url: https://github.com/Iconica-Development/flutter_timeline.git diff --git a/packages/flutter_timeline_interface/lib/src/model/timeline_reaction.dart b/packages/flutter_timeline_interface/lib/src/model/timeline_reaction.dart index 868e8d6..a97951e 100644 --- a/packages/flutter_timeline_interface/lib/src/model/timeline_reaction.dart +++ b/packages/flutter_timeline_interface/lib/src/model/timeline_reaction.dart @@ -11,8 +11,9 @@ class TimelinePostReaction { required this.id, required this.postId, required this.creatorId, - required this.reaction, required this.createdAt, + this.reaction, + this.imageUrl, this.creator, }); @@ -28,8 +29,11 @@ class TimelinePostReaction { /// The creator of the post. If null it isn't loaded yet. final TimelinePosterUserModel? creator; - /// The reactiontext - final String reaction; + /// The reaction text if the creator sent one + final String? reaction; + + /// The url of the image if the creator sent one + final String? imageUrl; /// Reaction creation date. final DateTime createdAt; diff --git a/packages/flutter_timeline_interface/lib/src/services/timeline_service.dart b/packages/flutter_timeline_interface/lib/src/services/timeline_service.dart new file mode 100644 index 0000000..f8cfdca --- /dev/null +++ b/packages/flutter_timeline_interface/lib/src/services/timeline_service.dart @@ -0,0 +1,17 @@ +import 'dart:typed_data'; + +import 'package:flutter_timeline_interface/src/model/timeline_post.dart'; +import 'package:flutter_timeline_interface/src/model/timeline_reaction.dart'; + +abstract class TimelineService { + Future deletePost(TimelinePost post); + Future createPost(TimelinePost post); + Future> fetchPosts(String? category); + Future fetchPostDetails(TimelinePost post); + Future reactToPost( + TimelinePost post, + TimelinePostReaction reaction, { + Uint8List image, + }); + Future likePost(TimelinePost post); +} diff --git a/packages/flutter_timeline_interface/lib/src/services/user_service.dart b/packages/flutter_timeline_interface/lib/src/services/user_service.dart new file mode 100644 index 0000000..d2cd20b --- /dev/null +++ b/packages/flutter_timeline_interface/lib/src/services/user_service.dart @@ -0,0 +1,5 @@ +import 'package:flutter_timeline_interface/src/model/timeline_poster.dart'; + +mixin TimelineUserService { + Future getUser(String userId); +} diff --git a/packages/flutter_timeline_view/lib/src/screens/timeline_post_screen.dart b/packages/flutter_timeline_view/lib/src/screens/timeline_post_screen.dart index cf4a953..8c5effe 100644 --- a/packages/flutter_timeline_view/lib/src/screens/timeline_post_screen.dart +++ b/packages/flutter_timeline_view/lib/src/screens/timeline_post_screen.dart @@ -155,9 +155,10 @@ class TimelinePostScreen extends StatelessWidget { ), ], const SizedBox(width: 10), + // TODO(anyone): show image if the user send one Expanded( child: Text( - reaction.reaction, + reaction.reaction ?? '', style: theme.textTheme.bodyMedium, // text should go to new line softWrap: true, From 4113e9fea27e46672962062da976673195a4a9f9 Mon Sep 17 00:00:00 2001 From: Freek van de Ven Date: Mon, 20 Nov 2023 08:20:37 +0100 Subject: [PATCH 09/16] feat: add firebase implementation of timeline service --- .../lib/config/firebase_timeline_options.dart | 18 +++ .../lib/models/firebase_user_document.dart | 36 +++++ .../service/firebase_timeline_service.dart | 138 ++++++++++++++++++ .../lib/service/firebase_user_service.dart | 55 +++++++ .../lib/flutter_timeline_interface.dart | 3 + .../lib/src/model/timeline_post.dart | 81 ++++++++++ .../lib/src/model/timeline_poster.dart | 4 +- .../lib/src/model/timeline_reaction.dart | 23 +++ .../lib/src/services/timeline_service.dart | 7 +- .../lib/src/services/user_service.dart | 4 + 10 files changed, 366 insertions(+), 3 deletions(-) create mode 100644 packages/flutter_timeline_firebase/lib/config/firebase_timeline_options.dart create mode 100644 packages/flutter_timeline_firebase/lib/models/firebase_user_document.dart create mode 100644 packages/flutter_timeline_firebase/lib/service/firebase_timeline_service.dart create mode 100644 packages/flutter_timeline_firebase/lib/service/firebase_user_service.dart diff --git a/packages/flutter_timeline_firebase/lib/config/firebase_timeline_options.dart b/packages/flutter_timeline_firebase/lib/config/firebase_timeline_options.dart new file mode 100644 index 0000000..6acd32a --- /dev/null +++ b/packages/flutter_timeline_firebase/lib/config/firebase_timeline_options.dart @@ -0,0 +1,18 @@ +// SPDX-FileCopyrightText: 2023 Iconica +// +// SPDX-License-Identifier: BSD-3-Clause + +import 'package:flutter/material.dart'; + +@immutable +class FirebaseTimelineOptions { + const FirebaseTimelineOptions({ + this.usersCollectionName = 'users', + this.timelineCollectionName = 'timeline', + this.allTimelineCategories = const [], + }); + + final String usersCollectionName; + final String timelineCollectionName; + final List allTimelineCategories; +} diff --git a/packages/flutter_timeline_firebase/lib/models/firebase_user_document.dart b/packages/flutter_timeline_firebase/lib/models/firebase_user_document.dart new file mode 100644 index 0000000..19fe9c9 --- /dev/null +++ b/packages/flutter_timeline_firebase/lib/models/firebase_user_document.dart @@ -0,0 +1,36 @@ +// SPDX-FileCopyrightText: 2023 Iconica +// +// SPDX-License-Identifier: BSD-3-Clause + +import 'package:flutter/material.dart'; + +@immutable +class FirebaseUserDocument { + const FirebaseUserDocument({ + this.firstName, + this.lastName, + this.imageUrl, + this.userId, + }); + + FirebaseUserDocument.fromJson( + Map json, + String userId, + ) : this( + userId: userId, + firstName: json['first_name'] as String?, + lastName: json['last_name'] as String?, + imageUrl: json['image_url'] as String?, + ); + + final String? firstName; + final String? lastName; + final String? imageUrl; + final String? userId; + + Map toJson() => { + 'first_name': firstName, + 'last_name': lastName, + 'image_url': imageUrl, + }; +} diff --git a/packages/flutter_timeline_firebase/lib/service/firebase_timeline_service.dart b/packages/flutter_timeline_firebase/lib/service/firebase_timeline_service.dart new file mode 100644 index 0000000..c59727d --- /dev/null +++ b/packages/flutter_timeline_firebase/lib/service/firebase_timeline_service.dart @@ -0,0 +1,138 @@ +// SPDX-FileCopyrightText: 2023 Iconica +// +// SPDX-License-Identifier: BSD-3-Clause + +import 'dart:typed_data'; + +import 'package:cloud_firestore/cloud_firestore.dart'; +import 'package:firebase_core/firebase_core.dart'; +import 'package:firebase_storage/firebase_storage.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_timeline_firebase/config/firebase_timeline_options.dart'; +import 'package:flutter_timeline_interface/flutter_timeline_interface.dart'; + +class FirebaseTimelineService implements TimelineService { + FirebaseTimelineService({ + required TimelineUserService userService, + FirebaseApp? app, + options = const FirebaseTimelineOptions(), + }) { + var appInstance = app ?? Firebase.app(); + _db = FirebaseFirestore.instanceFor(app: appInstance); + _storage = FirebaseStorage.instanceFor(app: appInstance); + _userService = userService; + _options = options; + } + + late FirebaseFirestore _db; + late FirebaseStorage _storage; + late TimelineUserService _userService; + late FirebaseTimelineOptions _options; + + List _posts = []; + + @override + Future createPost(TimelinePost post) async { + var imageRef = _storage.ref().child('timeline/${post.id}'); + var result = await imageRef.putData(post.image!); + var imageUrl = await result.ref.getDownloadURL(); + var updatedPost = post.copyWith(imageUrl: imageUrl); + var postRef = _db.collection(_options.timelineCollectionName).doc(post.id); + _posts.add(updatedPost); + return postRef.set(updatedPost.toJson()); + } + + @override + Future deletePost(TimelinePost post) async { + var postRef = _db.collection(_options.timelineCollectionName).doc(post.id); + return postRef.delete(); + } + + @override + Future fetchPostDetails(TimelinePost post) async { + debugPrint('fetchPostDetails'); + return post; + } + + @override + Future> fetchPosts(String? category) async { + var snapshot = await _db + .collection(_options.timelineCollectionName) + .where('category', isEqualTo: category) + .get(); + + var posts = []; + for (var doc in snapshot.docs) { + var data = doc.data(); + var user = await _userService.getUser(data['user_id']); + var post = TimelinePost.fromJson(doc.id, data).copyWith(creator: user); + posts.add(post); + } + _posts = posts; + return posts; + } + + @override + Future likePost(String userId, TimelinePost post) { + // update the post with the new like + _posts = _posts + .map( + (p) => (p.id == post.id) + ? p.copyWith( + likes: p.likes + 1, + likedBy: p.likedBy?..add(userId), + ) + : p, + ) + .toList(); + var postRef = _db.collection(_options.timelineCollectionName).doc(post.id); + return postRef.update({ + 'likes': FieldValue.increment(1), + 'liked_by': FieldValue.arrayUnion([userId]), + }); + } + + @override + Future unlikePost(String userId, TimelinePost post) { + // update the post with the new like + _posts = _posts + .map( + (p) => (p.id == post.id) + ? p.copyWith( + likes: p.likes - 1, + likedBy: p.likedBy?..remove(userId), + ) + : p, + ) + .toList(); + var postRef = _db.collection(_options.timelineCollectionName).doc(post.id); + return postRef.update({ + 'likes': FieldValue.increment(-1), + 'liked_by': FieldValue.arrayRemove([userId]), + }); + } + + @override + Future reactToPost( + TimelinePost post, + TimelinePostReaction reaction, { + Uint8List? image, + }) { + // update the post with the new reaction + _posts = _posts + .map( + (p) => (p.id == post.id) + ? p.copyWith( + reaction: p.reaction + 1, + reactions: p.reactions?..add(reaction), + ) + : p, + ) + .toList(); + var postRef = _db.collection(_options.timelineCollectionName).doc(post.id); + return postRef.update({ + 'reaction': FieldValue.increment(1), + 'reactions': FieldValue.arrayUnion([reaction.toJson()]), + }); + } +} diff --git a/packages/flutter_timeline_firebase/lib/service/firebase_user_service.dart b/packages/flutter_timeline_firebase/lib/service/firebase_user_service.dart new file mode 100644 index 0000000..cb09c62 --- /dev/null +++ b/packages/flutter_timeline_firebase/lib/service/firebase_user_service.dart @@ -0,0 +1,55 @@ +// SPDX-FileCopyrightText: 2023 Iconica +// +// SPDX-License-Identifier: BSD-3-Clause + +import 'package:cloud_firestore/cloud_firestore.dart'; +import 'package:firebase_core/firebase_core.dart'; +import 'package:flutter_timeline_firebase/config/firebase_timeline_options.dart'; +import 'package:flutter_timeline_firebase/models/firebase_user_document.dart'; +import 'package:flutter_timeline_interface/flutter_timeline_interface.dart'; + +class FirebaseUserService implements TimelineUserService { + FirebaseUserService({ + FirebaseApp? app, + options = const FirebaseTimelineOptions(), + }) { + var appInstance = app ?? Firebase.app(); + _db = FirebaseFirestore.instanceFor(app: appInstance); + _options = options; + } + + late FirebaseFirestore _db; + late FirebaseTimelineOptions _options; + + final Map _users = {}; + + CollectionReference get _userCollection => _db + .collection(_options.usersCollectionName) + .withConverter( + fromFirestore: (snapshot, _) => FirebaseUserDocument.fromJson( + snapshot.data()!, + snapshot.id, + ), + toFirestore: (user, _) => user.toJson(), + ); + @override + Future getUser(String userId) async { + if (_users.containsKey(userId)) { + return _users[userId]!; + } + var data = (await _userCollection.doc(userId).get()).data(); + + var user = data == null + ? TimelinePosterUserModel(userId: userId) + : TimelinePosterUserModel( + userId: userId, + firstName: data.firstName, + lastName: data.lastName, + imageUrl: data.imageUrl, + ); + + _users[userId] = user; + + return user; + } +} diff --git a/packages/flutter_timeline_interface/lib/flutter_timeline_interface.dart b/packages/flutter_timeline_interface/lib/flutter_timeline_interface.dart index c52ef3a..6004105 100644 --- a/packages/flutter_timeline_interface/lib/flutter_timeline_interface.dart +++ b/packages/flutter_timeline_interface/lib/flutter_timeline_interface.dart @@ -7,3 +7,6 @@ library flutter_timeline_interface; export 'src/model/timeline_post.dart'; export 'src/model/timeline_poster.dart'; export 'src/model/timeline_reaction.dart'; + +export 'src/services/timeline_service.dart'; +export 'src/services/user_service.dart'; diff --git a/packages/flutter_timeline_interface/lib/src/model/timeline_post.dart b/packages/flutter_timeline_interface/lib/src/model/timeline_post.dart index d8cfe13..8fe1eb9 100644 --- a/packages/flutter_timeline_interface/lib/src/model/timeline_post.dart +++ b/packages/flutter_timeline_interface/lib/src/model/timeline_post.dart @@ -2,6 +2,8 @@ // // SPDX-License-Identifier: BSD-3-Clause +import 'dart:typed_data'; + import 'package:flutter/material.dart'; import 'package:flutter_timeline_interface/src/model/timeline_poster.dart'; import 'package:flutter_timeline_interface/src/model/timeline_reaction.dart'; @@ -23,8 +25,37 @@ class TimelinePost { this.likedBy, this.reactions, this.imageUrl, + this.image, }); + factory TimelinePost.fromJson(String id, Map json) => + TimelinePost( + id: id, + creatorId: json['creator_id'] as String, + title: json['title'] as String, + category: json['category'] as String, + imageUrl: json['image_url'] as String?, + content: json['content'] as String, + likes: json['likes'] as int, + likedBy: (json['liked_by'] as List?)?.cast(), + reaction: json['reaction'] as int, + reactions: (json['reactions'] as Map?) + ?.map( + (key, value) => MapEntry( + key, + TimelinePostReaction.fromJson( + key, + id, + value as Map, + ), + ), + ) + .values + .toList(), + createdAt: DateTime.parse(json['created_at'] as String), + reactionEnabled: json['reaction_enabled'] as bool, + ); + /// The unique identifier of the post. final String id; @@ -43,6 +74,9 @@ class TimelinePost { /// The url of the image of the post. final String? imageUrl; + /// The image of the post used for uploading. + final Uint8List? image; + /// The content of the post. final String content; @@ -63,4 +97,51 @@ class TimelinePost { /// If reacting is enabled on the post. final bool reactionEnabled; + + TimelinePost copyWith({ + String? id, + String? creatorId, + TimelinePosterUserModel? creator, + String? title, + String? category, + String? imageUrl, + Uint8List? image, + String? content, + int? likes, + List? likedBy, + int? reaction, + List? reactions, + DateTime? createdAt, + bool? reactionEnabled, + }) => + TimelinePost( + id: id ?? this.id, + creatorId: creatorId ?? this.creatorId, + creator: creator ?? this.creator, + title: title ?? this.title, + category: category ?? this.category, + imageUrl: imageUrl ?? this.imageUrl, + image: image ?? this.image, + content: content ?? this.content, + likes: likes ?? this.likes, + likedBy: likedBy ?? this.likedBy, + reaction: reaction ?? this.reaction, + reactions: reactions ?? this.reactions, + createdAt: createdAt ?? this.createdAt, + reactionEnabled: reactionEnabled ?? this.reactionEnabled, + ); + + Map toJson() => { + 'creator_id': creatorId, + 'title': title, + 'category': category, + 'image_url': imageUrl, + 'content': content, + 'likes': likes, + 'liked_by': likedBy, + 'reaction': reaction, + 'reactions': reactions?.map((e) => e.toJson()).toList(), + 'created_at': createdAt.toIso8601String(), + 'reaction_enabled': reactionEnabled, + }; } diff --git a/packages/flutter_timeline_interface/lib/src/model/timeline_poster.dart b/packages/flutter_timeline_interface/lib/src/model/timeline_poster.dart index 534810a..c652508 100644 --- a/packages/flutter_timeline_interface/lib/src/model/timeline_poster.dart +++ b/packages/flutter_timeline_interface/lib/src/model/timeline_poster.dart @@ -7,13 +7,13 @@ import 'package:flutter/material.dart'; @immutable class TimelinePosterUserModel { const TimelinePosterUserModel({ - required this.id, + required this.userId, this.firstName, this.lastName, this.imageUrl, }); - final String id; + final String userId; final String? firstName; final String? lastName; final String? imageUrl; diff --git a/packages/flutter_timeline_interface/lib/src/model/timeline_reaction.dart b/packages/flutter_timeline_interface/lib/src/model/timeline_reaction.dart index a97951e..76b22ca 100644 --- a/packages/flutter_timeline_interface/lib/src/model/timeline_reaction.dart +++ b/packages/flutter_timeline_interface/lib/src/model/timeline_reaction.dart @@ -17,6 +17,20 @@ class TimelinePostReaction { this.creator, }); + factory TimelinePostReaction.fromJson( + String id, + String postId, + Map json, + ) => + TimelinePostReaction( + id: id, + postId: postId, + creatorId: json['creator_id'] as String, + reaction: json['reaction'] as String?, + imageUrl: json['image_url'] as String?, + createdAt: DateTime.parse(json['created_at'] as String), + ); + /// The unique identifier of the reaction. final String id; @@ -37,4 +51,13 @@ class TimelinePostReaction { /// Reaction creation date. final DateTime createdAt; + + Map toJson() => { + id: { + 'creator_id': creatorId, + 'reaction': reaction, + 'image_url': imageUrl, + 'created_at': createdAt.toIso8601String(), + }, + }; } diff --git a/packages/flutter_timeline_interface/lib/src/services/timeline_service.dart b/packages/flutter_timeline_interface/lib/src/services/timeline_service.dart index f8cfdca..87c459e 100644 --- a/packages/flutter_timeline_interface/lib/src/services/timeline_service.dart +++ b/packages/flutter_timeline_interface/lib/src/services/timeline_service.dart @@ -1,3 +1,7 @@ +// SPDX-FileCopyrightText: 2023 Iconica +// +// SPDX-License-Identifier: BSD-3-Clause + import 'dart:typed_data'; import 'package:flutter_timeline_interface/src/model/timeline_post.dart'; @@ -13,5 +17,6 @@ abstract class TimelineService { TimelinePostReaction reaction, { Uint8List image, }); - Future likePost(TimelinePost post); + Future likePost(String userId, TimelinePost post); + Future unlikePost(String userId, TimelinePost post); } diff --git a/packages/flutter_timeline_interface/lib/src/services/user_service.dart b/packages/flutter_timeline_interface/lib/src/services/user_service.dart index d2cd20b..0fbf1d4 100644 --- a/packages/flutter_timeline_interface/lib/src/services/user_service.dart +++ b/packages/flutter_timeline_interface/lib/src/services/user_service.dart @@ -1,3 +1,7 @@ +// SPDX-FileCopyrightText: 2023 Iconica +// +// SPDX-License-Identifier: BSD-3-Clause + import 'package:flutter_timeline_interface/src/model/timeline_poster.dart'; mixin TimelineUserService { From e8822d92a39beab371dd6a9942af58727eb79a64 Mon Sep 17 00:00:00 2001 From: Freek van de Ven Date: Mon, 20 Nov 2023 11:54:00 +0100 Subject: [PATCH 10/16] feat: combine UI screens with service layer --- .../lib/flutter_timeline_firebase.dart | 4 ++ .../config/firebase_timeline_options.dart | 0 .../models/firebase_user_document.dart | 0 .../service/firebase_timeline_service.dart | 33 ++++++++---- .../service/firebase_user_service.dart | 4 +- .../lib/src/services/timeline_service.dart | 3 +- .../timeline_post_creation_screen.dart | 36 ++++++++++++- .../lib/src/screens/timeline_screen.dart | 50 ++++++++++++------- 8 files changed, 97 insertions(+), 33 deletions(-) rename packages/flutter_timeline_firebase/lib/{ => src}/config/firebase_timeline_options.dart (100%) rename packages/flutter_timeline_firebase/lib/{ => src}/models/firebase_user_document.dart (100%) rename packages/flutter_timeline_firebase/lib/{ => src}/service/firebase_timeline_service.dart (78%) rename packages/flutter_timeline_firebase/lib/{ => src}/service/firebase_user_service.dart (90%) diff --git a/packages/flutter_timeline_firebase/lib/flutter_timeline_firebase.dart b/packages/flutter_timeline_firebase/lib/flutter_timeline_firebase.dart index 34125a0..9ad1f86 100644 --- a/packages/flutter_timeline_firebase/lib/flutter_timeline_firebase.dart +++ b/packages/flutter_timeline_firebase/lib/flutter_timeline_firebase.dart @@ -4,3 +4,7 @@ /// library flutter_timeline_firebase; + +export 'src/config/firebase_timeline_options.dart'; +export 'src/service/firebase_timeline_service.dart'; +export 'src/service/firebase_user_service.dart'; diff --git a/packages/flutter_timeline_firebase/lib/config/firebase_timeline_options.dart b/packages/flutter_timeline_firebase/lib/src/config/firebase_timeline_options.dart similarity index 100% rename from packages/flutter_timeline_firebase/lib/config/firebase_timeline_options.dart rename to packages/flutter_timeline_firebase/lib/src/config/firebase_timeline_options.dart diff --git a/packages/flutter_timeline_firebase/lib/models/firebase_user_document.dart b/packages/flutter_timeline_firebase/lib/src/models/firebase_user_document.dart similarity index 100% rename from packages/flutter_timeline_firebase/lib/models/firebase_user_document.dart rename to packages/flutter_timeline_firebase/lib/src/models/firebase_user_document.dart diff --git a/packages/flutter_timeline_firebase/lib/service/firebase_timeline_service.dart b/packages/flutter_timeline_firebase/lib/src/service/firebase_timeline_service.dart similarity index 78% rename from packages/flutter_timeline_firebase/lib/service/firebase_timeline_service.dart rename to packages/flutter_timeline_firebase/lib/src/service/firebase_timeline_service.dart index c59727d..3048832 100644 --- a/packages/flutter_timeline_firebase/lib/service/firebase_timeline_service.dart +++ b/packages/flutter_timeline_firebase/lib/src/service/firebase_timeline_service.dart @@ -8,8 +8,9 @@ import 'package:cloud_firestore/cloud_firestore.dart'; import 'package:firebase_core/firebase_core.dart'; import 'package:firebase_storage/firebase_storage.dart'; import 'package:flutter/material.dart'; -import 'package:flutter_timeline_firebase/config/firebase_timeline_options.dart'; +import 'package:flutter_timeline_firebase/src/config/firebase_timeline_options.dart'; import 'package:flutter_timeline_interface/flutter_timeline_interface.dart'; +import 'package:uuid/uuid.dart'; class FirebaseTimelineService implements TimelineService { FirebaseTimelineService({ @@ -32,14 +33,17 @@ class FirebaseTimelineService implements TimelineService { List _posts = []; @override - Future createPost(TimelinePost post) async { - var imageRef = _storage.ref().child('timeline/${post.id}'); + Future createPost(TimelinePost post) async { + var postId = const Uuid().v4(); + var imageRef = _storage.ref().child('timeline/$postId'); var result = await imageRef.putData(post.image!); var imageUrl = await result.ref.getDownloadURL(); - var updatedPost = post.copyWith(imageUrl: imageUrl); - var postRef = _db.collection(_options.timelineCollectionName).doc(post.id); + var updatedPost = post.copyWith(imageUrl: imageUrl, id: postId); + var postRef = + _db.collection(_options.timelineCollectionName).doc(updatedPost.id); _posts.add(updatedPost); - return postRef.set(updatedPost.toJson()); + await postRef.set(updatedPost.toJson()); + return updatedPost; } @override @@ -56,15 +60,17 @@ class FirebaseTimelineService implements TimelineService { @override Future> fetchPosts(String? category) async { - var snapshot = await _db - .collection(_options.timelineCollectionName) - .where('category', isEqualTo: category) - .get(); + var snapshot = (category != null) + ? await _db + .collection(_options.timelineCollectionName) + .where('category', isEqualTo: category) + .get() + : await _db.collection(_options.timelineCollectionName).get(); var posts = []; for (var doc in snapshot.docs) { var data = doc.data(); - var user = await _userService.getUser(data['user_id']); + var user = await _userService.getUser(data['creator_id']); var post = TimelinePost.fromJson(doc.id, data).copyWith(creator: user); posts.add(post); } @@ -72,6 +78,11 @@ class FirebaseTimelineService implements TimelineService { return posts; } + @override + List getPosts(String? category) => _posts + .where((element) => category == null || element.category == category) + .toList(); + @override Future likePost(String userId, TimelinePost post) { // update the post with the new like diff --git a/packages/flutter_timeline_firebase/lib/service/firebase_user_service.dart b/packages/flutter_timeline_firebase/lib/src/service/firebase_user_service.dart similarity index 90% rename from packages/flutter_timeline_firebase/lib/service/firebase_user_service.dart rename to packages/flutter_timeline_firebase/lib/src/service/firebase_user_service.dart index cb09c62..fb1da17 100644 --- a/packages/flutter_timeline_firebase/lib/service/firebase_user_service.dart +++ b/packages/flutter_timeline_firebase/lib/src/service/firebase_user_service.dart @@ -4,8 +4,8 @@ import 'package:cloud_firestore/cloud_firestore.dart'; import 'package:firebase_core/firebase_core.dart'; -import 'package:flutter_timeline_firebase/config/firebase_timeline_options.dart'; -import 'package:flutter_timeline_firebase/models/firebase_user_document.dart'; +import 'package:flutter_timeline_firebase/src/config/firebase_timeline_options.dart'; +import 'package:flutter_timeline_firebase/src/models/firebase_user_document.dart'; import 'package:flutter_timeline_interface/flutter_timeline_interface.dart'; class FirebaseUserService implements TimelineUserService { diff --git a/packages/flutter_timeline_interface/lib/src/services/timeline_service.dart b/packages/flutter_timeline_interface/lib/src/services/timeline_service.dart index 87c459e..d00c911 100644 --- a/packages/flutter_timeline_interface/lib/src/services/timeline_service.dart +++ b/packages/flutter_timeline_interface/lib/src/services/timeline_service.dart @@ -9,8 +9,9 @@ import 'package:flutter_timeline_interface/src/model/timeline_reaction.dart'; abstract class TimelineService { Future deletePost(TimelinePost post); - Future createPost(TimelinePost post); + Future createPost(TimelinePost post); Future> fetchPosts(String? category); + List getPosts(String? category); Future fetchPostDetails(TimelinePost post); Future reactToPost( TimelinePost post, diff --git a/packages/flutter_timeline_view/lib/src/screens/timeline_post_creation_screen.dart b/packages/flutter_timeline_view/lib/src/screens/timeline_post_creation_screen.dart index b4a88ea..44a2c71 100644 --- a/packages/flutter_timeline_view/lib/src/screens/timeline_post_creation_screen.dart +++ b/packages/flutter_timeline_view/lib/src/screens/timeline_post_creation_screen.dart @@ -7,15 +7,30 @@ import 'dart:typed_data'; import 'package:dotted_border/dotted_border.dart'; import 'package:flutter/material.dart'; import 'package:flutter_image_picker/flutter_image_picker.dart'; +import 'package:flutter_timeline_interface/flutter_timeline_interface.dart'; import 'package:flutter_timeline_view/src/config/timeline_options.dart'; class TimelinePostCreationScreen extends StatefulWidget { const TimelinePostCreationScreen({ + required this.userId, + required this.postCategory, + required this.onPostCreated, + required this.service, required this.options, this.padding = const EdgeInsets.symmetric(vertical: 24, horizontal: 16), super.key, }); + final String userId; + + final String postCategory; + + /// called when the post is created + final Function(TimelinePost) onPostCreated; + + /// The service to use for creating the post + final TimelineService service; + /// The options for the timeline final TimelineOptions options; @@ -59,6 +74,23 @@ class _TimelinePostCreationScreenState @override Widget build(BuildContext context) { + Future onPostCreated() async { + var post = TimelinePost( + id: '', + creatorId: widget.userId, + title: titleController.text, + category: widget.postCategory, + content: contentController.text, + likes: 0, + reaction: 0, + createdAt: DateTime.now(), + reactionEnabled: allowComments, + image: image, + ); + var newPost = await widget.service.createPost(post); + widget.onPostCreated.call(newPost); + } + var theme = Theme.of(context); return Padding( padding: widget.padding, @@ -214,12 +246,12 @@ class _TimelinePostCreationScreenState child: (widget.options.buttonBuilder != null) ? widget.options.buttonBuilder!( context, - () {}, + onPostCreated, widget.options.translations.checkPost, enabled: editingDone, ) : ElevatedButton( - onPressed: editingDone ? () {} : null, + onPressed: editingDone ? onPostCreated : null, child: Text( widget.options.translations.checkPost, style: theme.textTheme.bodyMedium, diff --git a/packages/flutter_timeline_view/lib/src/screens/timeline_screen.dart b/packages/flutter_timeline_view/lib/src/screens/timeline_screen.dart index d0be49b..3282979 100644 --- a/packages/flutter_timeline_view/lib/src/screens/timeline_screen.dart +++ b/packages/flutter_timeline_view/lib/src/screens/timeline_screen.dart @@ -9,15 +9,21 @@ import 'package:flutter_timeline_view/src/widgets/timeline_post_widget.dart'; class TimelineScreen extends StatefulWidget { const TimelineScreen({ + required this.userId, required this.options, required this.posts, required this.onPostTap, + required this.service, this.controller, this.timelineCategoryFilter, this.timelinePostHeight = 100.0, super.key, }); + final String userId; + + final TimelineService service; + final TimelineOptions options; final ScrollController? controller; @@ -44,22 +50,32 @@ class _TimelineScreenState extends State { } @override - Widget build(BuildContext context) => SingleChildScrollView( - child: Column( - children: [ - for (var post in widget.posts) - if (widget.timelineCategoryFilter == null || - post.category == widget.timelineCategoryFilter) - Padding( - padding: const EdgeInsets.symmetric(vertical: 8.0), - child: TimelinePostWidget( - options: widget.options, - post: post, - height: widget.timelinePostHeight, - onTap: () => widget.onPostTap.call(post), - ), - ), - ], - ), + Widget build(BuildContext context) => FutureBuilder( + // ignore: discarded_futures + future: widget.service.fetchPosts(widget.timelineCategoryFilter), + builder: (context, snapshot) { + if (snapshot.hasData && snapshot.data != null) { + return SingleChildScrollView( + child: Column( + children: [ + for (var post in snapshot.data!) + if (widget.timelineCategoryFilter == null || + post.category == widget.timelineCategoryFilter) + Padding( + padding: const EdgeInsets.symmetric(vertical: 8.0), + child: TimelinePostWidget( + options: widget.options, + post: post, + height: widget.timelinePostHeight, + onTap: () => widget.onPostTap.call(post), + ), + ), + ], + ), + ); + } else { + return const Center(child: CircularProgressIndicator()); + } + }, ); } From 4f2aba4cc4dd48e6a768d9e75979a8765f81a833 Mon Sep 17 00:00:00 2001 From: Freek van de Ven Date: Mon, 20 Nov 2023 20:31:17 +0100 Subject: [PATCH 11/16] feat: add timeline reactions --- .../service/firebase_timeline_service.dart | 57 ++-- .../lib/src/model/timeline_post.dart | 17 +- .../lib/src/model/timeline_reaction.dart | 19 ++ .../lib/src/services/timeline_service.dart | 2 +- .../lib/src/config/timeline_options.dart | 4 + .../lib/src/config/timeline_translations.dart | 11 + .../lib/src/screens/timeline_post_screen.dart | 251 ++++++++++++++---- 7 files changed, 274 insertions(+), 87 deletions(-) diff --git a/packages/flutter_timeline_firebase/lib/src/service/firebase_timeline_service.dart b/packages/flutter_timeline_firebase/lib/src/service/firebase_timeline_service.dart index 3048832..54bb9b2 100644 --- a/packages/flutter_timeline_firebase/lib/src/service/firebase_timeline_service.dart +++ b/packages/flutter_timeline_firebase/lib/src/service/firebase_timeline_service.dart @@ -7,7 +7,6 @@ import 'dart:typed_data'; import 'package:cloud_firestore/cloud_firestore.dart'; import 'package:firebase_core/firebase_core.dart'; import 'package:firebase_storage/firebase_storage.dart'; -import 'package:flutter/material.dart'; import 'package:flutter_timeline_firebase/src/config/firebase_timeline_options.dart'; import 'package:flutter_timeline_interface/flutter_timeline_interface.dart'; import 'package:uuid/uuid.dart'; @@ -35,7 +34,8 @@ class FirebaseTimelineService implements TimelineService { @override Future createPost(TimelinePost post) async { var postId = const Uuid().v4(); - var imageRef = _storage.ref().child('timeline/$postId'); + var imageRef = + _storage.ref().child('${_options.timelineCollectionName}/$postId'); var result = await imageRef.putData(post.image!); var imageUrl = await result.ref.getDownloadURL(); var updatedPost = post.copyWith(imageUrl: imageUrl, id: postId); @@ -54,8 +54,17 @@ class FirebaseTimelineService implements TimelineService { @override Future fetchPostDetails(TimelinePost post) async { - debugPrint('fetchPostDetails'); - return post; + var reactions = post.reactions ?? []; + var updatedReactions = []; + for (var reaction in reactions) { + var user = await _userService.getUser(reaction.creatorId); + if (user != null) { + updatedReactions.add(reaction.copyWith(creator: user)); + } + } + var updatedPost = post.copyWith(reactions: updatedReactions); + _posts = _posts.map((p) => (p.id == post.id) ? updatedPost : p).toList(); + return updatedPost; } @override @@ -124,26 +133,36 @@ class FirebaseTimelineService implements TimelineService { } @override - Future reactToPost( + Future reactToPost( TimelinePost post, TimelinePostReaction reaction, { Uint8List? image, - }) { - // update the post with the new reaction - _posts = _posts - .map( - (p) => (p.id == post.id) - ? p.copyWith( - reaction: p.reaction + 1, - reactions: p.reactions?..add(reaction), - ) - : p, - ) - .toList(); + }) async { + var reactionId = const Uuid().v4(); + // also fetch the user information and add it to the reaction + var user = await _userService.getUser(reaction.creatorId); + var updatedReaction = reaction.copyWith(id: reactionId, creator: user); + if (image != null) { + var imageRef = _storage + .ref() + .child('${_options.timelineCollectionName}/${post.id}/$reactionId}'); + var result = await imageRef.putData(image); + var imageUrl = await result.ref.getDownloadURL(); + updatedReaction = updatedReaction.copyWith(imageUrl: imageUrl); + } + + var updatedPost = post.copyWith( + reaction: post.reaction + 1, + reactions: post.reactions?..add(updatedReaction), + ); + var postRef = _db.collection(_options.timelineCollectionName).doc(post.id); - return postRef.update({ + await postRef.update({ 'reaction': FieldValue.increment(1), - 'reactions': FieldValue.arrayUnion([reaction.toJson()]), + // 'reactions' is a map of reactions, so we need to add the new reaction + // to the map + 'reactions': FieldValue.arrayUnion([updatedReaction.toJson()]), }); + return updatedPost; } } diff --git a/packages/flutter_timeline_interface/lib/src/model/timeline_post.dart b/packages/flutter_timeline_interface/lib/src/model/timeline_post.dart index 8fe1eb9..68780e2 100644 --- a/packages/flutter_timeline_interface/lib/src/model/timeline_post.dart +++ b/packages/flutter_timeline_interface/lib/src/model/timeline_post.dart @@ -39,18 +39,14 @@ class TimelinePost { likes: json['likes'] as int, likedBy: (json['liked_by'] as List?)?.cast(), reaction: json['reaction'] as int, - reactions: (json['reactions'] as Map?) + reactions: (json['reactions'] as List?) ?.map( - (key, value) => MapEntry( - key, - TimelinePostReaction.fromJson( - key, - id, - value as Map, - ), + (e) => TimelinePostReaction.fromJson( + (e as Map).keys.first, + id, + e.values.first as Map, ), ) - .values .toList(), createdAt: DateTime.parse(json['created_at'] as String), reactionEnabled: json['reaction_enabled'] as bool, @@ -140,7 +136,8 @@ class TimelinePost { 'likes': likes, 'liked_by': likedBy, 'reaction': reaction, - 'reactions': reactions?.map((e) => e.toJson()).toList(), + // reactions is a list of maps so we need to convert it to a map + 'reactions': reactions?.map((e) => e.toJson()).toList() ?? {}, 'created_at': createdAt.toIso8601String(), 'reaction_enabled': reactionEnabled, }; diff --git a/packages/flutter_timeline_interface/lib/src/model/timeline_reaction.dart b/packages/flutter_timeline_interface/lib/src/model/timeline_reaction.dart index 76b22ca..0b8b231 100644 --- a/packages/flutter_timeline_interface/lib/src/model/timeline_reaction.dart +++ b/packages/flutter_timeline_interface/lib/src/model/timeline_reaction.dart @@ -52,6 +52,25 @@ class TimelinePostReaction { /// Reaction creation date. final DateTime createdAt; + TimelinePostReaction copyWith({ + String? id, + String? postId, + String? creatorId, + TimelinePosterUserModel? creator, + String? reaction, + String? imageUrl, + DateTime? createdAt, + }) => + TimelinePostReaction( + id: id ?? this.id, + postId: postId ?? this.postId, + creatorId: creatorId ?? this.creatorId, + creator: creator ?? this.creator, + reaction: reaction ?? this.reaction, + imageUrl: imageUrl ?? this.imageUrl, + createdAt: createdAt ?? this.createdAt, + ); + Map toJson() => { id: { 'creator_id': creatorId, diff --git a/packages/flutter_timeline_interface/lib/src/services/timeline_service.dart b/packages/flutter_timeline_interface/lib/src/services/timeline_service.dart index d00c911..8791e1c 100644 --- a/packages/flutter_timeline_interface/lib/src/services/timeline_service.dart +++ b/packages/flutter_timeline_interface/lib/src/services/timeline_service.dart @@ -13,7 +13,7 @@ abstract class TimelineService { Future> fetchPosts(String? category); List getPosts(String? category); Future fetchPostDetails(TimelinePost post); - Future reactToPost( + Future reactToPost( TimelinePost post, TimelinePostReaction reaction, { Uint8List image, diff --git a/packages/flutter_timeline_view/lib/src/config/timeline_options.dart b/packages/flutter_timeline_view/lib/src/config/timeline_options.dart index 5649978..d62890f 100644 --- a/packages/flutter_timeline_view/lib/src/config/timeline_options.dart +++ b/packages/flutter_timeline_view/lib/src/config/timeline_options.dart @@ -15,6 +15,7 @@ class TimelineOptions { this.translations = const TimelineTranslations(), this.imagePickerConfig = const ImagePickerConfig(), this.imagePickerTheme = const ImagePickerTheme(), + this.sortCommentsAscending = false, this.dateformat, this.timeFormat, this.buttonBuilder, @@ -31,6 +32,9 @@ class TimelineOptions { /// The format to display the post time in final DateFormat? timeFormat; + /// Whether to sort comments ascending or descending + final bool sortCommentsAscending; + final TimelineTranslations translations; final ButtonBuilder? buttonBuilder; diff --git a/packages/flutter_timeline_view/lib/src/config/timeline_translations.dart b/packages/flutter_timeline_view/lib/src/config/timeline_translations.dart index df33ff7..c24f96f 100644 --- a/packages/flutter_timeline_view/lib/src/config/timeline_translations.dart +++ b/packages/flutter_timeline_view/lib/src/config/timeline_translations.dart @@ -7,6 +7,9 @@ import 'package:flutter/material.dart'; @immutable class TimelineTranslations { const TimelineTranslations({ + this.anonymousUser = 'Anonymous user', + this.noPosts = 'No posts yet', + this.noPostsWithFilter = 'No posts with this filter', this.title = 'Title', this.content = 'Content', this.contentDescription = 'What do you want to share?', @@ -20,10 +23,16 @@ class TimelineTranslations { this.viewPost = 'View post', this.likesTitle = 'Likes', this.commentsTitle = 'Comments', + this.firstComment = 'Be the first to comment', this.writeComment = 'Write your comment here...', this.postAt = 'at', + this.postLoadingError = 'Something went wrong while loading the post', }); + final String noPosts; + final String noPostsWithFilter; + final String anonymousUser; + final String title; final String content; final String contentDescription; @@ -39,4 +48,6 @@ class TimelineTranslations { final String likesTitle; final String commentsTitle; final String writeComment; + final String firstComment; + final String postLoadingError; } diff --git a/packages/flutter_timeline_view/lib/src/screens/timeline_post_screen.dart b/packages/flutter_timeline_view/lib/src/screens/timeline_post_screen.dart index 8c5effe..5577137 100644 --- a/packages/flutter_timeline_view/lib/src/screens/timeline_post_screen.dart +++ b/packages/flutter_timeline_view/lib/src/screens/timeline_post_screen.dart @@ -2,39 +2,111 @@ // // SPDX-License-Identifier: BSD-3-Clause +import 'dart:async'; +import 'dart:typed_data'; + import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/material.dart'; +import 'package:flutter_image_picker/flutter_image_picker.dart'; import 'package:flutter_timeline_interface/flutter_timeline_interface.dart'; import 'package:flutter_timeline_view/src/config/timeline_options.dart'; import 'package:flutter_timeline_view/src/widgets/reaction_bottom.dart'; import 'package:intl/intl.dart'; -class TimelinePostScreen extends StatelessWidget { +class TimelinePostScreen extends StatefulWidget { const TimelinePostScreen({ + required this.userId, + required this.service, + required this.userService, required this.options, required this.post, this.padding = const EdgeInsets.symmetric(vertical: 24, horizontal: 16), super.key, }); + /// The user id of the current user + final String userId; + + /// The timeline service to fetch the post details + final TimelineService service; + + /// The user service to fetch the profile picture of the user + final TimelineUserService userService; + + /// Options to configure the timeline screens final TimelineOptions options; + /// The post to show final TimelinePost post; /// The padding around the screen final EdgeInsets padding; + @override + State createState() => _TimelinePostScreenState(); +} + +class _TimelinePostScreenState extends State { + TimelinePost? post; + bool isLoading = true; + + @override + void initState() { + super.initState(); + unawaited(loadPostDetails()); + } + + Future loadPostDetails() async { + try { + // Assuming fetchPostDetails is an async function returning a TimelinePost + var loadedPost = await widget.service.fetchPostDetails(widget.post); + setState(() { + post = loadedPost; + isLoading = false; + }); + } on Exception catch (e) { + // Handle any errors here + debugPrint('Error loading post: $e'); + setState(() { + isLoading = false; + }); + } + } + + void updatePost(TimelinePost newPost) { + setState(() { + post = newPost; + }); + } + @override Widget build(BuildContext context) { var theme = Theme.of(context); - var dateFormat = options.dateformat ?? + var dateFormat = widget.options.dateformat ?? DateFormat('dd/MM/yyyy', Localizations.localeOf(context).languageCode); - var timeFormat = options.timeFormat ?? DateFormat('HH:mm'); + var timeFormat = widget.options.timeFormat ?? DateFormat('HH:mm'); + if (isLoading) { + return const Center( + child: CircularProgressIndicator(), + ); + } + if (this.post == null) { + return Center( + child: Text(widget.options.translations.postLoadingError), + ); + } + var post = this.post!; + post.reactions?.sort( + (a, b) => widget.options.sortCommentsAscending + ? b.createdAt.compareTo(a.createdAt) + : a.createdAt.compareTo(b.createdAt), + ); + return Stack( children: [ SingleChildScrollView( child: Padding( - padding: padding, + padding: widget.padding, child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -42,7 +114,7 @@ class TimelinePostScreen extends StatelessWidget { Row( children: [ if (post.creator!.imageUrl != null) ...[ - options.userAvatarBuilder?.call( + widget.options.userAvatarBuilder?.call( post.creator!, 40, ) ?? @@ -93,7 +165,7 @@ class TimelinePostScreen extends StatelessWidget { ], ), Text( - '${post.likes} ${options.translations.likesTitle}', + '${post.likes} ${widget.options.translations.likesTitle}', style: theme.textTheme.titleSmall, ), Row( @@ -118,70 +190,135 @@ class TimelinePostScreen extends StatelessWidget { const SizedBox(height: 4), Text( '${dateFormat.format(post.createdAt)} ' - '${options.translations.postAt} ' + '${widget.options.translations.postAt} ' '${timeFormat.format(post.createdAt)}', style: theme.textTheme.bodySmall, ), const SizedBox(height: 12), - - Text( - options.translations.commentsTitle, - style: theme.textTheme.displaySmall, - ), - for (var reaction - in post.reactions ?? []) ...[ - const SizedBox(height: 8), - Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - if (reaction.creator?.imageUrl != null && - reaction.creator!.imageUrl!.isNotEmpty) ...[ - options.userAvatarBuilder?.call( - reaction.creator!, - 25, - ) ?? - CircleAvatar( - radius: 20, - backgroundImage: CachedNetworkImageProvider( - reaction.creator!.imageUrl!, + if (post.reactionEnabled) ...[ + Text( + widget.options.translations.commentsTitle, + style: theme.textTheme.displaySmall, + ), + for (var reaction + in post.reactions ?? []) ...[ + const SizedBox(height: 16), + Row( + crossAxisAlignment: reaction.imageUrl != null + ? CrossAxisAlignment.start + : CrossAxisAlignment.center, + children: [ + if (reaction.creator?.imageUrl != null && + reaction.creator!.imageUrl!.isNotEmpty) ...[ + widget.options.userAvatarBuilder?.call( + reaction.creator!, + 25, + ) ?? + CircleAvatar( + radius: 20, + backgroundImage: CachedNetworkImageProvider( + reaction.creator!.imageUrl!, + ), + ), + ], + const SizedBox(width: 10), + if (reaction.imageUrl != null) ...[ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + reaction.creator?.fullName ?? + widget.options.translations.anonymousUser, + style: theme.textTheme.titleSmall, + ), + Padding( + padding: const EdgeInsets.all(8.0), + child: CachedNetworkImage( + imageUrl: reaction.imageUrl!, + fit: BoxFit.fitWidth, + ), + ), + ], + ), + ), + ] else ...[ + Expanded( + child: Text.rich( + TextSpan( + text: reaction.creator?.fullName ?? + widget.options.translations.anonymousUser, + style: theme.textTheme.titleSmall, + children: [ + const TextSpan(text: ' '), + TextSpan( + text: reaction.reaction ?? '', + style: theme.textTheme.bodyMedium, + ), + // text should go to new line + ], ), ), + ), + ], ], - const SizedBox(width: 10), - if (reaction.creator?.fullName != null) ...[ - Text( - reaction.creator!.fullName!, - style: theme.textTheme.titleSmall, - ), - ], - const SizedBox(width: 10), - // TODO(anyone): show image if the user send one - Expanded( - child: Text( - reaction.reaction ?? '', - style: theme.textTheme.bodyMedium, - // text should go to new line - softWrap: true, - ), - ), - ], - ), - const SizedBox(height: 100), + ), + ], + const SizedBox(height: 120), ], ], ), ), ), - Align( - alignment: Alignment.bottomCenter, - child: ReactionBottom( - messageInputBuilder: options.textInputBuilder!, - onPressSelectImage: () async {}, - onReactionSubmit: (reaction) async {}, - translations: options.translations, - iconColor: options.theme.iconColor, + if (post.reactionEnabled) + Align( + alignment: Alignment.bottomCenter, + child: ReactionBottom( + messageInputBuilder: widget.options.textInputBuilder!, + onPressSelectImage: () async { + // open the image picker + var result = await showModalBottomSheet( + context: context, + builder: (context) => Container( + padding: const EdgeInsets.all(8.0), + color: Colors.black, + child: ImagePicker( + imagePickerConfig: widget.options.imagePickerConfig, + imagePickerTheme: widget.options.imagePickerTheme, + ), + ), + ); + if (result != null) { + updatePost( + await widget.service.reactToPost( + post, + TimelinePostReaction( + id: '', + postId: post.id, + creatorId: widget.userId, + createdAt: DateTime.now(), + ), + image: result, + ), + ); + } + }, + onReactionSubmit: (reaction) async => updatePost( + await widget.service.reactToPost( + post, + TimelinePostReaction( + id: '', + postId: post.id, + reaction: reaction, + creatorId: widget.userId, + createdAt: DateTime.now(), + ), + ), + ), + translations: widget.options.translations, + iconColor: widget.options.theme.iconColor, + ), ), - ), ], ); } From 8792079fa46e743c78a53097804153b16223781d Mon Sep 17 00:00:00 2001 From: Freek van de Ven Date: Tue, 21 Nov 2023 12:33:46 +0100 Subject: [PATCH 12/16] feat: add like functionality --- .../service/firebase_timeline_service.dart | 34 +++---- .../lib/src/model/timeline_post.dart | 2 +- .../lib/src/services/timeline_service.dart | 4 +- .../lib/src/config/timeline_theme.dart | 16 +++ .../lib/src/screens/timeline_post_screen.dart | 90 +++++++++++------ .../lib/src/screens/timeline_screen.dart | 97 +++++++++++++------ .../lib/src/widgets/timeline_post_widget.dart | 86 ++++++++++------ 7 files changed, 221 insertions(+), 108 deletions(-) diff --git a/packages/flutter_timeline_firebase/lib/src/service/firebase_timeline_service.dart b/packages/flutter_timeline_firebase/lib/src/service/firebase_timeline_service.dart index 54bb9b2..77ef4e4 100644 --- a/packages/flutter_timeline_firebase/lib/src/service/firebase_timeline_service.dart +++ b/packages/flutter_timeline_firebase/lib/src/service/firebase_timeline_service.dart @@ -93,43 +93,43 @@ class FirebaseTimelineService implements TimelineService { .toList(); @override - Future likePost(String userId, TimelinePost post) { + Future likePost(String userId, TimelinePost post) async { // update the post with the new like + var updatedPost = post.copyWith( + likes: post.likes + 1, + likedBy: post.likedBy?..add(userId), + ); _posts = _posts .map( - (p) => (p.id == post.id) - ? p.copyWith( - likes: p.likes + 1, - likedBy: p.likedBy?..add(userId), - ) - : p, + (p) => p.id == post.id ? updatedPost : p, ) .toList(); var postRef = _db.collection(_options.timelineCollectionName).doc(post.id); - return postRef.update({ + await postRef.update({ 'likes': FieldValue.increment(1), 'liked_by': FieldValue.arrayUnion([userId]), }); + return updatedPost; } @override - Future unlikePost(String userId, TimelinePost post) { + Future unlikePost(String userId, TimelinePost post) async { // update the post with the new like + var updatedPost = post.copyWith( + likes: post.likes - 1, + likedBy: post.likedBy?..remove(userId), + ); _posts = _posts .map( - (p) => (p.id == post.id) - ? p.copyWith( - likes: p.likes - 1, - likedBy: p.likedBy?..remove(userId), - ) - : p, + (p) => p.id == post.id ? updatedPost : p, ) .toList(); var postRef = _db.collection(_options.timelineCollectionName).doc(post.id); - return postRef.update({ + await postRef.update({ 'likes': FieldValue.increment(-1), 'liked_by': FieldValue.arrayRemove([userId]), }); + return updatedPost; } @override @@ -159,8 +159,6 @@ class FirebaseTimelineService implements TimelineService { var postRef = _db.collection(_options.timelineCollectionName).doc(post.id); await postRef.update({ 'reaction': FieldValue.increment(1), - // 'reactions' is a map of reactions, so we need to add the new reaction - // to the map 'reactions': FieldValue.arrayUnion([updatedReaction.toJson()]), }); return updatedPost; diff --git a/packages/flutter_timeline_interface/lib/src/model/timeline_post.dart b/packages/flutter_timeline_interface/lib/src/model/timeline_post.dart index 68780e2..213f728 100644 --- a/packages/flutter_timeline_interface/lib/src/model/timeline_post.dart +++ b/packages/flutter_timeline_interface/lib/src/model/timeline_post.dart @@ -37,7 +37,7 @@ class TimelinePost { imageUrl: json['image_url'] as String?, content: json['content'] as String, likes: json['likes'] as int, - likedBy: (json['liked_by'] as List?)?.cast(), + likedBy: (json['liked_by'] as List?)?.cast() ?? [], reaction: json['reaction'] as int, reactions: (json['reactions'] as List?) ?.map( diff --git a/packages/flutter_timeline_interface/lib/src/services/timeline_service.dart b/packages/flutter_timeline_interface/lib/src/services/timeline_service.dart index 8791e1c..8305d1b 100644 --- a/packages/flutter_timeline_interface/lib/src/services/timeline_service.dart +++ b/packages/flutter_timeline_interface/lib/src/services/timeline_service.dart @@ -18,6 +18,6 @@ abstract class TimelineService { TimelinePostReaction reaction, { Uint8List image, }); - Future likePost(String userId, TimelinePost post); - Future unlikePost(String userId, TimelinePost post); + Future likePost(String userId, TimelinePost post); + Future unlikePost(String userId, TimelinePost post); } diff --git a/packages/flutter_timeline_view/lib/src/config/timeline_theme.dart b/packages/flutter_timeline_view/lib/src/config/timeline_theme.dart index 57d8235..85d4cea 100644 --- a/packages/flutter_timeline_view/lib/src/config/timeline_theme.dart +++ b/packages/flutter_timeline_view/lib/src/config/timeline_theme.dart @@ -8,7 +8,23 @@ import 'package:flutter/material.dart'; class TimelineTheme { const TimelineTheme({ this.iconColor, + this.likeIcon, + this.commentIcon, + this.likedIcon, + this.sendIcon, }); final Color? iconColor; + + /// The icon to display when the post is not yet liked + final Widget? likeIcon; + + /// The icon to display to indicate that a post has comments enabled + final Widget? commentIcon; + + /// The icon to display when the post is liked + final Widget? likedIcon; + + /// The icon to display to submit a comment + final Widget? sendIcon; } diff --git a/packages/flutter_timeline_view/lib/src/screens/timeline_post_screen.dart b/packages/flutter_timeline_view/lib/src/screens/timeline_post_screen.dart index 5577137..2526115 100644 --- a/packages/flutter_timeline_view/lib/src/screens/timeline_post_screen.dart +++ b/packages/flutter_timeline_view/lib/src/screens/timeline_post_screen.dart @@ -148,41 +148,73 @@ class _TimelinePostScreenState extends State { ), ], // post information - Row( - children: [ - // like icon - IconButton( - onPressed: () {}, - icon: const Icon(Icons.thumb_up_rounded), - ), - // comment icon - IconButton( - onPressed: () {}, - icon: const Icon( - Icons.chat_bubble_outline_rounded, - ), - ), - ], + Padding( + padding: const EdgeInsets.symmetric(vertical: 4.0), + child: Row( + children: [ + if (post.likedBy?.contains(widget.userId) ?? false) ...[ + InkWell( + onTap: () async { + updatePost( + await widget.service.unlikePost( + widget.userId, + post, + ), + ); + }, + child: widget.options.theme.likedIcon ?? + Icon( + Icons.thumb_up_rounded, + color: widget.options.theme.iconColor, + ), + ), + ] else ...[ + InkWell( + onTap: () async { + updatePost( + await widget.service.likePost( + widget.userId, + post, + ), + ); + }, + child: widget.options.theme.likeIcon ?? + Icon( + Icons.thumb_up_alt_outlined, + color: widget.options.theme.iconColor, + ), + ), + ], + const SizedBox(width: 8), + if (post.reactionEnabled) + widget.options.theme.commentIcon ?? + const Icon( + Icons.chat_bubble_outline_rounded, + ), + ], + ), ), Text( '${post.likes} ${widget.options.translations.likesTitle}', style: theme.textTheme.titleSmall, ), - Row( - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - Text( - post.creator?.fullName ?? '', - style: theme.textTheme.titleSmall, - ), - const SizedBox(width: 8), - Text( - post.title, - style: theme.textTheme.bodyMedium, - overflow: TextOverflow.fade, - ), - ], + const SizedBox(height: 4), + Text.rich( + TextSpan( + text: post.creator?.fullName ?? + widget.options.translations.anonymousUser, + style: theme.textTheme.titleSmall, + children: [ + const TextSpan(text: ' '), + TextSpan( + text: post.title, + style: theme.textTheme.bodyMedium, + ), + ], + ), + overflow: TextOverflow.ellipsis, ), + const SizedBox(height: 4), Text( post.content, style: theme.textTheme.bodyMedium, diff --git a/packages/flutter_timeline_view/lib/src/screens/timeline_screen.dart b/packages/flutter_timeline_view/lib/src/screens/timeline_screen.dart index 3282979..6d01f82 100644 --- a/packages/flutter_timeline_view/lib/src/screens/timeline_screen.dart +++ b/packages/flutter_timeline_view/lib/src/screens/timeline_screen.dart @@ -2,6 +2,8 @@ // // SPDX-License-Identifier: BSD-3-Clause +import 'dart:async'; + import 'package:flutter/material.dart'; import 'package:flutter_timeline_interface/flutter_timeline_interface.dart'; import 'package:flutter_timeline_view/src/config/timeline_options.dart'; @@ -20,6 +22,7 @@ class TimelineScreen extends StatefulWidget { super.key, }); + /// The user id of the current user final String userId; final TimelineService service; @@ -42,40 +45,80 @@ class TimelineScreen extends StatefulWidget { class _TimelineScreenState extends State { late ScrollController controller; + late List posts; + bool isLoading = true; @override void initState() { super.initState(); controller = widget.controller ?? ScrollController(); + unawaited(loadPosts()); + } + + Future loadPosts() async { + try { + // Fetching posts from the service + var fetchedPosts = + await widget.service.fetchPosts(widget.timelineCategoryFilter); + setState(() { + posts = fetchedPosts; + isLoading = false; + }); + } on Exception catch (e) { + // Handle errors here + debugPrint('Error loading posts: $e'); + setState(() { + isLoading = false; + }); + } + } + + void updatePostInList(TimelinePost updatedPost) { + if (posts.any((p) => p.id == updatedPost.id)) + setState(() { + posts = posts + .map((p) => (p.id == updatedPost.id) ? updatedPost : p) + .toList(); + }); + else { + setState(() { + posts = [updatedPost, ...posts]; + }); + } } @override - Widget build(BuildContext context) => FutureBuilder( - // ignore: discarded_futures - future: widget.service.fetchPosts(widget.timelineCategoryFilter), - builder: (context, snapshot) { - if (snapshot.hasData && snapshot.data != null) { - return SingleChildScrollView( - child: Column( - children: [ - for (var post in snapshot.data!) - if (widget.timelineCategoryFilter == null || - post.category == widget.timelineCategoryFilter) - Padding( - padding: const EdgeInsets.symmetric(vertical: 8.0), - child: TimelinePostWidget( - options: widget.options, - post: post, - height: widget.timelinePostHeight, - onTap: () => widget.onPostTap.call(post), - ), - ), - ], + Widget build(BuildContext context) { + if (isLoading) { + // Show loading indicator while data is being fetched + return const Center(child: CircularProgressIndicator()); + } + + // Build the list of posts + return SingleChildScrollView( + controller: controller, + child: Column( + children: posts + .map( + (post) => Padding( + padding: const EdgeInsets.symmetric(vertical: 8.0), + child: TimelinePostWidget( + userId: widget.userId, + options: widget.options, + post: post, + height: widget.timelinePostHeight, + onTap: () => widget.onPostTap.call(post), + onTapLike: () async => updatePostInList( + await widget.service.likePost(widget.userId, post), + ), + onTapUnlike: () async => updatePostInList( + await widget.service.unlikePost(widget.userId, post), + ), + ), ), - ); - } else { - return const Center(child: CircularProgressIndicator()); - } - }, - ); + ) + .toList(), + ), + ); + } } diff --git a/packages/flutter_timeline_view/lib/src/widgets/timeline_post_widget.dart b/packages/flutter_timeline_view/lib/src/widgets/timeline_post_widget.dart index 277bec3..49be26a 100644 --- a/packages/flutter_timeline_view/lib/src/widgets/timeline_post_widget.dart +++ b/packages/flutter_timeline_view/lib/src/widgets/timeline_post_widget.dart @@ -5,18 +5,25 @@ import 'package:flutter_timeline_view/src/config/timeline_options.dart'; class TimelinePostWidget extends StatelessWidget { const TimelinePostWidget({ + required this.userId, required this.options, required this.post, required this.height, - this.onTap, + required this.onTapLike, + required this.onTapUnlike, + required this.onTap, super.key, }); + /// The user id of the current user + final String userId; final TimelineOptions options; final TimelinePost post; final double height; - final VoidCallback? onTap; + final VoidCallback onTap; + final VoidCallback onTapLike; + final VoidCallback onTapUnlike; @override Widget build(BuildContext context) { @@ -69,40 +76,57 @@ class TimelinePostWidget extends StatelessWidget { ), ], // post information - Row( - children: [ - // like icon - IconButton( - onPressed: () {}, - icon: const Icon(Icons.thumb_up_rounded), - ), - // comment icon - IconButton( - onPressed: () {}, - icon: const Icon( - Icons.chat_bubble_outline_rounded, - ), - ), - ], + Padding( + padding: const EdgeInsets.symmetric(vertical: 4.0), + child: Row( + children: [ + if (post.likedBy?.contains(userId) ?? false) ...[ + InkWell( + onTap: onTapUnlike, + child: options.theme.likedIcon ?? + Icon( + Icons.thumb_up_rounded, + color: options.theme.iconColor, + ), + ), + ] else ...[ + InkWell( + onTap: onTapLike, + child: options.theme.likeIcon ?? + Icon( + Icons.thumb_up_alt_outlined, + color: options.theme.iconColor, + ), + ), + ], + const SizedBox(width: 8), + if (post.reactionEnabled) + options.theme.commentIcon ?? + const Icon( + Icons.chat_bubble_outline_rounded, + ), + ], + ), ), Text( '${post.likes} ${options.translations.likesTitle}', style: theme.textTheme.titleSmall, ), - Row( - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - Text( - post.creator?.fullName ?? '', - style: theme.textTheme.titleSmall, - ), - const SizedBox(width: 8), - Text( - post.title, - style: theme.textTheme.bodyMedium, - overflow: TextOverflow.fade, - ), - ], + const SizedBox(height: 4), + Text.rich( + TextSpan( + text: post.creator?.fullName ?? + options.translations.anonymousUser, + style: theme.textTheme.titleSmall, + children: [ + const TextSpan(text: ' '), + TextSpan( + text: post.title, + style: theme.textTheme.bodyMedium, + ), + ], + ), + overflow: TextOverflow.ellipsis, ), Text( options.translations.viewPost, From ca4ec030021f120d906ca197ec25b8e2e80530d6 Mon Sep 17 00:00:00 2001 From: Freek van de Ven Date: Tue, 21 Nov 2023 17:56:52 +0100 Subject: [PATCH 13/16] feat: small improvements to the screens --- .../example/lib/main.dart | 4 + .../lib/flutter_timeline_view.dart | 2 + .../lib/src/config/timeline_options.dart | 4 + .../lib/src/config/timeline_theme.dart | 8 ++ .../lib/src/screens/timeline_post_screen.dart | 66 ++++++---- .../lib/src/screens/timeline_screen.dart | 118 ++++++++++++------ .../lib/src/widgets/timeline_post_widget.dart | 70 +++++++---- 7 files changed, 183 insertions(+), 89 deletions(-) diff --git a/packages/flutter_timeline_view/example/lib/main.dart b/packages/flutter_timeline_view/example/lib/main.dart index 7fbc6ad..568731b 100644 --- a/packages/flutter_timeline_view/example/lib/main.dart +++ b/packages/flutter_timeline_view/example/lib/main.dart @@ -1,3 +1,7 @@ +// SPDX-FileCopyrightText: 2023 Iconica +// +// SPDX-License-Identifier: BSD-3-Clause + import 'package:flutter/material.dart'; void main() { diff --git a/packages/flutter_timeline_view/lib/flutter_timeline_view.dart b/packages/flutter_timeline_view/lib/flutter_timeline_view.dart index fcd9330..196be36 100644 --- a/packages/flutter_timeline_view/lib/flutter_timeline_view.dart +++ b/packages/flutter_timeline_view/lib/flutter_timeline_view.dart @@ -5,7 +5,9 @@ library flutter_timeline_view; export 'src/config/timeline_options.dart'; +export 'src/config/timeline_theme.dart'; export 'src/config/timeline_translations.dart'; export 'src/screens/timeline_post_creation_screen.dart'; export 'src/screens/timeline_post_screen.dart'; export 'src/screens/timeline_screen.dart'; +export 'src/widgets/timeline_post_widget.dart'; diff --git a/packages/flutter_timeline_view/lib/src/config/timeline_options.dart b/packages/flutter_timeline_view/lib/src/config/timeline_options.dart index d62890f..ced3f32 100644 --- a/packages/flutter_timeline_view/lib/src/config/timeline_options.dart +++ b/packages/flutter_timeline_view/lib/src/config/timeline_options.dart @@ -16,6 +16,7 @@ class TimelineOptions { this.imagePickerConfig = const ImagePickerConfig(), this.imagePickerTheme = const ImagePickerTheme(), this.sortCommentsAscending = false, + this.sortPostsAscending = false, this.dateformat, this.timeFormat, this.buttonBuilder, @@ -35,6 +36,9 @@ class TimelineOptions { /// Whether to sort comments ascending or descending final bool sortCommentsAscending; + /// Whether to sort posts ascending or descending + final bool sortPostsAscending; + final TimelineTranslations translations; final ButtonBuilder? buttonBuilder; diff --git a/packages/flutter_timeline_view/lib/src/config/timeline_theme.dart b/packages/flutter_timeline_view/lib/src/config/timeline_theme.dart index 85d4cea..d880031 100644 --- a/packages/flutter_timeline_view/lib/src/config/timeline_theme.dart +++ b/packages/flutter_timeline_view/lib/src/config/timeline_theme.dart @@ -12,6 +12,8 @@ class TimelineTheme { this.commentIcon, this.likedIcon, this.sendIcon, + this.moreIcon, + this.deleteIcon, }); final Color? iconColor; @@ -27,4 +29,10 @@ class TimelineTheme { /// The icon to display to submit a comment final Widget? sendIcon; + + /// The icon for more actions (open delete menu) + final Widget? moreIcon; + + /// The icon for delete action (delete post) + final Widget? deleteIcon; } diff --git a/packages/flutter_timeline_view/lib/src/screens/timeline_post_screen.dart b/packages/flutter_timeline_view/lib/src/screens/timeline_post_screen.dart index 2526115..68ef907 100644 --- a/packages/flutter_timeline_view/lib/src/screens/timeline_post_screen.dart +++ b/packages/flutter_timeline_view/lib/src/screens/timeline_post_screen.dart @@ -20,6 +20,7 @@ class TimelinePostScreen extends StatefulWidget { required this.userService, required this.options, required this.post, + this.onUserTap, this.padding = const EdgeInsets.symmetric(vertical: 24, horizontal: 16), super.key, }); @@ -42,6 +43,9 @@ class TimelinePostScreen extends StatefulWidget { /// The padding around the screen final EdgeInsets padding; + /// If this is not null, the user can tap on the user avatar or name + final Function(String userId)? onUserTap; + @override State createState() => _TimelinePostScreenState(); } @@ -110,34 +114,44 @@ class _TimelinePostScreenState extends State { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - if (post.creator != null) - Row( - children: [ - if (post.creator!.imageUrl != null) ...[ - widget.options.userAvatarBuilder?.call( - post.creator!, - 40, - ) ?? - CircleAvatar( - radius: 20, - backgroundImage: CachedNetworkImageProvider( - post.creator!.imageUrl!, + Row( + children: [ + if (post.creator != null) + InkWell( + onTap: widget.onUserTap != null + ? () => widget.onUserTap?.call(post.creator!.userId) + : null, + child: Row( + children: [ + if (post.creator!.imageUrl != null) ...[ + widget.options.userAvatarBuilder?.call( + post.creator!, + 40, + ) ?? + CircleAvatar( + radius: 20, + backgroundImage: CachedNetworkImageProvider( + post.creator!.imageUrl!, + ), + ), + ], + const SizedBox(width: 10), + if (post.creator!.fullName != null) ...[ + Text( + post.creator!.fullName!, + style: theme.textTheme.titleMedium, ), - ), - ], - const SizedBox(width: 10), - if (post.creator!.fullName != null) ...[ - Text( - post.creator!.fullName!, - style: theme.textTheme.titleMedium, + ], + ], ), - ], - - // three small dots at the end - const Spacer(), - const Icon(Icons.more_horiz), - ], - ), + ), + const Spacer(), + widget.options.theme.moreIcon ?? + const Icon( + Icons.more_horiz_rounded, + ), + ], + ), const SizedBox(height: 8), // image of the post if (post.imageUrl != null) ...[ diff --git a/packages/flutter_timeline_view/lib/src/screens/timeline_screen.dart b/packages/flutter_timeline_view/lib/src/screens/timeline_screen.dart index 6d01f82..09409c8 100644 --- a/packages/flutter_timeline_view/lib/src/screens/timeline_screen.dart +++ b/packages/flutter_timeline_view/lib/src/screens/timeline_screen.dart @@ -13,32 +13,47 @@ class TimelineScreen extends StatefulWidget { const TimelineScreen({ required this.userId, required this.options, - required this.posts, required this.onPostTap, required this.service, + this.onUserTap, + this.posts, this.controller, this.timelineCategoryFilter, this.timelinePostHeight = 100.0, + this.padding = const EdgeInsets.symmetric(vertical: 12.0), super.key, }); /// The user id of the current user final String userId; + /// The service to use for fetching and manipulating posts final TimelineService service; + /// All the configuration options for the timelinescreens and widgets final TimelineOptions options; + /// The controller for the scroll view final ScrollController? controller; final String? timelineCategoryFilter; + /// The height of a post in the timeline final double timelinePostHeight; - final List posts; + /// This is used if you want to pass in a list of posts instead + /// of fetching them from the service + final List? posts; + /// Called when a post is tapped final Function(TimelinePost) onPostTap; + /// If this is not null, the user can tap on the user avatar or name + final Function(String userId)? onUserTap; + + /// The padding between posts in the timeline + final EdgeInsets padding; + @override State createState() => _TimelineScreenState(); } @@ -55,7 +70,71 @@ class _TimelineScreenState extends State { unawaited(loadPosts()); } + @override + Widget build(BuildContext context) { + if (isLoading && widget.posts == null) { + // Show loading indicator while data is being fetched + return const Center(child: CircularProgressIndicator()); + } + + var posts = widget.posts ?? this.posts; + posts = posts + .where( + (p) => + widget.timelineCategoryFilter == null || + p.category == widget.timelineCategoryFilter, + ) + .toList(); + + // sort posts by date + posts.sort( + (a, b) => widget.options.sortPostsAscending + ? b.createdAt.compareTo(a.createdAt) + : a.createdAt.compareTo(b.createdAt), + ); + + // Build the list of posts + return SingleChildScrollView( + controller: controller, + child: Column( + children: [ + ...posts.map( + (post) => Padding( + padding: widget.padding, + child: TimelinePostWidget( + userId: widget.userId, + options: widget.options, + post: post, + height: widget.timelinePostHeight, + onTap: () => widget.onPostTap.call(post), + onTapLike: () async => updatePostInList( + await widget.service.likePost(widget.userId, post), + ), + onTapUnlike: () async => updatePostInList( + await widget.service.unlikePost(widget.userId, post), + ), + onUserTap: widget.onUserTap, + ), + ), + ), + if (posts.isEmpty) + Center( + child: Padding( + padding: const EdgeInsets.all(8.0), + child: Text( + widget.timelineCategoryFilter == null + ? widget.options.translations.noPosts + : widget.options.translations.noPostsWithFilter, + ), + ), + ), + ], + ), + ); + } + Future loadPosts() async { + if (widget.posts != null) return; try { // Fetching posts from the service var fetchedPosts = @@ -86,39 +165,4 @@ class _TimelineScreenState extends State { }); } } - - @override - Widget build(BuildContext context) { - if (isLoading) { - // Show loading indicator while data is being fetched - return const Center(child: CircularProgressIndicator()); - } - - // Build the list of posts - return SingleChildScrollView( - controller: controller, - child: Column( - children: posts - .map( - (post) => Padding( - padding: const EdgeInsets.symmetric(vertical: 8.0), - child: TimelinePostWidget( - userId: widget.userId, - options: widget.options, - post: post, - height: widget.timelinePostHeight, - onTap: () => widget.onPostTap.call(post), - onTapLike: () async => updatePostInList( - await widget.service.likePost(widget.userId, post), - ), - onTapUnlike: () async => updatePostInList( - await widget.service.unlikePost(widget.userId, post), - ), - ), - ), - ) - .toList(), - ), - ); - } } diff --git a/packages/flutter_timeline_view/lib/src/widgets/timeline_post_widget.dart b/packages/flutter_timeline_view/lib/src/widgets/timeline_post_widget.dart index 49be26a..4733230 100644 --- a/packages/flutter_timeline_view/lib/src/widgets/timeline_post_widget.dart +++ b/packages/flutter_timeline_view/lib/src/widgets/timeline_post_widget.dart @@ -1,3 +1,7 @@ +// SPDX-FileCopyrightText: 2023 Iconica +// +// SPDX-License-Identifier: BSD-3-Clause + import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/material.dart'; import 'package:flutter_timeline_interface/flutter_timeline_interface.dart'; @@ -12,6 +16,7 @@ class TimelinePostWidget extends StatelessWidget { required this.onTapLike, required this.onTapUnlike, required this.onTap, + this.onUserTap, super.key, }); @@ -25,6 +30,9 @@ class TimelinePostWidget extends StatelessWidget { final VoidCallback onTapLike; final VoidCallback onTapUnlike; + /// If this is not null, the user can tap on the user avatar or name + final Function(String userId)? onUserTap; + @override Widget build(BuildContext context) { var theme = Theme.of(context); @@ -36,34 +44,44 @@ class TimelinePostWidget extends StatelessWidget { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - if (post.creator != null) - Row( - children: [ - if (post.creator!.imageUrl != null) ...[ - options.userAvatarBuilder?.call( - post.creator!, - 40, - ) ?? - CircleAvatar( - radius: 20, - backgroundImage: CachedNetworkImageProvider( - post.creator!.imageUrl!, + Row( + children: [ + if (post.creator != null) + InkWell( + onTap: onUserTap != null + ? () => onUserTap?.call(post.creator!.userId) + : null, + child: Row( + children: [ + if (post.creator!.imageUrl != null) ...[ + options.userAvatarBuilder?.call( + post.creator!, + 40, + ) ?? + CircleAvatar( + radius: 20, + backgroundImage: CachedNetworkImageProvider( + post.creator!.imageUrl!, + ), + ), + ], + const SizedBox(width: 10), + if (post.creator!.fullName != null) ...[ + Text( + post.creator!.fullName!, + style: theme.textTheme.titleMedium, ), - ), - ], - const SizedBox(width: 10), - if (post.creator!.fullName != null) ...[ - Text( - post.creator!.fullName!, - style: theme.textTheme.titleMedium, + ], + ], ), - ], - - // three small dots at the end - const Spacer(), - const Icon(Icons.more_horiz), - ], - ), + ), + const Spacer(), + options.theme.moreIcon ?? + const Icon( + Icons.more_horiz_rounded, + ), + ], + ), const SizedBox(height: 8), // image of the post if (post.imageUrl != null) ...[ From 753ecc039eefd154c9c3cf9c439102fd8bf0ea60 Mon Sep 17 00:00:00 2001 From: Freek van de Ven Date: Tue, 21 Nov 2023 20:02:01 +0100 Subject: [PATCH 14/16] feat: allow post deletion and add changenotifiersystem --- .../service/firebase_timeline_service.dart | 21 ++- .../lib/src/model/timeline_post.dart | 2 +- .../lib/src/services/timeline_service.dart | 3 +- .../lib/src/config/timeline_options.dart | 7 +- .../lib/src/screens/timeline_post_screen.dart | 52 ++++++-- .../lib/src/screens/timeline_screen.dart | 123 ++++++++---------- .../lib/src/widgets/timeline_post_widget.dart | 38 +++++- 7 files changed, 156 insertions(+), 90 deletions(-) diff --git a/packages/flutter_timeline_firebase/lib/src/service/firebase_timeline_service.dart b/packages/flutter_timeline_firebase/lib/src/service/firebase_timeline_service.dart index 77ef4e4..9693f1d 100644 --- a/packages/flutter_timeline_firebase/lib/src/service/firebase_timeline_service.dart +++ b/packages/flutter_timeline_firebase/lib/src/service/firebase_timeline_service.dart @@ -7,11 +7,12 @@ import 'dart:typed_data'; import 'package:cloud_firestore/cloud_firestore.dart'; import 'package:firebase_core/firebase_core.dart'; import 'package:firebase_storage/firebase_storage.dart'; +import 'package:flutter/material.dart'; import 'package:flutter_timeline_firebase/src/config/firebase_timeline_options.dart'; import 'package:flutter_timeline_interface/flutter_timeline_interface.dart'; import 'package:uuid/uuid.dart'; -class FirebaseTimelineService implements TimelineService { +class FirebaseTimelineService with ChangeNotifier implements TimelineService { FirebaseTimelineService({ required TimelineUserService userService, FirebaseApp? app, @@ -41,15 +42,18 @@ class FirebaseTimelineService implements TimelineService { var updatedPost = post.copyWith(imageUrl: imageUrl, id: postId); var postRef = _db.collection(_options.timelineCollectionName).doc(updatedPost.id); - _posts.add(updatedPost); await postRef.set(updatedPost.toJson()); + _posts.add(updatedPost); + notifyListeners(); return updatedPost; } @override Future deletePost(TimelinePost post) async { + _posts = _posts.where((element) => element.id != post.id).toList(); var postRef = _db.collection(_options.timelineCollectionName).doc(post.id); - return postRef.delete(); + await postRef.delete(); + notifyListeners(); } @override @@ -64,11 +68,13 @@ class FirebaseTimelineService implements TimelineService { } var updatedPost = post.copyWith(reactions: updatedReactions); _posts = _posts.map((p) => (p.id == post.id) ? updatedPost : p).toList(); + notifyListeners(); return updatedPost; } @override Future> fetchPosts(String? category) async { + debugPrint('fetching posts from firebase $category!!!'); var snapshot = (category != null) ? await _db .collection(_options.timelineCollectionName) @@ -84,6 +90,7 @@ class FirebaseTimelineService implements TimelineService { posts.add(post); } _posts = posts; + notifyListeners(); return posts; } @@ -109,6 +116,7 @@ class FirebaseTimelineService implements TimelineService { 'likes': FieldValue.increment(1), 'liked_by': FieldValue.arrayUnion([userId]), }); + notifyListeners(); return updatedPost; } @@ -129,6 +137,7 @@ class FirebaseTimelineService implements TimelineService { 'likes': FieldValue.increment(-1), 'liked_by': FieldValue.arrayRemove([userId]), }); + notifyListeners(); return updatedPost; } @@ -161,6 +170,12 @@ class FirebaseTimelineService implements TimelineService { 'reaction': FieldValue.increment(1), 'reactions': FieldValue.arrayUnion([updatedReaction.toJson()]), }); + _posts = _posts + .map( + (p) => p.id == post.id ? updatedPost : p, + ) + .toList(); + notifyListeners(); return updatedPost; } } diff --git a/packages/flutter_timeline_interface/lib/src/model/timeline_post.dart b/packages/flutter_timeline_interface/lib/src/model/timeline_post.dart index 213f728..9b8c3ae 100644 --- a/packages/flutter_timeline_interface/lib/src/model/timeline_post.dart +++ b/packages/flutter_timeline_interface/lib/src/model/timeline_post.dart @@ -137,7 +137,7 @@ class TimelinePost { 'liked_by': likedBy, 'reaction': reaction, // reactions is a list of maps so we need to convert it to a map - 'reactions': reactions?.map((e) => e.toJson()).toList() ?? {}, + 'reactions': reactions?.map((e) => e.toJson()).toList() ?? [], 'created_at': createdAt.toIso8601String(), 'reaction_enabled': reactionEnabled, }; diff --git a/packages/flutter_timeline_interface/lib/src/services/timeline_service.dart b/packages/flutter_timeline_interface/lib/src/services/timeline_service.dart index 8305d1b..6e60f1a 100644 --- a/packages/flutter_timeline_interface/lib/src/services/timeline_service.dart +++ b/packages/flutter_timeline_interface/lib/src/services/timeline_service.dart @@ -4,10 +4,11 @@ import 'dart:typed_data'; +import 'package:flutter/material.dart'; import 'package:flutter_timeline_interface/src/model/timeline_post.dart'; import 'package:flutter_timeline_interface/src/model/timeline_reaction.dart'; -abstract class TimelineService { +abstract class TimelineService with ChangeNotifier { Future deletePost(TimelinePost post); Future createPost(TimelinePost post); Future> fetchPosts(String? category); diff --git a/packages/flutter_timeline_view/lib/src/config/timeline_options.dart b/packages/flutter_timeline_view/lib/src/config/timeline_options.dart index ced3f32..5015244 100644 --- a/packages/flutter_timeline_view/lib/src/config/timeline_options.dart +++ b/packages/flutter_timeline_view/lib/src/config/timeline_options.dart @@ -15,7 +15,8 @@ class TimelineOptions { this.translations = const TimelineTranslations(), this.imagePickerConfig = const ImagePickerConfig(), this.imagePickerTheme = const ImagePickerTheme(), - this.sortCommentsAscending = false, + this.allowAllDeletion = false, + this.sortCommentsAscending = true, this.sortPostsAscending = false, this.dateformat, this.timeFormat, @@ -39,6 +40,10 @@ class TimelineOptions { /// Whether to sort posts ascending or descending final bool sortPostsAscending; + /// Allow all posts to be deleted instead of + /// only the posts of the current user + final bool allowAllDeletion; + final TimelineTranslations translations; final ButtonBuilder? buttonBuilder; diff --git a/packages/flutter_timeline_view/lib/src/screens/timeline_post_screen.dart b/packages/flutter_timeline_view/lib/src/screens/timeline_post_screen.dart index 68ef907..fd6083c 100644 --- a/packages/flutter_timeline_view/lib/src/screens/timeline_post_screen.dart +++ b/packages/flutter_timeline_view/lib/src/screens/timeline_post_screen.dart @@ -20,6 +20,7 @@ class TimelinePostScreen extends StatefulWidget { required this.userService, required this.options, required this.post, + required this.onPostDelete, this.onUserTap, this.padding = const EdgeInsets.symmetric(vertical: 24, horizontal: 16), super.key, @@ -46,6 +47,8 @@ class TimelinePostScreen extends StatefulWidget { /// If this is not null, the user can tap on the user avatar or name final Function(String userId)? onUserTap; + final VoidCallback onPostDelete; + @override State createState() => _TimelinePostScreenState(); } @@ -57,19 +60,19 @@ class _TimelinePostScreenState extends State { @override void initState() { super.initState(); - unawaited(loadPostDetails()); + WidgetsBinding.instance.addPostFrameCallback((_) async { + await loadPostDetails(); + }); } Future loadPostDetails() async { try { - // Assuming fetchPostDetails is an async function returning a TimelinePost var loadedPost = await widget.service.fetchPostDetails(widget.post); setState(() { post = loadedPost; isLoading = false; }); } on Exception catch (e) { - // Handle any errors here debugPrint('Error loading post: $e'); setState(() { isLoading = false; @@ -102,8 +105,8 @@ class _TimelinePostScreenState extends State { var post = this.post!; post.reactions?.sort( (a, b) => widget.options.sortCommentsAscending - ? b.createdAt.compareTo(a.createdAt) - : a.createdAt.compareTo(b.createdAt), + ? a.createdAt.compareTo(b.createdAt) + : b.createdAt.compareTo(a.createdAt), ); return Stack( @@ -146,10 +149,38 @@ class _TimelinePostScreenState extends State { ), ), const Spacer(), - widget.options.theme.moreIcon ?? - const Icon( - Icons.more_horiz_rounded, - ), + if (widget.options.allowAllDeletion || + post.creator?.userId == widget.userId) + PopupMenuButton( + onSelected: (value) async { + if (value == 'delete') { + await widget.service.deletePost(post); + widget.onPostDelete(); + } + }, + itemBuilder: (BuildContext context) => + >[ + PopupMenuItem( + value: 'delete', + child: Row( + children: [ + Text(widget.options.translations.deletePost), + const SizedBox(width: 8), + widget.options.theme.deleteIcon ?? + Icon( + Icons.delete, + color: widget.options.theme.iconColor, + ), + ], + ), + ), + ], + child: widget.options.theme.moreIcon ?? + Icon( + Icons.more_horiz_rounded, + color: widget.options.theme.iconColor, + ), + ), ], ), const SizedBox(height: 8), @@ -202,8 +233,9 @@ class _TimelinePostScreenState extends State { const SizedBox(width: 8), if (post.reactionEnabled) widget.options.theme.commentIcon ?? - const Icon( + Icon( Icons.chat_bubble_outline_rounded, + color: widget.options.theme.iconColor, ), ], ), diff --git a/packages/flutter_timeline_view/lib/src/screens/timeline_screen.dart b/packages/flutter_timeline_view/lib/src/screens/timeline_screen.dart index 09409c8..7b07a72 100644 --- a/packages/flutter_timeline_view/lib/src/screens/timeline_screen.dart +++ b/packages/flutter_timeline_view/lib/src/screens/timeline_screen.dart @@ -60,7 +60,6 @@ class TimelineScreen extends StatefulWidget { class _TimelineScreenState extends State { late ScrollController controller; - late List posts; bool isLoading = true; @override @@ -73,74 +72,74 @@ class _TimelineScreenState extends State { @override Widget build(BuildContext context) { if (isLoading && widget.posts == null) { - // Show loading indicator while data is being fetched return const Center(child: CircularProgressIndicator()); } - var posts = widget.posts ?? this.posts; - posts = posts - .where( - (p) => - widget.timelineCategoryFilter == null || - p.category == widget.timelineCategoryFilter, - ) - .toList(); - - // sort posts by date - posts.sort( - (a, b) => widget.options.sortPostsAscending - ? b.createdAt.compareTo(a.createdAt) - : a.createdAt.compareTo(b.createdAt), - ); - // Build the list of posts - return SingleChildScrollView( - controller: controller, - child: Column( - children: [ - ...posts.map( - (post) => Padding( - padding: widget.padding, - child: TimelinePostWidget( - userId: widget.userId, - options: widget.options, - post: post, - height: widget.timelinePostHeight, - onTap: () => widget.onPostTap.call(post), - onTapLike: () async => updatePostInList( - await widget.service.likePost(widget.userId, post), + return ListenableBuilder( + listenable: widget.service, + builder: (context, _) { + var posts = widget.posts ?? + widget.service.getPosts(widget.timelineCategoryFilter); + posts = posts + .where( + (p) => + widget.timelineCategoryFilter == null || + p.category == widget.timelineCategoryFilter, + ) + .toList(); + + // sort posts by date + posts.sort( + (a, b) => widget.options.sortPostsAscending + ? a.createdAt.compareTo(b.createdAt) + : b.createdAt.compareTo(a.createdAt), + ); + return SingleChildScrollView( + controller: controller, + child: Column( + children: [ + ...posts.map( + (post) => Padding( + padding: widget.padding, + child: TimelinePostWidget( + userId: widget.userId, + options: widget.options, + post: post, + height: widget.timelinePostHeight, + onTap: () => widget.onPostTap.call(post), + onTapLike: () async => + widget.service.likePost(widget.userId, post), + onTapUnlike: () async => + widget.service.unlikePost(widget.userId, post), + onPostDelete: () async => widget.service.deletePost(post), + onUserTap: widget.onUserTap, + ), ), - onTapUnlike: () async => updatePostInList( - await widget.service.unlikePost(widget.userId, post), - ), - onUserTap: widget.onUserTap, ), - ), + if (posts.isEmpty) + Center( + child: Padding( + padding: const EdgeInsets.all(8.0), + child: Text( + widget.timelineCategoryFilter == null + ? widget.options.translations.noPosts + : widget.options.translations.noPostsWithFilter, + ), + ), + ), + ], ), - if (posts.isEmpty) - Center( - child: Padding( - padding: const EdgeInsets.all(8.0), - child: Text( - widget.timelineCategoryFilter == null - ? widget.options.translations.noPosts - : widget.options.translations.noPostsWithFilter, - ), - ), - ), - ], - ), + ); + }, ); } Future loadPosts() async { if (widget.posts != null) return; try { - // Fetching posts from the service - var fetchedPosts = - await widget.service.fetchPosts(widget.timelineCategoryFilter); + await widget.service.fetchPosts(widget.timelineCategoryFilter); setState(() { - posts = fetchedPosts; isLoading = false; }); } on Exception catch (e) { @@ -151,18 +150,4 @@ class _TimelineScreenState extends State { }); } } - - void updatePostInList(TimelinePost updatedPost) { - if (posts.any((p) => p.id == updatedPost.id)) - setState(() { - posts = posts - .map((p) => (p.id == updatedPost.id) ? updatedPost : p) - .toList(); - }); - else { - setState(() { - posts = [updatedPost, ...posts]; - }); - } - } } diff --git a/packages/flutter_timeline_view/lib/src/widgets/timeline_post_widget.dart b/packages/flutter_timeline_view/lib/src/widgets/timeline_post_widget.dart index 4733230..4ee162b 100644 --- a/packages/flutter_timeline_view/lib/src/widgets/timeline_post_widget.dart +++ b/packages/flutter_timeline_view/lib/src/widgets/timeline_post_widget.dart @@ -13,9 +13,10 @@ class TimelinePostWidget extends StatelessWidget { required this.options, required this.post, required this.height, + required this.onTap, required this.onTapLike, required this.onTapUnlike, - required this.onTap, + required this.onPostDelete, this.onUserTap, super.key, }); @@ -29,6 +30,7 @@ class TimelinePostWidget extends StatelessWidget { final VoidCallback onTap; final VoidCallback onTapLike; final VoidCallback onTapUnlike; + final VoidCallback onPostDelete; /// If this is not null, the user can tap on the user avatar or name final Function(String userId)? onUserTap; @@ -76,10 +78,36 @@ class TimelinePostWidget extends StatelessWidget { ), ), const Spacer(), - options.theme.moreIcon ?? - const Icon( - Icons.more_horiz_rounded, - ), + if (options.allowAllDeletion || post.creator?.userId == userId) + PopupMenuButton( + onSelected: (value) { + if (value == 'delete') { + onPostDelete(); + } + }, + itemBuilder: (BuildContext context) => + >[ + PopupMenuItem( + value: 'delete', + child: Row( + children: [ + Text(options.translations.deletePost), + const SizedBox(width: 8), + options.theme.deleteIcon ?? + Icon( + Icons.delete, + color: options.theme.iconColor, + ), + ], + ), + ), + ], + child: options.theme.moreIcon ?? + Icon( + Icons.more_horiz_rounded, + color: options.theme.iconColor, + ), + ), ], ), const SizedBox(height: 8), From f587e81a4b89b90750e1b91021f2663d4e8bb431 Mon Sep 17 00:00:00 2001 From: Freek van de Ven Date: Tue, 21 Nov 2023 23:04:56 +0100 Subject: [PATCH 15/16] feat: add refreshindicator for postdetail --- .../service/firebase_timeline_service.dart | 17 + .../lib/src/services/timeline_service.dart | 1 + .../lib/src/screens/timeline_post_screen.dart | 456 +++++++++--------- 3 files changed, 253 insertions(+), 221 deletions(-) diff --git a/packages/flutter_timeline_firebase/lib/src/service/firebase_timeline_service.dart b/packages/flutter_timeline_firebase/lib/src/service/firebase_timeline_service.dart index 9693f1d..523df2b 100644 --- a/packages/flutter_timeline_firebase/lib/src/service/firebase_timeline_service.dart +++ b/packages/flutter_timeline_firebase/lib/src/service/firebase_timeline_service.dart @@ -94,6 +94,23 @@ class FirebaseTimelineService with ChangeNotifier implements TimelineService { return posts; } + @override + Future fetchPost(TimelinePost post) async { + var doc = await _db + .collection(_options.timelineCollectionName) + .doc(post.id) + .get(); + var data = doc.data(); + if (data == null) return post; + var user = await _userService.getUser(data['creator_id']); + var updatedPost = TimelinePost.fromJson(doc.id, data).copyWith( + creator: user, + ); + _posts = _posts.map((p) => (p.id == post.id) ? updatedPost : p).toList(); + notifyListeners(); + return updatedPost; + } + @override List getPosts(String? category) => _posts .where((element) => category == null || element.category == category) diff --git a/packages/flutter_timeline_interface/lib/src/services/timeline_service.dart b/packages/flutter_timeline_interface/lib/src/services/timeline_service.dart index 6e60f1a..feb456f 100644 --- a/packages/flutter_timeline_interface/lib/src/services/timeline_service.dart +++ b/packages/flutter_timeline_interface/lib/src/services/timeline_service.dart @@ -12,6 +12,7 @@ abstract class TimelineService with ChangeNotifier { Future deletePost(TimelinePost post); Future createPost(TimelinePost post); Future> fetchPosts(String? category); + Future fetchPost(TimelinePost post); List getPosts(String? category); Future fetchPostDetails(TimelinePost post); Future reactToPost( diff --git a/packages/flutter_timeline_view/lib/src/screens/timeline_post_screen.dart b/packages/flutter_timeline_view/lib/src/screens/timeline_post_screen.dart index fd6083c..46a4444 100644 --- a/packages/flutter_timeline_view/lib/src/screens/timeline_post_screen.dart +++ b/packages/flutter_timeline_view/lib/src/screens/timeline_post_screen.dart @@ -111,240 +111,254 @@ class _TimelinePostScreenState extends State { return Stack( children: [ - SingleChildScrollView( - child: Padding( - padding: widget.padding, - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - if (post.creator != null) - InkWell( - onTap: widget.onUserTap != null - ? () => widget.onUserTap?.call(post.creator!.userId) - : null, - child: Row( - children: [ - if (post.creator!.imageUrl != null) ...[ - widget.options.userAvatarBuilder?.call( - post.creator!, - 40, - ) ?? - CircleAvatar( - radius: 20, - backgroundImage: CachedNetworkImageProvider( - post.creator!.imageUrl!, - ), - ), - ], - const SizedBox(width: 10), - if (post.creator!.fullName != null) ...[ - Text( - post.creator!.fullName!, - style: theme.textTheme.titleMedium, - ), - ], - ], - ), - ), - const Spacer(), - if (widget.options.allowAllDeletion || - post.creator?.userId == widget.userId) - PopupMenuButton( - onSelected: (value) async { - if (value == 'delete') { - await widget.service.deletePost(post); - widget.onPostDelete(); - } - }, - itemBuilder: (BuildContext context) => - >[ - PopupMenuItem( - value: 'delete', - child: Row( - children: [ - Text(widget.options.translations.deletePost), - const SizedBox(width: 8), - widget.options.theme.deleteIcon ?? - Icon( - Icons.delete, - color: widget.options.theme.iconColor, + RefreshIndicator( + onRefresh: () async { + updatePost( + await widget.service.fetchPostDetails( + await widget.service.fetchPost( + post, + ), + ), + ); + }, + child: SingleChildScrollView( + child: Padding( + padding: widget.padding, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + if (post.creator != null) + InkWell( + onTap: widget.onUserTap != null + ? () => + widget.onUserTap?.call(post.creator!.userId) + : null, + child: Row( + children: [ + if (post.creator!.imageUrl != null) ...[ + widget.options.userAvatarBuilder?.call( + post.creator!, + 40, + ) ?? + CircleAvatar( + radius: 20, + backgroundImage: + CachedNetworkImageProvider( + post.creator!.imageUrl!, + ), ), ], - ), - ), - ], - child: widget.options.theme.moreIcon ?? - Icon( - Icons.more_horiz_rounded, - color: widget.options.theme.iconColor, - ), - ), - ], - ), - const SizedBox(height: 8), - // image of the post - if (post.imageUrl != null) ...[ - CachedNetworkImage( - imageUrl: post.imageUrl!, - width: double.infinity, - fit: BoxFit.fitHeight, - ), - ], - // post information - Padding( - padding: const EdgeInsets.symmetric(vertical: 4.0), - child: Row( - children: [ - if (post.likedBy?.contains(widget.userId) ?? false) ...[ - InkWell( - onTap: () async { - updatePost( - await widget.service.unlikePost( - widget.userId, - post, - ), - ); - }, - child: widget.options.theme.likedIcon ?? - Icon( - Icons.thumb_up_rounded, - color: widget.options.theme.iconColor, - ), - ), - ] else ...[ - InkWell( - onTap: () async { - updatePost( - await widget.service.likePost( - widget.userId, - post, - ), - ); - }, - child: widget.options.theme.likeIcon ?? - Icon( - Icons.thumb_up_alt_outlined, - color: widget.options.theme.iconColor, - ), - ), - ], - const SizedBox(width: 8), - if (post.reactionEnabled) - widget.options.theme.commentIcon ?? - Icon( - Icons.chat_bubble_outline_rounded, - color: widget.options.theme.iconColor, - ), - ], - ), - ), - Text( - '${post.likes} ${widget.options.translations.likesTitle}', - style: theme.textTheme.titleSmall, - ), - const SizedBox(height: 4), - Text.rich( - TextSpan( - text: post.creator?.fullName ?? - widget.options.translations.anonymousUser, - style: theme.textTheme.titleSmall, - children: [ - const TextSpan(text: ' '), - TextSpan( - text: post.title, - style: theme.textTheme.bodyMedium, - ), - ], - ), - overflow: TextOverflow.ellipsis, - ), - const SizedBox(height: 4), - Text( - post.content, - style: theme.textTheme.bodyMedium, - ), - const SizedBox(height: 4), - Text( - '${dateFormat.format(post.createdAt)} ' - '${widget.options.translations.postAt} ' - '${timeFormat.format(post.createdAt)}', - style: theme.textTheme.bodySmall, - ), - const SizedBox(height: 12), - if (post.reactionEnabled) ...[ - Text( - widget.options.translations.commentsTitle, - style: theme.textTheme.displaySmall, - ), - for (var reaction - in post.reactions ?? []) ...[ - const SizedBox(height: 16), - Row( - crossAxisAlignment: reaction.imageUrl != null - ? CrossAxisAlignment.start - : CrossAxisAlignment.center, - children: [ - if (reaction.creator?.imageUrl != null && - reaction.creator!.imageUrl!.isNotEmpty) ...[ - widget.options.userAvatarBuilder?.call( - reaction.creator!, - 25, - ) ?? - CircleAvatar( - radius: 20, - backgroundImage: CachedNetworkImageProvider( - reaction.creator!.imageUrl!, - ), - ), - ], - const SizedBox(width: 10), - if (reaction.imageUrl != null) ...[ - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ + const SizedBox(width: 10), + if (post.creator!.fullName != null) ...[ Text( - reaction.creator?.fullName ?? - widget.options.translations.anonymousUser, - style: theme.textTheme.titleSmall, - ), - Padding( - padding: const EdgeInsets.all(8.0), - child: CachedNetworkImage( - imageUrl: reaction.imageUrl!, - fit: BoxFit.fitWidth, - ), + post.creator!.fullName!, + style: theme.textTheme.titleMedium, ), ], - ), + ], ), - ] else ...[ - Expanded( - child: Text.rich( - TextSpan( - text: reaction.creator?.fullName ?? - widget.options.translations.anonymousUser, - style: theme.textTheme.titleSmall, + ), + const Spacer(), + if (widget.options.allowAllDeletion || + post.creator?.userId == widget.userId) + PopupMenuButton( + onSelected: (value) async { + if (value == 'delete') { + await widget.service.deletePost(post); + widget.onPostDelete(); + } + }, + itemBuilder: (BuildContext context) => + >[ + PopupMenuItem( + value: 'delete', + child: Row( children: [ - const TextSpan(text: ' '), - TextSpan( - text: reaction.reaction ?? '', - style: theme.textTheme.bodyMedium, - ), - // text should go to new line + Text(widget.options.translations.deletePost), + const SizedBox(width: 8), + widget.options.theme.deleteIcon ?? + Icon( + Icons.delete, + color: widget.options.theme.iconColor, + ), ], ), ), - ), - ], - ], + ], + child: widget.options.theme.moreIcon ?? + Icon( + Icons.more_horiz_rounded, + color: widget.options.theme.iconColor, + ), + ), + ], + ), + const SizedBox(height: 8), + // image of the post + if (post.imageUrl != null) ...[ + CachedNetworkImage( + imageUrl: post.imageUrl!, + width: double.infinity, + fit: BoxFit.fitHeight, ), ], - const SizedBox(height: 120), + // post information + Padding( + padding: const EdgeInsets.symmetric(vertical: 4.0), + child: Row( + children: [ + if (post.likedBy?.contains(widget.userId) ?? false) ...[ + InkWell( + onTap: () async { + updatePost( + await widget.service.unlikePost( + widget.userId, + post, + ), + ); + }, + child: widget.options.theme.likedIcon ?? + Icon( + Icons.thumb_up_rounded, + color: widget.options.theme.iconColor, + ), + ), + ] else ...[ + InkWell( + onTap: () async { + updatePost( + await widget.service.likePost( + widget.userId, + post, + ), + ); + }, + child: widget.options.theme.likeIcon ?? + Icon( + Icons.thumb_up_alt_outlined, + color: widget.options.theme.iconColor, + ), + ), + ], + const SizedBox(width: 8), + if (post.reactionEnabled) + widget.options.theme.commentIcon ?? + Icon( + Icons.chat_bubble_outline_rounded, + color: widget.options.theme.iconColor, + ), + ], + ), + ), + Text( + '${post.likes} ${widget.options.translations.likesTitle}', + style: theme.textTheme.titleSmall, + ), + const SizedBox(height: 4), + Text.rich( + TextSpan( + text: post.creator?.fullName ?? + widget.options.translations.anonymousUser, + style: theme.textTheme.titleSmall, + children: [ + const TextSpan(text: ' '), + TextSpan( + text: post.title, + style: theme.textTheme.bodyMedium, + ), + ], + ), + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 4), + Text( + post.content, + style: theme.textTheme.bodyMedium, + ), + const SizedBox(height: 4), + Text( + '${dateFormat.format(post.createdAt)} ' + '${widget.options.translations.postAt} ' + '${timeFormat.format(post.createdAt)}', + style: theme.textTheme.bodySmall, + ), + const SizedBox(height: 12), + if (post.reactionEnabled) ...[ + Text( + widget.options.translations.commentsTitle, + style: theme.textTheme.displaySmall, + ), + for (var reaction + in post.reactions ?? []) ...[ + const SizedBox(height: 16), + Row( + crossAxisAlignment: reaction.imageUrl != null + ? CrossAxisAlignment.start + : CrossAxisAlignment.center, + children: [ + if (reaction.creator?.imageUrl != null && + reaction.creator!.imageUrl!.isNotEmpty) ...[ + widget.options.userAvatarBuilder?.call( + reaction.creator!, + 25, + ) ?? + CircleAvatar( + radius: 20, + backgroundImage: CachedNetworkImageProvider( + reaction.creator!.imageUrl!, + ), + ), + ], + const SizedBox(width: 10), + if (reaction.imageUrl != null) ...[ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + reaction.creator?.fullName ?? + widget + .options.translations.anonymousUser, + style: theme.textTheme.titleSmall, + ), + Padding( + padding: const EdgeInsets.all(8.0), + child: CachedNetworkImage( + imageUrl: reaction.imageUrl!, + fit: BoxFit.fitWidth, + ), + ), + ], + ), + ), + ] else ...[ + Expanded( + child: Text.rich( + TextSpan( + text: reaction.creator?.fullName ?? + widget.options.translations.anonymousUser, + style: theme.textTheme.titleSmall, + children: [ + const TextSpan(text: ' '), + TextSpan( + text: reaction.reaction ?? '', + style: theme.textTheme.bodyMedium, + ), + // text should go to new line + ], + ), + ), + ), + ], + ], + ), + ], + const SizedBox(height: 120), + ], ], - ], + ), ), ), ), From a16c77be0ebb76b8f4ec9d148cad0809d4067519 Mon Sep 17 00:00:00 2001 From: Freek van de Ven Date: Tue, 21 Nov 2023 23:19:48 +0100 Subject: [PATCH 16/16] feat: make postheight optional --- .../src/service/firebase_timeline_service.dart | 15 +++++++++------ .../screens/timeline_post_creation_screen.dart | 5 ++--- .../lib/src/screens/timeline_post_screen.dart | 6 ++++++ .../lib/src/screens/timeline_screen.dart | 4 ++-- .../lib/src/widgets/timeline_post_widget.dart | 8 ++++++-- 5 files changed, 25 insertions(+), 13 deletions(-) diff --git a/packages/flutter_timeline_firebase/lib/src/service/firebase_timeline_service.dart b/packages/flutter_timeline_firebase/lib/src/service/firebase_timeline_service.dart index 523df2b..9a8248a 100644 --- a/packages/flutter_timeline_firebase/lib/src/service/firebase_timeline_service.dart +++ b/packages/flutter_timeline_firebase/lib/src/service/firebase_timeline_service.dart @@ -35,11 +35,14 @@ class FirebaseTimelineService with ChangeNotifier implements TimelineService { @override Future createPost(TimelinePost post) async { var postId = const Uuid().v4(); - var imageRef = - _storage.ref().child('${_options.timelineCollectionName}/$postId'); - var result = await imageRef.putData(post.image!); - var imageUrl = await result.ref.getDownloadURL(); - var updatedPost = post.copyWith(imageUrl: imageUrl, id: postId); + var updatedPost = post.copyWith(id: postId); + if (post.image != null) { + var imageRef = + _storage.ref().child('${_options.timelineCollectionName}/$postId'); + var result = await imageRef.putData(post.image!); + var imageUrl = await result.ref.getDownloadURL(); + updatedPost = updatedPost.copyWith(imageUrl: imageUrl); + } var postRef = _db.collection(_options.timelineCollectionName).doc(updatedPost.id); await postRef.set(updatedPost.toJson()); @@ -74,7 +77,7 @@ class FirebaseTimelineService with ChangeNotifier implements TimelineService { @override Future> fetchPosts(String? category) async { - debugPrint('fetching posts from firebase $category!!!'); + debugPrint('fetching posts from firebase with category: $category'); var snapshot = (category != null) ? await _db .collection(_options.timelineCollectionName) diff --git a/packages/flutter_timeline_view/lib/src/screens/timeline_post_creation_screen.dart b/packages/flutter_timeline_view/lib/src/screens/timeline_post_creation_screen.dart index 44a2c71..19c639f 100644 --- a/packages/flutter_timeline_view/lib/src/screens/timeline_post_creation_screen.dart +++ b/packages/flutter_timeline_view/lib/src/screens/timeline_post_creation_screen.dart @@ -66,9 +66,8 @@ class _TimelinePostCreationScreenState void checkIfEditingDone() { setState(() { - editingDone = titleController.text.isNotEmpty && - contentController.text.isNotEmpty && - image != null; + editingDone = + titleController.text.isNotEmpty && contentController.text.isNotEmpty; }); } diff --git a/packages/flutter_timeline_view/lib/src/screens/timeline_post_screen.dart b/packages/flutter_timeline_view/lib/src/screens/timeline_post_screen.dart index 46a4444..ff77b5f 100644 --- a/packages/flutter_timeline_view/lib/src/screens/timeline_post_screen.dart +++ b/packages/flutter_timeline_view/lib/src/screens/timeline_post_screen.dart @@ -355,6 +355,12 @@ class _TimelinePostScreenState extends State { ], ), ], + if (post.reactions?.isEmpty ?? true) ...[ + const SizedBox(height: 16), + Text( + widget.options.translations.firstComment, + ), + ], const SizedBox(height: 120), ], ], diff --git a/packages/flutter_timeline_view/lib/src/screens/timeline_screen.dart b/packages/flutter_timeline_view/lib/src/screens/timeline_screen.dart index 7b07a72..85a60a2 100644 --- a/packages/flutter_timeline_view/lib/src/screens/timeline_screen.dart +++ b/packages/flutter_timeline_view/lib/src/screens/timeline_screen.dart @@ -19,7 +19,7 @@ class TimelineScreen extends StatefulWidget { this.posts, this.controller, this.timelineCategoryFilter, - this.timelinePostHeight = 100.0, + this.timelinePostHeight, this.padding = const EdgeInsets.symmetric(vertical: 12.0), super.key, }); @@ -39,7 +39,7 @@ class TimelineScreen extends StatefulWidget { final String? timelineCategoryFilter; /// The height of a post in the timeline - final double timelinePostHeight; + final double? timelinePostHeight; /// This is used if you want to pass in a list of posts instead /// of fetching them from the service diff --git a/packages/flutter_timeline_view/lib/src/widgets/timeline_post_widget.dart b/packages/flutter_timeline_view/lib/src/widgets/timeline_post_widget.dart index 4ee162b..8bb570f 100644 --- a/packages/flutter_timeline_view/lib/src/widgets/timeline_post_widget.dart +++ b/packages/flutter_timeline_view/lib/src/widgets/timeline_post_widget.dart @@ -26,7 +26,9 @@ class TimelinePostWidget extends StatelessWidget { final TimelineOptions options; final TimelinePost post; - final double height; + + /// Optional max height of the post + final double? height; final VoidCallback onTap; final VoidCallback onTapLike; final VoidCallback onTapUnlike; @@ -41,7 +43,8 @@ class TimelinePostWidget extends StatelessWidget { return InkWell( onTap: onTap, child: SizedBox( - height: height, + // TODO(anyone): should posts with text have a max height? + height: post.imageUrl != null ? height : null, width: double.infinity, child: Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -114,6 +117,7 @@ class TimelinePostWidget extends StatelessWidget { // image of the post if (post.imageUrl != null) ...[ Flexible( + flex: height != null ? 1 : 0, child: CachedNetworkImage( imageUrl: post.imageUrl!, width: double.infinity,