Merge pull request #1 from Iconica-Development/0.0.1

First version of flutter_timeline
This commit is contained in:
Gorter-dev 2023-11-22 14:58:44 +01:00 committed by Freek van de Ven
commit b3de42f308
32 changed files with 2105 additions and 11 deletions

View 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
View file

@ -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

View file

@ -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.

View file

@ -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';

View file

@ -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';

View file

@ -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;
}

View file

@ -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,
};
}

View file

@ -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;
}
}

View file

@ -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;
}
}

View file

@ -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

View file

@ -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';

View file

@ -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,
};
}

View file

@ -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;
}
}

View file

@ -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(),
},
};
}

View file

@ -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);
}

View file

@ -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);
}

View 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

View 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

View 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:',
),
],
),
),
);
}
}

View 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

View 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);
});
}

View file

@ -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';

View file

@ -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,
);

View file

@ -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;
}

View file

@ -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;
}

View file

@ -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,
),
),
),
],
),
);
}
}

View file

@ -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,
),
),
],
);
}
}

View file

@ -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;
});
}
}
}

View file

@ -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,
),
),
);
}

View file

@ -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,
),
],
),
),
);
}
}

View file

@ -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