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/.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..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 flutter format . --fix + run: dart run melos exec dart format . description: Run `flutter format` for all packages. format-check: - run: melos exec flutter format . --set-exit-if-changed + run: dart run melos exec dart format . --set-exit-if-changed description: Run `flutter format` checks for all packages. 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_firebase/lib/flutter_timeline_firebase.dart b/packages/flutter_timeline_firebase/lib/flutter_timeline_firebase.dart index 71f31b5..9ad1f86 100644 --- a/packages/flutter_timeline_firebase/lib/flutter_timeline_firebase.dart +++ b/packages/flutter_timeline_firebase/lib/flutter_timeline_firebase.dart @@ -2,4 +2,9 @@ // // SPDX-License-Identifier: BSD-3-Clause +/// 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/src/config/firebase_timeline_options.dart b/packages/flutter_timeline_firebase/lib/src/config/firebase_timeline_options.dart new file mode 100644 index 0000000..6acd32a --- /dev/null +++ b/packages/flutter_timeline_firebase/lib/src/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/src/models/firebase_user_document.dart b/packages/flutter_timeline_firebase/lib/src/models/firebase_user_document.dart new file mode 100644 index 0000000..19fe9c9 --- /dev/null +++ b/packages/flutter_timeline_firebase/lib/src/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/src/service/firebase_timeline_service.dart b/packages/flutter_timeline_firebase/lib/src/service/firebase_timeline_service.dart new file mode 100644 index 0000000..03b76a9 --- /dev/null +++ b/packages/flutter_timeline_firebase/lib/src/service/firebase_timeline_service.dart @@ -0,0 +1,202 @@ +// 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/src/config/firebase_timeline_options.dart'; +import 'package:flutter_timeline_interface/flutter_timeline_interface.dart'; +import 'package:uuid/uuid.dart'; + +class FirebaseTimelineService with ChangeNotifier 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 postId = const Uuid().v4(); + var user = await _userService.getUser(post.creatorId); + var updatedPost = post.copyWith(id: postId, creator: user); + 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()); + _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); + await postRef.delete(); + notifyListeners(); + } + + @override + Future fetchPostDetails(TimelinePost post) async { + 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(); + notifyListeners(); + return updatedPost; + } + + @override + Future> fetchPosts(String? category) async { + debugPrint('fetching posts from firebase with category: $category'); + 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['creator_id']); + var post = TimelinePost.fromJson(doc.id, data).copyWith(creator: user); + posts.add(post); + } + _posts = posts; + notifyListeners(); + 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) + .toList(); + + @override + 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 ? updatedPost : p, + ) + .toList(); + var postRef = _db.collection(_options.timelineCollectionName).doc(post.id); + await postRef.update({ + 'likes': FieldValue.increment(1), + 'liked_by': FieldValue.arrayUnion([userId]), + }); + notifyListeners(); + return updatedPost; + } + + @override + 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 ? updatedPost : p, + ) + .toList(); + var postRef = _db.collection(_options.timelineCollectionName).doc(post.id); + await postRef.update({ + 'likes': FieldValue.increment(-1), + 'liked_by': FieldValue.arrayRemove([userId]), + }); + notifyListeners(); + return updatedPost; + } + + @override + Future reactToPost( + TimelinePost post, + TimelinePostReaction reaction, { + Uint8List? image, + }) 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); + await postRef.update({ + '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_firebase/lib/src/service/firebase_user_service.dart b/packages/flutter_timeline_firebase/lib/src/service/firebase_user_service.dart new file mode 100644 index 0000000..fb1da17 --- /dev/null +++ b/packages/flutter_timeline_firebase/lib/src/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/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 { + 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_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/flutter_timeline_interface.dart b/packages/flutter_timeline_interface/lib/flutter_timeline_interface.dart index 70c4091..6004105 100644 --- a/packages/flutter_timeline_interface/lib/flutter_timeline_interface.dart +++ b/packages/flutter_timeline_interface/lib/flutter_timeline_interface.dart @@ -1,5 +1,12 @@ // SPDX-FileCopyrightText: 2023 Iconica // // SPDX-License-Identifier: BSD-3-Clause - +/// 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 new file mode 100644 index 0000000..9b8c3ae --- /dev/null +++ b/packages/flutter_timeline_interface/lib/src/model/timeline_post.dart @@ -0,0 +1,144 @@ +// SPDX-FileCopyrightText: 2023 Iconica +// +// 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'; + +/// 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.creator, + 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 List?) + ?.map( + (e) => TimelinePostReaction.fromJson( + (e as Map).keys.first, + id, + e.values.first as Map, + ), + ) + .toList(), + createdAt: DateTime.parse(json['created_at'] as String), + reactionEnabled: json['reaction_enabled'] as bool, + ); + + /// The unique identifier of the post. + final String id; + + /// 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; + + /// 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 image of the post used for uploading. + final Uint8List? image; + + /// 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; + + 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 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_poster.dart b/packages/flutter_timeline_interface/lib/src/model/timeline_poster.dart new file mode 100644 index 0000000..c652508 --- /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.userId, + this.firstName, + this.lastName, + this.imageUrl, + }); + + final String userId; + 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 new file mode 100644 index 0000000..0b8b231 --- /dev/null +++ b/packages/flutter_timeline_interface/lib/src/model/timeline_reaction.dart @@ -0,0 +1,82 @@ +// 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'; + +@immutable +class TimelinePostReaction { + const TimelinePostReaction({ + required this.id, + required this.postId, + required this.creatorId, + required this.createdAt, + this.reaction, + this.imageUrl, + 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; + + /// 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 creator of the post. If null it isn't loaded yet. + final TimelinePosterUserModel? creator; + + /// 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; + + 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, + '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 new file mode 100644 index 0000000..feb456f --- /dev/null +++ b/packages/flutter_timeline_interface/lib/src/services/timeline_service.dart @@ -0,0 +1,25 @@ +// SPDX-FileCopyrightText: 2023 Iconica +// +// SPDX-License-Identifier: BSD-3-Clause + +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 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( + TimelinePost post, + TimelinePostReaction reaction, { + Uint8List image, + }); + 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 new file mode 100644 index 0000000..0fbf1d4 --- /dev/null +++ b/packages/flutter_timeline_interface/lib/src/services/user_service.dart @@ -0,0 +1,9 @@ +// SPDX-FileCopyrightText: 2023 Iconica +// +// SPDX-License-Identifier: BSD-3-Clause + +import 'package:flutter_timeline_interface/src/model/timeline_poster.dart'; + +mixin TimelineUserService { + Future getUser(String userId); +} 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..568731b --- /dev/null +++ b/packages/flutter_timeline_view/example/lib/main.dart @@ -0,0 +1,49 @@ +// SPDX-FileCopyrightText: 2023 Iconica +// +// SPDX-License-Identifier: BSD-3-Clause + +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); + }); +} diff --git a/packages/flutter_timeline_view/lib/flutter_timeline_view.dart b/packages/flutter_timeline_view/lib/flutter_timeline_view.dart index 3ea67db..196be36 100644 --- a/packages/flutter_timeline_view/lib/flutter_timeline_view.dart +++ b/packages/flutter_timeline_view/lib/flutter_timeline_view.dart @@ -1,5 +1,13 @@ // SPDX-FileCopyrightText: 2023 Iconica // // SPDX-License-Identifier: BSD-3-Clause - +/// 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 new file mode 100644 index 0000000..5015244 --- /dev/null +++ b/packages/flutter_timeline_view/lib/src/config/timeline_options.dart @@ -0,0 +1,80 @@ +// 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_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.allowAllDeletion = false, + this.sortCommentsAscending = true, + this.sortPostsAscending = false, + this.dateformat, + this.timeFormat, + this.buttonBuilder, + this.textInputBuilder, + this.userAvatarBuilder, + }); + + /// 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; + + /// Whether to sort comments ascending or descending + final bool sortCommentsAscending; + + /// 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; + + 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; + + /// 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, +); + +typedef UserAvatarBuilder = Widget Function( + TimelinePosterUserModel user, + double size, +); 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..d880031 --- /dev/null +++ b/packages/flutter_timeline_view/lib/src/config/timeline_theme.dart @@ -0,0 +1,38 @@ +// SPDX-FileCopyrightText: 2023 Iconica +// +// SPDX-License-Identifier: BSD-3-Clause + +import 'package:flutter/material.dart'; + +@immutable +class TimelineTheme { + const TimelineTheme({ + this.iconColor, + this.likeIcon, + this.commentIcon, + this.likedIcon, + this.sendIcon, + this.moreIcon, + this.deleteIcon, + }); + + 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; + + /// 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/config/timeline_translations.dart b/packages/flutter_timeline_view/lib/src/config/timeline_translations.dart new file mode 100644 index 0000000..c24f96f --- /dev/null +++ b/packages/flutter_timeline_view/lib/src/config/timeline_translations.dart @@ -0,0 +1,53 @@ +// SPDX-FileCopyrightText: 2023 Iconica +// +// SPDX-License-Identifier: BSD-3-Clause + +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?', + 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.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; + final String uploadImage; + final String uploadImageDescription; + final String allowComments; + final String allowCommentsDescription; + final String checkPost; + final String postAt; + + final String deletePost; + final String viewPost; + 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_creation_screen.dart b/packages/flutter_timeline_view/lib/src/screens/timeline_post_creation_screen.dart new file mode 100644 index 0000000..19c639f --- /dev/null +++ b/packages/flutter_timeline_view/lib/src/screens/timeline_post_creation_screen.dart @@ -0,0 +1,264 @@ +// SPDX-FileCopyrightText: 2023 Iconica +// +// SPDX-License-Identifier: BSD-3-Clause + +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; + + /// The padding around the screen + final EdgeInsets padding; + + @override + 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; + }); + } + + @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, + 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 + ), + ) + : DottedBorder( + radius: const Radius.circular(8.0), + color: theme.textTheme.displayMedium?.color ?? + Colors.white, + 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, + onPostCreated, + widget.options.translations.checkPost, + enabled: editingDone, + ) + : ElevatedButton( + 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_post_screen.dart b/packages/flutter_timeline_view/lib/src/screens/timeline_post_screen.dart new file mode 100644 index 0000000..ff77b5f --- /dev/null +++ b/packages/flutter_timeline_view/lib/src/screens/timeline_post_screen.dart @@ -0,0 +1,423 @@ +// SPDX-FileCopyrightText: 2023 Iconica +// +// 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 StatefulWidget { + const TimelinePostScreen({ + required this.userId, + required this.service, + 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, + }); + + /// 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; + + /// 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(); +} + +class _TimelinePostScreenState extends State { + TimelinePost? post; + bool isLoading = true; + + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addPostFrameCallback((_) async { + await loadPostDetails(); + }); + } + + Future loadPostDetails() async { + try { + var loadedPost = await widget.service.fetchPostDetails(widget.post); + setState(() { + post = loadedPost; + isLoading = false; + }); + } on Exception catch (e) { + 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 = widget.options.dateformat ?? + DateFormat('dd/MM/yyyy', Localizations.localeOf(context).languageCode); + 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 + ? a.createdAt.compareTo(b.createdAt) + : b.createdAt.compareTo(a.createdAt), + ); + + return Stack( + children: [ + 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!, + ), + ), + ], + 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, + ), + ], + ), + ), + ], + 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: [ + 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 + ], + ), + ), + ), + ], + ], + ), + ], + if (post.reactions?.isEmpty ?? true) ...[ + const SizedBox(height: 16), + Text( + widget.options.translations.firstComment, + ), + ], + const SizedBox(height: 120), + ], + ], + ), + ), + ), + ), + 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, + ), + ), + ], + ); + } +} 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..85a60a2 --- /dev/null +++ b/packages/flutter_timeline_view/lib/src/screens/timeline_screen.dart @@ -0,0 +1,153 @@ +// SPDX-FileCopyrightText: 2023 Iconica +// +// 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'; +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.onPostTap, + required this.service, + this.onUserTap, + this.posts, + this.controller, + this.timelineCategoryFilter, + this.timelinePostHeight, + 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; + + /// 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(); +} + +class _TimelineScreenState extends State { + late ScrollController controller; + bool isLoading = true; + + @override + void initState() { + super.initState(); + controller = widget.controller ?? ScrollController(); + unawaited(loadPosts()); + } + + @override + Widget build(BuildContext context) { + if (isLoading && widget.posts == null) { + return const Center(child: CircularProgressIndicator()); + } + + // Build the list of posts + 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, + ), + ), + ), + 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 { + await widget.service.fetchPosts(widget.timelineCategoryFilter); + setState(() { + isLoading = false; + }); + } on Exception catch (e) { + // Handle errors here + debugPrint('Error loading posts: $e'); + setState(() { + isLoading = 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 new file mode 100644 index 0000000..8bb570f --- /dev/null +++ b/packages/flutter_timeline_view/lib/src/widgets/timeline_post_widget.dart @@ -0,0 +1,190 @@ +// 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'; +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, + required this.onTap, + required this.onTapLike, + required this.onTapUnlike, + required this.onPostDelete, + this.onUserTap, + super.key, + }); + + /// The user id of the current user + final String userId; + final TimelineOptions options; + + final TimelinePost post; + + /// Optional max height of the post + final double? height; + 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; + + @override + Widget build(BuildContext context) { + var theme = Theme.of(context); + return InkWell( + onTap: onTap, + child: SizedBox( + // TODO(anyone): should posts with text have a max height? + height: post.imageUrl != null ? height : null, + width: double.infinity, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + 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 Spacer(), + 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), + // image of the post + if (post.imageUrl != null) ...[ + Flexible( + flex: height != null ? 1 : 0, + child: CachedNetworkImage( + imageUrl: post.imageUrl!, + width: double.infinity, + fit: BoxFit.fitWidth, + ), + ), + ], + // post information + 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, + ), + 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, + style: theme.textTheme.bodySmall, + ), + ], + ), + ), + ); + } +} diff --git a/packages/flutter_timeline_view/pubspec.yaml b/packages/flutter_timeline_view/pubspec.yaml index 470fe67..956c698 100644 --- a/packages/flutter_timeline_view/pubspec.yaml +++ b/packages/flutter_timeline_view/pubspec.yaml @@ -14,11 +14,19 @@ environment: dependencies: flutter: 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 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