Compare commits

...

6 commits

Author SHA1 Message Date
812478bc37 fix(app): X 카드 하단 'X에서 보기' 영역 모서리 둥글게 수정
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 14:29:24 +09:00
5368c863c3 fix(app): X 카드 하단 모서리 테두리 잘림 수정
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 14:26:11 +09:00
6918a18334 fix(app): 일정 상세 화면 하단 소프트키 영역 잘림 수정
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 14:22:15 +09:00
998125333b fix(app): X 상세 화면 프로필 사진/표시 이름 누락 수정
- ScheduleDetail에 profileDisplayName, profileAvatarUrl 필드 추가
- X 섹션: profile.displayName으로 표시 이름, profile.avatarUrl로 프로필 사진 표시
- 아바타 URL이 없을 때 이니셜 폴백 유지
- @username 표시 수정 (displayName이 아닌 실제 username 사용)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 14:21:24 +09:00
fc38678fbd revert(app): X 게시글 해시태그 하이라이트 제거
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 14:17:18 +09:00
1f3c2f3d9b feat: X 게시글 해시태그 하이라이트 및 클릭 링크 추가
- 해시태그(#xxx): primary 색상 하이라이트, 클릭 시 x.com/hashtag/xxx로 이동
- URL: primary 색상 + 밑줄, 클릭 시 해당 URL로 이동
- 웹(모바일/PC), 앱 모두 적용

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 14:15:58 +09:00
4 changed files with 99 additions and 49 deletions

View file

@ -55,6 +55,9 @@ class ScheduleDetail {
final String? content;
final List<String> imageUrls;
final String? postUrl;
// X
final String? profileDisplayName;
final String? profileAvatarUrl;
ScheduleDetail({
required this.id,
@ -75,6 +78,8 @@ class ScheduleDetail {
this.content,
this.imageUrls = const [],
this.postUrl,
this.profileDisplayName,
this.profileAvatarUrl,
});
factory ScheduleDetail.fromJson(Map<String, dynamic> json) {
@ -103,6 +108,8 @@ class ScheduleDetail {
content: json['content'] as String?,
imageUrls: (json['imageUrls'] as List<dynamic>?)?.cast<String>() ?? [],
postUrl: json['postUrl'] as String?,
profileDisplayName: (json['profile'] as Map<String, dynamic>?)?['displayName'] as String?,
profileAvatarUrl: (json['profile'] as Map<String, dynamic>?)?['avatarUrl'] as String?,
);
}

View file

@ -176,8 +176,9 @@ class _ScheduleDetailViewState extends ConsumerState<ScheduleDetailView> {
///
Widget _buildContent(ScheduleDetail schedule) {
final bottomPadding = MediaQuery.of(context).padding.bottom;
return SingleChildScrollView(
padding: const EdgeInsets.all(16),
padding: EdgeInsets.fromLTRB(16, 16, 16, 16 + bottomPadding),
child: _buildCategorySection(schedule),
);
}
@ -423,9 +424,12 @@ class _ScheduleDetailViewState extends ConsumerState<ScheduleDetailView> {
/// X
Widget _buildXSection(ScheduleDetail schedule) {
final displayName = schedule.username ?? 'Unknown';
final username = schedule.username ?? 'Unknown';
final displayName = schedule.profileDisplayName ?? username;
final avatarUrl = schedule.profileAvatarUrl;
return Container(
clipBehavior: Clip.antiAlias,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(12),
@ -440,28 +444,24 @@ class _ScheduleDetailViewState extends ConsumerState<ScheduleDetailView> {
child: Row(
children: [
//
Container(
avatarUrl != null
? ClipOval(
child: CachedNetworkImage(
imageUrl: avatarUrl,
width: 40,
height: 40,
fit: BoxFit.cover,
placeholder: (_, _) => Container(
width: 40, height: 40,
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [Colors.grey[700]!, Colors.grey[900]!],
),
color: Colors.grey[300],
shape: BoxShape.circle,
),
child: Center(
child: Text(
displayName[0].toUpperCase(),
style: const TextStyle(
color: Colors.white,
fontWeight: FontWeight.bold,
fontSize: 16,
),
),
),
errorWidget: (_, _, _) => _buildAvatarFallback(displayName),
),
)
: _buildAvatarFallback(displayName),
const SizedBox(width: 12),
Expanded(
child: Column(
@ -484,7 +484,7 @@ class _ScheduleDetailViewState extends ConsumerState<ScheduleDetailView> {
],
),
Text(
'@$displayName',
'@$username',
style: TextStyle(
fontSize: 13,
color: Colors.grey[500],
@ -565,6 +565,7 @@ class _ScheduleDetailViewState extends ConsumerState<ScheduleDetailView> {
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.grey[50],
borderRadius: const BorderRadius.vertical(bottom: Radius.circular(12)),
border: Border(top: BorderSide(color: Colors.grey[100]!)),
),
child: SizedBox(
@ -691,6 +692,32 @@ class _ScheduleDetailViewState extends ConsumerState<ScheduleDetailView> {
);
}
/// ()
Widget _buildAvatarFallback(String name) {
return Container(
width: 40,
height: 40,
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [Colors.grey[700]!, Colors.grey[900]!],
),
shape: BoxShape.circle,
),
child: Center(
child: Text(
name[0].toUpperCase(),
style: const TextStyle(
color: Colors.white,
fontWeight: FontWeight.bold,
fontSize: 16,
),
),
),
);
}
///
Widget _buildVerifiedBadge() {
return SizedBox(

View file

@ -13,26 +13,37 @@ import Birthday from './Birthday';
function linkifyText(text) {
if (!text) return null;
// URL : http(s):// URL
const urlPattern = /(https?:\/\/[^\s]+|(?:bit\.ly|youtu\.be|t\.co|goo\.gl|tinyurl\.com)\/[^\s]+)/gi;
// URL
const pattern = /(#[^\s#]+)|(https?:\/\/[^\s]+|(?:bit\.ly|youtu\.be|t\.co|goo\.gl|tinyurl\.com)\/[^\s]+)/gi;
const parts = [];
let lastIndex = 0;
let match;
while ((match = urlPattern.exec(text)) !== null) {
while ((match = pattern.exec(text)) !== null) {
if (match.index > lastIndex) {
parts.push(text.slice(lastIndex, match.index));
}
let url = match[0];
const href = url.startsWith('http') ? url : `https://${url}`;
const matched = match[0];
if (matched.startsWith('#')) {
//
const tag = matched.slice(1);
parts.push(
<a key={match.index} href={href} target="_blank" rel="noopener noreferrer" className="text-blue-500 hover:underline">
{url}
<a key={match.index} href={`https://x.com/hashtag/${encodeURIComponent(tag)}?src=hashtag_click`} target="_blank" rel="noopener noreferrer" className="text-primary hover:underline">
{matched}
</a>
);
} else {
// URL
const href = matched.startsWith('http') ? matched : `https://${matched}`;
parts.push(
<a key={match.index} href={href} target="_blank" rel="noopener noreferrer" className="text-primary hover:underline">
{matched}
</a>
);
}
lastIndex = match.index + match[0].length;
}

View file

@ -9,34 +9,39 @@ import { Lightbox } from '@/components/common';
function linkifyText(text) {
if (!text) return null;
// URL : http(s)://
const urlPattern = /(https?:\/\/[^\s]+|(?:bit\.ly|youtu\.be|t\.co|goo\.gl|tinyurl\.com)\/[^\s]+)/gi;
// URL
const pattern = /(#[^\s#]+)|(https?:\/\/[^\s]+|(?:bit\.ly|youtu\.be|t\.co|goo\.gl|tinyurl\.com)\/[^\s]+)/gi;
const parts = [];
let lastIndex = 0;
let match;
while ((match = urlPattern.exec(text)) !== null) {
//
while ((match = pattern.exec(text)) !== null) {
if (match.index > lastIndex) {
parts.push(text.slice(lastIndex, match.index));
}
// URL
let url = match[0];
// http(s)://
const href = url.startsWith('http') ? url : `https://${url}`;
const matched = match[0];
if (matched.startsWith('#')) {
const tag = matched.slice(1);
parts.push(
<a key={match.index} href={href} target="_blank" rel="noopener noreferrer" className="text-blue-500 hover:underline">
{url}
<a key={match.index} href={`https://x.com/hashtag/${encodeURIComponent(tag)}?src=hashtag_click`} target="_blank" rel="noopener noreferrer" className="text-primary hover:underline">
{matched}
</a>
);
} else {
const href = matched.startsWith('http') ? matched : `https://${matched}`;
parts.push(
<a key={match.index} href={href} target="_blank" rel="noopener noreferrer" className="text-primary hover:underline">
{matched}
</a>
);
}
lastIndex = match.index + match[0].length;
}
//
if (lastIndex < text.length) {
parts.push(text.slice(lastIndex));
}