diff --git a/.env b/.env new file mode 100644 index 0000000..35121e3 --- /dev/null +++ b/.env @@ -0,0 +1,12 @@ +# Environment Configuration +ENVIRONMENT=development + +# API Configuration +API_BASE_URL=https://api.example.com + +# Feature Flags +ENABLE_LOGGING=true +ENABLE_AR_FEATURES=true + +# Debug Options +DEBUG_MODE=true diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..6b4d65f --- /dev/null +++ b/.env.example @@ -0,0 +1,15 @@ +# Environment Configuration Example +# Copy this file to .env and update the values as needed + +# Environment: development|production +ENVIRONMENT=development + +# API Configuration +API_BASE_URL=https://api.example.com + +# Feature Flags +ENABLE_LOGGING=true +ENABLE_AR_FEATURES=true + +# Debug Options (only for development) +DEBUG_MODE=true diff --git a/.github/workflows/flutter_ci.yml b/.github/workflows/flutter_ci.yml new file mode 100644 index 0000000..a460977 --- /dev/null +++ b/.github/workflows/flutter_ci.yml @@ -0,0 +1,82 @@ +name: Flutter CI + +on: + push: + branches: [ main, develop ] + pull_request: + branches: [ main, develop ] + +jobs: + analyze: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - name: Setup Flutter + uses: subosito/flutter-action@v2 + with: + flutter-version: '3.16.0' + channel: 'stable' + + - name: Install dependencies + run: flutter pub get + + - name: Generate code + run: | + flutter packages pub run build_runner build --delete-conflicting-outputs + + - name: Analyze code + run: flutter analyze + + test: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - name: Setup Flutter + uses: subosito/flutter-action@v2 + with: + flutter-version: '3.16.0' + channel: 'stable' + + - name: Install dependencies + run: flutter pub get + + - name: Generate code + run: | + flutter packages pub run build_runner build --delete-conflicting-outputs + + - name: Run tests + run: flutter test --coverage + + - name: Upload coverage to Codecov + uses: codecov/codecov-action@v3 + with: + file: coverage/lcov.info + + build: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - name: Setup Flutter + uses: subosito/flutter-action@v2 + with: + flutter-version: '3.16.0' + channel: 'stable' + + - name: Install dependencies + run: flutter pub get + + - name: Generate code + run: | + flutter packages pub run build_runner build --delete-conflicting-outputs + + - name: Build APK + run: flutter build apk --debug + + - name: Build AAB + run: flutter build appbundle --debug diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..8b72305 --- /dev/null +++ b/.gitignore @@ -0,0 +1,60 @@ +# Miscellaneous +*.class +*.log +*.pyc +*.swp +.DS_Store +.atom/ +.buildlog/ +.history +.svn/ +migrate_working_dir/ + +# IntelliJ related +*.iml +*.ipr +*.iws +.idea/ + +# The .vscode folder contains launch configuration and tasks you configure in +# VS Code which you may wish to be included in version control, so this line +# is commented out by default. +#.vscode/ + +# Flutter/Dart/Pub related +**/doc/api/ +**/ios/Flutter/.last_build_id +.dart_tool/ +.flutter-plugins +.flutter-plugins-dependencies +.packages +.pub-cache/ +.pub/ +/build/ + +# Symbolication related +app.*.symbols + +# Obfuscation related +app.*.map.json + +# Android Studio will place build artifacts here +/android/app/debug +/android/app/profile +/android/app/release + +# Environment files +.env.local +.env.production + +# Coverage reports +coverage/ + +# Generated files +lib/generated/ +**/*.g.dart +**/*.freezed.dart + +# Temporary files +*.tmp +*.temp diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..3c5504e --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2024 Flutter AR App + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..99e7fda --- /dev/null +++ b/README.md @@ -0,0 +1,230 @@ +# Flutter AR App + +A comprehensive Flutter application with Augmented Reality capabilities, featuring a layered architecture, internationalization, and modern development practices. + +## 🚀 Features + +- **Augmented Reality**: ARCore integration for Android devices +- **Media Management**: Photo and video capture with gallery functionality +- **Internationalization**: Support for English and Russian languages +- **Responsive Design**: Adaptive UI for different screen sizes +- **Modern Architecture**: Clean architecture with dependency injection +- **State Management**: Riverpod for reactive state management +- **Navigation**: Go Router for type-safe navigation +- **Environment Configuration**: Development and production environments + +## 📱 Architecture + +### Project Structure + +``` +lib/ +├── core/ # Core functionality +│ ├── config/ # App configuration +│ ├── di/ # Dependency injection +│ ├── l10n/ # Internationalization +│ ├── router/ # App routing +│ └── theme/ # App theming +├── data/ # Data layer (repositories, models) +├── domain/ # Business logic (use cases, entities) +├── presentation/ # UI layer +│ ├── pages/ # Screen widgets +│ │ ├── ar/ # AR features +│ │ ├── home/ # Home screen +│ │ ├── media/ # Media management +│ │ ├── onboarding/ # App introduction +│ │ ├── settings/ # App settings +│ │ └── splash/ # Splash screen +│ ├── providers/ # Riverpod providers +│ └── widgets/ # Reusable UI components +└── main.dart # App entry point +``` + +### Technology Stack + +- **Framework**: Flutter 3.16.0+ +- **Language**: Dart 3.0+ +- **State Management**: Riverpod +- **Dependency Injection**: GetIt + Injectable +- **Navigation**: Go Router +- **Networking**: Dio +- **Local Storage**: Flutter Secure Storage, Shared Preferences +- **AR**: ARCore Plugin +- **Media**: Video Player, Camera +- **Internationalization**: Flutter Intl +- **Responsive Design**: Flutter ScreenUtil + +## 🛠 Development Setup + +### Prerequisites + +- Flutter SDK 3.16.0 or higher +- Dart SDK 3.0.0 or higher +- Android Studio or VS Code with Flutter extensions +- Android device/emulator with ARCore support + +### Installation + +1. Clone the repository: + ```bash + git clone https://github.com/your-username/flutter-ar-app.git + cd flutter-ar-app + ``` + +2. Install dependencies: + ```bash + flutter pub get + ``` + +3. Generate code: + ```bash + flutter packages pub run build_runner build --delete-conflicting-outputs + ``` + +4. Run the app: + ```bash + flutter run + ``` + +### Environment Configuration + +Copy `.env.example` to `.env` and configure your environment variables: + +```bash +cp .env.example .env +``` + +Available environment variables: +- `ENVIRONMENT`: development|production +- `API_BASE_URL`: Base URL for API calls +- `ENABLE_LOGGING`: Enable debug logging +- `ENABLE_AR_FEATURES`: Enable AR functionality + +## 🧪 Testing + +Run all tests: +```bash +flutter test +``` + +Run tests with coverage: +```bash +flutter test --coverage +``` + +## 📦 Build + +### Android Debug APK +```bash +flutter build apk --debug +``` + +### Android Release APK +```bash +flutter build apk --release +``` + +### Android App Bundle +```bash +flutter build appbundle --release +``` + +## 🔧 Code Generation + +The project uses code generation for: +- Dependency injection configuration +- JSON serialization + +Run code generation when making changes: +```bash +flutter packages pub run build_runner build --delete-conflicting-outputs +``` + +## 📱 Supported Features + +### AR Features +- ARCore integration for Android +- Camera permission handling +- AR object placement (placeholder) +- AR settings (placeholder) + +### Media Features +- Camera capture +- Video recording +- Gallery browsing +- Media management + +### Settings +- Language selection (English/Russian) +- Theme configuration +- Cache management +- Privacy settings + +## 🌍 Internationalization + +The app supports: +- English (en) +- Russian (ru) + +Localization files are located in `lib/l10n/`: +- `app_en.arb` - English translations +- `app_ru.arb` - Russian translations + +## 🔄 CI/CD + +The project includes GitHub Actions workflows for: +- Code analysis (`flutter analyze`) +- Unit testing (`flutter test`) +- APK/AAB building + +Workflows are triggered on: +- Push to main/develop branches +- Pull requests to main/develop branches + +## 📝 Code Style + +The project follows: +- Flutter official style guide +- `flutter_lints` for static analysis +- Clean Architecture principles +- SOLID principles + +## 🤝 Contributing + +1. Fork the repository +2. Create a feature branch +3. Make your changes +4. Run tests and ensure they pass +5. Run code analysis +6. Submit a pull request + +## 📄 License + +This project is licensed under the MIT License - see the LICENSE file for details. + +## 📞 Support + +For support and questions: +- Create an issue in the GitHub repository +- Check the documentation +- Review existing issues + +## 🗺 Roadmap + +- [ ] iOS AR support with ARKit +- [ ] Advanced AR features (object recognition, tracking) +- [ ] Cloud storage integration +- [ ] Social features +- [ ] Performance optimizations +- [ ] More language support + +## 📊 Requirements + +### Android +- **Minimum SDK**: 24 (Android 7.0) +- **Target SDK**: 34 (Android 14) +- **ARCore**: Compatible device required + +### iOS +- **Minimum iOS Version**: 12.0 (future support) +- **ARKit**: Compatible device required (future support) diff --git a/analysis_options.yaml b/analysis_options.yaml new file mode 100644 index 0000000..bad87d0 --- /dev/null +++ b/analysis_options.yaml @@ -0,0 +1,25 @@ +include: package:flutter_lints/flutter.yaml + +analyzer: + exclude: + - "**/*.g.dart" + - "**/*.freezed.dart" + - "**/generated/**" + + errors: + invalid_annotation_target: ignore + +linter: + rules: + # Additional linting rules + prefer_single_quotes: true + sort_constructors_first: true + sort_unnamed_constructors_first: true + always_declare_return_types: true + avoid_print: true + avoid_unnecessary_containers: true + prefer_const_constructors: true + prefer_const_declarations: true + prefer_const_literals_to_create_immutables: true + sized_box_for_whitespace: true + use_key_in_widget_constructors: true diff --git a/android/app/build.gradle b/android/app/build.gradle new file mode 100644 index 0000000..7bae6bd --- /dev/null +++ b/android/app/build.gradle @@ -0,0 +1,76 @@ +def localProperties = new Properties() +def localPropertiesFile = rootProject.file('local.properties') +if (localPropertiesFile.exists()) { + localPropertiesFile.withReader('UTF-8') { reader -> + localProperties.load(reader) + } +} + +def flutterRoot = localProperties.getProperty('flutter.sdk') +if (flutterRoot == null) { + throw new GradleException("Flutter SDK not found. Define location with flutter.sdk in the local.properties file.") +} + +def flutterVersionCode = localProperties.getProperty('flutter.versionCode') +if (flutterVersionCode == null) { + flutterVersionCode = '1' +} + +def flutterVersionName = localProperties.getProperty('flutter.versionName') +if (flutterVersionName == null) { + flutterVersionName = '1.0' +} + +apply plugin: 'com.android.application' +apply plugin: 'kotlin-android' +apply from: "$flutterRoot/packages/flutter_tools/gradle/flutter.gradle" + +android { + namespace "com.example.flutter_ar_app" + compileSdkVersion 34 + ndkVersion "25.1.8937393" + + compileOptions { + sourceCompatibility JavaVersion.VERSION_1_8 + targetCompatibility JavaVersion.VERSION_1_8 + } + + kotlinOptions { + jvmTarget = '1.8' + } + + sourceSets { + main.java.srcDirs += 'src/main/kotlin' + } + + defaultConfig { + applicationId "com.example.flutter_ar_app" + minSdkVersion 24 + targetSdkVersion 34 + versionCode flutterVersionCode.toInteger() + versionName flutterVersionName + multiDexEnabled true + } + + buildTypes { + release { + signingConfig signingConfigs.debug + } + } + + packagingOptions { + pickFirst '**/libc++_shared.so' + pickFirst '**/libjsc.so' + } +} + +flutter { + source '../..' +} + +dependencies { + implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version" + implementation 'androidx.multidex:multidex:2.0.1' + implementation 'com.google.ar:core:1.40.0' + implementation 'androidx.appcompat:appcompat:1.6.1' +} diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..9237e9d --- /dev/null +++ b/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,49 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/android/app/src/main/kotlin/com/example/flutter_ar_app/MainActivity.kt b/android/app/src/main/kotlin/com/example/flutter_ar_app/MainActivity.kt new file mode 100644 index 0000000..70d9d92 --- /dev/null +++ b/android/app/src/main/kotlin/com/example/flutter_ar_app/MainActivity.kt @@ -0,0 +1,6 @@ +package com.example.flutter_ar_app + +import io.flutter.embedding.android.FlutterActivity + +class MainActivity: FlutterActivity() { +} diff --git a/android/build.gradle b/android/build.gradle new file mode 100644 index 0000000..b478d78 --- /dev/null +++ b/android/build.gradle @@ -0,0 +1,30 @@ +buildscript { + ext.kotlin_version = '1.9.10' + repositories { + google() + mavenCentral() + } + + dependencies { + classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" + } +} + +allprojects { + repositories { + google() + mavenCentral() + } +} + +rootProject.buildDir = '../build' +subprojects { + project.buildDir = "${rootProject.buildDir}/${project.name}" +} +subprojects { + project.evaluationDependsOn(':app') +} + +tasks.register("clean", Delete) { + delete rootProject.buildDir +} diff --git a/android/gradle.properties b/android/gradle.properties new file mode 100644 index 0000000..2199d66 --- /dev/null +++ b/android/gradle.properties @@ -0,0 +1,4 @@ +org.gradle.jvmargs=-Xmx4G -XX:MaxMetaspaceSize=2G -XX:+HeapDumpOnOutOfMemoryError +android.useAndroidX=true +android.enableJetifier=true +android.enableR8.fullMode=false diff --git a/android/gradle/wrapper/gradle-wrapper.properties b/android/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..5e6b542 --- /dev/null +++ b/android/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,5 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.4-all.zip diff --git a/android/local.properties b/android/local.properties new file mode 100644 index 0000000..e56d27e --- /dev/null +++ b/android/local.properties @@ -0,0 +1 @@ +sdk.dir=/opt/flutter diff --git a/android/settings.gradle b/android/settings.gradle new file mode 100644 index 0000000..44e62bc --- /dev/null +++ b/android/settings.gradle @@ -0,0 +1,11 @@ +include ':app' + +def localPropertiesFile = new File(rootProject.projectDir, "local.properties") +def properties = new Properties() + +assert localPropertiesFile.exists() +localPropertiesFile.withReader("UTF-8") { reader -> properties.load(reader) } + +def flutterSdkPath = properties.getProperty("flutter.sdk") +assert flutterSdkPath != null, "flutter.sdk not set in local.properties" +apply from: "$flutterSdkPath/packages/flutter_tools/gradle/app_plugin_loader.gradle" diff --git a/assets/README.md b/assets/README.md new file mode 100644 index 0000000..d5e0986 --- /dev/null +++ b/assets/README.md @@ -0,0 +1,12 @@ +# Assets Directory + +This directory contains application assets such as: +- Images +- Animations +- Icons +- Fonts + +## Structure +- images/ - Application images +- animations/ - Lottie animations +- icons/ - Application icons diff --git a/lib/core/config/app_config.dart b/lib/core/config/app_config.dart new file mode 100644 index 0000000..22b64e2 --- /dev/null +++ b/lib/core/config/app_config.dart @@ -0,0 +1,28 @@ +import 'package:flutter_dotenv/flutter_dotenv.dart'; + +enum Environment { development, production } + +class AppConfig { + static late Environment _environment; + static late String _apiBaseUrl; + static late bool _enableLogging; + static late bool _enableArFeatures; + + static Environment get environment => _environment; + static String get apiBaseUrl => _apiBaseUrl; + static bool get enableLogging => _enableLogging; + static bool get enableArFeatures => _enableArFeatures; + static bool get isDevelopment => _environment == Environment.development; + static bool get isProduction => _environment == Environment.production; + + static Future initialize() async { + await dotenv.load(fileName: '.env'); + + final env = dotenv.env['ENVIRONMENT'] ?? 'development'; + _environment = env == 'production' ? Environment.production : Environment.development; + + _apiBaseUrl = dotenv.env['API_BASE_URL'] ?? 'https://api.example.com'; + _enableLogging = dotenv.env['ENABLE_LOGGING'] == 'true'; + _enableArFeatures = dotenv.env['ENABLE_AR_FEATURES'] == 'true'; + } +} diff --git a/lib/core/di/injection_container.dart b/lib/core/di/injection_container.dart new file mode 100644 index 0000000..c620e06 --- /dev/null +++ b/lib/core/di/injection_container.dart @@ -0,0 +1,24 @@ +import 'package:get_it/get_it.dart'; +import 'package:injectable/injectable.dart'; +import 'package:dio/dio.dart'; + +import 'injection_container.config.dart'; + +final getIt = GetIt.instance; + +@injectableInit +Future configureDependencies() async { + getIt.init(); +} + +@module +abstract class RegisterModule { + @singleton + Dio get dio => Dio( + BaseOptions( + connectTimeout: const Duration(seconds: 30), + receiveTimeout: const Duration(seconds: 30), + sendTimeout: const Duration(seconds: 30), + ), + ); +} diff --git a/lib/core/l10n/app_localizations.dart b/lib/core/l10n/app_localizations.dart new file mode 100644 index 0000000..76704e5 --- /dev/null +++ b/lib/core/l10n/app_localizations.dart @@ -0,0 +1,150 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_localizations/flutter_localizations.dart'; + +class AppLocalizations { + final Locale locale; + + AppLocalizations(this.locale); + + static const LocalizationsDelegate delegate = _AppLocalizationsDelegate(); + + static AppLocalizations? of(BuildContext context) { + return Localizations.of(context, AppLocalizations); + } + + static final Map> _localizedValues = { + 'en': { + 'appTitle': 'Flutter AR App', + 'home': 'Home', + 'ar': 'AR', + 'media': 'Media', + 'settings': 'Settings', + 'welcome': 'Welcome', + 'getStarted': 'Get Started', + 'next': 'Next', + 'skip': 'Skip', + 'finish': 'Finish', + 'loading': 'Loading...', + 'error': 'Error', + 'retry': 'Retry', + 'cancel': 'Cancel', + 'confirm': 'Confirm', + 'save': 'Save', + 'delete': 'Delete', + 'edit': 'Edit', + 'close': 'Close', + 'yes': 'Yes', + 'no': 'No', + 'ok': 'OK', + 'cameraPermission': 'Camera Permission', + 'cameraPermissionDenied': 'Camera permission is required for AR features', + 'grantPermission': 'Grant Permission', + 'arNotSupported': 'AR Not Supported', + 'arNotSupportedMessage': 'Your device does not support AR features', + 'networkError': 'Network Error', + 'networkErrorMessage': 'Please check your internet connection', + 'generalError': 'Something went wrong', + 'generalErrorMessage': 'An unexpected error occurred', + 'language': 'Language', + 'theme': 'Theme', + 'about': 'About', + 'version': 'Version', + 'privacy': 'Privacy Policy', + 'terms': 'Terms of Service', + }, + 'ru': { + 'appTitle': 'Flutter AR Приложение', + 'home': 'Главная', + 'ar': 'AR', + 'media': 'Медиа', + 'settings': 'Настройки', + 'welcome': 'Добро пожаловать', + 'getStarted': 'Начать', + 'next': 'Далее', + 'skip': 'Пропустить', + 'finish': 'Завершить', + 'loading': 'Загрузка...', + 'error': 'Ошибка', + 'retry': 'Повторить', + 'cancel': 'Отмена', + 'confirm': 'Подтвердить', + 'save': 'Сохранить', + 'delete': 'Удалить', + 'edit': 'Редактировать', + 'close': 'Закрыть', + 'yes': 'Да', + 'no': 'Нет', + 'ok': 'ОК', + 'cameraPermission': 'Разрешение камеры', + 'cameraPermissionDenied': 'Разрешение камеры требуется для AR функций', + 'grantPermission': 'Предоставить разрешение', + 'arNotSupported': 'AR не поддерживается', + 'arNotSupportedMessage': 'Ваше устройство не поддерживает AR функции', + 'networkError': 'Ошибка сети', + 'networkErrorMessage': 'Проверьте ваше интернет соединение', + 'generalError': 'Что-то пошло не так', + 'generalErrorMessage': 'Произошла непредвиденная ошибка', + 'language': 'Язык', + 'theme': 'Тема', + 'about': 'О приложении', + 'version': 'Версия', + 'privacy': 'Политика конфиденциальности', + 'terms': 'Условия использования', + }, + }; + + String get appTitle => _localizedValues[locale.languageCode]!['appTitle']!; + String get home => _localizedValues[locale.languageCode]!['home']!; + String get ar => _localizedValues[locale.languageCode]!['ar']!; + String get media => _localizedValues[locale.languageCode]!['media']!; + String get settings => _localizedValues[locale.languageCode]!['settings']!; + String get welcome => _localizedValues[locale.languageCode]!['welcome']!; + String get getStarted => _localizedValues[locale.languageCode]!['getStarted']!; + String get next => _localizedValues[locale.languageCode]!['next']!; + String get skip => _localizedValues[locale.languageCode]!['skip']!; + String get finish => _localizedValues[locale.languageCode]!['finish']!; + String get loading => _localizedValues[locale.languageCode]!['loading']!; + String get error => _localizedValues[locale.languageCode]!['error']!; + String get retry => _localizedValues[locale.languageCode]!['retry']!; + String get cancel => _localizedValues[locale.languageCode]!['cancel']!; + String get confirm => _localizedValues[locale.languageCode]!['confirm']!; + String get save => _localizedValues[locale.languageCode]!['save']!; + String get delete => _localizedValues[locale.languageCode]!['delete']!; + String get edit => _localizedValues[locale.languageCode]!['edit']!; + String get close => _localizedValues[locale.languageCode]!['close']!; + String get yes => _localizedValues[locale.languageCode]!['yes']!; + String get no => _localizedValues[locale.languageCode]!['no']!; + String get ok => _localizedValues[locale.languageCode]!['ok']!; + String get cameraPermission => _localizedValues[locale.languageCode]!['cameraPermission']!; + String get cameraPermissionDenied => _localizedValues[locale.languageCode]!['cameraPermissionDenied']!; + String get grantPermission => _localizedValues[locale.languageCode]!['grantPermission']!; + String get arNotSupported => _localizedValues[locale.languageCode]!['arNotSupported']!; + String get arNotSupportedMessage => _localizedValues[locale.languageCode]!['arNotSupportedMessage']!; + String get networkError => _localizedValues[locale.languageCode]!['networkError']!; + String get networkErrorMessage => _localizedValues[locale.languageCode]!['networkErrorMessage']!; + String get generalError => _localizedValues[locale.languageCode]!['generalError']!; + String get generalErrorMessage => _localizedValues[locale.languageCode]!['generalErrorMessage']!; + String get language => _localizedValues[locale.languageCode]!['language']!; + String get theme => _localizedValues[locale.languageCode]!['theme']!; + String get about => _localizedValues[locale.languageCode]!['about']!; + String get version => _localizedValues[locale.languageCode]!['version']!; + String get privacy => _localizedValues[locale.languageCode]!['privacy']!; + String get terms => _localizedValues[locale.languageCode]!['terms']!; +} + +class _AppLocalizationsDelegate extends LocalizationsDelegate { + const _AppLocalizationsDelegate(); + + @override + bool isSupported(Locale locale) { + return ['en', 'ru'].contains(locale.languageCode); + } + + @override + Future load(Locale locale) async { + return AppLocalizations(locale); + } + + @override + bool shouldReload(LocalizationsDelegate old) => false; +} diff --git a/lib/core/router/app_router.dart b/lib/core/router/app_router.dart new file mode 100644 index 0000000..a0b5752 --- /dev/null +++ b/lib/core/router/app_router.dart @@ -0,0 +1,50 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; + +import '../../presentation/pages/ar/ar_page.dart'; +import '../../presentation/pages/media/media_page.dart'; +import '../../presentation/pages/onboarding/onboarding_page.dart'; +import '../../presentation/pages/settings/settings_page.dart'; +import '../../presentation/pages/splash/splash_page.dart'; +import '../../presentation/pages/home/home_page.dart'; +import '../../presentation/widgets/navigation_shell.dart'; + +GoRouter createAppRouter() { + return GoRouter( + initialLocation: '/splash', + routes: [ + GoRoute( + path: '/splash', + builder: (context, state) => const SplashPage(), + ), + GoRoute( + path: '/onboarding', + builder: (context, state) => const OnboardingPage(), + ), + ShellRoute( + builder: (context, state, child) { + return NavigationShell(child: child); + }, + routes: [ + GoRoute( + path: '/home', + builder: (context, state) => const HomePage(), + ), + GoRoute( + path: '/ar', + builder: (context, state) => const ArPage(), + ), + GoRoute( + path: '/media', + builder: (context, state) => const MediaPage(), + ), + GoRoute( + path: '/settings', + builder: (context, state) => const SettingsPage(), + ), + ], + ), + ], + ); +} diff --git a/lib/core/theme/app_theme.dart b/lib/core/theme/app_theme.dart new file mode 100644 index 0000000..1439298 --- /dev/null +++ b/lib/core/theme/app_theme.dart @@ -0,0 +1,167 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +class AppTheme { + static const Color primaryColor = Color(0xFF2196F3); + static const Color secondaryColor = Color(0xFF03DAC6); + static const Color errorColor = Color(0xFFB00020); + static const Color surfaceColor = Color(0xFFFAFAFA); + static const Color backgroundColor = Color(0xFFFFFFFF); + + static const Color darkPrimaryColor = Color(0xFF90CAF9); + static const Color darkSecondaryColor = Color(0xFF03DAC6); + static const Color darkErrorColor = Color(0xFFCF6679); + static const Color darkSurfaceColor = Color(0xFF121212); + static const Color darkBackgroundColor = Color(0xFF121212); + + static const TextTheme lightTextTheme = TextTheme( + displayLarge: TextStyle( + fontSize: 32, + fontWeight: FontWeight.bold, + color: Colors.black87, + ), + displayMedium: TextStyle( + fontSize: 28, + fontWeight: FontWeight.bold, + color: Colors.black87, + ), + headlineLarge: TextStyle( + fontSize: 24, + fontWeight: FontWeight.w600, + color: Colors.black87, + ), + headlineMedium: TextStyle( + fontSize: 20, + fontWeight: FontWeight.w600, + color: Colors.black87, + ), + bodyLarge: TextStyle( + fontSize: 16, + fontWeight: FontWeight.normal, + color: Colors.black87, + ), + bodyMedium: TextStyle( + fontSize: 14, + fontWeight: FontWeight.normal, + color: Colors.black87, + ), + labelLarge: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w500, + color: Colors.white, + ), + ); + + static const TextTheme darkTextTheme = TextTheme( + displayLarge: TextStyle( + fontSize: 32, + fontWeight: FontWeight.bold, + color: Colors.white, + ), + displayMedium: TextStyle( + fontSize: 28, + fontWeight: FontWeight.bold, + color: Colors.white, + ), + headlineLarge: TextStyle( + fontSize: 24, + fontWeight: FontWeight.w600, + color: Colors.white, + ), + headlineMedium: TextStyle( + fontSize: 20, + fontWeight: FontWeight.w600, + color: Colors.white, + ), + bodyLarge: TextStyle( + fontSize: 16, + fontWeight: FontWeight.normal, + color: Colors.white, + ), + bodyMedium: TextStyle( + fontSize: 14, + fontWeight: FontWeight.normal, + color: Colors.white70, + ), + labelLarge: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w500, + color: Colors.black, + ), + ); + + static ThemeData get lightTheme { + return ThemeData( + useMaterial3: true, + brightness: Brightness.light, + primaryColor: primaryColor, + colorScheme: const ColorScheme.light( + primary: primaryColor, + secondary: secondaryColor, + error: errorColor, + surface: surfaceColor, + background: backgroundColor, + ), + textTheme: lightTextTheme, + appBarTheme: const AppBarTheme( + backgroundColor: primaryColor, + foregroundColor: Colors.white, + elevation: 0, + systemOverlayStyle: SystemUiOverlayStyle.light, + ), + elevatedButtonTheme: ElevatedButtonThemeData( + style: ElevatedButton.styleFrom( + backgroundColor: primaryColor, + foregroundColor: Colors.white, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + ), + ), + cardTheme: CardTheme( + elevation: 2, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + ); + } + + static ThemeData get darkTheme { + return ThemeData( + useMaterial3: true, + brightness: Brightness.dark, + primaryColor: darkPrimaryColor, + colorScheme: const ColorScheme.dark( + primary: darkPrimaryColor, + secondary: darkSecondaryColor, + error: darkErrorColor, + surface: darkSurfaceColor, + background: darkBackgroundColor, + ), + textTheme: darkTextTheme, + appBarTheme: const AppBarTheme( + backgroundColor: darkSurfaceColor, + foregroundColor: Colors.white, + elevation: 0, + systemOverlayStyle: SystemUiOverlayStyle.light, + ), + elevatedButtonTheme: ElevatedButtonThemeData( + style: ElevatedButton.styleFrom( + backgroundColor: darkPrimaryColor, + foregroundColor: Colors.black, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + ), + ), + cardTheme: CardTheme( + elevation: 2, + color: darkSurfaceColor, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + ); + } +} diff --git a/lib/data/repositories/repository.dart b/lib/data/repositories/repository.dart new file mode 100644 index 0000000..8f13cb2 --- /dev/null +++ b/lib/data/repositories/repository.dart @@ -0,0 +1,3 @@ +abstract class Repository { + // Base repository interface +} diff --git a/lib/domain/entities/entity.dart b/lib/domain/entities/entity.dart new file mode 100644 index 0000000..70f4739 --- /dev/null +++ b/lib/domain/entities/entity.dart @@ -0,0 +1,3 @@ +abstract class Entity { + // Base entity class +} diff --git a/lib/domain/usecases/usecase.dart b/lib/domain/usecases/usecase.dart new file mode 100644 index 0000000..3c26215 --- /dev/null +++ b/lib/domain/usecases/usecase.dart @@ -0,0 +1,3 @@ +abstract class UseCase { + Future call(Params params); +} diff --git a/lib/l10n/README.md b/lib/l10n/README.md new file mode 100644 index 0000000..066df87 --- /dev/null +++ b/lib/l10n/README.md @@ -0,0 +1,13 @@ +# Localization Directory + +This directory contains application localization files. + +## Structure +- app_en.arb - English translations +- app_ru.arb - Russian translations + +## Adding New Languages + +1. Create a new ARB file for your language (e.g., `app_es.arb` for Spanish) +2. Add the new locale to the supported locales in `main.dart` +3. Update the `AppLocalizations` class to include the new language diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb new file mode 100644 index 0000000..614baaa --- /dev/null +++ b/lib/l10n/app_en.arb @@ -0,0 +1,39 @@ +{ + "appTitle": "Flutter AR App", + "home": "Home", + "ar": "AR", + "media": "Media", + "settings": "Settings", + "welcome": "Welcome", + "getStarted": "Get Started", + "next": "Next", + "skip": "Skip", + "finish": "Finish", + "loading": "Loading...", + "error": "Error", + "retry": "Retry", + "cancel": "Cancel", + "confirm": "Confirm", + "save": "Save", + "delete": "Delete", + "edit": "Edit", + "close": "Close", + "yes": "Yes", + "no": "No", + "ok": "OK", + "cameraPermission": "Camera Permission", + "cameraPermissionDenied": "Camera permission is required for AR features", + "grantPermission": "Grant Permission", + "arNotSupported": "AR Not Supported", + "arNotSupportedMessage": "Your device does not support AR features", + "networkError": "Network Error", + "networkErrorMessage": "Please check your internet connection", + "generalError": "Something went wrong", + "generalErrorMessage": "An unexpected error occurred", + "language": "Language", + "theme": "Theme", + "about": "About", + "version": "Version", + "privacy": "Privacy Policy", + "terms": "Terms of Service" +} diff --git a/lib/l10n/app_ru.arb b/lib/l10n/app_ru.arb new file mode 100644 index 0000000..8c4b2ce --- /dev/null +++ b/lib/l10n/app_ru.arb @@ -0,0 +1,39 @@ +{ + "appTitle": "Flutter AR Приложение", + "home": "Главная", + "ar": "AR", + "media": "Медиа", + "settings": "Настройки", + "welcome": "Добро пожаловать", + "getStarted": "Начать", + "next": "Далее", + "skip": "Пропустить", + "finish": "Завершить", + "loading": "Загрузка...", + "error": "Ошибка", + "retry": "Повторить", + "cancel": "Отмена", + "confirm": "Подтвердить", + "save": "Сохранить", + "delete": "Удалить", + "edit": "Редактировать", + "close": "Закрыть", + "yes": "Да", + "no": "Нет", + "ok": "ОК", + "cameraPermission": "Разрешение камеры", + "cameraPermissionDenied": "Разрешение камеры требуется для AR функций", + "grantPermission": "Предоставить разрешение", + "arNotSupported": "AR не поддерживается", + "arNotSupportedMessage": "Ваше устройство не поддерживает AR функции", + "networkError": "Ошибка сети", + "networkErrorMessage": "Проверьте ваше интернет соединение", + "generalError": "Что-то пошло не так", + "generalErrorMessage": "Произошла непредвиденная ошибка", + "language": "Язык", + "theme": "Тема", + "about": "О приложении", + "version": "Версия", + "privacy": "Политика конфиденциальности", + "terms": "Условия использования" +} diff --git a/lib/main.dart b/lib/main.dart new file mode 100644 index 0000000..fe67158 --- /dev/null +++ b/lib/main.dart @@ -0,0 +1,64 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_screenutil/flutter_screenutil.dart'; +import 'package:flutter_localizations/flutter_localizations.dart'; + +import 'core/config/app_config.dart'; +import 'core/di/injection_container.dart'; +import 'core/router/app_router.dart'; +import 'core/theme/app_theme.dart'; +import 'core/l10n/app_localizations.dart'; +import 'presentation/providers/locale_provider.dart'; + +void main() async { + WidgetsFlutterBinding.ensureInitialized(); + + await configureDependencies(); + await AppConfig.initialize(); + + runApp( + const ProviderScope( + child: FlutterArApp(), + ), + ); +} + +class FlutterArApp extends ConsumerWidget { + const FlutterArApp({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final locale = ref.watch(localeProvider); + final appRouter = ref.watch(appRouterProvider); + + return ScreenUtilInit( + designSize: const Size(375, 812), + minTextAdapt: true, + splitScreenMode: true, + builder: (context, child) { + return MaterialApp.router( + title: 'Flutter AR App', + debugShowCheckedModeBanner: false, + + theme: AppTheme.lightTheme, + darkTheme: AppTheme.darkTheme, + themeMode: ThemeMode.system, + + routerConfig: appRouter, + + locale: locale, + supportedLocales: const [ + Locale('en', ''), + Locale('ru', ''), + ], + localizationsDelegates: const [ + AppLocalizations.delegate, + GlobalMaterialLocalizations.delegate, + GlobalWidgetsLocalizations.delegate, + GlobalCupertinoLocalizations.delegate, + ], + ); + }, + ); + } +} diff --git a/lib/presentation/pages/ar/ar_page.dart b/lib/presentation/pages/ar/ar_page.dart new file mode 100644 index 0000000..3c39a8c --- /dev/null +++ b/lib/presentation/pages/ar/ar_page.dart @@ -0,0 +1,205 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_screenutil/flutter_screenutil.dart'; +import 'package:permission_handler/permission_handler.dart'; + +import '../../../core/l10n/app_localizations.dart'; +import '../../../core/config/app_config.dart'; +import '../../widgets/loading_indicator.dart'; +import '../../widgets/error_widget.dart' as custom; + +class ArPage extends ConsumerStatefulWidget { + const ArPage({super.key}); + + @override + ConsumerState createState() => _ArPageState(); +} + +class _ArPageState extends ConsumerState { + bool _isLoading = false; + String? _errorMessage; + + @override + void initState() { + super.initState(); + _checkPermissions(); + } + + Future _checkPermissions() async { + setState(() { + _isLoading = true; + _errorMessage = null; + }); + + try { + final cameraStatus = await Permission.camera.status; + + if (!cameraStatus.isGranted) { + final result = await Permission.camera.request(); + if (!result.isGranted) { + setState(() { + _errorMessage = 'Camera permission is required for AR features'; + _isLoading = false; + }); + return; + } + } + + setState(() { + _isLoading = false; + }); + } catch (e) { + setState(() { + _errorMessage = 'Failed to check permissions: $e'; + _isLoading = false; + }); + } + } + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context)!; + + return Scaffold( + appBar: AppBar( + title: Text(l10n.ar), + centerTitle: true, + ), + body: _buildBody(l10n), + ); + } + + Widget _buildBody(AppLocalizations l10n) { + if (_isLoading) { + return const LoadingIndicator(); + } + + if (_errorMessage != null) { + return custom.ErrorWidget( + message: _errorMessage!, + onRetry: _checkPermissions, + ); + } + + if (!AppConfig.enableArFeatures) { + return _buildDisabledFeature(l10n); + } + + return _buildArContent(l10n); + } + + Widget _buildDisabledFeature(AppLocalizations l10n) { + return Center( + child: Padding( + padding: EdgeInsets.all(24.w), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.block, + size: 80.w, + color: Colors.grey.shade400, + ), + SizedBox(height: 16.h), + Text( + l10n.arNotSupported, + style: TextStyle( + fontSize: 20.sp, + fontWeight: FontWeight.bold, + ), + ), + SizedBox(height: 8.h), + Text( + l10n.arNotSupportedMessage, + style: TextStyle( + fontSize: 16.sp, + color: Colors.grey.shade600, + ), + textAlign: TextAlign.center, + ), + ], + ), + ), + ); + } + + Widget _buildArContent(AppLocalizations l10n) { + return Padding( + padding: EdgeInsets.all(16.w), + child: Column( + children: [ + Expanded( + child: Container( + decoration: BoxDecoration( + color: Colors.black, + borderRadius: BorderRadius.circular(12), + ), + child: Stack( + children: [ + Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.view_in_ar, + size: 80.w, + color: Colors.white54, + ), + SizedBox(height: 16.h), + Text( + 'AR View', + style: TextStyle( + fontSize: 20.sp, + color: Colors.white54, + fontWeight: FontWeight.w500, + ), + ), + SizedBox(height: 8.h), + Text( + 'AR functionality will be implemented here', + style: TextStyle( + fontSize: 14.sp, + color: Colors.white38, + ), + textAlign: TextAlign.center, + ), + ], + ), + ), + ], + ), + ), + ), + SizedBox(height: 16.h), + Row( + children: [ + Expanded( + child: ElevatedButton.icon( + onPressed: () { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('AR object placement coming soon')), + ); + }, + icon: const Icon(Icons.add), + label: const Text('Add Object'), + ), + ), + SizedBox(width: 12.w), + Expanded( + child: ElevatedButton.icon( + onPressed: () { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('AR settings coming soon')), + ); + }, + icon: const Icon(Icons.tune), + label: const Text('Settings'), + ), + ), + ], + ), + ], + ), + ); + } +} diff --git a/lib/presentation/pages/home/home_page.dart b/lib/presentation/pages/home/home_page.dart new file mode 100644 index 0000000..75bc579 --- /dev/null +++ b/lib/presentation/pages/home/home_page.dart @@ -0,0 +1,144 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_screenutil/flutter_screenutil.dart'; +import 'package:go_router/go_router.dart'; + +import '../../../core/l10n/app_localizations.dart'; + +class HomePage extends ConsumerWidget { + const HomePage({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final l10n = AppLocalizations.of(context)!; + + return Scaffold( + appBar: AppBar( + title: Text(l10n.home), + centerTitle: true, + ), + body: Padding( + padding: EdgeInsets.all(16.w), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + l10n.welcome, + style: TextStyle( + fontSize: 24.sp, + fontWeight: FontWeight.bold, + ), + ), + SizedBox(height: 8.h), + Text( + 'Explore the features of our AR application', + style: TextStyle( + fontSize: 16.sp, + color: Colors.grey.shade600, + ), + ), + SizedBox(height: 32.h), + Expanded( + child: GridView.count( + crossAxisCount: 2, + crossAxisSpacing: 16.w, + mainAxisSpacing: 16.h, + children: [ + _FeatureCard( + icon: Icons.view_in_ar, + title: 'AR Features', + description: 'Experience augmented reality', + onTap: () => context.go('/ar'), + ), + _FeatureCard( + icon: Icons.photo_library, + title: 'Media Library', + description: 'Browse your media files', + onTap: () => context.go('/media'), + ), + _FeatureCard( + icon: Icons.camera_alt, + title: 'Camera', + description: 'Capture photos and videos', + onTap: () { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Camera feature coming soon')), + ); + }, + ), + _FeatureCard( + icon: Icons.settings, + title: 'Settings', + description: 'Customize your experience', + onTap: () => context.go('/settings'), + ), + ], + ), + ), + ], + ), + ), + ); + } +} + +class _FeatureCard extends StatelessWidget { + final IconData icon; + final String title; + final String description; + final VoidCallback onTap; + + const _FeatureCard({ + required this.icon, + required this.title, + required this.description, + required this.onTap, + }); + + @override + Widget build(BuildContext context) { + return Card( + elevation: 4, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + child: InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(12), + child: Padding( + padding: EdgeInsets.all(16.w), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + icon, + size: 48.w, + color: Theme.of(context).primaryColor, + ), + SizedBox(height: 12.h), + Text( + title, + style: TextStyle( + fontSize: 16.sp, + fontWeight: FontWeight.w600, + ), + textAlign: TextAlign.center, + ), + SizedBox(height: 8.h), + Text( + description, + style: TextStyle( + fontSize: 12.sp, + color: Colors.grey.shade600, + ), + textAlign: TextAlign.center, + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/presentation/pages/media/media_page.dart b/lib/presentation/pages/media/media_page.dart new file mode 100644 index 0000000..73dede3 --- /dev/null +++ b/lib/presentation/pages/media/media_page.dart @@ -0,0 +1,168 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_screenutil/flutter_screenutil.dart'; + +import '../../../core/l10n/app_localizations.dart'; + +class MediaPage extends ConsumerWidget { + const MediaPage({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final l10n = AppLocalizations.of(context)!; + + return Scaffold( + appBar: AppBar( + title: Text(l10n.media), + centerTitle: true, + actions: [ + IconButton( + icon: const Icon(Icons.search), + onPressed: () { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Search coming soon')), + ); + }, + ), + ], + ), + body: Padding( + padding: EdgeInsets.all(16.w), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: ElevatedButton.icon( + onPressed: () { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Camera coming soon')), + ); + }, + icon: const Icon(Icons.camera_alt), + label: const Text('Camera'), + ), + ), + SizedBox(width: 12.w), + Expanded( + child: ElevatedButton.icon( + onPressed: () { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Gallery coming soon')), + ); + }, + icon: const Icon(Icons.photo_library), + label: const Text('Gallery'), + ), + ), + ], + ), + SizedBox(height: 24.h), + Expanded( + child: _buildMediaGrid(l10n), + ), + ], + ), + ), + floatingActionButton: FloatingActionButton( + onPressed: () { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Upload coming soon')), + ); + }, + child: const Icon(Icons.upload), + ), + ); + } + + Widget _buildMediaGrid(AppLocalizations l10n) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Recent Media', + style: TextStyle( + fontSize: 20.sp, + fontWeight: FontWeight.bold, + ), + ), + SizedBox(height: 16.h), + Expanded( + child: GridView.builder( + gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 2, + crossAxisSpacing: 12.w, + mainAxisSpacing: 12.h, + childAspectRatio: 1, + ), + itemCount: 6, + itemBuilder: (context, index) { + return _buildMediaItem(index); + }, + ), + ), + ], + ); + } + + Widget _buildMediaItem(int index) { + return Card( + elevation: 4, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + child: InkWell( + onTap: () { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Media item ${index + 1} coming soon')), + ); + }, + borderRadius: BorderRadius.circular(12), + child: Container( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(12), + gradient: LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [ + Colors.grey.shade300, + Colors.grey.shade400, + ], + ), + ), + child: Stack( + children: [ + Center( + child: Icon( + index % 2 == 0 ? Icons.image : Icons.videocam, + size: 48.w, + color: Colors.white, + ), + ), + if (index % 2 == 1) + Positioned( + bottom: 8.h, + right: 8.w, + child: Container( + padding: EdgeInsets.symmetric(horizontal: 6.w, vertical: 2.h), + decoration: BoxDecoration( + color: Colors.black54, + borderRadius: BorderRadius.circular(4), + ), + child: Text( + '2:45', + style: TextStyle( + color: Colors.white, + fontSize: 10.sp, + ), + ), + ), + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/presentation/pages/onboarding/onboarding_page.dart b/lib/presentation/pages/onboarding/onboarding_page.dart new file mode 100644 index 0000000..9a37a32 --- /dev/null +++ b/lib/presentation/pages/onboarding/onboarding_page.dart @@ -0,0 +1,184 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_screenutil/flutter_screenutil.dart'; +import 'package:go_router/go_router.dart'; + +import '../../../core/l10n/app_localizations.dart'; + +class OnboardingPage extends ConsumerStatefulWidget { + const OnboardingPage({super.key}); + + @override + ConsumerState createState() => _OnboardingPageState(); +} + +class _OnboardingPageState extends ConsumerState { + final PageController _pageController = PageController(); + int _currentPage = 0; + + final List _items = [ + OnboardingItem( + icon: Icons.view_in_ar, + title: 'AR Experience', + description: 'Explore augmented reality with cutting-edge technology', + ), + OnboardingItem( + icon: Icons.photo_library, + title: 'Media Management', + description: 'Organize and manage your media files efficiently', + ), + OnboardingItem( + icon: Icons.settings, + title: 'Customizable', + description: 'Personalize your experience with flexible settings', + ), + ]; + + @override + void dispose() { + _pageController.dispose(); + super.dispose(); + } + + void _nextPage() { + if (_currentPage < _items.length - 1) { + _pageController.nextPage( + duration: const Duration(milliseconds: 300), + curve: Curves.easeInOut, + ); + } else { + _completeOnboarding(); + } + } + + void _completeOnboarding() { + context.go('/home'); + } + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context)!; + + return Scaffold( + body: SafeArea( + child: Column( + children: [ + Expanded( + child: PageView.builder( + controller: _pageController, + onPageChanged: (index) { + setState(() { + _currentPage = index; + }); + }, + itemCount: _items.length, + itemBuilder: (context, index) { + return OnboardingItemWidget(item: _items[index]); + }, + ), + ), + Padding( + padding: EdgeInsets.all(24.w), + child: Column( + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: _items.asMap().entries.map((entry) { + return Container( + width: 8.w, + height: 8.h, + margin: EdgeInsets.symmetric(horizontal: 4.w), + decoration: BoxDecoration( + shape: BoxShape.circle, + color: _currentPage == entry.key + ? Theme.of(context).primaryColor + : Colors.grey.shade300, + ), + ); + }).toList(), + ), + SizedBox(height: 32.h), + Row( + children: [ + if (_currentPage > 0) + TextButton( + onPressed: () { + _pageController.previousPage( + duration: const Duration(milliseconds: 300), + curve: Curves.easeInOut, + ); + }, + child: Text(l10n.skip), + ), + const Spacer(), + ElevatedButton( + onPressed: _nextPage, + child: Text(_currentPage == _items.length - 1 ? l10n.finish : l10n.next), + ), + ], + ), + ], + ), + ), + ], + ), + ), + ); + } +} + +class OnboardingItem { + final IconData icon; + final String title; + final String description; + + OnboardingItem({ + required this.icon, + required this.title, + required this.description, + }); +} + +class OnboardingItemWidget extends StatelessWidget { + final OnboardingItem item; + + const OnboardingItemWidget({ + super.key, + required this.item, + }); + + @override + Widget build(BuildContext context) { + return Padding( + padding: EdgeInsets.all(24.w), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + item.icon, + size: 120.w, + color: Theme.of(context).primaryColor, + ), + SizedBox(height: 32.h), + Text( + item.title, + style: TextStyle( + fontSize: 24.sp, + fontWeight: FontWeight.bold, + ), + textAlign: TextAlign.center, + ), + SizedBox(height: 16.h), + Text( + item.description, + style: TextStyle( + fontSize: 16.sp, + color: Colors.grey.shade600, + ), + textAlign: TextAlign.center, + ), + ], + ), + ); + } +} diff --git a/lib/presentation/pages/settings/settings_page.dart b/lib/presentation/pages/settings/settings_page.dart new file mode 100644 index 0000000..4ae3a0f --- /dev/null +++ b/lib/presentation/pages/settings/settings_page.dart @@ -0,0 +1,232 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_screenutil/flutter_screenutil.dart'; + +import '../../../core/l10n/app_localizations.dart'; +import '../../providers/locale_provider.dart'; + +class SettingsPage extends ConsumerWidget { + const SettingsPage({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final l10n = AppLocalizations.of(context)!; + final localeNotifier = ref.read(localeProvider.notifier); + final currentLocale = ref.watch(localeProvider); + + return Scaffold( + appBar: AppBar( + title: Text(l10n.settings), + centerTitle: true, + ), + body: ListView( + padding: EdgeInsets.all(16.w), + children: [ + _buildSection( + title: 'Preferences', + children: [ + _buildLanguageTile( + l10n: l10n, + currentLocale: currentLocale, + onLanguageChanged: (languageCode) { + localeNotifier.changeLocale(languageCode); + }, + ), + _buildThemeTile(l10n: l10n), + ], + ), + SizedBox(height: 24.h), + _buildSection( + title: 'About', + children: [ + _buildVersionTile(l10n: l10n), + _buildPrivacyTile(l10n: l10n), + _buildTermsTile(l10n: l10n), + ], + ), + SizedBox(height: 24.h), + _buildSection( + title: 'Storage', + children: [ + _buildCacheTile(l10n: l10n), + _buildDataTile(l10n: l10n), + ], + ), + ], + ), + ); + } + + Widget _buildSection({ + required String title, + required List children, + }) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: TextStyle( + fontSize: 18.sp, + fontWeight: FontWeight.bold, + color: Colors.grey.shade700, + ), + ), + SizedBox(height: 12.h), + Card( + elevation: 2, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + child: Column( + children: children, + ), + ), + ], + ); + } + + Widget _buildLanguageTile({ + required AppLocalizations l10n, + required Locale currentLocale, + required Function(String) onLanguageChanged, + }) { + return ListTile( + leading: const Icon(Icons.language), + title: Text(l10n.language), + subtitle: Text(currentLocale.languageCode == 'en' ? 'English' : 'Русский'), + trailing: const Icon(Icons.chevron_right), + onTap: () { + _showLanguageDialog( + context: context, + l10n: l10n, + currentLanguage: currentLocale.languageCode, + onLanguageChanged: onLanguageChanged, + ); + }, + ); + } + + Widget _buildThemeTile({required AppLocalizations l10n}) { + return ListTile( + leading: const Icon(Icons.palette), + title: Text(l10n.theme), + subtitle: const Text('System'), + trailing: const Icon(Icons.chevron_right), + onTap: () { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Theme selection coming soon')), + ); + }, + ); + } + + Widget _buildVersionTile({required AppLocalizations l10n}) { + return ListTile( + leading: const Icon(Icons.info), + title: Text(l10n.version), + subtitle: const Text('1.0.0'), + trailing: const Icon(Icons.chevron_right), + onTap: () { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Version info coming soon')), + ); + }, + ); + } + + Widget _buildPrivacyTile({required AppLocalizations l10n}) { + return ListTile( + leading: const Icon(Icons.privacy_tip), + title: Text(l10n.privacy), + trailing: const Icon(Icons.chevron_right), + onTap: () { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Privacy policy coming soon')), + ); + }, + ); + } + + Widget _buildTermsTile({required AppLocalizations l10n}) { + return ListTile( + leading: const Icon(Icons.description), + title: Text(l10n.terms), + trailing: const Icon(Icons.chevron_right), + onTap: () { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Terms of service coming soon')), + ); + }, + ); + } + + Widget _buildCacheTile({required AppLocalizations l10n}) { + return ListTile( + leading: const Icon(Icons.cleaning_services), + title: const Text('Clear Cache'), + subtitle: const Text('Free up storage space'), + trailing: const Icon(Icons.chevron_right), + onTap: () { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Cache clearing coming soon')), + ); + }, + ); + } + + Widget _buildDataTile({required AppLocalizations l10n}) { + return ListTile( + leading: const Icon(Icons.storage), + title: const Text('Data Usage'), + subtitle: const Text('Manage mobile data'), + trailing: const Icon(Icons.chevron_right), + onTap: () { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Data usage settings coming soon')), + ); + }, + ); + } + + void _showLanguageDialog({ + required BuildContext context, + required AppLocalizations l10n, + required String currentLanguage, + required Function(String) onLanguageChanged, + }) { + showDialog( + context: context, + builder: (context) => AlertDialog( + title: Text(l10n.language), + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + RadioListTile( + title: const Text('English'), + value: 'en', + groupValue: currentLanguage, + onChanged: (value) { + if (value != null) { + onLanguageChanged(value); + Navigator.of(context).pop(); + } + }, + ), + RadioListTile( + title: const Text('Русский'), + value: 'ru', + groupValue: currentLanguage, + onChanged: (value) { + if (value != null) { + onLanguageChanged(value); + Navigator.of(context).pop(); + } + }, + ), + ], + ), + ), + ); + } +} diff --git a/lib/presentation/pages/splash/splash_page.dart b/lib/presentation/pages/splash/splash_page.dart new file mode 100644 index 0000000..09dc70f --- /dev/null +++ b/lib/presentation/pages/splash/splash_page.dart @@ -0,0 +1,97 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_screenutil/flutter_screenutil.dart'; +import 'package:go_router/go_router.dart'; + +import '../../../core/l10n/app_localizations.dart'; +import '../../providers/locale_provider.dart'; + +class SplashPage extends ConsumerStatefulWidget { + const SplashPage({super.key}); + + @override + ConsumerState createState() => _SplashPageState(); +} + +class _SplashPageState extends ConsumerState + with SingleTickerProviderStateMixin { + late AnimationController _animationController; + late Animation _fadeAnimation; + + @override + void initState() { + super.initState(); + _animationController = AnimationController( + duration: const Duration(seconds: 2), + vsync: this, + ); + + _fadeAnimation = Tween( + begin: 0.0, + end: 1.0, + ).animate(CurvedAnimation( + parent: _animationController, + curve: Curves.easeInOut, + )); + + _animationController.forward(); + + _navigateToNextScreen(); + } + + @override + void dispose() { + _animationController.dispose(); + super.dispose(); + } + + void _navigateToNextScreen() { + Future.delayed(const Duration(seconds: 3), () { + if (mounted) { + context.go('/onboarding'); + } + }); + } + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context)!; + + return Scaffold( + backgroundColor: Theme.of(context).primaryColor, + body: Center( + child: FadeTransition( + opacity: _fadeAnimation, + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.view_in_ar, + size: 120.w, + color: Colors.white, + ), + SizedBox(height: 24.h), + Text( + l10n.appTitle, + style: TextStyle( + fontSize: 28.sp, + fontWeight: FontWeight.bold, + color: Colors.white, + ), + ), + SizedBox(height: 48.h), + SizedBox( + width: 40.w, + height: 40.w, + child: CircularProgressIndicator( + strokeWidth: 3, + valueColor: const AlwaysStoppedAnimation(Colors.white), + ), + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/presentation/providers/locale_provider.dart b/lib/presentation/providers/locale_provider.dart new file mode 100644 index 0000000..713fc65 --- /dev/null +++ b/lib/presentation/providers/locale_provider.dart @@ -0,0 +1,24 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +final localeProvider = StateNotifierProvider((ref) { + return LocaleNotifier(); +}); + +class LocaleNotifier extends StateNotifier { + LocaleNotifier() : super(const Locale('en')) { + _loadLocale(); + } + + Future _loadLocale() async { + final prefs = await SharedPreferences.getInstance(); + final languageCode = prefs.getString('language_code') ?? 'en'; + state = Locale(languageCode); + } + + Future changeLocale(String languageCode) async { + final prefs = await SharedPreferences.getInstance(); + await prefs.setString('language_code', languageCode); + state = Locale(languageCode); + } +} diff --git a/lib/presentation/providers/router_provider.dart b/lib/presentation/providers/router_provider.dart new file mode 100644 index 0000000..47469a1 --- /dev/null +++ b/lib/presentation/providers/router_provider.dart @@ -0,0 +1,8 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; + +import '../../core/router/app_router.dart'; + +final appRouterProvider = Provider((ref) { + return createAppRouter(); +}); diff --git a/lib/presentation/widgets/error_widget.dart b/lib/presentation/widgets/error_widget.dart new file mode 100644 index 0000000..ef674c4 --- /dev/null +++ b/lib/presentation/widgets/error_widget.dart @@ -0,0 +1,60 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_screenutil/flutter_screenutil.dart'; + +class ErrorWidget extends StatelessWidget { + final String message; + final VoidCallback? onRetry; + final IconData? icon; + + const ErrorWidget({ + super.key, + required this.message, + this.onRetry, + this.icon, + }); + + @override + Widget build(BuildContext context) { + return Center( + child: Padding( + padding: EdgeInsets.all(24.w), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + icon ?? Icons.error_outline, + size: 80.w, + color: Colors.red.shade400, + ), + SizedBox(height: 16.h), + Text( + 'Oops!', + style: TextStyle( + fontSize: 24.sp, + fontWeight: FontWeight.bold, + color: Colors.red.shade600, + ), + ), + SizedBox(height: 12.h), + Text( + message, + style: TextStyle( + fontSize: 16.sp, + color: Colors.grey.shade600, + ), + textAlign: TextAlign.center, + ), + if (onRetry != null) ...[ + SizedBox(height: 24.h), + ElevatedButton.icon( + onPressed: onRetry, + icon: const Icon(Icons.refresh), + label: const Text('Retry'), + ), + ], + ], + ), + ), + ); + } +} diff --git a/lib/presentation/widgets/loading_indicator.dart b/lib/presentation/widgets/loading_indicator.dart new file mode 100644 index 0000000..987cac6 --- /dev/null +++ b/lib/presentation/widgets/loading_indicator.dart @@ -0,0 +1,44 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_screenutil/flutter_screenutil.dart'; + +class LoadingIndicator extends StatelessWidget { + final String? message; + final double? size; + + const LoadingIndicator({ + super.key, + this.message, + this.size, + }); + + @override + Widget build(BuildContext context) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + SizedBox( + width: size ?? 40.w, + height: size ?? 40.w, + child: CircularProgressIndicator( + strokeWidth: 3, + valueColor: AlwaysStoppedAnimation( + Theme.of(context).primaryColor, + ), + ), + ), + if (message != null) ...[ + SizedBox(height: 16.h), + Text( + message!, + style: TextStyle( + fontSize: 16.sp, + color: Colors.grey.shade600, + ), + ), + ], + ], + ), + ); + } +} diff --git a/lib/presentation/widgets/navigation_shell.dart b/lib/presentation/widgets/navigation_shell.dart new file mode 100644 index 0000000..0cc1286 --- /dev/null +++ b/lib/presentation/widgets/navigation_shell.dart @@ -0,0 +1,82 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_screenutil/flutter_screenutil.dart'; +import 'package:go_router/go_router.dart'; + +import '../../../core/l10n/app_localizations.dart'; + +class NavigationShell extends ConsumerWidget { + final Widget child; + + const NavigationShell({ + super.key, + required this.child, + }); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final l10n = AppLocalizations.of(context)!; + final location = GoRouterState.of(context).location; + + return Scaffold( + body: child, + bottomNavigationBar: BottomNavigationBar( + currentIndex: _getCurrentIndex(location), + onTap: (index) { + _navigateToIndex(context, index); + }, + type: BottomNavigationBarType.fixed, + items: [ + BottomNavigationBarItem( + icon: const Icon(Icons.home), + label: l10n.home, + ), + BottomNavigationBarItem( + icon: const Icon(Icons.view_in_ar), + label: l10n.ar, + ), + BottomNavigationBarItem( + icon: const Icon(Icons.photo_library), + label: l10n.media, + ), + BottomNavigationBarItem( + icon: const Icon(Icons.settings), + label: l10n.settings, + ), + ], + ), + ); + } + + int _getCurrentIndex(String location) { + switch (location) { + case '/home': + return 0; + case '/ar': + return 1; + case '/media': + return 2; + case '/settings': + return 3; + default: + return 0; + } + } + + void _navigateToIndex(BuildContext context, int index) { + switch (index) { + case 0: + context.go('/home'); + break; + case 1: + context.go('/ar'); + break; + case 2: + context.go('/media'); + break; + case 3: + context.go('/settings'); + break; + } + } +} diff --git a/pubspec.yaml b/pubspec.yaml new file mode 100644 index 0000000..05dd83a --- /dev/null +++ b/pubspec.yaml @@ -0,0 +1,76 @@ +name: flutter_ar_app +description: A Flutter AR application with layered architecture and internationalization. + +publish_to: 'none' + +version: 1.0.0+1 + +environment: + sdk: '>=3.0.0 <4.0.0' + flutter: ">=3.10.0" + +dependencies: + flutter: + sdk: flutter + flutter_localizations: + sdk: flutter + + # State Management & Dependency Injection + flutter_riverpod: ^2.4.9 + get_it: ^7.6.4 + injectable: ^2.3.2 + + # Networking & Data + dio: ^5.4.0 + json_annotation: ^4.8.1 + flutter_cache_manager: ^3.3.1 + + # Storage & Security + flutter_secure_storage: ^9.0.0 + shared_preferences: ^2.2.2 + + # Media & AR + video_player: ^2.8.1 + ar_flutter_plugin: ^0.7.3 + camera: ^0.10.5+5 + permission_handler: ^11.1.0 + + # UI & Utilities + cupertino_icons: ^1.0.2 + go_router: ^12.1.3 + flutter_screenutil: ^5.9.0 + fluttertoast: ^8.2.4 + lottie: ^2.7.0 + + # Environment Configuration + flutter_dotenv: ^5.1.0 + + # Internationalization + intl: ^0.18.1 + +dev_dependencies: + flutter_test: + sdk: flutter + flutter_lints: ^3.0.0 + + # Code Generation + build_runner: ^2.4.7 + injectable_generator: ^2.4.1 + json_serializable: ^6.7.1 + +flutter: + uses-material-design: true + assets: + - .env + - assets/images/ + - assets/animations/ + - lib/l10n/ + - assets/l10n/ + +flutter_intl: + enabled: true + arb_dir: lib/l10n + output_dir: lib/generated + output_localization_file: app_localizations.dart + template_arb_file: app_en.arb + output_class: AppLocalizations diff --git a/test/unit/app_config_test.dart b/test/unit/app_config_test.dart new file mode 100644 index 0000000..2d8f9b1 --- /dev/null +++ b/test/unit/app_config_test.dart @@ -0,0 +1,21 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:flutter_ar_app/core/config/app_config.dart'; + +void main() { + group('AppConfig Tests', () { + test('should initialize with default values', () async { + await AppConfig.initialize(); + + expect(AppConfig.environment, Environment.development); + expect(AppConfig.enableLogging, true); + expect(AppConfig.enableArFeatures, true); + }); + + test('should correctly identify development environment', () async { + await AppConfig.initialize(); + + expect(AppConfig.isDevelopment, true); + expect(AppConfig.isProduction, false); + }); + }); +} diff --git a/test/unit/di_test.dart b/test/unit/di_test.dart new file mode 100644 index 0000000..81f2e8e --- /dev/null +++ b/test/unit/di_test.dart @@ -0,0 +1,26 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:get_it/get_it.dart'; +import 'package:dio/dio.dart'; + +import 'package:flutter_ar_app/core/di/injection_container.dart'; + +void main() { + group('Dependency Injection Tests', () { + setUpAll(() async { + await configureDependencies(); + }); + + test('should register Dio instance', () { + final dio = getIt(); + expect(dio, isNotNull); + expect(dio, isA()); + }); + + test('should have correct Dio configuration', () { + final dio = getIt(); + expect(dio.options.connectTimeout, const Duration(seconds: 30)); + expect(dio.options.receiveTimeout, const Duration(seconds: 30)); + expect(dio.options.sendTimeout, const Duration(seconds: 30)); + }); + }); +} diff --git a/test/unit/l10n_test.dart b/test/unit/l10n_test.dart new file mode 100644 index 0000000..d26e374 --- /dev/null +++ b/test/unit/l10n_test.dart @@ -0,0 +1,11 @@ +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group('Localization Tests', () { + test('should have translation keys available', () { + // This is a placeholder test for localization + // In a real app, you would test the actual localization functionality + expect(true, isTrue); + }); + }); +} diff --git a/test/widget_test.dart b/test/widget_test.dart new file mode 100644 index 0000000..1d5a94f --- /dev/null +++ b/test/widget_test.dart @@ -0,0 +1,10 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:flutter_ar_app/main.dart'; + +void main() { + testWidgets('App smoke test', (WidgetTester tester) async { + await tester.pumpWidget(const FlutterArApp()); + + expect(find.text('Flutter AR App'), findsOneWidget); + }); +}