**Major Features Added:** - **Inline Reply System**: Replace compose screen with inline reply boxes - **Thread Navigation**: Parent/child navigation with jump functionality - **Chain Flow UI**: Reply counts, expand/collapse animations, visual hierarchy - **Enhanced Animations**: Smooth transitions, hover effects, micro-interactions **Frontend Changes:** - **ThreadedCommentWidget**: Complete rewrite with animations and navigation - **ThreadNode Model**: Added parent references and descendant counting - **ThreadedConversationScreen**: Integrated navigation handlers - **PostDetailScreen**: Replaced with threaded conversation view - **ComposeScreen**: Added reply indicators and context - **PostActions**: Fixed visibility checks for chain buttons **Backend Changes:** - **API Route**: Added /posts/:id/thread endpoint - **Post Repository**: Include allow_chain and visibility fields in feed - **Thread Handler**: Support for fetching post chains **UI/UX Improvements:** - **Reply Context**: Clear indication when replying to specific posts - **Character Counting**: 500 character limit with live counter - **Visual Hierarchy**: Depth-based indentation and styling - **Smooth Animations**: SizeTransition, FadeTransition, hover states - **Chain Navigation**: Parent/child buttons with visual feedback **Technical Enhancements:** - **Animation Controllers**: Proper lifecycle management - **State Management**: Clean separation of concerns - **Navigation Callbacks**: Reusable navigation system - **Error Handling**: Graceful fallbacks and user feedback This creates a Reddit-style threaded conversation experience with smooth animations, inline replies, and intuitive navigation between posts in a chain.
190 lines
4.6 KiB
Dart
190 lines
4.6 KiB
Dart
/// User search result with minimal info for display
|
|
class SearchUser {
|
|
final String id;
|
|
final String username;
|
|
final String displayName;
|
|
final String? avatarUrl;
|
|
final String harmonyTier;
|
|
|
|
SearchUser({
|
|
required this.id,
|
|
required this.username,
|
|
required this.displayName,
|
|
this.avatarUrl,
|
|
this.harmonyTier = 'new',
|
|
});
|
|
|
|
factory SearchUser.fromJson(Map<String, dynamic> json) {
|
|
return SearchUser(
|
|
id: json['id'] as String,
|
|
username: json['username'] as String,
|
|
displayName: json['display_name'] as String? ?? json['displayName'] as String? ?? json['username'] as String,
|
|
avatarUrl: json['avatar_url'] as String?,
|
|
harmonyTier: json['harmony_tier'] as String? ?? json['harmonyTier'] as String? ?? 'new',
|
|
);
|
|
}
|
|
|
|
Map<String, dynamic> toJson() {
|
|
return {
|
|
'id': id,
|
|
'username': username,
|
|
'display_name': displayName,
|
|
'avatar_url': avatarUrl,
|
|
'harmony_tier': harmonyTier,
|
|
};
|
|
}
|
|
}
|
|
|
|
/// Hashtag search result with post count
|
|
class SearchTag {
|
|
final String tag;
|
|
final int count;
|
|
|
|
SearchTag({
|
|
required this.tag,
|
|
required this.count,
|
|
});
|
|
|
|
factory SearchTag.fromJson(Map<String, dynamic> json) {
|
|
return SearchTag(
|
|
tag: json['tag'] as String,
|
|
count: json['count'] as int? ?? 0,
|
|
);
|
|
}
|
|
|
|
Map<String, dynamic> toJson() {
|
|
return {
|
|
'tag': tag,
|
|
'count': count,
|
|
};
|
|
}
|
|
|
|
String get withHash => '#$tag';
|
|
}
|
|
|
|
/// Post search result with minimal info for display
|
|
class SearchPost {
|
|
final String id;
|
|
final String body;
|
|
final String authorId;
|
|
final String authorHandle;
|
|
final String authorDisplayName;
|
|
final DateTime createdAt;
|
|
|
|
SearchPost({
|
|
required this.id,
|
|
required this.body,
|
|
required this.authorId,
|
|
required this.authorHandle,
|
|
required this.authorDisplayName,
|
|
required this.createdAt,
|
|
});
|
|
|
|
factory SearchPost.fromJson(Map<String, dynamic> json) {
|
|
return SearchPost(
|
|
id: json['id'] as String,
|
|
body: json['body'] as String,
|
|
authorId: json['author_id'] as String,
|
|
authorHandle: json['author_handle'] as String,
|
|
authorDisplayName: json['author_display_name'] as String,
|
|
createdAt: DateTime.parse(json['created_at'] as String),
|
|
);
|
|
}
|
|
|
|
Map<String, dynamic> toJson() {
|
|
return {
|
|
'id': id,
|
|
'body': body,
|
|
'author_id': authorId,
|
|
'author_handle': authorHandle,
|
|
'author_display_name': authorDisplayName,
|
|
'created_at': createdAt.toIso8601String(),
|
|
};
|
|
}
|
|
}
|
|
|
|
/// Search results model for discovery search
|
|
/// Contains users, hashtags, and posts matching the query
|
|
class SearchResults {
|
|
final List<SearchUser> users;
|
|
final List<SearchTag> tags;
|
|
final List<SearchPost> posts;
|
|
|
|
SearchResults({
|
|
required this.users,
|
|
required this.tags,
|
|
required this.posts,
|
|
});
|
|
|
|
factory SearchResults.fromJson(Map<String, dynamic> json) {
|
|
final usersJson = json['users'] as List<dynamic>? ?? [];
|
|
final tagsJson = json['tags'] as List<dynamic>? ?? [];
|
|
final postsJson = json['posts'] as List<dynamic>? ?? [];
|
|
|
|
return SearchResults(
|
|
users: usersJson
|
|
.map((u) => SearchUser.fromJson(u as Map<String, dynamic>))
|
|
.toList(),
|
|
tags: tagsJson
|
|
.map((t) => SearchTag.fromJson(t as Map<String, dynamic>))
|
|
.toList(),
|
|
posts: postsJson
|
|
.map((p) => SearchPost.fromJson(p as Map<String, dynamic>))
|
|
.toList(),
|
|
);
|
|
}
|
|
|
|
Map<String, dynamic> toJson() {
|
|
return {
|
|
'users': users.map((u) => u.toJson()).toList(),
|
|
'tags': tags.map((t) => t.toJson()).toList(),
|
|
'posts': posts.map((p) => p.toJson()).toList(),
|
|
};
|
|
}
|
|
|
|
bool get isEmpty => users.isEmpty && tags.isEmpty && posts.isEmpty;
|
|
bool get hasResults => users.isNotEmpty || tags.isNotEmpty || posts.isNotEmpty;
|
|
}
|
|
|
|
/// Recent search item (stored locally)
|
|
class RecentSearch {
|
|
final String id;
|
|
final String text;
|
|
final DateTime searchedAt;
|
|
final RecentSearchType type;
|
|
|
|
RecentSearch({
|
|
required this.id,
|
|
required this.text,
|
|
required this.searchedAt,
|
|
required this.type,
|
|
});
|
|
|
|
factory RecentSearch.fromJson(Map<String, dynamic> json) {
|
|
return RecentSearch(
|
|
id: json['id'] as String,
|
|
text: json['text'] as String,
|
|
searchedAt: DateTime.parse(json['searched_at'] as String),
|
|
type: RecentSearchType.values.firstWhere(
|
|
(e) => e.name == json['type'],
|
|
orElse: () => RecentSearchType.text,
|
|
),
|
|
);
|
|
}
|
|
|
|
Map<String, dynamic> toJson() {
|
|
return {
|
|
'id': id,
|
|
'text': text,
|
|
'searched_at': searchedAt.toIso8601String(),
|
|
'type': type.name,
|
|
};
|
|
}
|
|
}
|
|
|
|
enum RecentSearchType {
|
|
user,
|
|
tag,
|
|
text,
|
|
}
|