mirror of
https://github.com/Iconica-Development/flutter_timeline.git
synced 2025-05-19 10:33:44 +02:00
Merge pull request #1 from Iconica-Development/0.0.1
First version of flutter_timeline
This commit is contained in:
commit
b3de42f308
32 changed files with 2105 additions and 11 deletions
12
.github/workflows/melos-component-ci.yml
vendored
Normal file
12
.github/workflows/melos-component-ci.yml
vendored
Normal file
|
@ -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
|
8
.gitignore
vendored
8
.gitignore
vendored
|
@ -43,3 +43,11 @@ packages/flutter_timeline_interface/pubspec.lock
|
||||||
packages/flutter_timeline_view/pubspec.lock
|
packages/flutter_timeline_view/pubspec.lock
|
||||||
|
|
||||||
pubspec_overrides.yaml
|
pubspec_overrides.yaml
|
||||||
|
|
||||||
|
**/example/android
|
||||||
|
**/example/ios
|
||||||
|
**/example/linux
|
||||||
|
**/example/macos
|
||||||
|
**/example/windows
|
||||||
|
**/example/web
|
||||||
|
**/example/README.md
|
16
melos.yaml
16
melos.yaml
|
@ -13,31 +13,31 @@ command:
|
||||||
|
|
||||||
scripts:
|
scripts:
|
||||||
lint:all:
|
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.
|
description: Run all static analysis checks.
|
||||||
|
|
||||||
get:
|
get:
|
||||||
run: |
|
run: |
|
||||||
melos exec -c 1 -- "flutter pub get"
|
dart run melos exec -c 1 -- "flutter pub get"
|
||||||
melos exec --scope="*example*" -c 1 -- "flutter pub get"
|
dart run melos exec --scope="*example*" -c 1 -- "flutter pub get"
|
||||||
|
|
||||||
upgrade:
|
upgrade:
|
||||||
run: melos exec -c 1 -- "flutter pub upgrade"
|
run: dart run melos exec -c 1 -- "flutter pub upgrade"
|
||||||
|
|
||||||
create:
|
create:
|
||||||
# run create in the example folder of flutter_timeline_view
|
# 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:
|
analyze:
|
||||||
run: |
|
run: |
|
||||||
melos exec -c 1 -- \
|
dart run melos exec -c 1 -- \
|
||||||
flutter analyze --fatal-infos
|
flutter analyze --fatal-infos
|
||||||
description: Run `flutter analyze` for all packages.
|
description: Run `flutter analyze` for all packages.
|
||||||
|
|
||||||
format:
|
format:
|
||||||
run: melos exec flutter format . --fix
|
run: dart run melos exec dart format .
|
||||||
description: Run `flutter format` for all packages.
|
description: Run `flutter format` for all packages.
|
||||||
|
|
||||||
format-check:
|
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.
|
description: Run `flutter format` checks for all packages.
|
||||||
|
|
|
@ -1,5 +1,8 @@
|
||||||
// SPDX-FileCopyrightText: 2023 Iconica
|
// SPDX-FileCopyrightText: 2023 Iconica
|
||||||
//
|
//
|
||||||
// SPDX-License-Identifier: BSD-3-Clause
|
// SPDX-License-Identifier: BSD-3-Clause
|
||||||
|
/// Flutter Timeline library
|
||||||
library flutter_timeline;
|
library flutter_timeline;
|
||||||
|
|
||||||
|
export 'package:flutter_timeline_interface/flutter_timeline_interface.dart';
|
||||||
|
export 'package:flutter_timeline_view/flutter_timeline_view.dart';
|
||||||
|
|
|
@ -2,4 +2,9 @@
|
||||||
//
|
//
|
||||||
// SPDX-License-Identifier: BSD-3-Clause
|
// SPDX-License-Identifier: BSD-3-Clause
|
||||||
|
|
||||||
|
///
|
||||||
library flutter_timeline_firebase;
|
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';
|
||||||
|
|
|
@ -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<String> allTimelineCategories;
|
||||||
|
}
|
|
@ -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<String, Object?> 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<String, Object?> toJson() => {
|
||||||
|
'first_name': firstName,
|
||||||
|
'last_name': lastName,
|
||||||
|
'image_url': imageUrl,
|
||||||
|
};
|
||||||
|
}
|
|
@ -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<TimelinePost> _posts = [];
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<TimelinePost> 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<void> 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<TimelinePost> fetchPostDetails(TimelinePost post) async {
|
||||||
|
var reactions = post.reactions ?? [];
|
||||||
|
var updatedReactions = <TimelinePostReaction>[];
|
||||||
|
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<List<TimelinePost>> 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 = <TimelinePost>[];
|
||||||
|
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<TimelinePost> 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<TimelinePost> getPosts(String? category) => _posts
|
||||||
|
.where((element) => category == null || element.category == category)
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<TimelinePost> 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<TimelinePost> 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<TimelinePost> 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;
|
||||||
|
}
|
||||||
|
}
|
|
@ -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<String, TimelinePosterUserModel> _users = {};
|
||||||
|
|
||||||
|
CollectionReference<FirebaseUserDocument> get _userCollection => _db
|
||||||
|
.collection(_options.usersCollectionName)
|
||||||
|
.withConverter<FirebaseUserDocument>(
|
||||||
|
fromFirestore: (snapshot, _) => FirebaseUserDocument.fromJson(
|
||||||
|
snapshot.data()!,
|
||||||
|
snapshot.id,
|
||||||
|
),
|
||||||
|
toFirestore: (user, _) => user.toJson(),
|
||||||
|
);
|
||||||
|
@override
|
||||||
|
Future<TimelinePosterUserModel?> 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;
|
||||||
|
}
|
||||||
|
}
|
|
@ -14,6 +14,11 @@ environment:
|
||||||
dependencies:
|
dependencies:
|
||||||
flutter:
|
flutter:
|
||||||
sdk: 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:
|
flutter_timeline_interface:
|
||||||
git:
|
git:
|
||||||
url: https://github.com/Iconica-Development/flutter_timeline.git
|
url: https://github.com/Iconica-Development/flutter_timeline.git
|
||||||
|
|
|
@ -1,5 +1,12 @@
|
||||||
// SPDX-FileCopyrightText: 2023 Iconica
|
// SPDX-FileCopyrightText: 2023 Iconica
|
||||||
//
|
//
|
||||||
// SPDX-License-Identifier: BSD-3-Clause
|
// SPDX-License-Identifier: BSD-3-Clause
|
||||||
|
///
|
||||||
library flutter_timeline_interface;
|
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';
|
||||||
|
|
|
@ -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<String, dynamic> 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<dynamic>?)?.cast<String>() ?? [],
|
||||||
|
reaction: json['reaction'] as int,
|
||||||
|
reactions: (json['reactions'] as List<dynamic>?)
|
||||||
|
?.map(
|
||||||
|
(e) => TimelinePostReaction.fromJson(
|
||||||
|
(e as Map).keys.first,
|
||||||
|
id,
|
||||||
|
e.values.first as Map<String, dynamic>,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.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<String>? 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<TimelinePostReaction>? 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<String>? likedBy,
|
||||||
|
int? reaction,
|
||||||
|
List<TimelinePostReaction>? 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<String, dynamic> 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,
|
||||||
|
};
|
||||||
|
}
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
|
@ -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<String, dynamic> 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<String, dynamic> toJson() => <String, dynamic>{
|
||||||
|
id: {
|
||||||
|
'creator_id': creatorId,
|
||||||
|
'reaction': reaction,
|
||||||
|
'image_url': imageUrl,
|
||||||
|
'created_at': createdAt.toIso8601String(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
|
@ -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<void> deletePost(TimelinePost post);
|
||||||
|
Future<TimelinePost> createPost(TimelinePost post);
|
||||||
|
Future<List<TimelinePost>> fetchPosts(String? category);
|
||||||
|
Future<TimelinePost> fetchPost(TimelinePost post);
|
||||||
|
List<TimelinePost> getPosts(String? category);
|
||||||
|
Future<TimelinePost> fetchPostDetails(TimelinePost post);
|
||||||
|
Future<TimelinePost> reactToPost(
|
||||||
|
TimelinePost post,
|
||||||
|
TimelinePostReaction reaction, {
|
||||||
|
Uint8List image,
|
||||||
|
});
|
||||||
|
Future<TimelinePost> likePost(String userId, TimelinePost post);
|
||||||
|
Future<TimelinePost> unlikePost(String userId, TimelinePost post);
|
||||||
|
}
|
|
@ -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<TimelinePosterUserModel?> getUser(String userId);
|
||||||
|
}
|
44
packages/flutter_timeline_view/example/.gitignore
vendored
Normal file
44
packages/flutter_timeline_view/example/.gitignore
vendored
Normal file
|
@ -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
|
28
packages/flutter_timeline_view/example/analysis_options.yaml
Normal file
28
packages/flutter_timeline_view/example/analysis_options.yaml
Normal file
|
@ -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
|
49
packages/flutter_timeline_view/example/lib/main.dart
Normal file
49
packages/flutter_timeline_view/example/lib/main.dart
Normal file
|
@ -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: <Widget>[
|
||||||
|
Text(
|
||||||
|
'You have pushed the button this many times:',
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
21
packages/flutter_timeline_view/example/pubspec.yaml
Normal file
21
packages/flutter_timeline_view/example/pubspec.yaml
Normal file
|
@ -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
|
14
packages/flutter_timeline_view/example/test/widget_test.dart
Normal file
14
packages/flutter_timeline_view/example/test/widget_test.dart
Normal file
|
@ -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);
|
||||||
|
});
|
||||||
|
}
|
|
@ -1,5 +1,13 @@
|
||||||
// SPDX-FileCopyrightText: 2023 Iconica
|
// SPDX-FileCopyrightText: 2023 Iconica
|
||||||
//
|
//
|
||||||
// SPDX-License-Identifier: BSD-3-Clause
|
// SPDX-License-Identifier: BSD-3-Clause
|
||||||
|
///
|
||||||
library flutter_timeline_view;
|
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';
|
||||||
|
|
|
@ -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,
|
||||||
|
);
|
|
@ -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;
|
||||||
|
}
|
|
@ -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;
|
||||||
|
}
|
|
@ -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<TimelinePostCreationScreen> createState() =>
|
||||||
|
_TimelinePostCreationScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _TimelinePostCreationScreenState
|
||||||
|
extends State<TimelinePostCreationScreen> {
|
||||||
|
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<void> 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<Uint8List?>(
|
||||||
|
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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
|
@ -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<TimelinePostScreen> createState() => _TimelinePostScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _TimelinePostScreenState extends State<TimelinePostScreen> {
|
||||||
|
TimelinePost? post;
|
||||||
|
bool isLoading = true;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) async {
|
||||||
|
await loadPostDetails();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> 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) =>
|
||||||
|
<PopupMenuEntry<String>>[
|
||||||
|
PopupMenuItem<String>(
|
||||||
|
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 ?? <TimelinePostReaction>[]) ...[
|
||||||
|
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<Uint8List?>(
|
||||||
|
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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
|
@ -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<TimelinePost>? 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<TimelineScreen> createState() => _TimelineScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _TimelineScreenState extends State<TimelineScreen> {
|
||||||
|
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<void> 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;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -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<void> Function(String text) onReactionSubmit;
|
||||||
|
final TextInputBuilder messageInputBuilder;
|
||||||
|
final VoidCallback? onPressSelectImage;
|
||||||
|
final TimelineTranslations translations;
|
||||||
|
final Color? iconColor;
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<ReactionBottom> createState() => _ReactionBottomState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ReactionBottomState extends State<ReactionBottom> {
|
||||||
|
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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
|
@ -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) =>
|
||||||
|
<PopupMenuEntry<String>>[
|
||||||
|
PopupMenuItem<String>(
|
||||||
|
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,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
|
@ -14,11 +14,19 @@ environment:
|
||||||
dependencies:
|
dependencies:
|
||||||
flutter:
|
flutter:
|
||||||
sdk: flutter
|
sdk: flutter
|
||||||
|
intl: any
|
||||||
|
cached_network_image: ^3.2.2
|
||||||
|
dotted_border: ^2.1.0
|
||||||
|
|
||||||
flutter_timeline_interface:
|
flutter_timeline_interface:
|
||||||
git:
|
git:
|
||||||
url: https://github.com/Iconica-Development/flutter_timeline.git
|
url: https://github.com/Iconica-Development/flutter_timeline.git
|
||||||
path: packages/flutter_timeline_interface
|
path: packages/flutter_timeline_interface
|
||||||
ref: 0.0.1
|
ref: 0.0.1
|
||||||
|
flutter_image_picker:
|
||||||
|
git:
|
||||||
|
url: https://github.com/Iconica-Development/flutter_image_picker
|
||||||
|
ref: 1.0.4
|
||||||
|
|
||||||
dev_dependencies:
|
dev_dependencies:
|
||||||
flutter_lints: ^2.0.0
|
flutter_lints: ^2.0.0
|
||||||
|
|
Loading…
Reference in a new issue