Compare commits
6 commits
3cf07a8214
...
812478bc37
| Author | SHA1 | Date | |
|---|---|---|---|
| 812478bc37 | |||
| 5368c863c3 | |||
| 6918a18334 | |||
| 998125333b | |||
| fc38678fbd | |||
| 1f3c2f3d9b |
4 changed files with 99 additions and 49 deletions
|
|
@ -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?,
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
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(
|
||||
displayName[0].toUpperCase(),
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 16,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
avatarUrl != null
|
||||
? ClipOval(
|
||||
child: CachedNetworkImage(
|
||||
imageUrl: avatarUrl,
|
||||
width: 40,
|
||||
height: 40,
|
||||
fit: BoxFit.cover,
|
||||
placeholder: (_, _) => Container(
|
||||
width: 40, height: 40,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey[300],
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
),
|
||||
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(
|
||||
|
|
|
|||
|
|
@ -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];
|
||||
|
||||
parts.push(
|
||||
<a key={match.index} href={href} target="_blank" rel="noopener noreferrer" className="text-blue-500 hover:underline">
|
||||
{url}
|
||||
</a>
|
||||
);
|
||||
if (matched.startsWith('#')) {
|
||||
// 해시태그
|
||||
const tag = matched.slice(1);
|
||||
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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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];
|
||||
|
||||
parts.push(
|
||||
<a key={match.index} href={href} target="_blank" rel="noopener noreferrer" className="text-blue-500 hover:underline">
|
||||
{url}
|
||||
</a>
|
||||
);
|
||||
if (matched.startsWith('#')) {
|
||||
const tag = matched.slice(1);
|
||||
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 {
|
||||
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));
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue