commit 0311b8955aae71f385484224b4093db521b98480 Author: davmar Date: Fri Aug 28 18:23:56 2026 +0200 first diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..79f7eca --- /dev/null +++ b/.gitignore @@ -0,0 +1,48 @@ +# Miscellaneous +*.class +*.log +*.pyc +*.swp +.DS_Store +.atom/ +.build/ +.buildlog/ +.history +.svn/ +.swiftpm/ +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-dependencies +.pub-cache/ +.pub/ +/build/ +/coverage/ + +# 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 + +# Widget Preview related +.widget_preview/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..8055eea --- /dev/null +++ b/README.md @@ -0,0 +1,83 @@ +# MyApp + +Generic Flutter Android starter built with Flutter **3.47** (Dart **3.13**), Material 3, null safety, and [Provider](https://pub.dev/packages/provider) for state management. + +Package name: `com.example.myapp` + +## Features + +- Splash screen that fades into the main shell +- Bottom navigation: Home, Search, Profile, Settings +- `ListView.builder` catalog with rounded, shadowed cards +- Detail screen with a matching `Hero` animation +- Light / dark / system theme, persisted with `shared_preferences` +- Add-item form with validation +- Mock catalog plus an `http` client aimed at JSONPlaceholder (toggle in Settings) + +## Folder structure + +``` +android-flutter-app-generic/ +├── android/ # Android embedding, Gradle, manifest +├── lib/ +│ ├── main.dart # Entry point, Provider scope, MaterialApp +│ ├── models/ +│ │ └── item.dart # Item + ItemCategory +│ ├── providers/ +│ │ ├── item_provider.dart # Catalog ChangeNotifier +│ │ └── theme_provider.dart # ThemeMode ChangeNotifier +│ ├── screens/ +│ │ ├── splash_screen.dart +│ │ ├── main_shell.dart # NavigationBar host +│ │ ├── home_screen.dart +│ │ ├── search_screen.dart +│ │ ├── profile_screen.dart +│ │ ├── settings_screen.dart +│ │ ├── item_detail_screen.dart +│ │ └── add_item_screen.dart +│ ├── services/ +│ │ ├── storage_service.dart # shared_preferences wrapper +│ │ ├── item_api.dart # http placeholder +│ │ ├── fake_item_data.dart # Offline sample catalog +│ │ └── item_repository.dart # Mock vs remote switch +│ ├── utils/ +│ │ ├── app_theme.dart # Material 3 light/dark themes +│ │ ├── constants.dart +│ │ └── helpers.dart +│ └── widgets/ +│ ├── app_text_field.dart +│ ├── empty_state.dart +│ ├── item_card.dart +│ ├── item_hero_badge.dart +│ ├── loading_view.dart +│ └── responsive_body.dart +├── test/ +│ └── widget_test.dart +└── pubspec.yaml +``` + +## Run the app + +You need the [Flutter SDK](https://docs.flutter.dev/get-started/install) (3.47 or newer) and an Android emulator or device. + +```bash +cd android-flutter-app-generic +flutter pub get +flutter run +``` + +Useful extras: + +```bash +flutter devices # list emulators / devices +flutter run -d android # target Android explicitly +flutter analyze # static analysis +flutter test # widget + unit tests +``` + +On first Android run, Flutter will download a Gradle distribution and compile the app. Accept any Android licenses with `flutter doctor --android-licenses` if prompted. + +## Theme and data + +- Open **Settings** to switch System / Light / Dark. The choice is stored locally. +- Turn on **Use remote sample API** to fetch posts from `https://jsonplaceholder.typicode.com/posts` through the `http` package. If the request fails, the app falls back to the bundled mock list. diff --git a/analysis_options.yaml b/analysis_options.yaml new file mode 100644 index 0000000..09d2e5b --- /dev/null +++ b/analysis_options.yaml @@ -0,0 +1,36 @@ +# This file configures the analyzer, which statically analyzes Dart code to +# check for errors, warnings, and lints. +# +# The issues identified by the analyzer are surfaced in the UI of Dart-enabled +# IDEs (https://dart.dev/tools#ides-and-editors). The analyzer can also be +# invoked from the command line by running `flutter analyze`. + +# The following line activates a set of recommended lints for Flutter apps, +# packages, and plugins designed to encourage good coding practices. +include: package:flutter_lints/flutter.yaml + +analyzer: + exclude: + - build/** + - android/** + +linter: + # The lint rules applied to this project can be customized in the + # section below to disable rules from the `package:flutter_lints/flutter.yaml` + # included above or to enable additional rules. A list of all available lints + # and their documentation is published at https://dart.dev/lints. + # + # Instead of disabling a lint rule for the entire project in the + # section below, it can also be suppressed for a single line of code + # or a specific dart file by using the `// ignore: name_of_lint` and + # `// ignore_for_file: name_of_lint` syntax on the line or in the file + # producing the lint. + rules: + prefer_single_quotes: true + directives_ordering: true + prefer_const_constructors: true + prefer_const_literals_to_create_immutables: true + avoid_print: true + +# Additional information about this file can be found at +# https://dart.dev/guides/language/analysis-options diff --git a/android/.gitignore b/android/.gitignore new file mode 100644 index 0000000..be3943c --- /dev/null +++ b/android/.gitignore @@ -0,0 +1,14 @@ +gradle-wrapper.jar +/.gradle +/captures/ +/gradlew +/gradlew.bat +/local.properties +GeneratedPluginRegistrant.java +.cxx/ + +# Remember to never publicly share your keystore. +# See https://flutter.dev/to/reference-keystore +key.properties +**/*.keystore +**/*.jks diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts new file mode 100644 index 0000000..00804d4 --- /dev/null +++ b/android/app/build.gradle.kts @@ -0,0 +1,49 @@ +plugins { + id("com.android.application") + // The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins. + id("dev.flutter.flutter-gradle-plugin") +} + +android { + namespace = "com.example.myapp" + compileSdk = flutter.compileSdkVersion + ndkVersion = flutter.ndkVersion + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + + defaultConfig { + // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html). + applicationId = "com.example.myapp" + // 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 + targetSdk = flutter.targetSdkVersion + // Uses the version code from pubspec.yaml. When using split APKs, 1000 * ABI_VERSION + // is added automatically by Flutter. (https://developer.android.com/studio/build/configure-apk-splits#configure-APK-versions) + // You can force using the value of versionCode by specifying the `-P force-version-code-ignoring-abi=true` + // flag during build. + versionCode = flutter.versionCode + versionName = flutter.versionName + } + + buildTypes { + release { + // TODO: Add your own signing config for the release build. + // Signing with the debug keys for now, so `flutter run --release` works. + signingConfig = signingConfigs.getByName("debug") + } + } +} + +kotlin { + compilerOptions { + jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17 + } +} + +flutter { + source = "../.." +} diff --git a/android/app/src/debug/AndroidManifest.xml b/android/app/src/debug/AndroidManifest.xml new file mode 100644 index 0000000..399f698 --- /dev/null +++ b/android/app/src/debug/AndroidManifest.xml @@ -0,0 +1,7 @@ + + + + diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..d8e2f52 --- /dev/null +++ b/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,46 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/android/app/src/main/kotlin/com/example/myapp/MainActivity.kt b/android/app/src/main/kotlin/com/example/myapp/MainActivity.kt new file mode 100644 index 0000000..6aec4e9 --- /dev/null +++ b/android/app/src/main/kotlin/com/example/myapp/MainActivity.kt @@ -0,0 +1,5 @@ +package com.example.myapp + +import io.flutter.embedding.android.FlutterActivity + +class MainActivity : FlutterActivity() diff --git a/android/app/src/main/res/drawable-v21/launch_background.xml b/android/app/src/main/res/drawable-v21/launch_background.xml new file mode 100644 index 0000000..f74085f --- /dev/null +++ b/android/app/src/main/res/drawable-v21/launch_background.xml @@ -0,0 +1,12 @@ + + + + + + + + diff --git a/android/app/src/main/res/drawable/launch_background.xml b/android/app/src/main/res/drawable/launch_background.xml new file mode 100644 index 0000000..304732f --- /dev/null +++ b/android/app/src/main/res/drawable/launch_background.xml @@ -0,0 +1,12 @@ + + + + + + + + diff --git a/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/android/app/src/main/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 0000000..db77bb4 Binary files /dev/null and b/android/app/src/main/res/mipmap-hdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/android/app/src/main/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 0000000..17987b7 Binary files /dev/null and b/android/app/src/main/res/mipmap-mdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 0000000..09d4391 Binary files /dev/null and b/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 0000000..d5f1c8d Binary files /dev/null and b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 0000000..4d6372e Binary files /dev/null and b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/values-night/styles.xml b/android/app/src/main/res/values-night/styles.xml new file mode 100644 index 0000000..06952be --- /dev/null +++ b/android/app/src/main/res/values-night/styles.xml @@ -0,0 +1,18 @@ + + + + + + + diff --git a/android/app/src/main/res/values/styles.xml b/android/app/src/main/res/values/styles.xml new file mode 100644 index 0000000..cb1ef88 --- /dev/null +++ b/android/app/src/main/res/values/styles.xml @@ -0,0 +1,18 @@ + + + + + + + diff --git a/android/app/src/profile/AndroidManifest.xml b/android/app/src/profile/AndroidManifest.xml new file mode 100644 index 0000000..399f698 --- /dev/null +++ b/android/app/src/profile/AndroidManifest.xml @@ -0,0 +1,7 @@ + + + + diff --git a/android/build.gradle.kts b/android/build.gradle.kts new file mode 100644 index 0000000..dbee657 --- /dev/null +++ b/android/build.gradle.kts @@ -0,0 +1,24 @@ +allprojects { + repositories { + google() + mavenCentral() + } +} + +val newBuildDir: Directory = + rootProject.layout.buildDirectory + .dir("../../build") + .get() +rootProject.layout.buildDirectory.value(newBuildDir) + +subprojects { + val newSubprojectBuildDir: Directory = newBuildDir.dir(project.name) + project.layout.buildDirectory.value(newSubprojectBuildDir) +} +subprojects { + project.evaluationDependsOn(":app") +} + +tasks.register("clean") { + delete(rootProject.layout.buildDirectory) +} diff --git a/android/gradle.properties b/android/gradle.properties new file mode 100644 index 0000000..e96108c --- /dev/null +++ b/android/gradle.properties @@ -0,0 +1,6 @@ +org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError +android.useAndroidX=true +# This newDsl flag was added by the Flutter template +android.newDsl=false +# This builtInKotlin flag was added by the Flutter template +android.builtInKotlin=false diff --git a/android/gradle/wrapper/gradle-wrapper.properties b/android/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..a20f2c4 --- /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-9.3.1-all.zip diff --git a/android/settings.gradle.kts b/android/settings.gradle.kts new file mode 100644 index 0000000..b28021a --- /dev/null +++ b/android/settings.gradle.kts @@ -0,0 +1,26 @@ +pluginManagement { + val flutterSdkPath = + run { + val properties = java.util.Properties() + file("local.properties").inputStream().use { properties.load(it) } + val flutterSdkPath = properties.getProperty("flutter.sdk") + require(flutterSdkPath != null) { "flutter.sdk not set in local.properties" } + flutterSdkPath + } + + includeBuild("$flutterSdkPath/packages/flutter_tools/gradle") + + repositories { + google() + mavenCentral() + gradlePluginPortal() + } +} + +plugins { + id("dev.flutter.flutter-plugin-loader") version "1.0.0" + id("com.android.application") version "9.1.0" apply false + id("org.jetbrains.kotlin.android") version "2.4.0" apply false +} + +include(":app") diff --git a/lib/main.dart b/lib/main.dart new file mode 100644 index 0000000..850cfb3 --- /dev/null +++ b/lib/main.dart @@ -0,0 +1,74 @@ +/// Application entry point: wires services, Provider, and [MaterialApp]. +library; + +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +import 'providers/item_provider.dart'; +import 'providers/theme_provider.dart'; +import 'screens/splash_screen.dart'; +import 'services/item_repository.dart'; +import 'services/storage_service.dart'; +import 'utils/app_theme.dart'; +import 'utils/constants.dart'; + +Future main() async { + WidgetsFlutterBinding.ensureInitialized(); + + final storage = StorageService(); + await storage.init(); + + runApp( + MyApp( + storage: storage, + repository: ItemRepository(useRemoteData: storage.useRemoteData), + ), + ); +} + +/// Root widget. Theme and catalog controllers live here so every route +/// can `context.watch` them. +class MyApp extends StatelessWidget { + const MyApp({ + super.key, + required this.storage, + required this.repository, + }); + + final StorageService storage; + final ItemRepository repository; + + @override + Widget build(BuildContext context) { + return MultiProvider( + providers: [ + ChangeNotifierProvider(create: (_) => ThemeController(storage)), + ChangeNotifierProvider( + create: (_) => ItemController( + repository: repository, + storage: storage, + )..loadItems(), + ), + ], + child: const _MaterialRoot(), + ); + } +} + +class _MaterialRoot extends StatelessWidget { + const _MaterialRoot(); + + @override + Widget build(BuildContext context) { + final themeMode = context.watch().themeMode; + + return MaterialApp( + title: kAppName, + debugShowCheckedModeBanner: false, + theme: AppTheme.light(), + darkTheme: AppTheme.dark(), + themeMode: themeMode, + home: const SplashScreen(), + ); + } +} diff --git a/lib/models/item.dart b/lib/models/item.dart new file mode 100644 index 0000000..e5e06ad --- /dev/null +++ b/lib/models/item.dart @@ -0,0 +1,130 @@ +/// Domain models for catalog items shown in lists and detail screens. +library; + +import 'package:flutter/material.dart'; + +/// High-level grouping used for filters, icons, and accent colors. +enum ItemCategory { + design, + development, + productivity, + lifestyle, +} + +extension ItemCategoryX on ItemCategory { + String get label => switch (this) { + ItemCategory.design => 'Design', + ItemCategory.development => 'Development', + ItemCategory.productivity => 'Productivity', + ItemCategory.lifestyle => 'Lifestyle', + }; + + IconData get icon => switch (this) { + ItemCategory.design => Icons.palette_outlined, + ItemCategory.development => Icons.code, + ItemCategory.productivity => Icons.bolt_outlined, + ItemCategory.lifestyle => Icons.spa_outlined, + }; + + /// Stable accent used by [Hero] tiles so the animation has a colored target. + Color get accent => switch (this) { + ItemCategory.design => const Color(0xFF7C3AED), + ItemCategory.development => const Color(0xFF0F766E), + ItemCategory.productivity => const Color(0xFFC2410C), + ItemCategory.lifestyle => const Color(0xFF0369A1), + }; + + static ItemCategory fromName(String name) { + return ItemCategory.values.firstWhere( + (category) => category.name == name, + orElse: () => ItemCategory.productivity, + ); + } + + static ItemCategory fromIndex(int index) { + return ItemCategory.values[index.abs() % ItemCategory.values.length]; + } +} + +/// A catalog entry displayed on Home / Search and opened on the detail route. +class Item { + const Item({ + required this.id, + required this.title, + required this.subtitle, + required this.description, + required this.category, + required this.createdAt, + }); + + final String id; + final String title; + final String subtitle; + final String description; + final ItemCategory category; + final DateTime createdAt; + + /// Tag used by the list-to-detail [Hero] animation. + String get heroTag => 'item-hero-$id'; + + Item copyWith({ + String? id, + String? title, + String? subtitle, + String? description, + ItemCategory? category, + DateTime? createdAt, + }) { + return Item( + id: id ?? this.id, + title: title ?? this.title, + subtitle: subtitle ?? this.subtitle, + description: description ?? this.description, + category: category ?? this.category, + createdAt: createdAt ?? this.createdAt, + ); + } + + /// Maps a JSONPlaceholder `/posts` payload (or any similar JSON object). + factory Item.fromJson(Map json) { + final id = json['id']?.toString() ?? '0'; + final title = (json['title'] as String? ?? 'Untitled').trim(); + final body = (json['body'] as String? ?? '').trim(); + final numericId = int.tryParse(id) ?? 0; + + return Item( + id: 'remote-$id', + title: _titleCase(title), + subtitle: _firstSentence(body), + description: body.replaceAll('\n', ' '), + category: ItemCategoryX.fromIndex(numericId), + createdAt: DateTime(2026, 1, 1).add(Duration(days: numericId)), + ); + } + + Map toJson() { + return { + 'id': id, + 'title': title, + 'subtitle': subtitle, + 'description': description, + 'category': category.name, + 'createdAt': createdAt.toIso8601String(), + }; + } + + static String _titleCase(String value) { + if (value.isEmpty) return value; + return value[0].toUpperCase() + value.substring(1); + } + + static String _firstSentence(String value) { + if (value.isEmpty) return 'No summary available.'; + final cleaned = value.replaceAll('\n', ' '); + final period = cleaned.indexOf('.'); + if (period <= 0) { + return cleaned.length > 80 ? '${cleaned.substring(0, 77)}…' : cleaned; + } + return cleaned.substring(0, period + 1); + } +} diff --git a/lib/providers/item_provider.dart b/lib/providers/item_provider.dart new file mode 100644 index 0000000..e44e7ea --- /dev/null +++ b/lib/providers/item_provider.dart @@ -0,0 +1,93 @@ +/// [ChangeNotifier] that loads, filters, and mutates the in-memory item list. +library; + +import 'package:flutter/foundation.dart'; + +import '../models/item.dart'; +import '../services/item_repository.dart'; +import '../services/storage_service.dart'; + +/// App-wide catalog state. Screens call [loadItems], [addItem], and [search]. +class ItemController extends ChangeNotifier { + ItemController({ + required this._repository, + required this._storage, + }) { + _repository.useRemoteData = _storage.useRemoteData; + } + + final ItemRepository _repository; + final StorageService _storage; + + List _items = const []; + bool _isLoading = false; + String? _error; + + List get items => List.unmodifiable(_items); + bool get isLoading => _isLoading; + String? get error => _error; + bool get useRemoteData => _repository.useRemoteData; + int get count => _items.length; + + Future loadItems() async { + _isLoading = true; + _error = null; + notifyListeners(); + + try { + // Small delay so the loading indicator is visible with local data. + await Future.delayed(const Duration(milliseconds: 250)); + _items = await _repository.fetchItems(); + } catch (error, stackTrace) { + _error = 'Could not load items.'; + debugPrint('ItemController.loadItems failed: $error\n$stackTrace'); + } finally { + _isLoading = false; + notifyListeners(); + } + } + + Future setUseRemoteData(bool value) async { + if (value == _repository.useRemoteData) return; + _repository.useRemoteData = value; + await _storage.setUseRemoteData(value); + notifyListeners(); + await loadItems(); + } + + /// Case-insensitive match against title, subtitle, and category. + List search(String query) { + final needle = query.trim().toLowerCase(); + if (needle.isEmpty) return items; + + return _items.where((item) { + return item.title.toLowerCase().contains(needle) || + item.subtitle.toLowerCase().contains(needle) || + item.category.label.toLowerCase().contains(needle); + }).toList(growable: false); + } + + Item addItem({ + required String title, + required String subtitle, + required String description, + required ItemCategory category, + }) { + final created = _repository.createLocal( + title: title, + subtitle: subtitle, + description: description, + category: category, + ); + _items = [created, ..._items]; + notifyListeners(); + return created; + } + + Item? findById(String id) { + for (final item in _items) { + if (item.id == id) return item; + } + return null; + } +} diff --git a/lib/providers/theme_provider.dart b/lib/providers/theme_provider.dart new file mode 100644 index 0000000..1cecdcd --- /dev/null +++ b/lib/providers/theme_provider.dart @@ -0,0 +1,37 @@ +/// [ChangeNotifier] that owns [ThemeMode] and persists it locally. +library; + +import 'package:flutter/material.dart'; + +import '../services/storage_service.dart'; +import '../utils/helpers.dart'; + +/// Provides [themeMode] to [MaterialApp] and writes changes to storage. +class ThemeController extends ChangeNotifier { + ThemeController(this._storage) { + _themeMode = themeModeFromName(_storage.themeModeName); + } + + final StorageService _storage; + late ThemeMode _themeMode; + + ThemeMode get themeMode => _themeMode; + + bool get isDark => _themeMode == ThemeMode.dark; + + Future setThemeMode(ThemeMode mode) async { + if (mode == _themeMode) return; + _themeMode = mode; + notifyListeners(); + await _storage.setThemeModeName(nameFromThemeMode(mode)); + } + + Future cycleThemeMode() { + final next = switch (_themeMode) { + ThemeMode.system => ThemeMode.light, + ThemeMode.light => ThemeMode.dark, + ThemeMode.dark => ThemeMode.system, + }; + return setThemeMode(next); + } +} diff --git a/lib/screens/add_item_screen.dart b/lib/screens/add_item_screen.dart new file mode 100644 index 0000000..f77f91e --- /dev/null +++ b/lib/screens/add_item_screen.dart @@ -0,0 +1,142 @@ +/// Validated form that inserts a new [Item] into [ItemController]. +library; + +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +import '../models/item.dart'; +import '../providers/item_provider.dart'; +import '../widgets/app_text_field.dart'; +import '../widgets/responsive_body.dart'; + +class AddItemScreen extends StatefulWidget { + const AddItemScreen({super.key}); + + @override + State createState() => _AddItemScreenState(); +} + +class _AddItemScreenState extends State { + final _formKey = GlobalKey(); + final _title = TextEditingController(); + final _subtitle = TextEditingController(); + final _description = TextEditingController(); + ItemCategory? _category; + bool _submitted = false; + + @override + void dispose() { + _title.dispose(); + _subtitle.dispose(); + _description.dispose(); + super.dispose(); + } + + String? _requiredLength(String? value, {required int min, String? label}) { + final text = value?.trim() ?? ''; + if (text.isEmpty) return 'Enter a ${label ?? 'value'}.'; + if (text.length < min) { + return '${label ?? 'This field'} must be at least $min characters.'; + } + return null; + } + + void _submit() { + setState(() => _submitted = true); + final form = _formKey.currentState; + if (form == null || !form.validate() || _category == null) return; + + context.read().addItem( + title: _title.text, + subtitle: _subtitle.text, + description: _description.text, + category: _category!, + ); + + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Added “${_title.text.trim()}”')), + ); + Navigator.of(context).pop(); + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + return Scaffold( + appBar: AppBar(title: const Text('Add item')), + body: ResponsiveBody( + child: Form( + key: _formKey, + autovalidateMode: _submitted + ? AutovalidateMode.onUserInteraction + : AutovalidateMode.disabled, + child: ListView( + children: [ + const SizedBox(height: 8), + Text( + 'Create a catalog entry. All fields are required.', + style: theme.textTheme.bodyMedium, + ), + const SizedBox(height: 20), + AppTextField( + controller: _title, + label: 'Title', + hint: 'Give it a short name', + autofocus: true, + textInputAction: TextInputAction.next, + validator: (value) => + _requiredLength(value, min: 3, label: 'Title'), + ), + const SizedBox(height: 16), + AppTextField( + controller: _subtitle, + label: 'Summary', + hint: 'One sentence overview', + textInputAction: TextInputAction.next, + validator: (value) => + _requiredLength(value, min: 8, label: 'Summary'), + ), + const SizedBox(height: 16), + DropdownButtonFormField( + initialValue: _category, + decoration: const InputDecoration(labelText: 'Category'), + items: [ + for (final category in ItemCategory.values) + DropdownMenuItem( + value: category, + child: Text(category.label), + ), + ], + onChanged: (value) => setState(() => _category = value), + validator: (value) => + value == null ? 'Choose a category.' : null, + ), + const SizedBox(height: 16), + AppTextField( + controller: _description, + label: 'Description', + hint: 'A longer note the detail screen will show', + maxLines: 5, + keyboardType: TextInputType.multiline, + validator: (value) => + _requiredLength(value, min: 16, label: 'Description'), + ), + const SizedBox(height: 24), + FilledButton( + onPressed: _submit, + child: const Text('Save item'), + ), + const SizedBox(height: 12), + OutlinedButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('Cancel'), + ), + const SizedBox(height: 24), + ], + ), + ), + ), + ); + } +} diff --git a/lib/screens/home_screen.dart b/lib/screens/home_screen.dart new file mode 100644 index 0000000..a713562 --- /dev/null +++ b/lib/screens/home_screen.dart @@ -0,0 +1,212 @@ +/// Home tab: greeting, stats, and a [ListView.builder] of catalog items. +library; + +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +import '../providers/item_provider.dart'; +import '../widgets/empty_state.dart'; +import '../widgets/item_card.dart'; +import '../widgets/loading_view.dart'; +import '../widgets/responsive_body.dart'; +import 'add_item_screen.dart'; + +class HomeScreen extends StatelessWidget { + const HomeScreen({super.key}); + + @override + Widget build(BuildContext context) { + final controller = context.watch(); + final theme = Theme.of(context); + + return Scaffold( + appBar: AppBar(title: const Text('Home')), + floatingActionButton: FloatingActionButton.extended( + onPressed: () { + Navigator.of(context).push( + MaterialPageRoute(builder: (_) => const AddItemScreen()), + ); + }, + icon: const Icon(Icons.add), + label: const Text('Add item'), + ), + body: RefreshIndicator( + onRefresh: controller.loadItems, + child: ResponsiveBody( + child: _HomeBody(controller: controller, theme: theme), + ), + ), + ); + } +} + +class _HomeBody extends StatelessWidget { + const _HomeBody({ + required this.controller, + required this.theme, + }); + + final ItemController controller; + final ThemeData theme; + + @override + Widget build(BuildContext context) { + if (controller.isLoading && controller.items.isEmpty) { + return const LoadingView(message: 'Fetching catalog…'); + } + + if (controller.error != null && controller.items.isEmpty) { + return ListView( + physics: const AlwaysScrollableScrollPhysics(), + children: [ + EmptyState( + icon: Icons.error_outline, + title: 'Something went wrong', + message: controller.error!, + actionLabel: 'Retry', + onAction: controller.loadItems, + ), + ], + ); + } + + if (controller.items.isEmpty) { + return ListView( + physics: const AlwaysScrollableScrollPhysics(), + children: const [ + EmptyState( + icon: Icons.inbox_outlined, + title: 'No items yet', + message: 'Add your first item with the button below.', + ), + ], + ); + } + + // Header occupies the first slot; remaining slots are catalog cards. + return ListView.builder( + physics: const AlwaysScrollableScrollPhysics(), + padding: const EdgeInsets.only(top: 8, bottom: 96), + itemCount: controller.items.length + 1, + itemBuilder: (context, index) { + if (index == 0) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _WelcomeBanner(itemCount: controller.count), + const SizedBox(height: 20), + Text('Featured items', style: theme.textTheme.titleMedium), + const SizedBox(height: 12), + ], + ); + } + + final item = controller.items[index - 1]; + return Padding( + padding: const EdgeInsets.only(bottom: 12), + child: ItemCard(item: item, heroScope: 'home'), + ); + }, + ); + } +} + +class _WelcomeBanner extends StatelessWidget { + const _WelcomeBanner({required this.itemCount}); + + final int itemCount; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final colors = theme.colorScheme; + + return Container( + width: double.infinity, + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(20), + gradient: LinearGradient( + colors: [colors.primary, colors.tertiary], + ), + boxShadow: [ + BoxShadow( + color: colors.primary.withValues(alpha: 0.28), + blurRadius: 20, + offset: const Offset(0, 10), + ), + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Welcome back', + style: theme.textTheme.labelMedium?.copyWith( + color: colors.onPrimary.withValues(alpha: 0.8), + ), + ), + const SizedBox(height: 4), + Text( + 'Your catalog at a glance', + style: theme.textTheme.headlineMedium?.copyWith( + color: colors.onPrimary, + ), + ), + const SizedBox(height: 16), + Row( + children: [ + _StatPill( + label: 'Items', + value: '$itemCount', + icon: Icons.collections_bookmark_outlined, + ), + const SizedBox(width: 10), + const _StatPill( + label: 'Theme', + value: 'M3', + icon: Icons.palette_outlined, + ), + ], + ), + ], + ), + ); + } +} + +class _StatPill extends StatelessWidget { + const _StatPill({ + required this.label, + required this.value, + required this.icon, + }); + + final String label; + final String value; + final IconData icon; + + @override + Widget build(BuildContext context) { + final onPrimary = Theme.of(context).colorScheme.onPrimary; + return Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + decoration: BoxDecoration( + color: onPrimary.withValues(alpha: 0.14), + borderRadius: BorderRadius.circular(12), + ), + child: Row( + children: [ + Icon(icon, size: 16, color: onPrimary), + const SizedBox(width: 8), + Text( + '$value $label', + style: Theme.of(context).textTheme.labelMedium?.copyWith( + color: onPrimary, + ), + ), + ], + ), + ); + } +} diff --git a/lib/screens/item_detail_screen.dart b/lib/screens/item_detail_screen.dart new file mode 100644 index 0000000..794fc15 --- /dev/null +++ b/lib/screens/item_detail_screen.dart @@ -0,0 +1,82 @@ +/// Detail route opened from a list card, with a [Hero] matching the list badge. +library; + +import 'package:flutter/material.dart'; + +import '../models/item.dart'; +import '../utils/helpers.dart'; +import '../widgets/item_hero_badge.dart'; +import '../widgets/responsive_body.dart'; + +class ItemDetailScreen extends StatelessWidget { + const ItemDetailScreen({ + super.key, + required this.item, + required this.heroTag, + }); + + final Item item; + final String heroTag; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final colors = theme.colorScheme; + + return Scaffold( + appBar: AppBar(title: const Text('Details')), + body: ResponsiveBody( + child: ListView( + children: [ + const SizedBox(height: 16), + Center( + child: ItemHeroBadge(item: item, size: 96, heroTag: heroTag), + ), + const SizedBox(height: 24), + Text(item.title, style: theme.textTheme.headlineMedium), + const SizedBox(height: 12), + Wrap( + spacing: 8, + runSpacing: 8, + children: [ + Chip( + avatar: Icon( + item.category.icon, + size: 16, + color: item.category.accent, + ), + label: Text(item.category.label), + side: BorderSide( + color: item.category.accent.withValues(alpha: 0.4), + ), + ), + Chip( + avatar: Icon( + Icons.calendar_today_outlined, + size: 16, + color: colors.onSurfaceVariant, + ), + label: Text(formatDate(item.createdAt)), + ), + ], + ), + const SizedBox(height: 20), + Text('Summary', style: theme.textTheme.titleMedium), + const SizedBox(height: 8), + Text(item.subtitle, style: theme.textTheme.bodyLarge), + const SizedBox(height: 20), + Text('Description', style: theme.textTheme.titleMedium), + const SizedBox(height: 8), + Text(item.description, style: theme.textTheme.bodyLarge), + const SizedBox(height: 32), + FilledButton.tonal( + onPressed: () => Navigator.of(context).pop(), + child: const Text('Back to list'), + ), + const SizedBox(height: 24), + ], + ), + ), + ); + } +} diff --git a/lib/screens/main_shell.dart b/lib/screens/main_shell.dart new file mode 100644 index 0000000..5878dbd --- /dev/null +++ b/lib/screens/main_shell.dart @@ -0,0 +1,64 @@ +/// Host scaffold that owns the bottom navigation bar and its four tabs. +library; + +import 'package:flutter/material.dart'; + +import 'home_screen.dart'; +import 'profile_screen.dart'; +import 'search_screen.dart'; +import 'settings_screen.dart'; + +/// Switches between Home, Search, Profile, and Settings without stacking routes. +class MainShell extends StatefulWidget { + const MainShell({super.key}); + + @override + State createState() => _MainShellState(); +} + +class _MainShellState extends State { + int _index = 0; + + static const _pages = [ + HomeScreen(), + SearchScreen(), + ProfileScreen(), + SettingsScreen(), + ]; + + @override + Widget build(BuildContext context) { + return Scaffold( + body: IndexedStack( + index: _index, + children: _pages, + ), + bottomNavigationBar: NavigationBar( + selectedIndex: _index, + onDestinationSelected: (index) => setState(() => _index = index), + destinations: const [ + NavigationDestination( + icon: Icon(Icons.home_outlined), + selectedIcon: Icon(Icons.home), + label: 'Home', + ), + NavigationDestination( + icon: Icon(Icons.search_outlined), + selectedIcon: Icon(Icons.search), + label: 'Search', + ), + NavigationDestination( + icon: Icon(Icons.person_outline), + selectedIcon: Icon(Icons.person), + label: 'Profile', + ), + NavigationDestination( + icon: Icon(Icons.settings_outlined), + selectedIcon: Icon(Icons.settings), + label: 'Settings', + ), + ], + ), + ); + } +} diff --git a/lib/screens/profile_screen.dart b/lib/screens/profile_screen.dart new file mode 100644 index 0000000..b727639 --- /dev/null +++ b/lib/screens/profile_screen.dart @@ -0,0 +1,142 @@ +/// Profile tab: avatar, stats, and a shortcut to the add-item form. +library; + +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +import '../providers/item_provider.dart'; +import '../providers/theme_provider.dart'; +import '../utils/constants.dart'; +import '../utils/helpers.dart'; +import '../widgets/responsive_body.dart'; +import 'add_item_screen.dart'; + +class ProfileScreen extends StatelessWidget { + const ProfileScreen({super.key}); + + @override + Widget build(BuildContext context) { + final items = context.watch(); + final themeController = context.watch(); + final theme = Theme.of(context); + final colors = theme.colorScheme; + + return Scaffold( + appBar: AppBar(title: const Text('Profile')), + body: ResponsiveBody( + child: ListView( + children: [ + const SizedBox(height: 12), + Center( + child: CircleAvatar( + radius: 44, + backgroundColor: colors.primaryContainer, + child: Icon( + Icons.person, + size: 44, + color: colors.onPrimaryContainer, + ), + ), + ), + const SizedBox(height: 16), + Text( + 'Alex Rivera', + textAlign: TextAlign.center, + style: theme.textTheme.headlineMedium, + ), + const SizedBox(height: 4), + Text( + 'alex@example.com', + textAlign: TextAlign.center, + style: theme.textTheme.bodyMedium, + ), + const SizedBox(height: 24), + Row( + children: [ + Expanded( + child: _ProfileStat( + label: 'Items', + value: '${items.count}', + ), + ), + const SizedBox(width: 12), + Expanded( + child: _ProfileStat( + label: 'Theme', + value: labelForThemeMode(themeController.themeMode), + ), + ), + const SizedBox(width: 12), + Expanded( + child: _ProfileStat( + label: 'Source', + value: items.useRemoteData ? 'Remote' : 'Local', + ), + ), + ], + ), + const SizedBox(height: 24), + Card( + child: Column( + children: [ + ListTile( + leading: const Icon(Icons.add_box_outlined), + title: const Text('Add a catalog item'), + subtitle: const Text('Validated form with category picker'), + trailing: const Icon(Icons.chevron_right), + onTap: () { + Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => const AddItemScreen(), + ), + ); + }, + ), + const Divider(height: 1), + const ListTile( + leading: Icon(Icons.info_outline), + title: Text('About'), + subtitle: Text('$kAppName · $kPackageName'), + ), + ], + ), + ), + ], + ), + ), + ); + } +} + +class _ProfileStat extends StatelessWidget { + const _ProfileStat({required this.label, required this.value}); + + final String label; + final String value; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Container( + padding: const EdgeInsets.symmetric(vertical: 16), + decoration: BoxDecoration( + color: theme.colorScheme.surfaceContainerLow, + borderRadius: BorderRadius.circular(16), + boxShadow: [ + BoxShadow( + color: theme.shadowColor.withValues(alpha: 0.06), + blurRadius: 12, + offset: const Offset(0, 4), + ), + ], + ), + child: Column( + children: [ + Text(value, style: theme.textTheme.titleLarge), + const SizedBox(height: 4), + Text(label, style: theme.textTheme.labelMedium), + ], + ), + ); + } +} diff --git a/lib/screens/search_screen.dart b/lib/screens/search_screen.dart new file mode 100644 index 0000000..fcabda3 --- /dev/null +++ b/lib/screens/search_screen.dart @@ -0,0 +1,104 @@ +/// Search tab: query field plus a [ListView.builder] of matching items. +library; + +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +import '../models/item.dart'; +import '../providers/item_provider.dart'; +import '../widgets/empty_state.dart'; +import '../widgets/item_card.dart'; +import '../widgets/responsive_body.dart'; + +class SearchScreen extends StatefulWidget { + const SearchScreen({super.key}); + + @override + State createState() => _SearchScreenState(); +} + +class _SearchScreenState extends State { + final _query = TextEditingController(); + + @override + void dispose() { + _query.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final results = context.watch().search(_query.text); + + return Scaffold( + appBar: AppBar(title: const Text('Search')), + body: ResponsiveBody( + child: Column( + children: [ + const SizedBox(height: 8), + TextField( + controller: _query, + textInputAction: TextInputAction.search, + onChanged: (_) => setState(() {}), + decoration: InputDecoration( + hintText: 'Title, summary, or category', + prefixIcon: const Icon(Icons.search), + suffixIcon: _query.text.isEmpty + ? null + : IconButton( + tooltip: 'Clear', + onPressed: () { + _query.clear(); + setState(() {}); + }, + icon: const Icon(Icons.close), + ), + ), + ), + const SizedBox(height: 8), + Align( + alignment: Alignment.centerLeft, + child: Text( + '${results.length} result${results.length == 1 ? '' : 's'}', + style: Theme.of(context).textTheme.labelMedium, + ), + ), + const SizedBox(height: 8), + Expanded(child: _ResultsList(results: results, query: _query.text)), + ], + ), + ), + ); + } +} + +class _ResultsList extends StatelessWidget { + const _ResultsList({required this.results, required this.query}); + + final List results; + final String query; + + @override + Widget build(BuildContext context) { + if (results.isEmpty) { + return EmptyState( + icon: Icons.search_off, + title: query.trim().isEmpty ? 'Start typing' : 'No matches', + message: query.trim().isEmpty + ? 'Search the catalog by title, summary, or category.' + : 'Try a different keyword.', + ); + } + + return ListView.builder( + padding: const EdgeInsets.only(bottom: 24), + itemCount: results.length, + itemBuilder: (context, index) { + return Padding( + padding: const EdgeInsets.only(bottom: 12), + child: ItemCard(item: results[index], heroScope: 'search'), + ); + }, + ); + } +} diff --git a/lib/screens/settings_screen.dart b/lib/screens/settings_screen.dart new file mode 100644 index 0000000..35b0c9b --- /dev/null +++ b/lib/screens/settings_screen.dart @@ -0,0 +1,104 @@ +/// Settings tab: theme mode, data source, and about information. +library; + +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +import '../providers/item_provider.dart'; +import '../providers/theme_provider.dart'; +import '../utils/constants.dart'; +import '../utils/helpers.dart'; +import '../widgets/responsive_body.dart'; + +class SettingsScreen extends StatelessWidget { + const SettingsScreen({super.key}); + + @override + Widget build(BuildContext context) { + final themeController = context.watch(); + final items = context.watch(); + final theme = Theme.of(context); + + return Scaffold( + appBar: AppBar(title: const Text('Settings')), + body: ResponsiveBody( + child: ListView( + children: [ + const SizedBox(height: 8), + Text('Appearance', style: theme.textTheme.titleMedium), + const SizedBox(height: 8), + Text( + 'Theme is saved with shared_preferences and restored on launch.', + style: theme.textTheme.bodyMedium, + ), + const SizedBox(height: 16), + SegmentedButton( + segments: const [ + ButtonSegment( + value: ThemeMode.system, + label: Text('System'), + icon: Icon(Icons.brightness_auto), + ), + ButtonSegment( + value: ThemeMode.light, + label: Text('Light'), + icon: Icon(Icons.light_mode_outlined), + ), + ButtonSegment( + value: ThemeMode.dark, + label: Text('Dark'), + icon: Icon(Icons.dark_mode_outlined), + ), + ], + selected: {themeController.themeMode}, + onSelectionChanged: (selection) { + themeController.setThemeMode(selection.first); + }, + ), + const SizedBox(height: 28), + Text('Data', style: theme.textTheme.titleMedium), + const SizedBox(height: 8), + Card( + child: SwitchListTile( + title: const Text('Use remote sample API'), + subtitle: const Text( + 'When on, items are fetched with the http package from ' + 'JSONPlaceholder. Failures fall back to local mock data.', + ), + value: items.useRemoteData, + onChanged: (value) => items.setUseRemoteData(value), + ), + ), + const SizedBox(height: 28), + Text('About', style: theme.textTheme.titleMedium), + const SizedBox(height: 8), + Card( + child: Column( + children: [ + const ListTile( + leading: Icon(Icons.apps), + title: Text(kAppName), + subtitle: Text('Generic Flutter Android starter'), + ), + const Divider(height: 1), + const ListTile( + leading: Icon(Icons.fingerprint), + title: Text('Package'), + subtitle: Text(kPackageName), + ), + const Divider(height: 1), + ListTile( + leading: const Icon(Icons.contrast), + title: const Text('Active theme'), + subtitle: Text(labelForThemeMode(themeController.themeMode)), + ), + ], + ), + ), + const SizedBox(height: 24), + ], + ), + ), + ); + } +} diff --git a/lib/screens/splash_screen.dart b/lib/screens/splash_screen.dart new file mode 100644 index 0000000..3e7df2d --- /dev/null +++ b/lib/screens/splash_screen.dart @@ -0,0 +1,130 @@ +/// First route shown after launch. Hands off to [MainShell] after a short delay. +library; + +import 'package:flutter/material.dart'; + +import '../utils/constants.dart'; +import 'main_shell.dart'; + +/// Branded splash with a fade-in logo. Replaced by the bottom-nav shell. +class SplashScreen extends StatefulWidget { + const SplashScreen({super.key}); + + @override + State createState() => _SplashScreenState(); +} + +class _SplashScreenState extends State + with SingleTickerProviderStateMixin { + late final AnimationController _controller; + late final Animation _fade; + late final Animation _scale; + + @override + void initState() { + super.initState(); + _controller = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 700), + ); + _fade = CurvedAnimation(parent: _controller, curve: Curves.easeOut); + _scale = Tween(begin: 0.92, end: 1).animate( + CurvedAnimation(parent: _controller, curve: Curves.easeOutBack), + ); + _controller.forward(); + _goToShell(); + } + + Future _goToShell() async { + await Future.delayed(kSplashDuration); + if (!mounted) return; + Navigator.of(context).pushReplacement( + PageRouteBuilder( + pageBuilder: (_, animation, _) => FadeTransition( + opacity: animation, + child: const MainShell(), + ), + transitionDuration: const Duration(milliseconds: 400), + ), + ); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final colors = Theme.of(context).colorScheme; + final textTheme = Theme.of(context).textTheme; + + return Scaffold( + body: DecoratedBox( + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [ + colors.primary, + colors.primaryContainer, + colors.tertiary, + ], + ), + ), + child: SafeArea( + child: FadeTransition( + opacity: _fade, + child: ScaleTransition( + scale: _scale, + child: Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 88, + height: 88, + decoration: BoxDecoration( + color: colors.onPrimary.withValues(alpha: 0.16), + borderRadius: BorderRadius.circular(24), + ), + child: Icon( + Icons.layers_rounded, + size: 44, + color: colors.onPrimary, + ), + ), + const SizedBox(height: 24), + Text( + kAppName, + style: textTheme.displaySmall?.copyWith( + color: colors.onPrimary, + ), + ), + const SizedBox(height: 8), + Text( + 'A Material 3 starter template', + style: textTheme.bodyLarge?.copyWith( + color: colors.onPrimary.withValues(alpha: 0.85), + ), + ), + const SizedBox(height: 40), + SizedBox( + width: 28, + height: 28, + child: CircularProgressIndicator( + strokeWidth: 3, + color: colors.onPrimary, + ), + ), + ], + ), + ), + ), + ), + ), + ), + ); + } +} diff --git a/lib/services/fake_item_data.dart b/lib/services/fake_item_data.dart new file mode 100644 index 0000000..9dbcdf9 --- /dev/null +++ b/lib/services/fake_item_data.dart @@ -0,0 +1,97 @@ +/// In-memory sample catalog used when the app is offline or in mock mode. +library; + +import '../models/item.dart'; + +/// Static starter data so the UI is useful without a network. +abstract final class FakeItemData { + static final List items = [ + Item( + id: 'local-1', + title: 'Material 3 surfaces', + subtitle: 'Use tonal layers instead of heavy drop shadows.', + description: + 'Material 3 prefers surface tints and short, soft shadows. Keep cards ' + 'on surfaceContainerLow, bump the hero to surfaceContainerHighest, and ' + 'reserve elevation for sheets and dialogs.', + category: ItemCategory.design, + createdAt: DateTime(2026, 4, 12), + ), + Item( + id: 'local-2', + title: 'Provider at the root', + subtitle: 'Lift shared state above the widget that consumes it.', + description: + 'Wrap MaterialApp with MultiProvider so ThemeController and ' + 'ItemController survive route changes. Keep UI widgets dumb: they ' + 'read context.watch and call controller methods.', + category: ItemCategory.development, + createdAt: DateTime(2026, 5, 3), + ), + Item( + id: 'local-3', + title: 'Focus on one task', + subtitle: 'A short list beats an overflowing dashboard.', + description: + 'The home screen shows a greeting, a compact stat strip, and a ' + 'ListView.builder of items. Extra tools live one tap away on Search, ' + 'Profile, and Settings.', + category: ItemCategory.productivity, + createdAt: DateTime(2026, 6, 18), + ), + Item( + id: 'local-4', + title: 'Breathable spacing', + subtitle: '16–24px padding and 12px gaps keep cards from crowding.', + description: + 'On compact phones use 16px page padding. From 600px up, step to 24px ' + 'and cap the content width so lines stay readable on tablets.', + category: ItemCategory.lifestyle, + createdAt: DateTime(2026, 7, 1), + ), + Item( + id: 'local-5', + title: 'Hero transitions', + subtitle: 'Match a tag on the list tile and the detail header.', + description: + 'Wrap the leading glyph in a Hero with a stable tag derived from the ' + 'item id. The detail screen reuses the same widget so Flutter can ' + 'animate size and position.', + category: ItemCategory.design, + createdAt: DateTime(2026, 7, 22), + ), + Item( + id: 'local-6', + title: 'Mock first, HTTP later', + subtitle: 'A repository hides whether data is fake or remote.', + description: + 'ItemRepository serves FakeItemData by default and can switch to ' + 'ItemApi (http + JSONPlaceholder) from Settings. Failures fall back ' + 'to the mock list so the UI never goes empty on a demo device.', + category: ItemCategory.development, + createdAt: DateTime(2026, 8, 4), + ), + Item( + id: 'local-7', + title: 'Capture ideas quickly', + subtitle: 'The add-item form validates before it writes.', + description: + 'Title, summary, and description each have a length rule. Category is ' + 'a required dropdown. On success the new item is inserted at the top ' + 'of ItemController so Home and Search update immediately.', + category: ItemCategory.productivity, + createdAt: DateTime(2026, 8, 15), + ), + Item( + id: 'local-8', + title: 'Light and dark equally', + subtitle: 'Seeded ColorSchemes keep contrast in both modes.', + description: + 'ThemeController persists ThemeMode in shared_preferences. The ' + 'settings screen offers System, Light, and Dark. Both schemes come ' + 'from the same teal seed color.', + category: ItemCategory.lifestyle, + createdAt: DateTime(2026, 8, 20), + ), + ]; +} diff --git a/lib/services/item_api.dart b/lib/services/item_api.dart new file mode 100644 index 0000000..56a417c --- /dev/null +++ b/lib/services/item_api.dart @@ -0,0 +1,61 @@ +/// HTTP client placeholder that talks to a public sample API. +library; + +import 'dart:convert'; + +import 'package:http/http.dart' as http; + +import '../models/item.dart'; + +/// Fetches catalog items from JSONPlaceholder. +/// +/// This is intentionally a thin, replaceable client. Swap [baseUrl] and +/// [Item.fromJson] when you point the app at a real backend. +class ItemApi { + ItemApi({http.Client? client, this.baseUrl = defaultBaseUrl}) + : _client = client ?? http.Client(); + + static const defaultBaseUrl = 'https://jsonplaceholder.typicode.com'; + + final http.Client _client; + final String baseUrl; + + /// GET `/posts` and map each record to an [Item]. + Future> fetchItems({int limit = 12}) async { + final uri = Uri.parse('$baseUrl/posts'); + final response = await _client.get( + uri, + headers: const {'Accept': 'application/json'}, + ); + + if (response.statusCode < 200 || response.statusCode >= 300) { + throw ItemApiException( + 'Request failed (${response.statusCode})', + statusCode: response.statusCode, + ); + } + + final decoded = jsonDecode(response.body); + if (decoded is! List) { + throw const ItemApiException('Unexpected response shape'); + } + + return decoded + .whereType>() + .take(limit) + .map(Item.fromJson) + .toList(growable: false); + } + + void close() => _client.close(); +} + +class ItemApiException implements Exception { + const ItemApiException(this.message, {this.statusCode}); + + final String message; + final int? statusCode; + + @override + String toString() => message; +} diff --git a/lib/services/item_repository.dart b/lib/services/item_repository.dart new file mode 100644 index 0000000..4ae8b48 --- /dev/null +++ b/lib/services/item_repository.dart @@ -0,0 +1,50 @@ +/// Chooses between mock data and the HTTP client, then returns [Item] lists. +library; + +import '../models/item.dart'; +import 'fake_item_data.dart'; +import 'item_api.dart'; + +/// Single entry point for catalog data. UI code should depend on this type, +/// not on [ItemApi] or [FakeItemData] directly. +class ItemRepository { + ItemRepository({ + ItemApi? api, + this.useRemoteData = false, + }) : _api = api ?? ItemApi(); + + final ItemApi _api; + + /// When true, [fetchItems] hits the network; otherwise it returns local fakes. + bool useRemoteData; + + Future> fetchItems() async { + if (!useRemoteData) { + return List.from(FakeItemData.items); + } + + try { + return await _api.fetchItems(); + } catch (_) { + // Keep the demo usable if the device is offline or the API fails. + return List.from(FakeItemData.items); + } + } + + /// Builds a locally-created item (no network round-trip). + Item createLocal({ + required String title, + required String subtitle, + required String description, + required ItemCategory category, + }) { + return Item( + id: 'local-${DateTime.now().microsecondsSinceEpoch}', + title: title.trim(), + subtitle: subtitle.trim(), + description: description.trim(), + category: category, + createdAt: DateTime.now(), + ); + } +} diff --git a/lib/services/storage_service.dart b/lib/services/storage_service.dart new file mode 100644 index 0000000..ea525a5 --- /dev/null +++ b/lib/services/storage_service.dart @@ -0,0 +1,38 @@ +/// Thin wrapper around [SharedPreferences] for local key-value persistence. +library; + +import 'package:shared_preferences/shared_preferences.dart'; + +import '../utils/constants.dart'; + +/// Loads and stores user preferences. Call [init] once at startup. +class StorageService { + StorageService({this._preferences}); + + SharedPreferences? _preferences; + + Future init() async { + _preferences ??= await SharedPreferences.getInstance(); + } + + SharedPreferences get _prefs { + final prefs = _preferences; + if (prefs == null) { + throw StateError('StorageService.init() must be called before use.'); + } + return prefs; + } + + String get themeModeName => + _prefs.getString(StorageKeys.themeMode) ?? 'system'; + + Future setThemeModeName(String value) { + return _prefs.setString(StorageKeys.themeMode, value); + } + + bool get useRemoteData => _prefs.getBool(StorageKeys.useRemoteData) ?? false; + + Future setUseRemoteData(bool value) { + return _prefs.setBool(StorageKeys.useRemoteData, value); + } +} diff --git a/lib/utils/app_theme.dart b/lib/utils/app_theme.dart new file mode 100644 index 0000000..2b8d926 --- /dev/null +++ b/lib/utils/app_theme.dart @@ -0,0 +1,173 @@ +/// Material 3 light and dark themes, including typography and component styles. +library; + +import 'package:flutter/material.dart'; + +/// Builds the [ThemeData] used by [MaterialApp] for light and dark mode. +/// +/// Colors are generated from a single seed so both schemes stay consistent +/// with Material 3 tonal palettes. +abstract final class AppTheme { + static const Color seed = Color(0xFF0F766E); + + static ThemeData light() => _build(Brightness.light); + + static ThemeData dark() => _build(Brightness.dark); + + static ThemeData _build(Brightness brightness) { + final colorScheme = ColorScheme.fromSeed( + seedColor: seed, + brightness: brightness, + ); + + final isDark = brightness == Brightness.dark; + final textTheme = _textTheme(colorScheme); + + return ThemeData( + useMaterial3: true, + colorScheme: colorScheme, + textTheme: textTheme, + scaffoldBackgroundColor: colorScheme.surface, + appBarTheme: AppBarTheme( + centerTitle: false, + elevation: 0, + scrolledUnderElevation: 1, + backgroundColor: colorScheme.surface, + foregroundColor: colorScheme.onSurface, + titleTextStyle: textTheme.titleLarge, + ), + cardTheme: CardThemeData( + elevation: 0, + color: colorScheme.surfaceContainerLow, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + ), + clipBehavior: Clip.antiAlias, + margin: EdgeInsets.zero, + ), + navigationBarTheme: NavigationBarThemeData( + elevation: 0, + height: 72, + backgroundColor: colorScheme.surfaceContainer, + indicatorColor: colorScheme.secondaryContainer, + labelTextStyle: WidgetStateProperty.resolveWith((states) { + final selected = states.contains(WidgetState.selected); + return textTheme.labelMedium?.copyWith( + fontWeight: selected ? FontWeight.w600 : FontWeight.w500, + ); + }), + ), + inputDecorationTheme: InputDecorationTheme( + filled: true, + fillColor: colorScheme.surfaceContainerHighest.withValues(alpha: 0.6), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide(color: colorScheme.outlineVariant), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide(color: colorScheme.primary, width: 2), + ), + contentPadding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 14, + ), + ), + filledButtonTheme: FilledButtonThemeData( + style: FilledButton.styleFrom( + minimumSize: const Size.fromHeight(48), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + textStyle: textTheme.labelLarge, + ), + ), + floatingActionButtonTheme: FloatingActionButtonThemeData( + backgroundColor: colorScheme.primary, + foregroundColor: colorScheme.onPrimary, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + ), + ), + dividerTheme: DividerThemeData( + color: colorScheme.outlineVariant, + space: 1, + ), + snackBarTheme: SnackBarThemeData( + behavior: SnackBarBehavior.floating, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + listTileTheme: ListTileThemeData( + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + iconColor: colorScheme.primary, + ), + shadowColor: isDark ? Colors.black : const Color(0x1A0F172A), + ); + } + + /// Slightly tighter, more editorial type scale than the Material defaults. + static TextTheme _textTheme(ColorScheme colors) { + const family = 'Roboto'; + return TextTheme( + displaySmall: TextStyle( + fontFamily: family, + fontSize: 36, + fontWeight: FontWeight.w700, + letterSpacing: -0.5, + color: colors.onSurface, + ), + headlineMedium: TextStyle( + fontFamily: family, + fontSize: 28, + fontWeight: FontWeight.w600, + letterSpacing: -0.3, + color: colors.onSurface, + ), + titleLarge: TextStyle( + fontFamily: family, + fontSize: 22, + fontWeight: FontWeight.w600, + color: colors.onSurface, + ), + titleMedium: TextStyle( + fontFamily: family, + fontSize: 16, + fontWeight: FontWeight.w600, + color: colors.onSurface, + ), + bodyLarge: TextStyle( + fontFamily: family, + fontSize: 16, + height: 1.45, + color: colors.onSurface, + ), + bodyMedium: TextStyle( + fontFamily: family, + fontSize: 14, + height: 1.4, + color: colors.onSurfaceVariant, + ), + labelLarge: TextStyle( + fontFamily: family, + fontSize: 14, + fontWeight: FontWeight.w600, + letterSpacing: 0.1, + color: colors.onPrimary, + ), + labelMedium: TextStyle( + fontFamily: family, + fontSize: 12, + fontWeight: FontWeight.w500, + letterSpacing: 0.4, + color: colors.onSurfaceVariant, + ), + ); + } +} diff --git a/lib/utils/constants.dart b/lib/utils/constants.dart new file mode 100644 index 0000000..dcacb69 --- /dev/null +++ b/lib/utils/constants.dart @@ -0,0 +1,34 @@ +/// Shared string keys, timings, and layout breakpoints used across the app. +library; + +/// Human-readable name shown in the splash screen, app bars, and About tile. +const String kAppName = 'MyApp'; + +/// Package / Android application id. +const String kPackageName = 'com.example.myapp'; + +/// How long the splash screen stays visible before navigating to the shell. +const Duration kSplashDuration = Duration(seconds: 2); + +/// SharedPreferences keys. +abstract final class StorageKeys { + static const themeMode = 'theme_mode'; + static const useRemoteData = 'use_remote_data'; +} + +/// Named routes registered on [MaterialApp]. +abstract final class AppRoutes { + static const splash = '/'; + static const shell = '/shell'; + static const itemDetail = '/item'; + static const addItem = '/add-item'; +} + +/// Width thresholds used to adapt padding and card density. +abstract final class Breakpoints { + static const compact = 600.0; + static const medium = 840.0; +} + +/// Maximum content width on tablets / large phones so lists stay readable. +const double kMaxContentWidth = 720.0; diff --git a/lib/utils/helpers.dart b/lib/utils/helpers.dart new file mode 100644 index 0000000..4d6e0fb --- /dev/null +++ b/lib/utils/helpers.dart @@ -0,0 +1,59 @@ +/// Small pure helpers for layout, formatting, and user-facing strings. +library; + +import 'package:flutter/material.dart'; + +import 'constants.dart'; + +/// Horizontal padding that grows a little on wider screens. +double pagePadding(double width) { + if (width >= Breakpoints.medium) return 32; + if (width >= Breakpoints.compact) return 24; + return 16; +} + +/// Formats [date] as `MMM d, y` without pulling in `intl`. +String formatDate(DateTime date) { + const months = [ + 'Jan', + 'Feb', + 'Mar', + 'Apr', + 'May', + 'Jun', + 'Jul', + 'Aug', + 'Sep', + 'Oct', + 'Nov', + 'Dec', + ]; + return '${months[date.month - 1]} ${date.day}, ${date.year}'; +} + +/// Converts a stored theme key into a [ThemeMode]. +ThemeMode themeModeFromName(String name) { + return switch (name) { + 'light' => ThemeMode.light, + 'dark' => ThemeMode.dark, + _ => ThemeMode.system, + }; +} + +/// Inverse of [themeModeFromName] for persistence. +String nameFromThemeMode(ThemeMode mode) { + return switch (mode) { + ThemeMode.light => 'light', + ThemeMode.dark => 'dark', + ThemeMode.system => 'system', + }; +} + +/// Title-cases a [ThemeMode] for settings labels. +String labelForThemeMode(ThemeMode mode) { + return switch (mode) { + ThemeMode.light => 'Light', + ThemeMode.dark => 'Dark', + ThemeMode.system => 'System', + }; +} diff --git a/lib/widgets/app_text_field.dart b/lib/widgets/app_text_field.dart new file mode 100644 index 0000000..4cdeb3e --- /dev/null +++ b/lib/widgets/app_text_field.dart @@ -0,0 +1,44 @@ +/// Labeled text field with a shared decoration used by the add-item form. +library; + +import 'package:flutter/material.dart'; + +class AppTextField extends StatelessWidget { + const AppTextField({ + super.key, + required this.controller, + required this.label, + this.hint, + this.maxLines = 1, + this.textInputAction, + this.keyboardType, + this.validator, + this.autofocus = false, + }); + + final TextEditingController controller; + final String label; + final String? hint; + final int maxLines; + final TextInputAction? textInputAction; + final TextInputType? keyboardType; + final String? Function(String?)? validator; + final bool autofocus; + + @override + Widget build(BuildContext context) { + return TextFormField( + controller: controller, + maxLines: maxLines, + autofocus: autofocus, + textInputAction: textInputAction, + keyboardType: keyboardType, + validator: validator, + decoration: InputDecoration( + labelText: label, + hintText: hint, + alignLabelWithHint: maxLines > 1, + ), + ); + } +} diff --git a/lib/widgets/empty_state.dart b/lib/widgets/empty_state.dart new file mode 100644 index 0000000..088c92e --- /dev/null +++ b/lib/widgets/empty_state.dart @@ -0,0 +1,51 @@ +/// Placeholder shown when a list or search has nothing to display. +library; + +import 'package:flutter/material.dart'; + +class EmptyState extends StatelessWidget { + const EmptyState({ + super.key, + required this.icon, + required this.title, + required this.message, + this.actionLabel, + this.onAction, + }); + + final IconData icon; + final String title; + final String message; + final String? actionLabel; + final VoidCallback? onAction; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final colors = theme.colorScheme; + + return Center( + child: Padding( + padding: const EdgeInsets.all(32), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, size: 48, color: colors.outline), + const SizedBox(height: 16), + Text(title, style: theme.textTheme.titleMedium), + const SizedBox(height: 8), + Text( + message, + textAlign: TextAlign.center, + style: theme.textTheme.bodyMedium, + ), + if (actionLabel != null && onAction != null) ...[ + const SizedBox(height: 20), + FilledButton(onPressed: onAction, child: Text(actionLabel!)), + ], + ], + ), + ), + ); + } +} diff --git a/lib/widgets/item_card.dart b/lib/widgets/item_card.dart new file mode 100644 index 0000000..71ed062 --- /dev/null +++ b/lib/widgets/item_card.dart @@ -0,0 +1,127 @@ +/// Tappable card used inside [ListView.builder] on Home and Search. +library; + +import 'package:flutter/material.dart'; + +import '../models/item.dart'; +import '../screens/item_detail_screen.dart'; +import '../utils/helpers.dart'; +import 'item_hero_badge.dart'; + +/// Displays one [Item] as a rounded, shadowed card that opens the detail route. +class ItemCard extends StatelessWidget { + const ItemCard({ + super.key, + required this.item, + this.heroScope = 'home', + }); + + final Item item; + + /// Prefix so Home and Search can show the same item without colliding Heroes. + final String heroScope; + + String get heroTag => '$heroScope-${item.heroTag}'; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final colors = theme.colorScheme; + + return DecoratedBox( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(16), + boxShadow: [ + BoxShadow( + color: theme.shadowColor.withValues(alpha: 0.08), + blurRadius: 18, + offset: const Offset(0, 8), + ), + ], + ), + child: Material( + color: colors.surfaceContainerLow, + borderRadius: BorderRadius.circular(16), + child: InkWell( + borderRadius: BorderRadius.circular(16), + onTap: () { + Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => ItemDetailScreen( + item: item, + heroTag: heroTag, + ), + ), + ); + }, + child: Padding( + padding: const EdgeInsets.all(16), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + ItemHeroBadge(item: item, heroTag: heroTag), + const SizedBox(width: 14), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(item.title, style: theme.textTheme.titleMedium), + const SizedBox(height: 4), + Text( + item.subtitle, + style: theme.textTheme.bodyMedium, + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 10), + Wrap( + spacing: 8, + runSpacing: 4, + children: [ + _MetaChip( + label: item.category.label, + color: item.category.accent, + ), + _MetaChip( + label: formatDate(item.createdAt), + color: colors.outline, + ), + ], + ), + ], + ), + ), + Icon( + Icons.chevron_right, + color: colors.outline, + ), + ], + ), + ), + ), + ), + ); + } +} + +class _MetaChip extends StatelessWidget { + const _MetaChip({required this.label, required this.color}); + + final String label; + final Color color; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), + decoration: BoxDecoration( + color: color.withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(8), + ), + child: Text( + label, + style: Theme.of(context).textTheme.labelMedium?.copyWith(color: color), + ), + ); + } +} diff --git a/lib/widgets/item_hero_badge.dart b/lib/widgets/item_hero_badge.dart new file mode 100644 index 0000000..fbbb30b --- /dev/null +++ b/lib/widgets/item_hero_badge.dart @@ -0,0 +1,54 @@ +/// Colored glyph used as the [Hero] flight target for an [Item]. +library; + +import 'package:flutter/material.dart'; + +import '../models/item.dart'; + +/// Rounded square that carries the category icon. Size is animated by [Hero]. +class ItemHeroBadge extends StatelessWidget { + const ItemHeroBadge({ + super.key, + required this.item, + required this.heroTag, + this.size = 48, + }); + + final Item item; + final double size; + + /// Unique among on-screen Heroes. Home and Search both list the same items, + /// so callers pass a scoped tag (for example `home-item-hero-1`). + final String heroTag; + + @override + Widget build(BuildContext context) { + final radius = size * 0.28; + return Hero( + tag: heroTag, + child: Material( + color: Colors.transparent, + child: Container( + width: size, + height: size, + decoration: BoxDecoration( + color: item.category.accent, + borderRadius: BorderRadius.circular(radius), + boxShadow: [ + BoxShadow( + color: item.category.accent.withValues(alpha: 0.35), + blurRadius: size * 0.18, + offset: Offset(0, size * 0.08), + ), + ], + ), + child: Icon( + item.category.icon, + color: Colors.white, + size: size * 0.5, + ), + ), + ), + ); + } +} diff --git a/lib/widgets/loading_view.dart b/lib/widgets/loading_view.dart new file mode 100644 index 0000000..1ad7652 --- /dev/null +++ b/lib/widgets/loading_view.dart @@ -0,0 +1,24 @@ +/// Centered spinner used while [ItemController] is fetching data. +library; + +import 'package:flutter/material.dart'; + +class LoadingView extends StatelessWidget { + const LoadingView({super.key, this.message = 'Loading…'}); + + final String message; + + @override + Widget build(BuildContext context) { + return Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const CircularProgressIndicator(), + const SizedBox(height: 16), + Text(message, style: Theme.of(context).textTheme.bodyMedium), + ], + ), + ); + } +} diff --git a/lib/widgets/responsive_body.dart b/lib/widgets/responsive_body.dart new file mode 100644 index 0000000..089fea2 --- /dev/null +++ b/lib/widgets/responsive_body.dart @@ -0,0 +1,34 @@ +/// Shared layout helper that centers content and caps width on large screens. +library; + +import 'package:flutter/material.dart'; + +import '../utils/constants.dart'; +import '../utils/helpers.dart'; + +/// Wraps [child] with responsive horizontal padding and an optional max width. +class ResponsiveBody extends StatelessWidget { + const ResponsiveBody({ + super.key, + required this.child, + this.maxWidth = kMaxContentWidth, + }); + + final Widget child; + final double maxWidth; + + @override + Widget build(BuildContext context) { + final width = MediaQuery.sizeOf(context).width; + return Align( + alignment: Alignment.topCenter, + child: ConstrainedBox( + constraints: BoxConstraints(maxWidth: maxWidth), + child: Padding( + padding: EdgeInsets.symmetric(horizontal: pagePadding(width)), + child: child, + ), + ), + ); + } +} diff --git a/pubspec.lock b/pubspec.lock new file mode 100644 index 0000000..5600031 --- /dev/null +++ b/pubspec.lock @@ -0,0 +1,386 @@ +# Generated by pub +# See https://dart.dev/tools/pub/glossary#lockfile +packages: + async: + dependency: transitive + description: + name: async + sha256: e2eb0491ba5ddb6177742d2da23904574082139b07c1e33b8503b9f46f3e1a37 + url: "https://pub.dev" + source: hosted + version: "2.13.1" + boolean_selector: + dependency: transitive + description: + name: boolean_selector + sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea" + url: "https://pub.dev" + source: hosted + version: "2.1.2" + characters: + dependency: transitive + description: + name: characters + sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b + url: "https://pub.dev" + source: hosted + version: "1.4.1" + clock: + dependency: transitive + description: + name: clock + sha256: fddb70d9b5277016c77a80201021d40a2247104d9f4aa7bab7157b7e3f05b84b + url: "https://pub.dev" + source: hosted + version: "1.1.2" + collection: + dependency: transitive + description: + name: collection + sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76" + url: "https://pub.dev" + source: hosted + version: "1.19.1" + cupertino_icons: + dependency: "direct main" + description: + name: cupertino_icons + sha256: "41e005c33bd814be4d3096aff55b1908d419fde52ca656c8c47719ec745873cd" + url: "https://pub.dev" + source: hosted + version: "1.0.9" + fake_async: + dependency: transitive + description: + name: fake_async + sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44" + url: "https://pub.dev" + source: hosted + version: "1.3.3" + ffi: + dependency: transitive + description: + name: ffi + sha256: "6d7fd89431262d8f3125e81b50d3847a091d846eafcd4fdb88dd06f36d705a45" + url: "https://pub.dev" + source: hosted + version: "2.2.0" + file: + dependency: transitive + description: + name: file + sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4 + url: "https://pub.dev" + source: hosted + version: "7.0.1" + flutter: + dependency: "direct main" + description: flutter + source: sdk + version: "0.0.0" + flutter_lints: + dependency: "direct dev" + description: + name: flutter_lints + sha256: "3105dc8492f6183fb076ccf1f351ac3d60564bff92e20bfc4af9cc1651f4e7e1" + url: "https://pub.dev" + source: hosted + version: "6.0.0" + flutter_test: + dependency: "direct dev" + description: flutter + source: sdk + version: "0.0.0" + flutter_web_plugins: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + http: + dependency: "direct main" + description: + name: http + sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412" + url: "https://pub.dev" + source: hosted + version: "1.6.0" + http_parser: + dependency: transitive + description: + name: http_parser + sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571" + url: "https://pub.dev" + source: hosted + version: "4.1.2" + leak_tracker: + dependency: transitive + description: + name: leak_tracker + sha256: "33e2e26bdd85a0112ec15400c8cbffea70d0f9c3407491f672a2fad47915e2de" + url: "https://pub.dev" + source: hosted + version: "11.0.2" + leak_tracker_flutter_testing: + dependency: transitive + description: + name: leak_tracker_flutter_testing + sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1" + url: "https://pub.dev" + source: hosted + version: "3.0.10" + leak_tracker_testing: + dependency: transitive + description: + name: leak_tracker_testing + sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1" + url: "https://pub.dev" + source: hosted + version: "3.0.2" + lints: + dependency: transitive + description: + name: lints + sha256: "12f842a479589fea194fe5c5a3095abc7be0c1f2ddfa9a0e76aed1dbd26a87df" + url: "https://pub.dev" + source: hosted + version: "6.1.0" + matcher: + dependency: transitive + description: + name: matcher + sha256: "31bd099b47c10cd1aeb55146a2d46ce0277630ecef3f7dae54ad7873f36696cd" + url: "https://pub.dev" + source: hosted + version: "0.12.20" + material_color_utilities: + dependency: transitive + description: + name: material_color_utilities + sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b" + url: "https://pub.dev" + source: hosted + version: "0.13.0" + meta: + dependency: transitive + description: + name: meta + sha256: c82594181e3312f3d0695fc95aaaf7758d75b8d4ae2bbecf223b9fd5109a059d + url: "https://pub.dev" + source: hosted + version: "1.18.3" + nested: + dependency: transitive + description: + name: nested + sha256: "03bac4c528c64c95c722ec99280375a6f2fc708eec17c7b3f07253b626cd2a20" + url: "https://pub.dev" + source: hosted + version: "1.0.0" + path: + dependency: transitive + description: + name: path + sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5" + url: "https://pub.dev" + source: hosted + version: "1.9.1" + path_provider_linux: + dependency: transitive + description: + name: path_provider_linux + sha256: "58c2005f147315b11e9b4a7bc889cd5203e250cba8e3f012dae259b4972b5c16" + url: "https://pub.dev" + source: hosted + version: "2.2.2" + path_provider_platform_interface: + dependency: transitive + description: + name: path_provider_platform_interface + sha256: "484838772624c3a4b94f1e44a3e19897fee738f2d5c4ce448443b0417f7c9dda" + url: "https://pub.dev" + source: hosted + version: "2.1.3" + path_provider_windows: + dependency: transitive + description: + name: path_provider_windows + sha256: bd6f00dbd873bfb70d0761682da2b3a2c2fccc2b9e84c495821639601d81afe7 + url: "https://pub.dev" + source: hosted + version: "2.3.0" + platform: + dependency: transitive + description: + name: platform + sha256: "5d6b1b0036a5f331ebc77c850ebc8506cbc1e9416c27e59b439f917a902a4984" + url: "https://pub.dev" + source: hosted + version: "3.1.6" + plugin_platform_interface: + dependency: transitive + description: + name: plugin_platform_interface + sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02" + url: "https://pub.dev" + source: hosted + version: "2.1.8" + provider: + dependency: "direct main" + description: + name: provider + sha256: "4e82183fa20e5ca25703ead7e05de9e4cceed1fbd1eadc1ac3cb6f565a09f272" + url: "https://pub.dev" + source: hosted + version: "6.1.5+1" + shared_preferences: + dependency: "direct main" + description: + name: shared_preferences + sha256: c3025c5534b01739267eb7d76959bbc25a6d10f6988e1c2a3036940133dd10bf + url: "https://pub.dev" + source: hosted + version: "2.5.5" + shared_preferences_android: + dependency: transitive + description: + name: shared_preferences_android + sha256: "1e12aafe408aa50da80edfd679a2a6bf63ba7ab37c7fa98286da459a757b3399" + url: "https://pub.dev" + source: hosted + version: "2.4.28" + shared_preferences_foundation: + dependency: transitive + description: + name: shared_preferences_foundation + sha256: "2ec3934efa51e46117f23031cc141b8fc878e8525b94ec1ea4f7f586cf1b47ea" + url: "https://pub.dev" + source: hosted + version: "2.5.7" + shared_preferences_linux: + dependency: transitive + description: + name: shared_preferences_linux + sha256: "580abfd40f415611503cae30adf626e6656dfb2f0cee8f465ece7b6defb40f2f" + url: "https://pub.dev" + source: hosted + version: "2.4.1" + shared_preferences_platform_interface: + dependency: transitive + description: + name: shared_preferences_platform_interface + sha256: "649dc798a33931919ea356c4305c2d1f81619ea6e92244070b520187b5140ef9" + url: "https://pub.dev" + source: hosted + version: "2.4.2" + shared_preferences_web: + dependency: transitive + description: + name: shared_preferences_web + sha256: c49bd060261c9a3f0ff445892695d6212ff603ef3115edbb448509d407600019 + url: "https://pub.dev" + source: hosted + version: "2.4.3" + shared_preferences_windows: + dependency: transitive + description: + name: shared_preferences_windows + sha256: "94ef0f72b2d71bc3e700e025db3710911bd51a71cefb65cc609dd0d9a982e3c1" + url: "https://pub.dev" + source: hosted + version: "2.4.1" + sky_engine: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + source_span: + dependency: transitive + description: + name: source_span + sha256: "56a02f1f4cd1a2d96303c0144c93bd6d909eea6bee6bf5a0e0b685edbd4c47ab" + url: "https://pub.dev" + source: hosted + version: "1.10.2" + stack_trace: + dependency: transitive + description: + name: stack_trace + sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1" + url: "https://pub.dev" + source: hosted + version: "1.12.1" + stream_channel: + dependency: transitive + description: + name: stream_channel + sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d" + url: "https://pub.dev" + source: hosted + version: "2.1.4" + string_scanner: + dependency: transitive + description: + name: string_scanner + sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43" + url: "https://pub.dev" + source: hosted + version: "1.4.1" + term_glyph: + dependency: transitive + description: + name: term_glyph + sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e" + url: "https://pub.dev" + source: hosted + version: "1.2.2" + test_api: + dependency: transitive + description: + name: test_api + sha256: "2a122cbe059f8b610d3a5415f42e255b6c17b1f21eee1d960f31080237fb4f11" + url: "https://pub.dev" + source: hosted + version: "0.7.12" + typed_data: + dependency: transitive + description: + name: typed_data + sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006 + url: "https://pub.dev" + source: hosted + version: "1.4.0" + vector_math: + dependency: transitive + description: + name: vector_math + sha256: "1d774bbdf6b72a0b12122fc1560c9c2d2a67db5a4a4cc2bd8a5c990ab20e3188" + url: "https://pub.dev" + source: hosted + version: "2.4.0" + vm_service: + dependency: transitive + description: + name: vm_service + sha256: "5f37239c4851efcef929cea7824e76df7f2f0970aef85d66bbc430afa40e72f0" + url: "https://pub.dev" + source: hosted + version: "15.3.0" + web: + dependency: transitive + description: + name: web + sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a" + url: "https://pub.dev" + source: hosted + version: "1.1.1" + xdg_directories: + dependency: transitive + description: + name: xdg_directories + sha256: "7a3f37b05d989967cdddcbb571f1ea834867ae2faa29725fd085180e0883aa15" + url: "https://pub.dev" + source: hosted + version: "1.1.0" +sdks: + dart: ">=3.13.2 <4.0.0" + flutter: ">=3.44.0" diff --git a/pubspec.yaml b/pubspec.yaml new file mode 100644 index 0000000..20fabd9 --- /dev/null +++ b/pubspec.yaml @@ -0,0 +1,92 @@ +name: myapp +description: Generic Flutter Android starter with Material 3, Provider, and local persistence. +# The following line prevents the package from being accidentally published to +# pub.dev using `flutter pub publish`. This is preferred for private packages. +publish_to: 'none' # Remove this line if you wish to publish to pub.dev + +# The following defines the version and build number for your application. +# A version number is three numbers separated by dots, like 1.2.43 +# followed by an optional build number separated by a +. +# Both the version and the builder number may be overridden in flutter +# build by specifying --build-name and --build-number, respectively. +# In Android, build-name is used as versionName while build-number used as versionCode. +# Read more about Android versioning at https://developer.android.com/studio/publish/versioning +# In iOS, build-name is used as CFBundleShortVersionString while build-number is used as CFBundleVersion. +# Read more about iOS versioning at +# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html +# In Windows, build-name is used as the major, minor, and patch parts +# of the product and file versions while build-number is used as the build suffix. +version: 1.0.0+1 + +environment: + sdk: ^3.13.2 + +# Dependencies specify other packages that your package needs in order to work. +# To automatically upgrade your package dependencies to the latest versions +# consider running `flutter pub upgrade --major-versions`. Alternatively, +# dependencies can be manually updated by changing the version numbers below to +# the latest version available on pub.dev. To see which dependencies have newer +# versions available, run `flutter pub outdated`. +dependencies: + flutter: + sdk: flutter + + # The following adds the Cupertino Icons font to your application. + # Use with the CupertinoIcons class for iOS style icons. + cupertino_icons: ^1.0.8 + provider: ^6.1.5+1 # ChangeNotifier state for theme and catalog + http: ^1.6.0 # Placeholder network client (JSONPlaceholder) + shared_preferences: ^2.5.5 # Persist theme mode and data-source toggle + +dev_dependencies: + flutter_test: + sdk: flutter + + # The "flutter_lints" package below contains a set of recommended lints to + # encourage good coding practices. The lint set provided by the package is + # activated in the `analysis_options.yaml` file located at the root of your + # package. See that file for information about deactivating specific lint + # rules and activating additional ones. + flutter_lints: ^6.0.0 + +# For information on the generic Dart part of this file, see the +# following page: https://dart.dev/tools/pub/pubspec + +# The following section is specific to Flutter packages. +flutter: + + # The following line ensures that the Material Icons font is + # included with your application, so that you can use the icons in + # the material Icons class. + uses-material-design: true + + # To add assets to your application, add an assets section, like this: + # assets: + # - images/a_dot_burr.jpeg + # - images/a_dot_ham.jpeg + + # An image asset can refer to one or more resolution-specific "variants", see + # https://flutter.dev/to/resolution-aware-images + + # For details regarding adding assets from package dependencies, see + # https://flutter.dev/to/asset-from-package + + # To add custom fonts to your application, add a fonts section here, + # in this "flutter" section. Each entry in this list should have a + # "family" key with the font family name, and a "fonts" key with a + # list giving the asset and other descriptors for the font. For + # example: + # fonts: + # - family: Schyler + # fonts: + # - asset: fonts/Schyler-Regular.ttf + # - asset: fonts/Schyler-Italic.ttf + # style: italic + # - family: Trajan Pro + # fonts: + # - asset: fonts/TrajanPro.ttf + # - asset: fonts/TrajanPro_Bold.ttf + # weight: 700 + # + # For details regarding fonts from package dependencies, + # see https://flutter.dev/to/font-from-package diff --git a/test/widget_test.dart b/test/widget_test.dart new file mode 100644 index 0000000..9793445 --- /dev/null +++ b/test/widget_test.dart @@ -0,0 +1,66 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:myapp/main.dart'; +import 'package:myapp/models/item.dart'; +import 'package:myapp/services/item_repository.dart'; +import 'package:myapp/services/storage_service.dart'; +import 'package:myapp/utils/constants.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + Future pumpApp(WidgetTester tester) async { + SharedPreferences.setMockInitialValues({}); + final storage = StorageService(); + await storage.init(); + await tester.pumpWidget( + MyApp( + storage: storage, + repository: ItemRepository(), + ), + ); + } + + testWidgets('splash shows the app name then opens Home', (tester) async { + await pumpApp(tester); + + expect(find.text(kAppName), findsWidgets); + + await tester.pump(kSplashDuration); + await tester.pump(const Duration(milliseconds: 450)); + await tester.pump(const Duration(milliseconds: 350)); + + expect(find.text('Home'), findsWidgets); + expect(find.text('Featured items'), findsOneWidget); + expect(find.text('Search'), findsOneWidget); + expect(find.text('Profile'), findsOneWidget); + expect(find.text('Settings'), findsOneWidget); + }); + + testWidgets('tapping a list card opens the detail screen', (tester) async { + await pumpApp(tester); + await tester.pump(kSplashDuration); + await tester.pump(const Duration(milliseconds: 450)); + await tester.pump(const Duration(milliseconds: 350)); + + await tester.tap(find.text('Material 3 surfaces')); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 400)); + + expect(find.text('Details'), findsOneWidget); + expect(find.text('Back to list'), findsOneWidget); + }); + + test('Item.fromJson maps a JSONPlaceholder post', () { + final item = Item.fromJson({ + 'id': 3, + 'title': 'hello world', + 'body': 'First sentence. Second sentence.', + }); + + expect(item.id, 'remote-3'); + expect(item.title, 'Hello world'); + expect(item.subtitle, 'First sentence.'); + expect(item.category, ItemCategory.lifestyle); + }); +}