perf: 파싱/렌더 최적화 다수 적용
- SmsScanner 키워드/정규식 상수화로 반복 컴파일 제거\n- 리스트에 prototypeItem 추가, 카드 RepaintBoundary 적용\n- 차트 영역 RepaintBoundary로 페인트 분리\n- GlassmorphicScaffold 파티클 수를 disableAnimations에 따라 감소\n- 캐시 초기화 플래그를 --dart-define로 제어(CLEAR_CACHE_ON_STARTUP)
This commit is contained in:
@@ -45,8 +45,15 @@ Future<void> main() async {
|
|||||||
try {
|
try {
|
||||||
// 메모리 이미지 캐시는 유지하지만 필요한 경우 삭제할 수 있도록 준비
|
// 메모리 이미지 캐시는 유지하지만 필요한 경우 삭제할 수 있도록 준비
|
||||||
|
|
||||||
// 오래된 디스크 캐시 파일만 지우기 (새로운 것은 유지)
|
// 캐시 전체 삭제는 큰 I/O 부하를 유발할 수 있어 비활성화
|
||||||
await DefaultCacheManager().emptyCache();
|
// 필요 시 환경 플래그로 제어하거나 주기적 백그라운드 정리로 전환하세요.
|
||||||
|
const bool clearCacheOnStartup = bool.fromEnvironment(
|
||||||
|
'CLEAR_CACHE_ON_STARTUP',
|
||||||
|
defaultValue: false,
|
||||||
|
);
|
||||||
|
if (clearCacheOnStartup) {
|
||||||
|
await DefaultCacheManager().emptyCache();
|
||||||
|
}
|
||||||
|
|
||||||
if (kDebugMode) {
|
if (kDebugMode) {
|
||||||
Log.d('이미지 캐시 관리 초기화 완료');
|
Log.d('이미지 캐시 관리 초기화 완료');
|
||||||
|
|||||||
@@ -93,32 +93,32 @@ class _CategoryManagementScreenState extends State<CategoryManagementScreen> {
|
|||||||
value: '#1976D2',
|
value: '#1976D2',
|
||||||
child: Text(
|
child: Text(
|
||||||
AppLocalizations.of(context).colorBlue,
|
AppLocalizations.of(context).colorBlue,
|
||||||
style:
|
style: const TextStyle(
|
||||||
const TextStyle(color: AppColors.darkNavy))),
|
color: AppColors.darkNavy))),
|
||||||
DropdownMenuItem(
|
DropdownMenuItem(
|
||||||
value: '#4CAF50',
|
value: '#4CAF50',
|
||||||
child: Text(
|
child: Text(
|
||||||
AppLocalizations.of(context).colorGreen,
|
AppLocalizations.of(context).colorGreen,
|
||||||
style:
|
style: const TextStyle(
|
||||||
const TextStyle(color: AppColors.darkNavy))),
|
color: AppColors.darkNavy))),
|
||||||
DropdownMenuItem(
|
DropdownMenuItem(
|
||||||
value: '#FF9800',
|
value: '#FF9800',
|
||||||
child: Text(
|
child: Text(
|
||||||
AppLocalizations.of(context).colorOrange,
|
AppLocalizations.of(context).colorOrange,
|
||||||
style:
|
style: const TextStyle(
|
||||||
const TextStyle(color: AppColors.darkNavy))),
|
color: AppColors.darkNavy))),
|
||||||
DropdownMenuItem(
|
DropdownMenuItem(
|
||||||
value: '#F44336',
|
value: '#F44336',
|
||||||
child: Text(
|
child: Text(
|
||||||
AppLocalizations.of(context).colorRed,
|
AppLocalizations.of(context).colorRed,
|
||||||
style:
|
style: const TextStyle(
|
||||||
const TextStyle(color: AppColors.darkNavy))),
|
color: AppColors.darkNavy))),
|
||||||
DropdownMenuItem(
|
DropdownMenuItem(
|
||||||
value: '#9C27B0',
|
value: '#9C27B0',
|
||||||
child: Text(
|
child: Text(
|
||||||
AppLocalizations.of(context).colorPurple,
|
AppLocalizations.of(context).colorPurple,
|
||||||
style:
|
style: const TextStyle(
|
||||||
const TextStyle(color: AppColors.darkNavy))),
|
color: AppColors.darkNavy))),
|
||||||
],
|
],
|
||||||
onChanged: (value) {
|
onChanged: (value) {
|
||||||
setState(() {
|
setState(() {
|
||||||
|
|||||||
@@ -533,9 +533,8 @@ class SettingsScreen extends StatelessWidget {
|
|||||||
onPressed: () async {
|
onPressed: () async {
|
||||||
await permission.openAppSettings();
|
await permission.openAppSettings();
|
||||||
},
|
},
|
||||||
child: Text(
|
child: Text(AppLocalizations.of(context)
|
||||||
AppLocalizations.of(context)
|
.openSettings),
|
||||||
.openSettings),
|
|
||||||
)
|
)
|
||||||
: ElevatedButton(
|
: ElevatedButton(
|
||||||
onPressed: () async {
|
onPressed: () async {
|
||||||
@@ -545,11 +544,13 @@ class SettingsScreen extends StatelessWidget {
|
|||||||
final newStatus = await permission
|
final newStatus = await permission
|
||||||
.Permission.sms.status;
|
.Permission.sms.status;
|
||||||
if (newStatus.isPermanentlyDenied) {
|
if (newStatus.isPermanentlyDenied) {
|
||||||
await permission.openAppSettings();
|
await permission
|
||||||
|
.openAppSettings();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (context.mounted) {
|
if (context.mounted) {
|
||||||
(context as Element).markNeedsBuild();
|
(context as Element)
|
||||||
|
.markNeedsBuild();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
child: Text(AppLocalizations.of(context)
|
child: Text(AppLocalizations.of(context)
|
||||||
|
|||||||
@@ -7,6 +7,38 @@ import '../services/subscription_url_matcher.dart';
|
|||||||
import '../utils/platform_helper.dart';
|
import '../utils/platform_helper.dart';
|
||||||
|
|
||||||
class SmsScanner {
|
class SmsScanner {
|
||||||
|
// 반복 사용되는 리소스 상수화로 파싱 성능 최적화
|
||||||
|
static const List<String> _subscriptionKeywords = [
|
||||||
|
'구독',
|
||||||
|
'결제',
|
||||||
|
'정기결제',
|
||||||
|
'자동결제',
|
||||||
|
'월정액',
|
||||||
|
'subscription',
|
||||||
|
'payment',
|
||||||
|
'billing',
|
||||||
|
'charge',
|
||||||
|
'넷플릭스',
|
||||||
|
'Netflix',
|
||||||
|
'유튜브',
|
||||||
|
'YouTube',
|
||||||
|
'Spotify',
|
||||||
|
'멜론',
|
||||||
|
'웨이브',
|
||||||
|
'Disney+',
|
||||||
|
'디즈니플러스',
|
||||||
|
'Apple',
|
||||||
|
'Microsoft',
|
||||||
|
'GitHub',
|
||||||
|
'Adobe',
|
||||||
|
'Amazon'
|
||||||
|
];
|
||||||
|
static final List<RegExp> _amountPatterns = <RegExp>[
|
||||||
|
RegExp(r'(\d{1,3}(?:,\d{3})*)(?:원|₩)'), // 원화
|
||||||
|
RegExp(r'\$(\d+(?:\.\d{2})?)'), // 달러
|
||||||
|
RegExp(r'(\d+(?:\.\d{2})?)\s*USD'), // USD
|
||||||
|
RegExp(r'결제.*?(\d{1,3}(?:,\d{3})*)'), // 결제 금액
|
||||||
|
];
|
||||||
final SmsQuery _query = SmsQuery();
|
final SmsQuery _query = SmsQuery();
|
||||||
|
|
||||||
Future<List<SubscriptionModel>> scanForSubscriptions() async {
|
Future<List<SubscriptionModel>> scanForSubscriptions() async {
|
||||||
@@ -106,35 +138,8 @@ class SmsScanner {
|
|||||||
final sender = message.address ?? '';
|
final sender = message.address ?? '';
|
||||||
final date = message.date ?? DateTime.now();
|
final date = message.date ?? DateTime.now();
|
||||||
|
|
||||||
// 구독 서비스 키워드 매칭
|
|
||||||
final subscriptionKeywords = [
|
|
||||||
'구독',
|
|
||||||
'결제',
|
|
||||||
'정기결제',
|
|
||||||
'자동결제',
|
|
||||||
'월정액',
|
|
||||||
'subscription',
|
|
||||||
'payment',
|
|
||||||
'billing',
|
|
||||||
'charge',
|
|
||||||
'넷플릭스',
|
|
||||||
'Netflix',
|
|
||||||
'유튜브',
|
|
||||||
'YouTube',
|
|
||||||
'Spotify',
|
|
||||||
'멜론',
|
|
||||||
'웨이브',
|
|
||||||
'Disney+',
|
|
||||||
'디즈니플러스',
|
|
||||||
'Apple',
|
|
||||||
'Microsoft',
|
|
||||||
'GitHub',
|
|
||||||
'Adobe',
|
|
||||||
'Amazon'
|
|
||||||
];
|
|
||||||
|
|
||||||
// 구독 관련 키워드가 있는지 확인
|
// 구독 관련 키워드가 있는지 확인
|
||||||
bool isSubscription = subscriptionKeywords.any((keyword) =>
|
bool isSubscription = _subscriptionKeywords.any((keyword) =>
|
||||||
body.toLowerCase().contains(keyword.toLowerCase()) ||
|
body.toLowerCase().contains(keyword.toLowerCase()) ||
|
||||||
sender.toLowerCase().contains(keyword.toLowerCase()));
|
sender.toLowerCase().contains(keyword.toLowerCase()));
|
||||||
|
|
||||||
@@ -208,15 +213,8 @@ class SmsScanner {
|
|||||||
|
|
||||||
// 금액 추출 로직
|
// 금액 추출 로직
|
||||||
double? _extractAmount(String body) {
|
double? _extractAmount(String body) {
|
||||||
// 다양한 금액 패턴 매칭
|
// 다양한 금액 패턴 매칭(사전 컴파일)
|
||||||
final patterns = [
|
for (final pattern in _amountPatterns) {
|
||||||
RegExp(r'(\d{1,3}(?:,\d{3})*)(?:원|₩)'), // 원화
|
|
||||||
RegExp(r'\$(\d+(?:\.\d{2})?)'), // 달러
|
|
||||||
RegExp(r'(\d+(?:\.\d{2})?)\s*USD'), // USD
|
|
||||||
RegExp(r'결제.*?(\d{1,3}(?:,\d{3})*)'), // 결제 금액
|
|
||||||
];
|
|
||||||
|
|
||||||
for (final pattern in patterns) {
|
|
||||||
final match = pattern.firstMatch(body);
|
final match = pattern.firstMatch(body);
|
||||||
if (match != null) {
|
if (match != null) {
|
||||||
String amountStr = match.group(1) ?? '';
|
String amountStr = match.group(1) ?? '';
|
||||||
|
|||||||
@@ -154,99 +154,101 @@ class MonthlyExpenseChartCard extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 20),
|
const SizedBox(height: 20),
|
||||||
// 바 차트
|
// 바 차트 (RepaintBoundary로 페인트 분리)
|
||||||
AspectRatio(
|
RepaintBoundary(
|
||||||
aspectRatio: 1.6,
|
child: AspectRatio(
|
||||||
child: BarChart(
|
aspectRatio: 1.6,
|
||||||
BarChartData(
|
child: BarChart(
|
||||||
alignment: BarChartAlignment.spaceAround,
|
BarChartData(
|
||||||
maxY: _calculateChartMaxY(
|
alignment: BarChartAlignment.spaceAround,
|
||||||
monthlyData.fold<double>(
|
maxY: _calculateChartMaxY(
|
||||||
0,
|
monthlyData.fold<double>(
|
||||||
(max, data) => math.max(
|
0,
|
||||||
max, data['totalExpense'] as double)),
|
(max, data) => math.max(
|
||||||
locale),
|
max, data['totalExpense'] as double)),
|
||||||
barGroups: _getMonthlyBarGroups(locale),
|
locale),
|
||||||
gridData: FlGridData(
|
barGroups: _getMonthlyBarGroups(locale),
|
||||||
show: true,
|
gridData: FlGridData(
|
||||||
drawVerticalLine: false,
|
show: true,
|
||||||
horizontalInterval: _calculateGridInterval(
|
drawVerticalLine: false,
|
||||||
_calculateChartMaxY(
|
horizontalInterval: _calculateGridInterval(
|
||||||
monthlyData.fold<double>(
|
_calculateChartMaxY(
|
||||||
0,
|
monthlyData.fold<double>(
|
||||||
(max, data) => math.max(max,
|
0,
|
||||||
data['totalExpense'] as double)),
|
(max, data) => math.max(max,
|
||||||
locale),
|
data['totalExpense'] as double)),
|
||||||
CurrencyUtil.getDefaultCurrency(locale)),
|
locale),
|
||||||
getDrawingHorizontalLine: (value) {
|
CurrencyUtil.getDefaultCurrency(locale)),
|
||||||
return FlLine(
|
getDrawingHorizontalLine: (value) {
|
||||||
color:
|
return FlLine(
|
||||||
AppColors.navyGray.withValues(alpha: 0.1),
|
color:
|
||||||
strokeWidth: 1,
|
AppColors.navyGray.withValues(alpha: 0.1),
|
||||||
);
|
strokeWidth: 1,
|
||||||
},
|
);
|
||||||
),
|
},
|
||||||
titlesData: FlTitlesData(
|
|
||||||
show: true,
|
|
||||||
topTitles: const AxisTitles(
|
|
||||||
sideTitles: SideTitles(showTitles: false),
|
|
||||||
),
|
),
|
||||||
bottomTitles: AxisTitles(
|
titlesData: FlTitlesData(
|
||||||
sideTitles: SideTitles(
|
show: true,
|
||||||
showTitles: true,
|
topTitles: const AxisTitles(
|
||||||
getTitlesWidget: (value, meta) {
|
sideTitles: SideTitles(showTitles: false),
|
||||||
return Padding(
|
),
|
||||||
padding: const EdgeInsets.only(top: 8),
|
bottomTitles: AxisTitles(
|
||||||
child: ThemedText.caption(
|
sideTitles: SideTitles(
|
||||||
text: monthlyData[value.toInt()]
|
showTitles: true,
|
||||||
['monthName'],
|
getTitlesWidget: (value, meta) {
|
||||||
style: const TextStyle(
|
return Padding(
|
||||||
fontSize: 12,
|
padding: const EdgeInsets.only(top: 8),
|
||||||
fontWeight: FontWeight.bold,
|
child: ThemedText.caption(
|
||||||
|
text: monthlyData[value.toInt()]
|
||||||
|
['monthName'],
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
leftTitles: const AxisTitles(
|
||||||
|
sideTitles: SideTitles(showTitles: false),
|
||||||
|
),
|
||||||
|
rightTitles: const AxisTitles(
|
||||||
|
sideTitles: SideTitles(showTitles: false),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
borderData: FlBorderData(show: false),
|
||||||
|
barTouchData: BarTouchData(
|
||||||
|
enabled: true,
|
||||||
|
touchTooltipData: BarTouchTooltipData(
|
||||||
|
tooltipBgColor: AppColors.darkNavy,
|
||||||
|
tooltipRoundedRadius: 8,
|
||||||
|
getTooltipItem:
|
||||||
|
(group, groupIndex, rod, rodIndex) {
|
||||||
|
return BarTooltipItem(
|
||||||
|
'${monthlyData[group.x]['monthName']}\n',
|
||||||
|
const TextStyle(
|
||||||
|
color: AppColors.pureWhite,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
),
|
),
|
||||||
|
children: [
|
||||||
|
TextSpan(
|
||||||
|
text: CurrencyUtil
|
||||||
|
.formatTotalAmountWithLocale(
|
||||||
|
monthlyData[group.x]
|
||||||
|
['totalExpense'] as double,
|
||||||
|
locale),
|
||||||
|
style: const TextStyle(
|
||||||
|
color: Color(0xFFFBBF24),
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
leftTitles: const AxisTitles(
|
|
||||||
sideTitles: SideTitles(showTitles: false),
|
|
||||||
),
|
|
||||||
rightTitles: const AxisTitles(
|
|
||||||
sideTitles: SideTitles(showTitles: false),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
borderData: FlBorderData(show: false),
|
|
||||||
barTouchData: BarTouchData(
|
|
||||||
enabled: true,
|
|
||||||
touchTooltipData: BarTouchTooltipData(
|
|
||||||
tooltipBgColor: AppColors.darkNavy,
|
|
||||||
tooltipRoundedRadius: 8,
|
|
||||||
getTooltipItem:
|
|
||||||
(group, groupIndex, rod, rodIndex) {
|
|
||||||
return BarTooltipItem(
|
|
||||||
'${monthlyData[group.x]['monthName']}\n',
|
|
||||||
const TextStyle(
|
|
||||||
color: AppColors.pureWhite,
|
|
||||||
fontWeight: FontWeight.bold,
|
|
||||||
),
|
|
||||||
children: [
|
|
||||||
TextSpan(
|
|
||||||
text: CurrencyUtil
|
|
||||||
.formatTotalAmountWithLocale(
|
|
||||||
monthlyData[group.x]
|
|
||||||
['totalExpense'] as double,
|
|
||||||
locale),
|
|
||||||
style: const TextStyle(
|
|
||||||
color: Color(0xFFFBBF24),
|
|
||||||
fontSize: 14,
|
|
||||||
fontWeight: FontWeight.w500,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -177,10 +177,13 @@ class _GlassmorphicScaffoldState extends State<GlassmorphicScaffold>
|
|||||||
child: AnimatedBuilder(
|
child: AnimatedBuilder(
|
||||||
animation: _particleController,
|
animation: _particleController,
|
||||||
builder: (context, child) {
|
builder: (context, child) {
|
||||||
|
final media = MediaQuery.maybeOf(context);
|
||||||
|
final reduce = media?.disableAnimations ?? false;
|
||||||
|
final count = reduce ? 10 : 30;
|
||||||
return CustomPaint(
|
return CustomPaint(
|
||||||
painter: ParticlePainter(
|
painter: ParticlePainter(
|
||||||
animation: _particleController,
|
animation: _particleController,
|
||||||
particleCount: 30,
|
particleCount: count,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -71,6 +71,7 @@ class SubscriptionListWidget extends StatelessWidget {
|
|||||||
physics: const NeverScrollableScrollPhysics(),
|
physics: const NeverScrollableScrollPhysics(),
|
||||||
shrinkWrap: true,
|
shrinkWrap: true,
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||||
|
prototypeItem: const SizedBox(height: 156),
|
||||||
itemCount: subscriptions.length,
|
itemCount: subscriptions.length,
|
||||||
itemBuilder: (context, subIndex) {
|
itemBuilder: (context, subIndex) {
|
||||||
// 각 구독의 지연값 계산 (순차적으로 나타나도록)
|
// 각 구독의 지연값 계산 (순차적으로 나타나도록)
|
||||||
@@ -98,60 +99,63 @@ class SubscriptionListWidget extends StatelessWidget {
|
|||||||
child: StaggeredAnimationItem(
|
child: StaggeredAnimationItem(
|
||||||
index: subIndex,
|
index: subIndex,
|
||||||
delay: const Duration(milliseconds: 50),
|
delay: const Duration(milliseconds: 50),
|
||||||
child: SwipeableSubscriptionCard(
|
child: RepaintBoundary(
|
||||||
subscription: subscriptions[subIndex],
|
child: SwipeableSubscriptionCard(
|
||||||
onTap: () {
|
subscription: subscriptions[subIndex],
|
||||||
Log.d(
|
onTap: () {
|
||||||
'[SubscriptionListWidget] SwipeableSubscriptionCard onTap 호출됨');
|
Log.d(
|
||||||
AppNavigator.toDetail(
|
'[SubscriptionListWidget] SwipeableSubscriptionCard onTap 호출됨');
|
||||||
context, subscriptions[subIndex]);
|
AppNavigator.toDetail(
|
||||||
},
|
context, subscriptions[subIndex]);
|
||||||
onDelete: () async {
|
},
|
||||||
// 현재 로케일에 맞는 서비스명 가져오기
|
onDelete: () async {
|
||||||
final localeProvider =
|
// 현재 로케일에 맞는 서비스명 가져오기
|
||||||
Provider.of<LocaleProvider>(
|
final localeProvider =
|
||||||
context,
|
Provider.of<LocaleProvider>(
|
||||||
listen: false,
|
|
||||||
);
|
|
||||||
final locale =
|
|
||||||
localeProvider.locale.languageCode;
|
|
||||||
final displayName = await SubscriptionUrlMatcher
|
|
||||||
.getServiceDisplayName(
|
|
||||||
serviceName:
|
|
||||||
subscriptions[subIndex].serviceName,
|
|
||||||
locale: locale,
|
|
||||||
);
|
|
||||||
|
|
||||||
// 삭제 확인 다이얼로그 표시
|
|
||||||
if (!context.mounted) return;
|
|
||||||
final shouldDelete =
|
|
||||||
await DeleteConfirmationDialog.show(
|
|
||||||
context: context,
|
|
||||||
serviceName: displayName,
|
|
||||||
);
|
|
||||||
if (!context.mounted) return;
|
|
||||||
|
|
||||||
if (shouldDelete) {
|
|
||||||
// 사용자가 확인한 경우에만 삭제 진행
|
|
||||||
final provider =
|
|
||||||
Provider.of<SubscriptionProvider>(
|
|
||||||
context,
|
context,
|
||||||
listen: false,
|
listen: false,
|
||||||
);
|
);
|
||||||
await provider.deleteSubscription(
|
final locale =
|
||||||
subscriptions[subIndex].id,
|
localeProvider.locale.languageCode;
|
||||||
|
final displayName =
|
||||||
|
await SubscriptionUrlMatcher
|
||||||
|
.getServiceDisplayName(
|
||||||
|
serviceName:
|
||||||
|
subscriptions[subIndex].serviceName,
|
||||||
|
locale: locale,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (context.mounted) {
|
// 삭제 확인 다이얼로그 표시
|
||||||
AppSnackBar.showError(
|
if (!context.mounted) return;
|
||||||
context: context,
|
final shouldDelete =
|
||||||
message: AppLocalizations.of(context)
|
await DeleteConfirmationDialog.show(
|
||||||
.subscriptionDeleted(displayName),
|
context: context,
|
||||||
icon: Icons.delete_forever_rounded,
|
serviceName: displayName,
|
||||||
|
);
|
||||||
|
if (!context.mounted) return;
|
||||||
|
|
||||||
|
if (shouldDelete) {
|
||||||
|
// 사용자가 확인한 경우에만 삭제 진행
|
||||||
|
final provider =
|
||||||
|
Provider.of<SubscriptionProvider>(
|
||||||
|
context,
|
||||||
|
listen: false,
|
||||||
);
|
);
|
||||||
|
await provider.deleteSubscription(
|
||||||
|
subscriptions[subIndex].id,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (context.mounted) {
|
||||||
|
AppSnackBar.showError(
|
||||||
|
context: context,
|
||||||
|
message: AppLocalizations.of(context)
|
||||||
|
.subscriptionDeleted(displayName),
|
||||||
|
icon: Icons.delete_forever_rounded,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
},
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
Reference in New Issue
Block a user