Flutter 앱: 앨범 상세 화면, 라이트박스, 다운로드 기능 추가

- 앨범 상세 화면 구현 (히어로, 티저, 수록곡, 컨셉포토 섹션)
- 티저/컨셉포토 라이트박스 (photo_view 핀치줌)
- flutter_downloader로 백그라운드 이미지 다운로드
- modal_bottom_sheet로 앨범 소개 다이얼로그
- 뒤로가기 두 번 눌러 종료 기능
- 앱 아이콘 변경 (fromis_9 로고)
- 모든 아이콘 Lucide Icons로 통일
- 앨범 목록 애니메이션 최적화 (스크롤 시 애니메이션 제거)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
caadiq 2026-01-13 10:42:52 +09:00
parent 488e4094c8
commit 5691cb6ce0
66 changed files with 1986 additions and 188 deletions

View file

@ -1,5 +1,11 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"> <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 <application
android:usesCleartextTraffic="true"
android:label="fromis9" android:label="fromis9"
android:name="${applicationName}" android:name="${applicationName}"
android:icon="@mipmap/ic_launcher"> android:icon="@mipmap/ic_launcher">
@ -25,6 +31,16 @@
<category android:name="android.intent.category.LAUNCHER"/> <category android:name="android.intent.category.LAUNCHER"/>
</intent-filter> </intent-filter>
</activity> </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. <!-- Don't delete the meta-data below.
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java --> This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
<meta-data <meta-data

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

View file

@ -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>

View file

@ -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>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 544 B

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 986 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 442 B

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 721 B

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1 KiB

After

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 6.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

View file

@ -0,0 +1,3 @@
<resources>
<color name="ic_launcher_background">#960000</color>
</resources>

View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 75 KiB

View file

@ -427,7 +427,7 @@
isa = XCBuildConfiguration; isa = XCBuildConfiguration;
buildSettings = { buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO; 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_ANALYZER_NONNULL = YES;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x";
CLANG_CXX_LIBRARY = "libc++"; CLANG_CXX_LIBRARY = "libc++";
@ -484,7 +484,7 @@
isa = XCBuildConfiguration; isa = XCBuildConfiguration;
buildSettings = { buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO; 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_ANALYZER_NONNULL = YES;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x";
CLANG_CXX_LIBRARY = "libc++"; CLANG_CXX_LIBRARY = "libc++";

View file

@ -1,122 +1,128 @@
{ {
"images": [ "images": [
{ {
"idiom": "iphone",
"size": "20x20", "size": "20x20",
"idiom" : "iphone", "scale": "2x",
"filename" : "Icon-App-20x20@2x.png", "filename": "Icon-App-20x20@2x.png"
"scale" : "2x"
}, },
{ {
"idiom": "iphone",
"size": "20x20", "size": "20x20",
"idiom" : "iphone", "scale": "3x",
"filename" : "Icon-App-20x20@3x.png", "filename": "Icon-App-20x20@3x.png"
"scale" : "3x"
}, },
{ {
"idiom": "iphone",
"size": "29x29", "size": "29x29",
"idiom" : "iphone", "scale": "1x",
"filename" : "Icon-App-29x29@1x.png", "filename": "Icon-App-29x29@1x.png"
"scale" : "1x"
}, },
{ {
"idiom": "iphone",
"size": "29x29", "size": "29x29",
"idiom" : "iphone", "scale": "2x",
"filename" : "Icon-App-29x29@2x.png", "filename": "Icon-App-29x29@2x.png"
"scale" : "2x"
}, },
{ {
"idiom": "iphone",
"size": "29x29", "size": "29x29",
"idiom" : "iphone", "scale": "3x",
"filename" : "Icon-App-29x29@3x.png", "filename": "Icon-App-29x29@3x.png"
"scale" : "3x"
}, },
{ {
"idiom": "iphone",
"size": "40x40", "size": "40x40",
"idiom" : "iphone", "scale": "2x",
"filename" : "Icon-App-40x40@2x.png", "filename": "Icon-App-40x40@2x.png"
"scale" : "2x"
}, },
{ {
"idiom": "iphone",
"size": "40x40", "size": "40x40",
"idiom" : "iphone", "scale": "3x",
"filename" : "Icon-App-40x40@3x.png", "filename": "Icon-App-40x40@3x.png"
"scale" : "3x"
}, },
{ {
"idiom": "iphone",
"size": "60x60", "size": "60x60",
"idiom" : "iphone", "scale": "2x",
"filename" : "Icon-App-60x60@2x.png", "filename": "Icon-App-60x60@2x.png"
"scale" : "2x"
}, },
{ {
"idiom": "iphone",
"size": "60x60", "size": "60x60",
"scale": "3x",
"filename": "Icon-App-60x60@3x.png"
},
{
"idiom": "iphone", "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", "size": "76x76",
"idiom" : "ipad", "scale": "2x",
"filename" : "Icon-App-76x76@1x.png", "filename": "Icon-App-76x76@2x.png"
"scale" : "1x"
}, },
{ {
"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", "size": "76x76",
"idiom" : "ipad", "scale": "1x",
"filename" : "Icon-App-76x76@2x.png", "filename": "Icon-App-76x76@1x.png"
"scale" : "2x"
}, },
{ {
"idiom": "ipad",
"size": "76x76",
"scale": "2x",
"filename": "Icon-App-76x76@2x.png"
},
{
"idiom": "ipad",
"size": "83.5x83.5", "size": "83.5x83.5",
"idiom" : "ipad", "scale": "2x",
"filename" : "Icon-App-83.5x83.5@2x.png", "filename": "Icon-App-83.5x83.5@2x.png"
"scale" : "2x"
}, },
{ {
"size": "1024x1024", "size": "1024x1024",
"idiom": "ios-marketing", "idiom": "ios-marketing",
"filename" : "Icon-App-1024x1024@1x.png", "scale": "1x",
"scale" : "1x" "filename": "ItunesArtwork@2x.png"
} }
], ],
"info": { "info": {
"version": 1, "version": 1,
"author" : "xcode" "author": "easyappicon"
} }
} }

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 82 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 295 B

After

Width:  |  Height:  |  Size: 508 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 406 B

After

Width:  |  Height:  |  Size: 1,015 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 450 B

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 282 B

After

Width:  |  Height:  |  Size: 711 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 462 B

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 704 B

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 406 B

After

Width:  |  Height:  |  Size: 1,015 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 586 B

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 862 B

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 862 B

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 KiB

After

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 762 B

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

View file

@ -45,5 +45,7 @@
<true/> <true/>
<key>UIApplicationSupportsIndirectInputEvents</key> <key>UIApplicationSupportsIndirectInputEvents</key>
<true/> <true/>
<key>NSPhotoLibraryAddUsageDescription</key>
<string>사진을 갤러리에 저장하기 위해 권한이 필요합니다.</string>
</dict> </dict>
</plist> </plist>

View file

@ -7,6 +7,7 @@ import '../views/main_shell.dart';
import '../views/home/home_view.dart'; import '../views/home/home_view.dart';
import '../views/members/members_view.dart'; import '../views/members/members_view.dart';
import '../views/album/album_view.dart'; import '../views/album/album_view.dart';
import '../views/album/album_detail_view.dart';
import '../views/schedule/schedule_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);
},
),
], ],
); );

View file

@ -12,10 +12,14 @@ import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'core/router.dart'; import 'core/router.dart';
import 'core/constants.dart'; import 'core/constants.dart';
import 'services/download_service.dart';
void main() { void main() async {
WidgetsFlutterBinding.ensureInitialized(); WidgetsFlutterBinding.ensureInitialized();
//
await initDownloadService();
// //
SystemChrome.setSystemUIOverlayStyle( SystemChrome.setSystemUIOverlayStyle(
const SystemUiOverlayStyle( const SystemUiOverlayStyle(

View file

@ -12,6 +12,9 @@ class Album {
final String? coverThumbUrl; final String? coverThumbUrl;
final String? folderName; final String? folderName;
final String? description; final String? description;
final List<Track>? tracks;
final List<Teaser>? teasers;
final Map<String, List<ConceptPhoto>>? conceptPhotos;
Album({ Album({
required this.id, required this.id,
@ -24,12 +27,42 @@ class Album {
this.coverThumbUrl, this.coverThumbUrl,
this.folderName, this.folderName,
this.description, this.description,
this.tracks,
this.teasers,
this.conceptPhotos,
}); });
factory Album.fromJson(Map<String, dynamic> json) { 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( return Album(
id: json['id'] as int, id: (json['id'] as num?)?.toInt() ?? 0,
title: json['title'] as String, title: json['title'] as String? ?? '',
albumType: json['album_type'] as String?, albumType: json['album_type'] as String?,
albumTypeShort: json['album_type_short'] as String?, albumTypeShort: json['album_type_short'] as String?,
releaseDate: json['release_date'] as String?, releaseDate: json['release_date'] as String?,
@ -38,9 +71,110 @@ class Album {
coverThumbUrl: json['cover_thumb_url'] as String?, coverThumbUrl: json['cover_thumb_url'] as String?,
folderName: json['folder_name'] as String?, folderName: json['folder_name'] as String?,
description: json['description'] as String?, description: json['description'] as String?,
tracks: tracks,
teasers: teasers,
conceptPhotos: conceptPhotos,
); );
} }
/// ///
String? get releaseYear => releaseDate?.substring(0, 4); 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?,
);
}
} }

View file

@ -16,3 +16,9 @@ Future<List<Album>> getRecentAlbums(int count) async {
final albums = await getAlbums(); final albums = await getAlbums();
return albums.take(count).toList(); 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);
}

View 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;
}

File diff suppressed because it is too large Load diff

View file

@ -1,42 +1,269 @@
/// ///
library; library;
import 'package:flutter/material.dart'; 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 '../../core/constants.dart';
import '../../models/album.dart';
import '../../services/albums_service.dart';
class AlbumView extends StatelessWidget { class AlbumView extends StatefulWidget {
const AlbumView({super.key}); 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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return FutureBuilder<List<Album>>(
future: _albumsFuture,
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
return const Center( return const Center(
child: CircularProgressIndicator(
color: AppColors.primary,
),
);
}
if (snapshot.hasError) {
return Center(
child: Column( child: Column(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
children: [ children: [
Icon( const Icon(
Icons.album_outlined, LucideIcons.alertCircle,
size: 64, size: 48,
color: AppColors.textTertiary, color: AppColors.textTertiary,
), ),
SizedBox(height: 16), const SizedBox(height: 16),
Text( Text(
'앨범', '앨범을 불러오는데 실패했습니다',
style: TextStyle( style: TextStyle(
fontSize: 24, fontSize: 14,
fontWeight: FontWeight.bold,
color: AppColors.textSecondary, color: AppColors.textSecondary,
), ),
), ),
SizedBox(height: 8), const SizedBox(height: 16),
Text( TextButton(
'앨범 화면 준비 중', onPressed: () {
style: TextStyle( setState(() {
fontSize: 14, _albumsFuture = getAlbums();
color: AppColors.textTertiary, });
), },
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,
),
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,
),
),
],
),
),
],
),
),
),
),
);
}
} }

View file

@ -2,22 +2,55 @@
library; library;
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import 'package:flutter_svg/flutter_svg.dart'; import 'package:flutter_svg/flutter_svg.dart';
import '../core/constants.dart'; import '../core/constants.dart';
/// ( + + ) /// ( + + )
class MainShell extends StatelessWidget { class MainShell extends StatefulWidget {
final Widget child; final Widget child;
const MainShell({super.key, required this.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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final location = GoRouterState.of(context).uri.path; final location = GoRouterState.of(context).uri.path;
final isMembersPage = location == '/members'; final isMembersPage = location == '/members';
return Scaffold( return PopScope(
canPop: false,
onPopInvokedWithResult: (didPop, result) async {
if (didPop) return;
await _onWillPop();
},
child: Scaffold(
backgroundColor: AppColors.background, backgroundColor: AppColors.background,
// () - ( ) // () - ( )
appBar: PreferredSize( appBar: PreferredSize(
@ -54,9 +87,10 @@ class MainShell extends StatelessWidget {
), ),
), ),
// //
body: child, body: widget.child,
// //
bottomNavigationBar: const _BottomNavBar(), bottomNavigationBar: const _BottomNavBar(),
),
); );
} }

View file

@ -190,6 +190,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "3.4.1" 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: flutter_lints:
dependency: "direct dev" dependency: "direct dev"
description: description:
@ -376,6 +384,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.0.0" 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: node_preamble:
dependency: transitive dependency: transitive
description: description:
@ -464,6 +480,54 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.3.0" 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: petitparser:
dependency: transitive dependency: transitive
description: description:

View file

@ -44,6 +44,9 @@ dependencies:
lucide_icons: ^0.257.0 lucide_icons: ^0.257.0
flutter_svg: ^2.0.17 flutter_svg: ^2.0.17
url_launcher: ^6.3.1 url_launcher: ^6.3.1
flutter_downloader: ^1.11.8
permission_handler: ^11.3.1
modal_bottom_sheet: ^3.0.0
dev_dependencies: dev_dependencies:
flutter_test: flutter_test:

View file

@ -6,9 +6,12 @@
#include "generated_plugin_registrant.h" #include "generated_plugin_registrant.h"
#include <permission_handler_windows/permission_handler_windows_plugin.h>
#include <url_launcher_windows/url_launcher_windows.h> #include <url_launcher_windows/url_launcher_windows.h>
void RegisterPlugins(flutter::PluginRegistry* registry) { void RegisterPlugins(flutter::PluginRegistry* registry) {
PermissionHandlerWindowsPluginRegisterWithRegistrar(
registry->GetRegistrarForPlugin("PermissionHandlerWindowsPlugin"));
UrlLauncherWindowsRegisterWithRegistrar( UrlLauncherWindowsRegisterWithRegistrar(
registry->GetRegistrarForPlugin("UrlLauncherWindows")); registry->GetRegistrarForPlugin("UrlLauncherWindows"));
} }

View file

@ -3,6 +3,7 @@
# #
list(APPEND FLUTTER_PLUGIN_LIST list(APPEND FLUTTER_PLUGIN_LIST
permission_handler_windows
url_launcher_windows url_launcher_windows
) )

@ -1 +0,0 @@
Subproject commit bf6b7f76369b47437a3047615334b19f56037cba