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

View file

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

View file

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