Flutter 앱: 앨범 상세 화면, 라이트박스, 다운로드 기능 추가
- 앨범 상세 화면 구현 (히어로, 티저, 수록곡, 컨셉포토 섹션) - 티저/컨셉포토 라이트박스 (photo_view 핀치줌) - flutter_downloader로 백그라운드 이미지 다운로드 - modal_bottom_sheet로 앨범 소개 다이얼로그 - 뒤로가기 두 번 눌러 종료 기능 - 앱 아이콘 변경 (fromis_9 로고) - 모든 아이콘 Lucide Icons로 통일 - 앨범 목록 애니메이션 최적화 (스크롤 시 애니메이션 제거) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
|
|
@ -1,5 +1,11 @@
|
|||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<uses-permission android:name="android.permission.INTERNET"/>
|
||||
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" android:maxSdkVersion="28"/>
|
||||
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" android:maxSdkVersion="32"/>
|
||||
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES"/>
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
|
||||
<application
|
||||
android:usesCleartextTraffic="true"
|
||||
android:label="fromis9"
|
||||
android:name="${applicationName}"
|
||||
android:icon="@mipmap/ic_launcher">
|
||||
|
|
@ -25,6 +31,16 @@
|
|||
<category android:name="android.intent.category.LAUNCHER"/>
|
||||
</intent-filter>
|
||||
</activity>
|
||||
<!-- flutter_downloader provider -->
|
||||
<provider
|
||||
android:name="vn.hunghd.flutterdownloader.DownloadedFileProvider"
|
||||
android:authorities="${applicationId}.flutter_downloader.provider"
|
||||
android:exported="false"
|
||||
android:grantUriPermissions="true">
|
||||
<meta-data
|
||||
android:name="android.support.FILE_PROVIDER_PATHS"
|
||||
android:resource="@xml/provider_paths"/>
|
||||
</provider>
|
||||
<!-- Don't delete the meta-data below.
|
||||
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
|
||||
<meta-data
|
||||
|
|
|
|||
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 8.1 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 25 KiB |
|
After Width: | Height: | Size: 34 KiB |
|
|
@ -0,0 +1,4 @@
|
|||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@color/ic_launcher_background"/>
|
||||
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
|
||||
</adaptive-icon>
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@color/ic_launcher_background"/>
|
||||
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
|
||||
</adaptive-icon>
|
||||
|
Before Width: | Height: | Size: 544 B After Width: | Height: | Size: 2.1 KiB |
|
After Width: | Height: | Size: 4 KiB |
BIN
app/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png
Normal file
|
After Width: | Height: | Size: 3.5 KiB |
BIN
app/android/app/src/main/res/mipmap-ldpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 986 B |
BIN
app/android/app/src/main/res/mipmap-ldpi/ic_launcher_round.png
Normal file
|
After Width: | Height: | Size: 1.5 KiB |
|
Before Width: | Height: | Size: 442 B After Width: | Height: | Size: 1.3 KiB |
|
After Width: | Height: | Size: 2.5 KiB |
BIN
app/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png
Normal file
|
After Width: | Height: | Size: 2.1 KiB |
|
Before Width: | Height: | Size: 721 B After Width: | Height: | Size: 2.9 KiB |
|
After Width: | Height: | Size: 5.9 KiB |
BIN
app/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png
Normal file
|
After Width: | Height: | Size: 5.1 KiB |
|
Before Width: | Height: | Size: 1 KiB After Width: | Height: | Size: 4.7 KiB |
|
After Width: | Height: | Size: 9.8 KiB |
BIN
app/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png
Normal file
|
After Width: | Height: | Size: 8.7 KiB |
|
Before Width: | Height: | Size: 1.4 KiB After Width: | Height: | Size: 6.6 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
|
@ -0,0 +1,3 @@
|
|||
<resources>
|
||||
<color name="ic_launcher_background">#960000</color>
|
||||
</resources>
|
||||
7
app/android/app/src/main/res/xml/provider_paths.xml
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<paths>
|
||||
<external-path name="external" path="." />
|
||||
<external-files-path name="external_files" path="." />
|
||||
<cache-path name="cache" path="." />
|
||||
<files-path name="files" path="." />
|
||||
</paths>
|
||||
BIN
app/assets/icon.png
Normal file
|
After Width: | Height: | Size: 75 KiB |
|
|
@ -427,7 +427,7 @@
|
|||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ALWAYS_SEARCH_USER_PATHS = NO;
|
||||
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
|
||||
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = AppIcon;
|
||||
CLANG_ANALYZER_NONNULL = YES;
|
||||
CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x";
|
||||
CLANG_CXX_LIBRARY = "libc++";
|
||||
|
|
@ -484,7 +484,7 @@
|
|||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ALWAYS_SEARCH_USER_PATHS = NO;
|
||||
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
|
||||
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = AppIcon;
|
||||
CLANG_ANALYZER_NONNULL = YES;
|
||||
CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x";
|
||||
CLANG_CXX_LIBRARY = "libc++";
|
||||
|
|
|
|||
|
|
@ -1,122 +1,128 @@
|
|||
{
|
||||
"images" : [
|
||||
{
|
||||
"size" : "20x20",
|
||||
"idiom" : "iphone",
|
||||
"filename" : "Icon-App-20x20@2x.png",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"size" : "20x20",
|
||||
"idiom" : "iphone",
|
||||
"filename" : "Icon-App-20x20@3x.png",
|
||||
"scale" : "3x"
|
||||
},
|
||||
{
|
||||
"size" : "29x29",
|
||||
"idiom" : "iphone",
|
||||
"filename" : "Icon-App-29x29@1x.png",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"size" : "29x29",
|
||||
"idiom" : "iphone",
|
||||
"filename" : "Icon-App-29x29@2x.png",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"size" : "29x29",
|
||||
"idiom" : "iphone",
|
||||
"filename" : "Icon-App-29x29@3x.png",
|
||||
"scale" : "3x"
|
||||
},
|
||||
{
|
||||
"size" : "40x40",
|
||||
"idiom" : "iphone",
|
||||
"filename" : "Icon-App-40x40@2x.png",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"size" : "40x40",
|
||||
"idiom" : "iphone",
|
||||
"filename" : "Icon-App-40x40@3x.png",
|
||||
"scale" : "3x"
|
||||
},
|
||||
{
|
||||
"size" : "60x60",
|
||||
"idiom" : "iphone",
|
||||
"filename" : "Icon-App-60x60@2x.png",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"size" : "60x60",
|
||||
"idiom" : "iphone",
|
||||
"filename" : "Icon-App-60x60@3x.png",
|
||||
"scale" : "3x"
|
||||
},
|
||||
{
|
||||
"size" : "20x20",
|
||||
"idiom" : "ipad",
|
||||
"filename" : "Icon-App-20x20@1x.png",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"size" : "20x20",
|
||||
"idiom" : "ipad",
|
||||
"filename" : "Icon-App-20x20@2x.png",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"size" : "29x29",
|
||||
"idiom" : "ipad",
|
||||
"filename" : "Icon-App-29x29@1x.png",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"size" : "29x29",
|
||||
"idiom" : "ipad",
|
||||
"filename" : "Icon-App-29x29@2x.png",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"size" : "40x40",
|
||||
"idiom" : "ipad",
|
||||
"filename" : "Icon-App-40x40@1x.png",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"size" : "40x40",
|
||||
"idiom" : "ipad",
|
||||
"filename" : "Icon-App-40x40@2x.png",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"size" : "76x76",
|
||||
"idiom" : "ipad",
|
||||
"filename" : "Icon-App-76x76@1x.png",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"size" : "76x76",
|
||||
"idiom" : "ipad",
|
||||
"filename" : "Icon-App-76x76@2x.png",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"size" : "83.5x83.5",
|
||||
"idiom" : "ipad",
|
||||
"filename" : "Icon-App-83.5x83.5@2x.png",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"size" : "1024x1024",
|
||||
"idiom" : "ios-marketing",
|
||||
"filename" : "Icon-App-1024x1024@1x.png",
|
||||
"scale" : "1x"
|
||||
"images": [
|
||||
{
|
||||
"idiom": "iphone",
|
||||
"size": "20x20",
|
||||
"scale": "2x",
|
||||
"filename": "Icon-App-20x20@2x.png"
|
||||
},
|
||||
{
|
||||
"idiom": "iphone",
|
||||
"size": "20x20",
|
||||
"scale": "3x",
|
||||
"filename": "Icon-App-20x20@3x.png"
|
||||
},
|
||||
{
|
||||
"idiom": "iphone",
|
||||
"size": "29x29",
|
||||
"scale": "1x",
|
||||
"filename": "Icon-App-29x29@1x.png"
|
||||
},
|
||||
{
|
||||
"idiom": "iphone",
|
||||
"size": "29x29",
|
||||
"scale": "2x",
|
||||
"filename": "Icon-App-29x29@2x.png"
|
||||
},
|
||||
{
|
||||
"idiom": "iphone",
|
||||
"size": "29x29",
|
||||
"scale": "3x",
|
||||
"filename": "Icon-App-29x29@3x.png"
|
||||
},
|
||||
{
|
||||
"idiom": "iphone",
|
||||
"size": "40x40",
|
||||
"scale": "2x",
|
||||
"filename": "Icon-App-40x40@2x.png"
|
||||
},
|
||||
{
|
||||
"idiom": "iphone",
|
||||
"size": "40x40",
|
||||
"scale": "3x",
|
||||
"filename": "Icon-App-40x40@3x.png"
|
||||
},
|
||||
{
|
||||
"idiom": "iphone",
|
||||
"size": "60x60",
|
||||
"scale": "2x",
|
||||
"filename": "Icon-App-60x60@2x.png"
|
||||
},
|
||||
{
|
||||
"idiom": "iphone",
|
||||
"size": "60x60",
|
||||
"scale": "3x",
|
||||
"filename": "Icon-App-60x60@3x.png"
|
||||
},
|
||||
{
|
||||
"idiom": "iphone",
|
||||
"size": "76x76",
|
||||
"scale": "2x",
|
||||
"filename": "Icon-App-76x76@2x.png"
|
||||
},
|
||||
{
|
||||
"idiom": "ipad",
|
||||
"size": "20x20",
|
||||
"scale": "1x",
|
||||
"filename": "Icon-App-20x20@1x.png"
|
||||
},
|
||||
{
|
||||
"idiom": "ipad",
|
||||
"size": "20x20",
|
||||
"scale": "2x",
|
||||
"filename": "Icon-App-20x20@2x.png"
|
||||
},
|
||||
{
|
||||
"idiom": "ipad",
|
||||
"size": "29x29",
|
||||
"scale": "1x",
|
||||
"filename": "Icon-App-29x29@1x.png"
|
||||
},
|
||||
{
|
||||
"idiom": "ipad",
|
||||
"size": "29x29",
|
||||
"scale": "2x",
|
||||
"filename": "Icon-App-29x29@2x.png"
|
||||
},
|
||||
{
|
||||
"idiom": "ipad",
|
||||
"size": "40x40",
|
||||
"scale": "1x",
|
||||
"filename": "Icon-App-40x40@1x.png"
|
||||
},
|
||||
{
|
||||
"idiom": "ipad",
|
||||
"size": "40x40",
|
||||
"scale": "2x",
|
||||
"filename": "Icon-App-40x40@2x.png"
|
||||
},
|
||||
{
|
||||
"idiom": "ipad",
|
||||
"size": "76x76",
|
||||
"scale": "1x",
|
||||
"filename": "Icon-App-76x76@1x.png"
|
||||
},
|
||||
{
|
||||
"idiom": "ipad",
|
||||
"size": "76x76",
|
||||
"scale": "2x",
|
||||
"filename": "Icon-App-76x76@2x.png"
|
||||
},
|
||||
{
|
||||
"idiom": "ipad",
|
||||
"size": "83.5x83.5",
|
||||
"scale": "2x",
|
||||
"filename": "Icon-App-83.5x83.5@2x.png"
|
||||
},
|
||||
{
|
||||
"size": "1024x1024",
|
||||
"idiom": "ios-marketing",
|
||||
"scale": "1x",
|
||||
"filename": "ItunesArtwork@2x.png"
|
||||
}
|
||||
],
|
||||
"info": {
|
||||
"version": 1,
|
||||
"author": "easyappicon"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"version" : 1,
|
||||
"author" : "xcode"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 82 KiB |
|
Before Width: | Height: | Size: 295 B After Width: | Height: | Size: 508 B |
|
Before Width: | Height: | Size: 406 B After Width: | Height: | Size: 1,015 B |
|
Before Width: | Height: | Size: 450 B After Width: | Height: | Size: 1.6 KiB |
|
Before Width: | Height: | Size: 282 B After Width: | Height: | Size: 711 B |
|
Before Width: | Height: | Size: 462 B After Width: | Height: | Size: 1.5 KiB |
|
Before Width: | Height: | Size: 704 B After Width: | Height: | Size: 2.4 KiB |
|
Before Width: | Height: | Size: 406 B After Width: | Height: | Size: 1,015 B |
|
Before Width: | Height: | Size: 586 B After Width: | Height: | Size: 2.2 KiB |
|
Before Width: | Height: | Size: 862 B After Width: | Height: | Size: 3.5 KiB |
|
After Width: | Height: | Size: 3.6 KiB |
|
After Width: | Height: | Size: 7.5 KiB |
|
After Width: | Height: | Size: 4.2 KiB |
|
After Width: | Height: | Size: 8.4 KiB |
|
Before Width: | Height: | Size: 862 B After Width: | Height: | Size: 3.5 KiB |
|
Before Width: | Height: | Size: 1.6 KiB After Width: | Height: | Size: 5.5 KiB |
|
After Width: | Height: | Size: 5.4 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 762 B After Width: | Height: | Size: 2.1 KiB |
|
Before Width: | Height: | Size: 1.2 KiB After Width: | Height: | Size: 4.6 KiB |
|
Before Width: | Height: | Size: 1.4 KiB After Width: | Height: | Size: 5.2 KiB |
|
After Width: | Height: | Size: 54 KiB |
|
|
@ -45,5 +45,7 @@
|
|||
<true/>
|
||||
<key>UIApplicationSupportsIndirectInputEvents</key>
|
||||
<true/>
|
||||
<key>NSPhotoLibraryAddUsageDescription</key>
|
||||
<string>사진을 갤러리에 저장하기 위해 권한이 필요합니다.</string>
|
||||
</dict>
|
||||
</plist>
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import '../views/main_shell.dart';
|
|||
import '../views/home/home_view.dart';
|
||||
import '../views/members/members_view.dart';
|
||||
import '../views/album/album_view.dart';
|
||||
import '../views/album/album_detail_view.dart';
|
||||
import '../views/schedule/schedule_view.dart';
|
||||
|
||||
/// 네비게이션 키
|
||||
|
|
@ -47,5 +48,14 @@ final GoRouter appRouter = GoRouter(
|
|||
),
|
||||
],
|
||||
),
|
||||
// 앨범 상세 (셸 외부)
|
||||
GoRoute(
|
||||
path: '/album/:name',
|
||||
parentNavigatorKey: rootNavigatorKey,
|
||||
builder: (context, state) {
|
||||
final albumName = state.pathParameters['name']!;
|
||||
return AlbumDetailView(albumName: albumName);
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
/// fromis_9 Unofficial App
|
||||
///
|
||||
///
|
||||
/// MVCS 아키텍처:
|
||||
/// - Models: 데이터 모델
|
||||
/// - Views: UI 화면
|
||||
|
|
@ -12,10 +12,14 @@ import 'package:flutter/services.dart';
|
|||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'core/router.dart';
|
||||
import 'core/constants.dart';
|
||||
import 'services/download_service.dart';
|
||||
|
||||
void main() {
|
||||
void main() async {
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
|
||||
// 다운로드 서비스 초기화
|
||||
await initDownloadService();
|
||||
|
||||
// 상태바 및 네비게이션 바 스타일 설정
|
||||
SystemChrome.setSystemUIOverlayStyle(
|
||||
const SystemUiOverlayStyle(
|
||||
|
|
@ -27,7 +31,7 @@ void main() {
|
|||
systemNavigationBarIconBrightness: Brightness.dark,
|
||||
),
|
||||
);
|
||||
|
||||
|
||||
runApp(
|
||||
const ProviderScope(
|
||||
child: Fromis9App(),
|
||||
|
|
|
|||
|
|
@ -12,6 +12,9 @@ class Album {
|
|||
final String? coverThumbUrl;
|
||||
final String? folderName;
|
||||
final String? description;
|
||||
final List<Track>? tracks;
|
||||
final List<Teaser>? teasers;
|
||||
final Map<String, List<ConceptPhoto>>? conceptPhotos;
|
||||
|
||||
Album({
|
||||
required this.id,
|
||||
|
|
@ -24,12 +27,42 @@ class Album {
|
|||
this.coverThumbUrl,
|
||||
this.folderName,
|
||||
this.description,
|
||||
this.tracks,
|
||||
this.teasers,
|
||||
this.conceptPhotos,
|
||||
});
|
||||
|
||||
factory Album.fromJson(Map<String, dynamic> json) {
|
||||
// 트랙 파싱
|
||||
List<Track>? tracks;
|
||||
if (json['tracks'] != null) {
|
||||
tracks = (json['tracks'] as List)
|
||||
.map((t) => Track.fromJson(t))
|
||||
.toList();
|
||||
}
|
||||
|
||||
// 티저 파싱
|
||||
List<Teaser>? teasers;
|
||||
if (json['teasers'] != null) {
|
||||
teasers = (json['teasers'] as List)
|
||||
.map((t) => Teaser.fromJson(t))
|
||||
.toList();
|
||||
}
|
||||
|
||||
// 컨셉 포토 파싱
|
||||
Map<String, List<ConceptPhoto>>? conceptPhotos;
|
||||
if (json['conceptPhotos'] != null) {
|
||||
conceptPhotos = {};
|
||||
(json['conceptPhotos'] as Map<String, dynamic>).forEach((key, value) {
|
||||
conceptPhotos![key] = (value as List)
|
||||
.map((p) => ConceptPhoto.fromJson(p))
|
||||
.toList();
|
||||
});
|
||||
}
|
||||
|
||||
return Album(
|
||||
id: json['id'] as int,
|
||||
title: json['title'] as String,
|
||||
id: (json['id'] as num?)?.toInt() ?? 0,
|
||||
title: json['title'] as String? ?? '',
|
||||
albumType: json['album_type'] as String?,
|
||||
albumTypeShort: json['album_type_short'] as String?,
|
||||
releaseDate: json['release_date'] as String?,
|
||||
|
|
@ -38,9 +71,110 @@ class Album {
|
|||
coverThumbUrl: json['cover_thumb_url'] as String?,
|
||||
folderName: json['folder_name'] as String?,
|
||||
description: json['description'] as String?,
|
||||
tracks: tracks,
|
||||
teasers: teasers,
|
||||
conceptPhotos: conceptPhotos,
|
||||
);
|
||||
}
|
||||
|
||||
/// 발매 년도 추출
|
||||
String? get releaseYear => releaseDate?.substring(0, 4);
|
||||
|
||||
/// 총 재생 시간 계산
|
||||
String get totalDuration {
|
||||
if (tracks == null || tracks!.isEmpty) return '';
|
||||
int totalSeconds = 0;
|
||||
for (final track in tracks!) {
|
||||
if (track.duration != null) {
|
||||
final parts = track.duration!.split(':');
|
||||
if (parts.length == 2) {
|
||||
totalSeconds += int.parse(parts[0]) * 60 + int.parse(parts[1]);
|
||||
}
|
||||
}
|
||||
}
|
||||
final mins = totalSeconds ~/ 60;
|
||||
final secs = totalSeconds % 60;
|
||||
return '$mins:${secs.toString().padLeft(2, '0')}';
|
||||
}
|
||||
|
||||
/// 모든 컨셉 포토 리스트
|
||||
List<ConceptPhoto> get allConceptPhotos {
|
||||
if (conceptPhotos == null) return [];
|
||||
return conceptPhotos!.values.expand((list) => list).toList();
|
||||
}
|
||||
}
|
||||
|
||||
/// 트랙 모델
|
||||
class Track {
|
||||
final int id;
|
||||
final int trackNumber;
|
||||
final String title;
|
||||
final String? duration;
|
||||
final int isTitleTrack;
|
||||
|
||||
Track({
|
||||
required this.id,
|
||||
required this.trackNumber,
|
||||
required this.title,
|
||||
this.duration,
|
||||
this.isTitleTrack = 0,
|
||||
});
|
||||
|
||||
factory Track.fromJson(Map<String, dynamic> json) {
|
||||
return Track(
|
||||
id: (json['id'] as num?)?.toInt() ?? 0,
|
||||
trackNumber: (json['track_number'] as num?)?.toInt() ?? 0,
|
||||
title: json['title'] as String? ?? '',
|
||||
duration: json['duration'] as String?,
|
||||
isTitleTrack: (json['is_title_track'] as num?)?.toInt() ?? 0,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 티저 모델
|
||||
class Teaser {
|
||||
final int id;
|
||||
final String? originalUrl;
|
||||
final String? thumbUrl;
|
||||
final String? mediaType;
|
||||
|
||||
Teaser({
|
||||
required this.id,
|
||||
this.originalUrl,
|
||||
this.thumbUrl,
|
||||
this.mediaType,
|
||||
});
|
||||
|
||||
factory Teaser.fromJson(Map<String, dynamic> json) {
|
||||
return Teaser(
|
||||
id: (json['id'] as num?)?.toInt() ?? 0,
|
||||
originalUrl: json['original_url'] as String?,
|
||||
thumbUrl: json['thumb_url'] as String?,
|
||||
mediaType: json['media_type'] as String?,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 컨셉 포토 모델
|
||||
class ConceptPhoto {
|
||||
final int id;
|
||||
final String? originalUrl;
|
||||
final String? mediumUrl;
|
||||
final String? thumbUrl;
|
||||
|
||||
ConceptPhoto({
|
||||
required this.id,
|
||||
this.originalUrl,
|
||||
this.mediumUrl,
|
||||
this.thumbUrl,
|
||||
});
|
||||
|
||||
factory ConceptPhoto.fromJson(Map<String, dynamic> json) {
|
||||
return ConceptPhoto(
|
||||
id: (json['id'] as num?)?.toInt() ?? 0,
|
||||
originalUrl: json['original_url'] as String?,
|
||||
mediumUrl: json['medium_url'] as String?,
|
||||
thumbUrl: json['thumb_url'] as String?,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,3 +16,9 @@ Future<List<Album>> getRecentAlbums(int count) async {
|
|||
final albums = await getAlbums();
|
||||
return albums.take(count).toList();
|
||||
}
|
||||
|
||||
/// 앨범 상세 조회 (폴더명으로)
|
||||
Future<Album> getAlbumByName(String name) async {
|
||||
final response = await dio.get('/albums/by-name/$name');
|
||||
return Album.fromJson(response.data);
|
||||
}
|
||||
|
|
|
|||
54
app/lib/services/download_service.dart
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
/// 다운로드 서비스
|
||||
library;
|
||||
|
||||
import 'dart:io';
|
||||
import 'package:flutter_downloader/flutter_downloader.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:permission_handler/permission_handler.dart';
|
||||
|
||||
/// 다운로드 서비스 초기화
|
||||
Future<void> initDownloadService() async {
|
||||
await FlutterDownloader.initialize(
|
||||
debug: false,
|
||||
ignoreSsl: true,
|
||||
);
|
||||
}
|
||||
|
||||
/// 이미지 다운로드
|
||||
Future<String?> downloadImage(String url, {String? fileName}) async {
|
||||
// 권한 요청
|
||||
if (Platform.isAndroid) {
|
||||
final status = await Permission.notification.request();
|
||||
if (status.isDenied) {
|
||||
// 알림 권한 거부해도 다운로드는 진행
|
||||
}
|
||||
}
|
||||
|
||||
// 다운로드 경로 설정
|
||||
Directory? directory;
|
||||
if (Platform.isAndroid) {
|
||||
directory = Directory('/storage/emulated/0/Download');
|
||||
if (!await directory.exists()) {
|
||||
directory = await getExternalStorageDirectory();
|
||||
}
|
||||
} else {
|
||||
directory = await getApplicationDocumentsDirectory();
|
||||
}
|
||||
|
||||
if (directory == null) return null;
|
||||
|
||||
// 파일명 생성
|
||||
final name = fileName ?? 'fromis9_${DateTime.now().millisecondsSinceEpoch}.jpg';
|
||||
|
||||
// 다운로드 시작
|
||||
final taskId = await FlutterDownloader.enqueue(
|
||||
url: url,
|
||||
savedDir: directory.path,
|
||||
fileName: name,
|
||||
showNotification: true,
|
||||
openFileFromNotification: true,
|
||||
);
|
||||
|
||||
return taskId;
|
||||
}
|
||||
|
||||
1217
app/lib/views/album/album_detail_view.dart
Normal file
|
|
@ -1,41 +1,268 @@
|
|||
/// 앨범 화면
|
||||
/// 앨범 목록 화면
|
||||
library;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:lucide_icons/lucide_icons.dart';
|
||||
import '../../core/constants.dart';
|
||||
import '../../models/album.dart';
|
||||
import '../../services/albums_service.dart';
|
||||
|
||||
class AlbumView extends StatelessWidget {
|
||||
class AlbumView extends StatefulWidget {
|
||||
const AlbumView({super.key});
|
||||
|
||||
@override
|
||||
State<AlbumView> createState() => _AlbumViewState();
|
||||
}
|
||||
|
||||
class _AlbumViewState extends State<AlbumView> {
|
||||
late Future<List<Album>> _albumsFuture;
|
||||
bool _initialLoadComplete = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_albumsFuture = getAlbums();
|
||||
// 초기 애니메이션 시간 후에는 새로 생성되는 카드에 애니메이션 적용 안함
|
||||
Future.delayed(const Duration(milliseconds: 600), () {
|
||||
if (mounted) {
|
||||
setState(() => _initialLoadComplete = true);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return const Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.album_outlined,
|
||||
size: 64,
|
||||
color: AppColors.textTertiary,
|
||||
return FutureBuilder<List<Album>>(
|
||||
future: _albumsFuture,
|
||||
builder: (context, snapshot) {
|
||||
if (snapshot.connectionState == ConnectionState.waiting) {
|
||||
return const Center(
|
||||
child: CircularProgressIndicator(
|
||||
color: AppColors.primary,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (snapshot.hasError) {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const Icon(
|
||||
LucideIcons.alertCircle,
|
||||
size: 48,
|
||||
color: AppColors.textTertiary,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'앨범을 불러오는데 실패했습니다',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
color: AppColors.textSecondary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
setState(() {
|
||||
_albumsFuture = getAlbums();
|
||||
});
|
||||
},
|
||||
child: const Text('다시 시도'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
final albums = snapshot.data ?? [];
|
||||
|
||||
return GridView.builder(
|
||||
padding: const EdgeInsets.all(16),
|
||||
clipBehavior: Clip.none,
|
||||
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
|
||||
crossAxisCount: 2,
|
||||
crossAxisSpacing: 16,
|
||||
mainAxisSpacing: 16,
|
||||
childAspectRatio: 0.75,
|
||||
),
|
||||
SizedBox(height: 16),
|
||||
Text(
|
||||
'앨범',
|
||||
style: TextStyle(
|
||||
fontSize: 24,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: AppColors.textSecondary,
|
||||
itemCount: albums.length,
|
||||
itemBuilder: (context, index) {
|
||||
final album = albums[index];
|
||||
return _AlbumCard(
|
||||
album: album,
|
||||
index: index,
|
||||
skipAnimation: _initialLoadComplete,
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _AlbumCard extends StatefulWidget {
|
||||
final Album album;
|
||||
final int index;
|
||||
final bool skipAnimation;
|
||||
|
||||
const _AlbumCard({
|
||||
required this.album,
|
||||
required this.index,
|
||||
this.skipAnimation = false,
|
||||
});
|
||||
|
||||
@override
|
||||
State<_AlbumCard> createState() => _AlbumCardState();
|
||||
}
|
||||
|
||||
class _AlbumCardState extends State<_AlbumCard>
|
||||
with SingleTickerProviderStateMixin, AutomaticKeepAliveClientMixin {
|
||||
late AnimationController _controller;
|
||||
late Animation<double> _opacityAnimation;
|
||||
late Animation<Offset> _slideAnimation;
|
||||
bool _hasAnimated = false;
|
||||
|
||||
@override
|
||||
bool get wantKeepAlive => true;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_controller = AnimationController(
|
||||
duration: const Duration(milliseconds: 400),
|
||||
vsync: this,
|
||||
);
|
||||
|
||||
_opacityAnimation = Tween<double>(begin: 0.0, end: 1.0).animate(
|
||||
CurvedAnimation(parent: _controller, curve: Curves.easeOut),
|
||||
);
|
||||
|
||||
_slideAnimation = Tween<Offset>(
|
||||
begin: const Offset(0, 0.1),
|
||||
end: Offset.zero,
|
||||
).animate(
|
||||
CurvedAnimation(parent: _controller, curve: Curves.easeOut),
|
||||
);
|
||||
|
||||
// skipAnimation이면 애니메이션 건너뛰고 바로 표시
|
||||
if (widget.skipAnimation) {
|
||||
_hasAnimated = true;
|
||||
_controller.value = 1.0;
|
||||
} else {
|
||||
// 순차적으로 애니메이션 시작 (최대 8개까지만 순차, 이후는 동시에)
|
||||
final delay = widget.index < 8 ? widget.index * 50 : 400;
|
||||
Future.delayed(Duration(milliseconds: delay), () {
|
||||
if (mounted && !_hasAnimated) {
|
||||
_hasAnimated = true;
|
||||
_controller.forward();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
super.build(context);
|
||||
return FadeTransition(
|
||||
opacity: _opacityAnimation,
|
||||
child: SlideTransition(
|
||||
position: _slideAnimation,
|
||||
child: GestureDetector(
|
||||
onTap: () {
|
||||
final folderName = widget.album.folderName;
|
||||
if (folderName != null && folderName.isNotEmpty) {
|
||||
context.push('/album/$folderName');
|
||||
}
|
||||
},
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withValues(alpha: 0.08),
|
||||
blurRadius: 8,
|
||||
offset: const Offset(0, 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// 앨범 커버
|
||||
Expanded(
|
||||
child: ClipRRect(
|
||||
borderRadius: const BorderRadius.vertical(
|
||||
top: Radius.circular(16),
|
||||
),
|
||||
child: Container(
|
||||
width: double.infinity,
|
||||
color: AppColors.divider,
|
||||
child: widget.album.coverThumbUrl != null
|
||||
? CachedNetworkImage(
|
||||
imageUrl: widget.album.coverThumbUrl!,
|
||||
fit: BoxFit.cover,
|
||||
placeholder: (context, url) => Container(
|
||||
color: AppColors.divider,
|
||||
),
|
||||
errorWidget: (context, url, error) => const Center(
|
||||
child: Icon(
|
||||
LucideIcons.disc3,
|
||||
size: 48,
|
||||
color: AppColors.textTertiary,
|
||||
),
|
||||
),
|
||||
)
|
||||
: const Center(
|
||||
child: Icon(
|
||||
LucideIcons.disc3,
|
||||
size: 48,
|
||||
color: AppColors.textTertiary,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
// 앨범 정보
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
widget.album.title,
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.textPrimary,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'${widget.album.albumTypeShort ?? ''} · ${widget.album.releaseYear ?? ''}',
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
color: AppColors.textTertiary,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
SizedBox(height: 8),
|
||||
Text(
|
||||
'앨범 화면 준비 중',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
color: AppColors.textTertiary,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,61 +2,95 @@
|
|||
library;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:flutter_svg/flutter_svg.dart';
|
||||
import '../core/constants.dart';
|
||||
|
||||
/// 메인 앱 셸 (툴바 + 바텀 네비게이션 + 콘텐츠)
|
||||
class MainShell extends StatelessWidget {
|
||||
class MainShell extends StatefulWidget {
|
||||
final Widget child;
|
||||
|
||||
const MainShell({super.key, required this.child});
|
||||
|
||||
@override
|
||||
State<MainShell> createState() => _MainShellState();
|
||||
}
|
||||
|
||||
class _MainShellState extends State<MainShell> {
|
||||
DateTime? _lastBackPressed;
|
||||
|
||||
/// 뒤로가기 처리 - 두 번 눌러서 종료
|
||||
Future<bool> _onWillPop() async {
|
||||
final now = DateTime.now();
|
||||
if (_lastBackPressed == null || now.difference(_lastBackPressed!) > const Duration(seconds: 2)) {
|
||||
_lastBackPressed = now;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('한 번 더 누르면 종료됩니다'),
|
||||
duration: Duration(seconds: 2),
|
||||
behavior: SnackBarBehavior.floating,
|
||||
),
|
||||
);
|
||||
return false;
|
||||
}
|
||||
// 앱 종료
|
||||
SystemNavigator.pop();
|
||||
return true;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final location = GoRouterState.of(context).uri.path;
|
||||
final isMembersPage = location == '/members';
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: AppColors.background,
|
||||
// 앱바 (툴바) - 멤버 페이지에서는 그림자 제거 (인디케이터와 계단 효과 방지)
|
||||
appBar: PreferredSize(
|
||||
preferredSize: const Size.fromHeight(56),
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
boxShadow: isMembersPage
|
||||
? null
|
||||
: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withValues(alpha: 0.05),
|
||||
blurRadius: 4,
|
||||
offset: const Offset(0, 1),
|
||||
return PopScope(
|
||||
canPop: false,
|
||||
onPopInvokedWithResult: (didPop, result) async {
|
||||
if (didPop) return;
|
||||
await _onWillPop();
|
||||
},
|
||||
child: Scaffold(
|
||||
backgroundColor: AppColors.background,
|
||||
// 앱바 (툴바) - 멤버 페이지에서는 그림자 제거 (인디케이터와 계단 효과 방지)
|
||||
appBar: PreferredSize(
|
||||
preferredSize: const Size.fromHeight(56),
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
boxShadow: isMembersPage
|
||||
? null
|
||||
: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withValues(alpha: 0.05),
|
||||
blurRadius: 4,
|
||||
offset: const Offset(0, 1),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: SafeArea(
|
||||
child: SizedBox(
|
||||
height: 56,
|
||||
child: Center(
|
||||
child: Text(
|
||||
_getTitle(context),
|
||||
style: const TextStyle(
|
||||
fontFamily: 'Pretendard',
|
||||
color: AppColors.primary,
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
],
|
||||
),
|
||||
child: SafeArea(
|
||||
child: SizedBox(
|
||||
height: 56,
|
||||
child: Center(
|
||||
child: Text(
|
||||
_getTitle(context),
|
||||
style: const TextStyle(
|
||||
fontFamily: 'Pretendard',
|
||||
color: AppColors.primary,
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
// 콘텐츠
|
||||
body: widget.child,
|
||||
// 바텀 네비게이션
|
||||
bottomNavigationBar: const _BottomNavBar(),
|
||||
),
|
||||
// 콘텐츠
|
||||
body: child,
|
||||
// 바텀 네비게이션
|
||||
bottomNavigationBar: const _BottomNavBar(),
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -190,6 +190,14 @@ packages:
|
|||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.4.1"
|
||||
flutter_downloader:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: flutter_downloader
|
||||
sha256: "93a9ddbd561f8a3f5483b4189453fba145a0a1014a88143c96a966296b78a118"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.12.0"
|
||||
flutter_lints:
|
||||
dependency: "direct dev"
|
||||
description:
|
||||
|
|
@ -376,6 +384,14 @@ packages:
|
|||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.0.0"
|
||||
modal_bottom_sheet:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: modal_bottom_sheet
|
||||
sha256: eac66ef8cb0461bf069a38c5eb0fa728cee525a531a8304bd3f7b2185407c67e
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.0.0"
|
||||
node_preamble:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
|
@ -464,6 +480,54 @@ packages:
|
|||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.3.0"
|
||||
permission_handler:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: permission_handler
|
||||
sha256: "59adad729136f01ea9e35a48f5d1395e25cba6cea552249ddbe9cf950f5d7849"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "11.4.0"
|
||||
permission_handler_android:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: permission_handler_android
|
||||
sha256: d3971dcdd76182a0c198c096b5db2f0884b0d4196723d21a866fc4cdea057ebc
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "12.1.0"
|
||||
permission_handler_apple:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: permission_handler_apple
|
||||
sha256: f000131e755c54cf4d84a5d8bd6e4149e262cc31c5a8b1d698de1ac85fa41023
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "9.4.7"
|
||||
permission_handler_html:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: permission_handler_html
|
||||
sha256: "38f000e83355abb3392140f6bc3030660cfaef189e1f87824facb76300b4ff24"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.1.3+5"
|
||||
permission_handler_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: permission_handler_platform_interface
|
||||
sha256: eb99b295153abce5d683cac8c02e22faab63e50679b937fa1bf67d58bb282878
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.3.0"
|
||||
permission_handler_windows:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: permission_handler_windows
|
||||
sha256: "1a790728016f79a41216d88672dbc5df30e686e811ad4e698bfc51f76ad91f1e"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.2.1"
|
||||
petitparser:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
|
|
|||
|
|
@ -44,6 +44,9 @@ dependencies:
|
|||
lucide_icons: ^0.257.0
|
||||
flutter_svg: ^2.0.17
|
||||
url_launcher: ^6.3.1
|
||||
flutter_downloader: ^1.11.8
|
||||
permission_handler: ^11.3.1
|
||||
modal_bottom_sheet: ^3.0.0
|
||||
|
||||
dev_dependencies:
|
||||
flutter_test:
|
||||
|
|
|
|||
|
|
@ -6,9 +6,12 @@
|
|||
|
||||
#include "generated_plugin_registrant.h"
|
||||
|
||||
#include <permission_handler_windows/permission_handler_windows_plugin.h>
|
||||
#include <url_launcher_windows/url_launcher_windows.h>
|
||||
|
||||
void RegisterPlugins(flutter::PluginRegistry* registry) {
|
||||
PermissionHandlerWindowsPluginRegisterWithRegistrar(
|
||||
registry->GetRegistrarForPlugin("PermissionHandlerWindowsPlugin"));
|
||||
UrlLauncherWindowsRegisterWithRegistrar(
|
||||
registry->GetRegistrarForPlugin("UrlLauncherWindows"));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
#
|
||||
|
||||
list(APPEND FLUTTER_PLUGIN_LIST
|
||||
permission_handler_windows
|
||||
url_launcher_windows
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -1 +0,0 @@
|
|||
Subproject commit bf6b7f76369b47437a3047615334b19f56037cba
|
||||