Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
docs/site/
NOTES.md
firebase-debug.log
firebase-debug.log
.worktrees/
48 changes: 48 additions & 0 deletions client/lib/config/app_router.dart
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,11 @@ import 'package:papyrus/pages/shelves_page.dart';
import 'package:papyrus/pages/statistics_page.dart';
import 'package:papyrus/pages/annotations_page.dart';
import 'package:papyrus/pages/notes_page.dart';
import 'package:papyrus/pages/community_book_page.dart';
import 'package:papyrus/pages/community_page.dart';
import 'package:papyrus/pages/user_profile_page.dart';
import 'package:papyrus/pages/welcome_page.dart';
import 'package:papyrus/pages/write_review_page.dart';
import 'package:papyrus/widgets/shell/adaptive_app_shell.dart';

class AppRouter {
Expand Down Expand Up @@ -179,6 +183,50 @@ class AppRouter {
),
],
),
// Community
GoRoute(
name: 'COMMUNITY',
path: '/community',
pageBuilder: (context, state) => NoTransitionPage(
key: state.pageKey,
child: const CommunityPage(),
),
routes: [
GoRoute(
name: 'USER_PROFILE',
path: 'user/:userId',

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Unify profile identifier across client and server routes

The client routes community profiles by userId (/community/user/:userId), while the server only exposes GET /v1/profiles/{username} (server/src/papyrus/api/routes/profiles.py). This identifier mismatch means the profile flow will break as soon as SocialProvider.loadUserProfile is wired to the API, because UUID-based navigation and username-based lookup are not interchangeable. Pick one canonical identifier (username or user_id) and use it consistently in both route contracts.

Useful? React with 👍 / 👎.

pageBuilder: (context, state) => NoTransitionPage(
key: state.pageKey,
child: UserProfilePage(
userId: state.pathParameters['userId'],
),
),
),
GoRoute(
name: 'COMMUNITY_BOOK',
path: 'book/:catalogBookId',
pageBuilder: (context, state) => NoTransitionPage(
key: state.pageKey,
child: CommunityBookPage(
catalogBookId: state.pathParameters['catalogBookId'],
),
),
routes: [
GoRoute(
name: 'WRITE_REVIEW',
path: 'review',
pageBuilder: (context, state) => NoTransitionPage(
key: state.pageKey,
child: WriteReviewPage(
catalogBookId: state.pathParameters['catalogBookId'],
bookTitle: state.uri.queryParameters['title'],
),
),
),
],
),
],
),
// Goals
GoRoute(
name: 'GOALS',
Expand Down
4 changes: 4 additions & 0 deletions client/lib/main.dart
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,11 @@ import 'package:firebase_core/firebase_core.dart';
import 'package:flutter/material.dart';
import 'package:papyrus/data/data_store.dart';
import 'package:papyrus/data/sample_data.dart';
import 'package:papyrus/providers/community_provider.dart';
import 'package:papyrus/providers/display_mode_provider.dart';
import 'package:papyrus/providers/google_sign_in_provider.dart';
import 'package:papyrus/providers/library_provider.dart';
import 'package:papyrus/providers/social_provider.dart';
import 'package:papyrus/providers/preferences_provider.dart';
import 'package:papyrus/providers/sidebar_provider.dart';
import 'package:papyrus/themes/app_theme.dart';
Expand Down Expand Up @@ -62,6 +64,8 @@ class _PapyrusState extends State<Papyrus> {
ChangeNotifierProvider(
create: (_) => PreferencesProvider(widget.prefs),
),
ChangeNotifierProvider(create: (_) => CommunityProvider()),
ChangeNotifierProvider(create: (_) => SocialProvider()),
],
child: Consumer2<DisplayModeProvider, PreferencesProvider>(
builder: (context, displayModeProvider, preferencesProvider, child) {
Expand Down
73 changes: 73 additions & 0 deletions client/lib/models/activity_item.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
/// User summary within an activity feed item.
class ActivityUser {
final String userId;
final String displayName;
final String? username;
final String? avatarUrl;

const ActivityUser({
required this.userId,
required this.displayName,
this.username,
this.avatarUrl,
});

factory ActivityUser.fromJson(Map<String, dynamic> json) => ActivityUser(
userId: json['user_id'] as String,
displayName: json['display_name'] as String,
username: json['username'] as String?,
avatarUrl: json['avatar_url'] as String?,
);
}

/// Book summary within an activity feed item.
class ActivityBook {
final String catalogBookId;
final String title;
final String author;
final String? coverImageUrl;

const ActivityBook({
required this.catalogBookId,
required this.title,
required this.author,
this.coverImageUrl,
});

factory ActivityBook.fromJson(Map<String, dynamic> json) => ActivityBook(
catalogBookId: json['catalog_book_id'] as String,
title: json['title'] as String,
author: json['author'] as String,
coverImageUrl: json['cover_url'] as String?,
Copy link

Copilot AI Feb 23, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This feed model expects cover_url, but elsewhere in the client/API the cover field is cover_image_url. Aligning the key name across APIs would reduce client-side branching and model duplication.

Suggested change
coverImageUrl: json['cover_url'] as String?,
coverImageUrl: (json['cover_image_url'] ?? json['cover_url']) as String?,

Copilot uses AI. Check for mistakes.
);
}

/// Single activity feed item.
class ActivityItem {
final String activityId;
final ActivityUser user;
final String activityType;
final String description;
final ActivityBook? book;
final DateTime createdAt;

const ActivityItem({
required this.activityId,
required this.user,
required this.activityType,
required this.description,
this.book,
required this.createdAt,
});

factory ActivityItem.fromJson(Map<String, dynamic> json) => ActivityItem(
activityId: json['activity_id'] as String,
user: ActivityUser.fromJson(json['user'] as Map<String, dynamic>),
activityType: json['activity_type'] as String,
description: json['description'] as String,
book: json['book'] != null
? ActivityBook.fromJson(json['book'] as Map<String, dynamic>)
: null,
createdAt: DateTime.parse(json['created_at'] as String),
);
}
66 changes: 66 additions & 0 deletions client/lib/models/catalog_book.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
/// Community book catalog model.
class CatalogBook {
final String catalogBookId;
final String? openLibraryId;
final String? isbn;
final String title;
final String author;
final List<String>? authors;
final String? coverImageUrl;
final String? description;
final int? pageCount;
final double? averageRating;
final int ratingCount;
final int reviewCount;

const CatalogBook({
required this.catalogBookId,
this.openLibraryId,
this.isbn,
required this.title,
required this.author,
this.authors,
this.coverImageUrl,
this.description,
this.pageCount,
this.averageRating,
this.ratingCount = 0,
this.reviewCount = 0,
});

factory CatalogBook.fromJson(Map<String, dynamic> json) {
final authors = json['authors'] as List?;
final authorStr = authors != null && authors.isNotEmpty
? authors.first as String
: json['author'] as String? ?? 'Unknown';

return CatalogBook(
catalogBookId: json['catalog_book_id'] as String,
openLibraryId: json['open_library_id'] as String?,
isbn: json['isbn'] as String?,
title: json['title'] as String,
author: authorStr,
authors: authors?.cast<String>(),
coverImageUrl: json['cover_url'] as String?,
description: json['description'] as String?,
pageCount: json['page_count'] as int?,
Comment on lines +42 to +46
Copy link

Copilot AI Feb 23, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This model reads/writes the community catalog cover field as cover_url, which is inconsistent with the existing cover_image_url key used by the main Book model (client/lib/models/book.dart). Consider aligning the JSON key to cover_image_url to avoid needing two cover-field conventions in the client.

Copilot uses AI. Check for mistakes.
averageRating: (json['average_rating'] as num?)?.toDouble(),
ratingCount: json['rating_count'] as int? ?? 0,
reviewCount: json['review_count'] as int? ?? 0,
);
}

Map<String, dynamic> toJson() => {
'catalog_book_id': catalogBookId,
'open_library_id': openLibraryId,
'isbn': isbn,
'title': title,
'authors': authors ?? [author],
'cover_url': coverImageUrl,
'description': description,
'page_count': pageCount,
'average_rating': averageRating,
'rating_count': ratingCount,
'review_count': reviewCount,
};
}
61 changes: 61 additions & 0 deletions client/lib/models/community_review.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
/// Community book review model.
class CommunityReview {
final String reviewId;
final String userId;
final String catalogBookId;
final String? authorDisplayName;
final String? authorUsername;
final String? authorAvatarUrl;
final String? title;
final String body;
final bool containsSpoilers;
final String visibility;
final int likeCount;
final int helpfulCount;
final DateTime? createdAt;

const CommunityReview({
required this.reviewId,
required this.userId,
required this.catalogBookId,
this.authorDisplayName,
this.authorUsername,
this.authorAvatarUrl,
this.title,
required this.body,
this.containsSpoilers = false,
this.visibility = 'public',
this.likeCount = 0,
this.helpfulCount = 0,
this.createdAt,
});

factory CommunityReview.fromJson(Map<String, dynamic> json) =>
CommunityReview(
reviewId: json['review_id'] as String,
userId: json['user_id'] as String,
catalogBookId: json['catalog_book_id'] as String,
authorDisplayName: json['author_display_name'] as String?,
authorUsername: json['author_username'] as String?,
authorAvatarUrl: json['author_avatar_url'] as String?,
title: json['title'] as String?,
body: json['body'] as String,
containsSpoilers: json['contains_spoilers'] as bool? ?? false,
visibility: json['visibility'] as String? ?? 'public',
likeCount: json['like_count'] as int? ?? 0,
helpfulCount: json['helpful_count'] as int? ?? 0,
createdAt: json['created_at'] != null
? DateTime.parse(json['created_at'] as String)
: null,
);

Map<String, dynamic> toJson() => {
'review_id': reviewId,
'user_id': userId,
'catalog_book_id': catalogBookId,
'title': title,
'body': body,
'contains_spoilers': containsSpoilers,
'visibility': visibility,
};
}
94 changes: 94 additions & 0 deletions client/lib/models/community_user.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
/// Community user profile model.
class CommunityUser {
final String userId;
final String? username;
final String displayName;
final String? bio;
final String? avatarUrl;
final String profileVisibility;
final int followerCount;
final int followingCount;
final int bookCount;
final int reviewCount;
final bool isFollowing;
final bool isFriend;
final bool isBlocked;

const CommunityUser({
required this.userId,
this.username,
required this.displayName,
this.bio,
this.avatarUrl,
this.profileVisibility = 'public',
required this.followerCount,
required this.followingCount,
required this.bookCount,
this.reviewCount = 0,
this.isFollowing = false,
this.isFriend = false,
this.isBlocked = false,
});

CommunityUser copyWith({
String? userId,
String? username,
String? displayName,
String? bio,
String? avatarUrl,
String? profileVisibility,
int? followerCount,
int? followingCount,
int? bookCount,
int? reviewCount,
bool? isFollowing,
bool? isFriend,
bool? isBlocked,
}) => CommunityUser(
userId: userId ?? this.userId,
username: username ?? this.username,
displayName: displayName ?? this.displayName,
bio: bio ?? this.bio,
avatarUrl: avatarUrl ?? this.avatarUrl,
profileVisibility: profileVisibility ?? this.profileVisibility,
followerCount: followerCount ?? this.followerCount,
followingCount: followingCount ?? this.followingCount,
bookCount: bookCount ?? this.bookCount,
reviewCount: reviewCount ?? this.reviewCount,
isFollowing: isFollowing ?? this.isFollowing,
isFriend: isFriend ?? this.isFriend,
isBlocked: isBlocked ?? this.isBlocked,
);

factory CommunityUser.fromJson(Map<String, dynamic> json) => CommunityUser(
userId: json['user_id'] as String,
username: json['username'] as String?,
displayName: json['display_name'] as String,
bio: json['bio'] as String?,
avatarUrl: json['avatar_url'] as String?,
profileVisibility: json['profile_visibility'] as String? ?? 'public',
followerCount: json['follower_count'] as int? ?? 0,
followingCount: json['following_count'] as int? ?? 0,
bookCount: json['book_count'] as int? ?? 0,
reviewCount: json['review_count'] as int? ?? 0,
isFollowing: json['is_following'] as bool? ?? false,
isFriend: json['is_friend'] as bool? ?? false,
isBlocked: json['is_blocked'] as bool? ?? false,
);

Map<String, dynamic> toJson() => {
'user_id': userId,
'username': username,
'display_name': displayName,
'bio': bio,
'avatar_url': avatarUrl,
'profile_visibility': profileVisibility,
'follower_count': followerCount,
'following_count': followingCount,
'book_count': bookCount,
'review_count': reviewCount,
'is_following': isFollowing,
'is_friend': isFriend,
'is_blocked': isBlocked,
};
}
Loading
Loading