diff --git a/app/android/app/src/main/AndroidManifest.xml b/app/android/app/src/main/AndroidManifest.xml index 542454b..81eb1a8 100644 --- a/app/android/app/src/main/AndroidManifest.xml +++ b/app/android/app/src/main/AndroidManifest.xml @@ -1,5 +1,11 @@ + + + + + @@ -25,6 +31,16 @@ + + + + + + + \ No newline at end of file diff --git a/app/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/app/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml new file mode 100644 index 0000000..79d695e --- /dev/null +++ b/app/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/app/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/app/android/app/src/main/res/mipmap-hdpi/ic_launcher.png index db77bb4..e2d9fd9 100644 Binary files a/app/android/app/src/main/res/mipmap-hdpi/ic_launcher.png and b/app/android/app/src/main/res/mipmap-hdpi/ic_launcher.png differ diff --git a/app/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png b/app/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..f5c88d8 Binary files /dev/null and b/app/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png differ diff --git a/app/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png b/app/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png new file mode 100644 index 0000000..3919c7d Binary files /dev/null and b/app/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png differ diff --git a/app/android/app/src/main/res/mipmap-ldpi/ic_launcher.png b/app/android/app/src/main/res/mipmap-ldpi/ic_launcher.png new file mode 100644 index 0000000..b6eec65 Binary files /dev/null and b/app/android/app/src/main/res/mipmap-ldpi/ic_launcher.png differ diff --git a/app/android/app/src/main/res/mipmap-ldpi/ic_launcher_round.png b/app/android/app/src/main/res/mipmap-ldpi/ic_launcher_round.png new file mode 100644 index 0000000..1440240 Binary files /dev/null and b/app/android/app/src/main/res/mipmap-ldpi/ic_launcher_round.png differ diff --git a/app/android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/app/android/app/src/main/res/mipmap-mdpi/ic_launcher.png index 17987b7..be98b6d 100644 Binary files a/app/android/app/src/main/res/mipmap-mdpi/ic_launcher.png and b/app/android/app/src/main/res/mipmap-mdpi/ic_launcher.png differ diff --git a/app/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png b/app/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..5fc3837 Binary files /dev/null and b/app/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png differ diff --git a/app/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png b/app/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png new file mode 100644 index 0000000..b2b9a2a Binary files /dev/null and b/app/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png differ diff --git a/app/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/app/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png index 09d4391..3e8ed03 100644 Binary files a/app/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png and b/app/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/app/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png b/app/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..3385f77 Binary files /dev/null and b/app/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png differ diff --git a/app/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png b/app/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png new file mode 100644 index 0000000..10e3ee4 Binary files /dev/null and b/app/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png differ diff --git a/app/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/app/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png index d5f1c8d..8caea01 100644 Binary files a/app/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png and b/app/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/app/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png b/app/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..508bbad Binary files /dev/null and b/app/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png differ diff --git a/app/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png b/app/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png new file mode 100644 index 0000000..1e66ad9 Binary files /dev/null and b/app/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png differ diff --git a/app/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/app/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png index 4d6372e..9ef1865 100644 Binary files a/app/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png and b/app/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/app/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png b/app/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..1dea40e Binary files /dev/null and b/app/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png differ diff --git a/app/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png b/app/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png new file mode 100644 index 0000000..a211eca Binary files /dev/null and b/app/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png differ diff --git a/app/android/app/src/main/res/values/ic_launcher_background.xml b/app/android/app/src/main/res/values/ic_launcher_background.xml new file mode 100644 index 0000000..2546093 --- /dev/null +++ b/app/android/app/src/main/res/values/ic_launcher_background.xml @@ -0,0 +1,3 @@ + + #960000 + \ No newline at end of file diff --git a/app/android/app/src/main/res/xml/provider_paths.xml b/app/android/app/src/main/res/xml/provider_paths.xml new file mode 100644 index 0000000..7f18968 --- /dev/null +++ b/app/android/app/src/main/res/xml/provider_paths.xml @@ -0,0 +1,7 @@ + + + + + + + diff --git a/app/assets/icon.png b/app/assets/icon.png new file mode 100644 index 0000000..6825c11 Binary files /dev/null and b/app/assets/icon.png differ diff --git a/app/ios/Runner.xcodeproj/project.pbxproj b/app/ios/Runner.xcodeproj/project.pbxproj index f4e5f4f..a44a903 100644 --- a/app/ios/Runner.xcodeproj/project.pbxproj +++ b/app/ios/Runner.xcodeproj/project.pbxproj @@ -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++"; diff --git a/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json b/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json index d36b1fa..1815c38 100644 --- a/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json +++ b/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -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" - } -} +} \ No newline at end of file diff --git a/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png b/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png index dc9ada4..ec35849 100644 Binary files a/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png and b/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png differ diff --git a/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png b/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png index 7353c41..7e9c4e6 100644 Binary files a/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png and b/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png differ diff --git a/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png b/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png index 797d452..670c859 100644 Binary files a/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png and b/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png differ diff --git a/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png b/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png index 6ed2d93..1cd079e 100644 Binary files a/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png and b/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png differ diff --git a/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png b/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png index 4cd7b00..c9accb0 100644 Binary files a/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png and b/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png differ diff --git a/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png b/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png index fe73094..59a45ae 100644 Binary files a/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png and b/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png differ diff --git a/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png b/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png index 321773c..60d34ff 100644 Binary files a/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png and b/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png differ diff --git a/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png b/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png index 797d452..670c859 100644 Binary files a/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png and b/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png differ diff --git a/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png b/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png index 502f463..041a097 100644 Binary files a/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png and b/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png differ diff --git a/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png b/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png index 0ec3034..080f6bf 100644 Binary files a/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png and b/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png differ diff --git a/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@1x.png b/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@1x.png new file mode 100644 index 0000000..ee4b23c Binary files /dev/null and b/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@1x.png differ diff --git a/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@2x.png b/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@2x.png new file mode 100644 index 0000000..977458c Binary files /dev/null and b/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@2x.png differ diff --git a/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@1x.png b/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@1x.png new file mode 100644 index 0000000..e96e762 Binary files /dev/null and b/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@1x.png differ diff --git a/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@2x.png b/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@2x.png new file mode 100644 index 0000000..a2f6674 Binary files /dev/null and b/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@2x.png differ diff --git a/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png b/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png index 0ec3034..080f6bf 100644 Binary files a/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png and b/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png differ diff --git a/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png b/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png index e9f5fea..f4a4379 100644 Binary files a/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png and b/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png differ diff --git a/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@1x.png b/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@1x.png new file mode 100644 index 0000000..efcaa77 Binary files /dev/null and b/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@1x.png differ diff --git a/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@2x.png b/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@2x.png new file mode 100644 index 0000000..3ef5a7e Binary files /dev/null and b/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@2x.png differ diff --git a/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png b/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png index 84ac32a..fd7aab7 100644 Binary files a/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png and b/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png differ diff --git a/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png b/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png index 8953cba..d436bc1 100644 Binary files a/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png and b/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png differ diff --git a/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png b/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png index 0467bf1..90752b6 100644 Binary files a/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png and b/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png differ diff --git a/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/ItunesArtwork@2x.png b/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/ItunesArtwork@2x.png new file mode 100644 index 0000000..03f1ce1 Binary files /dev/null and b/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/ItunesArtwork@2x.png differ diff --git a/app/ios/Runner/Info.plist b/app/ios/Runner/Info.plist index 71a7384..9e435d2 100644 --- a/app/ios/Runner/Info.plist +++ b/app/ios/Runner/Info.plist @@ -45,5 +45,7 @@ UIApplicationSupportsIndirectInputEvents + NSPhotoLibraryAddUsageDescription + 사진을 갤러리에 저장하기 위해 권한이 필요합니다. diff --git a/app/lib/core/router.dart b/app/lib/core/router.dart index 9b843f4..a52dd30 100644 --- a/app/lib/core/router.dart +++ b/app/lib/core/router.dart @@ -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); + }, + ), ], ); diff --git a/app/lib/main.dart b/app/lib/main.dart index 66d22c1..732da1d 100644 --- a/app/lib/main.dart +++ b/app/lib/main.dart @@ -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(), diff --git a/app/lib/models/album.dart b/app/lib/models/album.dart index 486a3bc..bfc7c20 100644 --- a/app/lib/models/album.dart +++ b/app/lib/models/album.dart @@ -12,6 +12,9 @@ class Album { final String? coverThumbUrl; final String? folderName; final String? description; + final List? tracks; + final List? teasers; + final Map>? 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 json) { + // 트랙 파싱 + List? tracks; + if (json['tracks'] != null) { + tracks = (json['tracks'] as List) + .map((t) => Track.fromJson(t)) + .toList(); + } + + // 티저 파싱 + List? teasers; + if (json['teasers'] != null) { + teasers = (json['teasers'] as List) + .map((t) => Teaser.fromJson(t)) + .toList(); + } + + // 컨셉 포토 파싱 + Map>? conceptPhotos; + if (json['conceptPhotos'] != null) { + conceptPhotos = {}; + (json['conceptPhotos'] as Map).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 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 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 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 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?, + ); + } } diff --git a/app/lib/services/albums_service.dart b/app/lib/services/albums_service.dart index f559017..51ebf48 100644 --- a/app/lib/services/albums_service.dart +++ b/app/lib/services/albums_service.dart @@ -16,3 +16,9 @@ Future> getRecentAlbums(int count) async { final albums = await getAlbums(); return albums.take(count).toList(); } + +/// 앨범 상세 조회 (폴더명으로) +Future getAlbumByName(String name) async { + final response = await dio.get('/albums/by-name/$name'); + return Album.fromJson(response.data); +} diff --git a/app/lib/services/download_service.dart b/app/lib/services/download_service.dart new file mode 100644 index 0000000..9b84715 --- /dev/null +++ b/app/lib/services/download_service.dart @@ -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 initDownloadService() async { + await FlutterDownloader.initialize( + debug: false, + ignoreSsl: true, + ); +} + +/// 이미지 다운로드 +Future 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; +} + diff --git a/app/lib/views/album/album_detail_view.dart b/app/lib/views/album/album_detail_view.dart new file mode 100644 index 0000000..d73edf3 --- /dev/null +++ b/app/lib/views/album/album_detail_view.dart @@ -0,0 +1,1217 @@ +/// 앨범 상세 화면 +library; + +import 'dart:ui'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.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 'package:photo_view/photo_view.dart'; +import 'package:photo_view/photo_view_gallery.dart'; +import 'package:modal_bottom_sheet/modal_bottom_sheet.dart'; +import '../../core/constants.dart'; +import '../../models/album.dart'; +import '../../services/albums_service.dart'; +import '../../services/download_service.dart'; + +class AlbumDetailView extends StatefulWidget { + final String albumName; + + const AlbumDetailView({super.key, required this.albumName}); + + @override + State createState() => _AlbumDetailViewState(); +} + +class _AlbumDetailViewState extends State { + late Future _albumFuture; + bool _showAllTracks = false; + + @override + void initState() { + super.initState(); + _albumFuture = getAlbumByName(widget.albumName); + } + + String _formatDate(String? date) { + if (date == null || date.length < 10) return ''; + return '${date.substring(0, 4)}.${date.substring(5, 7)}.${date.substring(8, 10)}'; + } + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: AppColors.background, + body: FutureBuilder( + future: _albumFuture, + 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: const TextStyle(color: AppColors.textSecondary), + ), + const SizedBox(height: 8), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 32), + child: Text( + snapshot.error.toString(), + style: const TextStyle(fontSize: 12, color: AppColors.textTertiary), + textAlign: TextAlign.center, + ), + ), + const SizedBox(height: 16), + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + TextButton( + onPressed: () { + setState(() { + _albumFuture = getAlbumByName(widget.albumName); + }); + }, + child: const Text('다시 시도'), + ), + TextButton( + onPressed: () => context.pop(), + child: const Text('뒤로 가기'), + ), + ], + ), + ], + ), + ); + } + + if (!snapshot.hasData) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon(LucideIcons.disc3, size: 48, color: AppColors.textTertiary), + const SizedBox(height: 16), + const Text('앨범을 찾을 수 없습니다'), + const SizedBox(height: 16), + TextButton( + onPressed: () => context.pop(), + child: const Text('뒤로 가기'), + ), + ], + ), + ); + } + + final album = snapshot.data!; + final allPhotos = album.allConceptPhotos; + final displayTracks = _showAllTracks + ? album.tracks + : album.tracks?.take(5).toList(); + + return CustomScrollView( + slivers: [ + // 앱바 + SliverAppBar( + pinned: true, + backgroundColor: Colors.white, + foregroundColor: AppColors.textPrimary, + elevation: 0, + leading: IconButton( + icon: const Icon(LucideIcons.arrowLeft), + onPressed: () => context.pop(), + ), + title: const Text( + '앨범', + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.w600, + ), + ), + ), + + // 히어로 섹션 + SliverToBoxAdapter( + child: _HeroSection(album: album, formatDate: _formatDate), + ), + + // 티저 포토 + if (album.teasers != null && album.teasers!.isNotEmpty) + SliverToBoxAdapter( + child: _TeaserSection(teasers: album.teasers!), + ), + + // 수록곡 + if (album.tracks != null && album.tracks!.isNotEmpty) + SliverToBoxAdapter( + child: _TracksSection( + album: album, + displayTracks: displayTracks, + showAllTracks: _showAllTracks, + onToggle: () => setState(() => _showAllTracks = !_showAllTracks), + ), + ), + + // 컨셉 포토 + if (allPhotos.isNotEmpty) + SliverToBoxAdapter( + child: _ConceptPhotosSection( + photos: allPhotos, + albumName: widget.albumName, + ), + ), + + // 하단 여백 (컨셉 포토가 없을 때만) + if (allPhotos.isEmpty) + SliverToBoxAdapter( + child: SizedBox(height: 16 + MediaQuery.of(context).padding.bottom), + ), + ], + ); + }, + ), + ); + } +} + +/// 히어로 섹션 +class _HeroSection extends StatelessWidget { + final Album album; + final String Function(String?) formatDate; + + const _HeroSection({required this.album, required this.formatDate}); + + @override + Widget build(BuildContext context) { + return ClipRect( + child: Stack( + clipBehavior: Clip.hardEdge, + children: [ + // 배경 블러 이미지 + if (album.coverMediumUrl != null) + Positioned.fill( + child: ImageFiltered( + imageFilter: ImageFilter.blur(sigmaX: 30, sigmaY: 30), + child: Opacity( + opacity: 0.3, + child: CachedNetworkImage( + imageUrl: album.coverMediumUrl!, + fit: BoxFit.cover, + ), + ), + ), + ), + // 그라데이션 오버레이 + Positioned.fill( + child: Container( + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [ + Colors.white.withValues(alpha: 0.6), + Colors.white.withValues(alpha: 0.8), + AppColors.background, + ], + ), + ), + ), + ), + // 콘텐츠 + Padding( + padding: const EdgeInsets.fromLTRB(20, 16, 20, 20), + child: Column( + children: [ + // 앨범 커버 + Container( + width: 176, + height: 176, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(16), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.2), + blurRadius: 20, + offset: const Offset(0, 8), + ), + ], + ), + child: ClipRRect( + borderRadius: BorderRadius.circular(16), + child: album.coverMediumUrl != null + ? CachedNetworkImage( + imageUrl: album.coverMediumUrl!, + fit: BoxFit.cover, + placeholder: (context, url) => Container( + color: AppColors.divider, + ), + errorWidget: (context, url, error) => Container( + color: AppColors.divider, + child: const Icon( + LucideIcons.disc3, + size: 64, + color: AppColors.textTertiary, + ), + ), + ) + : Container( + color: AppColors.divider, + child: const Icon( + LucideIcons.disc3, + size: 64, + color: AppColors.textTertiary, + ), + ), + ), + ), + const SizedBox(height: 16), + // 앨범 타입 뱃지 + Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4), + decoration: BoxDecoration( + color: AppColors.primary.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(20), + ), + child: Text( + album.albumType ?? '', + style: const TextStyle( + fontSize: 12, + fontWeight: FontWeight.w500, + color: AppColors.primary, + ), + ), + ), + const SizedBox(height: 8), + // 앨범 제목 + Text( + album.title, + style: const TextStyle( + fontSize: 24, + fontWeight: FontWeight.bold, + color: AppColors.textPrimary, + ), + textAlign: TextAlign.center, + ), + const SizedBox(height: 12), + // 메타 정보 + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + _MetaItem( + icon: LucideIcons.calendar, + text: formatDate(album.releaseDate), + ), + const SizedBox(width: 16), + _MetaItem( + icon: LucideIcons.music2, + text: '${album.tracks?.length ?? 0}곡', + ), + const SizedBox(width: 16), + _MetaItem( + icon: LucideIcons.clock, + text: album.totalDuration, + ), + ], + ), + // 앨범 소개 버튼 + if (album.description != null && album.description!.isNotEmpty) ...[ + const SizedBox(height: 12), + GestureDetector( + onTap: () => _showDescriptionModal(context, album.description!), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: 0.8), + borderRadius: BorderRadius.circular(20), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.05), + blurRadius: 4, + ), + ], + ), + child: const Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(LucideIcons.fileText, size: 14, color: AppColors.textSecondary), + SizedBox(width: 4), + Text( + '앨범 소개', + style: TextStyle( + fontSize: 12, + color: AppColors.textSecondary, + ), + ), + ], + ), + ), + ), + ], + ], + ), + ), + ], + ), + ); + } + + void _showDescriptionModal(BuildContext context, String description) { + showBarModalBottomSheet( + context: context, + backgroundColor: Colors.white, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(20)), + ), + builder: (context) => _DescriptionContent(description: description), + ); + } +} + +/// 메타 정보 아이템 +class _MetaItem extends StatelessWidget { + final IconData icon; + final String text; + + const _MetaItem({required this.icon, required this.text}); + + @override + Widget build(BuildContext context) { + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, size: 14, color: AppColors.textSecondary), + const SizedBox(width: 4), + Text( + text, + style: const TextStyle( + fontSize: 13, + color: AppColors.textSecondary, + ), + ), + ], + ); + } +} + +/// 티저 포토 섹션 +class _TeaserSection extends StatelessWidget { + final List teasers; + + const _TeaserSection({required this.teasers}); + + @override + Widget build(BuildContext context) { + return Container( + decoration: const BoxDecoration( + color: AppColors.background, + border: Border( + bottom: BorderSide(color: AppColors.divider), + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Padding( + padding: EdgeInsets.fromLTRB(16, 16, 16, 12), + child: Text( + '티저 포토', + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w600, + ), + ), + ), + SizedBox( + height: 96, + child: ListView.builder( + scrollDirection: Axis.horizontal, + padding: const EdgeInsets.symmetric(horizontal: 16), + clipBehavior: Clip.none, + itemCount: teasers.length, + itemBuilder: (context, index) { + final teaser = teasers[index]; + return Padding( + padding: EdgeInsets.only(right: index < teasers.length - 1 ? 12 : 0), + child: GestureDetector( + onTap: () => _showImageViewer(context, teasers, index), + child: Container( + width: 96, + height: 96, + decoration: BoxDecoration( + color: AppColors.divider, + borderRadius: BorderRadius.circular(16), + ), + child: ClipRRect( + borderRadius: BorderRadius.circular(16), + child: Stack( + fit: StackFit.expand, + children: [ + if (teaser.thumbUrl != null || teaser.originalUrl != null) + CachedNetworkImage( + imageUrl: teaser.thumbUrl ?? teaser.originalUrl!, + fit: BoxFit.cover, + placeholder: (context, url) => Container( + color: AppColors.divider, + ), + errorWidget: (context, url, error) => const SizedBox(), + ), + if (teaser.mediaType == 'video') + Container( + color: Colors.black.withValues(alpha: 0.3), + child: const Center( + child: CircleAvatar( + radius: 16, + backgroundColor: Colors.white, + child: Icon( + LucideIcons.play, + size: 18, + color: AppColors.textPrimary, + ), + ), + ), + ), + ], + ), + ), + ), + ), + ); + }, + ), + ), + const SizedBox(height: 16), + ], + ), + ); + } + + void _showImageViewer(BuildContext context, List teasers, int initialIndex) { + Navigator.of(context).push( + PageRouteBuilder( + opaque: false, + pageBuilder: (context, animation, secondaryAnimation) { + return _TeaserImageViewer( + images: teasers.map((t) => t.originalUrl ?? '').toList(), + initialIndex: initialIndex, + ); + }, + transitionsBuilder: (context, animation, secondaryAnimation, child) { + return FadeTransition(opacity: animation, child: child); + }, + ), + ); + } +} + +/// 수록곡 섹션 +class _TracksSection extends StatelessWidget { + final Album album; + final List? displayTracks; + final bool showAllTracks; + final VoidCallback onToggle; + + const _TracksSection({ + required this.album, + required this.displayTracks, + required this.showAllTracks, + required this.onToggle, + }); + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(16), + decoration: const BoxDecoration( + color: AppColors.background, + border: Border( + bottom: BorderSide(color: AppColors.divider), + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + '수록곡', + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 12), + ...?displayTracks?.map((track) => _TrackItem(track: track)), + if (album.tracks != null && album.tracks!.length > 5) + GestureDetector( + onTap: onToggle, + child: Padding( + padding: const EdgeInsets.only(top: 8), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + showAllTracks + ? '접기' + : '${album.tracks!.length - 5}곡 더보기', + style: const TextStyle( + fontSize: 14, + color: AppColors.textSecondary, + ), + ), + Icon( + showAllTracks ? LucideIcons.chevronUp : LucideIcons.chevronDown, + size: 18, + color: AppColors.textSecondary, + ), + ], + ), + ), + ), + ], + ), + ); + } +} + +/// 트랙 아이템 +class _TrackItem extends StatelessWidget { + final Track track; + + const _TrackItem({required this.track}); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 10, horizontal: 12), + child: Row( + children: [ + // 트랙 번호 + SizedBox( + width: 24, + child: Text( + track.trackNumber.toString().padLeft(2, '0'), + style: const TextStyle( + fontSize: 14, + color: AppColors.textTertiary, + fontFeatures: [FontFeature.tabularFigures()], + ), + ), + ), + const SizedBox(width: 12), + // 트랙 제목 + Expanded( + child: Row( + children: [ + Flexible( + child: Text( + track.title, + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w500, + color: track.isTitleTrack == 1 + ? AppColors.primary + : AppColors.textPrimary, + ), + overflow: TextOverflow.ellipsis, + ), + ), + if (track.isTitleTrack == 1) ...[ + const SizedBox(width: 8), + Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + decoration: BoxDecoration( + color: AppColors.primary, + borderRadius: BorderRadius.circular(4), + ), + child: const Text( + 'TITLE', + style: TextStyle( + fontSize: 10, + fontWeight: FontWeight.bold, + color: Colors.white, + ), + ), + ), + ], + ], + ), + ), + // 재생 시간 + Text( + track.duration ?? '-', + style: const TextStyle( + fontSize: 12, + color: AppColors.textTertiary, + fontFeatures: [FontFeature.tabularFigures()], + ), + ), + ], + ), + ); + } +} + +/// 컨셉 포토 섹션 +class _ConceptPhotosSection extends StatelessWidget { + final List photos; + final String albumName; + + const _ConceptPhotosSection({required this.photos, required this.albumName}); + + @override + Widget build(BuildContext context) { + final bottomPadding = MediaQuery.of(context).padding.bottom; + + return Container( + color: AppColors.background, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Padding( + padding: EdgeInsets.fromLTRB(16, 16, 16, 12), + child: Text( + '컨셉 포토', + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w600, + ), + ), + ), + GridView.builder( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + padding: const EdgeInsets.symmetric(horizontal: 16), + clipBehavior: Clip.none, + gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 3, + crossAxisSpacing: 8, + mainAxisSpacing: 8, + ), + itemCount: photos.length > 6 ? 6 : photos.length, + itemBuilder: (context, index) { + final photo = photos[index]; + return GestureDetector( + onTap: () => _showImageViewer(context, photo), + child: Container( + decoration: BoxDecoration( + color: AppColors.divider, + borderRadius: BorderRadius.circular(12), + ), + child: ClipRRect( + borderRadius: BorderRadius.circular(12), + child: photo.thumbUrl != null || photo.mediumUrl != null + ? CachedNetworkImage( + imageUrl: photo.thumbUrl ?? photo.mediumUrl!, + fit: BoxFit.cover, + placeholder: (context, url) => Container( + color: AppColors.divider, + ), + errorWidget: (context, url, error) => const SizedBox(), + ) + : const SizedBox(), + ), + ), + ); + }, + ), + // 전체보기 버튼 + if (photos.length > 6) + Padding( + padding: EdgeInsets.fromLTRB(16, 12, 16, 16 + bottomPadding), + child: SizedBox( + width: double.infinity, + child: ElevatedButton( + onPressed: () { + // TODO: 갤러리 페이지로 이동 + }, + style: ElevatedButton.styleFrom( + backgroundColor: AppColors.primary.withValues(alpha: 0.05), + foregroundColor: AppColors.primary, + elevation: 0, + padding: const EdgeInsets.symmetric(vertical: 12), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text('전체 ${photos.length}장 보기'), + const SizedBox(width: 4), + const Icon(LucideIcons.chevronRight, size: 18), + ], + ), + ), + ), + ), + // 전체보기 버튼이 없을 때의 하단 여백 + if (photos.length <= 6) + SizedBox(height: 16 + bottomPadding), + ], + ), + ); + } + + void _showImageViewer(BuildContext context, ConceptPhoto photo) { + Navigator.of(context).push( + PageRouteBuilder( + opaque: false, + pageBuilder: (context, animation, secondaryAnimation) { + return _SingleImageViewer( + imageUrl: photo.originalUrl ?? '', + ); + }, + transitionsBuilder: (context, animation, secondaryAnimation, child) { + return FadeTransition(opacity: animation, child: child); + }, + ), + ); + } +} + +/// 티저용 이미지 뷰어 (스와이프, 인디케이터 있음) +class _TeaserImageViewer extends StatefulWidget { + final List images; + final int initialIndex; + + const _TeaserImageViewer({ + required this.images, + required this.initialIndex, + }); + + @override + State<_TeaserImageViewer> createState() => _TeaserImageViewerState(); +} + +class _TeaserImageViewerState extends State<_TeaserImageViewer> { + late PageController _pageController; + late int _currentIndex; + final Set _preloadedIndices = {}; + + @override + void initState() { + super.initState(); + _currentIndex = widget.initialIndex; + _pageController = PageController(initialPage: widget.initialIndex); + // 초기 로드 시 주변 이미지 프리로드 + WidgetsBinding.instance.addPostFrameCallback((_) { + _preloadAdjacentImages(_currentIndex); + }); + } + + @override + void dispose() { + _pageController.dispose(); + super.dispose(); + } + + /// 주변 이미지 프리로드 (좌우 2장씩) + void _preloadAdjacentImages(int index) { + for (int i = index - 2; i <= index + 2; i++) { + if (i >= 0 && i < widget.images.length && !_preloadedIndices.contains(i)) { + final url = widget.images[i]; + if (url.isNotEmpty) { + _preloadedIndices.add(i); + precacheImage( + CachedNetworkImageProvider(url), + context, + ); + } + } + } + } + + /// 이미지 다운로드 (시스템 다운로드 매니저 사용) + Future _downloadImage() async { + final imageUrl = widget.images[_currentIndex]; + if (imageUrl.isEmpty) return; + + final taskId = await downloadImage(imageUrl); + if (taskId != null && mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('다운로드를 시작합니다'), + duration: Duration(seconds: 2), + ), + ); + } + } + + @override + Widget build(BuildContext context) { + final bottomPadding = MediaQuery.of(context).padding.bottom; + final topPadding = MediaQuery.of(context).padding.top; + + return AnnotatedRegion( + value: SystemUiOverlayStyle.light, + child: Scaffold( + backgroundColor: Colors.black, + body: Stack( + children: [ + // 이미지 갤러리 (핀치줌 지원) + PhotoViewGallery.builder( + pageController: _pageController, + itemCount: widget.images.length, + onPageChanged: (index) { + setState(() => _currentIndex = index); + _preloadAdjacentImages(index); + }, + backgroundDecoration: const BoxDecoration(color: Colors.black), + builder: (context, index) { + final imageUrl = widget.images[index]; + if (imageUrl.isEmpty) { + return PhotoViewGalleryPageOptions.customChild( + child: const Center( + child: Icon( + LucideIcons.imageOff, + color: Colors.white54, + size: 64, + ), + ), + ); + } + return PhotoViewGalleryPageOptions( + imageProvider: CachedNetworkImageProvider(imageUrl), + minScale: PhotoViewComputedScale.contained, + maxScale: PhotoViewComputedScale.covered * 3, + initialScale: PhotoViewComputedScale.contained, + heroAttributes: PhotoViewHeroAttributes(tag: 'teaser_$index'), + ); + }, + loadingBuilder: (context, event) => const Center( + child: CircularProgressIndicator( + color: Colors.white54, + strokeWidth: 2, + ), + ), + ), + // 상단 헤더 - 3등분 + Positioned( + top: topPadding + 8, + left: 0, + right: 0, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Row( + children: [ + // 왼쪽: 닫기 버튼 + Expanded( + child: Align( + alignment: Alignment.centerLeft, + child: GestureDetector( + onTap: () => Navigator.pop(context), + child: const Padding( + padding: EdgeInsets.all(4), + child: Icon(LucideIcons.x, color: Colors.white70, size: 24), + ), + ), + ), + ), + // 가운데: 페이지 번호 + if (widget.images.length > 1) + Text( + '${_currentIndex + 1} / ${widget.images.length}', + style: const TextStyle( + color: Colors.white70, + fontSize: 14, + fontFeatures: [FontFeature.tabularFigures()], + ), + ), + // 오른쪽: 다운로드 버튼 + Expanded( + child: Align( + alignment: Alignment.centerRight, + child: GestureDetector( + onTap: _downloadImage, + child: const Padding( + padding: EdgeInsets.all(4), + child: Icon(LucideIcons.download, color: Colors.white70, size: 22), + ), + ), + ), + ), + ], + ), + ), + ), + // 하단 인디케이터 + if (widget.images.length > 1) + Positioned( + bottom: bottomPadding + 16, + left: 0, + right: 0, + child: _SlidingIndicator( + count: widget.images.length, + currentIndex: _currentIndex, + onTap: (index) { + _pageController.animateToPage( + index, + duration: const Duration(milliseconds: 300), + curve: Curves.easeOut, + ); + }, + ), + ), + ], + ), + ), + ); + } +} + +/// 컨셉 포토용 이미지 뷰어 (단일 이미지, 스와이프 없음) +class _SingleImageViewer extends StatelessWidget { + final String imageUrl; + + const _SingleImageViewer({required this.imageUrl}); + + /// 이미지 다운로드 (시스템 다운로드 매니저 사용) + Future _downloadImage(BuildContext context) async { + if (imageUrl.isEmpty) return; + + final taskId = await downloadImage(imageUrl); + if (taskId != null && context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('다운로드를 시작합니다'), + duration: Duration(seconds: 2), + ), + ); + } + } + + @override + Widget build(BuildContext context) { + final topPadding = MediaQuery.of(context).padding.top; + + return AnnotatedRegion( + value: SystemUiOverlayStyle.light, + child: Scaffold( + backgroundColor: Colors.black, + body: Stack( + children: [ + // 이미지 (핀치줌 - 전체 화면으로 확대 가능) + imageUrl.isNotEmpty + ? PhotoView( + imageProvider: CachedNetworkImageProvider(imageUrl), + minScale: PhotoViewComputedScale.contained, + maxScale: PhotoViewComputedScale.covered * 3, + initialScale: PhotoViewComputedScale.contained, + backgroundDecoration: const BoxDecoration(color: Colors.black), + loadingBuilder: (context, event) => const Center( + child: CircularProgressIndicator( + color: Colors.white54, + strokeWidth: 2, + ), + ), + errorBuilder: (context, error, stackTrace) => const Center( + child: Icon( + LucideIcons.imageOff, + color: Colors.white54, + size: 64, + ), + ), + ) + : const Center( + child: Icon( + LucideIcons.imageOff, + color: Colors.white54, + size: 64, + ), + ), + // 상단 헤더 + Positioned( + top: topPadding + 8, + left: 0, + right: 0, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Row( + children: [ + // 왼쪽: 닫기 버튼 + Expanded( + child: Align( + alignment: Alignment.centerLeft, + child: GestureDetector( + onTap: () => Navigator.pop(context), + child: const Padding( + padding: EdgeInsets.all(4), + child: Icon(LucideIcons.x, color: Colors.white70, size: 24), + ), + ), + ), + ), + // 오른쪽: 다운로드 버튼 + Expanded( + child: Align( + alignment: Alignment.centerRight, + child: GestureDetector( + onTap: () => _downloadImage(context), + child: const Padding( + padding: EdgeInsets.all(4), + child: Icon(LucideIcons.download, color: Colors.white70, size: 22), + ), + ), + ), + ), + ], + ), + ), + ), + ], + ), + ), + ); + } +} + +/// 슬라이딩 인디케이터 +class _SlidingIndicator extends StatelessWidget { + final int count; + final int currentIndex; + final Function(int) onTap; + + const _SlidingIndicator({ + required this.count, + required this.currentIndex, + required this.onTap, + }); + + @override + Widget build(BuildContext context) { + const double width = 120; + const double dotSpacing = 18; + const double activeDotSize = 12; + const double inactiveDotSize = 10; + + final double halfWidth = width / 2; + final double translateX = -(currentIndex * dotSpacing) + halfWidth - (activeDotSize / 2); + + return Center( + child: SizedBox( + width: width, + height: 20, + child: ShaderMask( + shaderCallback: (Rect bounds) { + return const LinearGradient( + colors: [ + Colors.transparent, + Colors.white, + Colors.white, + Colors.transparent, + ], + stops: [0.0, 0.15, 0.85, 1.0], + ).createShader(bounds); + }, + blendMode: BlendMode.dstIn, + child: Stack( + children: [ + // 슬라이딩 점들 + AnimatedPositioned( + duration: const Duration(milliseconds: 300), + curve: Curves.easeOutCubic, + left: translateX, + top: 0, + bottom: 0, + child: Row( + mainAxisSize: MainAxisSize.min, + children: List.generate(count, (index) { + final isActive = index == currentIndex; + return GestureDetector( + onTap: () => onTap(index), + child: Container( + width: dotSpacing, + alignment: Alignment.center, + child: AnimatedContainer( + duration: const Duration(milliseconds: 300), + width: isActive ? activeDotSize : inactiveDotSize, + height: isActive ? activeDotSize : inactiveDotSize, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: isActive + ? Colors.white + : Colors.white.withValues(alpha: 0.4), + ), + ), + ), + ); + }), + ), + ), + ], + ), + ), + ), + ); + } +} + +/// 앨범 소개 내용 +class _DescriptionContent extends StatelessWidget { + final String description; + + const _DescriptionContent({required this.description}); + + @override + Widget build(BuildContext context) { + final bottomPadding = MediaQuery.of(context).padding.bottom; + + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + // 헤더 (고정) + Padding( + padding: const EdgeInsets.fromLTRB(20, 16, 12, 12), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + const Text( + '앨범 소개', + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + ), + ), + GestureDetector( + onTap: () => Navigator.pop(context), + child: const Padding( + padding: EdgeInsets.all(8), + child: Icon(LucideIcons.x, size: 22), + ), + ), + ], + ), + ), + // 구분선 + Container( + height: 0.5, + color: AppColors.divider, + ), + // 내용 (스크롤) + Flexible( + child: SingleChildScrollView( + controller: ModalScrollController.of(context), + padding: EdgeInsets.fromLTRB(20, 20, 20, 20 + bottomPadding), + child: Text( + description, + style: const TextStyle( + fontSize: 14, + height: 1.8, + color: AppColors.textSecondary, + ), + textAlign: TextAlign.justify, + ), + ), + ), + ], + ); + } +} diff --git a/app/lib/views/album/album_view.dart b/app/lib/views/album/album_view.dart index ca9dc53..d4cb55f 100644 --- a/app/lib/views/album/album_view.dart +++ b/app/lib/views/album/album_view.dart @@ -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 createState() => _AlbumViewState(); +} + +class _AlbumViewState extends State { + late Future> _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>( + 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 _opacityAnimation; + late Animation _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(begin: 0.0, end: 1.0).animate( + CurvedAnimation(parent: _controller, curve: Curves.easeOut), + ); + + _slideAnimation = Tween( + 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, - ), - ), - ], + ), ), ); } diff --git a/app/lib/views/main_shell.dart b/app/lib/views/main_shell.dart index a11d120..2480c26 100644 --- a/app/lib/views/main_shell.dart +++ b/app/lib/views/main_shell.dart @@ -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 createState() => _MainShellState(); +} + +class _MainShellState extends State { + DateTime? _lastBackPressed; + + /// 뒤로가기 처리 - 두 번 눌러서 종료 + Future _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(), ); } diff --git a/app/pubspec.lock b/app/pubspec.lock index 01dc705..27fba4e 100644 --- a/app/pubspec.lock +++ b/app/pubspec.lock @@ -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: diff --git a/app/pubspec.yaml b/app/pubspec.yaml index 6a9917c..9273359 100644 --- a/app/pubspec.yaml +++ b/app/pubspec.yaml @@ -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: diff --git a/app/windows/flutter/generated_plugin_registrant.cc b/app/windows/flutter/generated_plugin_registrant.cc index 4f78848..a0d0bbe 100644 --- a/app/windows/flutter/generated_plugin_registrant.cc +++ b/app/windows/flutter/generated_plugin_registrant.cc @@ -6,9 +6,12 @@ #include "generated_plugin_registrant.h" +#include #include void RegisterPlugins(flutter::PluginRegistry* registry) { + PermissionHandlerWindowsPluginRegisterWithRegistrar( + registry->GetRegistrarForPlugin("PermissionHandlerWindowsPlugin")); UrlLauncherWindowsRegisterWithRegistrar( registry->GetRegistrarForPlugin("UrlLauncherWindows")); } diff --git a/app/windows/flutter/generated_plugins.cmake b/app/windows/flutter/generated_plugins.cmake index 88b22e5..c20a586 100644 --- a/app/windows/flutter/generated_plugins.cmake +++ b/app/windows/flutter/generated_plugins.cmake @@ -3,6 +3,7 @@ # list(APPEND FLUTTER_PLUGIN_LIST + permission_handler_windows url_launcher_windows ) diff --git a/app_expo_backup b/app_expo_backup deleted file mode 160000 index bf6b7f7..0000000 --- a/app_expo_backup +++ /dev/null @@ -1 +0,0 @@ -Subproject commit bf6b7f76369b47437a3047615334b19f56037cba