From 168da7d61e852e4ca6915e7dcd88710beaa5030e Mon Sep 17 00:00:00 2001 From: caadiq Date: Mon, 12 Jan 2026 22:27:46 +0900 Subject: [PATCH] =?UTF-8?q?Flutter=20=EC=95=B1:=20MVCS=20=EC=95=84?= =?UTF-8?q?=ED=82=A4=ED=85=8D=EC=B2=98=20+=20=ED=88=B4=EB=B0=94/=EB=B0=94?= =?UTF-8?q?=ED=85=80=20=EB=84=A4=EB=B9=84=EA=B2=8C=EC=9D=B4=EC=85=98=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/android/app/build.gradle.kts | 4 +- .../fromis9/unofficial}/MainActivity.kt | 2 +- app/lib/core/constants.dart | 53 ++++++ app/lib/core/router.dart | 51 ++++++ app/lib/main.dart | 160 ++++++------------ app/lib/views/album/album_view.dart | 42 +++++ app/lib/views/home/home_view.dart | 42 +++++ app/lib/views/main_shell.dart | 155 +++++++++++++++++ app/lib/views/members/members_view.dart | 42 +++++ app/lib/views/schedule/schedule_view.dart | 42 +++++ app/pubspec.lock | 16 +- app/pubspec.yaml | 2 +- 12 files changed, 491 insertions(+), 120 deletions(-) rename app/android/app/src/main/kotlin/{co/kr/caadiq/fromis9 => com/caadiq/fromis9/unofficial}/MainActivity.kt (70%) create mode 100644 app/lib/core/constants.dart create mode 100644 app/lib/core/router.dart create mode 100644 app/lib/views/album/album_view.dart create mode 100644 app/lib/views/home/home_view.dart create mode 100644 app/lib/views/main_shell.dart create mode 100644 app/lib/views/members/members_view.dart create mode 100644 app/lib/views/schedule/schedule_view.dart diff --git a/app/android/app/build.gradle.kts b/app/android/app/build.gradle.kts index 762132f..1ffd384 100644 --- a/app/android/app/build.gradle.kts +++ b/app/android/app/build.gradle.kts @@ -6,7 +6,7 @@ plugins { } android { - namespace = "co.kr.caadiq.fromis9" + namespace = "com.caadiq.fromis9.unofficial" compileSdk = flutter.compileSdkVersion ndkVersion = flutter.ndkVersion @@ -21,7 +21,7 @@ android { defaultConfig { // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html). - applicationId = "co.kr.caadiq.fromis9" + applicationId = "com.caadiq.fromis9.unofficial" // You can update the following values to match your application needs. // For more information, see: https://flutter.dev/to/review-gradle-config. minSdk = flutter.minSdkVersion diff --git a/app/android/app/src/main/kotlin/co/kr/caadiq/fromis9/MainActivity.kt b/app/android/app/src/main/kotlin/com/caadiq/fromis9/unofficial/MainActivity.kt similarity index 70% rename from app/android/app/src/main/kotlin/co/kr/caadiq/fromis9/MainActivity.kt rename to app/android/app/src/main/kotlin/com/caadiq/fromis9/unofficial/MainActivity.kt index e806bb3..09a33ee 100644 --- a/app/android/app/src/main/kotlin/co/kr/caadiq/fromis9/MainActivity.kt +++ b/app/android/app/src/main/kotlin/com/caadiq/fromis9/unofficial/MainActivity.kt @@ -1,4 +1,4 @@ -package co.kr.caadiq.fromis9 +package com.caadiq.fromis9.unofficial import io.flutter.embedding.android.FlutterActivity diff --git a/app/lib/core/constants.dart b/app/lib/core/constants.dart new file mode 100644 index 0000000..73b541a --- /dev/null +++ b/app/lib/core/constants.dart @@ -0,0 +1,53 @@ +/// 앱 전역 상수 정의 +library; + +import 'package:flutter/material.dart'; + +/// API 기본 URL +const String apiBaseUrl = 'https://fromis9.caadiq.co.kr/api'; + +/// 앱 테마 색상 +class AppColors { + // Primary 색상 (웹과 동일) + static const Color primary = Color(0xFF4B8B3B); + static const Color primaryLight = Color(0xFF6BA85A); + static const Color primaryDark = Color(0xFF3A6E2D); + + // 배경 색상 + static const Color background = Color(0xFFFAFAFA); + static const Color surface = Colors.white; + + // 텍스트 색상 + static const Color textPrimary = Color(0xFF1A1A1A); + static const Color textSecondary = Color(0xFF6B7280); + static const Color textTertiary = Color(0xFF9CA3AF); + + // 테두리 색상 + static const Color border = Color(0xFFE5E7EB); + static const Color divider = Color(0xFFF3F4F6); +} + +/// 앱 텍스트 스타일 +class AppTextStyles { + static const TextStyle heading1 = TextStyle( + fontSize: 24, + fontWeight: FontWeight.bold, + color: AppColors.textPrimary, + ); + + static const TextStyle heading2 = TextStyle( + fontSize: 20, + fontWeight: FontWeight.bold, + color: AppColors.textPrimary, + ); + + static const TextStyle body = TextStyle( + fontSize: 14, + color: AppColors.textPrimary, + ); + + static const TextStyle caption = TextStyle( + fontSize: 12, + color: AppColors.textSecondary, + ); +} diff --git a/app/lib/core/router.dart b/app/lib/core/router.dart new file mode 100644 index 0000000..9b843f4 --- /dev/null +++ b/app/lib/core/router.dart @@ -0,0 +1,51 @@ +/// 앱 라우터 설정 +library; + +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; +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/schedule/schedule_view.dart'; + +/// 네비게이션 키 +final GlobalKey rootNavigatorKey = GlobalKey(); + +/// 앱 라우터 설정 +final GoRouter appRouter = GoRouter( + navigatorKey: rootNavigatorKey, + initialLocation: '/', + routes: [ + // 메인 셸 (바텀 네비게이션) + ShellRoute( + builder: (context, state, child) => MainShell(child: child), + routes: [ + GoRoute( + path: '/', + pageBuilder: (context, state) => const NoTransitionPage( + child: HomeView(), + ), + ), + GoRoute( + path: '/members', + pageBuilder: (context, state) => const NoTransitionPage( + child: MembersView(), + ), + ), + GoRoute( + path: '/album', + pageBuilder: (context, state) => const NoTransitionPage( + child: AlbumView(), + ), + ), + GoRoute( + path: '/schedule', + pageBuilder: (context, state) => const NoTransitionPage( + child: ScheduleView(), + ), + ), + ], + ), + ], +); diff --git a/app/lib/main.dart b/app/lib/main.dart index 244a702..605bc80 100644 --- a/app/lib/main.dart +++ b/app/lib/main.dart @@ -1,122 +1,66 @@ +/// fromis_9 Unofficial App +/// +/// MVCS 아키텍처: +/// - Models: 데이터 모델 +/// - Views: UI 화면 +/// - Controllers: 비즈니스 로직 (Riverpod) +/// - Services: API 통신 +library; + import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'core/router.dart'; +import 'core/constants.dart'; void main() { - runApp(const MyApp()); + WidgetsFlutterBinding.ensureInitialized(); + + // 상태바 스타일 설정 + SystemChrome.setSystemUIOverlayStyle( + const SystemUiOverlayStyle( + statusBarColor: Colors.transparent, + statusBarIconBrightness: Brightness.dark, + ), + ); + + runApp( + const ProviderScope( + child: Fromis9App(), + ), + ); } -class MyApp extends StatelessWidget { - const MyApp({super.key}); +class Fromis9App extends StatelessWidget { + const Fromis9App({super.key}); - // This widget is the root of your application. @override Widget build(BuildContext context) { - return MaterialApp( - title: 'Flutter Demo', + return MaterialApp.router( + title: 'fromis_9', + debugShowCheckedModeBanner: false, theme: ThemeData( - // This is the theme of your application. - // - // TRY THIS: Try running your application with "flutter run". You'll see - // the application has a purple toolbar. Then, without quitting the app, - // try changing the seedColor in the colorScheme below to Colors.green - // and then invoke "hot reload" (save your changes or press the "hot - // reload" button in a Flutter-supported IDE, or press "r" if you used - // the command line to start the app). - // - // Notice that the counter didn't reset back to zero; the application - // state is not lost during the reload. To reset the state, use hot - // restart instead. - // - // This works for code too, not just values: Most code changes can be - // tested with just a hot reload. - colorScheme: .fromSeed(seedColor: Colors.deepPurple), - ), - home: const MyHomePage(title: 'Flutter Demo Home Page'), - ); - } -} - -class MyHomePage extends StatefulWidget { - const MyHomePage({super.key, required this.title}); - - // This widget is the home page of your application. It is stateful, meaning - // that it has a State object (defined below) that contains fields that affect - // how it looks. - - // This class is the configuration for the state. It holds the values (in this - // case the title) provided by the parent (in this case the App widget) and - // used by the build method of the State. Fields in a Widget subclass are - // always marked "final". - - final String title; - - @override - State createState() => _MyHomePageState(); -} - -class _MyHomePageState extends State { - int _counter = 0; - - void _incrementCounter() { - setState(() { - // This call to setState tells the Flutter framework that something has - // changed in this State, which causes it to rerun the build method below - // so that the display can reflect the updated values. If we changed - // _counter without calling setState(), then the build method would not be - // called again, and so nothing would appear to happen. - _counter++; - }); - } - - @override - Widget build(BuildContext context) { - // This method is rerun every time setState is called, for instance as done - // by the _incrementCounter method above. - // - // The Flutter framework has been optimized to make rerunning build methods - // fast, so that you can just rebuild anything that needs updating rather - // than having to individually change instances of widgets. - return Scaffold( - appBar: AppBar( - // TRY THIS: Try changing the color here to a specific color (to - // Colors.amber, perhaps?) and trigger a hot reload to see the AppBar - // change color while the other colors stay the same. - backgroundColor: Theme.of(context).colorScheme.inversePrimary, - // Here we take the value from the MyHomePage object that was created by - // the App.build method, and use it to set our appbar title. - title: Text(widget.title), - ), - body: Center( - // Center is a layout widget. It takes a single child and positions it - // in the middle of the parent. - child: Column( - // Column is also a layout widget. It takes a list of children and - // arranges them vertically. By default, it sizes itself to fit its - // children horizontally, and tries to be as tall as its parent. - // - // Column has various properties to control how it sizes itself and - // how it positions its children. Here we use mainAxisAlignment to - // center the children vertically; the main axis here is the vertical - // axis because Columns are vertical (the cross axis would be - // horizontal). - // - // TRY THIS: Invoke "debug painting" (choose the "Toggle Debug Paint" - // action in the IDE, or press "p" in the console), to see the - // wireframe for each widget. - mainAxisAlignment: .center, - children: [ - const Text('You have pushed the button this many times:'), - Text( - '$_counter', - style: Theme.of(context).textTheme.headlineMedium, - ), - ], + useMaterial3: true, + colorScheme: ColorScheme.fromSeed( + seedColor: AppColors.primary, + brightness: Brightness.light, ), + scaffoldBackgroundColor: AppColors.background, + appBarTheme: const AppBarTheme( + backgroundColor: Colors.white, + foregroundColor: AppColors.textPrimary, + elevation: 0, + scrolledUnderElevation: 1, + centerTitle: true, + titleTextStyle: TextStyle( + color: AppColors.primary, + fontSize: 20, + fontWeight: FontWeight.bold, + ), + ), + fontFamily: 'Pretendard', ), - floatingActionButton: FloatingActionButton( - onPressed: _incrementCounter, - tooltip: 'Increment', - child: const Icon(Icons.add), - ), + routerConfig: appRouter, ); } } diff --git a/app/lib/views/album/album_view.dart b/app/lib/views/album/album_view.dart new file mode 100644 index 0000000..ca9dc53 --- /dev/null +++ b/app/lib/views/album/album_view.dart @@ -0,0 +1,42 @@ +/// 앨범 화면 +library; + +import 'package:flutter/material.dart'; +import '../../core/constants.dart'; + +class AlbumView extends StatelessWidget { + const AlbumView({super.key}); + + @override + Widget build(BuildContext context) { + return const Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.album_outlined, + size: 64, + color: AppColors.textTertiary, + ), + SizedBox(height: 16), + Text( + '앨범', + style: TextStyle( + fontSize: 24, + fontWeight: FontWeight.bold, + color: AppColors.textSecondary, + ), + ), + SizedBox(height: 8), + Text( + '앨범 화면 준비 중', + style: TextStyle( + fontSize: 14, + color: AppColors.textTertiary, + ), + ), + ], + ), + ); + } +} diff --git a/app/lib/views/home/home_view.dart b/app/lib/views/home/home_view.dart new file mode 100644 index 0000000..458c9b7 --- /dev/null +++ b/app/lib/views/home/home_view.dart @@ -0,0 +1,42 @@ +/// 홈 화면 +library; + +import 'package:flutter/material.dart'; +import '../../core/constants.dart'; + +class HomeView extends StatelessWidget { + const HomeView({super.key}); + + @override + Widget build(BuildContext context) { + return const Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.home_outlined, + size: 64, + color: AppColors.textTertiary, + ), + SizedBox(height: 16), + Text( + '홈', + style: TextStyle( + fontSize: 24, + fontWeight: FontWeight.bold, + color: AppColors.textSecondary, + ), + ), + 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 new file mode 100644 index 0000000..82bc71a --- /dev/null +++ b/app/lib/views/main_shell.dart @@ -0,0 +1,155 @@ +/// 메인 셸 - 툴바 + 바텀 네비게이션 +library; + +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; +import 'package:lucide_icons/lucide_icons.dart'; +import '../core/constants.dart'; + +/// 메인 앱 셸 (툴바 + 바텀 네비게이션 + 콘텐츠) +class MainShell extends StatelessWidget { + final Widget child; + + const MainShell({super.key, required this.child}); + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: AppColors.background, + // 앱바 (툴바) + appBar: AppBar( + backgroundColor: Colors.white, + elevation: 0, + scrolledUnderElevation: 1, + centerTitle: true, + title: Text( + _getTitle(context), + style: const TextStyle( + color: AppColors.primary, + fontSize: 20, + fontWeight: FontWeight.bold, + ), + ), + ), + // 콘텐츠 + body: child, + // 바텀 네비게이션 + bottomNavigationBar: const _BottomNavBar(), + ); + } + + /// 현재 경로에 따른 타이틀 반환 + String _getTitle(BuildContext context) { + final location = GoRouterState.of(context).uri.path; + switch (location) { + case '/': + return 'fromis_9'; + case '/members': + return '멤버'; + case '/album': + return '앨범'; + case '/schedule': + return '일정'; + default: + return 'fromis_9'; + } + } +} + +/// 바텀 네비게이션 바 +class _BottomNavBar extends StatelessWidget { + const _BottomNavBar(); + + @override + Widget build(BuildContext context) { + final location = GoRouterState.of(context).uri.path; + + return Container( + decoration: const BoxDecoration( + color: Colors.white, + border: Border( + top: BorderSide(color: AppColors.border, width: 1), + ), + ), + child: SafeArea( + child: SizedBox( + height: 64, + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceAround, + children: [ + _NavItem( + icon: LucideIcons.home, + label: '홈', + isActive: location == '/', + onTap: () => context.go('/'), + ), + _NavItem( + icon: LucideIcons.users, + label: '멤버', + isActive: location == '/members', + onTap: () => context.go('/members'), + ), + _NavItem( + icon: LucideIcons.disc3, + label: '앨범', + isActive: location.startsWith('/album'), + onTap: () => context.go('/album'), + ), + _NavItem( + icon: LucideIcons.calendar, + label: '일정', + isActive: location.startsWith('/schedule'), + onTap: () => context.go('/schedule'), + ), + ], + ), + ), + ), + ); + } +} + +/// 네비게이션 아이템 +class _NavItem extends StatelessWidget { + final IconData icon; + final String label; + final bool isActive; + final VoidCallback onTap; + + const _NavItem({ + required this.icon, + required this.label, + required this.isActive, + required this.onTap, + }); + + @override + Widget build(BuildContext context) { + final color = isActive ? AppColors.primary : AppColors.textTertiary; + + return Expanded( + child: InkWell( + onTap: onTap, + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + icon, + size: 22, + color: color, + ), + const SizedBox(height: 4), + Text( + label, + style: TextStyle( + fontSize: 12, + fontWeight: isActive ? FontWeight.w600 : FontWeight.w500, + color: color, + ), + ), + ], + ), + ), + ); + } +} diff --git a/app/lib/views/members/members_view.dart b/app/lib/views/members/members_view.dart new file mode 100644 index 0000000..0f8048c --- /dev/null +++ b/app/lib/views/members/members_view.dart @@ -0,0 +1,42 @@ +/// 멤버 화면 +library; + +import 'package:flutter/material.dart'; +import '../../core/constants.dart'; + +class MembersView extends StatelessWidget { + const MembersView({super.key}); + + @override + Widget build(BuildContext context) { + return const Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.people_outline, + size: 64, + color: AppColors.textTertiary, + ), + SizedBox(height: 16), + Text( + '멤버', + style: TextStyle( + fontSize: 24, + fontWeight: FontWeight.bold, + color: AppColors.textSecondary, + ), + ), + SizedBox(height: 8), + Text( + '멤버 화면 준비 중', + style: TextStyle( + fontSize: 14, + color: AppColors.textTertiary, + ), + ), + ], + ), + ); + } +} diff --git a/app/lib/views/schedule/schedule_view.dart b/app/lib/views/schedule/schedule_view.dart new file mode 100644 index 0000000..5ab5413 --- /dev/null +++ b/app/lib/views/schedule/schedule_view.dart @@ -0,0 +1,42 @@ +/// 일정 화면 +library; + +import 'package:flutter/material.dart'; +import '../../core/constants.dart'; + +class ScheduleView extends StatelessWidget { + const ScheduleView({super.key}); + + @override + Widget build(BuildContext context) { + return const Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.calendar_today_outlined, + size: 64, + color: AppColors.textTertiary, + ), + SizedBox(height: 16), + Text( + '일정', + style: TextStyle( + fontSize: 24, + fontWeight: FontWeight.bold, + color: AppColors.textSecondary, + ), + ), + SizedBox(height: 8), + Text( + '일정 화면 준비 중', + style: TextStyle( + fontSize: 14, + color: AppColors.textTertiary, + ), + ), + ], + ), + ); + } +} diff --git a/app/pubspec.lock b/app/pubspec.lock index a86bb15..b596141 100644 --- a/app/pubspec.lock +++ b/app/pubspec.lock @@ -264,14 +264,6 @@ packages: url: "https://pub.dev" source: hosted version: "4.1.2" - image_gallery_saver: - dependency: "direct main" - description: - name: image_gallery_saver - sha256: "0aba74216a4d9b0561510cb968015d56b701ba1bd94aace26aacdd8ae5761816" - url: "https://pub.dev" - source: hosted - version: "2.0.3" intl: dependency: "direct main" description: @@ -336,6 +328,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.3.0" + lucide_icons: + dependency: "direct main" + description: + name: lucide_icons + sha256: ad24d0fd65707e48add30bebada7d90bff2a1bba0a72d6e9b19d44246b0e83c4 + url: "https://pub.dev" + source: hosted + version: "0.257.0" matcher: dependency: transitive description: diff --git a/app/pubspec.yaml b/app/pubspec.yaml index 7d7bdf3..e687f15 100644 --- a/app/pubspec.yaml +++ b/app/pubspec.yaml @@ -40,8 +40,8 @@ dependencies: flutter_riverpod: ^3.1.0 intl: ^0.20.2 photo_view: ^0.15.0 - image_gallery_saver: ^2.0.3 path_provider: ^2.1.5 + lucide_icons: ^0.257.0 dev_dependencies: flutter_test: