mirror of
https://github.com/Iconica-Development/flutter_timeline.git
synced 2025-05-19 02:23:46 +02:00
Merge f0c1c265b3
into a62935eb60
This commit is contained in:
commit
2e572cdb50
108 changed files with 3102 additions and 5520 deletions
|
@ -1,3 +1,6 @@
|
|||
## 6.0.0
|
||||
* Refactor the timeline package to use the new structure
|
||||
|
||||
## 5.1.0
|
||||
|
||||
* Added `routeToPostDetail` to the `TimelineUserStory` to allow for navigation to the post detail screen.
|
||||
|
|
|
@ -1 +0,0 @@
|
|||
List of Features from this component:
|
|
@ -24,9 +24,6 @@ If you are going to use Firebase as the back-end of the Timeline, you should als
|
|||
```
|
||||
|
||||
In firebase add firestore and storage to your project.
|
||||
In firestore add a collection named `timeline` and a collection named `users`.
|
||||
In the `timeline` collection all posts will be stored. In the `users` collection all users will be stored.
|
||||
In the `users` collection you should add your users data.
|
||||
|
||||
Add the following code in your `main` function, before the runApp().
|
||||
And import this package: import 'package:intl/date_symbol_data_local.dart';
|
||||
|
|
29
packages/firebase_timeline_repository/.gitignore
vendored
Normal file
29
packages/firebase_timeline_repository/.gitignore
vendored
Normal file
|
@ -0,0 +1,29 @@
|
|||
# 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
|
||||
# Libraries should not include pubspec.lock, per https://dart.dev/guides/libraries/private-files#pubspeclock.
|
||||
/pubspec.lock
|
||||
**/doc/api/
|
||||
.dart_tool/
|
||||
build/
|
1
packages/firebase_timeline_repository/CONTRIBUTING.md
Symbolic link
1
packages/firebase_timeline_repository/CONTRIBUTING.md
Symbolic link
|
@ -0,0 +1 @@
|
|||
../../CONTRIBUTING.md
|
|
@ -0,0 +1,7 @@
|
|||
include: package:flutter_iconica_analysis/analysis_options.yaml
|
||||
|
||||
analyzer:
|
||||
exclude:
|
||||
|
||||
linter:
|
||||
rules:
|
|
@ -0,0 +1,7 @@
|
|||
/// firebase_timeline_repository is a library for Firebase repositories.
|
||||
library firebase_timeline_repository;
|
||||
|
||||
/// Firebase repositories
|
||||
export "src/firebase_category_repository.dart";
|
||||
export "src/firebase_post_repository.dart";
|
||||
export "src/firebase_user_repository.dart";
|
|
@ -0,0 +1,73 @@
|
|||
import "package:cloud_firestore/cloud_firestore.dart";
|
||||
import "package:collection/collection.dart";
|
||||
import "package:timeline_repository_interface/timeline_repository_interface.dart";
|
||||
|
||||
class FirebaseCategoryRepository implements CategoryRepositoryInterface {
|
||||
final CollectionReference categoryCollection =
|
||||
FirebaseFirestore.instance.collection("timeline_categories");
|
||||
|
||||
final List<TimelineCategory> _categories = [];
|
||||
TimelineCategory? _selectedCategory;
|
||||
|
||||
@override
|
||||
Future<void> createCategory(TimelineCategory category) async {
|
||||
await categoryCollection.add(category.toJson());
|
||||
}
|
||||
|
||||
@override
|
||||
Stream<List<TimelineCategory>> getCategories() {
|
||||
var currentlySelected = _selectedCategory;
|
||||
|
||||
return categoryCollection
|
||||
.snapshots()
|
||||
.map(
|
||||
(snapshot) => snapshot.docs
|
||||
.map(
|
||||
(doc) => TimelineCategory.fromJson(
|
||||
doc.data()! as Map<String, dynamic>,
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
)
|
||||
.map((categories) {
|
||||
// Ensure that selected category is preserved during re-fetching
|
||||
|
||||
// Modify _categories without resetting selected category
|
||||
if (_categories.isEmpty) {
|
||||
_categories.add(
|
||||
const TimelineCategory(
|
||||
key: null,
|
||||
title: "All",
|
||||
),
|
||||
);
|
||||
_categories.addAll(categories);
|
||||
} else {
|
||||
_categories
|
||||
..clear()
|
||||
..add(const TimelineCategory(key: null, title: "All"))
|
||||
..addAll(categories);
|
||||
_selectedCategory = _categories.firstWhereOrNull(
|
||||
(category) => category.key == currentlySelected?.key,
|
||||
);
|
||||
}
|
||||
return _categories;
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
TimelineCategory? getCategory(String? categoryId) =>
|
||||
_categories.firstWhereOrNull(
|
||||
(category) => category.key == categoryId,
|
||||
);
|
||||
|
||||
@override
|
||||
TimelineCategory? getSelectedCategory() => _selectedCategory;
|
||||
|
||||
@override
|
||||
TimelineCategory? selectCategory(String? categoryId) {
|
||||
_selectedCategory = _categories.firstWhereOrNull(
|
||||
(category) => category.key == categoryId,
|
||||
);
|
||||
return _selectedCategory;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,176 @@
|
|||
import "dart:async";
|
||||
import "dart:typed_data";
|
||||
|
||||
import "package:cloud_firestore/cloud_firestore.dart";
|
||||
import "package:firebase_storage/firebase_storage.dart";
|
||||
import "package:firebase_timeline_repository/firebase_timeline_repository.dart";
|
||||
import "package:timeline_repository_interface/timeline_repository_interface.dart";
|
||||
|
||||
class FirebasePostRepository implements PostRepositoryInterface {
|
||||
FirebasePostRepository({
|
||||
this.userService,
|
||||
}) {
|
||||
userService ??= FirebaseUserRepository();
|
||||
}
|
||||
|
||||
TimelineUserRepositoryInterface? userService;
|
||||
|
||||
final CollectionReference postCollection =
|
||||
FirebaseFirestore.instance.collection("timeline");
|
||||
|
||||
late TimelinePost? _currentPost;
|
||||
|
||||
final List<TimelinePost> _posts = [];
|
||||
|
||||
@override
|
||||
Future<void> createPost(TimelinePost post) async {
|
||||
var updatedPost = post;
|
||||
if (post.image != null) {
|
||||
// add image upload logic here
|
||||
var imageRef = FirebaseStorage.instance.ref().child("timeline/$post.id");
|
||||
var result = await imageRef.putData(post.image!);
|
||||
var imageUrl = await result.ref.getDownloadURL();
|
||||
updatedPost = post.copyWith(
|
||||
imageUrl: imageUrl,
|
||||
);
|
||||
}
|
||||
_posts.add(updatedPost);
|
||||
|
||||
await postCollection.add(updatedPost.toJson());
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> createReaction(
|
||||
TimelinePost post,
|
||||
TimelinePostReaction reaction, {
|
||||
Uint8List? image,
|
||||
}) async {
|
||||
var user = await userService!.getCurrentUser();
|
||||
var currentReaction = reaction.copyWith(
|
||||
creatorId: user.userId,
|
||||
creator: user,
|
||||
);
|
||||
var updatedPost = post.copyWith(
|
||||
reaction: post.reaction + 1,
|
||||
reactions: post.reactions
|
||||
?..add(
|
||||
currentReaction,
|
||||
),
|
||||
);
|
||||
await postCollection.doc(post.id).update(updatedPost.toJson());
|
||||
_posts[_posts.indexWhere((element) => element.id == post.id)] = updatedPost;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> deletePost(String id) async {
|
||||
await postCollection.doc(id).delete();
|
||||
}
|
||||
|
||||
@override
|
||||
TimelinePost getCurrentPost() => _currentPost!;
|
||||
|
||||
@override
|
||||
Stream<List<TimelinePost>> getPosts(String? categoryId) => postCollection
|
||||
.where("category", isEqualTo: categoryId)
|
||||
.snapshots()
|
||||
.asyncMap((snapshot) async {
|
||||
// Fetch posts and their associated users
|
||||
var posts = await Future.wait(
|
||||
snapshot.docs.map((doc) async {
|
||||
// Get user who created the post
|
||||
var postData = doc.data()! as Map<String, dynamic>;
|
||||
var user = await userService!.getUser(postData["creator_id"]);
|
||||
|
||||
// Create post from document data
|
||||
var post = TimelinePost.fromJson(doc.id, postData);
|
||||
|
||||
// Update reactions with user details
|
||||
if (post.reactions != null) {
|
||||
post = post.copyWith(
|
||||
reactions: await Future.wait(
|
||||
post.reactions!.map((reaction) async {
|
||||
var reactionUser =
|
||||
await userService!.getUser(reaction.creatorId);
|
||||
return reaction.copyWith(creator: reactionUser);
|
||||
}).toList(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Return post with creator information
|
||||
return post.copyWith(creator: user);
|
||||
}).toList(),
|
||||
);
|
||||
|
||||
// Update internal post list
|
||||
_posts
|
||||
..clear()
|
||||
..addAll(posts);
|
||||
|
||||
return _posts;
|
||||
});
|
||||
|
||||
@override
|
||||
Future<void> likePost(String postId, String userId) async {
|
||||
var post = await postCollection.doc(postId).get();
|
||||
var updatedPost =
|
||||
TimelinePost.fromJson(post.id, post.data()! as Map<String, dynamic>);
|
||||
updatedPost = updatedPost.copyWith(
|
||||
likes: updatedPost.likes + 1,
|
||||
likedBy: updatedPost.likedBy?..add(userId),
|
||||
);
|
||||
await postCollection.doc(postId).update(updatedPost.toJson());
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> likePostReaction(
|
||||
TimelinePost post,
|
||||
TimelinePostReaction reaction,
|
||||
String userId,
|
||||
) async {
|
||||
var updatedPost = post.copyWith(
|
||||
reaction: post.reaction + 1,
|
||||
reactions: post.reactions
|
||||
?..[post.reactions!
|
||||
.indexWhere((element) => element.id == reaction.id)] =
|
||||
reaction.copyWith(
|
||||
likedBy: reaction.likedBy?..add(userId),
|
||||
),
|
||||
);
|
||||
await postCollection.doc(post.id).update(updatedPost.toJson());
|
||||
}
|
||||
|
||||
@override
|
||||
void setCurrentPost(TimelinePost post) {
|
||||
_currentPost = post;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> unlikePost(String postId, String userId) async {
|
||||
var updatedPost = _posts.firstWhere((element) => element.id == postId);
|
||||
updatedPost = updatedPost.copyWith(
|
||||
likes: updatedPost.likes - 1,
|
||||
likedBy: updatedPost.likedBy?..remove(userId),
|
||||
);
|
||||
await postCollection.doc(postId).update(updatedPost.toJson());
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> unlikePostReaction(
|
||||
TimelinePost post,
|
||||
TimelinePostReaction reaction,
|
||||
String userId,
|
||||
) async {
|
||||
var updatedPost = post.copyWith(
|
||||
reaction: post.reaction - 1,
|
||||
reactions: post.reactions
|
||||
?..[post.reactions!
|
||||
.indexWhere((element) => element.id == reaction.id)] =
|
||||
reaction.copyWith(
|
||||
likedBy: reaction.likedBy?..remove(userId),
|
||||
),
|
||||
);
|
||||
|
||||
await postCollection.doc(post.id).update(updatedPost.toJson());
|
||||
}
|
||||
}
|
|
@ -0,0 +1,48 @@
|
|||
import "package:cloud_firestore/cloud_firestore.dart";
|
||||
import "package:firebase_auth/firebase_auth.dart";
|
||||
import "package:timeline_repository_interface/timeline_repository_interface.dart";
|
||||
|
||||
class FirebaseUserRepository implements TimelineUserRepositoryInterface {
|
||||
final CollectionReference usersCollection =
|
||||
FirebaseFirestore.instance.collection("users");
|
||||
|
||||
@override
|
||||
Future<List<TimelineUser>> getAllUsers() async {
|
||||
var users = await usersCollection
|
||||
.withConverter<TimelineUser>(
|
||||
fromFirestore: (snapshot, _) =>
|
||||
TimelineUser.fromJson(snapshot.data()!, snapshot.id),
|
||||
toFirestore: (user, _) => user.toJson(),
|
||||
)
|
||||
.get();
|
||||
return users.docs.map((e) => e.data()).toList();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<TimelineUser> getCurrentUser() async {
|
||||
var authUser = FirebaseAuth.instance.currentUser;
|
||||
var user = await usersCollection
|
||||
.doc(authUser!.uid)
|
||||
.withConverter<TimelineUser>(
|
||||
fromFirestore: (snapshot, _) =>
|
||||
TimelineUser.fromJson(snapshot.data()!, snapshot.id),
|
||||
toFirestore: (user, _) => user.toJson(),
|
||||
)
|
||||
.get();
|
||||
return user.data()!;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<TimelineUser?> getUser(String userId) async {
|
||||
var userDoc = await usersCollection
|
||||
.doc(userId)
|
||||
.withConverter<TimelineUser>(
|
||||
fromFirestore: (snapshot, _) =>
|
||||
TimelineUser.fromJson(snapshot.data()!, snapshot.id),
|
||||
toFirestore: (user, _) => user.toJson(),
|
||||
)
|
||||
.get();
|
||||
// print(userDoc.data()?.firstName);
|
||||
return userDoc.data();
|
||||
}
|
||||
}
|
29
packages/firebase_timeline_repository/pubspec.yaml
Normal file
29
packages/firebase_timeline_repository/pubspec.yaml
Normal file
|
@ -0,0 +1,29 @@
|
|||
name: firebase_timeline_repository
|
||||
description: "A new Flutter package project."
|
||||
version: 6.0.0
|
||||
publish_to: none
|
||||
|
||||
environment:
|
||||
sdk: ^3.5.1
|
||||
flutter: ">=1.17.0"
|
||||
|
||||
dependencies:
|
||||
flutter:
|
||||
sdk: flutter
|
||||
timeline_repository_interface:
|
||||
git:
|
||||
url: https://github.com/Iconica-Development/flutter_timeline
|
||||
path: packages/timeline_repository_interface
|
||||
ref: 6.0.0
|
||||
|
||||
rxdart: any
|
||||
cloud_firestore: ^5.4.4
|
||||
firebase_auth: ^5.3.1
|
||||
firebase_storage: ^12.3.2
|
||||
collection: ^1.18.0
|
||||
|
||||
dev_dependencies:
|
||||
flutter_iconica_analysis:
|
||||
git:
|
||||
url: https://github.com/Iconica-Development/flutter_iconica_analysis
|
||||
ref: 7.0.0
|
36
packages/flutter_timeline/.gitignore
vendored
Normal file
36
packages/flutter_timeline/.gitignore
vendored
Normal file
|
@ -0,0 +1,36 @@
|
|||
# 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
|
||||
# Libraries should not include pubspec.lock, per https://dart.dev/guides/libraries/private-files#pubspeclock.
|
||||
/pubspec.lock
|
||||
**/doc/api/
|
||||
.dart_tool/
|
||||
build/
|
||||
|
||||
ios
|
||||
android
|
||||
web
|
||||
macos
|
||||
windows
|
||||
linux
|
1
packages/flutter_timeline/CONTRIBUTING.md
Symbolic link
1
packages/flutter_timeline/CONTRIBUTING.md
Symbolic link
|
@ -0,0 +1 @@
|
|||
../../CONTRIBUTING.md
|
|
@ -1,13 +1,7 @@
|
|||
# SPDX-FileCopyrightText: 2023 Iconica
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
include: package:flutter_iconica_analysis/analysis_options.yaml
|
||||
|
||||
# Possible to overwrite the rules from the package
|
||||
|
||||
analyzer:
|
||||
exclude:
|
||||
|
||||
|
||||
linter:
|
||||
rules:
|
||||
|
|
Before Width: | Height: | Size: 713 B After Width: | Height: | Size: 713 B |
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 1.1 KiB |
BIN
packages/flutter_timeline/example/fonts/Avenir-Regular.ttf
Normal file
BIN
packages/flutter_timeline/example/fonts/Avenir-Regular.ttf
Normal file
Binary file not shown.
BIN
packages/flutter_timeline/example/fonts/Merriweather-Regular.ttf
Normal file
BIN
packages/flutter_timeline/example/fonts/Merriweather-Regular.ttf
Normal file
Binary file not shown.
|
@ -1,45 +0,0 @@
|
|||
import 'package:example/config/config.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_timeline/flutter_timeline.dart';
|
||||
|
||||
class NavigatorApp extends StatelessWidget {
|
||||
const NavigatorApp({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return MaterialApp(
|
||||
title: 'Flutter Timeline',
|
||||
theme: ThemeData(
|
||||
colorScheme:
|
||||
ColorScheme.fromSeed(seedColor: Colors.deepPurple).copyWith(
|
||||
surface: const Color(0xFFB8E2E8),
|
||||
),
|
||||
useMaterial3: true,
|
||||
),
|
||||
home: const MyHomePage(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class MyHomePage extends StatefulWidget {
|
||||
const MyHomePage({
|
||||
super.key,
|
||||
});
|
||||
|
||||
@override
|
||||
State<MyHomePage> createState() => _MyHomePageState();
|
||||
}
|
||||
|
||||
class _MyHomePageState extends State<MyHomePage> {
|
||||
var timelineService =
|
||||
TimelineService(postService: LocalTimelinePostService());
|
||||
var timelineOptions = options;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return timeLineNavigatorUserStory(
|
||||
context: context,
|
||||
configuration: getConfig(timelineService),
|
||||
);
|
||||
}
|
||||
}
|
|
@ -1,80 +0,0 @@
|
|||
import 'package:example/config/config.dart';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_timeline/flutter_timeline.dart';
|
||||
|
||||
class WidgetApp extends StatelessWidget {
|
||||
const WidgetApp({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return MaterialApp(
|
||||
title: 'Flutter Timeline',
|
||||
theme: ThemeData(
|
||||
colorScheme:
|
||||
ColorScheme.fromSeed(seedColor: Colors.deepPurple).copyWith(
|
||||
surface: const Color(0xFFB8E2E8),
|
||||
),
|
||||
useMaterial3: true,
|
||||
),
|
||||
home: const MyHomePage(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class MyHomePage extends StatefulWidget {
|
||||
const MyHomePage({
|
||||
super.key,
|
||||
});
|
||||
|
||||
@override
|
||||
State<MyHomePage> createState() => _MyHomePageState();
|
||||
}
|
||||
|
||||
class _MyHomePageState extends State<MyHomePage> {
|
||||
var timelineService =
|
||||
TimelineService(postService: LocalTimelinePostService());
|
||||
var timelineOptions = options;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
floatingActionButton: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
FloatingActionButton(
|
||||
heroTag: 'btn1',
|
||||
onPressed: () {
|
||||
createPost(
|
||||
context,
|
||||
timelineService,
|
||||
timelineOptions,
|
||||
getConfig(timelineService),
|
||||
);
|
||||
},
|
||||
child: const Icon(
|
||||
Icons.edit,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
const SizedBox(
|
||||
height: 8,
|
||||
),
|
||||
FloatingActionButton(
|
||||
heroTag: 'btn2',
|
||||
onPressed: () {
|
||||
generatePost(timelineService);
|
||||
},
|
||||
child: const Icon(
|
||||
Icons.add,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
body: const SafeArea(
|
||||
child: TimelineScreen(),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
|
@ -1,56 +0,0 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_timeline/flutter_timeline.dart';
|
||||
|
||||
class PostScreen extends StatefulWidget {
|
||||
const PostScreen({
|
||||
required this.service,
|
||||
required this.post,
|
||||
super.key,
|
||||
});
|
||||
|
||||
final TimelineService service;
|
||||
final TimelinePost post;
|
||||
|
||||
@override
|
||||
State<PostScreen> createState() => _PostScreenState();
|
||||
}
|
||||
|
||||
class _PostScreenState extends State<PostScreen> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
body: TimelinePostScreen(
|
||||
userId: 'test_user',
|
||||
service: widget.service,
|
||||
options: const TimelineOptions(),
|
||||
post: widget.post,
|
||||
onPostDelete: () {
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class TestUserService implements TimelineUserService {
|
||||
final Map<String, TimelinePosterUserModel> _users = {
|
||||
'test_user': const TimelinePosterUserModel(
|
||||
userId: 'test_user',
|
||||
imageUrl:
|
||||
'https://cdn.britannica.com/68/143568-050-5246474F/Donkey.jpg?w=400&h=300&c=crop',
|
||||
firstName: 'Dirk',
|
||||
lastName: 'lukassen',
|
||||
)
|
||||
};
|
||||
|
||||
@override
|
||||
Future<TimelinePosterUserModel?> getUser(String userId) async {
|
||||
if (_users.containsKey(userId)) {
|
||||
return _users[userId]!;
|
||||
}
|
||||
|
||||
_users[userId] = TimelinePosterUserModel(userId: userId);
|
||||
|
||||
return TimelinePosterUserModel(userId: userId);
|
||||
}
|
||||
}
|
|
@ -1,101 +0,0 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_timeline/flutter_timeline.dart';
|
||||
|
||||
TimelineUserStoryConfiguration getConfig(TimelineService service) {
|
||||
return TimelineUserStoryConfiguration(
|
||||
service: service,
|
||||
userId: 'test_user',
|
||||
optionsBuilder: (context) => options,
|
||||
enablePostOverviewScreen: false,
|
||||
canDeleteAllPosts: (_) => true,
|
||||
);
|
||||
}
|
||||
|
||||
var options = TimelineOptions(
|
||||
textInputBuilder: null,
|
||||
paddings: TimelinePaddingOptions(
|
||||
mainPadding: const EdgeInsets.all(20).copyWith(top: 28),
|
||||
),
|
||||
);
|
||||
|
||||
void navigateToOverview(
|
||||
BuildContext context,
|
||||
TimelineService service,
|
||||
TimelineOptions options,
|
||||
TimelinePost post,
|
||||
) {
|
||||
if (context.mounted) {
|
||||
Navigator.of(context).pop();
|
||||
}
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => TimelinePostOverviewScreen(
|
||||
timelinePost: post,
|
||||
options: options,
|
||||
service: service,
|
||||
onPostSubmit: (post) {
|
||||
service.postService.createPost(post);
|
||||
if (context.mounted) {
|
||||
Navigator.of(context).pop();
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void createPost(
|
||||
BuildContext context,
|
||||
TimelineService service,
|
||||
TimelineOptions options,
|
||||
TimelineUserStoryConfiguration configuration) async {
|
||||
await Navigator.pushReplacement(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => Scaffold(
|
||||
body: TimelinePostCreationScreen(
|
||||
postCategory: 'category1',
|
||||
userId: 'test_user',
|
||||
service: service,
|
||||
options: options,
|
||||
onPostCreated: (post) {
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
onPostOverview: (post) {
|
||||
navigateToOverview(context, service, options, post);
|
||||
},
|
||||
enablePostOverviewScreen: configuration.enablePostOverviewScreen,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void generatePost(TimelineService service) {
|
||||
var amountOfPosts = service.postService.getPosts(null).length;
|
||||
|
||||
service.postService.createPost(
|
||||
TimelinePost(
|
||||
id: 'Post$amountOfPosts',
|
||||
creatorId: 'test_user',
|
||||
title: 'Post $amountOfPosts',
|
||||
category: amountOfPosts % 2 == 0 ? 'category1' : 'category2',
|
||||
content: "Post $amountOfPosts content",
|
||||
likes: 0,
|
||||
reaction: 0,
|
||||
creator: const TimelinePosterUserModel(
|
||||
userId: 'test_user',
|
||||
imageUrl:
|
||||
'https://cdn.britannica.com/68/143568-050-5246474F/Donkey.jpg?w=400&h=300&c=crop',
|
||||
firstName: 'Dirk',
|
||||
lastName: 'lukassen',
|
||||
),
|
||||
createdAt: DateTime.now(),
|
||||
reactionEnabled: amountOfPosts % 2 == 0 ? false : true,
|
||||
imageUrl: amountOfPosts % 3 != 0
|
||||
? 'https://s3-eu-west-1.amazonaws.com/sortlist-core-api/6qpvvqjtmniirpkvp8eg83bicnc2'
|
||||
: null,
|
||||
),
|
||||
);
|
||||
}
|
|
@ -1,9 +1,26 @@
|
|||
import 'package:example/apps/navigator/app.dart';
|
||||
import 'package:example/theme.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_timeline/flutter_timeline.dart';
|
||||
import 'package:intl/date_symbol_data_local.dart';
|
||||
|
||||
void main() {
|
||||
void main(List<String> args) {
|
||||
initializeDateFormatting();
|
||||
|
||||
runApp(const NavigatorApp());
|
||||
runApp(const MyApp());
|
||||
}
|
||||
|
||||
var timelineService = TimelineService();
|
||||
|
||||
class MyApp extends StatelessWidget {
|
||||
const MyApp({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return MaterialApp(
|
||||
theme: theme,
|
||||
home: const FlutterTimelineNavigatorUserstory(
|
||||
currentUserId: "1",
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
147
packages/flutter_timeline/example/lib/theme.dart
Normal file
147
packages/flutter_timeline/example/lib/theme.dart
Normal file
|
@ -0,0 +1,147 @@
|
|||
import 'package:flutter/material.dart';
|
||||
|
||||
const Color primaryColor = Color(0xFF71C6D1);
|
||||
|
||||
ThemeData theme = ThemeData(
|
||||
actionIconTheme: ActionIconThemeData(
|
||||
backButtonIconBuilder: (context) {
|
||||
return const Icon(Icons.arrow_back_ios_new_rounded);
|
||||
},
|
||||
),
|
||||
scaffoldBackgroundColor: const Color(0xFFFAF9F6),
|
||||
primaryColor: primaryColor,
|
||||
checkboxTheme: CheckboxThemeData(
|
||||
side: const BorderSide(
|
||||
color: Color(0xFF8D8D8D),
|
||||
width: 1,
|
||||
),
|
||||
fillColor: WidgetStateProperty.resolveWith<Color>(
|
||||
(Set<WidgetState> states) {
|
||||
if (states.contains(WidgetState.selected)) {
|
||||
return primaryColor;
|
||||
}
|
||||
return const Color(0xFFEEEEEE);
|
||||
},
|
||||
),
|
||||
),
|
||||
switchTheme: SwitchThemeData(
|
||||
trackColor:
|
||||
WidgetStateProperty.resolveWith<Color>((Set<WidgetState> states) {
|
||||
if (!states.contains(WidgetState.selected)) {
|
||||
return const Color(0xFF8D8D8D);
|
||||
}
|
||||
return primaryColor;
|
||||
}),
|
||||
thumbColor: const WidgetStatePropertyAll(
|
||||
Colors.white,
|
||||
),
|
||||
),
|
||||
appBarTheme: const AppBarTheme(
|
||||
centerTitle: true,
|
||||
iconTheme: IconThemeData(
|
||||
color: Colors.white,
|
||||
size: 16,
|
||||
),
|
||||
elevation: 0,
|
||||
backgroundColor: Color(0xFF212121),
|
||||
titleTextStyle: TextStyle(
|
||||
fontWeight: FontWeight.w900,
|
||||
fontSize: 24,
|
||||
color: Color(0xFF71C6D1),
|
||||
fontFamily: "Merriweather",
|
||||
),
|
||||
actionsIconTheme: IconThemeData()),
|
||||
fontFamily: "Merriweather",
|
||||
useMaterial3: false,
|
||||
textTheme: const TextTheme(
|
||||
headlineSmall: TextStyle(
|
||||
fontWeight: FontWeight.w400,
|
||||
fontSize: 16,
|
||||
color: Colors.white,
|
||||
),
|
||||
headlineLarge: TextStyle(
|
||||
fontWeight: FontWeight.w900,
|
||||
fontSize: 24,
|
||||
color: Color(0xFF71C6D1),
|
||||
),
|
||||
|
||||
displayLarge: TextStyle(
|
||||
fontFamily: "Avenir",
|
||||
fontWeight: FontWeight.w800,
|
||||
fontSize: 20,
|
||||
color: Colors.white,
|
||||
),
|
||||
displayMedium: TextStyle(
|
||||
fontFamily: "Avenir",
|
||||
fontWeight: FontWeight.w800,
|
||||
fontSize: 18,
|
||||
color: Color(0xFF71C6D1),
|
||||
),
|
||||
displaySmall: TextStyle(
|
||||
fontFamily: "Avenir",
|
||||
fontWeight: FontWeight.w800,
|
||||
fontSize: 14,
|
||||
color: Colors.black,
|
||||
),
|
||||
|
||||
// TITLE
|
||||
titleSmall: TextStyle(
|
||||
fontFamily: "Avenir",
|
||||
fontWeight: FontWeight.w800,
|
||||
fontSize: 14,
|
||||
color: Colors.white,
|
||||
),
|
||||
titleMedium: TextStyle(
|
||||
fontFamily: "Avenir",
|
||||
fontWeight: FontWeight.w800,
|
||||
fontSize: 16,
|
||||
color: Colors.black,
|
||||
),
|
||||
titleLarge: TextStyle(
|
||||
fontFamily: "Avenir",
|
||||
fontWeight: FontWeight.w800,
|
||||
fontSize: 20,
|
||||
color: Colors.black,
|
||||
),
|
||||
|
||||
// LABEL
|
||||
labelSmall: TextStyle(
|
||||
fontFamily: "Avenir",
|
||||
fontWeight: FontWeight.w400,
|
||||
fontSize: 12,
|
||||
color: Color(0xFF8D8D8D),
|
||||
),
|
||||
|
||||
// BODY
|
||||
bodySmall: TextStyle(
|
||||
fontFamily: "Avenir",
|
||||
fontWeight: FontWeight.w400,
|
||||
fontSize: 14,
|
||||
color: Colors.black,
|
||||
),
|
||||
bodyMedium: TextStyle(
|
||||
fontFamily: "Avenir",
|
||||
fontWeight: FontWeight.w400,
|
||||
fontSize: 16,
|
||||
color: Colors.black,
|
||||
),
|
||||
),
|
||||
radioTheme: RadioThemeData(
|
||||
visualDensity: const VisualDensity(
|
||||
horizontal: 0,
|
||||
vertical: -2,
|
||||
),
|
||||
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
||||
fillColor: WidgetStateProperty.resolveWith<Color>(
|
||||
(Set<WidgetState> states) {
|
||||
if (states.contains(WidgetState.selected)) {
|
||||
return primaryColor;
|
||||
}
|
||||
return Colors.black;
|
||||
},
|
||||
),
|
||||
),
|
||||
colorScheme: const ColorScheme.light(
|
||||
primary: primaryColor,
|
||||
),
|
||||
);
|
|
@ -1,92 +1,28 @@
|
|||
name: example
|
||||
description: "A new Flutter project."
|
||||
# The following line prevents the package from being accidentally published to
|
||||
# pub.dev using `flutter pub publish`. This is preferred for private packages.
|
||||
publish_to: 'none' # Remove this line if you wish to publish to pub.dev
|
||||
|
||||
# The following defines the version and build number for your application.
|
||||
# A version number is three numbers separated by dots, like 1.2.43
|
||||
# followed by an optional build number separated by a +.
|
||||
# Both the version and the builder number may be overridden in flutter
|
||||
# build by specifying --build-name and --build-number, respectively.
|
||||
# In Android, build-name is used as versionName while build-number used as versionCode.
|
||||
# Read more about Android versioning at https://developer.android.com/studio/publish/versioning
|
||||
# In iOS, build-name is used as CFBundleShortVersionString while build-number is used as CFBundleVersion.
|
||||
# Read more about iOS versioning at
|
||||
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
|
||||
# In Windows, build-name is used as the major, minor, and patch parts
|
||||
# of the product and file versions while build-number is used as the build suffix.
|
||||
publish_to: "none"
|
||||
|
||||
version: 1.0.0+1
|
||||
|
||||
environment:
|
||||
sdk: '>=3.2.3 <4.0.0'
|
||||
sdk: ^3.5.1
|
||||
|
||||
# Dependencies specify other packages that your package needs in order to work.
|
||||
# To automatically upgrade your package dependencies to the latest versions
|
||||
# consider running `flutter pub upgrade --major-versions`. Alternatively,
|
||||
# dependencies can be manually updated by changing the version numbers below to
|
||||
# the latest version available on pub.dev. To see which dependencies have newer
|
||||
# versions available, run `flutter pub outdated`.
|
||||
dependencies:
|
||||
flutter:
|
||||
sdk: flutter
|
||||
|
||||
|
||||
# The following adds the Cupertino Icons font to your application.
|
||||
# Use with the CupertinoIcons class for iOS style icons.
|
||||
cupertino_icons: ^1.0.2
|
||||
flutter_timeline:
|
||||
path: ../
|
||||
intl: ^0.19.0
|
||||
|
||||
intl: 0.19.0
|
||||
dev_dependencies:
|
||||
flutter_test:
|
||||
sdk: flutter
|
||||
flutter_lints: ^4.0.0
|
||||
|
||||
# The "flutter_lints" package below contains a set of recommended lints to
|
||||
# encourage good coding practices. The lint set provided by the package is
|
||||
# activated in the `analysis_options.yaml` file located at the root of your
|
||||
# package. See that file for information about deactivating specific lint
|
||||
# rules and activating additional ones.
|
||||
flutter_lints: ^2.0.0
|
||||
|
||||
# For information on the generic Dart part of this file, see the
|
||||
# following page: https://dart.dev/tools/pub/pubspec
|
||||
|
||||
# The following section is specific to Flutter packages.
|
||||
flutter:
|
||||
|
||||
# The following line ensures that the Material Icons font is
|
||||
# included with your application, so that you can use the icons in
|
||||
# the material Icons class.
|
||||
uses-material-design: true
|
||||
|
||||
# To add assets to your application, add an assets section, like this:
|
||||
# assets:
|
||||
# - assets/
|
||||
|
||||
# An image asset can refer to one or more resolution-specific "variants", see
|
||||
# https://flutter.dev/assets-and-images/#resolution-aware
|
||||
|
||||
# For details regarding adding assets from package dependencies, see
|
||||
# https://flutter.dev/assets-and-images/#from-packages
|
||||
|
||||
# To add custom fonts to your application, add a fonts section here,
|
||||
# in this "flutter" section. Each entry in this list should have a
|
||||
# "family" key with the font family name, and a "fonts" key with a
|
||||
# list giving the asset and other descriptors for the font. For
|
||||
# example:
|
||||
# fonts:
|
||||
# - family: Schyler
|
||||
# fonts:
|
||||
# - asset: fonts/Schyler-Regular.ttf
|
||||
# - asset: fonts/Schyler-Italic.ttf
|
||||
# style: italic
|
||||
# - family: Trajan Pro
|
||||
# fonts:
|
||||
# - asset: fonts/TrajanPro.ttf
|
||||
# - asset: fonts/TrajanPro_Bold.ttf
|
||||
# weight: 700
|
||||
#
|
||||
# For details regarding fonts from package dependencies,
|
||||
# see https://flutter.dev/custom-fonts/#from-packages
|
||||
fonts:
|
||||
- family: Merriweather
|
||||
fonts:
|
||||
- asset: fonts/Merriweather-Regular.ttf
|
||||
- family: Avenir
|
||||
fonts:
|
||||
- asset: fonts/Avenir-Regular.ttf
|
||||
|
|
|
@ -1,29 +0,0 @@
|
|||
// 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:example/apps/widgets/app.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
void main() {
|
||||
testWidgets('Counter increments smoke test', (WidgetTester tester) async {
|
||||
// Build our app and trigger a frame.
|
||||
await tester.pumpWidget(const WidgetApp());
|
||||
|
||||
// Verify that our counter starts at 0.
|
||||
expect(find.text('0'), findsOneWidget);
|
||||
expect(find.text('1'), findsNothing);
|
||||
|
||||
// Tap the '+' icon and trigger a frame.
|
||||
await tester.tap(find.byIcon(Icons.add));
|
||||
await tester.pump();
|
||||
|
||||
// Verify that our counter has incremented.
|
||||
expect(find.text('0'), findsNothing);
|
||||
expect(find.text('1'), findsOneWidget);
|
||||
});
|
||||
}
|
|
@ -1,12 +1,30 @@
|
|||
// SPDX-FileCopyrightText: 2023 Iconica
|
||||
//
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
// ignore_for_file: directives_ordering
|
||||
|
||||
/// Flutter Timeline library
|
||||
library flutter_timeline;
|
||||
/// userstory
|
||||
library;
|
||||
|
||||
export 'package:flutter_timeline/src/flutter_timeline_navigator_userstory.dart';
|
||||
export 'package:flutter_timeline/src/models/timeline_configuration.dart';
|
||||
export 'package:flutter_timeline/src/routes.dart';
|
||||
export 'package:flutter_timeline_interface/flutter_timeline_interface.dart';
|
||||
export 'package:flutter_timeline_view/flutter_timeline_view.dart';
|
||||
export "src/flutter_timeline_navigator_userstory.dart";
|
||||
export "package:timeline_repository_interface/timeline_repository_interface.dart";
|
||||
|
||||
/// models
|
||||
export "src/models/timeline_options.dart";
|
||||
export "src/models/timeline_translations.dart";
|
||||
|
||||
/// screens
|
||||
export "src/screens/timeline_screen.dart";
|
||||
export "src/screens/timeline_post_overview.dart";
|
||||
export "src/screens/timeline_post_detail_screen.dart";
|
||||
export "src/screens/timeline_add_post_information_screen.dart";
|
||||
export "src/screens/timeline_choose_category_screen.dart";
|
||||
|
||||
/// widgets
|
||||
export "src/widgets/category_list.dart";
|
||||
export "src/widgets/category_widget.dart";
|
||||
export "src/widgets/comment_section.dart";
|
||||
export "src/widgets/image_picker.dart";
|
||||
export "src/widgets/post_info_textfield.dart";
|
||||
export "src/widgets/post_list.dart";
|
||||
export "src/widgets/post_more_options_widget.dart";
|
||||
export "src/widgets/reaction_textfield.dart";
|
||||
export "src/widgets/tappable_image.dart";
|
||||
export "src/widgets/timeline_post.dart";
|
||||
|
|
|
@ -1,374 +1,115 @@
|
|||
// SPDX-FileCopyrightText: 2024 Iconica
|
||||
//
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
import "package:flutter/material.dart";
|
||||
import "package:flutter_timeline/flutter_timeline.dart";
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_timeline/flutter_timeline.dart';
|
||||
class FlutterTimelineNavigatorUserstory extends StatefulWidget {
|
||||
const FlutterTimelineNavigatorUserstory({
|
||||
required this.currentUserId,
|
||||
this.options = const TimelineOptions(),
|
||||
this.timelineService,
|
||||
super.key,
|
||||
});
|
||||
|
||||
/// A widget function that creates a timeline navigator for user stories.
|
||||
///
|
||||
/// This function creates a navigator for displaying user stories on a timeline.
|
||||
/// It takes a [BuildContext] and an optional [TimelineUserStoryConfiguration]
|
||||
/// as parameters. If no configuration is provided, default values will be used.
|
||||
late TimelineUserStoryConfiguration timelineUserStoryConfiguration;
|
||||
final TimelineOptions options;
|
||||
final TimelineService? timelineService;
|
||||
final String currentUserId;
|
||||
|
||||
Widget timeLineNavigatorUserStory({
|
||||
required BuildContext context,
|
||||
TimelineUserStoryConfiguration? configuration,
|
||||
}) {
|
||||
timelineUserStoryConfiguration = configuration ??
|
||||
TimelineUserStoryConfiguration(
|
||||
userId: 'test_user',
|
||||
service: TimelineService(
|
||||
postService: LocalTimelinePostService(),
|
||||
),
|
||||
optionsBuilder: (context) => const TimelineOptions(),
|
||||
);
|
||||
|
||||
return _timelineScreenRoute(
|
||||
config: timelineUserStoryConfiguration,
|
||||
context: context,
|
||||
);
|
||||
@override
|
||||
State<FlutterTimelineNavigatorUserstory> createState() =>
|
||||
_FlutterTimelineNavigatorUserstoryState();
|
||||
}
|
||||
|
||||
/// A widget function that creates a timeline screen route.
|
||||
///
|
||||
/// This function creates a route for displaying a timeline screen. It takes
|
||||
/// a [BuildContext] and an optional [TimelineUserStoryConfiguration] as
|
||||
/// parameters. If no configuration is provided, default values will be used.
|
||||
Widget _timelineScreenRoute({
|
||||
required BuildContext context,
|
||||
required TimelineUserStoryConfiguration config,
|
||||
String? initalCategory,
|
||||
}) {
|
||||
var timelineScreen = TimelineScreen(
|
||||
timelineCategory: initalCategory,
|
||||
userId: config.getUserId?.call(context) ?? config.userId,
|
||||
allowAllDeletion: config.canDeleteAllPosts?.call(context) ?? false,
|
||||
onUserTap: (user) => config.onUserTap?.call(context, user),
|
||||
service: config.service,
|
||||
options: config.optionsBuilder(context),
|
||||
onPostTap: (post) async =>
|
||||
config.onPostTap?.call(context, post) ??
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute(
|
||||
builder: (context) => _postDetailScreenRoute(
|
||||
config: config,
|
||||
context: context,
|
||||
post: post,
|
||||
),
|
||||
),
|
||||
),
|
||||
onRefresh: config.onRefresh,
|
||||
filterEnabled: config.filterEnabled,
|
||||
postWidgetBuilder: config.postWidgetBuilder,
|
||||
);
|
||||
var theme = Theme.of(context);
|
||||
var button = FloatingActionButton(
|
||||
backgroundColor: config
|
||||
.optionsBuilder(context)
|
||||
.theme
|
||||
.postCreationFloatingActionButtonColor ??
|
||||
theme.colorScheme.primary,
|
||||
onPressed: () async {
|
||||
var selectedCategory = config.service.postService.selectedCategory;
|
||||
if (selectedCategory != null && selectedCategory.key != null) {
|
||||
await Navigator.of(context).push(
|
||||
MaterialPageRoute(
|
||||
builder: (context) => _postCreationScreenRoute(
|
||||
config: config,
|
||||
context: context,
|
||||
category: selectedCategory,
|
||||
),
|
||||
),
|
||||
);
|
||||
} else {
|
||||
await Navigator.of(context).push(
|
||||
MaterialPageRoute(
|
||||
builder: (context) => _postCategorySelectionScreen(
|
||||
config: config,
|
||||
context: context,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
shape: const CircleBorder(),
|
||||
child: const Icon(
|
||||
Icons.add,
|
||||
color: Colors.white,
|
||||
size: 24,
|
||||
),
|
||||
);
|
||||
class _FlutterTimelineNavigatorUserstoryState
|
||||
extends State<FlutterTimelineNavigatorUserstory> {
|
||||
late TimelineService timelineService;
|
||||
|
||||
return config.homeOpenPageBuilder?.call(context, timelineScreen, button) ??
|
||||
Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text(
|
||||
config.optionsBuilder(context).translations.timeLineScreenTitle,
|
||||
style: theme.textTheme.headlineLarge,
|
||||
),
|
||||
),
|
||||
body: timelineScreen,
|
||||
floatingActionButton: button,
|
||||
);
|
||||
}
|
||||
@override
|
||||
void initState() {
|
||||
timelineService = widget.timelineService ?? TimelineService();
|
||||
super.initState();
|
||||
}
|
||||
|
||||
/// A widget function that creates a post detail screen route.
|
||||
///
|
||||
/// This function creates a route for displaying a post detail screen. It takes
|
||||
/// a [BuildContext], a [TimelinePost], and an optional
|
||||
/// [TimelineUserStoryConfiguration] as parameters. If no configuration is
|
||||
/// provided, default values will be used.
|
||||
Widget _postDetailScreenRoute({
|
||||
required BuildContext context,
|
||||
required TimelinePost post,
|
||||
required TimelineUserStoryConfiguration config,
|
||||
}) {
|
||||
var timelinePostScreen = TimelinePostScreen(
|
||||
userId: config.getUserId?.call(context) ?? config.userId,
|
||||
allowAllDeletion: config.canDeleteAllPosts?.call(context) ?? false,
|
||||
options: config.optionsBuilder(context),
|
||||
service: config.service,
|
||||
post: post,
|
||||
onPostDelete: () async =>
|
||||
config.onPostDelete?.call(context, post) ??
|
||||
() async {
|
||||
await config.service.postService.deletePost(post);
|
||||
if (context.mounted) {
|
||||
Navigator.of(context).pop();
|
||||
@override
|
||||
Widget build(BuildContext context) => _timelineScreenWidget();
|
||||
|
||||
Widget _timelineScreenWidget() => TimelineScreen(
|
||||
currentUserId: widget.currentUserId,
|
||||
timelineService: timelineService,
|
||||
options: widget.options,
|
||||
onTapComments: (post) async {
|
||||
var currentUser = await timelineService.getCurrentUser();
|
||||
|
||||
await widget.options.onTapComments?.call(post, currentUser) ??
|
||||
await _push(_timelinePostDetailScreenWidget(post, currentUser));
|
||||
},
|
||||
onTapCreatePost: () async {
|
||||
var selectedCategory = timelineService.getSelectedCategory();
|
||||
if (widget.options.onTapCreatePost != null) {
|
||||
await widget.options.onTapCreatePost!(selectedCategory);
|
||||
} else {
|
||||
if (selectedCategory?.key != null) {
|
||||
await _push(_timelineAddpostInformationScreenWidget());
|
||||
} else {
|
||||
await _push(_timelineChooseCategoryScreenWidget());
|
||||
}
|
||||
}
|
||||
}.call(),
|
||||
onUserTap: (user) => config.onUserTap?.call(context, user),
|
||||
);
|
||||
|
||||
var category = config.service.postService.categories
|
||||
.firstWhere((element) => element.key == post.category);
|
||||
|
||||
var backButton = IconButton(
|
||||
color: Colors.white,
|
||||
icon: const Icon(Icons.arrow_back_ios),
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
);
|
||||
|
||||
return config.postViewOpenPageBuilder
|
||||
?.call(context, timelinePostScreen, backButton, post, category) ??
|
||||
Scaffold(
|
||||
appBar: AppBar(
|
||||
iconTheme: Theme.of(context).appBarTheme.iconTheme,
|
||||
title: Text(
|
||||
category.title.toLowerCase(),
|
||||
style: TextStyle(
|
||||
color: Theme.of(context).primaryColor,
|
||||
fontSize: 24,
|
||||
fontWeight: FontWeight.w800,
|
||||
),
|
||||
),
|
||||
),
|
||||
body: timelinePostScreen,
|
||||
},
|
||||
onTapPost: (post) async {
|
||||
var currentUser = await timelineService.getCurrentUser();
|
||||
if (context.mounted)
|
||||
await widget.options.onTapPost?.call(post, currentUser) ??
|
||||
await _push(_timelinePostDetailScreenWidget(post, currentUser));
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// A widget function that creates a post creation screen route.
|
||||
///
|
||||
/// This function creates a route for displaying a post creation screen.
|
||||
/// It takes a [BuildContext] and an optional [TimelineUserStoryConfiguration]
|
||||
/// as parameters. If no configuration is provided, default values will be used.
|
||||
Widget _postCreationScreenRoute({
|
||||
required BuildContext context,
|
||||
required TimelineCategory category,
|
||||
required TimelineUserStoryConfiguration config,
|
||||
}) {
|
||||
var timelinePostCreationScreen = TimelinePostCreationScreen(
|
||||
userId: config.getUserId?.call(context) ?? config.userId,
|
||||
options: config.optionsBuilder(context),
|
||||
service: config.service,
|
||||
onPostCreated: (post) async {
|
||||
var newPost = await config.service.postService.createPost(post);
|
||||
|
||||
if (!context.mounted) return;
|
||||
if (config.afterPostCreationGoHome) {
|
||||
await Navigator.pushReplacement(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => _timelineScreenRoute(
|
||||
config: config,
|
||||
context: context,
|
||||
initalCategory: category.title,
|
||||
),
|
||||
),
|
||||
);
|
||||
} else {
|
||||
await Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => _postOverviewScreenRoute(
|
||||
config: config,
|
||||
context: context,
|
||||
post: newPost,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
onPostOverview: (post) async => Navigator.of(context).push(
|
||||
MaterialPageRoute(
|
||||
builder: (context) => _postOverviewScreenRoute(
|
||||
config: config,
|
||||
context: context,
|
||||
post: post,
|
||||
),
|
||||
),
|
||||
),
|
||||
enablePostOverviewScreen: config.enablePostOverviewScreen,
|
||||
postCategory: category.key,
|
||||
);
|
||||
|
||||
var backButton = IconButton(
|
||||
icon: const Icon(
|
||||
Icons.arrow_back_ios,
|
||||
color: Colors.white,
|
||||
),
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
);
|
||||
|
||||
return config.postCreationOpenPageBuilder
|
||||
?.call(context, timelinePostCreationScreen, backButton) ??
|
||||
Scaffold(
|
||||
appBar: AppBar(
|
||||
iconTheme: Theme.of(context).appBarTheme.iconTheme,
|
||||
leading: backButton,
|
||||
title: Text(
|
||||
config.optionsBuilder(context).translations.postCreation,
|
||||
style: TextStyle(
|
||||
color: Theme.of(context).primaryColor,
|
||||
fontSize: 24,
|
||||
fontWeight: FontWeight.w800,
|
||||
),
|
||||
),
|
||||
),
|
||||
body: timelinePostCreationScreen,
|
||||
);
|
||||
}
|
||||
|
||||
/// A widget function that creates a post overview screen route.
|
||||
///
|
||||
/// This function creates a route for displaying a post overview screen.
|
||||
/// It takes a [BuildContext], a [TimelinePost], and an optional
|
||||
/// [TimelineUserStoryConfiguration] as parameters. If no configuration is
|
||||
/// provided, default values will be used.
|
||||
Widget _postOverviewScreenRoute({
|
||||
required BuildContext context,
|
||||
required TimelinePost post,
|
||||
required TimelineUserStoryConfiguration config,
|
||||
}) {
|
||||
var timelinePostOverviewWidget = TimelinePostOverviewScreen(
|
||||
options: config.optionsBuilder(context),
|
||||
service: config.service,
|
||||
timelinePost: post,
|
||||
onPostSubmit: (post) async {
|
||||
var createdPost = await config.service.postService.createPost(post);
|
||||
config.onPostCreate?.call(createdPost);
|
||||
if (context.mounted) {
|
||||
await Navigator.of(context).pushAndRemoveUntil(
|
||||
MaterialPageRoute(
|
||||
builder: (context) => _timelineScreenRoute(
|
||||
config: config,
|
||||
context: context,
|
||||
initalCategory: post.category,
|
||||
),
|
||||
),
|
||||
(route) => false,
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
var backButton = IconButton(
|
||||
icon: const Icon(
|
||||
Icons.arrow_back_ios,
|
||||
color: Colors.white,
|
||||
),
|
||||
onPressed: () async => Navigator.of(context).pop(),
|
||||
);
|
||||
|
||||
return config.postOverviewOpenPageBuilder?.call(
|
||||
context,
|
||||
timelinePostOverviewWidget,
|
||||
) ??
|
||||
Scaffold(
|
||||
appBar: AppBar(
|
||||
iconTheme: Theme.of(context).appBarTheme.iconTheme,
|
||||
leading: backButton,
|
||||
title: Text(
|
||||
config.optionsBuilder(context).translations.postCreation,
|
||||
style: TextStyle(
|
||||
color: Theme.of(context).primaryColor,
|
||||
fontSize: 24,
|
||||
fontWeight: FontWeight.w800,
|
||||
),
|
||||
),
|
||||
),
|
||||
body: timelinePostOverviewWidget,
|
||||
);
|
||||
}
|
||||
|
||||
Widget _postCategorySelectionScreen({
|
||||
required BuildContext context,
|
||||
required TimelineUserStoryConfiguration config,
|
||||
}) {
|
||||
var timelineSelectionScreen = TimelineSelectionScreen(
|
||||
postService: config.service.postService,
|
||||
options: config.optionsBuilder(context),
|
||||
categories: config.service.postService.categories,
|
||||
onCategorySelected: (category) async {
|
||||
await Navigator.of(context).push(
|
||||
MaterialPageRoute(
|
||||
builder: (context) => _postCreationScreenRoute(
|
||||
config: config,
|
||||
context: context,
|
||||
category: category,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
var backButton = IconButton(
|
||||
color: Colors.white,
|
||||
icon: const Icon(Icons.arrow_back_ios),
|
||||
onPressed: () async {
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
);
|
||||
|
||||
return config.categorySelectionOpenPageBuilder
|
||||
?.call(context, timelineSelectionScreen) ??
|
||||
Scaffold(
|
||||
appBar: AppBar(
|
||||
iconTheme: Theme.of(context).appBarTheme.iconTheme,
|
||||
leading: backButton,
|
||||
title: Text(
|
||||
config.optionsBuilder(context).translations.postCreation,
|
||||
style: TextStyle(
|
||||
color: Theme.of(context).primaryColor,
|
||||
fontSize: 24,
|
||||
fontWeight: FontWeight.w800,
|
||||
),
|
||||
),
|
||||
),
|
||||
body: timelineSelectionScreen,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> routeToPostDetail(BuildContext context, TimelinePost post) async {
|
||||
await Navigator.of(context).push(
|
||||
MaterialPageRoute(
|
||||
builder: (context) => _postDetailScreenRoute(
|
||||
config: timelineUserStoryConfiguration,
|
||||
context: context,
|
||||
Widget _timelinePostDetailScreenWidget(
|
||||
TimelinePost post,
|
||||
TimelineUser currentUser,
|
||||
) =>
|
||||
TimelinePostDetailScreen(
|
||||
currentUserId: widget.currentUserId,
|
||||
timelineService: timelineService,
|
||||
currentUser: currentUser,
|
||||
options: widget.options,
|
||||
post: post,
|
||||
),
|
||||
),
|
||||
);
|
||||
);
|
||||
|
||||
Widget _timelineChooseCategoryScreenWidget() => TimelineChooseCategoryScreen(
|
||||
timelineService: timelineService,
|
||||
options: widget.options,
|
||||
ontapCategory: (category) async {
|
||||
await widget.options.onTapCategory?.call(category) ??
|
||||
await _push(_timelineAddpostInformationScreenWidget());
|
||||
},
|
||||
);
|
||||
|
||||
Widget _timelineAddpostInformationScreenWidget() =>
|
||||
TimelineAddPostInformationScreen(
|
||||
timelineService: timelineService,
|
||||
options: widget.options,
|
||||
onTapOverview: () async {
|
||||
await widget.options.onTapOverview?.call() ??
|
||||
await _push(_timelinePostOverviewWidget());
|
||||
},
|
||||
);
|
||||
|
||||
Widget _timelinePostOverviewWidget() => TimelinePostOverview(
|
||||
timelineService: timelineService,
|
||||
options: widget.options,
|
||||
onTapCreatePost: (post) async {
|
||||
await widget.options.onTapCreatePostInOverview?.call(post) ??
|
||||
await _pushAndRemoveUntil(_timelineScreenWidget());
|
||||
},
|
||||
);
|
||||
|
||||
Future<void> _push(Widget screen) async {
|
||||
await Navigator.of(context)
|
||||
.push(MaterialPageRoute(builder: (context) => screen));
|
||||
}
|
||||
|
||||
Future<void> _pushAndRemoveUntil(Widget screen) async {
|
||||
await Navigator.of(context).pushAndRemoveUntil(
|
||||
MaterialPageRoute(builder: (context) => screen),
|
||||
(route) => route.isFirst,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,165 +0,0 @@
|
|||
// SPDX-FileCopyrightText: 2023 Iconica
|
||||
//
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_timeline_interface/flutter_timeline_interface.dart';
|
||||
import 'package:flutter_timeline_view/flutter_timeline_view.dart';
|
||||
|
||||
/// Configuration class for defining user-specific settings and callbacks for a
|
||||
/// timeline user story.
|
||||
///
|
||||
/// This class holds various parameters to customize the behavior and appearance
|
||||
/// of a user story timeline.
|
||||
@immutable
|
||||
class TimelineUserStoryConfiguration {
|
||||
/// Constructs a [TimelineUserStoryConfiguration] with the specified
|
||||
/// parameters.
|
||||
///
|
||||
/// [service] is the TimelineService responsible for fetching user story data.
|
||||
///
|
||||
/// [optionsBuilder] is a function that builds TimelineOptions based on the
|
||||
/// given [BuildContext].
|
||||
///
|
||||
/// [userId] is the ID of the user associated with this user story
|
||||
/// configuration. Default is 'test_user'.
|
||||
///
|
||||
/// [openPageBuilder] is a function that defines the behavior when a page
|
||||
/// needs to be opened. This function should accept a [BuildContext] and a
|
||||
/// child widget.
|
||||
///
|
||||
/// [onPostTap] is a callback function invoked when a timeline post is
|
||||
/// tapped. It should accept a [BuildContext] and the tapped post.
|
||||
///
|
||||
/// [onUserTap] is a callback function invoked when the user's profile is
|
||||
/// tapped. It should accept a [BuildContext] and the user ID of the tapped
|
||||
/// user.
|
||||
///
|
||||
/// [onPostDelete] is a callback function invoked when a post deletion is
|
||||
/// requested. It should accept a [BuildContext] and the post widget. This
|
||||
/// function can return a widget to be displayed after the post is deleted.
|
||||
///
|
||||
/// [filterEnabled] determines whether filtering functionality is enabled for
|
||||
/// this user story timeline. Default is false.
|
||||
///
|
||||
/// [postWidgetBuilder] is a function that builds a widget for a timeline
|
||||
/// post. It should accept a [TimelinePost] and return a widget representing
|
||||
/// that post.
|
||||
const TimelineUserStoryConfiguration({
|
||||
required this.service,
|
||||
required this.optionsBuilder,
|
||||
this.getUserId,
|
||||
this.serviceBuilder,
|
||||
this.canDeleteAllPosts,
|
||||
this.userId = 'test_user',
|
||||
this.homeOpenPageBuilder,
|
||||
this.postCreationOpenPageBuilder,
|
||||
this.postViewOpenPageBuilder,
|
||||
this.postOverviewOpenPageBuilder,
|
||||
this.onPostTap,
|
||||
this.onUserTap,
|
||||
this.onRefresh,
|
||||
this.onPostDelete,
|
||||
this.filterEnabled = false,
|
||||
this.postWidgetBuilder,
|
||||
this.afterPostCreationGoHome = false,
|
||||
this.enablePostOverviewScreen = true,
|
||||
this.categorySelectionOpenPageBuilder,
|
||||
this.onPostCreate,
|
||||
});
|
||||
|
||||
/// The ID of the user associated with this user story configuration.
|
||||
final String userId;
|
||||
|
||||
/// A function to get the userId only when needed and with a context
|
||||
final String Function(BuildContext context)? getUserId;
|
||||
|
||||
/// A function to determine if a user can delete posts that is called
|
||||
/// when needed
|
||||
final bool Function(BuildContext context)? canDeleteAllPosts;
|
||||
|
||||
/// The TimelineService responsible for fetching user story data.
|
||||
final TimelineService service;
|
||||
|
||||
/// A function to get the timeline service only when needed and with a context
|
||||
final TimelineService Function(BuildContext context)? serviceBuilder;
|
||||
|
||||
/// A function that builds TimelineOptions based on the given BuildContext.
|
||||
final TimelineOptions Function(BuildContext context) optionsBuilder;
|
||||
|
||||
/// Open page builder function for the home page. This function accepts
|
||||
/// a [BuildContext], a child widget, and a FloatingActionButton which can
|
||||
/// route to the post creation page.
|
||||
|
||||
final Function(
|
||||
BuildContext context,
|
||||
Widget child,
|
||||
FloatingActionButton? button,
|
||||
)? homeOpenPageBuilder;
|
||||
|
||||
/// Open page builder function for the post creation page. This function
|
||||
/// accepts a [BuildContext], a child widget, and an IconButton which can
|
||||
/// route to the home page.
|
||||
|
||||
final Function(
|
||||
BuildContext context,
|
||||
Widget child,
|
||||
IconButton? button,
|
||||
)? postCreationOpenPageBuilder;
|
||||
|
||||
/// Open page builder function for the post view page. This function accepts
|
||||
/// a [BuildContext], a child widget, and an IconButton which can route to the
|
||||
/// home page.
|
||||
|
||||
final Function(
|
||||
BuildContext context,
|
||||
Widget child,
|
||||
IconButton? button,
|
||||
TimelinePost post,
|
||||
TimelineCategory? category,
|
||||
)? postViewOpenPageBuilder;
|
||||
|
||||
/// Open page builder function for the post overview page. This function
|
||||
/// accepts a [BuildContext], a child widget, and an IconButton which can
|
||||
/// route to the home page.
|
||||
|
||||
final Function(
|
||||
BuildContext context,
|
||||
Widget child,
|
||||
)? postOverviewOpenPageBuilder;
|
||||
|
||||
/// A callback function invoked when a timeline post is tapped.
|
||||
final Function(BuildContext context, TimelinePost post)? onPostTap;
|
||||
|
||||
/// A callback function invoked when the user's profile is tapped.
|
||||
final Function(BuildContext context, String userId)? onUserTap;
|
||||
|
||||
/// A callback function invoked when the timeline is refreshed by pulling down
|
||||
final Function(BuildContext context, String? category)? onRefresh;
|
||||
|
||||
/// A callback function invoked when a post deletion is requested.
|
||||
final Widget Function(BuildContext context, TimelinePost post)? onPostDelete;
|
||||
|
||||
/// Determines whether filtering functionality is enabled for this user story
|
||||
/// timeline.
|
||||
final bool filterEnabled;
|
||||
|
||||
/// A function that builds a widget for a timeline post.
|
||||
final Widget Function(TimelinePost post)? postWidgetBuilder;
|
||||
|
||||
/// Boolean to enable timeline post overview screen before submitting
|
||||
final bool enablePostOverviewScreen;
|
||||
|
||||
/// Boolean to enable redirect to home after post creation.
|
||||
/// If false, it will redirect to created post screen
|
||||
final bool afterPostCreationGoHome;
|
||||
|
||||
/// Open page builder function for the category selection page. This function
|
||||
/// accepts a [BuildContext] and a child widget.
|
||||
final Function(
|
||||
BuildContext context,
|
||||
Widget child,
|
||||
)? categorySelectionOpenPageBuilder;
|
||||
|
||||
final Function(TimelinePost post)? onPostCreate;
|
||||
}
|
190
packages/flutter_timeline/lib/src/models/timeline_options.dart
Normal file
190
packages/flutter_timeline/lib/src/models/timeline_options.dart
Normal file
|
@ -0,0 +1,190 @@
|
|||
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/flutter_timeline.dart";
|
||||
import "package:intl/intl.dart";
|
||||
|
||||
class TimelineOptions {
|
||||
const TimelineOptions({
|
||||
this.translations = const TimelineTranslations(),
|
||||
this.everyoneCanDelete = false,
|
||||
this.onTapLike,
|
||||
this.onTapUnlike,
|
||||
this.onPostDelete,
|
||||
this.userAvatarBuilder = _defaultUserAvatarBuilder,
|
||||
this.iconSize = 24,
|
||||
this.iconColor = Colors.black,
|
||||
this.doubleTapToLike = false,
|
||||
this.userNameBuilder = _defaultNameBuilder,
|
||||
this.floatingActionButtonBuilder = _defaultFloatingActionButton,
|
||||
this.allowCreatingCategories = true,
|
||||
this.initialCategoryId,
|
||||
this.likeIcon = Icons.favorite_outline,
|
||||
this.likedIcon = Icons.favorite,
|
||||
this.commentIcon = Icons.chat_bubble_outline,
|
||||
this.imagePickerTheme,
|
||||
this.dateFormat = _defaultDateFormat,
|
||||
this.buttonBuilder = _defaultButtonBuilder,
|
||||
this.postBuilder,
|
||||
this.timelineScreenDrawer,
|
||||
this.timelineScreenAppBarBuilder,
|
||||
this.onCreatePost,
|
||||
this.onTapComments,
|
||||
this.onTapCreatePost,
|
||||
this.onTapPost,
|
||||
this.onTapCategory,
|
||||
this.onTapOverview,
|
||||
this.onTapCreatePostInOverview,
|
||||
});
|
||||
|
||||
// Builders
|
||||
final UserAvatarBuilder userAvatarBuilder;
|
||||
final UserNameBuilder userNameBuilder;
|
||||
final DateFormat Function(BuildContext context) dateFormat;
|
||||
final FloatingActionButtonBuilder floatingActionButtonBuilder;
|
||||
final ButtonBuilder buttonBuilder;
|
||||
final PostBuilder? postBuilder;
|
||||
|
||||
//general
|
||||
final TimelineTranslations translations;
|
||||
final Function(TimelinePost post, TimelineUser user)? onTapComments;
|
||||
final Function(TimelineCategory? category)? onTapCreatePost;
|
||||
final Function(TimelinePost post, TimelineUser user)? onTapPost;
|
||||
final Function(TimelineCategory? category)? onTapCategory;
|
||||
final Function()? onTapOverview;
|
||||
final Function(TimelinePost post)? onTapCreatePostInOverview;
|
||||
|
||||
// TimelinePostWidget
|
||||
final bool everyoneCanDelete;
|
||||
final VoidCallback? onTapLike;
|
||||
final VoidCallback? onTapUnlike;
|
||||
final VoidCallback? onPostDelete;
|
||||
final Function(TimelinePost post)? onCreatePost;
|
||||
final double iconSize;
|
||||
final Color iconColor;
|
||||
final IconData likeIcon;
|
||||
final IconData likedIcon;
|
||||
final IconData commentIcon;
|
||||
final bool doubleTapToLike;
|
||||
final bool allowCreatingCategories;
|
||||
final String? initialCategoryId;
|
||||
final ImagePickerTheme? imagePickerTheme;
|
||||
final Widget? timelineScreenDrawer;
|
||||
final AppBarBuilder? timelineScreenAppBarBuilder;
|
||||
}
|
||||
|
||||
Widget _defaultFloatingActionButton(
|
||||
Function() onPressed,
|
||||
BuildContext context,
|
||||
) {
|
||||
var theme = Theme.of(context);
|
||||
return FloatingActionButton.large(
|
||||
backgroundColor: theme.primaryColor,
|
||||
onPressed: onPressed,
|
||||
child: Icon(
|
||||
Icons.add,
|
||||
size: 44,
|
||||
color: theme.colorScheme.onPrimary,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _defaultUserAvatarBuilder(TimelineUser? user, double size) {
|
||||
if (user == null || user.imageUrl == null) {
|
||||
return CircleAvatar(
|
||||
radius: size / 2,
|
||||
child: const Icon(
|
||||
Icons.person,
|
||||
),
|
||||
);
|
||||
}
|
||||
return Container(
|
||||
height: size,
|
||||
width: size,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
image: DecorationImage(
|
||||
image: CachedNetworkImageProvider(
|
||||
user.imageUrl!,
|
||||
),
|
||||
fit: BoxFit.cover,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _defaultNameBuilder(
|
||||
TimelineUser? user,
|
||||
String anonymousUserText,
|
||||
BuildContext context,
|
||||
) {
|
||||
if (user == null || user.fullName == null) {
|
||||
return Text(anonymousUserText);
|
||||
}
|
||||
return Text(
|
||||
user.fullName!,
|
||||
style: Theme.of(context).textTheme.titleSmall?.copyWith(
|
||||
color: Colors.black,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _defaultButtonBuilder({
|
||||
required String title,
|
||||
required Function() onPressed,
|
||||
required BuildContext context,
|
||||
}) {
|
||||
var theme = Theme.of(context);
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 20),
|
||||
child: FilledButton(
|
||||
style: ElevatedButton.styleFrom(
|
||||
maximumSize: const Size(254, 50),
|
||||
minimumSize: const Size(254, 50),
|
||||
),
|
||||
onPressed: onPressed,
|
||||
child: Text(
|
||||
title,
|
||||
style: theme.textTheme.displayLarge,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
DateFormat _defaultDateFormat(BuildContext context) => DateFormat(
|
||||
"dd/MM/yyyy 'at' HH:mm",
|
||||
Localizations.localeOf(context).languageCode,
|
||||
);
|
||||
|
||||
typedef UserAvatarBuilder = Widget Function(
|
||||
TimelineUser? user,
|
||||
double size,
|
||||
);
|
||||
|
||||
typedef UserNameBuilder = Widget Function(
|
||||
TimelineUser? user,
|
||||
String anonymousUserText,
|
||||
BuildContext context,
|
||||
);
|
||||
|
||||
typedef FloatingActionButtonBuilder = Widget Function(
|
||||
Function() onPressed,
|
||||
BuildContext context,
|
||||
);
|
||||
|
||||
typedef ButtonBuilder = Widget Function({
|
||||
required String title,
|
||||
required Function() onPressed,
|
||||
required BuildContext context,
|
||||
});
|
||||
|
||||
typedef PostBuilder = Widget Function({
|
||||
required TimelinePost post,
|
||||
required Function(TimelinePost) onTap,
|
||||
required BuildContext context,
|
||||
});
|
||||
|
||||
typedef AppBarBuilder = PreferredSizeWidget Function(
|
||||
BuildContext context,
|
||||
String title,
|
||||
);
|
|
@ -0,0 +1,58 @@
|
|||
class TimelineTranslations {
|
||||
const TimelineTranslations({
|
||||
this.timelineTitle = "iconinstagram",
|
||||
this.viewPostTitle = "View post",
|
||||
this.deletePostTitle = "Delete",
|
||||
this.oneLikeTitle = "like",
|
||||
this.multipleLikesTitle = "likes",
|
||||
this.anonymousUser = "Anonymous User",
|
||||
this.commentFieldHint = "Write your comment here...",
|
||||
this.commentsTitle = "Comments",
|
||||
this.allowCommentsYes = "Yes",
|
||||
this.allowCommentsNo = "No",
|
||||
this.allowCommentsTitle = "Are people allowed to comment?",
|
||||
this.allowCommentsDescription =
|
||||
"Indicate whether people are allowed to respond",
|
||||
this.uploadimageTitle = "Upload image",
|
||||
this.uploadImageDescription = "Upload an image to your message",
|
||||
this.postTitle = "Title",
|
||||
this.postTitleHint = "Title...",
|
||||
this.contentTitle = "Content",
|
||||
this.contentDescription = "What do you want to share?",
|
||||
this.contentTitleHint = "Content...",
|
||||
this.titleErrorText = "Please enter a title",
|
||||
this.contentErrorText = "Please enter content",
|
||||
this.addPost = "Add post",
|
||||
this.overviewButton = "Overview",
|
||||
this.chooseCategory = "Choose a category",
|
||||
this.addCategory = "Add category",
|
||||
this.postButtonTitle = "Post",
|
||||
});
|
||||
|
||||
final String timelineTitle;
|
||||
final String viewPostTitle;
|
||||
final String deletePostTitle;
|
||||
final String oneLikeTitle;
|
||||
final String multipleLikesTitle;
|
||||
final String anonymousUser;
|
||||
final String commentFieldHint;
|
||||
final String commentsTitle;
|
||||
final String allowCommentsYes;
|
||||
final String allowCommentsNo;
|
||||
final String allowCommentsTitle;
|
||||
final String allowCommentsDescription;
|
||||
final String uploadimageTitle;
|
||||
final String uploadImageDescription;
|
||||
final String postTitle;
|
||||
final String postTitleHint;
|
||||
final String contentTitle;
|
||||
final String contentDescription;
|
||||
final String contentTitleHint;
|
||||
final String titleErrorText;
|
||||
final String contentErrorText;
|
||||
final String addPost;
|
||||
final String overviewButton;
|
||||
final String chooseCategory;
|
||||
final String addCategory;
|
||||
final String postButtonTitle;
|
||||
}
|
|
@ -1,16 +0,0 @@
|
|||
// SPDX-FileCopyrightText: 2023 Iconica
|
||||
//
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
mixin TimelineUserStoryRoutes {
|
||||
static const String timelineHome = '/timeline';
|
||||
static const String timelineView = '/timeline-view/:post';
|
||||
static String timelineViewPath(String postId) => '/timeline-view/$postId';
|
||||
static String timelinepostCreation(String category) =>
|
||||
'/timeline-post-creation/$category';
|
||||
|
||||
static const String timelinePostCreation =
|
||||
'/timeline-post-creation/:category';
|
||||
static String timelinePostOverview = '/timeline-post-overview';
|
||||
static String timelineCategorySelection = '/timeline-category-selection';
|
||||
}
|
|
@ -0,0 +1,206 @@
|
|||
import "dart:typed_data";
|
||||
|
||||
import "package:flutter/material.dart";
|
||||
import "package:flutter_timeline/flutter_timeline.dart";
|
||||
|
||||
class TimelineAddPostInformationScreen extends StatefulWidget {
|
||||
const TimelineAddPostInformationScreen({
|
||||
required this.timelineService,
|
||||
required this.onTapOverview,
|
||||
required this.options,
|
||||
super.key,
|
||||
});
|
||||
final TimelineService timelineService;
|
||||
final void Function() onTapOverview;
|
||||
final TimelineOptions options;
|
||||
|
||||
@override
|
||||
State<TimelineAddPostInformationScreen> createState() =>
|
||||
_TimelineAddPostInformationScreenState();
|
||||
}
|
||||
|
||||
class _TimelineAddPostInformationScreenState
|
||||
extends State<TimelineAddPostInformationScreen> {
|
||||
final titleController = TextEditingController();
|
||||
final contentController = TextEditingController();
|
||||
bool allowedToComment = true;
|
||||
Uint8List? image;
|
||||
final _formKey = GlobalKey<FormState>();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
var theme = Theme.of(context);
|
||||
|
||||
var category =
|
||||
widget.timelineService.categoryRepository.getSelectedCategory();
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text(
|
||||
category?.title.toLowerCase() ?? widget.options.translations.addPost,
|
||||
),
|
||||
),
|
||||
body: CustomScrollView(
|
||||
shrinkWrap: true,
|
||||
slivers: [
|
||||
SliverList(
|
||||
delegate: SliverChildListDelegate([
|
||||
Form(
|
||||
key: _formKey,
|
||||
child: Padding(
|
||||
padding:
|
||||
const EdgeInsets.symmetric(vertical: 20, horizontal: 32),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
widget.options.translations.postTitle,
|
||||
style: theme.textTheme.titleMedium,
|
||||
),
|
||||
PostInfoTextfield(
|
||||
expands: false,
|
||||
maxLines: 1,
|
||||
controller: titleController,
|
||||
hintText: widget.options.translations.postTitleHint,
|
||||
validator: (value) {
|
||||
if (value == null || value.isEmpty) {
|
||||
return widget.options.translations.titleErrorText;
|
||||
}
|
||||
return null;
|
||||
},
|
||||
),
|
||||
const SizedBox(
|
||||
height: 16,
|
||||
),
|
||||
Text(
|
||||
widget.options.translations.contentTitle,
|
||||
style: theme.textTheme.titleMedium,
|
||||
),
|
||||
Text(
|
||||
widget.options.translations.contentDescription,
|
||||
style: theme.textTheme.bodySmall,
|
||||
),
|
||||
PostInfoTextfield(
|
||||
expands: false,
|
||||
maxLines: 1,
|
||||
controller: contentController,
|
||||
hintText: widget.options.translations.contentTitleHint,
|
||||
validator: (value) {
|
||||
if (value == null || value.isEmpty) {
|
||||
return widget.options.translations.contentErrorText;
|
||||
}
|
||||
return null;
|
||||
},
|
||||
),
|
||||
const SizedBox(
|
||||
height: 16,
|
||||
),
|
||||
Text(
|
||||
widget.options.translations.uploadimageTitle,
|
||||
style: theme.textTheme.titleMedium,
|
||||
),
|
||||
Text(
|
||||
widget.options.translations.uploadImageDescription,
|
||||
style: theme.textTheme.bodySmall,
|
||||
),
|
||||
const SizedBox(
|
||||
height: 12,
|
||||
),
|
||||
ImagePickerWidget(
|
||||
onImageChanged: (pickedImage) {
|
||||
image = pickedImage;
|
||||
setState(() {});
|
||||
},
|
||||
),
|
||||
const SizedBox(
|
||||
height: 24,
|
||||
),
|
||||
Text(
|
||||
widget.options.translations.allowCommentsTitle,
|
||||
style: theme.textTheme.titleMedium,
|
||||
),
|
||||
Text(
|
||||
widget.options.translations.allowCommentsDescription,
|
||||
style: theme.textTheme.bodySmall,
|
||||
),
|
||||
Row(
|
||||
children: [
|
||||
Radio<bool>(
|
||||
materialTapTargetSize:
|
||||
MaterialTapTargetSize.shrinkWrap,
|
||||
visualDensity: const VisualDensity(horizontal: -4),
|
||||
value: true,
|
||||
groupValue: allowedToComment,
|
||||
onChanged: (value) {
|
||||
setState(() {
|
||||
allowedToComment = true;
|
||||
});
|
||||
},
|
||||
),
|
||||
const SizedBox(
|
||||
width: 8,
|
||||
),
|
||||
Text(widget.options.translations.allowCommentsYes),
|
||||
Radio<bool>(
|
||||
materialTapTargetSize:
|
||||
MaterialTapTargetSize.shrinkWrap,
|
||||
visualDensity: VisualDensity.standard,
|
||||
value: false,
|
||||
groupValue: allowedToComment,
|
||||
onChanged: (value) {
|
||||
setState(() {
|
||||
allowedToComment = false;
|
||||
});
|
||||
},
|
||||
),
|
||||
Text(widget.options.translations.allowCommentsNo),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
]),
|
||||
),
|
||||
SliverFillRemaining(
|
||||
hasScrollBody: false,
|
||||
fillOverscroll: false,
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
widget.options.buttonBuilder(
|
||||
title: widget.options.translations.overviewButton,
|
||||
onPressed: () async {
|
||||
if (!_formKey.currentState!.validate()) {
|
||||
return;
|
||||
}
|
||||
var user = await widget.timelineService.getCurrentUser();
|
||||
widget.timelineService.setCurrentPost(
|
||||
TimelinePost(
|
||||
id: "",
|
||||
creatorId: user.userId,
|
||||
title: titleController.text,
|
||||
content: contentController.text,
|
||||
image: image,
|
||||
likes: 0,
|
||||
reaction: 0,
|
||||
createdAt: DateTime.now(),
|
||||
reactionEnabled: allowedToComment,
|
||||
category: category?.key,
|
||||
reactions: [],
|
||||
likedBy: [],
|
||||
creator: user,
|
||||
),
|
||||
);
|
||||
widget.onTapOverview();
|
||||
},
|
||||
context: context,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,198 @@
|
|||
import "package:flutter/material.dart";
|
||||
import "package:flutter_timeline/flutter_timeline.dart";
|
||||
|
||||
class TimelineChooseCategoryScreen extends StatelessWidget {
|
||||
const TimelineChooseCategoryScreen({
|
||||
required this.timelineService,
|
||||
required this.ontapCategory,
|
||||
required this.options,
|
||||
super.key,
|
||||
});
|
||||
final TimelineService timelineService;
|
||||
final Function(TimelineCategory category) ontapCategory;
|
||||
final TimelineOptions options;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
var theme = Theme.of(context);
|
||||
var newCategoryController = TextEditingController();
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text(
|
||||
options.translations.addPost,
|
||||
style: theme.textTheme.headlineLarge,
|
||||
),
|
||||
),
|
||||
body: Padding(
|
||||
padding: const EdgeInsets.all(20),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
options.translations.chooseCategory,
|
||||
style: theme.textTheme.titleLarge,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
StreamBuilder(
|
||||
stream: timelineService.getCategories(),
|
||||
builder: (context, snapshot) {
|
||||
if (snapshot.hasData) {
|
||||
var categories = snapshot.data;
|
||||
return Column(
|
||||
children: [
|
||||
...categories!
|
||||
.where((category) => category.key != null)
|
||||
.map(
|
||||
(category) => CategoryOption(
|
||||
category: category.title,
|
||||
onTap: () {
|
||||
timelineService.selectCategory(category.key);
|
||||
|
||||
ontapCategory.call(category);
|
||||
},
|
||||
),
|
||||
),
|
||||
if (options.allowCreatingCategories)
|
||||
CategoryOption(
|
||||
addCategory: true,
|
||||
category: options.translations.addCategory,
|
||||
onTap: () async {
|
||||
/// shop dialog to add category
|
||||
|
||||
await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
backgroundColor: theme.scaffoldBackgroundColor,
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Text(
|
||||
options.translations.addCategory,
|
||||
style: theme.textTheme.titleLarge,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
PostInfoTextfield(
|
||||
controller: newCategoryController,
|
||||
hintText: "Category...",
|
||||
validator: (p0) {
|
||||
if (p0 == null || p0.isEmpty) {
|
||||
return "Category cannot be empty";
|
||||
}
|
||||
return null;
|
||||
},
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
),
|
||||
child: options.buttonBuilder(
|
||||
title: options.translations.addCategory,
|
||||
context: context,
|
||||
onPressed: () async {
|
||||
await timelineService.createCategory(
|
||||
TimelineCategory(
|
||||
key: newCategoryController.text
|
||||
.toLowerCase(),
|
||||
title: newCategoryController.text,
|
||||
),
|
||||
);
|
||||
if (context.mounted)
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
),
|
||||
),
|
||||
TextButton(
|
||||
child: Text(
|
||||
"Cancel",
|
||||
style: theme.textTheme.titleMedium,
|
||||
),
|
||||
onPressed: () {
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
return const CircularProgressIndicator();
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class CategoryOption extends StatelessWidget {
|
||||
const CategoryOption({
|
||||
required this.category,
|
||||
required this.onTap,
|
||||
this.addCategory = false,
|
||||
super.key,
|
||||
});
|
||||
final String category;
|
||||
final bool addCategory;
|
||||
final Function() onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
var theme = Theme.of(context);
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 8),
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(
|
||||
width: 2,
|
||||
color: addCategory
|
||||
? Colors.black.withOpacity(0.3)
|
||||
: theme.primaryColor,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
if (addCategory) ...[
|
||||
const SizedBox(width: 8),
|
||||
Icon(
|
||||
Icons.add,
|
||||
color: addCategory
|
||||
? Colors.black.withOpacity(0.3)
|
||||
: theme.primaryColor,
|
||||
),
|
||||
],
|
||||
Padding(
|
||||
padding: EdgeInsets.symmetric(
|
||||
vertical: 16,
|
||||
horizontal: addCategory ? 8 : 16,
|
||||
),
|
||||
child: Text(
|
||||
category,
|
||||
style: theme.textTheme.titleMedium?.copyWith(
|
||||
color: addCategory
|
||||
? Colors.black.withOpacity(0.3)
|
||||
: Colors.black,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,108 @@
|
|||
import "package:flutter/material.dart";
|
||||
import "package:flutter_svg/svg.dart";
|
||||
import "package:flutter_timeline/flutter_timeline.dart";
|
||||
|
||||
class TimelinePostDetailScreen extends StatefulWidget {
|
||||
const TimelinePostDetailScreen({
|
||||
required this.post,
|
||||
required this.timelineService,
|
||||
required this.options,
|
||||
required this.currentUserId,
|
||||
required this.currentUser,
|
||||
super.key,
|
||||
});
|
||||
|
||||
final TimelinePost post;
|
||||
final TimelineService timelineService;
|
||||
final TimelineOptions options;
|
||||
final String currentUserId;
|
||||
final TimelineUser? currentUser;
|
||||
|
||||
@override
|
||||
State<TimelinePostDetailScreen> createState() =>
|
||||
_TimelinePostDetailScreenState();
|
||||
}
|
||||
|
||||
class _TimelinePostDetailScreenState extends State<TimelinePostDetailScreen> {
|
||||
final TextEditingController _commentController = TextEditingController();
|
||||
TimelineCategory? selectedCategory;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
selectedCategory = widget.timelineService.categoryRepository
|
||||
.selectCategory(widget.post.category);
|
||||
super.initState();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
var theme = Theme.of(context);
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text(
|
||||
selectedCategory?.key == null
|
||||
? widget.timelineService
|
||||
.getCategory(widget.post.category)
|
||||
?.title ??
|
||||
""
|
||||
: selectedCategory?.title ?? "",
|
||||
style: theme.textTheme.headlineLarge,
|
||||
),
|
||||
),
|
||||
body: Stack(
|
||||
children: [
|
||||
SingleChildScrollView(
|
||||
child: TimelinePostWidget(
|
||||
post: widget.post,
|
||||
timelineService: widget.timelineService,
|
||||
options: widget.options,
|
||||
isInDetialView: true,
|
||||
currentUserId: widget.currentUserId,
|
||||
onTapPost: (post) {},
|
||||
onTapComments: (post) {},
|
||||
),
|
||||
),
|
||||
if (widget.post.reactionEnabled)
|
||||
Align(
|
||||
alignment: Alignment.bottomCenter,
|
||||
child: ReactionTextfield(
|
||||
controller: _commentController,
|
||||
options: widget.options,
|
||||
user: widget.currentUser,
|
||||
suffixIcon: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8.0),
|
||||
child: IconButton(
|
||||
onPressed: () async {
|
||||
var comment = _commentController.text;
|
||||
if (comment.isNotEmpty) {
|
||||
var reaction = TimelinePostReaction(
|
||||
id: DateTime.now().millisecondsSinceEpoch.toString(),
|
||||
postId: widget.post.id,
|
||||
creatorId: widget.currentUserId,
|
||||
createdAt: DateTime.now(),
|
||||
reaction: comment,
|
||||
likedBy: [],
|
||||
);
|
||||
await widget.timelineService.postRepository
|
||||
.createReaction(
|
||||
widget.post,
|
||||
reaction,
|
||||
);
|
||||
_commentController.clear();
|
||||
setState(() {});
|
||||
}
|
||||
},
|
||||
icon: SvgPicture.asset(
|
||||
"assets/send.svg",
|
||||
package: "flutter_timeline",
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,76 @@
|
|||
import "package:flutter/material.dart";
|
||||
import "package:flutter_timeline/flutter_timeline.dart";
|
||||
|
||||
class TimelinePostOverview extends StatefulWidget {
|
||||
const TimelinePostOverview({
|
||||
required this.timelineService,
|
||||
required this.options,
|
||||
required this.onTapCreatePost,
|
||||
super.key,
|
||||
});
|
||||
|
||||
final TimelineService timelineService;
|
||||
final TimelineOptions options;
|
||||
final Function(TimelinePost post) onTapCreatePost;
|
||||
|
||||
@override
|
||||
State<TimelinePostOverview> createState() => _TimelinePostOverviewState();
|
||||
}
|
||||
|
||||
class _TimelinePostOverviewState extends State<TimelinePostOverview> {
|
||||
bool isLoading = false;
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
var currentPost = widget.timelineService.getCurrentPost();
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text(
|
||||
widget.options.translations.addPost,
|
||||
),
|
||||
),
|
||||
body: CustomScrollView(
|
||||
shrinkWrap: true,
|
||||
slivers: [
|
||||
SliverList(
|
||||
delegate: SliverChildListDelegate([
|
||||
Column(
|
||||
children: [
|
||||
TimelinePostWidget(
|
||||
timelineService: widget.timelineService,
|
||||
post: currentPost,
|
||||
options: widget.options,
|
||||
currentUserId: currentPost.creatorId,
|
||||
onTapPost: (post) {},
|
||||
onTapComments: (post) {},
|
||||
isInDetialView: true,
|
||||
isInPostOverview: true,
|
||||
),
|
||||
],
|
||||
),
|
||||
]),
|
||||
),
|
||||
SliverFillRemaining(
|
||||
hasScrollBody: false,
|
||||
fillOverscroll: false,
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
widget.options.buttonBuilder(
|
||||
title: widget.options.translations.postButtonTitle,
|
||||
onPressed: () async {
|
||||
if (isLoading) return;
|
||||
isLoading = true;
|
||||
await widget.timelineService.createPost(currentPost);
|
||||
widget.options.onCreatePost?.call(currentPost);
|
||||
widget.onTapCreatePost(currentPost);
|
||||
},
|
||||
context: context,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
112
packages/flutter_timeline/lib/src/screens/timeline_screen.dart
Normal file
112
packages/flutter_timeline/lib/src/screens/timeline_screen.dart
Normal file
|
@ -0,0 +1,112 @@
|
|||
import "package:flutter/material.dart";
|
||||
import "package:flutter_timeline/src/models/timeline_options.dart";
|
||||
import "package:flutter_timeline/src/widgets/category_list.dart";
|
||||
import "package:flutter_timeline/src/widgets/post_list.dart";
|
||||
import "package:timeline_repository_interface/timeline_repository_interface.dart";
|
||||
|
||||
class TimelineScreen extends StatefulWidget {
|
||||
const TimelineScreen({
|
||||
required this.options,
|
||||
required this.timelineService,
|
||||
required this.onTapPost,
|
||||
required this.currentUserId,
|
||||
required this.onTapComments,
|
||||
required this.onTapCreatePost,
|
||||
super.key,
|
||||
});
|
||||
final TimelineService timelineService;
|
||||
final TimelineOptions options;
|
||||
final Function(TimelinePost post) onTapPost;
|
||||
final String currentUserId;
|
||||
final Function(TimelinePost post) onTapComments;
|
||||
final Function() onTapCreatePost;
|
||||
|
||||
@override
|
||||
State<TimelineScreen> createState() => _TimelineScreenState();
|
||||
}
|
||||
|
||||
class _TimelineScreenState extends State<TimelineScreen> {
|
||||
final ScrollController _scrollController = ScrollController();
|
||||
bool _isOnTop = true;
|
||||
List<TimelineCategory> categories = [];
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
_scrollController.addListener(_updateIsOnTop);
|
||||
if (widget.timelineService.getSelectedCategory() == null) {
|
||||
widget.timelineService.selectCategory(widget.options.initialCategoryId);
|
||||
}
|
||||
super.initState();
|
||||
}
|
||||
|
||||
void _updateIsOnTop() {
|
||||
setState(() {
|
||||
_isOnTop = _scrollController.position.pixels < 0.1;
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
var translations = widget.options.translations;
|
||||
var theme = Theme.of(context);
|
||||
return Scaffold(
|
||||
drawer: widget.options.timelineScreenDrawer,
|
||||
floatingActionButton: widget.options
|
||||
.floatingActionButtonBuilder(widget.onTapCreatePost, context),
|
||||
appBar: widget.options.timelineScreenAppBarBuilder
|
||||
?.call(context, translations.timelineTitle) ??
|
||||
AppBar(
|
||||
title: Text(
|
||||
translations.timelineTitle,
|
||||
style: theme.textTheme.headlineLarge,
|
||||
),
|
||||
),
|
||||
body: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
StreamBuilder(
|
||||
stream: widget.timelineService.getCategories(),
|
||||
builder: (context, snapshot) {
|
||||
if (snapshot.hasData) {
|
||||
categories = snapshot.data!;
|
||||
|
||||
return CategoryList(
|
||||
selectedCategory:
|
||||
widget.timelineService.getSelectedCategory(),
|
||||
categories: categories,
|
||||
isOnTop: _isOnTop,
|
||||
onTap: (category) {
|
||||
widget.timelineService.selectCategory(category.key);
|
||||
setState(() {});
|
||||
},
|
||||
);
|
||||
} else {
|
||||
return const CircularProgressIndicator();
|
||||
}
|
||||
},
|
||||
),
|
||||
StreamBuilder(
|
||||
stream: widget.timelineService.postRepository
|
||||
.getPosts(widget.timelineService.getSelectedCategory()?.key),
|
||||
builder: (context, snapshot) {
|
||||
if (snapshot.hasData) {
|
||||
var posts = snapshot.data!;
|
||||
return PostList(
|
||||
timelineService: widget.timelineService,
|
||||
currentUserId: widget.currentUserId,
|
||||
controller: _scrollController,
|
||||
onTapPost: widget.onTapPost,
|
||||
onTapComments: widget.onTapComments,
|
||||
options: widget.options,
|
||||
posts: posts,
|
||||
);
|
||||
} else {
|
||||
return const CircularProgressIndicator();
|
||||
}
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
57
packages/flutter_timeline/lib/src/widgets/category_list.dart
Normal file
57
packages/flutter_timeline/lib/src/widgets/category_list.dart
Normal file
|
@ -0,0 +1,57 @@
|
|||
import "package:collection/collection.dart";
|
||||
import "package:flutter/material.dart";
|
||||
import "package:flutter_timeline/src/widgets/category_widget.dart";
|
||||
import "package:timeline_repository_interface/timeline_repository_interface.dart";
|
||||
|
||||
class CategoryList extends StatefulWidget {
|
||||
const CategoryList({
|
||||
required this.categories,
|
||||
required this.onTap,
|
||||
required this.isOnTop,
|
||||
required this.selectedCategory,
|
||||
super.key,
|
||||
});
|
||||
|
||||
final List<TimelineCategory> categories;
|
||||
final Function(TimelineCategory) onTap;
|
||||
final bool isOnTop;
|
||||
final TimelineCategory? selectedCategory;
|
||||
|
||||
@override
|
||||
State<CategoryList> createState() => _CategoryListState();
|
||||
}
|
||||
|
||||
class _CategoryListState extends State<CategoryList> {
|
||||
TimelineCategory? selectedCategory;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
selectedCategory = widget.selectedCategory ?? widget.categories.firstOrNull;
|
||||
super.initState();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => SingleChildScrollView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(20),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.start,
|
||||
children: [
|
||||
for (var i = 0; i < widget.categories.length; i++)
|
||||
CategoryWidget(
|
||||
category: widget.categories[i],
|
||||
onTap: (category) {
|
||||
widget.onTap(category);
|
||||
setState(() {
|
||||
selectedCategory = category;
|
||||
});
|
||||
},
|
||||
isOnTop: widget.isOnTop,
|
||||
selected: selectedCategory?.key == widget.categories[i].key,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
134
packages/flutter_timeline/lib/src/widgets/category_widget.dart
Normal file
134
packages/flutter_timeline/lib/src/widgets/category_widget.dart
Normal file
|
@ -0,0 +1,134 @@
|
|||
import "package:flutter/material.dart";
|
||||
import "package:timeline_repository_interface/timeline_repository_interface.dart";
|
||||
|
||||
class CategoryWidget extends StatelessWidget {
|
||||
const CategoryWidget({
|
||||
required this.category,
|
||||
required this.onTap,
|
||||
required this.isOnTop,
|
||||
required this.selected,
|
||||
super.key,
|
||||
});
|
||||
|
||||
final TimelineCategory category;
|
||||
final Function(TimelineCategory) onTap;
|
||||
final bool isOnTop;
|
||||
final bool selected;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
var theme = Theme.of(context);
|
||||
return InkWell(
|
||||
onTap: () => onTap(category),
|
||||
child: AnimatedCrossFade(
|
||||
crossFadeState:
|
||||
isOnTop ? CrossFadeState.showFirst : CrossFadeState.showSecond,
|
||||
duration: const Duration(milliseconds: 100),
|
||||
firstChild: ExpandedCategoryWidget(
|
||||
selected: selected,
|
||||
theme: theme,
|
||||
category: category,
|
||||
),
|
||||
secondChild: CollapsedCategoryWidget(
|
||||
selected: selected,
|
||||
theme: theme,
|
||||
category: category,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class CollapsedCategoryWidget extends StatelessWidget {
|
||||
const CollapsedCategoryWidget({
|
||||
required this.selected,
|
||||
required this.theme,
|
||||
required this.category,
|
||||
super.key,
|
||||
});
|
||||
|
||||
final bool selected;
|
||||
final ThemeData theme;
|
||||
final TimelineCategory category;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => Padding(
|
||||
padding: const EdgeInsets.only(right: 8),
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: selected ? theme.primaryColor : theme.colorScheme.surface,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(
|
||||
color: theme.primaryColor,
|
||||
width: 2,
|
||||
),
|
||||
),
|
||||
width: 140,
|
||||
height: 40,
|
||||
child: Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(left: 8),
|
||||
child: Text(
|
||||
maxLines: 1,
|
||||
category.title,
|
||||
style: selected
|
||||
? theme.textTheme.titleMedium
|
||||
: theme.textTheme.bodyMedium?.copyWith(
|
||||
color: selected
|
||||
? theme.colorScheme.onPrimary
|
||||
: theme.colorScheme.onSurface,
|
||||
),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
class ExpandedCategoryWidget extends StatelessWidget {
|
||||
const ExpandedCategoryWidget({
|
||||
required this.selected,
|
||||
required this.theme,
|
||||
required this.category,
|
||||
super.key,
|
||||
});
|
||||
|
||||
final bool selected;
|
||||
final ThemeData theme;
|
||||
final TimelineCategory category;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => Padding(
|
||||
padding: const EdgeInsets.only(right: 8),
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: selected ? theme.primaryColor : theme.colorScheme.surface,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(
|
||||
color: theme.primaryColor,
|
||||
width: 2,
|
||||
),
|
||||
),
|
||||
width: 140,
|
||||
height: 140,
|
||||
child: Align(
|
||||
alignment: Alignment.bottomLeft,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(8),
|
||||
child: Text(
|
||||
category.title,
|
||||
style: selected
|
||||
? theme.textTheme.titleMedium
|
||||
: theme.textTheme.bodyMedium?.copyWith(
|
||||
color: selected
|
||||
? theme.colorScheme.onPrimary
|
||||
: theme.colorScheme.onSurface,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
117
packages/flutter_timeline/lib/src/widgets/comment_section.dart
Normal file
117
packages/flutter_timeline/lib/src/widgets/comment_section.dart
Normal file
|
@ -0,0 +1,117 @@
|
|||
import "package:cached_network_image/cached_network_image.dart";
|
||||
import "package:flutter/material.dart";
|
||||
import "package:flutter_timeline/flutter_timeline.dart";
|
||||
|
||||
class CommentSection extends StatefulWidget {
|
||||
const CommentSection({
|
||||
required this.options,
|
||||
required this.post,
|
||||
required this.currentUserId,
|
||||
required this.timelineService,
|
||||
super.key,
|
||||
});
|
||||
final TimelineOptions options;
|
||||
final TimelinePost post;
|
||||
final String currentUserId;
|
||||
final TimelineService timelineService;
|
||||
|
||||
@override
|
||||
State<CommentSection> createState() => _CommentSectionState();
|
||||
}
|
||||
|
||||
class _CommentSectionState extends State<CommentSection> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
var theme = Theme.of(context);
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const SizedBox(
|
||||
height: 20,
|
||||
),
|
||||
Text(
|
||||
widget.options.translations.commentsTitle,
|
||||
style: theme.textTheme.titleSmall!.copyWith(color: Colors.black),
|
||||
),
|
||||
const SizedBox(
|
||||
height: 4,
|
||||
),
|
||||
for (TimelinePostReaction reaction in widget.post.reactions ?? []) ...[
|
||||
Builder(
|
||||
builder: (context) => const SizedBox(),
|
||||
),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Expanded(
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
widget.options.userAvatarBuilder.call(reaction.creator, 24),
|
||||
const SizedBox(width: 8),
|
||||
widget.options.userNameBuilder.call(
|
||||
reaction.creator,
|
||||
widget.options.translations.anonymousUser,
|
||||
context,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
if (reaction.imageUrl != null) ...[
|
||||
CachedNetworkImage(
|
||||
imageUrl: reaction.imageUrl!,
|
||||
),
|
||||
] else ...[
|
||||
Flexible(
|
||||
child: Text(
|
||||
reaction.reaction ?? "",
|
||||
style: theme.textTheme.bodySmall!.copyWith(
|
||||
color: Colors.black,
|
||||
),
|
||||
overflow: TextOverflow.clip,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
Builder(
|
||||
builder: (context) {
|
||||
var reactionIsLikedByCurrentUser =
|
||||
reaction.likedBy?.contains(widget.currentUserId) ?? false;
|
||||
return IconButton(
|
||||
iconSize: 14,
|
||||
padding: EdgeInsets.zero,
|
||||
constraints: const BoxConstraints(),
|
||||
icon: Icon(
|
||||
reactionIsLikedByCurrentUser
|
||||
? widget.options.likedIcon
|
||||
: widget.options.likeIcon,
|
||||
),
|
||||
onPressed: () async {
|
||||
if (reactionIsLikedByCurrentUser) {
|
||||
await widget.timelineService.unlikePostReaction(
|
||||
widget.post,
|
||||
reaction,
|
||||
widget.currentUserId,
|
||||
);
|
||||
} else {
|
||||
await widget.timelineService.likePostReaction(
|
||||
widget.post,
|
||||
reaction,
|
||||
widget.currentUserId,
|
||||
);
|
||||
}
|
||||
setState(() {});
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(
|
||||
height: 8,
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
103
packages/flutter_timeline/lib/src/widgets/image_picker.dart
Normal file
103
packages/flutter_timeline/lib/src/widgets/image_picker.dart
Normal file
|
@ -0,0 +1,103 @@
|
|||
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";
|
||||
|
||||
class ImagePickerWidget extends StatefulWidget {
|
||||
const ImagePickerWidget({required this.onImageChanged, super.key});
|
||||
|
||||
final Function(Uint8List?) onImageChanged;
|
||||
|
||||
@override
|
||||
State<ImagePickerWidget> createState() => _ImagePickerWidgetState();
|
||||
}
|
||||
|
||||
class _ImagePickerWidgetState extends State<ImagePickerWidget> {
|
||||
Uint8List? image;
|
||||
@override
|
||||
Widget build(BuildContext context) => Stack(
|
||||
children: [
|
||||
GestureDetector(
|
||||
onTap: () async {
|
||||
image = await pickImage(context);
|
||||
widget.onImageChanged(image);
|
||||
setState(() {});
|
||||
},
|
||||
child: DottedBorder(
|
||||
borderType: BorderType.RRect,
|
||||
dashPattern: const [6, 6],
|
||||
color: Colors.grey,
|
||||
strokeWidth: 3,
|
||||
child: image == null
|
||||
? const SizedBox(
|
||||
height: 150,
|
||||
width: double.infinity,
|
||||
child: Icon(Icons.image, size: 64),
|
||||
)
|
||||
: Image.memory(image!),
|
||||
),
|
||||
),
|
||||
if (image != null) ...[
|
||||
Positioned(
|
||||
top: 8,
|
||||
right: 8,
|
||||
child: GestureDetector(
|
||||
onTap: () {
|
||||
widget.onImageChanged(null);
|
||||
setState(() {
|
||||
image = null;
|
||||
});
|
||||
},
|
||||
child: DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.black.withOpacity(0.5),
|
||||
borderRadius: BorderRadius.circular(8.0),
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.delete,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Future<Uint8List?> pickImage(BuildContext context) async {
|
||||
var theme = Theme.of(context);
|
||||
var result = await showModalBottomSheet<Uint8List?>(
|
||||
context: context,
|
||||
builder: (context) => Container(
|
||||
padding: const EdgeInsets.all(20),
|
||||
color: theme.colorScheme.surface,
|
||||
child: ImagePicker(
|
||||
config: const ImagePickerConfig(),
|
||||
theme: ImagePickerTheme(
|
||||
titleStyle: theme.textTheme.titleMedium,
|
||||
iconSize: 40,
|
||||
selectImageText: "UPLOAD FILE",
|
||||
makePhotoText: "TAKE PICTURE",
|
||||
selectImageIcon: const Icon(
|
||||
size: 40,
|
||||
Icons.insert_drive_file,
|
||||
),
|
||||
closeButtonBuilder: (onTap) => TextButton(
|
||||
onPressed: () {
|
||||
onTap();
|
||||
},
|
||||
child: Text(
|
||||
"Cancel",
|
||||
style: theme.textTheme.bodyMedium!.copyWith(
|
||||
decoration: TextDecoration.underline,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
return result;
|
||||
}
|
|
@ -1,7 +1,7 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import "package:flutter/material.dart";
|
||||
|
||||
class PostCreationTextfield extends StatelessWidget {
|
||||
const PostCreationTextfield({
|
||||
class PostInfoTextfield extends StatelessWidget {
|
||||
const PostInfoTextfield({
|
||||
required this.controller,
|
||||
required this.hintText,
|
||||
required this.validator,
|
||||
|
@ -30,6 +30,7 @@ class PostCreationTextfield extends StatelessWidget {
|
|||
Widget build(BuildContext context) {
|
||||
var theme = Theme.of(context);
|
||||
return TextFormField(
|
||||
keyboardType: TextInputType.text,
|
||||
key: fieldKey,
|
||||
validator: validator,
|
||||
style: theme.textTheme.bodySmall,
|
51
packages/flutter_timeline/lib/src/widgets/post_list.dart
Normal file
51
packages/flutter_timeline/lib/src/widgets/post_list.dart
Normal file
|
@ -0,0 +1,51 @@
|
|||
import "package:flutter/material.dart";
|
||||
import "package:flutter_timeline/flutter_timeline.dart";
|
||||
|
||||
class PostList extends StatelessWidget {
|
||||
const PostList({
|
||||
required this.controller,
|
||||
required this.posts,
|
||||
required this.timelineService,
|
||||
required this.options,
|
||||
required this.onTapPost,
|
||||
required this.currentUserId,
|
||||
required this.onTapComments,
|
||||
super.key,
|
||||
});
|
||||
|
||||
final ScrollController controller;
|
||||
final List<TimelinePost> posts;
|
||||
final TimelineService timelineService;
|
||||
final TimelineOptions options;
|
||||
final Function(TimelinePost post) onTapPost;
|
||||
final String currentUserId;
|
||||
final Function(TimelinePost post) onTapComments;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => Expanded(
|
||||
child: ListView.builder(
|
||||
controller: controller,
|
||||
itemCount: posts.length,
|
||||
itemBuilder: (context, index) {
|
||||
posts.sort(
|
||||
(b, a) => a.createdAt.compareTo(b.createdAt),
|
||||
);
|
||||
var post = posts[index];
|
||||
// var post = posts[index];
|
||||
return options.postBuilder?.call(
|
||||
context: context,
|
||||
onTap: onTapPost,
|
||||
post: post,
|
||||
) ??
|
||||
TimelinePostWidget(
|
||||
timelineService: timelineService,
|
||||
currentUserId: currentUserId,
|
||||
onTapPost: onTapPost,
|
||||
options: options,
|
||||
post: post,
|
||||
onTapComments: onTapComments,
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
|
@ -0,0 +1,33 @@
|
|||
import "package:flutter/material.dart";
|
||||
import "package:flutter_timeline/flutter_timeline.dart";
|
||||
|
||||
class MoreOptionsButton extends StatelessWidget {
|
||||
const MoreOptionsButton({
|
||||
required this.timelineService,
|
||||
required this.post,
|
||||
required this.options,
|
||||
super.key,
|
||||
});
|
||||
|
||||
final TimelineService timelineService;
|
||||
final TimelinePost post;
|
||||
final TimelineOptions options;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => PopupMenuButton(
|
||||
onSelected: (value) async {
|
||||
if (value == "delete") {
|
||||
options.onPostDelete ?? await timelineService.deletePost(post.id);
|
||||
}
|
||||
},
|
||||
itemBuilder: (BuildContext context) => <PopupMenuEntry<String>>[
|
||||
PopupMenuItem(
|
||||
value: "delete",
|
||||
child: Text(options.translations.deletePostTitle),
|
||||
),
|
||||
],
|
||||
child: const Icon(
|
||||
Icons.more_horiz_rounded,
|
||||
),
|
||||
);
|
||||
}
|
|
@ -0,0 +1,82 @@
|
|||
import "package:flutter/material.dart";
|
||||
import "package:flutter_timeline/flutter_timeline.dart";
|
||||
|
||||
class ReactionTextfield extends StatelessWidget {
|
||||
const ReactionTextfield({
|
||||
required this.options,
|
||||
required this.controller,
|
||||
required this.suffixIcon,
|
||||
required this.user,
|
||||
super.key,
|
||||
});
|
||||
|
||||
final TimelineUser? user;
|
||||
final TimelineOptions options;
|
||||
final TextEditingController controller;
|
||||
final Widget suffixIcon;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
var theme = Theme.of(context);
|
||||
return ColoredBox(
|
||||
color: theme.scaffoldBackgroundColor,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(
|
||||
left: 12,
|
||||
bottom: 20,
|
||||
right: 16,
|
||||
top: 20,
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8.0),
|
||||
child: options.userAvatarBuilder(
|
||||
user,
|
||||
26,
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: TextField(
|
||||
style: theme.textTheme.bodyMedium,
|
||||
textCapitalization: TextCapitalization.sentences,
|
||||
controller: controller,
|
||||
decoration: InputDecoration(
|
||||
enabledBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(25),
|
||||
borderSide: const BorderSide(
|
||||
color: Colors.black,
|
||||
),
|
||||
),
|
||||
focusedBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(25),
|
||||
borderSide: const BorderSide(
|
||||
color: Colors.black,
|
||||
),
|
||||
),
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
vertical: 0,
|
||||
horizontal: 16,
|
||||
),
|
||||
hintText: options.translations.commentFieldHint,
|
||||
hintStyle: theme.textTheme.bodyMedium!.copyWith(
|
||||
color: theme.textTheme.bodyMedium!.color!.withOpacity(0.5),
|
||||
),
|
||||
fillColor: Colors.white,
|
||||
filled: true,
|
||||
border: const OutlineInputBorder(
|
||||
borderRadius: BorderRadius.all(
|
||||
Radius.circular(25),
|
||||
),
|
||||
borderSide: BorderSide.none,
|
||||
),
|
||||
suffixIcon: suffixIcon,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
|
@ -1,8 +1,8 @@
|
|||
import 'dart:async';
|
||||
import "dart:async";
|
||||
|
||||
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:cached_network_image/cached_network_image.dart";
|
||||
import "package:flutter/material.dart";
|
||||
import "package:timeline_repository_interface/timeline_repository_interface.dart";
|
||||
|
||||
class TappableImage extends StatefulWidget {
|
||||
const TappableImage({
|
||||
|
@ -15,7 +15,7 @@ class TappableImage extends StatefulWidget {
|
|||
|
||||
final TimelinePost post;
|
||||
final String userId;
|
||||
final Future<bool> Function({required bool liked}) onLike;
|
||||
final Future<bool> Function() onLike;
|
||||
final (Icon?, Icon?) likeAndDislikeIcon;
|
||||
|
||||
@override
|
||||
|
@ -73,12 +73,7 @@ class _TappableImageState extends State<TappableImage>
|
|||
loading = true;
|
||||
await animationController.forward();
|
||||
|
||||
var liked = await widget.onLike(
|
||||
liked: widget.post.likedBy?.contains(
|
||||
widget.userId,
|
||||
) ??
|
||||
false,
|
||||
);
|
||||
var liked = await widget.onLike();
|
||||
|
||||
if (context.mounted) {
|
||||
await showDialog(
|
||||
|
@ -101,15 +96,19 @@ class _TappableImageState extends State<TappableImage>
|
|||
scale: 1 + animation.value * 0.1,
|
||||
child: widget.post.imageUrl != null
|
||||
? CachedNetworkImage(
|
||||
imageUrl: widget.post.imageUrl ?? '',
|
||||
height: 250,
|
||||
imageUrl: widget.post.imageUrl ?? "",
|
||||
width: double.infinity,
|
||||
fit: BoxFit.fitHeight,
|
||||
fit: BoxFit.cover,
|
||||
)
|
||||
: Image.memory(
|
||||
width: double.infinity,
|
||||
widget.post.image!,
|
||||
fit: BoxFit.fitHeight,
|
||||
),
|
||||
: widget.post.image != null
|
||||
? Image.memory(
|
||||
width: double.infinity,
|
||||
widget.post.image!,
|
||||
fit: BoxFit.cover,
|
||||
height: 250,
|
||||
)
|
||||
: null,
|
||||
),
|
||||
),
|
||||
);
|
250
packages/flutter_timeline/lib/src/widgets/timeline_post.dart
Normal file
250
packages/flutter_timeline/lib/src/widgets/timeline_post.dart
Normal file
|
@ -0,0 +1,250 @@
|
|||
import "package:cached_network_image/cached_network_image.dart";
|
||||
import "package:flutter/material.dart";
|
||||
import "package:flutter_timeline/flutter_timeline.dart";
|
||||
|
||||
class TimelinePostWidget extends StatefulWidget {
|
||||
const TimelinePostWidget({
|
||||
required this.post,
|
||||
required this.timelineService,
|
||||
required this.options,
|
||||
required this.currentUserId,
|
||||
required this.onTapPost,
|
||||
required this.onTapComments,
|
||||
this.isInDetialView = false,
|
||||
this.isInPostOverview = false,
|
||||
super.key,
|
||||
});
|
||||
|
||||
final TimelinePost post;
|
||||
final TimelineService timelineService;
|
||||
final TimelineOptions options;
|
||||
final String currentUserId;
|
||||
final Function(TimelinePost post) onTapPost;
|
||||
final bool isInDetialView;
|
||||
final Function(TimelinePost post) onTapComments;
|
||||
final bool isInPostOverview;
|
||||
|
||||
@override
|
||||
State<TimelinePostWidget> createState() => _TimelinePostWidgetState();
|
||||
}
|
||||
|
||||
class _TimelinePostWidgetState extends State<TimelinePostWidget> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
var theme = Theme.of(context);
|
||||
var translations = widget.options.translations;
|
||||
var user = widget.post.creator;
|
||||
var options = widget.options;
|
||||
var post = widget.post;
|
||||
var isLikedByCurrentUser =
|
||||
widget.post.likedBy?.contains(widget.currentUserId) ?? false;
|
||||
var likesTitle = widget.post.likes == 1
|
||||
? translations.oneLikeTitle
|
||||
: translations.multipleLikesTitle;
|
||||
|
||||
return Padding(
|
||||
padding: EdgeInsets.only(
|
||||
left: 20,
|
||||
right: 20,
|
||||
top: 20,
|
||||
bottom: widget.isInDetialView ? 100 : 0,
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
options.userAvatarBuilder.call(user, 24),
|
||||
const SizedBox(width: 8),
|
||||
options.userNameBuilder
|
||||
.call(user, options.translations.anonymousUser, context),
|
||||
],
|
||||
),
|
||||
if (post.creatorId == widget.currentUserId &&
|
||||
!widget.isInPostOverview &&
|
||||
!widget.isInDetialView)
|
||||
MoreOptionsButton(
|
||||
timelineService: widget.timelineService,
|
||||
options: options,
|
||||
post: post,
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(
|
||||
height: 8,
|
||||
),
|
||||
if (post.imageUrl != null || post.image != null) ...[
|
||||
if (options.doubleTapToLike) ...[
|
||||
TappableImage(
|
||||
post: post,
|
||||
onLike: () async {
|
||||
if (isLikedByCurrentUser) {
|
||||
widget.options.onTapUnlike ??
|
||||
widget.timelineService.unlikePost(
|
||||
widget.post.id,
|
||||
widget.currentUserId,
|
||||
);
|
||||
setState(() {});
|
||||
return true;
|
||||
} else {
|
||||
widget.options.onTapLike ??
|
||||
widget.timelineService.likePost(
|
||||
widget.post.id,
|
||||
widget.currentUserId,
|
||||
);
|
||||
setState(() {});
|
||||
return false;
|
||||
}
|
||||
},
|
||||
userId: widget.currentUserId,
|
||||
likeAndDislikeIcon: (
|
||||
Icon(options.likeIcon),
|
||||
Icon(options.likedIcon)
|
||||
),
|
||||
),
|
||||
] else ...[
|
||||
if (post.imageUrl != null)
|
||||
Container(
|
||||
height: 250,
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
image: DecorationImage(
|
||||
image: CachedNetworkImageProvider(widget.post.imageUrl!),
|
||||
fit: BoxFit.cover,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (post.image != null)
|
||||
Container(
|
||||
height: 250,
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
image: DecorationImage(
|
||||
image: MemoryImage(widget.post.image!),
|
||||
fit: BoxFit.cover,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
const SizedBox(
|
||||
height: 12,
|
||||
),
|
||||
Row(
|
||||
children: [
|
||||
Builder(
|
||||
builder: (context) {
|
||||
var postIsLikedByCurrentUser =
|
||||
post.likedBy?.contains(widget.currentUserId) ?? false;
|
||||
return IconButton(
|
||||
iconSize: options.iconSize,
|
||||
padding: EdgeInsets.zero,
|
||||
constraints: const BoxConstraints(),
|
||||
icon: Icon(
|
||||
postIsLikedByCurrentUser
|
||||
? widget.options.likedIcon
|
||||
: widget.options.likeIcon,
|
||||
),
|
||||
onPressed: () async {
|
||||
if (postIsLikedByCurrentUser) {
|
||||
await widget.timelineService.unlikePost(
|
||||
post.id,
|
||||
widget.currentUserId,
|
||||
);
|
||||
} else {
|
||||
await widget.timelineService.likePost(
|
||||
widget.post.id,
|
||||
widget.currentUserId,
|
||||
);
|
||||
}
|
||||
setState(() {});
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
if (post.reactionEnabled) ...[
|
||||
IconButton(
|
||||
padding: EdgeInsets.zero,
|
||||
constraints: const BoxConstraints(),
|
||||
icon: Icon(
|
||||
options.commentIcon,
|
||||
size: options.iconSize,
|
||||
color: options.iconColor,
|
||||
),
|
||||
onPressed: () {
|
||||
widget.onTapComments(widget.post);
|
||||
},
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
const SizedBox(
|
||||
height: 8,
|
||||
),
|
||||
if (!widget.isInPostOverview) ...[
|
||||
Text(
|
||||
"${widget.post.likes} $likesTitle",
|
||||
style: theme.textTheme.titleSmall?.copyWith(
|
||||
color: Colors.black,
|
||||
),
|
||||
),
|
||||
],
|
||||
Row(
|
||||
children: [
|
||||
options.userNameBuilder.call(
|
||||
user,
|
||||
options.translations.anonymousUser,
|
||||
context,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
widget.post.title,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: Colors.black,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (widget.isInDetialView) ...[
|
||||
const SizedBox(
|
||||
height: 20,
|
||||
),
|
||||
Text(
|
||||
widget.post.content,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: Colors.black,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
widget.options.dateFormat(context).format(widget.post.createdAt),
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: Colors.black.withOpacity(0.5),
|
||||
),
|
||||
),
|
||||
if (widget.post.reactionEnabled)
|
||||
if (!widget.isInPostOverview)
|
||||
CommentSection(
|
||||
options: options,
|
||||
post: post,
|
||||
currentUserId: widget.currentUserId,
|
||||
timelineService: widget.timelineService,
|
||||
),
|
||||
],
|
||||
if (!widget.isInDetialView)
|
||||
InkWell(
|
||||
onTap: () => widget.onTapPost(widget.post),
|
||||
child: Text(
|
||||
translations.viewPostTitle,
|
||||
style: theme.textTheme.titleSmall
|
||||
?.copyWith(color: Colors.black.withOpacity(0.5)),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
|
@ -1,31 +1,37 @@
|
|||
# SPDX-FileCopyrightText: 2023 Iconica
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
name: flutter_timeline
|
||||
description: Visual elements and interface combined into one package
|
||||
version: 5.1.0
|
||||
publish_to: https://forgejo.internal.iconica.nl/api/packages/internal/pub
|
||||
description: "A new Flutter package project."
|
||||
version: 6.0.0
|
||||
publish_to: none
|
||||
|
||||
environment:
|
||||
sdk: ">=3.1.3 <4.0.0"
|
||||
sdk: ^3.5.1
|
||||
flutter: ">=1.17.0"
|
||||
|
||||
dependencies:
|
||||
flutter:
|
||||
sdk: flutter
|
||||
flutter_timeline_view:
|
||||
hosted: https://forgejo.internal.iconica.nl/api/packages/internal/pub
|
||||
version: ^5.1.0
|
||||
|
||||
flutter_timeline_interface:
|
||||
flutter_image_picker:
|
||||
hosted: https://forgejo.internal.iconica.nl/api/packages/internal/pub
|
||||
version: ^5.1.0
|
||||
collection: any
|
||||
version: ^4.0.0
|
||||
timeline_repository_interface:
|
||||
git:
|
||||
url: https://github.com/Iconica-Development/flutter_timeline
|
||||
path: packages/timeline_repository_interface
|
||||
ref: 6.0.0
|
||||
|
||||
cached_network_image: ^3.4.1
|
||||
intl: 0.19.0
|
||||
flutter_svg: ^2.0.10+1
|
||||
dotted_border: ^2.1.0
|
||||
collection: ^1.18.0
|
||||
|
||||
dev_dependencies:
|
||||
flutter_lints: ^2.0.0
|
||||
flutter_iconica_analysis:
|
||||
git:
|
||||
url: https://github.com/Iconica-Development/flutter_iconica_analysis
|
||||
ref: 6.0.0
|
||||
ref: 7.0.0
|
||||
|
||||
flutter:
|
||||
assets:
|
||||
- assets/
|
||||
|
|
|
@ -1,13 +0,0 @@
|
|||
# SPDX-FileCopyrightText: 2023 Iconica
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
include: package:flutter_iconica_analysis/analysis_options.yaml
|
||||
|
||||
# Possible to overwrite the rules from the package
|
||||
|
||||
analyzer:
|
||||
exclude:
|
||||
|
||||
linter:
|
||||
rules:
|
|
@ -1,11 +0,0 @@
|
|||
// SPDX-FileCopyrightText: 2023 Iconica
|
||||
//
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
///
|
||||
library flutter_timeline_firebase;
|
||||
|
||||
export 'src/config/firebase_timeline_options.dart';
|
||||
export 'src/service/firebase_post_service.dart';
|
||||
export 'src/service/firebase_timeline_service.dart';
|
||||
export 'src/service/firebase_user_service.dart';
|
|
@ -1,18 +0,0 @@
|
|||
// 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.timelineCategoryCollectionName = 'timeline_categories',
|
||||
});
|
||||
|
||||
final String usersCollectionName;
|
||||
final String timelineCollectionName;
|
||||
final String timelineCategoryCollectionName;
|
||||
}
|
|
@ -1,36 +0,0 @@
|
|||
// 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,
|
||||
};
|
||||
}
|
|
@ -1,495 +0,0 @@
|
|||
// SPDX-FileCopyrightText: 2023 Iconica
|
||||
//
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:cloud_firestore/cloud_firestore.dart';
|
||||
import 'package:collection/collection.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_firebase/src/models/firebase_user_document.dart';
|
||||
import 'package:flutter_timeline_interface/flutter_timeline_interface.dart';
|
||||
import 'package:uuid/uuid.dart';
|
||||
|
||||
class FirebaseTimelinePostService
|
||||
with TimelineUserService, ChangeNotifier
|
||||
implements TimelinePostService {
|
||||
FirebaseTimelinePostService({
|
||||
required TimelineUserService userService,
|
||||
FirebaseApp? app,
|
||||
FirebaseTimelineOptions? options,
|
||||
}) {
|
||||
var appInstance = app ?? Firebase.app();
|
||||
_db = FirebaseFirestore.instanceFor(app: appInstance);
|
||||
_storage = FirebaseStorage.instanceFor(app: appInstance);
|
||||
_userService = userService;
|
||||
_options = options ?? const FirebaseTimelineOptions();
|
||||
}
|
||||
|
||||
late FirebaseFirestore _db;
|
||||
late FirebaseStorage _storage;
|
||||
late TimelineUserService _userService;
|
||||
late FirebaseTimelineOptions _options;
|
||||
|
||||
final Map<String, TimelinePosterUserModel> _users = {};
|
||||
|
||||
@override
|
||||
List<TimelinePost> posts = [];
|
||||
|
||||
@override
|
||||
List<TimelineCategory> categories = [];
|
||||
|
||||
@override
|
||||
TimelineCategory? selectedCategory;
|
||||
|
||||
@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> deletePostReaction(
|
||||
TimelinePost post,
|
||||
String reactionId,
|
||||
) async {
|
||||
if (post.reactions != null && post.reactions!.isNotEmpty) {
|
||||
var reaction =
|
||||
post.reactions!.firstWhere((element) => element.id == reactionId);
|
||||
var updatedPost = post.copyWith(
|
||||
reaction: post.reaction - 1,
|
||||
reactions: (post.reactions ?? [])..remove(reaction),
|
||||
);
|
||||
posts = posts
|
||||
.map(
|
||||
(p) => p.id == post.id ? updatedPost : p,
|
||||
)
|
||||
.toList();
|
||||
var postRef =
|
||||
_db.collection(_options.timelineCollectionName).doc(post.id);
|
||||
await postRef.update({
|
||||
'reaction': FieldValue.increment(-1),
|
||||
'reactions': FieldValue.arrayRemove(
|
||||
[reaction.toJsonWithMicroseconds()],
|
||||
),
|
||||
});
|
||||
notifyListeners();
|
||||
return updatedPost;
|
||||
}
|
||||
return post;
|
||||
}
|
||||
|
||||
@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,
|
||||
creator: await _userService.getUser(post.creatorId),
|
||||
);
|
||||
posts = posts.map((p) => (p.id == post.id) ? updatedPost : p).toList();
|
||||
notifyListeners();
|
||||
return updatedPost;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<TimelinePost>> fetchPosts(String? category) async {
|
||||
var snapshot = (category != null)
|
||||
? await _db
|
||||
.collection(_options.timelineCollectionName)
|
||||
.where('category', isEqualTo: category)
|
||||
.get()
|
||||
: await _db.collection(_options.timelineCollectionName).get();
|
||||
|
||||
var fetchedPosts = <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);
|
||||
fetchedPosts.add(post);
|
||||
}
|
||||
|
||||
posts = fetchedPosts;
|
||||
|
||||
notifyListeners();
|
||||
return posts;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<TimelinePost>> fetchPostsPaginated(
|
||||
String? category,
|
||||
int limit,
|
||||
) async {
|
||||
// only take posts that are in our category
|
||||
var oldestPost = posts
|
||||
.where(
|
||||
(element) => category == null || element.category == category,
|
||||
)
|
||||
.fold(
|
||||
posts.first,
|
||||
(previousValue, element) =>
|
||||
(previousValue.createdAt.isBefore(element.createdAt))
|
||||
? previousValue
|
||||
: element,
|
||||
);
|
||||
var snapshot = (category != null)
|
||||
? await _db
|
||||
.collection(_options.timelineCollectionName)
|
||||
.where('category', isEqualTo: category)
|
||||
.orderBy('created_at', descending: true)
|
||||
.startAfter([oldestPost])
|
||||
.limit(limit)
|
||||
.get()
|
||||
: await _db
|
||||
.collection(_options.timelineCollectionName)
|
||||
.orderBy('created_at', descending: true)
|
||||
.startAfter([oldestPost.createdAt])
|
||||
.limit(limit)
|
||||
.get();
|
||||
// add the new posts to the list
|
||||
var newPosts = <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);
|
||||
newPosts.add(post);
|
||||
}
|
||||
posts = [...posts, ...newPosts];
|
||||
notifyListeners();
|
||||
return newPosts;
|
||||
}
|
||||
|
||||
@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
|
||||
Future<List<TimelinePost>> refreshPosts(String? category) async {
|
||||
// fetch all posts between now and the newest posts we have
|
||||
var newestPostWeHave = posts
|
||||
.where(
|
||||
(element) => category == null || element.category == category,
|
||||
)
|
||||
.fold(
|
||||
posts.first,
|
||||
(previousValue, element) =>
|
||||
(previousValue.createdAt.isAfter(element.createdAt))
|
||||
? previousValue
|
||||
: element,
|
||||
);
|
||||
var snapshot = (category != null)
|
||||
? await _db
|
||||
.collection(_options.timelineCollectionName)
|
||||
.where('category', isEqualTo: category)
|
||||
.orderBy('created_at', descending: true)
|
||||
.endBefore([newestPostWeHave.createdAt]).get()
|
||||
: await _db
|
||||
.collection(_options.timelineCollectionName)
|
||||
.orderBy('created_at', descending: true)
|
||||
.endBefore([newestPostWeHave.createdAt]).get();
|
||||
// add the new posts to the list
|
||||
var newPosts = <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);
|
||||
newPosts.add(post);
|
||||
}
|
||||
posts = [...posts, ...newPosts];
|
||||
notifyListeners();
|
||||
return newPosts;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<TimelinePost?> getPost(String postId) async {
|
||||
var post = await _db
|
||||
.collection(_options.timelineCollectionName)
|
||||
.doc(postId)
|
||||
.withConverter<TimelinePost>(
|
||||
fromFirestore: (snapshot, _) => TimelinePost.fromJson(
|
||||
snapshot.id,
|
||||
snapshot.data()!,
|
||||
),
|
||||
toFirestore: (user, _) => user.toJson(),
|
||||
)
|
||||
.get();
|
||||
return post.data();
|
||||
}
|
||||
|
||||
@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 ?? [], 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;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<bool> addCategory(TimelineCategory category) async {
|
||||
var exists = categories.firstWhereOrNull(
|
||||
(element) => element.title.toLowerCase() == category.title.toLowerCase(),
|
||||
);
|
||||
if (exists != null) return false;
|
||||
try {
|
||||
await _db
|
||||
.collection(_options.timelineCategoryCollectionName)
|
||||
.add(category.toJson());
|
||||
categories.add(category);
|
||||
notifyListeners();
|
||||
return true;
|
||||
} on Exception catch (_) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<TimelineCategory>> fetchCategories() async {
|
||||
categories.clear();
|
||||
categories.add(
|
||||
const TimelineCategory(
|
||||
key: null,
|
||||
title: 'All',
|
||||
),
|
||||
);
|
||||
var categoriesSnapshot = await _db
|
||||
.collection(_options.timelineCategoryCollectionName)
|
||||
.withConverter(
|
||||
fromFirestore: (snapshot, _) =>
|
||||
TimelineCategory.fromJson(snapshot.data()!),
|
||||
toFirestore: (model, _) => model.toJson(),
|
||||
)
|
||||
.get();
|
||||
categories.addAll(categoriesSnapshot.docs.map((e) => e.data()));
|
||||
|
||||
notifyListeners();
|
||||
return categories;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<TimelinePost> likeReaction(
|
||||
String userId,
|
||||
TimelinePost post,
|
||||
String reactionId,
|
||||
) async {
|
||||
// update the post with the new like
|
||||
var updatedPost = post.copyWith(
|
||||
reactions: post.reactions?.map(
|
||||
(r) {
|
||||
if (r.id == reactionId) {
|
||||
return r.copyWith(
|
||||
likedBy: (r.likedBy ?? [])..add(userId),
|
||||
);
|
||||
}
|
||||
return r;
|
||||
},
|
||||
).toList(),
|
||||
);
|
||||
posts = posts
|
||||
.map(
|
||||
(p) => p.id == post.id ? updatedPost : p,
|
||||
)
|
||||
.toList();
|
||||
var postRef = _db.collection(_options.timelineCollectionName).doc(post.id);
|
||||
await postRef.update({
|
||||
'reactions': post.reactions
|
||||
?.map(
|
||||
(r) =>
|
||||
r.id == reactionId ? r.copyWith(likedBy: r.likedBy ?? []) : r,
|
||||
)
|
||||
.map((e) => e.toJson())
|
||||
.toList(),
|
||||
});
|
||||
notifyListeners();
|
||||
return updatedPost;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<TimelinePost> unlikeReaction(
|
||||
String userId,
|
||||
TimelinePost post,
|
||||
String reactionId,
|
||||
) async {
|
||||
// update the post with the new like
|
||||
var updatedPost = post.copyWith(
|
||||
reactions: post.reactions?.map(
|
||||
(r) {
|
||||
if (r.id == reactionId) {
|
||||
return r.copyWith(
|
||||
likedBy: r.likedBy?..remove(userId),
|
||||
);
|
||||
}
|
||||
return r;
|
||||
},
|
||||
).toList(),
|
||||
);
|
||||
posts = posts
|
||||
.map(
|
||||
(p) => p.id == post.id ? updatedPost : p,
|
||||
)
|
||||
.toList();
|
||||
var postRef = _db.collection(_options.timelineCollectionName).doc(post.id);
|
||||
await postRef.update({
|
||||
'reactions': post.reactions
|
||||
?.map(
|
||||
(r) => r.id == reactionId
|
||||
? r.copyWith(likedBy: r.likedBy?..remove(userId))
|
||||
: r,
|
||||
)
|
||||
.map((e) => e.toJson())
|
||||
.toList(),
|
||||
});
|
||||
notifyListeners();
|
||||
return updatedPost;
|
||||
}
|
||||
}
|
|
@ -1,53 +0,0 @@
|
|||
import 'package:firebase_core/firebase_core.dart';
|
||||
import 'package:flutter_timeline_firebase/flutter_timeline_firebase.dart';
|
||||
import 'package:flutter_timeline_interface/flutter_timeline_interface.dart';
|
||||
|
||||
class FirebaseTimelineService implements TimelineService {
|
||||
FirebaseTimelineService({
|
||||
this.options,
|
||||
this.app,
|
||||
this.firebasePostService,
|
||||
this.firebaseUserService,
|
||||
}) {
|
||||
firebaseUserService ??= FirebaseTimelineUserService(
|
||||
options: options,
|
||||
app: app,
|
||||
);
|
||||
|
||||
firebasePostService ??= FirebaseTimelinePostService(
|
||||
userService: userService,
|
||||
options: options,
|
||||
app: app,
|
||||
);
|
||||
}
|
||||
|
||||
final FirebaseTimelineOptions? options;
|
||||
final FirebaseApp? app;
|
||||
TimelinePostService? firebasePostService;
|
||||
TimelineUserService? firebaseUserService;
|
||||
|
||||
@override
|
||||
TimelinePostService get postService {
|
||||
if (firebasePostService != null) {
|
||||
return firebasePostService!;
|
||||
} else {
|
||||
return FirebaseTimelinePostService(
|
||||
userService: userService,
|
||||
options: options,
|
||||
app: app,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
TimelineUserService get userService {
|
||||
if (firebaseUserService != null) {
|
||||
return firebaseUserService!;
|
||||
} else {
|
||||
return FirebaseTimelineUserService(
|
||||
options: options,
|
||||
app: app,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,55 +0,0 @@
|
|||
// 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 FirebaseTimelineUserService implements TimelineUserService {
|
||||
FirebaseTimelineUserService({
|
||||
FirebaseApp? app,
|
||||
FirebaseTimelineOptions? options,
|
||||
}) {
|
||||
var appInstance = app ?? Firebase.app();
|
||||
_db = FirebaseFirestore.instanceFor(app: appInstance);
|
||||
_options = options ?? const FirebaseTimelineOptions();
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
|
@ -1,32 +0,0 @@
|
|||
# SPDX-FileCopyrightText: 2023 Iconica
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
name: flutter_timeline_firebase
|
||||
description: Implementation of the Flutter Timeline interface for Firebase.
|
||||
version: 5.1.0
|
||||
publish_to: https://forgejo.internal.iconica.nl/api/packages/internal/pub
|
||||
|
||||
environment:
|
||||
sdk: ">=3.1.3 <4.0.0"
|
||||
|
||||
dependencies:
|
||||
flutter:
|
||||
sdk: flutter
|
||||
cloud_firestore: ^4.13.1
|
||||
firebase_core: ^2.22.0
|
||||
firebase_storage: ^11.5.1
|
||||
uuid: ^4.2.1
|
||||
collection: ^1.18.0
|
||||
flutter_timeline_interface:
|
||||
hosted: https://forgejo.internal.iconica.nl/api/packages/internal/pub
|
||||
version: ^5.1.0
|
||||
|
||||
dev_dependencies:
|
||||
flutter_lints: ^2.0.0
|
||||
flutter_iconica_analysis:
|
||||
git:
|
||||
url: https://github.com/Iconica-Development/flutter_iconica_analysis
|
||||
ref: 6.0.0
|
||||
|
||||
flutter:
|
|
@ -1,13 +0,0 @@
|
|||
# SPDX-FileCopyrightText: 2023 Iconica
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
include: package:flutter_iconica_analysis/analysis_options.yaml
|
||||
|
||||
# Possible to overwrite the rules from the package
|
||||
|
||||
analyzer:
|
||||
exclude:
|
||||
|
||||
linter:
|
||||
rules:
|
|
@ -1,14 +0,0 @@
|
|||
// SPDX-FileCopyrightText: 2023 Iconica
|
||||
//
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
///
|
||||
library flutter_timeline_interface;
|
||||
|
||||
export 'src/model/timeline_category.dart';
|
||||
export 'src/model/timeline_post.dart';
|
||||
export 'src/model/timeline_poster.dart';
|
||||
export 'src/model/timeline_reaction.dart';
|
||||
export 'src/services/filter_service.dart';
|
||||
export 'src/services/timeline_post_service.dart';
|
||||
export 'src/services/timeline_service.dart';
|
||||
export 'src/services/user_service.dart';
|
|
@ -1,51 +0,0 @@
|
|||
// 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,
|
||||
});
|
||||
|
||||
factory TimelinePosterUserModel.fromJson(
|
||||
Map<String, dynamic> json,
|
||||
String userId,
|
||||
) =>
|
||||
TimelinePosterUserModel(
|
||||
userId: userId,
|
||||
firstName: json['first_name'] as String?,
|
||||
lastName: json['last_name'] as String?,
|
||||
imageUrl: json['image_url'] as String?,
|
||||
);
|
||||
|
||||
final String userId;
|
||||
final String? firstName;
|
||||
final String? lastName;
|
||||
final String? imageUrl;
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'first_name': firstName,
|
||||
'last_name': lastName,
|
||||
'image_url': 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;
|
||||
}
|
||||
}
|
|
@ -1,22 +0,0 @@
|
|||
// SPDX-FileCopyrightText: 2024 Iconica
|
||||
//
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
import 'package:flutter_timeline_interface/flutter_timeline_interface.dart';
|
||||
|
||||
mixin TimelineFilterService on TimelinePostService {
|
||||
List<TimelinePost> filterPosts(
|
||||
String filterWord,
|
||||
Map<String, dynamic> options,
|
||||
) {
|
||||
var filteredPosts = posts
|
||||
.where(
|
||||
(post) => post.title.toLowerCase().contains(
|
||||
filterWord.toLowerCase(),
|
||||
),
|
||||
)
|
||||
.toList();
|
||||
|
||||
return filteredPosts;
|
||||
}
|
||||
}
|
|
@ -1,45 +0,0 @@
|
|||
// SPDX-FileCopyrightText: 2023 Iconica
|
||||
//
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_timeline_interface/flutter_timeline_interface.dart';
|
||||
|
||||
abstract class TimelinePostService with ChangeNotifier {
|
||||
List<TimelinePost> posts = [];
|
||||
List<TimelineCategory> categories = [];
|
||||
TimelineCategory? selectedCategory;
|
||||
|
||||
Future<void> deletePost(TimelinePost post);
|
||||
Future<TimelinePost> deletePostReaction(TimelinePost post, String reactionId);
|
||||
Future<TimelinePost> createPost(TimelinePost post);
|
||||
Future<List<TimelinePost>> fetchPosts(String? category);
|
||||
Future<TimelinePost> fetchPost(TimelinePost post);
|
||||
Future<List<TimelinePost>> fetchPostsPaginated(String? category, int limit);
|
||||
Future<TimelinePost?> getPost(String postId);
|
||||
List<TimelinePost> getPosts(String? category);
|
||||
Future<List<TimelinePost>> refreshPosts(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);
|
||||
|
||||
Future<List<TimelineCategory>> fetchCategories();
|
||||
Future<bool> addCategory(TimelineCategory category);
|
||||
Future<TimelinePost> likeReaction(
|
||||
String userId,
|
||||
TimelinePost post,
|
||||
String reactionId,
|
||||
);
|
||||
Future<TimelinePost> unlikeReaction(
|
||||
String userId,
|
||||
TimelinePost post,
|
||||
String reactionId,
|
||||
);
|
||||
}
|
|
@ -1,12 +0,0 @@
|
|||
import 'package:flutter_timeline_interface/src/services/timeline_post_service.dart';
|
||||
import 'package:flutter_timeline_interface/src/services/user_service.dart';
|
||||
|
||||
class TimelineService {
|
||||
TimelineService({
|
||||
required this.postService,
|
||||
this.userService,
|
||||
});
|
||||
|
||||
final TimelinePostService postService;
|
||||
final TimelineUserService? userService;
|
||||
}
|
|
@ -1,9 +0,0 @@
|
|||
// 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);
|
||||
}
|
|
@ -1,25 +0,0 @@
|
|||
# SPDX-FileCopyrightText: 2023 Iconica
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
name: flutter_timeline_interface
|
||||
description: Interface for the service of the Flutter Timeline component
|
||||
version: 5.1.0
|
||||
publish_to: https://forgejo.internal.iconica.nl/api/packages/internal/pub
|
||||
|
||||
environment:
|
||||
sdk: '>=3.1.3 <4.0.0'
|
||||
|
||||
dependencies:
|
||||
flutter:
|
||||
sdk: flutter
|
||||
|
||||
dev_dependencies:
|
||||
flutter_lints: ^2.0.0
|
||||
flutter_iconica_analysis:
|
||||
git:
|
||||
url: https://github.com/Iconica-Development/flutter_iconica_analysis
|
||||
ref: 6.0.0
|
||||
|
||||
flutter:
|
||||
|
|
@ -1 +0,0 @@
|
|||
../../CHANGELOG.md
|
|
@ -1 +0,0 @@
|
|||
../../LICENSE
|
|
@ -1 +0,0 @@
|
|||
../../README.md
|
|
@ -1,13 +0,0 @@
|
|||
# SPDX-FileCopyrightText: 2023 Iconica
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
include: package:flutter_iconica_analysis/analysis_options.yaml
|
||||
|
||||
# Possible to overwrite the rules from the package
|
||||
|
||||
analyzer:
|
||||
exclude:
|
||||
|
||||
linter:
|
||||
rules:
|
|
@ -1,20 +0,0 @@
|
|||
// SPDX-FileCopyrightText: 2023 Iconica
|
||||
//
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
///
|
||||
library flutter_timeline_view;
|
||||
|
||||
export 'src/config/timeline_options.dart';
|
||||
export 'src/config/timeline_paddings.dart';
|
||||
export 'src/config/timeline_styles.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_overview_screen.dart';
|
||||
export 'src/screens/timeline_post_screen.dart';
|
||||
export 'src/screens/timeline_screen.dart';
|
||||
export 'src/screens/timeline_selection_screen.dart';
|
||||
export 'src/services/local_post_service.dart';
|
||||
export 'src/widgets/category_selector.dart';
|
||||
export 'src/widgets/category_selector_button.dart';
|
||||
export 'src/widgets/timeline_post_widget.dart';
|
|
@ -1,244 +0,0 @@
|
|||
// SPDX-FileCopyrightText: 2023 Iconica
|
||||
//
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
import 'package:collection/collection.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_paddings.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';
|
||||
|
||||
class TimelineOptions {
|
||||
const TimelineOptions({
|
||||
this.theme = const TimelineTheme(),
|
||||
this.translations = const TimelineTranslations.empty(),
|
||||
this.paddings = const TimelinePaddingOptions(),
|
||||
this.imagePickerConfig = const ImagePickerConfig(),
|
||||
this.imagePickerTheme,
|
||||
this.timelinePostHeight,
|
||||
this.sortCommentsAscending = true,
|
||||
this.sortPostsAscending = false,
|
||||
this.doubleTapTolike = false,
|
||||
this.iconsWithValues = false,
|
||||
this.likeAndDislikeIconsForDoubleTap = const (
|
||||
Icon(
|
||||
Icons.favorite_rounded,
|
||||
color: Color(0xFFC3007A),
|
||||
),
|
||||
null,
|
||||
),
|
||||
this.itemInfoBuilder,
|
||||
this.dateFormat,
|
||||
this.timeFormat,
|
||||
this.buttonBuilder,
|
||||
this.textInputBuilder,
|
||||
this.dividerBuilder,
|
||||
this.userAvatarBuilder,
|
||||
this.anonymousAvatarBuilder,
|
||||
this.nameBuilder,
|
||||
this.iconSize = 24,
|
||||
this.postWidgetHeight,
|
||||
this.filterOptions = const FilterOptions(),
|
||||
this.categoriesOptions = const CategoriesOptions(),
|
||||
this.requireImageForPost = false,
|
||||
this.minTitleLength,
|
||||
this.maxTitleLength,
|
||||
this.minContentLength,
|
||||
this.maxContentLength,
|
||||
this.categorySelectorButtonBuilder,
|
||||
this.postOverviewButtonBuilder,
|
||||
this.deletionDialogBuilder,
|
||||
this.listHeaderBuilder,
|
||||
this.titleInputDecoration,
|
||||
this.contentInputDecoration,
|
||||
});
|
||||
|
||||
/// 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;
|
||||
|
||||
/// The height of a post in the timeline
|
||||
final double? timelinePostHeight;
|
||||
|
||||
/// Class that contains all the translations used in the timeline
|
||||
final TimelineTranslations translations;
|
||||
|
||||
/// Class that contains all the paddings used in the timeline
|
||||
final TimelinePaddingOptions paddings;
|
||||
|
||||
final ButtonBuilder? buttonBuilder;
|
||||
|
||||
final TextInputBuilder? textInputBuilder;
|
||||
|
||||
final UserAvatarBuilder? userAvatarBuilder;
|
||||
|
||||
/// When the imageUrl is null this anonymousAvatarBuilder will be used
|
||||
/// You can use it to display a default avatarW
|
||||
final UserAvatarBuilder? anonymousAvatarBuilder;
|
||||
|
||||
final String Function(TimelinePosterUserModel?)? nameBuilder;
|
||||
|
||||
/// 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;
|
||||
|
||||
/// Whether to allow double tap to like
|
||||
final bool doubleTapTolike;
|
||||
|
||||
/// The icons to display when double tap to like is enabled
|
||||
final (Icon?, Icon?) likeAndDislikeIconsForDoubleTap;
|
||||
|
||||
/// Whether to display the icons with values
|
||||
final bool iconsWithValues;
|
||||
|
||||
/// The builder for the item info, all below the like and comment buttons
|
||||
final Widget Function({required TimelinePost post})? itemInfoBuilder;
|
||||
|
||||
/// The builder for the divider
|
||||
final Widget Function()? dividerBuilder;
|
||||
|
||||
/// Size of icons like the comment and like icons. Dafualts to 26
|
||||
final double iconSize;
|
||||
|
||||
/// Sets a predefined height for the postWidget.
|
||||
final double? postWidgetHeight;
|
||||
|
||||
/// Options for filtering
|
||||
final FilterOptions filterOptions;
|
||||
|
||||
/// Options for using the category selector.
|
||||
final CategoriesOptions categoriesOptions;
|
||||
|
||||
/// Require image for post
|
||||
final bool requireImageForPost;
|
||||
|
||||
/// Minimum length of the title
|
||||
final int? minTitleLength;
|
||||
|
||||
/// Maximum length of the title
|
||||
final int? maxTitleLength;
|
||||
|
||||
/// Minimum length of the post content
|
||||
final int? minContentLength;
|
||||
|
||||
/// Maximum length of the post content
|
||||
final int? maxContentLength;
|
||||
|
||||
/// Builder for the category selector button
|
||||
/// on the timeline category selection screen
|
||||
final Widget Function(
|
||||
BuildContext context,
|
||||
Function() onPressed,
|
||||
String text,
|
||||
)? categorySelectorButtonBuilder;
|
||||
|
||||
/// This widgetbuilder is placed at the top of the list of posts and can be
|
||||
/// used to add custom elements
|
||||
final Widget Function(BuildContext context, String? category)?
|
||||
listHeaderBuilder;
|
||||
|
||||
/// Builder for the post overview button
|
||||
/// on the timeline post overview screen
|
||||
final Widget Function(
|
||||
BuildContext context,
|
||||
Function() onPressed,
|
||||
String text,
|
||||
TimelinePost post,
|
||||
)? postOverviewButtonBuilder;
|
||||
|
||||
/// Optional builder to override the default alertdialog for post deletion
|
||||
/// It should pop the navigator with true to delete the post and
|
||||
/// false to cancel deletion
|
||||
final WidgetBuilder? deletionDialogBuilder;
|
||||
|
||||
/// inputdecoration for the title textfield
|
||||
final InputDecoration? titleInputDecoration;
|
||||
|
||||
/// inputdecoration for the content textfield
|
||||
final InputDecoration? contentInputDecoration;
|
||||
}
|
||||
|
||||
class CategoriesOptions {
|
||||
const CategoriesOptions({
|
||||
this.categoryButtonBuilder,
|
||||
this.categorySelectorHorizontalPadding,
|
||||
});
|
||||
|
||||
/// List of categories that the user can select.
|
||||
/// If this is null no categories will be shown.
|
||||
|
||||
/// Abilty to override the standard category selector
|
||||
final Widget Function(
|
||||
TimelineCategory category,
|
||||
Function() onTap,
|
||||
// ignore: avoid_positional_boolean_parameters
|
||||
bool selected,
|
||||
bool isOnTop,
|
||||
)? categoryButtonBuilder;
|
||||
|
||||
/// Overides the standard horizontal padding of the whole category selector.
|
||||
final double? categorySelectorHorizontalPadding;
|
||||
|
||||
TimelineCategory? getCategoryByKey(
|
||||
List<TimelineCategory> categories,
|
||||
BuildContext context,
|
||||
String? key,
|
||||
) =>
|
||||
categories.firstWhereOrNull((category) => category.key == key);
|
||||
}
|
||||
|
||||
class FilterOptions {
|
||||
const FilterOptions({
|
||||
this.initialFilterWord,
|
||||
this.searchBarBuilder,
|
||||
this.onFilterEnabledChange,
|
||||
});
|
||||
|
||||
/// Set a value to search through posts. When set the searchbar is shown.
|
||||
/// If null no searchbar is shown.
|
||||
final String? initialFilterWord;
|
||||
|
||||
// Possibilty to override the standard search bar.
|
||||
final Widget Function(
|
||||
Future<List<TimelinePost>> Function(
|
||||
String filterWord,
|
||||
) search,
|
||||
)? searchBarBuilder;
|
||||
|
||||
final void Function({required bool filterEnabled})? onFilterEnabledChange;
|
||||
}
|
||||
|
||||
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,
|
||||
);
|
|
@ -1,25 +0,0 @@
|
|||
import 'package:flutter/material.dart';
|
||||
|
||||
/// This class contains the paddings used in the timeline options
|
||||
class TimelinePaddingOptions {
|
||||
const TimelinePaddingOptions({
|
||||
this.mainPadding =
|
||||
const EdgeInsets.only(left: 32, top: 20, right: 32, bottom: 40),
|
||||
this.postPadding =
|
||||
const EdgeInsets.only(left: 12.0, top: 12, right: 12.0, bottom: 8),
|
||||
this.postOverviewButtonBottomPadding = 30.0,
|
||||
this.categoryButtonTextPadding,
|
||||
});
|
||||
|
||||
/// The padding between posts in the timeline
|
||||
final EdgeInsets mainPadding;
|
||||
|
||||
/// The padding of each post
|
||||
final EdgeInsets postPadding;
|
||||
|
||||
/// The bottom padding of the button on the post overview screen
|
||||
final double postOverviewButtonBottomPadding;
|
||||
|
||||
/// The padding between the icon and the text in the category button
|
||||
final double? categoryButtonTextPadding;
|
||||
}
|
|
@ -1,76 +0,0 @@
|
|||
import 'package:flutter/material.dart';
|
||||
|
||||
@immutable
|
||||
class TimelineTextStyles {
|
||||
/// Options to update all the texts in the timeline view
|
||||
/// with different textstyles
|
||||
const TimelineTextStyles({
|
||||
this.viewPostStyle,
|
||||
this.listPostTitleStyle,
|
||||
this.listPostCreatorTitleStyle,
|
||||
this.listCreatorNameStyle,
|
||||
this.listPostLikeTitleAndAmount,
|
||||
this.deletePostStyle,
|
||||
this.categorySelectionDescriptionStyle,
|
||||
this.categorySelectionTitleStyle,
|
||||
this.noPostsStyle,
|
||||
this.errorTextStyle,
|
||||
this.postCreatorTitleStyle,
|
||||
this.postCreatorNameStyle,
|
||||
this.postTitleStyle,
|
||||
this.postLikeTitleAndAmount,
|
||||
this.postCreatedAtStyle,
|
||||
this.categoryTitleStyle,
|
||||
});
|
||||
|
||||
/// The TextStyle for the text indicating that you can view a post
|
||||
final TextStyle? viewPostStyle;
|
||||
|
||||
/// The TextStyle for the creatorname at the top of the card
|
||||
/// when it is in the list
|
||||
final TextStyle? listPostCreatorTitleStyle;
|
||||
|
||||
/// The TextStyle for the post title when it is in the list
|
||||
final TextStyle? listPostTitleStyle;
|
||||
|
||||
/// The TextStyle for the creatorname at the bottom of the card
|
||||
/// when it is in the list
|
||||
final TextStyle? listCreatorNameStyle;
|
||||
|
||||
/// The TextStyle for the amount of like and name of the likes at
|
||||
/// the bottom of the card when it is in the list
|
||||
final TextStyle? listPostLikeTitleAndAmount;
|
||||
|
||||
/// The TextStyle for the deletion text that shows in the popupmenu
|
||||
final TextStyle? deletePostStyle;
|
||||
|
||||
/// The TextStyle for the category explainer on the selection page
|
||||
final TextStyle? categorySelectionDescriptionStyle;
|
||||
|
||||
/// The TextStyle for the category items in the list on the selection page
|
||||
final TextStyle? categorySelectionTitleStyle;
|
||||
|
||||
/// The TextStyle for the text when there are no posts
|
||||
final TextStyle? noPostsStyle;
|
||||
|
||||
/// The TextStyle for all error texts
|
||||
final TextStyle? errorTextStyle;
|
||||
|
||||
/// The TextStyle for the creatorname at the top of the post page
|
||||
final TextStyle? postCreatorTitleStyle;
|
||||
|
||||
/// The TextStyle for the creatorname at the bottom of the post page
|
||||
final TextStyle? postCreatorNameStyle;
|
||||
|
||||
/// The TextStyle for the title of the post on the post page
|
||||
final TextStyle? postTitleStyle;
|
||||
|
||||
/// The TextStyle for the amount of likes and name of the likes
|
||||
/// on the post page
|
||||
final TextStyle? postLikeTitleAndAmount;
|
||||
|
||||
/// The TextStyle for the creation time of the post
|
||||
final TextStyle? postCreatedAtStyle;
|
||||
|
||||
final TextStyle? categoryTitleStyle;
|
||||
}
|
|
@ -1,65 +0,0 @@
|
|||
// SPDX-FileCopyrightText: 2023 Iconica
|
||||
//
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_timeline_view/src/config/timeline_styles.dart';
|
||||
|
||||
@immutable
|
||||
class TimelineTheme {
|
||||
const TimelineTheme({
|
||||
this.iconColor,
|
||||
this.likeIcon,
|
||||
this.commentIcon,
|
||||
this.likedIcon,
|
||||
this.sendIcon,
|
||||
this.moreIcon,
|
||||
this.deleteIcon,
|
||||
this.categorySelectionButtonBorderColor,
|
||||
this.categorySelectionButtonBackgroundColor,
|
||||
this.categorySelectionButtonSelectedTextColor,
|
||||
this.categorySelectionButtonUnselectedTextColor,
|
||||
this.postCreationFloatingActionButtonColor,
|
||||
this.textStyles = const TimelineTextStyles(),
|
||||
});
|
||||
|
||||
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;
|
||||
|
||||
/// The text style overrides for all the texts in the timeline
|
||||
final TimelineTextStyles textStyles;
|
||||
|
||||
/// The color of the border around the category in the selection screen
|
||||
final Color? categorySelectionButtonBorderColor;
|
||||
|
||||
/// The color of the background of the category selection button in the
|
||||
/// selection screen
|
||||
final Color? categorySelectionButtonBackgroundColor;
|
||||
|
||||
/// The color of the text of the category selection button when it is selected
|
||||
final Color? categorySelectionButtonSelectedTextColor;
|
||||
|
||||
/// The color of the text of the category selection button when
|
||||
/// it is not selected
|
||||
final Color? categorySelectionButtonUnselectedTextColor;
|
||||
|
||||
/// The color of the floating action button on the overview screen
|
||||
final Color? postCreationFloatingActionButtonColor;
|
||||
}
|
|
@ -1,263 +0,0 @@
|
|||
// SPDX-FileCopyrightText: 2023 Iconica
|
||||
//
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
@immutable
|
||||
|
||||
/// Class that holds all the translations for the timeline component view and
|
||||
/// the corresponding userstory
|
||||
class TimelineTranslations {
|
||||
/// TimelineTranslations constructor where everything is required use this
|
||||
/// if you want to be sure to have all translations specified
|
||||
/// If you just want the default values use the empty constructor
|
||||
/// and optionally override the values with the copyWith method
|
||||
const TimelineTranslations({
|
||||
required this.anonymousUser,
|
||||
required this.noPosts,
|
||||
required this.noPostsWithFilter,
|
||||
required this.title,
|
||||
required this.titleHintText,
|
||||
required this.content,
|
||||
required this.contentHintText,
|
||||
required this.contentDescription,
|
||||
required this.uploadImage,
|
||||
required this.uploadImageDescription,
|
||||
required this.allowComments,
|
||||
required this.allowCommentsDescription,
|
||||
required this.commentsTitleOnPost,
|
||||
required this.checkPost,
|
||||
required this.deletePost,
|
||||
required this.deleteReaction,
|
||||
required this.deleteConfirmationMessage,
|
||||
required this.deleteConfirmationTitle,
|
||||
required this.deleteCancelButton,
|
||||
required this.deleteButton,
|
||||
required this.viewPost,
|
||||
required this.oneLikeTitle,
|
||||
required this.multipleLikesTitle,
|
||||
required this.commentsTitle,
|
||||
required this.firstComment,
|
||||
required this.writeComment,
|
||||
required this.postLoadingError,
|
||||
required this.timelineSelectionDescription,
|
||||
required this.searchHint,
|
||||
required this.postOverview,
|
||||
required this.postIn,
|
||||
required this.postCreation,
|
||||
required this.yes,
|
||||
required this.no,
|
||||
required this.timeLineScreenTitle,
|
||||
required this.createCategoryPopuptitle,
|
||||
required this.addCategoryTitle,
|
||||
required this.addCategorySubmitButton,
|
||||
required this.addCategoryCancelButtton,
|
||||
required this.addCategoryHintText,
|
||||
required this.addCategoryErrorText,
|
||||
required this.titleErrorText,
|
||||
required this.contentErrorText,
|
||||
});
|
||||
|
||||
/// Default translations for the timeline component view
|
||||
const TimelineTranslations.empty({
|
||||
this.anonymousUser = 'Anonymous user',
|
||||
this.noPosts = 'No posts yet',
|
||||
this.noPostsWithFilter = 'No posts with this filter',
|
||||
this.title = 'Title',
|
||||
this.titleHintText = 'Title...',
|
||||
this.content = 'Content',
|
||||
this.contentHintText = '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.commentsTitleOnPost = 'Comments',
|
||||
this.checkPost = 'Overview',
|
||||
this.deletePost = 'Delete post',
|
||||
this.deleteConfirmationTitle = 'Delete Post',
|
||||
this.deleteConfirmationMessage =
|
||||
'Are you sure you want to delete this post?',
|
||||
this.deleteButton = 'Delete',
|
||||
this.deleteCancelButton = 'Cancel',
|
||||
this.deleteReaction = 'Delete Reaction',
|
||||
this.viewPost = 'View post',
|
||||
this.oneLikeTitle = 'like',
|
||||
this.multipleLikesTitle = 'likes',
|
||||
this.commentsTitle = 'Are people allowed to comment?',
|
||||
this.firstComment = 'Be the first to comment',
|
||||
this.writeComment = 'Write your comment here...',
|
||||
this.postLoadingError = 'Something went wrong while loading the post',
|
||||
this.timelineSelectionDescription = 'Choose a category',
|
||||
this.searchHint = 'Search...',
|
||||
this.postOverview = 'Post Overview',
|
||||
this.postIn = 'Post',
|
||||
this.postCreation = 'add post',
|
||||
this.yes = 'Yes',
|
||||
this.no = 'No',
|
||||
this.timeLineScreenTitle = 'iconinstagram',
|
||||
this.createCategoryPopuptitle = 'Choose a title for the new category',
|
||||
this.addCategoryTitle = 'Add category',
|
||||
this.addCategorySubmitButton = 'Add category',
|
||||
this.addCategoryCancelButtton = 'Cancel',
|
||||
this.addCategoryHintText = 'Category name...',
|
||||
this.addCategoryErrorText = 'Please enter a category name',
|
||||
this.titleErrorText = 'Please enter a title',
|
||||
this.contentErrorText = 'Please enter content',
|
||||
});
|
||||
|
||||
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 titleHintText;
|
||||
final String contentHintText;
|
||||
final String titleErrorText;
|
||||
final String contentErrorText;
|
||||
|
||||
final String deletePost;
|
||||
final String deleteConfirmationTitle;
|
||||
final String deleteConfirmationMessage;
|
||||
final String deleteButton;
|
||||
final String deleteCancelButton;
|
||||
|
||||
final String deleteReaction;
|
||||
final String viewPost;
|
||||
final String oneLikeTitle;
|
||||
final String multipleLikesTitle;
|
||||
final String commentsTitle;
|
||||
final String commentsTitleOnPost;
|
||||
final String writeComment;
|
||||
final String firstComment;
|
||||
final String postLoadingError;
|
||||
|
||||
final String timelineSelectionDescription;
|
||||
|
||||
final String searchHint;
|
||||
|
||||
final String postOverview;
|
||||
final String postIn;
|
||||
final String postCreation;
|
||||
|
||||
final String createCategoryPopuptitle;
|
||||
final String addCategoryTitle;
|
||||
final String addCategorySubmitButton;
|
||||
final String addCategoryCancelButtton;
|
||||
final String addCategoryHintText;
|
||||
final String addCategoryErrorText;
|
||||
|
||||
final String yes;
|
||||
final String no;
|
||||
final String timeLineScreenTitle;
|
||||
|
||||
/// Method to override the default values of the translations
|
||||
TimelineTranslations copyWith({
|
||||
String? noPosts,
|
||||
String? noPostsWithFilter,
|
||||
String? anonymousUser,
|
||||
String? title,
|
||||
String? content,
|
||||
String? contentDescription,
|
||||
String? uploadImage,
|
||||
String? uploadImageDescription,
|
||||
String? allowComments,
|
||||
String? allowCommentsDescription,
|
||||
String? commentsTitleOnPost,
|
||||
String? checkPost,
|
||||
String? deletePost,
|
||||
String? deleteConfirmationTitle,
|
||||
String? deleteConfirmationMessage,
|
||||
String? deleteButton,
|
||||
String? deleteCancelButton,
|
||||
String? deleteReaction,
|
||||
String? viewPost,
|
||||
String? oneLikeTitle,
|
||||
String? multipleLikesTitle,
|
||||
String? commentsTitle,
|
||||
String? writeComment,
|
||||
String? firstComment,
|
||||
String? postLoadingError,
|
||||
String? timelineSelectionDescription,
|
||||
String? searchHint,
|
||||
String? postOverview,
|
||||
String? postIn,
|
||||
String? postCreation,
|
||||
String? titleHintText,
|
||||
String? contentHintText,
|
||||
String? yes,
|
||||
String? no,
|
||||
String? timeLineScreenTitle,
|
||||
String? createCategoryPopuptitle,
|
||||
String? addCategoryTitle,
|
||||
String? addCategorySubmitButton,
|
||||
String? addCategoryCancelButtton,
|
||||
String? addCategoryHintText,
|
||||
String? addCategoryErrorText,
|
||||
String? titleErrorText,
|
||||
String? contentErrorText,
|
||||
}) =>
|
||||
TimelineTranslations(
|
||||
noPosts: noPosts ?? this.noPosts,
|
||||
noPostsWithFilter: noPostsWithFilter ?? this.noPostsWithFilter,
|
||||
anonymousUser: anonymousUser ?? this.anonymousUser,
|
||||
title: title ?? this.title,
|
||||
content: content ?? this.content,
|
||||
contentDescription: contentDescription ?? this.contentDescription,
|
||||
uploadImage: uploadImage ?? this.uploadImage,
|
||||
uploadImageDescription:
|
||||
uploadImageDescription ?? this.uploadImageDescription,
|
||||
allowComments: allowComments ?? this.allowComments,
|
||||
allowCommentsDescription:
|
||||
allowCommentsDescription ?? this.allowCommentsDescription,
|
||||
commentsTitleOnPost: commentsTitleOnPost ?? this.commentsTitleOnPost,
|
||||
checkPost: checkPost ?? this.checkPost,
|
||||
deletePost: deletePost ?? this.deletePost,
|
||||
deleteConfirmationTitle:
|
||||
deleteConfirmationTitle ?? this.deleteConfirmationTitle,
|
||||
deleteConfirmationMessage:
|
||||
deleteConfirmationMessage ?? this.deleteConfirmationMessage,
|
||||
deleteButton: deleteButton ?? this.deleteButton,
|
||||
deleteCancelButton: deleteCancelButton ?? this.deleteCancelButton,
|
||||
deleteReaction: deleteReaction ?? this.deleteReaction,
|
||||
viewPost: viewPost ?? this.viewPost,
|
||||
oneLikeTitle: oneLikeTitle ?? this.oneLikeTitle,
|
||||
multipleLikesTitle: multipleLikesTitle ?? this.multipleLikesTitle,
|
||||
commentsTitle: commentsTitle ?? this.commentsTitle,
|
||||
writeComment: writeComment ?? this.writeComment,
|
||||
firstComment: firstComment ?? this.firstComment,
|
||||
postLoadingError: postLoadingError ?? this.postLoadingError,
|
||||
timelineSelectionDescription:
|
||||
timelineSelectionDescription ?? this.timelineSelectionDescription,
|
||||
searchHint: searchHint ?? this.searchHint,
|
||||
postOverview: postOverview ?? this.postOverview,
|
||||
postIn: postIn ?? this.postIn,
|
||||
postCreation: postCreation ?? this.postCreation,
|
||||
titleHintText: titleHintText ?? this.titleHintText,
|
||||
contentHintText: contentHintText ?? this.contentHintText,
|
||||
yes: yes ?? this.yes,
|
||||
no: no ?? this.no,
|
||||
timeLineScreenTitle: timeLineScreenTitle ?? this.timeLineScreenTitle,
|
||||
addCategoryTitle: addCategoryTitle ?? this.addCategoryTitle,
|
||||
addCategorySubmitButton:
|
||||
addCategorySubmitButton ?? this.addCategorySubmitButton,
|
||||
addCategoryCancelButtton:
|
||||
addCategoryCancelButtton ?? this.addCategoryCancelButtton,
|
||||
addCategoryHintText: addCategoryHintText ?? this.addCategoryHintText,
|
||||
createCategoryPopuptitle:
|
||||
createCategoryPopuptitle ?? this.createCategoryPopuptitle,
|
||||
addCategoryErrorText: addCategoryErrorText ?? this.addCategoryErrorText,
|
||||
titleErrorText: titleErrorText ?? this.titleErrorText,
|
||||
contentErrorText: contentErrorText ?? this.contentErrorText,
|
||||
);
|
||||
}
|
|
@ -1,395 +0,0 @@
|
|||
// SPDX-FileCopyrightText: 2023 Iconica
|
||||
//
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
import 'dart:math';
|
||||
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/flutter_timeline_view.dart';
|
||||
import 'package:flutter_timeline_view/src/config/timeline_options.dart';
|
||||
import 'package:flutter_timeline_view/src/widgets/default_filled_button.dart';
|
||||
import 'package:flutter_timeline_view/src/widgets/post_creation_textfield.dart';
|
||||
|
||||
class TimelinePostCreationScreen extends StatefulWidget {
|
||||
const TimelinePostCreationScreen({
|
||||
required this.userId,
|
||||
required this.onPostCreated,
|
||||
required this.service,
|
||||
required this.options,
|
||||
this.postCategory,
|
||||
this.onPostOverview,
|
||||
this.enablePostOverviewScreen = false,
|
||||
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;
|
||||
|
||||
/// Nullable callback for routing to the post overview
|
||||
final void Function(TimelinePost)? onPostOverview;
|
||||
final bool enablePostOverviewScreen;
|
||||
|
||||
@override
|
||||
State<TimelinePostCreationScreen> createState() =>
|
||||
_TimelinePostCreationScreenState();
|
||||
}
|
||||
|
||||
class _TimelinePostCreationScreenState
|
||||
extends State<TimelinePostCreationScreen> {
|
||||
TextEditingController titleController = TextEditingController();
|
||||
TextEditingController contentController = TextEditingController();
|
||||
Uint8List? image;
|
||||
bool allowComments = false;
|
||||
bool titleIsValid = false;
|
||||
bool contentIsValid = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
titleController.addListener(_listenForInputs);
|
||||
contentController.addListener(_listenForInputs);
|
||||
|
||||
super.initState();
|
||||
}
|
||||
|
||||
void _listenForInputs() {
|
||||
titleIsValid = titleController.text.isNotEmpty;
|
||||
contentIsValid = contentController.text.isNotEmpty;
|
||||
setState(() {});
|
||||
}
|
||||
|
||||
var formkey = GlobalKey<FormState>();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
var imageRequired = widget.options.requireImageForPost;
|
||||
|
||||
Future<void> onPostCreated() async {
|
||||
var user = await widget.service.userService?.getUser(widget.userId);
|
||||
var post = TimelinePost(
|
||||
id: 'Post${Random().nextInt(1000)}',
|
||||
creatorId: widget.userId,
|
||||
title: titleController.text,
|
||||
category: widget.postCategory,
|
||||
content: contentController.text,
|
||||
likes: 0,
|
||||
likedBy: const [],
|
||||
reaction: 0,
|
||||
createdAt: DateTime.now(),
|
||||
reactionEnabled: allowComments,
|
||||
image: image,
|
||||
creator: user,
|
||||
);
|
||||
|
||||
if (widget.enablePostOverviewScreen) {
|
||||
widget.onPostOverview?.call(post);
|
||||
} else {
|
||||
widget.onPostCreated.call(post);
|
||||
}
|
||||
}
|
||||
|
||||
var theme = Theme.of(context);
|
||||
|
||||
return GestureDetector(
|
||||
onTap: () => FocusScope.of(context).unfocus(),
|
||||
child: SingleChildScrollView(
|
||||
child: Padding(
|
||||
padding: widget.options.paddings.mainPadding,
|
||||
child: Form(
|
||||
key: formkey,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.max,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
widget.options.translations.title,
|
||||
style: theme.textTheme.titleMedium,
|
||||
),
|
||||
const SizedBox(
|
||||
height: 4,
|
||||
),
|
||||
widget.options.textInputBuilder?.call(
|
||||
titleController,
|
||||
null,
|
||||
'',
|
||||
) ??
|
||||
PostCreationTextfield(
|
||||
fieldKey: const ValueKey('title'),
|
||||
controller: titleController,
|
||||
hintText: widget.options.translations.titleHintText,
|
||||
textMaxLength: widget.options.maxTitleLength,
|
||||
decoration: widget.options.titleInputDecoration,
|
||||
textCapitalization: TextCapitalization.sentences,
|
||||
expands: null,
|
||||
minLines: null,
|
||||
maxLines: 1,
|
||||
validator: (value) {
|
||||
if (value == null || value.isEmpty) {
|
||||
return widget.options.translations.titleErrorText;
|
||||
}
|
||||
if (value.trim().isEmpty) {
|
||||
return widget.options.translations.titleErrorText;
|
||||
}
|
||||
return null;
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
Text(
|
||||
widget.options.translations.content,
|
||||
style: theme.textTheme.titleMedium,
|
||||
),
|
||||
Text(
|
||||
widget.options.translations.contentDescription,
|
||||
style: theme.textTheme.bodySmall,
|
||||
),
|
||||
const SizedBox(
|
||||
height: 4,
|
||||
),
|
||||
PostCreationTextfield(
|
||||
fieldKey: const ValueKey('content'),
|
||||
controller: contentController,
|
||||
hintText: widget.options.translations.contentHintText,
|
||||
textMaxLength: null,
|
||||
decoration: widget.options.contentInputDecoration,
|
||||
textCapitalization: TextCapitalization.sentences,
|
||||
expands: false,
|
||||
minLines: null,
|
||||
maxLines: null,
|
||||
validator: (value) {
|
||||
if (value == null || value.isEmpty) {
|
||||
return widget.options.translations.contentErrorText;
|
||||
}
|
||||
if (value.trim().isEmpty) {
|
||||
return widget.options.translations.contentErrorText;
|
||||
}
|
||||
return null;
|
||||
},
|
||||
),
|
||||
const SizedBox(
|
||||
height: 24,
|
||||
),
|
||||
Text(
|
||||
widget.options.translations.uploadImage,
|
||||
style: theme.textTheme.titleMedium,
|
||||
),
|
||||
Text(
|
||||
widget.options.translations.uploadImageDescription,
|
||||
style: theme.textTheme.bodySmall,
|
||||
),
|
||||
const SizedBox(
|
||||
height: 8,
|
||||
),
|
||||
Stack(
|
||||
children: [
|
||||
GestureDetector(
|
||||
onTap: () async {
|
||||
var result = await showModalBottomSheet<Uint8List?>(
|
||||
context: context,
|
||||
builder: (context) => Container(
|
||||
padding: const EdgeInsets.all(20),
|
||||
color: theme.colorScheme.surface,
|
||||
child: ImagePicker(
|
||||
config: widget.options.imagePickerConfig,
|
||||
theme: widget.options.imagePickerTheme ??
|
||||
ImagePickerTheme(
|
||||
titleStyle: theme.textTheme.titleMedium,
|
||||
iconSize: 40,
|
||||
selectImageText: 'UPLOAD FILE',
|
||||
makePhotoText: 'TAKE PICTURE',
|
||||
selectImageIcon: const Icon(
|
||||
size: 40,
|
||||
Icons.insert_drive_file,
|
||||
),
|
||||
closeButtonBuilder: (onTap) => TextButton(
|
||||
onPressed: () {
|
||||
onTap();
|
||||
},
|
||||
child: Text(
|
||||
'Cancel',
|
||||
style: theme.textTheme.bodyMedium!
|
||||
.copyWith(
|
||||
decoration: TextDecoration.underline,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
if (result != null) {
|
||||
setState(() {
|
||||
image = result;
|
||||
});
|
||||
}
|
||||
},
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(8.0),
|
||||
child: image != null
|
||||
? Image.memory(
|
||||
image!,
|
||||
width: double.infinity,
|
||||
height: 150.0,
|
||||
fit: BoxFit.cover,
|
||||
// give it a rounded border
|
||||
)
|
||||
: DottedBorder(
|
||||
dashPattern: const [4, 4],
|
||||
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: 50,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
// if an image is selected, show a delete button
|
||||
if (image != null) ...[
|
||||
Positioned(
|
||||
top: 8,
|
||||
right: 8,
|
||||
child: GestureDetector(
|
||||
onTap: () {
|
||||
setState(() {
|
||||
image = null;
|
||||
});
|
||||
},
|
||||
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.titleMedium,
|
||||
),
|
||||
Text(
|
||||
widget.options.translations.allowCommentsDescription,
|
||||
style: theme.textTheme.bodySmall,
|
||||
),
|
||||
const SizedBox(
|
||||
height: 8,
|
||||
),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
Checkbox(
|
||||
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
||||
visualDensity:
|
||||
const VisualDensity(horizontal: -4, vertical: -4),
|
||||
activeColor: theme.colorScheme.primary,
|
||||
value: allowComments,
|
||||
onChanged: (value) {
|
||||
setState(() {
|
||||
allowComments = true;
|
||||
});
|
||||
},
|
||||
),
|
||||
const SizedBox(
|
||||
width: 4,
|
||||
),
|
||||
Text(
|
||||
widget.options.translations.yes,
|
||||
style: theme.textTheme.bodyMedium,
|
||||
),
|
||||
const SizedBox(
|
||||
width: 32,
|
||||
),
|
||||
Checkbox(
|
||||
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
||||
visualDensity:
|
||||
const VisualDensity(horizontal: -4, vertical: -4),
|
||||
activeColor: theme.colorScheme.primary,
|
||||
value: !allowComments,
|
||||
onChanged: (value) {
|
||||
setState(() {
|
||||
allowComments = false;
|
||||
});
|
||||
},
|
||||
),
|
||||
const SizedBox(
|
||||
width: 4,
|
||||
),
|
||||
Text(
|
||||
widget.options.translations.no,
|
||||
style: theme.textTheme.bodyMedium,
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 120),
|
||||
SafeArea(
|
||||
bottom: true,
|
||||
child: Align(
|
||||
alignment: Alignment.bottomCenter,
|
||||
child: widget.options.buttonBuilder?.call(
|
||||
context,
|
||||
onPostCreated,
|
||||
widget.options.translations.checkPost,
|
||||
enabled: formkey.currentState!.validate(),
|
||||
) ??
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 48),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: DefaultFilledButton(
|
||||
onPressed: titleIsValid &&
|
||||
contentIsValid &&
|
||||
(!imageRequired || image != null)
|
||||
? () async {
|
||||
if (formkey.currentState!
|
||||
.validate()) {
|
||||
await onPostCreated();
|
||||
await widget.service.postService
|
||||
.fetchPosts(null);
|
||||
}
|
||||
}
|
||||
: null,
|
||||
buttonText: widget.enablePostOverviewScreen
|
||||
? widget.options.translations.checkPost
|
||||
: widget
|
||||
.options.translations.postCreation,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
|
@ -1,78 +0,0 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_timeline_interface/flutter_timeline_interface.dart';
|
||||
import 'package:flutter_timeline_view/flutter_timeline_view.dart';
|
||||
import 'package:flutter_timeline_view/src/widgets/default_filled_button.dart';
|
||||
|
||||
class TimelinePostOverviewScreen extends StatelessWidget {
|
||||
const TimelinePostOverviewScreen({
|
||||
required this.timelinePost,
|
||||
required this.options,
|
||||
required this.service,
|
||||
required this.onPostSubmit,
|
||||
super.key,
|
||||
});
|
||||
final TimelinePost timelinePost;
|
||||
final TimelineOptions options;
|
||||
final TimelineService service;
|
||||
final void Function(TimelinePost) onPostSubmit;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
var isSubmitted = false;
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.max,
|
||||
children: [
|
||||
Expanded(
|
||||
child: TimelinePostScreen(
|
||||
userId: timelinePost.creatorId,
|
||||
options: options,
|
||||
post: timelinePost,
|
||||
onPostDelete: () async {},
|
||||
service: service,
|
||||
isOverviewScreen: true,
|
||||
),
|
||||
),
|
||||
options.postOverviewButtonBuilder?.call(
|
||||
context,
|
||||
() {
|
||||
if (isSubmitted) return;
|
||||
isSubmitted = true;
|
||||
onPostSubmit(timelinePost);
|
||||
},
|
||||
options.translations.postIn,
|
||||
timelinePost,
|
||||
) ??
|
||||
options.buttonBuilder?.call(
|
||||
context,
|
||||
() {
|
||||
if (isSubmitted) return;
|
||||
isSubmitted = true;
|
||||
onPostSubmit(timelinePost);
|
||||
},
|
||||
options.translations.postIn,
|
||||
enabled: true,
|
||||
) ??
|
||||
SafeArea(
|
||||
bottom: true,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 80),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: DefaultFilledButton(
|
||||
onPressed: () async {
|
||||
if (isSubmitted) return;
|
||||
isSubmitted = true;
|
||||
onPostSubmit(timelinePost);
|
||||
},
|
||||
buttonText: options.translations.postIn,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
|
@ -1,695 +0,0 @@
|
|||
// SPDX-FileCopyrightText: 2023 Iconica
|
||||
//
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_svg/svg.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:flutter_timeline_view/src/widgets/tappable_image.dart';
|
||||
import 'package:flutter_timeline_view/src/widgets/timeline_post_widget.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
|
||||
class TimelinePostScreen extends StatefulWidget {
|
||||
const TimelinePostScreen({
|
||||
required this.userId,
|
||||
required this.service,
|
||||
required this.options,
|
||||
required this.post,
|
||||
required this.onPostDelete,
|
||||
this.allowAllDeletion = false,
|
||||
this.isOverviewScreen = false,
|
||||
this.onUserTap,
|
||||
super.key,
|
||||
});
|
||||
|
||||
/// The user id of the current user
|
||||
final String userId;
|
||||
|
||||
/// Allow all posts to be deleted instead of
|
||||
/// only the posts of the current user
|
||||
final bool allowAllDeletion;
|
||||
|
||||
/// The timeline service to fetch the post details
|
||||
final TimelineService service;
|
||||
|
||||
/// Options to configure the timeline screens
|
||||
final TimelineOptions options;
|
||||
|
||||
/// The post to show
|
||||
final TimelinePost post;
|
||||
|
||||
/// If this is not null, the user can tap on the user avatar or name
|
||||
final Function(String userId)? onUserTap;
|
||||
|
||||
final VoidCallback onPostDelete;
|
||||
|
||||
final bool? isOverviewScreen;
|
||||
|
||||
@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.postService.fetchPostDetails(widget.post);
|
||||
setState(() {
|
||||
post = loadedPost;
|
||||
isLoading = false;
|
||||
});
|
||||
} on Exception catch (_) {
|
||||
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 'at' HH:mm",
|
||||
Localizations.localeOf(context).languageCode,
|
||||
);
|
||||
if (isLoading) {
|
||||
return const Center(
|
||||
child: CircularProgressIndicator.adaptive(),
|
||||
);
|
||||
}
|
||||
if (this.post == null) {
|
||||
return Center(
|
||||
child: Text(
|
||||
widget.options.translations.postLoadingError,
|
||||
style: widget.options.theme.textStyles.errorTextStyle,
|
||||
),
|
||||
);
|
||||
}
|
||||
var post = this.post!;
|
||||
post.reactions?.sort(
|
||||
(a, b) => widget.options.sortCommentsAscending
|
||||
? a.createdAt.compareTo(b.createdAt)
|
||||
: b.createdAt.compareTo(a.createdAt),
|
||||
);
|
||||
var isLikedByUser = post.likedBy?.contains(widget.userId) ?? false;
|
||||
|
||||
var textInputBuilder = widget.options.textInputBuilder ??
|
||||
(controller, suffixIcon, hintText) => TextField(
|
||||
style: theme.textTheme.bodyMedium,
|
||||
textCapitalization: TextCapitalization.sentences,
|
||||
controller: controller,
|
||||
decoration: InputDecoration(
|
||||
enabledBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(25),
|
||||
borderSide: const BorderSide(
|
||||
color: Colors.black,
|
||||
),
|
||||
),
|
||||
focusedBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(25),
|
||||
borderSide: const BorderSide(
|
||||
color: Colors.black,
|
||||
),
|
||||
),
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
vertical: 0,
|
||||
horizontal: 16,
|
||||
),
|
||||
hintText: widget.options.translations.writeComment,
|
||||
hintStyle: theme.textTheme.bodyMedium!.copyWith(
|
||||
color: theme.textTheme.bodyMedium!.color!.withOpacity(0.5),
|
||||
),
|
||||
fillColor: Colors.white,
|
||||
filled: true,
|
||||
border: const OutlineInputBorder(
|
||||
borderRadius: BorderRadius.all(
|
||||
Radius.circular(25),
|
||||
),
|
||||
borderSide: BorderSide.none,
|
||||
),
|
||||
suffixIcon: suffixIcon,
|
||||
),
|
||||
);
|
||||
|
||||
return Stack(
|
||||
children: [
|
||||
RefreshIndicator.adaptive(
|
||||
onRefresh: () async {
|
||||
updatePost(
|
||||
await widget.service.postService.fetchPostDetails(
|
||||
await widget.service.postService.fetchPost(
|
||||
post,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
child: SingleChildScrollView(
|
||||
child: Padding(
|
||||
padding: widget.options.paddings.postPadding,
|
||||
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!,
|
||||
28,
|
||||
) ??
|
||||
CircleAvatar(
|
||||
radius: 14,
|
||||
backgroundImage:
|
||||
CachedNetworkImageProvider(
|
||||
post.creator!.imageUrl!,
|
||||
),
|
||||
),
|
||||
] else ...[
|
||||
widget.options.anonymousAvatarBuilder?.call(
|
||||
post.creator!,
|
||||
28,
|
||||
) ??
|
||||
const CircleAvatar(
|
||||
radius: 14,
|
||||
child: Icon(
|
||||
Icons.person,
|
||||
),
|
||||
),
|
||||
],
|
||||
const SizedBox(width: 10),
|
||||
Text(
|
||||
widget.options.nameBuilder
|
||||
?.call(post.creator) ??
|
||||
post.creator?.fullName ??
|
||||
widget.options.translations.anonymousUser,
|
||||
style: widget.options.theme.textStyles
|
||||
.postCreatorTitleStyle ??
|
||||
theme.textTheme.titleSmall!.copyWith(
|
||||
color: Colors.black,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
if (!(widget.isOverviewScreen ?? false) &&
|
||||
(widget.allowAllDeletion ||
|
||||
post.creator?.userId == widget.userId)) ...[
|
||||
PopupMenuButton(
|
||||
onSelected: (value) async {
|
||||
if (value == 'delete') {
|
||||
await showPostDeletionConfirmationDialog(
|
||||
widget.options,
|
||||
context,
|
||||
widget.onPostDelete,
|
||||
);
|
||||
}
|
||||
},
|
||||
itemBuilder: (BuildContext context) =>
|
||||
<PopupMenuEntry<String>>[
|
||||
PopupMenuItem<String>(
|
||||
value: 'delete',
|
||||
child: Row(
|
||||
children: [
|
||||
Text(
|
||||
widget.options.translations.deletePost,
|
||||
style: widget.options.theme.textStyles
|
||||
.deletePostStyle ??
|
||||
theme.textTheme.bodyMedium,
|
||||
),
|
||||
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,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
// image of the posts
|
||||
if (post.imageUrl != null || post.image != null) ...[
|
||||
const SizedBox(height: 8),
|
||||
ClipRRect(
|
||||
borderRadius: const BorderRadius.all(Radius.circular(8)),
|
||||
child: widget.options.doubleTapTolike
|
||||
? TappableImage(
|
||||
likeAndDislikeIcon: widget
|
||||
.options.likeAndDislikeIconsForDoubleTap,
|
||||
post: post,
|
||||
userId: widget.userId,
|
||||
onLike: ({required bool liked}) async {
|
||||
var userId = widget.userId;
|
||||
|
||||
late TimelinePost result;
|
||||
|
||||
if (!liked) {
|
||||
result =
|
||||
await widget.service.postService.likePost(
|
||||
userId,
|
||||
post,
|
||||
);
|
||||
} else {
|
||||
result = await widget.service.postService
|
||||
.unlikePost(
|
||||
userId,
|
||||
post,
|
||||
);
|
||||
}
|
||||
|
||||
await loadPostDetails();
|
||||
|
||||
return result.likedBy?.contains(userId) ??
|
||||
false;
|
||||
},
|
||||
)
|
||||
: post.image != null
|
||||
? Image.memory(
|
||||
width: double.infinity,
|
||||
post.image!,
|
||||
fit: BoxFit.fitHeight,
|
||||
)
|
||||
: CachedNetworkImage(
|
||||
width: double.infinity,
|
||||
imageUrl: post.imageUrl!,
|
||||
fit: BoxFit.fitHeight,
|
||||
),
|
||||
),
|
||||
],
|
||||
const SizedBox(
|
||||
height: 8,
|
||||
),
|
||||
// post information
|
||||
Row(
|
||||
children: [
|
||||
IconButton(
|
||||
padding: EdgeInsets.zero,
|
||||
constraints: const BoxConstraints(),
|
||||
onPressed: () async {
|
||||
if (widget.isOverviewScreen ?? false) return;
|
||||
if (isLikedByUser) {
|
||||
updatePost(
|
||||
await widget.service.postService.unlikePost(
|
||||
widget.userId,
|
||||
post,
|
||||
),
|
||||
);
|
||||
setState(() {});
|
||||
} else {
|
||||
updatePost(
|
||||
await widget.service.postService.likePost(
|
||||
widget.userId,
|
||||
post,
|
||||
),
|
||||
);
|
||||
setState(() {});
|
||||
}
|
||||
},
|
||||
icon: isLikedByUser
|
||||
? widget.options.theme.likedIcon ??
|
||||
Icon(
|
||||
Icons.favorite_rounded,
|
||||
color: widget.options.theme.iconColor,
|
||||
size: widget.options.iconSize,
|
||||
)
|
||||
: widget.options.theme.likeIcon ??
|
||||
Icon(
|
||||
Icons.favorite_outline_outlined,
|
||||
color: widget.options.theme.iconColor,
|
||||
size: widget.options.iconSize,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
if (post.reactionEnabled)
|
||||
widget.options.theme.commentIcon ??
|
||||
SvgPicture.asset(
|
||||
'assets/Comment.svg',
|
||||
package: 'flutter_timeline_view',
|
||||
// ignore: deprecated_member_use
|
||||
color: widget.options.theme.iconColor,
|
||||
width: widget.options.iconSize,
|
||||
height: widget.options.iconSize,
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
// ignore: avoid_bool_literals_in_conditional_expressions
|
||||
if (widget.isOverviewScreen != null
|
||||
? !widget.isOverviewScreen!
|
||||
: false) ...[
|
||||
Text(
|
||||
// ignore: lines_longer_than_80_chars
|
||||
'${post.likes} ${post.likes > 1 ? widget.options.translations.multipleLikesTitle : widget.options.translations.oneLikeTitle}',
|
||||
style: widget.options.theme.textStyles
|
||||
.postLikeTitleAndAmount ??
|
||||
theme.textTheme.titleSmall
|
||||
?.copyWith(color: Colors.black),
|
||||
),
|
||||
],
|
||||
Text.rich(
|
||||
TextSpan(
|
||||
text: widget.options.nameBuilder?.call(post.creator) ??
|
||||
post.creator?.fullName ??
|
||||
widget.options.translations.anonymousUser,
|
||||
style: widget
|
||||
.options.theme.textStyles.postCreatorNameStyle ??
|
||||
theme.textTheme.titleSmall!
|
||||
.copyWith(color: Colors.black),
|
||||
children: [
|
||||
TextSpan(
|
||||
text: post.title,
|
||||
style:
|
||||
widget.options.theme.textStyles.postTitleStyle ??
|
||||
theme.textTheme.bodySmall,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
Text(
|
||||
post.content,
|
||||
style: theme.textTheme.bodySmall,
|
||||
),
|
||||
Text(
|
||||
'${dateFormat.format(post.createdAt)} ',
|
||||
style: theme.textTheme.labelSmall?.copyWith(
|
||||
letterSpacing: 0.5,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
// ignore: avoid_bool_literals_in_conditional_expressions
|
||||
if (post.reactionEnabled && widget.isOverviewScreen != null
|
||||
? !widget.isOverviewScreen!
|
||||
: false) ...[
|
||||
Text(
|
||||
widget.options.translations.commentsTitleOnPost,
|
||||
style: theme.textTheme.titleSmall!
|
||||
.copyWith(color: Colors.black),
|
||||
),
|
||||
for (var reaction
|
||||
in post.reactions ?? <TimelinePostReaction>[]) ...[
|
||||
const SizedBox(height: 4),
|
||||
GestureDetector(
|
||||
onLongPressStart: (details) async {
|
||||
if (reaction.creatorId == widget.userId ||
|
||||
widget.allowAllDeletion) {
|
||||
var overlay = Overlay.of(context)
|
||||
.context
|
||||
.findRenderObject()! as RenderBox;
|
||||
var position = RelativeRect.fromRect(
|
||||
Rect.fromPoints(
|
||||
details.globalPosition,
|
||||
details.globalPosition,
|
||||
),
|
||||
Offset.zero & overlay.size,
|
||||
);
|
||||
// Show popup menu for deletion
|
||||
var value = await showMenu<String>(
|
||||
context: context,
|
||||
position: position,
|
||||
items: [
|
||||
PopupMenuItem<String>(
|
||||
value: 'delete',
|
||||
child: Text(
|
||||
widget.options.translations.deleteReaction,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
if (value == 'delete') {
|
||||
// Call service to delete reaction
|
||||
updatePost(
|
||||
await widget.service.postService
|
||||
.deletePostReaction(post, reaction.id),
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (reaction.creator?.imageUrl != null &&
|
||||
reaction.creator!.imageUrl!.isNotEmpty) ...[
|
||||
widget.options.userAvatarBuilder?.call(
|
||||
reaction.creator!,
|
||||
14,
|
||||
) ??
|
||||
CircleAvatar(
|
||||
radius: 14,
|
||||
backgroundImage: CachedNetworkImageProvider(
|
||||
reaction.creator!.imageUrl!,
|
||||
),
|
||||
),
|
||||
] else ...[
|
||||
widget.options.anonymousAvatarBuilder?.call(
|
||||
reaction.creator!,
|
||||
14,
|
||||
) ??
|
||||
const CircleAvatar(
|
||||
radius: 14,
|
||||
child: Icon(
|
||||
Icons.person,
|
||||
),
|
||||
),
|
||||
],
|
||||
const SizedBox(width: 10),
|
||||
if (reaction.imageUrl != null) ...[
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
widget.options.nameBuilder
|
||||
?.call(reaction.creator) ??
|
||||
reaction.creator?.fullName ??
|
||||
widget.options.translations
|
||||
.anonymousUser,
|
||||
style: theme.textTheme.titleSmall!
|
||||
.copyWith(color: Colors.black),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(8.0),
|
||||
child: CachedNetworkImage(
|
||||
imageUrl: reaction.imageUrl!,
|
||||
fit: BoxFit.fitWidth,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
] else ...[
|
||||
Expanded(
|
||||
child: Text.rich(
|
||||
TextSpan(
|
||||
text: widget.options.nameBuilder
|
||||
?.call(reaction.creator) ??
|
||||
reaction.creator?.fullName ??
|
||||
widget
|
||||
.options.translations.anonymousUser,
|
||||
style: theme.textTheme.titleSmall!
|
||||
.copyWith(color: Colors.black),
|
||||
children: [
|
||||
const TextSpan(text: ' '),
|
||||
TextSpan(
|
||||
text: reaction.reaction ?? '',
|
||||
style: theme.textTheme.bodySmall,
|
||||
),
|
||||
const TextSpan(text: '\n'),
|
||||
TextSpan(
|
||||
text: dateFormat
|
||||
.format(reaction.createdAt),
|
||||
style: theme.textTheme.labelSmall!
|
||||
.copyWith(
|
||||
color: theme
|
||||
.textTheme.labelSmall!.color!
|
||||
.withOpacity(0.5),
|
||||
letterSpacing: 0.5,
|
||||
),
|
||||
),
|
||||
|
||||
// text should go to new line
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
Builder(
|
||||
builder: (context) {
|
||||
var isLikedByUser =
|
||||
reaction.likedBy?.contains(widget.userId) ??
|
||||
false;
|
||||
return IconButton(
|
||||
padding: const EdgeInsets.only(left: 12),
|
||||
constraints: const BoxConstraints(),
|
||||
onPressed: () async {
|
||||
if (isLikedByUser) {
|
||||
updatePost(
|
||||
await widget.service.postService
|
||||
.unlikeReaction(
|
||||
widget.userId,
|
||||
post,
|
||||
reaction.id,
|
||||
),
|
||||
);
|
||||
setState(() {});
|
||||
} else {
|
||||
updatePost(
|
||||
await widget.service.postService
|
||||
.likeReaction(
|
||||
widget.userId,
|
||||
post,
|
||||
reaction.id,
|
||||
),
|
||||
);
|
||||
setState(() {});
|
||||
}
|
||||
},
|
||||
icon: isLikedByUser
|
||||
? widget.options.theme.likedIcon ??
|
||||
Icon(
|
||||
Icons.favorite_rounded,
|
||||
color:
|
||||
widget.options.theme.iconColor,
|
||||
size: 14,
|
||||
)
|
||||
: widget.options.theme.likeIcon ??
|
||||
Icon(
|
||||
Icons.favorite_outline_outlined,
|
||||
color:
|
||||
widget.options.theme.iconColor,
|
||||
size: 14,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
],
|
||||
if (post.reactions?.isEmpty ?? true) ...[
|
||||
Text(
|
||||
widget.options.translations.firstComment,
|
||||
style: theme.textTheme.bodySmall,
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 120),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (post.reactionEnabled && !(widget.isOverviewScreen ?? false))
|
||||
Align(
|
||||
alignment: Alignment.bottomCenter,
|
||||
child: Container(
|
||||
color: theme.scaffoldBackgroundColor,
|
||||
constraints: BoxConstraints(
|
||||
maxWidth: MediaQuery.of(context).size.width,
|
||||
),
|
||||
child: SafeArea(
|
||||
bottom: true,
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 8),
|
||||
child: post.creator!.imageUrl != null
|
||||
? widget.options.userAvatarBuilder?.call(
|
||||
post.creator!,
|
||||
28,
|
||||
) ??
|
||||
CircleAvatar(
|
||||
radius: 14,
|
||||
backgroundImage: CachedNetworkImageProvider(
|
||||
post.creator!.imageUrl!,
|
||||
),
|
||||
)
|
||||
: widget.options.anonymousAvatarBuilder?.call(
|
||||
post.creator!,
|
||||
28,
|
||||
) ??
|
||||
const CircleAvatar(
|
||||
radius: 14,
|
||||
child: Icon(
|
||||
Icons.person,
|
||||
),
|
||||
),
|
||||
),
|
||||
Flexible(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(
|
||||
left: 8,
|
||||
right: 16,
|
||||
top: 8,
|
||||
bottom: 8,
|
||||
),
|
||||
child: ReactionBottom(
|
||||
messageInputBuilder: textInputBuilder,
|
||||
onReactionSubmit: (reaction) async => updatePost(
|
||||
await widget.service.postService.reactToPost(
|
||||
post,
|
||||
TimelinePostReaction(
|
||||
id: '',
|
||||
postId: post.id,
|
||||
reaction: reaction,
|
||||
creatorId: widget.userId,
|
||||
createdAt: DateTime.now(),
|
||||
),
|
||||
),
|
||||
),
|
||||
translations: widget.options.translations,
|
||||
iconColor: widget.options.theme.iconColor,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
|
@ -1,343 +0,0 @@
|
|||
// SPDX-FileCopyrightText: 2023 Iconica
|
||||
//
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_timeline_interface/flutter_timeline_interface.dart';
|
||||
import 'package:flutter_timeline_view/flutter_timeline_view.dart';
|
||||
|
||||
class TimelineScreen extends StatefulWidget {
|
||||
const TimelineScreen({
|
||||
this.userId = 'test_user',
|
||||
this.service,
|
||||
this.options = const TimelineOptions(),
|
||||
this.onPostTap,
|
||||
this.scrollController,
|
||||
this.onUserTap,
|
||||
this.onRefresh,
|
||||
this.posts,
|
||||
this.timelineCategory,
|
||||
this.postWidgetBuilder,
|
||||
this.filterEnabled = false,
|
||||
this.allowAllDeletion = false,
|
||||
super.key,
|
||||
});
|
||||
|
||||
/// The user id of the current user
|
||||
final String userId;
|
||||
|
||||
/// Allow all posts to be deleted instead of
|
||||
/// only the posts of the current user
|
||||
final bool allowAllDeletion;
|
||||
|
||||
/// 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? scrollController;
|
||||
|
||||
/// The string to filter the timeline by category
|
||||
final String? timelineCategory;
|
||||
|
||||
/// 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;
|
||||
|
||||
/// Called when the timeline is refreshed by pulling down
|
||||
final Function(BuildContext context, String? category)? onRefresh;
|
||||
|
||||
/// If this is not null, the user can tap on the user avatar or name
|
||||
final Function(String userId)? onUserTap;
|
||||
|
||||
/// Override the standard postwidget
|
||||
final Widget Function(TimelinePost post)? postWidgetBuilder;
|
||||
|
||||
/// if true the filter textfield is enabled.
|
||||
final bool filterEnabled;
|
||||
|
||||
@override
|
||||
State<TimelineScreen> createState() => _TimelineScreenState();
|
||||
}
|
||||
|
||||
class _TimelineScreenState extends State<TimelineScreen> {
|
||||
late ScrollController controller;
|
||||
late var textFieldController = TextEditingController(
|
||||
text: widget.options.filterOptions.initialFilterWord,
|
||||
);
|
||||
late var service = widget.service ??
|
||||
TimelineService(
|
||||
postService: LocalTimelinePostService(),
|
||||
);
|
||||
|
||||
bool isLoading = true;
|
||||
|
||||
late var category = widget.timelineCategory;
|
||||
|
||||
late var filterWord = widget.options.filterOptions.initialFilterWord;
|
||||
|
||||
bool _isOnTop = true;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
controller.removeListener(_updateIsOnTop);
|
||||
controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _updateIsOnTop() {
|
||||
setState(() {
|
||||
_isOnTop = controller.position.pixels < 0.1;
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
controller = widget.scrollController ?? ScrollController();
|
||||
controller.addListener(_updateIsOnTop);
|
||||
|
||||
// only load the posts after the first frame
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
unawaited(loadPosts());
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (isLoading && widget.posts == null) {
|
||||
return const Center(child: CircularProgressIndicator.adaptive());
|
||||
}
|
||||
|
||||
// Build the list of posts
|
||||
return ListenableBuilder(
|
||||
listenable: service.postService,
|
||||
builder: (context, _) {
|
||||
if (!context.mounted) return const SizedBox();
|
||||
var posts = widget.posts ?? service.postService.getPosts(category);
|
||||
|
||||
if (widget.filterEnabled && filterWord != null) {
|
||||
if (service.postService is TimelineFilterService) {
|
||||
posts = (service.postService as TimelineFilterService)
|
||||
.filterPosts(filterWord!, {});
|
||||
} else {
|
||||
debugPrint('Timeline service needs to mixin'
|
||||
' with TimelineFilterService');
|
||||
}
|
||||
}
|
||||
|
||||
posts = posts
|
||||
.where(
|
||||
(p) => category == null || p.category == category,
|
||||
)
|
||||
.toList();
|
||||
|
||||
// sort posts by date
|
||||
if (widget.options.sortPostsAscending != null) {
|
||||
posts.sort(
|
||||
(a, b) => widget.options.sortPostsAscending!
|
||||
? a.createdAt.compareTo(b.createdAt)
|
||||
: b.createdAt.compareTo(a.createdAt),
|
||||
);
|
||||
}
|
||||
|
||||
var categories = service.postService.categories;
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
SizedBox(
|
||||
height: widget.options.paddings.mainPadding.top,
|
||||
),
|
||||
if (widget.filterEnabled) ...[
|
||||
Padding(
|
||||
padding: EdgeInsets.only(
|
||||
left: widget.options.paddings.mainPadding.left,
|
||||
right: widget.options.paddings.mainPadding.right,
|
||||
),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
Expanded(
|
||||
child: TextField(
|
||||
controller: textFieldController,
|
||||
onChanged: (value) {
|
||||
setState(() {
|
||||
filterWord = value;
|
||||
});
|
||||
},
|
||||
decoration: InputDecoration(
|
||||
hintText: widget.options.translations.searchHint,
|
||||
suffixIconConstraints:
|
||||
const BoxConstraints(maxHeight: 14),
|
||||
contentPadding: const EdgeInsets.only(
|
||||
left: 12,
|
||||
right: 12,
|
||||
bottom: -10,
|
||||
),
|
||||
suffixIcon: const Padding(
|
||||
padding: EdgeInsets.only(right: 12),
|
||||
child: Icon(Icons.search),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(
|
||||
width: 8,
|
||||
),
|
||||
InkWell(
|
||||
onTap: () {
|
||||
setState(() {
|
||||
textFieldController.clear();
|
||||
filterWord = null;
|
||||
widget.options.filterOptions.onFilterEnabledChange
|
||||
?.call(filterEnabled: false);
|
||||
});
|
||||
},
|
||||
child: const Padding(
|
||||
padding: EdgeInsets.all(8),
|
||||
child: Icon(
|
||||
Icons.close,
|
||||
color: Color(0xFF000000),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(
|
||||
height: 24,
|
||||
),
|
||||
],
|
||||
CategorySelector(
|
||||
categories: categories,
|
||||
isOnTop: _isOnTop,
|
||||
filter: category,
|
||||
options: widget.options,
|
||||
onTapCategory: (categoryKey) {
|
||||
setState(() {
|
||||
service.postService.selectedCategory =
|
||||
categories.firstWhereOrNull(
|
||||
(element) => element.key == categoryKey,
|
||||
);
|
||||
category = categoryKey;
|
||||
});
|
||||
},
|
||||
),
|
||||
const SizedBox(
|
||||
height: 12,
|
||||
),
|
||||
Expanded(
|
||||
child: RefreshIndicator.adaptive(
|
||||
onRefresh: () async {
|
||||
await widget.onRefresh?.call(context, category);
|
||||
await loadPosts();
|
||||
},
|
||||
child: SingleChildScrollView(
|
||||
controller: controller,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
/// Add a optional custom header to the list of posts
|
||||
widget.options.listHeaderBuilder
|
||||
?.call(context, category) ??
|
||||
const SizedBox.shrink(),
|
||||
...posts.map(
|
||||
(post) => Padding(
|
||||
padding: widget.options.paddings.postPadding,
|
||||
child: widget.postWidgetBuilder?.call(post) ??
|
||||
TimelinePostWidget(
|
||||
service: service,
|
||||
userId: widget.userId,
|
||||
options: widget.options,
|
||||
allowAllDeletion: widget.allowAllDeletion,
|
||||
post: post,
|
||||
onTap: () async {
|
||||
if (widget.onPostTap != null) {
|
||||
widget.onPostTap!.call(post);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
await Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => Scaffold(
|
||||
body: TimelinePostScreen(
|
||||
userId: 'test_user',
|
||||
service: service,
|
||||
options: widget.options,
|
||||
post: post,
|
||||
onPostDelete: () {
|
||||
service.postService
|
||||
.deletePost(post);
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
onTapLike: () async => service.postService
|
||||
.likePost(widget.userId, post),
|
||||
onTapUnlike: () async => service.postService
|
||||
.unlikePost(widget.userId, post),
|
||||
onPostDelete: () async =>
|
||||
service.postService.deletePost(post),
|
||||
onUserTap: widget.onUserTap,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (posts.isEmpty)
|
||||
Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(8.0),
|
||||
child: Text(
|
||||
category == null
|
||||
? widget.options.translations.noPosts
|
||||
: widget
|
||||
.options.translations.noPostsWithFilter,
|
||||
style:
|
||||
widget.options.theme.textStyles.noPostsStyle,
|
||||
),
|
||||
),
|
||||
),
|
||||
SizedBox(
|
||||
height: widget.options.paddings.mainPadding.bottom,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> loadPosts() async {
|
||||
if (widget.posts != null || !context.mounted) return;
|
||||
try {
|
||||
await service.postService.fetchCategories();
|
||||
await service.postService.fetchPosts(category);
|
||||
setState(() {
|
||||
isLoading = false;
|
||||
});
|
||||
} on Exception catch (e) {
|
||||
// Handle errors here
|
||||
debugPrint('Error loading posts: $e');
|
||||
setState(() {
|
||||
isLoading = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,215 +0,0 @@
|
|||
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/default_filled_button.dart';
|
||||
import 'package:flutter_timeline_view/src/widgets/post_creation_textfield.dart';
|
||||
|
||||
class TimelineSelectionScreen extends StatefulWidget {
|
||||
const TimelineSelectionScreen({
|
||||
required this.options,
|
||||
required this.categories,
|
||||
required this.onCategorySelected,
|
||||
required this.postService,
|
||||
super.key,
|
||||
});
|
||||
|
||||
final List<TimelineCategory> categories;
|
||||
|
||||
final TimelineOptions options;
|
||||
|
||||
final Function(TimelineCategory) onCategorySelected;
|
||||
|
||||
final TimelinePostService postService;
|
||||
|
||||
@override
|
||||
State<TimelineSelectionScreen> createState() =>
|
||||
_TimelineSelectionScreenState();
|
||||
}
|
||||
|
||||
class _TimelineSelectionScreenState extends State<TimelineSelectionScreen> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
var size = MediaQuery.of(context).size;
|
||||
var theme = Theme.of(context);
|
||||
|
||||
return Padding(
|
||||
padding: EdgeInsets.symmetric(
|
||||
horizontal: size.width * 0.05,
|
||||
),
|
||||
child: SingleChildScrollView(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 20, bottom: 12),
|
||||
child: Text(
|
||||
widget.options.translations.timelineSelectionDescription,
|
||||
style: theme.textTheme.titleLarge,
|
||||
),
|
||||
),
|
||||
for (var category in widget.categories.where(
|
||||
(element) => element.canCreate && element.key != null,
|
||||
)) ...[
|
||||
widget.options.categorySelectorButtonBuilder?.call(
|
||||
context,
|
||||
() {
|
||||
widget.onCategorySelected.call(category);
|
||||
},
|
||||
category.title,
|
||||
) ??
|
||||
InkWell(
|
||||
onTap: () => widget.onCategorySelected.call(category),
|
||||
child: Container(
|
||||
height: 60,
|
||||
width: double.infinity,
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
border: Border.all(
|
||||
color: widget.options.theme
|
||||
.categorySelectionButtonBorderColor ??
|
||||
Theme.of(context).primaryColor,
|
||||
width: 2,
|
||||
),
|
||||
color: widget.options.theme
|
||||
.categorySelectionButtonBackgroundColor,
|
||||
),
|
||||
margin: const EdgeInsets.symmetric(vertical: 4),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Padding(
|
||||
padding:
|
||||
const EdgeInsets.symmetric(horizontal: 12.0),
|
||||
child: Text(
|
||||
category.title,
|
||||
style: theme.textTheme.titleMedium,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
InkWell(
|
||||
onTap: showCategoryPopup,
|
||||
child: Container(
|
||||
height: 60,
|
||||
width: double.infinity,
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
border: Border.all(
|
||||
color: widget
|
||||
.options.theme.categorySelectionButtonBorderColor ??
|
||||
const Color(0xFF9E9E9E),
|
||||
width: 2,
|
||||
),
|
||||
color: widget
|
||||
.options.theme.categorySelectionButtonBackgroundColor,
|
||||
),
|
||||
margin: const EdgeInsets.symmetric(vertical: 4),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12.0),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.add,
|
||||
color: theme.textTheme.titleMedium?.color!
|
||||
.withOpacity(0.5),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
widget.options.translations.addCategoryTitle,
|
||||
style: theme.textTheme.titleMedium!.copyWith(
|
||||
color: theme.textTheme.titleMedium?.color!
|
||||
.withOpacity(0.5),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> showCategoryPopup() async {
|
||||
var theme = Theme.of(context);
|
||||
var controller = TextEditingController();
|
||||
await showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
backgroundColor: theme.scaffoldBackgroundColor,
|
||||
insetPadding: const EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
),
|
||||
contentPadding:
|
||||
const EdgeInsets.symmetric(horizontal: 50, vertical: 24),
|
||||
titlePadding: const EdgeInsets.only(left: 44, right: 44, top: 32),
|
||||
title: Text(
|
||||
widget.options.translations.createCategoryPopuptitle,
|
||||
style: theme.textTheme.titleMedium,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
PostCreationTextfield(
|
||||
controller: controller,
|
||||
hintText: widget.options.translations.addCategoryHintText,
|
||||
validator: (p0) => p0!.isEmpty
|
||||
? widget.options.translations.addCategoryErrorText
|
||||
: null,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 14),
|
||||
child: DefaultFilledButton(
|
||||
onPressed: () async {
|
||||
if (controller.text.isEmpty) return;
|
||||
await widget.postService.addCategory(
|
||||
TimelineCategory(
|
||||
key: controller.text,
|
||||
title: controller.text,
|
||||
),
|
||||
);
|
||||
setState(() {});
|
||||
if (context.mounted) Navigator.pop(context);
|
||||
},
|
||||
buttonText:
|
||||
widget.options.translations.addCategorySubmitButton,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
Navigator.of(context).pop(false);
|
||||
},
|
||||
child: Text(
|
||||
widget.options.translations.addCategoryCancelButtton,
|
||||
style: theme.textTheme.bodyMedium!.copyWith(
|
||||
decoration: TextDecoration.underline,
|
||||
color: theme.textTheme.bodyMedium?.color!.withOpacity(0.5),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
|
@ -1,332 +0,0 @@
|
|||
// SPDX-FileCopyrightText: 2023 Iconica
|
||||
//
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_timeline_interface/flutter_timeline_interface.dart';
|
||||
|
||||
class LocalTimelinePostService
|
||||
with ChangeNotifier
|
||||
implements TimelinePostService {
|
||||
@override
|
||||
List<TimelinePost> posts = [];
|
||||
|
||||
@override
|
||||
List<TimelineCategory> categories = [];
|
||||
|
||||
@override
|
||||
TimelineCategory? selectedCategory;
|
||||
|
||||
@override
|
||||
Future<TimelinePost> createPost(TimelinePost post) async {
|
||||
posts.add(
|
||||
post.copyWith(
|
||||
creator: const TimelinePosterUserModel(
|
||||
userId: 'test_user',
|
||||
imageUrl:
|
||||
'https://cdn.britannica.com/68/143568-050-5246474F/Donkey.jpg?w=400&h=300&c=crop',
|
||||
firstName: 'Ico',
|
||||
lastName: 'Nica',
|
||||
),
|
||||
),
|
||||
);
|
||||
notifyListeners();
|
||||
return post;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> deletePost(TimelinePost post) async {
|
||||
posts = posts.where((element) => element.id != post.id).toList();
|
||||
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<TimelinePost> deletePostReaction(
|
||||
TimelinePost post,
|
||||
String reactionId,
|
||||
) async {
|
||||
if (post.reactions != null && post.reactions!.isNotEmpty) {
|
||||
var reaction =
|
||||
post.reactions!.firstWhere((element) => element.id == reactionId);
|
||||
var updatedPost = post.copyWith(
|
||||
reaction: post.reaction - 1,
|
||||
reactions: (post.reactions ?? [])..remove(reaction),
|
||||
);
|
||||
posts = posts
|
||||
.map(
|
||||
(p) => p.id == post.id ? updatedPost : p,
|
||||
)
|
||||
.toList();
|
||||
|
||||
notifyListeners();
|
||||
return updatedPost;
|
||||
}
|
||||
return post;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<TimelinePost> fetchPostDetails(TimelinePost post) async {
|
||||
var reactions = post.reactions ?? [];
|
||||
var updatedReactions = <TimelinePostReaction>[];
|
||||
for (var reaction in reactions) {
|
||||
updatedReactions.add(
|
||||
reaction.copyWith(
|
||||
creator: const TimelinePosterUserModel(
|
||||
userId: 'test_user',
|
||||
imageUrl:
|
||||
'https://cdn.britannica.com/68/143568-050-5246474F/Donkey.jpg?w=400&h=300&c=crop',
|
||||
firstName: 'Dirk',
|
||||
lastName: 'lukassen',
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
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 {
|
||||
if (posts.isEmpty) {
|
||||
posts = getMockedPosts();
|
||||
}
|
||||
notifyListeners();
|
||||
return posts;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<TimelinePost>> fetchPostsPaginated(
|
||||
String? category,
|
||||
int limit,
|
||||
) async {
|
||||
notifyListeners();
|
||||
return posts;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<TimelinePost> fetchPost(TimelinePost post) async {
|
||||
notifyListeners();
|
||||
return post;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<TimelinePost>> refreshPosts(String? category) async {
|
||||
var newPosts = <TimelinePost>[];
|
||||
|
||||
posts = [...posts, ...newPosts];
|
||||
notifyListeners();
|
||||
return posts;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<TimelinePost?> getPost(String postId) => Future.value(
|
||||
(posts.any((element) => element.id == postId))
|
||||
? posts.firstWhere((element) => element.id == postId)
|
||||
: null,
|
||||
);
|
||||
|
||||
@override
|
||||
List<TimelinePost> getPosts(String? category) => posts
|
||||
.where((element) => category == null || element.category == category)
|
||||
.toList();
|
||||
|
||||
@override
|
||||
Future<TimelinePost> likePost(String userId, TimelinePost post) async {
|
||||
var updatedPost = post.copyWith(
|
||||
likes: post.likes + 1,
|
||||
likedBy: (post.likedBy ?? [])..add(userId),
|
||||
);
|
||||
posts = posts
|
||||
.map(
|
||||
(p) => p.id == post.id ? updatedPost : p,
|
||||
)
|
||||
.toList();
|
||||
|
||||
notifyListeners();
|
||||
return updatedPost;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<TimelinePost> unlikePost(String userId, TimelinePost post) async {
|
||||
var updatedPost = post.copyWith(
|
||||
likes: post.likes - 1,
|
||||
likedBy: post.likedBy?..remove(userId),
|
||||
);
|
||||
posts = posts
|
||||
.map(
|
||||
(p) => p.id == post.id ? updatedPost : p,
|
||||
)
|
||||
.toList();
|
||||
|
||||
notifyListeners();
|
||||
return updatedPost;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<TimelinePost> reactToPost(
|
||||
TimelinePost post,
|
||||
TimelinePostReaction reaction, {
|
||||
Uint8List? image,
|
||||
}) async {
|
||||
var reactionId = DateTime.now().millisecondsSinceEpoch.toString();
|
||||
|
||||
var updatedReaction = reaction.copyWith(
|
||||
id: reactionId,
|
||||
creator: const TimelinePosterUserModel(
|
||||
userId: 'test_user',
|
||||
imageUrl:
|
||||
'https://cdn.britannica.com/68/143568-050-5246474F/Donkey.jpg?w=400&h=300&c=crop',
|
||||
firstName: 'Ico',
|
||||
lastName: 'Nica',
|
||||
),
|
||||
);
|
||||
|
||||
var updatedPost = post.copyWith(
|
||||
reaction: post.reaction + 1,
|
||||
reactions: post.reactions?..add(updatedReaction),
|
||||
);
|
||||
|
||||
posts = posts
|
||||
.map(
|
||||
(p) => p.id == post.id ? updatedPost : p,
|
||||
)
|
||||
.toList();
|
||||
notifyListeners();
|
||||
return updatedPost;
|
||||
}
|
||||
|
||||
List<TimelinePost> getMockedPosts() => [
|
||||
TimelinePost(
|
||||
id: 'Post0',
|
||||
creatorId: 'test_user',
|
||||
title: 'De topper van de maand september',
|
||||
category: 'Category',
|
||||
imageUrl:
|
||||
'https://firebasestorage.googleapis.com/v0/b/appshell-demo.appspot.com/o/do_not_delete_1.png?alt=media&token=e4b2f9f3-c81f-4ac7-a938-e846691399f7',
|
||||
content: 'Dit is onze topper van de maand september! Gefeliciteerd!',
|
||||
likes: 72,
|
||||
reaction: 0,
|
||||
createdAt: DateTime.now(),
|
||||
reactionEnabled: true,
|
||||
creator: const TimelinePosterUserModel(
|
||||
userId: 'test_user',
|
||||
imageUrl:
|
||||
'https://firebasestorage.googleapis.com/v0/b/appshell-demo.appspot.com/o/do_not_delete_3.png?alt=media&token=cd7c156d-0dda-43be-9199-f7d31c30132e',
|
||||
firstName: 'Robin',
|
||||
lastName: 'De Vries',
|
||||
),
|
||||
),
|
||||
TimelinePost(
|
||||
id: 'Post1',
|
||||
creatorId: 'test_user2',
|
||||
title: 'De soep van de week is: Aspergesoep',
|
||||
category: 'Category with two lines',
|
||||
content:
|
||||
'Aspergesoep is echt een heerlijke delicatesse. Deze soep wordt'
|
||||
' vaak gemaakt met verse asperges, bouillon en wat kruiden voor'
|
||||
' smaak. Het is een perfecte keuze voor een lichte en smaakvolle'
|
||||
' maaltijd, vooral in het voorjaar wanneer asperges in seizoen'
|
||||
' zijn. We serveren het met een vleugje room en wat knapperige'
|
||||
' croutons voor die extra touch.',
|
||||
likes: 72,
|
||||
reaction: 0,
|
||||
createdAt: DateTime.now(),
|
||||
reactionEnabled: true,
|
||||
imageUrl:
|
||||
'https://firebasestorage.googleapis.com/v0/b/appshell-demo.appspot.com/o/do_not_delete_2.png?alt=media&token=ee4a8771-531f-4d1d-8613-a2366771e775',
|
||||
creator: const TimelinePosterUserModel(
|
||||
userId: 'test_user',
|
||||
imageUrl:
|
||||
'https://firebasestorage.googleapis.com/v0/b/appshell-demo.appspot.com/o/do_not_delete_4.png?alt=media&token=775d4d10-6d2b-4aef-a51b-ba746b7b137f',
|
||||
firstName: 'Elise',
|
||||
lastName: 'Welling',
|
||||
),
|
||||
),
|
||||
];
|
||||
|
||||
@override
|
||||
Future<bool> addCategory(TimelineCategory category) async {
|
||||
categories.add(category);
|
||||
notifyListeners();
|
||||
return true;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<TimelineCategory>> fetchCategories() async {
|
||||
categories = [
|
||||
const TimelineCategory(key: null, title: 'All'),
|
||||
const TimelineCategory(
|
||||
key: 'Category',
|
||||
title: 'Category',
|
||||
),
|
||||
const TimelineCategory(
|
||||
key: 'Category with two lines',
|
||||
title: 'Category with two lines',
|
||||
),
|
||||
];
|
||||
notifyListeners();
|
||||
|
||||
return categories;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<TimelinePost> likeReaction(
|
||||
String userId,
|
||||
TimelinePost post,
|
||||
String reactionId,
|
||||
) async {
|
||||
var updatedPost = post.copyWith(
|
||||
reactions: post.reactions?.map(
|
||||
(r) {
|
||||
if (r.id == reactionId) {
|
||||
return r.copyWith(
|
||||
likedBy: (r.likedBy ?? [])..add(userId),
|
||||
);
|
||||
}
|
||||
return r;
|
||||
},
|
||||
).toList(),
|
||||
);
|
||||
posts = posts
|
||||
.map(
|
||||
(p) => p.id == post.id ? updatedPost : p,
|
||||
)
|
||||
.toList();
|
||||
|
||||
notifyListeners();
|
||||
return updatedPost;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<TimelinePost> unlikeReaction(
|
||||
String userId,
|
||||
TimelinePost post,
|
||||
String reactionId,
|
||||
) async {
|
||||
var updatedPost = post.copyWith(
|
||||
reactions: post.reactions?.map(
|
||||
(r) {
|
||||
if (r.id == reactionId) {
|
||||
return r.copyWith(
|
||||
likedBy: r.likedBy?..remove(userId),
|
||||
);
|
||||
}
|
||||
return r;
|
||||
},
|
||||
).toList(),
|
||||
);
|
||||
posts = posts
|
||||
.map(
|
||||
(p) => p.id == post.id ? updatedPost : p,
|
||||
)
|
||||
.toList();
|
||||
|
||||
notifyListeners();
|
||||
return updatedPost;
|
||||
}
|
||||
}
|
|
@ -1,67 +0,0 @@
|
|||
import 'dart:math';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_timeline_interface/flutter_timeline_interface.dart';
|
||||
import 'package:flutter_timeline_view/flutter_timeline_view.dart';
|
||||
|
||||
class CategorySelector extends StatefulWidget {
|
||||
const CategorySelector({
|
||||
required this.filter,
|
||||
required this.options,
|
||||
required this.onTapCategory,
|
||||
required this.isOnTop,
|
||||
required this.categories,
|
||||
super.key,
|
||||
});
|
||||
|
||||
final String? filter;
|
||||
final TimelineOptions options;
|
||||
final void Function(String? categoryKey) onTapCategory;
|
||||
final bool isOnTop;
|
||||
final List<TimelineCategory> categories;
|
||||
|
||||
@override
|
||||
State<CategorySelector> createState() => _CategorySelectorState();
|
||||
}
|
||||
|
||||
class _CategorySelectorState extends State<CategorySelector> {
|
||||
@override
|
||||
Widget build(BuildContext context) => SingleChildScrollView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||
child: Row(
|
||||
children: [
|
||||
SizedBox(
|
||||
width: widget.options.categoriesOptions
|
||||
.categorySelectorHorizontalPadding ??
|
||||
max(widget.options.paddings.mainPadding.left - 20, 0),
|
||||
),
|
||||
for (var category in widget.categories) ...[
|
||||
widget.options.categoriesOptions.categoryButtonBuilder?.call(
|
||||
category,
|
||||
() => widget.onTapCategory(category.key),
|
||||
widget.filter == category.key,
|
||||
widget.isOnTop,
|
||||
) ??
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 4),
|
||||
child: CategorySelectorButton(
|
||||
isOnTop: widget.isOnTop,
|
||||
category: category,
|
||||
selected: widget.filter == category.key,
|
||||
onTap: () => widget.onTapCategory(category.key),
|
||||
options: widget.options,
|
||||
),
|
||||
),
|
||||
],
|
||||
SizedBox(
|
||||
width: widget.options.categoriesOptions
|
||||
.categorySelectorHorizontalPadding ??
|
||||
max(widget.options.paddings.mainPadding.right - 4, 0),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
|
@ -1,143 +0,0 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_timeline_interface/flutter_timeline_interface.dart';
|
||||
import 'package:flutter_timeline_view/flutter_timeline_view.dart';
|
||||
|
||||
class CategorySelectorButton extends StatelessWidget {
|
||||
const CategorySelectorButton({
|
||||
required this.category,
|
||||
required this.selected,
|
||||
required this.onTap,
|
||||
required this.options,
|
||||
required this.isOnTop,
|
||||
super.key,
|
||||
});
|
||||
|
||||
final TimelineCategory category;
|
||||
final bool selected;
|
||||
final VoidCallback onTap;
|
||||
final TimelineOptions options;
|
||||
final bool isOnTop;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
var theme = Theme.of(context);
|
||||
var size = MediaQuery.of(context).size;
|
||||
|
||||
return AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 100),
|
||||
height: isOnTop ? 140 : 40,
|
||||
child: TextButton(
|
||||
onPressed: onTap,
|
||||
style: ButtonStyle(
|
||||
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
||||
padding: const WidgetStatePropertyAll(
|
||||
EdgeInsets.symmetric(
|
||||
vertical: 5,
|
||||
horizontal: 12,
|
||||
),
|
||||
),
|
||||
fixedSize: WidgetStatePropertyAll(Size(140, isOnTop ? 140 : 20)),
|
||||
backgroundColor: WidgetStatePropertyAll(
|
||||
selected
|
||||
? theme.colorScheme.primary
|
||||
: options.theme.categorySelectionButtonBackgroundColor ??
|
||||
Colors.transparent,
|
||||
),
|
||||
shape: WidgetStatePropertyAll(
|
||||
RoundedRectangleBorder(
|
||||
borderRadius: const BorderRadius.all(
|
||||
Radius.circular(8),
|
||||
),
|
||||
side: BorderSide(
|
||||
color: options.theme.categorySelectionButtonBorderColor ??
|
||||
theme.colorScheme.primary,
|
||||
width: 2,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
child: isOnTop
|
||||
? SizedBox(
|
||||
width: size.width,
|
||||
child: Stack(
|
||||
children: [
|
||||
Column(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_CategoryButtonText(
|
||||
category: category,
|
||||
options: options,
|
||||
theme: theme,
|
||||
selected: selected,
|
||||
),
|
||||
],
|
||||
),
|
||||
Center(child: category.icon),
|
||||
],
|
||||
),
|
||||
)
|
||||
: Row(
|
||||
children: [
|
||||
Flexible(
|
||||
child: Row(
|
||||
children: [
|
||||
if (category.icon != null) ...[
|
||||
category.icon!,
|
||||
SizedBox(
|
||||
width:
|
||||
options.paddings.categoryButtonTextPadding ?? 8,
|
||||
),
|
||||
],
|
||||
Expanded(
|
||||
child: _CategoryButtonText(
|
||||
category: category,
|
||||
options: options,
|
||||
theme: theme,
|
||||
selected: selected,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _CategoryButtonText extends StatelessWidget {
|
||||
const _CategoryButtonText({
|
||||
required this.category,
|
||||
required this.options,
|
||||
required this.theme,
|
||||
required this.selected,
|
||||
this.overflow,
|
||||
});
|
||||
|
||||
final TimelineCategory category;
|
||||
final TimelineOptions options;
|
||||
final ThemeData theme;
|
||||
final bool selected;
|
||||
final TextOverflow? overflow;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => Text(
|
||||
category.title,
|
||||
style: (options.theme.textStyles.categoryTitleStyle ??
|
||||
(selected
|
||||
? theme.textTheme.titleMedium
|
||||
: theme.textTheme.bodyMedium))
|
||||
?.copyWith(
|
||||
color: selected
|
||||
? options.theme.categorySelectionButtonSelectedTextColor ??
|
||||
theme.colorScheme.onPrimary
|
||||
: options.theme.categorySelectionButtonUnselectedTextColor ??
|
||||
theme.colorScheme.onSurface,
|
||||
),
|
||||
textAlign: TextAlign.start,
|
||||
overflow: overflow,
|
||||
);
|
||||
}
|
|
@ -1,35 +0,0 @@
|
|||
import 'package:flutter/material.dart';
|
||||
|
||||
class DefaultFilledButton extends StatelessWidget {
|
||||
const DefaultFilledButton({
|
||||
required this.onPressed,
|
||||
required this.buttonText,
|
||||
super.key,
|
||||
});
|
||||
|
||||
final Future<void> Function()? onPressed;
|
||||
final String buttonText;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
var theme = Theme.of(context);
|
||||
return FilledButton(
|
||||
style: onPressed != null
|
||||
? ButtonStyle(
|
||||
backgroundColor: WidgetStatePropertyAll(
|
||||
theme.colorScheme.primary,
|
||||
),
|
||||
)
|
||||
: null,
|
||||
onPressed: onPressed,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(8),
|
||||
child: Text(
|
||||
buttonText,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: theme.textTheme.displayLarge,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
|
@ -1,58 +0,0 @@
|
|||
// SPDX-FileCopyrightText: 2023 Iconica
|
||||
//
|
||||
// SPDX-License-Identifier: BSD-3-Clause
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_svg/flutter_svg.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.iconColor,
|
||||
super.key,
|
||||
});
|
||||
|
||||
final Future<void> Function(String text) onReactionSubmit;
|
||||
final TextInputBuilder messageInputBuilder;
|
||||
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(
|
||||
child: widget.messageInputBuilder(
|
||||
_textEditingController,
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 8,
|
||||
),
|
||||
child: IconButton(
|
||||
onPressed: () async {
|
||||
var value = _textEditingController.text;
|
||||
if (value.isNotEmpty) {
|
||||
await widget.onReactionSubmit(value);
|
||||
_textEditingController.clear();
|
||||
}
|
||||
},
|
||||
icon: SvgPicture.asset(
|
||||
'assets/send.svg',
|
||||
package: 'flutter_timeline_view',
|
||||
// ignore: deprecated_member_use
|
||||
color: widget.iconColor,
|
||||
),
|
||||
),
|
||||
),
|
||||
widget.translations.writeComment,
|
||||
),
|
||||
);
|
||||
}
|
|
@ -1,444 +0,0 @@
|
|||
// 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_svg/flutter_svg.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/default_filled_button.dart';
|
||||
import 'package:flutter_timeline_view/src/widgets/tappable_image.dart';
|
||||
|
||||
class TimelinePostWidget extends StatefulWidget {
|
||||
const TimelinePostWidget({
|
||||
required this.userId,
|
||||
required this.options,
|
||||
required this.post,
|
||||
required this.onTap,
|
||||
required this.onTapLike,
|
||||
required this.onTapUnlike,
|
||||
required this.onPostDelete,
|
||||
required this.service,
|
||||
required this.allowAllDeletion,
|
||||
this.onUserTap,
|
||||
super.key,
|
||||
});
|
||||
|
||||
/// The user id of the current user
|
||||
final String userId;
|
||||
|
||||
/// Allow all posts to be deleted instead of
|
||||
/// only the posts of the current user
|
||||
final bool allowAllDeletion;
|
||||
|
||||
final TimelineOptions options;
|
||||
|
||||
final TimelinePost post;
|
||||
|
||||
/// Optional max height of the post
|
||||
final VoidCallback onTap;
|
||||
final VoidCallback onTapLike;
|
||||
final VoidCallback onTapUnlike;
|
||||
final VoidCallback onPostDelete;
|
||||
final TimelineService service;
|
||||
|
||||
/// If this is not null, the user can tap on the user avatar or name
|
||||
final Function(String userId)? onUserTap;
|
||||
|
||||
@override
|
||||
State<TimelinePostWidget> createState() => _TimelinePostWidgetState();
|
||||
}
|
||||
|
||||
class _TimelinePostWidgetState extends State<TimelinePostWidget> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
var theme = Theme.of(context);
|
||||
var isLikedByUser = widget.post.likedBy?.contains(widget.userId) ?? false;
|
||||
|
||||
return SizedBox(
|
||||
height: widget.post.imageUrl != null || widget.post.image != null
|
||||
? widget.options.postWidgetHeight
|
||||
: null,
|
||||
width: double.infinity,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
if (widget.post.creator != null) ...[
|
||||
InkWell(
|
||||
onTap: widget.onUserTap != null
|
||||
? () =>
|
||||
widget.onUserTap?.call(widget.post.creator!.userId)
|
||||
: null,
|
||||
child: Row(
|
||||
children: [
|
||||
if (widget.post.creator!.imageUrl != null) ...[
|
||||
widget.options.userAvatarBuilder?.call(
|
||||
widget.post.creator!,
|
||||
28,
|
||||
) ??
|
||||
CircleAvatar(
|
||||
radius: 14,
|
||||
backgroundImage: CachedNetworkImageProvider(
|
||||
widget.post.creator!.imageUrl!,
|
||||
),
|
||||
),
|
||||
] else ...[
|
||||
widget.options.anonymousAvatarBuilder?.call(
|
||||
widget.post.creator!,
|
||||
28,
|
||||
) ??
|
||||
const CircleAvatar(
|
||||
radius: 14,
|
||||
child: Icon(
|
||||
Icons.person,
|
||||
),
|
||||
),
|
||||
],
|
||||
const SizedBox(width: 10),
|
||||
Text(
|
||||
widget.options.nameBuilder?.call(widget.post.creator) ??
|
||||
widget.post.creator?.fullName ??
|
||||
widget.options.translations.anonymousUser,
|
||||
style: widget.options.theme.textStyles
|
||||
.postCreatorTitleStyle ??
|
||||
theme.textTheme.titleSmall!.copyWith(
|
||||
color: Colors.black,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
const Spacer(),
|
||||
if (widget.allowAllDeletion ||
|
||||
widget.post.creator?.userId == widget.userId) ...[
|
||||
PopupMenuButton(
|
||||
onSelected: (value) async {
|
||||
if (value == 'delete') {
|
||||
await showPostDeletionConfirmationDialog(
|
||||
widget.options,
|
||||
context,
|
||||
widget.onPostDelete,
|
||||
);
|
||||
}
|
||||
},
|
||||
itemBuilder: (BuildContext context) =>
|
||||
<PopupMenuEntry<String>>[
|
||||
PopupMenuItem<String>(
|
||||
value: 'delete',
|
||||
child: Row(
|
||||
children: [
|
||||
Text(
|
||||
widget.options.translations.deletePost,
|
||||
style: widget
|
||||
.options.theme.textStyles.deletePostStyle ??
|
||||
theme.textTheme.bodyMedium,
|
||||
),
|
||||
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,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
// image of the post
|
||||
if (widget.post.imageUrl != null || widget.post.image != null) ...[
|
||||
const SizedBox(height: 8),
|
||||
Flexible(
|
||||
flex: widget.options.postWidgetHeight != null ? 1 : 0,
|
||||
child: ClipRRect(
|
||||
borderRadius: const BorderRadius.all(Radius.circular(8)),
|
||||
child: widget.options.doubleTapTolike
|
||||
? TappableImage(
|
||||
likeAndDislikeIcon:
|
||||
widget.options.likeAndDislikeIconsForDoubleTap,
|
||||
post: widget.post,
|
||||
userId: widget.userId,
|
||||
onLike: ({required bool liked}) async {
|
||||
var userId = widget.userId;
|
||||
|
||||
late TimelinePost result;
|
||||
|
||||
if (!liked) {
|
||||
result = await widget.service.postService.likePost(
|
||||
userId,
|
||||
widget.post,
|
||||
);
|
||||
} else {
|
||||
result =
|
||||
await widget.service.postService.unlikePost(
|
||||
userId,
|
||||
widget.post,
|
||||
);
|
||||
}
|
||||
|
||||
return result.likedBy?.contains(userId) ?? false;
|
||||
},
|
||||
)
|
||||
: widget.post.imageUrl != null
|
||||
? CachedNetworkImage(
|
||||
width: double.infinity,
|
||||
imageUrl: widget.post.imageUrl!,
|
||||
fit: BoxFit.fitWidth,
|
||||
)
|
||||
: Image.memory(
|
||||
width: double.infinity,
|
||||
widget.post.image!,
|
||||
fit: BoxFit.fitWidth,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
const SizedBox(
|
||||
height: 8,
|
||||
),
|
||||
// post information
|
||||
if (widget.options.iconsWithValues) ...[
|
||||
Row(
|
||||
children: [
|
||||
IconButton(
|
||||
padding: EdgeInsets.zero,
|
||||
constraints: const BoxConstraints(),
|
||||
onPressed: () async {
|
||||
var userId = widget.userId;
|
||||
|
||||
if (!isLikedByUser) {
|
||||
await widget.service.postService.likePost(
|
||||
userId,
|
||||
widget.post,
|
||||
);
|
||||
} else {
|
||||
await widget.service.postService.unlikePost(
|
||||
userId,
|
||||
widget.post,
|
||||
);
|
||||
}
|
||||
},
|
||||
icon: widget.options.theme.likeIcon ??
|
||||
Icon(
|
||||
isLikedByUser
|
||||
? Icons.favorite_rounded
|
||||
: Icons.favorite_outline_outlined,
|
||||
color: widget.options.theme.iconColor,
|
||||
size: widget.options.iconSize,
|
||||
),
|
||||
),
|
||||
const SizedBox(
|
||||
width: 4,
|
||||
),
|
||||
Text('${widget.post.likes}'),
|
||||
if (widget.post.reactionEnabled) ...[
|
||||
const SizedBox(
|
||||
width: 8,
|
||||
),
|
||||
IconButton(
|
||||
padding: EdgeInsets.zero,
|
||||
constraints: const BoxConstraints(),
|
||||
onPressed: widget.onTap,
|
||||
icon: widget.options.theme.commentIcon ??
|
||||
SvgPicture.asset(
|
||||
'assets/Comment.svg',
|
||||
package: 'flutter_timeline_view',
|
||||
// ignore: deprecated_member_use
|
||||
color: widget.options.theme.iconColor,
|
||||
width: widget.options.iconSize,
|
||||
height: widget.options.iconSize,
|
||||
),
|
||||
),
|
||||
const SizedBox(
|
||||
width: 4,
|
||||
),
|
||||
Text('${widget.post.reaction}'),
|
||||
],
|
||||
],
|
||||
),
|
||||
] else ...[
|
||||
Row(
|
||||
children: [
|
||||
IconButton(
|
||||
padding: EdgeInsets.zero,
|
||||
constraints: const BoxConstraints(),
|
||||
onPressed:
|
||||
isLikedByUser ? widget.onTapUnlike : widget.onTapLike,
|
||||
icon: (isLikedByUser
|
||||
? widget.options.theme.likedIcon
|
||||
: widget.options.theme.likeIcon) ??
|
||||
Icon(
|
||||
isLikedByUser
|
||||
? Icons.favorite_rounded
|
||||
: Icons.favorite_outline,
|
||||
color: widget.options.theme.iconColor,
|
||||
size: widget.options.iconSize,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
if (widget.post.reactionEnabled) ...[
|
||||
IconButton(
|
||||
padding: EdgeInsets.zero,
|
||||
constraints: const BoxConstraints(),
|
||||
onPressed: widget.onTap,
|
||||
icon: widget.options.theme.commentIcon ??
|
||||
SvgPicture.asset(
|
||||
'assets/Comment.svg',
|
||||
package: 'flutter_timeline_view',
|
||||
// ignore: deprecated_member_use
|
||||
color: widget.options.theme.iconColor,
|
||||
width: widget.options.iconSize,
|
||||
height: widget.options.iconSize,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
],
|
||||
|
||||
const SizedBox(
|
||||
height: 8,
|
||||
),
|
||||
|
||||
if (widget.options.itemInfoBuilder != null) ...[
|
||||
widget.options.itemInfoBuilder!(
|
||||
post: widget.post,
|
||||
),
|
||||
] else ...[
|
||||
_PostLikeCountText(
|
||||
post: widget.post,
|
||||
options: widget.options,
|
||||
),
|
||||
Text.rich(
|
||||
TextSpan(
|
||||
text: widget.options.nameBuilder?.call(widget.post.creator) ??
|
||||
widget.post.creator?.fullName ??
|
||||
widget.options.translations.anonymousUser,
|
||||
style: widget.options.theme.textStyles.listCreatorNameStyle ??
|
||||
theme.textTheme.titleSmall!.copyWith(
|
||||
color: Colors.black,
|
||||
),
|
||||
children: [
|
||||
TextSpan(
|
||||
text: widget.post.title,
|
||||
style: widget.options.theme.textStyles.listPostTitleStyle ??
|
||||
theme.textTheme.bodySmall,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
InkWell(
|
||||
onTap: widget.onTap,
|
||||
child: Text(
|
||||
widget.options.translations.viewPost,
|
||||
style: widget.options.theme.textStyles.viewPostStyle ??
|
||||
theme.textTheme.titleSmall!.copyWith(
|
||||
color: const Color(0xFF8D8D8D),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
if (widget.options.dividerBuilder != null)
|
||||
widget.options.dividerBuilder!(),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _PostLikeCountText extends StatelessWidget {
|
||||
const _PostLikeCountText({
|
||||
required this.post,
|
||||
required this.options,
|
||||
});
|
||||
|
||||
final TimelineOptions options;
|
||||
final TimelinePost post;
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
var theme = Theme.of(context);
|
||||
var likeTranslation = post.likes > 1
|
||||
? options.translations.multipleLikesTitle
|
||||
: options.translations.oneLikeTitle;
|
||||
|
||||
return Text(
|
||||
'${post.likes} '
|
||||
'$likeTranslation',
|
||||
style: options.theme.textStyles.listPostLikeTitleAndAmount ??
|
||||
theme.textTheme.titleSmall!.copyWith(
|
||||
color: Colors.black,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> showPostDeletionConfirmationDialog(
|
||||
TimelineOptions options,
|
||||
BuildContext context,
|
||||
Function() onPostDelete,
|
||||
) async {
|
||||
var theme = Theme.of(context);
|
||||
var result = await showDialog(
|
||||
context: context,
|
||||
builder: (BuildContext context) =>
|
||||
options.deletionDialogBuilder?.call(context) ??
|
||||
AlertDialog(
|
||||
insetPadding: const EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
),
|
||||
contentPadding:
|
||||
const EdgeInsets.symmetric(horizontal: 64, vertical: 24),
|
||||
titlePadding: const EdgeInsets.only(left: 44, right: 44, top: 32),
|
||||
title: Text(
|
||||
options.translations.deleteConfirmationMessage,
|
||||
style: theme.textTheme.titleMedium,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: DefaultFilledButton(
|
||||
onPressed: () async {
|
||||
Navigator.of(context).pop(true);
|
||||
},
|
||||
buttonText: options.translations.deleteButton,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
Navigator.of(context).pop(false);
|
||||
},
|
||||
child: Text(
|
||||
options.translations.deleteCancelButton,
|
||||
style: theme.textTheme.bodyMedium!.copyWith(
|
||||
decoration: TextDecoration.underline,
|
||||
color: theme.textTheme.bodyMedium?.color!.withOpacity(0.5),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
if (result == true) {
|
||||
onPostDelete();
|
||||
}
|
||||
}
|
|
@ -1,37 +0,0 @@
|
|||
# SPDX-FileCopyrightText: 2023 Iconica
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
name: flutter_timeline_view
|
||||
description: Visual elements of the Flutter Timeline Component
|
||||
version: 5.1.0
|
||||
publish_to: https://forgejo.internal.iconica.nl/api/packages/internal/pub
|
||||
|
||||
environment:
|
||||
sdk: ">=3.1.3 <4.0.0"
|
||||
|
||||
dependencies:
|
||||
flutter:
|
||||
sdk: flutter
|
||||
intl: any
|
||||
cached_network_image: ^3.2.2
|
||||
dotted_border: ^2.1.0
|
||||
collection: any
|
||||
flutter_svg: ^2.0.10+1
|
||||
flutter_timeline_interface:
|
||||
hosted: https://forgejo.internal.iconica.nl/api/packages/internal/pub
|
||||
version: ^5.1.0
|
||||
flutter_image_picker:
|
||||
hosted: https://forgejo.internal.iconica.nl/api/packages/internal/pub
|
||||
version: ^4.0.0
|
||||
|
||||
dev_dependencies:
|
||||
flutter_lints: ^2.0.0
|
||||
flutter_iconica_analysis:
|
||||
git:
|
||||
url: https://github.com/Iconica-Development/flutter_iconica_analysis
|
||||
ref: 6.0.0
|
||||
|
||||
flutter:
|
||||
assets:
|
||||
- assets/
|
29
packages/timeline_repository_interface/.gitignore
vendored
Normal file
29
packages/timeline_repository_interface/.gitignore
vendored
Normal file
|
@ -0,0 +1,29 @@
|
|||
# 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
|
||||
# Libraries should not include pubspec.lock, per https://dart.dev/guides/libraries/private-files#pubspeclock.
|
||||
/pubspec.lock
|
||||
**/doc/api/
|
||||
.dart_tool/
|
||||
build/
|
1
packages/timeline_repository_interface/CONTRIBUTING.md
Symbolic link
1
packages/timeline_repository_interface/CONTRIBUTING.md
Symbolic link
|
@ -0,0 +1 @@
|
|||
../../CONTRIBUTING.md
|
|
@ -0,0 +1,7 @@
|
|||
include: package:flutter_iconica_analysis/analysis_options.yaml
|
||||
|
||||
analyzer:
|
||||
exclude:
|
||||
|
||||
linter:
|
||||
rules:
|
|
@ -0,0 +1,10 @@
|
|||
import "package:timeline_repository_interface/src/models/timeline_category.dart";
|
||||
|
||||
abstract class CategoryRepositoryInterface {
|
||||
// everything is done with streams
|
||||
Stream<List<TimelineCategory>> getCategories();
|
||||
Future<void> createCategory(TimelineCategory category);
|
||||
TimelineCategory? selectCategory(String? categoryId);
|
||||
TimelineCategory? getSelectedCategory();
|
||||
TimelineCategory? getCategory(String? categoryId);
|
||||
}
|
|
@ -0,0 +1,30 @@
|
|||
import "dart:typed_data";
|
||||
|
||||
import "package:timeline_repository_interface/timeline_repository_interface.dart";
|
||||
|
||||
abstract class PostRepositoryInterface {
|
||||
Stream<List<TimelinePost>> getPosts(String? categoryId);
|
||||
Future<void> deletePost(String id);
|
||||
//like post
|
||||
Future<void> likePost(String postId, String userId);
|
||||
Future<void> unlikePost(String postId, String userId);
|
||||
Future<void> likePostReaction(
|
||||
TimelinePost post,
|
||||
TimelinePostReaction reaction,
|
||||
String userId,
|
||||
);
|
||||
Future<void> unlikePostReaction(
|
||||
TimelinePost post,
|
||||
TimelinePostReaction reaction,
|
||||
String userId,
|
||||
);
|
||||
Future<void> createReaction(
|
||||
TimelinePost post,
|
||||
TimelinePostReaction reaction, {
|
||||
Uint8List? image,
|
||||
});
|
||||
|
||||
void setCurrentPost(TimelinePost post);
|
||||
TimelinePost getCurrentPost();
|
||||
Future<void> createPost(TimelinePost post);
|
||||
}
|
|
@ -0,0 +1,7 @@
|
|||
import "package:timeline_repository_interface/src/models/timeline_user.dart";
|
||||
|
||||
abstract class TimelineUserRepositoryInterface {
|
||||
Future<List<TimelineUser>> getAllUsers();
|
||||
Future<TimelineUser> getCurrentUser();
|
||||
Future<TimelineUser?> getUser(String userId);
|
||||
}
|
|
@ -0,0 +1,37 @@
|
|||
import "package:timeline_repository_interface/timeline_repository_interface.dart";
|
||||
|
||||
class LocalCategoryRepository implements CategoryRepositoryInterface {
|
||||
final List<TimelineCategory> _categories = [
|
||||
const TimelineCategory(key: null, title: "All"),
|
||||
const TimelineCategory(key: "1", title: "Category"),
|
||||
const TimelineCategory(key: "2", title: "Category with two lines"),
|
||||
];
|
||||
|
||||
TimelineCategory? _selectedCategory;
|
||||
|
||||
@override
|
||||
Future<void> createCategory(TimelineCategory category) async {
|
||||
_categories.add(category);
|
||||
}
|
||||
|
||||
@override
|
||||
Stream<List<TimelineCategory>> getCategories() => Stream.value(_categories);
|
||||
|
||||
@override
|
||||
TimelineCategory selectCategory(String? categoryId) {
|
||||
_selectedCategory = _categories.firstWhere(
|
||||
(category) => category.key == categoryId,
|
||||
orElse: () => _categories.first,
|
||||
);
|
||||
return _selectedCategory!;
|
||||
}
|
||||
|
||||
@override
|
||||
TimelineCategory? getSelectedCategory() => _selectedCategory;
|
||||
|
||||
@override
|
||||
TimelineCategory? getCategory(String? categoryId) => _categories.firstWhere(
|
||||
(category) => category.key == categoryId,
|
||||
orElse: () => _categories.first,
|
||||
);
|
||||
}
|
|
@ -0,0 +1,187 @@
|
|||
import "dart:async";
|
||||
import "dart:typed_data";
|
||||
|
||||
import "package:rxdart/rxdart.dart";
|
||||
import "package:timeline_repository_interface/src/interfaces/post_repository_interface.dart";
|
||||
import "package:timeline_repository_interface/src/models/timeline_post.dart";
|
||||
import "package:timeline_repository_interface/src/models/timeline_post_reaction.dart";
|
||||
import "package:timeline_repository_interface/src/models/timeline_user.dart";
|
||||
|
||||
class LocalPostRepository implements PostRepositoryInterface {
|
||||
LocalPostRepository();
|
||||
|
||||
final StreamController<List<TimelinePost>> _postsController =
|
||||
BehaviorSubject<List<TimelinePost>>();
|
||||
|
||||
late TimelinePost? _currentPost;
|
||||
|
||||
final jane = const TimelineUser(
|
||||
userId: "1",
|
||||
firstName: "Jane",
|
||||
lastName: "Doe",
|
||||
imageUrl: "https://via.placeholder.com/150",
|
||||
);
|
||||
|
||||
final List<TimelinePost> _posts = List.generate(
|
||||
10,
|
||||
(index) => TimelinePost(
|
||||
id: index.toString(),
|
||||
creatorId: "1",
|
||||
title: "test title",
|
||||
content: "lore ipsum, dolor sit amet, consectetur adipiscing elit,"
|
||||
" sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.",
|
||||
likes: 50,
|
||||
reaction: 5,
|
||||
createdAt: DateTime.now(),
|
||||
reactionEnabled: true,
|
||||
category: "2",
|
||||
reactions: [
|
||||
TimelinePostReaction(
|
||||
id: "2",
|
||||
postId: index.toString(),
|
||||
creatorId: "1",
|
||||
createdAt: DateTime.now(),
|
||||
reaction: "This is a test reaction",
|
||||
likedBy: [],
|
||||
creator: const TimelineUser(
|
||||
userId: "2",
|
||||
firstName: "John",
|
||||
lastName: "Doe",
|
||||
imageUrl: "https://via.placeholder.com/150",
|
||||
),
|
||||
),
|
||||
],
|
||||
likedBy: [],
|
||||
creator: const TimelineUser(
|
||||
userId: "1",
|
||||
firstName: "Jane",
|
||||
lastName: "Doe",
|
||||
imageUrl: "https://via.placeholder.com/150",
|
||||
),
|
||||
imageUrl: "https://via.placeholder.com/1000",
|
||||
),
|
||||
);
|
||||
|
||||
@override
|
||||
Stream<List<TimelinePost>> getPosts(String? categoryId) {
|
||||
if (categoryId == null) {
|
||||
_postsController.add(_posts);
|
||||
} else {
|
||||
_postsController.add(
|
||||
_posts.where((element) => element.category == categoryId).toList(),
|
||||
);
|
||||
}
|
||||
return _postsController.stream;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> deletePost(String id) async {
|
||||
_posts.removeWhere((element) => element.id == id);
|
||||
_postsController.add(_posts);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> likePost(String postId, String userId) async {
|
||||
var post = _posts.firstWhere((element) => element.id == postId);
|
||||
var updatedPost = post.copyWith(
|
||||
likes: post.likes + 1,
|
||||
likedBy: post.likedBy?..add(userId),
|
||||
);
|
||||
_posts[_posts.indexWhere((element) => element.id == postId)] = updatedPost;
|
||||
_postsController.add(_posts);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> unlikePost(String postId, String userId) async {
|
||||
var post = _posts.firstWhere((element) => element.id == postId);
|
||||
var updatedPost = post.copyWith(
|
||||
likes: post.likes - 1,
|
||||
likedBy: post.likedBy?..remove(userId),
|
||||
);
|
||||
_posts[_posts.indexWhere((element) => element.id == postId)] = updatedPost;
|
||||
_postsController.add(_posts);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> likePostReaction(
|
||||
TimelinePost post,
|
||||
TimelinePostReaction reaction,
|
||||
String userId,
|
||||
) async {
|
||||
var updatedPost = post.copyWith(
|
||||
reaction: post.reaction + 1,
|
||||
reactions: post.reactions
|
||||
?..[post.reactions!
|
||||
.indexWhere((element) => element.id == reaction.id)] =
|
||||
reaction.copyWith(
|
||||
likedBy: reaction.likedBy?..add(userId),
|
||||
),
|
||||
);
|
||||
_posts[_posts.indexWhere((element) => element.id == post.id)] = updatedPost;
|
||||
_postsController.add(_posts);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> unlikePostReaction(
|
||||
TimelinePost post,
|
||||
TimelinePostReaction reaction,
|
||||
String userId,
|
||||
) async {
|
||||
var updatedPost = post.copyWith(
|
||||
reaction: post.reaction - 1,
|
||||
reactions: post.reactions
|
||||
?..[post.reactions!
|
||||
.indexWhere((element) => element.id == reaction.id)] =
|
||||
reaction.copyWith(
|
||||
likedBy: reaction.likedBy?..remove(userId),
|
||||
),
|
||||
);
|
||||
_posts[_posts.indexWhere((element) => element.id == post.id)] = updatedPost;
|
||||
_postsController.add(_posts);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> createReaction(
|
||||
TimelinePost post,
|
||||
TimelinePostReaction reaction, {
|
||||
Uint8List? image,
|
||||
}) async {
|
||||
var reactionId = DateTime.now().millisecondsSinceEpoch.toString();
|
||||
var updatedReaction = reaction.copyWith(
|
||||
id: reactionId,
|
||||
creator: const TimelineUser(
|
||||
userId: "2",
|
||||
firstName: "John",
|
||||
lastName: "Doe",
|
||||
imageUrl: "https://via.placeholder.com/150",
|
||||
),
|
||||
);
|
||||
|
||||
var updatedPost = post.copyWith(
|
||||
reaction: post.reaction + 1,
|
||||
reactions: post.reactions?..add(updatedReaction),
|
||||
);
|
||||
_posts[_posts.indexWhere((element) => element.id == post.id)] = updatedPost;
|
||||
_postsController.add(_posts);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> setCurrentPost(TimelinePost post) async {
|
||||
_currentPost = post;
|
||||
}
|
||||
|
||||
@override
|
||||
TimelinePost getCurrentPost() => _currentPost!;
|
||||
|
||||
@override
|
||||
Future<void> createPost(TimelinePost post) async {
|
||||
var postId = DateTime.now().millisecondsSinceEpoch.toString();
|
||||
var updatedPost = post.copyWith(
|
||||
id: postId,
|
||||
creator: jane,
|
||||
createdAt: DateTime.now(),
|
||||
);
|
||||
_posts.add(updatedPost);
|
||||
_postsController.add(_posts);
|
||||
}
|
||||
}
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue