mirror of
https://github.com/Iconica-Development/flutter_timeline.git
synced 2025-05-19 02:23:46 +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
|
||||
|
||||
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:
|
||||
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.
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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:
|
||||
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
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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-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';
|
||||
|
|
|
@ -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:
|
||||
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
|
||||
|
|
Loading…
Reference in a new issue