This commit is contained in:
mike doornenbal 2025-02-17 16:57:58 +01:00 committed by GitHub
commit 2e572cdb50
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
108 changed files with 3102 additions and 5520 deletions

View file

@ -1,3 +1,6 @@
## 6.0.0
* Refactor the timeline package to use the new structure
## 5.1.0 ## 5.1.0
* Added `routeToPostDetail` to the `TimelineUserStory` to allow for navigation to the post detail screen. * Added `routeToPostDetail` to the `TimelineUserStory` to allow for navigation to the post detail screen.

View file

@ -1 +0,0 @@
List of Features from this component:

View file

@ -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 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(). Add the following code in your `main` function, before the runApp().
And import this package: import 'package:intl/date_symbol_data_local.dart'; And import this package: import 'package:intl/date_symbol_data_local.dart';

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

View file

@ -0,0 +1 @@
../../CONTRIBUTING.md

View file

@ -0,0 +1,7 @@
include: package:flutter_iconica_analysis/analysis_options.yaml
analyzer:
exclude:
linter:
rules:

View file

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

View file

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

View file

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

View file

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

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

View file

@ -0,0 +1 @@
../../CONTRIBUTING.md

View file

@ -1,11 +1,5 @@
# SPDX-FileCopyrightText: 2023 Iconica
#
# SPDX-License-Identifier: GPL-3.0-or-later
include: package:flutter_iconica_analysis/analysis_options.yaml include: package:flutter_iconica_analysis/analysis_options.yaml
# Possible to overwrite the rules from the package
analyzer: analyzer:
exclude: exclude:

View file

Before

Width:  |  Height:  |  Size: 713 B

After

Width:  |  Height:  |  Size: 713 B

View file

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

View file

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

View file

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

View file

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

View file

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

View file

@ -1,9 +1,26 @@
import 'package:example/apps/navigator/app.dart'; import 'package:example/theme.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_timeline/flutter_timeline.dart';
import 'package:intl/date_symbol_data_local.dart'; import 'package:intl/date_symbol_data_local.dart';
void main() { void main(List<String> args) {
initializeDateFormatting(); 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",
),
);
}
} }

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

View file

@ -1,92 +1,28 @@
name: example name: example
description: "A new Flutter project." 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. publish_to: "none"
# 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.
version: 1.0.0+1 version: 1.0.0+1
environment: 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: dependencies:
flutter: flutter:
sdk: 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: flutter_timeline:
path: ../ path: ../
intl: ^0.19.0 intl: 0.19.0
dev_dependencies: dev_dependencies:
flutter_test: flutter_lints: ^4.0.0
sdk: flutter
# 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: 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 uses-material-design: true
fonts:
# To add assets to your application, add an assets section, like this: - family: Merriweather
# assets: fonts:
# - assets/ - asset: fonts/Merriweather-Regular.ttf
- family: Avenir
# An image asset can refer to one or more resolution-specific "variants", see fonts:
# https://flutter.dev/assets-and-images/#resolution-aware - asset: fonts/Avenir-Regular.ttf
# 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

View file

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

View file

@ -1,12 +1,30 @@
// SPDX-FileCopyrightText: 2023 Iconica // ignore_for_file: directives_ordering
//
// SPDX-License-Identifier: BSD-3-Clause
/// Flutter Timeline library /// userstory
library flutter_timeline; library;
export 'package:flutter_timeline/src/flutter_timeline_navigator_userstory.dart'; export "src/flutter_timeline_navigator_userstory.dart";
export 'package:flutter_timeline/src/models/timeline_configuration.dart'; export "package:timeline_repository_interface/timeline_repository_interface.dart";
export 'package:flutter_timeline/src/routes.dart';
export 'package:flutter_timeline_interface/flutter_timeline_interface.dart'; /// models
export 'package:flutter_timeline_view/flutter_timeline_view.dart'; 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";

View file

@ -1,374 +1,115 @@
// SPDX-FileCopyrightText: 2024 Iconica import "package:flutter/material.dart";
// import "package:flutter_timeline/flutter_timeline.dart";
// SPDX-License-Identifier: BSD-3-Clause
import 'package:flutter/material.dart'; class FlutterTimelineNavigatorUserstory extends StatefulWidget {
import 'package:flutter_timeline/flutter_timeline.dart'; const FlutterTimelineNavigatorUserstory({
required this.currentUserId,
this.options = const TimelineOptions(),
this.timelineService,
super.key,
});
/// A widget function that creates a timeline navigator for user stories. final TimelineOptions options;
/// final TimelineService? timelineService;
/// This function creates a navigator for displaying user stories on a timeline. final String currentUserId;
/// It takes a [BuildContext] and an optional [TimelineUserStoryConfiguration]
/// as parameters. If no configuration is provided, default values will be used.
late TimelineUserStoryConfiguration timelineUserStoryConfiguration;
Widget timeLineNavigatorUserStory({ @override
required BuildContext context, State<FlutterTimelineNavigatorUserstory> createState() =>
TimelineUserStoryConfiguration? configuration, _FlutterTimelineNavigatorUserstoryState();
}) {
timelineUserStoryConfiguration = configuration ??
TimelineUserStoryConfiguration(
userId: 'test_user',
service: TimelineService(
postService: LocalTimelinePostService(),
),
optionsBuilder: (context) => const TimelineOptions(),
);
return _timelineScreenRoute(
config: timelineUserStoryConfiguration,
context: context,
);
} }
/// A widget function that creates a timeline screen route. class _FlutterTimelineNavigatorUserstoryState
/// extends State<FlutterTimelineNavigatorUserstory> {
/// This function creates a route for displaying a timeline screen. It takes late TimelineService timelineService;
/// 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,
),
);
return config.homeOpenPageBuilder?.call(context, timelineScreen, button) ?? @override
Scaffold( void initState() {
appBar: AppBar( timelineService = widget.timelineService ?? TimelineService();
title: Text( super.initState();
config.optionsBuilder(context).translations.timeLineScreenTitle, }
style: theme.textTheme.headlineLarge,
),
),
body: timelineScreen,
floatingActionButton: button,
);
}
/// A widget function that creates a post detail screen route. @override
/// Widget build(BuildContext context) => _timelineScreenWidget();
/// This function creates a route for displaying a post detail screen. It takes
/// a [BuildContext], a [TimelinePost], and an optional Widget _timelineScreenWidget() => TimelineScreen(
/// [TimelineUserStoryConfiguration] as parameters. If no configuration is currentUserId: widget.currentUserId,
/// provided, default values will be used. timelineService: timelineService,
Widget _postDetailScreenRoute({ options: widget.options,
required BuildContext context, onTapComments: (post) async {
required TimelinePost post, var currentUser = await timelineService.getCurrentUser();
required TimelineUserStoryConfiguration config,
}) { await widget.options.onTapComments?.call(post, currentUser) ??
var timelinePostScreen = TimelinePostScreen( await _push(_timelinePostDetailScreenWidget(post, currentUser));
userId: config.getUserId?.call(context) ?? config.userId, },
allowAllDeletion: config.canDeleteAllPosts?.call(context) ?? false, onTapCreatePost: () async {
options: config.optionsBuilder(context), var selectedCategory = timelineService.getSelectedCategory();
service: config.service, if (widget.options.onTapCreatePost != null) {
post: post, await widget.options.onTapCreatePost!(selectedCategory);
onPostDelete: () async => } else {
config.onPostDelete?.call(context, post) ?? if (selectedCategory?.key != null) {
() async { await _push(_timelineAddpostInformationScreenWidget());
await config.service.postService.deletePost(post); } else {
if (context.mounted) { await _push(_timelineChooseCategoryScreenWidget());
Navigator.of(context).pop(); }
} }
}.call(), },
onUserTap: (user) => config.onUserTap?.call(context, user), onTapPost: (post) async {
); var currentUser = await timelineService.getCurrentUser();
if (context.mounted)
var category = config.service.postService.categories await widget.options.onTapPost?.call(post, currentUser) ??
.firstWhere((element) => element.key == post.category); await _push(_timelinePostDetailScreenWidget(post, currentUser));
},
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,
); );
}
/// A widget function that creates a post creation screen route. Widget _timelinePostDetailScreenWidget(
/// TimelinePost post,
/// This function creates a route for displaying a post creation screen. TimelineUser currentUser,
/// It takes a [BuildContext] and an optional [TimelineUserStoryConfiguration] ) =>
/// as parameters. If no configuration is provided, default values will be used. TimelinePostDetailScreen(
Widget _postCreationScreenRoute({ currentUserId: widget.currentUserId,
required BuildContext context, timelineService: timelineService,
required TimelineCategory category, currentUser: currentUser,
required TimelineUserStoryConfiguration config, options: widget.options,
}) {
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,
post: post, 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,
);
}
} }

View file

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

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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

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

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

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

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

View file

@ -1,7 +1,7 @@
import 'package:flutter/material.dart'; import "package:flutter/material.dart";
class PostCreationTextfield extends StatelessWidget { class PostInfoTextfield extends StatelessWidget {
const PostCreationTextfield({ const PostInfoTextfield({
required this.controller, required this.controller,
required this.hintText, required this.hintText,
required this.validator, required this.validator,
@ -30,6 +30,7 @@ class PostCreationTextfield extends StatelessWidget {
Widget build(BuildContext context) { Widget build(BuildContext context) {
var theme = Theme.of(context); var theme = Theme.of(context);
return TextFormField( return TextFormField(
keyboardType: TextInputType.text,
key: fieldKey, key: fieldKey,
validator: validator, validator: validator,
style: theme.textTheme.bodySmall, style: theme.textTheme.bodySmall,

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

View file

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

View file

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

View file

@ -1,8 +1,8 @@
import 'dart:async'; import "dart:async";
import 'package:cached_network_image/cached_network_image.dart'; import "package:cached_network_image/cached_network_image.dart";
import 'package:flutter/material.dart'; import "package:flutter/material.dart";
import 'package:flutter_timeline_interface/flutter_timeline_interface.dart'; import "package:timeline_repository_interface/timeline_repository_interface.dart";
class TappableImage extends StatefulWidget { class TappableImage extends StatefulWidget {
const TappableImage({ const TappableImage({
@ -15,7 +15,7 @@ class TappableImage extends StatefulWidget {
final TimelinePost post; final TimelinePost post;
final String userId; final String userId;
final Future<bool> Function({required bool liked}) onLike; final Future<bool> Function() onLike;
final (Icon?, Icon?) likeAndDislikeIcon; final (Icon?, Icon?) likeAndDislikeIcon;
@override @override
@ -73,12 +73,7 @@ class _TappableImageState extends State<TappableImage>
loading = true; loading = true;
await animationController.forward(); await animationController.forward();
var liked = await widget.onLike( var liked = await widget.onLike();
liked: widget.post.likedBy?.contains(
widget.userId,
) ??
false,
);
if (context.mounted) { if (context.mounted) {
await showDialog( await showDialog(
@ -101,15 +96,19 @@ class _TappableImageState extends State<TappableImage>
scale: 1 + animation.value * 0.1, scale: 1 + animation.value * 0.1,
child: widget.post.imageUrl != null child: widget.post.imageUrl != null
? CachedNetworkImage( ? CachedNetworkImage(
imageUrl: widget.post.imageUrl ?? '', height: 250,
imageUrl: widget.post.imageUrl ?? "",
width: double.infinity, width: double.infinity,
fit: BoxFit.fitHeight, fit: BoxFit.cover,
) )
: Image.memory( : widget.post.image != null
width: double.infinity, ? Image.memory(
widget.post.image!, width: double.infinity,
fit: BoxFit.fitHeight, widget.post.image!,
), fit: BoxFit.cover,
height: 250,
)
: null,
), ),
), ),
); );

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

View file

@ -1,31 +1,37 @@
# SPDX-FileCopyrightText: 2023 Iconica
#
# SPDX-License-Identifier: GPL-3.0-or-later
name: flutter_timeline name: flutter_timeline
description: Visual elements and interface combined into one package description: "A new Flutter package project."
version: 5.1.0 version: 6.0.0
publish_to: https://forgejo.internal.iconica.nl/api/packages/internal/pub publish_to: none
environment: environment:
sdk: ">=3.1.3 <4.0.0" sdk: ^3.5.1
flutter: ">=1.17.0"
dependencies: dependencies:
flutter: flutter:
sdk: 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 hosted: https://forgejo.internal.iconica.nl/api/packages/internal/pub
version: ^5.1.0 version: ^4.0.0
collection: any 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: dev_dependencies:
flutter_lints: ^2.0.0
flutter_iconica_analysis: flutter_iconica_analysis:
git: git:
url: https://github.com/Iconica-Development/flutter_iconica_analysis url: https://github.com/Iconica-Development/flutter_iconica_analysis
ref: 6.0.0 ref: 7.0.0
flutter: flutter:
assets:
- assets/

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1 +0,0 @@
../../CHANGELOG.md

View file

@ -1 +0,0 @@
../../LICENSE

View file

@ -1 +0,0 @@
../../README.md

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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

View file

@ -0,0 +1 @@
../../CONTRIBUTING.md

View file

@ -0,0 +1,7 @@
include: package:flutter_iconica_analysis/analysis_options.yaml
analyzer:
exclude:
linter:
rules:

View file

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

View file

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

View file

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

View file

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

View file

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