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 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?,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
? ClipOval(
|
||||||
|
child: CachedNetworkImage(
|
||||||
|
imageUrl: avatarUrl,
|
||||||
width: 40,
|
width: 40,
|
||||||
height: 40,
|
height: 40,
|
||||||
|
fit: BoxFit.cover,
|
||||||
|
placeholder: (_, _) => Container(
|
||||||
|
width: 40, height: 40,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
gradient: LinearGradient(
|
color: Colors.grey[300],
|
||||||
begin: Alignment.topLeft,
|
|
||||||
end: Alignment.bottomRight,
|
|
||||||
colors: [Colors.grey[700]!, Colors.grey[900]!],
|
|
||||||
),
|
|
||||||
shape: BoxShape.circle,
|
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),
|
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(
|
||||||
|
|
|
||||||
|
|
@ -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}`;
|
|
||||||
|
|
||||||
|
if (matched.startsWith('#')) {
|
||||||
|
// 해시태그
|
||||||
|
const tag = matched.slice(1);
|
||||||
parts.push(
|
parts.push(
|
||||||
<a key={match.index} href={href} target="_blank" rel="noopener noreferrer" className="text-blue-500 hover:underline">
|
<a key={match.index} href={`https://x.com/hashtag/${encodeURIComponent(tag)}?src=hashtag_click`} target="_blank" rel="noopener noreferrer" className="text-primary hover:underline">
|
||||||
{url}
|
{matched}
|
||||||
</a>
|
</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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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}`;
|
|
||||||
|
|
||||||
|
if (matched.startsWith('#')) {
|
||||||
|
const tag = matched.slice(1);
|
||||||
parts.push(
|
parts.push(
|
||||||
<a key={match.index} href={href} target="_blank" rel="noopener noreferrer" className="text-blue-500 hover:underline">
|
<a key={match.index} href={`https://x.com/hashtag/${encodeURIComponent(tag)}?src=hashtag_click`} target="_blank" rel="noopener noreferrer" className="text-primary hover:underline">
|
||||||
{url}
|
{matched}
|
||||||
</a>
|
</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));
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue