From 39a4f3283f7c9cb3acba1f5fd3a1d468066c3397 Mon Sep 17 00:00:00 2001 From: rbhat Date: Fri, 10 Apr 2026 19:08:30 +0530 Subject: [PATCH] Initialize project and update portal port configuration Set default portal port to 8081, fix Dart build issue in cart screen, and update setup documentation. Co-Authored-By: Oz --- .dockerignore | 8 + .env.example | 10 ++ Dockerfile | 23 +++ README.md | 81 +++++++++ assets/config/runtime_config.json | 9 + assets/config/runtime_config.template.json | 9 + docker-compose.yml | 20 +++ .../entrypoint/30-generate-runtime-config.sh | 21 +++ docker/nginx/default.conf | 16 ++ install_portal.sh | 27 +++ lib/main.dart | 34 ++++ lib/models/cart_item.dart | 10 ++ lib/models/product.dart | 15 ++ lib/screens/auth_gate.dart | 30 ++++ lib/screens/cart_screen.dart | 99 +++++++++++ lib/screens/checkout_screen.dart | 39 ++++ lib/screens/forgot_password_screen.dart | 94 ++++++++++ lib/screens/home_screen.dart | 121 +++++++++++++ lib/screens/login_screen.dart | 168 ++++++++++++++++++ lib/screens/product_detail_screen.dart | 72 ++++++++ lib/screens/profile_screen.dart | 53 ++++++ lib/screens/register_screen.dart | 125 +++++++++++++ lib/services/app_config_service.dart | 47 +++++ lib/services/auth_service.dart | 48 +++++ lib/services/cart_service.dart | 43 +++++ lib/services/product_repository.dart | 55 ++++++ lib/widgets/product_card.dart | 69 +++++++ pubspec.yaml | 25 +++ uninstall_portal.sh | 34 ++++ 29 files changed, 1405 insertions(+) create mode 100644 .dockerignore create mode 100644 .env.example create mode 100644 Dockerfile create mode 100644 README.md create mode 100644 assets/config/runtime_config.json create mode 100644 assets/config/runtime_config.template.json create mode 100644 docker-compose.yml create mode 100644 docker/entrypoint/30-generate-runtime-config.sh create mode 100644 docker/nginx/default.conf create mode 100755 install_portal.sh create mode 100644 lib/main.dart create mode 100644 lib/models/cart_item.dart create mode 100644 lib/models/product.dart create mode 100644 lib/screens/auth_gate.dart create mode 100644 lib/screens/cart_screen.dart create mode 100644 lib/screens/checkout_screen.dart create mode 100644 lib/screens/forgot_password_screen.dart create mode 100644 lib/screens/home_screen.dart create mode 100644 lib/screens/login_screen.dart create mode 100644 lib/screens/product_detail_screen.dart create mode 100644 lib/screens/profile_screen.dart create mode 100644 lib/screens/register_screen.dart create mode 100644 lib/services/app_config_service.dart create mode 100644 lib/services/auth_service.dart create mode 100644 lib/services/cart_service.dart create mode 100644 lib/services/product_repository.dart create mode 100644 lib/widgets/product_card.dart create mode 100644 pubspec.yaml create mode 100755 uninstall_portal.sh diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..d7d837f --- /dev/null +++ b/.dockerignore @@ -0,0 +1,8 @@ +.git +.gitignore +build +.dart_tool +.idea +.vscode +*.iml +README.md diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..172b1b1 --- /dev/null +++ b/.env.example @@ -0,0 +1,10 @@ +PORTAL_PORT=8081 +PORTAL_TITLE=Flutter E-Commerce Portal + +FIREBASE_API_KEY=replace-with-firebase-api-key +FIREBASE_AUTH_DOMAIN=replace-with-project-id.firebaseapp.com +FIREBASE_PROJECT_ID=replace-with-project-id +FIREBASE_STORAGE_BUCKET=replace-with-project-id.appspot.com +FIREBASE_MESSAGING_SENDER_ID=replace-with-sender-id +FIREBASE_APP_ID=replace-with-app-id +FIREBASE_MEASUREMENT_ID= diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..2589dc9 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,23 @@ +FROM ghcr.io/cirruslabs/flutter:stable AS build + +WORKDIR /workspace/app +RUN flutter config --enable-web +RUN flutter create --platforms=web --project-name flutter_ecommerce_portal --org com.example.portal /workspace/app + +COPY pubspec.yaml /workspace/app/pubspec.yaml +RUN flutter pub get + +COPY lib /workspace/app/lib +COPY assets /workspace/app/assets +RUN flutter build web --release + +FROM nginx:1.27-alpine AS runtime + +COPY --from=build /workspace/app/build/web /usr/share/nginx/html +COPY docker/nginx/default.conf /etc/nginx/conf.d/default.conf +COPY docker/entrypoint/30-generate-runtime-config.sh /docker-entrypoint.d/30-generate-runtime-config.sh +COPY assets/config/runtime_config.template.json /opt/runtime_config.template.json + +RUN chmod +x /docker-entrypoint.d/30-generate-runtime-config.sh + +EXPOSE 80 diff --git a/README.md b/README.md new file mode 100644 index 0000000..40d9c1a --- /dev/null +++ b/README.md @@ -0,0 +1,81 @@ +# Flutter E-Commerce Portal (Dockerized) + +A self-contained Flutter web e-commerce portal served through Docker + Nginx. + +## Features +- Flutter web e-commerce layout: + - Product grid (home) + - Product detail page + - Cart page + - Checkout skeleton page + - User profile page +- Authentication via Firebase Authentication: + - Email + password + - Google login + - GitHub login +- Dockerized build and runtime (no host Flutter SDK required) + +## Prerequisites +- Ubuntu with: + - Docker Engine + - Docker Compose plugin (`docker compose`) + +## Project Structure +- `lib/screens/` UI pages and auth flow +- `lib/widgets/` reusable UI widgets +- `lib/models/` domain models +- `lib/services/` auth, config, catalog, and cart logic +- `assets/config/` runtime Firebase config templates +- `Dockerfile` multi-stage Flutter web build + Nginx runtime +- `docker-compose.yml` app service orchestration +- `install_portal.sh` one-command install/run +- `uninstall_portal.sh` cleanup script + +## Configure Authentication Providers +1. Create a Firebase project. +2. In Firebase Console > Authentication > Sign-in method, enable: + - Email/Password + - Google + - GitHub +3. In Firebase Console > Project settings > General, copy web app config values. +4. Create local env file: + ```bash + cp .env.example .env + ``` +5. Edit `.env` and set: + - `FIREBASE_API_KEY` + - `FIREBASE_AUTH_DOMAIN` + - `FIREBASE_PROJECT_ID` + - `FIREBASE_STORAGE_BUCKET` + - `FIREBASE_MESSAGING_SENDER_ID` + - `FIREBASE_APP_ID` + - `FIREBASE_MEASUREMENT_ID` (optional) +6. For GitHub provider in Firebase, configure GitHub OAuth app callback URL exactly as Firebase instructs. + +## Install and Run +```bash +chmod +x install_portal.sh uninstall_portal.sh +./install_portal.sh +``` + +The portal will be available at: +- `http://localhost:8081` (default) +- or `http://localhost:` if you changed `PORTAL_PORT` in `.env` + +## Uninstall / Cleanup +Interactive cleanup: +```bash +./uninstall_portal.sh +``` + +Non-interactive cleanup: +```bash +./uninstall_portal.sh --force +``` + +This removes containers, local images, networks, and volumes created by this portal's Compose project. + +## Notes on Secrets +- Keep secrets and keys in `.env` only; do not hard-code in Dart files. +- `.env` is consumed by Docker Compose and injected into the runtime config file inside the container. +- For production, use a proper secret manager or CI/CD secret injection. diff --git a/assets/config/runtime_config.json b/assets/config/runtime_config.json new file mode 100644 index 0000000..d25b154 --- /dev/null +++ b/assets/config/runtime_config.json @@ -0,0 +1,9 @@ +{ + "firebase_api_key": "replace-with-firebase-api-key", + "firebase_auth_domain": "replace-with-project-id.firebaseapp.com", + "firebase_project_id": "replace-with-project-id", + "firebase_storage_bucket": "replace-with-project-id.appspot.com", + "firebase_messaging_sender_id": "replace-with-sender-id", + "firebase_app_id": "replace-with-app-id", + "firebase_measurement_id": "" +} diff --git a/assets/config/runtime_config.template.json b/assets/config/runtime_config.template.json new file mode 100644 index 0000000..aa09409 --- /dev/null +++ b/assets/config/runtime_config.template.json @@ -0,0 +1,9 @@ +{ + "firebase_api_key": "${FIREBASE_API_KEY}", + "firebase_auth_domain": "${FIREBASE_AUTH_DOMAIN}", + "firebase_project_id": "${FIREBASE_PROJECT_ID}", + "firebase_storage_bucket": "${FIREBASE_STORAGE_BUCKET}", + "firebase_messaging_sender_id": "${FIREBASE_MESSAGING_SENDER_ID}", + "firebase_app_id": "${FIREBASE_APP_ID}", + "firebase_measurement_id": "${FIREBASE_MEASUREMENT_ID}" +} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..c55c7dd --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,20 @@ +name: flutter_ecommerce_portal + +services: + web: + build: + context: . + dockerfile: Dockerfile + container_name: flutter_ecommerce_portal_web + ports: + - "${PORTAL_PORT:-8081}:80" + environment: + FIREBASE_API_KEY: ${FIREBASE_API_KEY} + FIREBASE_AUTH_DOMAIN: ${FIREBASE_AUTH_DOMAIN} + FIREBASE_PROJECT_ID: ${FIREBASE_PROJECT_ID} + FIREBASE_STORAGE_BUCKET: ${FIREBASE_STORAGE_BUCKET} + FIREBASE_MESSAGING_SENDER_ID: ${FIREBASE_MESSAGING_SENDER_ID} + FIREBASE_APP_ID: ${FIREBASE_APP_ID} + FIREBASE_MEASUREMENT_ID: ${FIREBASE_MEASUREMENT_ID} + PORTAL_TITLE: ${PORTAL_TITLE:-Flutter E-Commerce Portal} + restart: unless-stopped diff --git a/docker/entrypoint/30-generate-runtime-config.sh b/docker/entrypoint/30-generate-runtime-config.sh new file mode 100644 index 0000000..beb1e94 --- /dev/null +++ b/docker/entrypoint/30-generate-runtime-config.sh @@ -0,0 +1,21 @@ +#!/bin/sh +set -eu + +TARGET="/usr/share/nginx/html/assets/assets/config/runtime_config.json" +mkdir -p "$(dirname "$TARGET")" + +if command -v envsubst >/dev/null 2>&1; then + envsubst < /opt/runtime_config.template.json > "$TARGET" +else + cat > "$TARGET" < main() async { + WidgetsFlutterBinding.ensureInitialized(); + await AppConfigService.instance.initialize(); + runApp(const EcommercePortalApp()); +} + +class EcommercePortalApp extends StatelessWidget { + const EcommercePortalApp({super.key}); + + @override + Widget build(BuildContext context) { + return MultiProvider( + providers: [ + ChangeNotifierProvider(create: (_) => CartService()), + ], + child: MaterialApp( + debugShowCheckedModeBanner: false, + title: 'Flutter E-Commerce Portal', + theme: ThemeData( + colorScheme: ColorScheme.fromSeed(seedColor: Colors.indigo), + useMaterial3: true, + ), + home: const AuthGate(), + ), + ); + } +} diff --git a/lib/models/cart_item.dart b/lib/models/cart_item.dart new file mode 100644 index 0000000..e9fb37a --- /dev/null +++ b/lib/models/cart_item.dart @@ -0,0 +1,10 @@ +import 'product.dart'; + +class CartItem { + final Product product; + int quantity; + + CartItem({required this.product, this.quantity = 1}); + + double get lineTotal => product.price * quantity; +} diff --git a/lib/models/product.dart b/lib/models/product.dart new file mode 100644 index 0000000..a7481ec --- /dev/null +++ b/lib/models/product.dart @@ -0,0 +1,15 @@ +class Product { + final String id; + final String name; + final String description; + final String imageUrl; + final double price; + + const Product({ + required this.id, + required this.name, + required this.description, + required this.imageUrl, + required this.price, + }); +} diff --git a/lib/screens/auth_gate.dart b/lib/screens/auth_gate.dart new file mode 100644 index 0000000..d6bafd7 --- /dev/null +++ b/lib/screens/auth_gate.dart @@ -0,0 +1,30 @@ +import 'package:firebase_auth/firebase_auth.dart'; +import 'package:flutter/material.dart'; + +import '../services/auth_service.dart'; +import 'home_screen.dart'; +import 'login_screen.dart'; + +class AuthGate extends StatelessWidget { + const AuthGate({super.key}); + + @override + Widget build(BuildContext context) { + return StreamBuilder( + stream: AuthService.instance.authStateChanges(), + builder: (context, snapshot) { + if (snapshot.connectionState == ConnectionState.waiting) { + return const Scaffold( + body: Center(child: CircularProgressIndicator()), + ); + } + + if (snapshot.hasData) { + return const HomeScreen(); + } + + return const LoginScreen(); + }, + ); + } +} diff --git a/lib/screens/cart_screen.dart b/lib/screens/cart_screen.dart new file mode 100644 index 0000000..1108a05 --- /dev/null +++ b/lib/screens/cart_screen.dart @@ -0,0 +1,99 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +import '../services/cart_service.dart'; +import 'checkout_screen.dart'; + +class CartScreen extends StatelessWidget { + const CartScreen({super.key}); + + @override + Widget build(BuildContext context) { + final cart = context.watch(); + + return Scaffold( + appBar: AppBar(title: const Text('Your Cart')), + body: cart.items.isEmpty + ? const Center(child: Text('Your cart is empty.')) + : Column( + children: [ + Expanded( + child: ListView.separated( + itemCount: cart.items.length, + separatorBuilder: (_, __) => const Divider(height: 1), + itemBuilder: (context, index) { + final item = cart.items[index]; + return ListTile( + leading: SizedBox( + width: 64, + child: + Image.network(item.product.imageUrl, fit: BoxFit.cover), + ), + title: Text(item.product.name), + subtitle: + Text('\$${item.product.price.toStringAsFixed(2)} each'), + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + IconButton( + icon: const Icon(Icons.remove_circle_outline), + onPressed: item.quantity > 1 + ? () => cart.updateQuantity( + item.product.id, + item.quantity - 1, + ) + : null, + ), + Text('${item.quantity}'), + IconButton( + icon: const Icon(Icons.add_circle_outline), + onPressed: () => cart.updateQuantity( + item.product.id, + item.quantity + 1, + ), + ), + IconButton( + icon: const Icon(Icons.delete_outline), + onPressed: () => cart.removeProduct(item.product.id), + ), + ], + ), + ); + }, + ), + ), + Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + border: Border( + top: BorderSide(color: Colors.grey.shade300), + ), + ), + child: Row( + children: [ + Text( + 'Total: \$${cart.totalPrice.toStringAsFixed(2)}', + style: const TextStyle( + fontSize: 18, + fontWeight: FontWeight.w600, + ), + ), + const Spacer(), + FilledButton( + onPressed: () { + Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => const CheckoutScreen(), + ), + ); + }, + child: const Text('Proceed to Checkout'), + ), + ], + ), + ), + ], + ), + ); + } +} diff --git a/lib/screens/checkout_screen.dart b/lib/screens/checkout_screen.dart new file mode 100644 index 0000000..445041e --- /dev/null +++ b/lib/screens/checkout_screen.dart @@ -0,0 +1,39 @@ +import 'package:flutter/material.dart'; + +class CheckoutScreen extends StatelessWidget { + const CheckoutScreen({super.key}); + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text('Checkout')), + body: Center( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 640), + child: Card( + margin: const EdgeInsets.all(20), + child: Padding( + padding: const EdgeInsets.all(20), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: const [ + Text( + 'Checkout (Skeleton)', + style: TextStyle(fontSize: 24, fontWeight: FontWeight.bold), + ), + SizedBox(height: 10), + Text('This page is intentionally a placeholder for:'), + SizedBox(height: 8), + Text('- Shipping address collection'), + Text('- Payment gateway integration'), + Text('- Order review and confirmation flow'), + ], + ), + ), + ), + ), + ), + ); + } +} diff --git a/lib/screens/forgot_password_screen.dart b/lib/screens/forgot_password_screen.dart new file mode 100644 index 0000000..f96dbfa --- /dev/null +++ b/lib/screens/forgot_password_screen.dart @@ -0,0 +1,94 @@ +import 'package:flutter/material.dart'; + +import '../services/auth_service.dart'; + +class ForgotPasswordScreen extends StatefulWidget { + const ForgotPasswordScreen({super.key}); + + @override + State createState() => _ForgotPasswordScreenState(); +} + +class _ForgotPasswordScreenState extends State { + final _formKey = GlobalKey(); + final _emailController = TextEditingController(); + bool _loading = false; + + @override + void dispose() { + _emailController.dispose(); + super.dispose(); + } + + Future _submit() async { + if (!_formKey.currentState!.validate()) return; + + setState(() => _loading = true); + try { + await AuthService.instance.sendPasswordResetEmail( + _emailController.text.trim(), + ); + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Password reset email sent.')), + ); + Navigator.of(context).pop(); + } catch (e) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(e.toString())), + ); + } finally { + if (mounted) setState(() => _loading = false); + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text('Reset Password')), + body: Center( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 420), + child: Card( + margin: const EdgeInsets.all(20), + child: Padding( + padding: const EdgeInsets.all(20), + child: Form( + key: _formKey, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + TextFormField( + controller: _emailController, + decoration: const InputDecoration(labelText: 'Email'), + validator: (value) => + (value == null || value.trim().isEmpty) + ? 'Email is required' + : null, + ), + const SizedBox(height: 16), + SizedBox( + width: double.infinity, + child: FilledButton( + onPressed: _loading ? null : _submit, + child: _loading + ? const SizedBox( + width: 18, + height: 18, + child: + CircularProgressIndicator(strokeWidth: 2), + ) + : const Text('Send Reset Link'), + ), + ) + ], + ), + ), + ), + ), + ), + ), + ); + } +} diff --git a/lib/screens/home_screen.dart b/lib/screens/home_screen.dart new file mode 100644 index 0000000..4e8d631 --- /dev/null +++ b/lib/screens/home_screen.dart @@ -0,0 +1,121 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +import '../services/auth_service.dart'; +import '../services/cart_service.dart'; +import '../services/product_repository.dart'; +import '../widgets/product_card.dart'; +import 'cart_screen.dart'; +import 'product_detail_screen.dart'; +import 'profile_screen.dart'; + +class HomeScreen extends StatelessWidget { + const HomeScreen({super.key}); + + @override + Widget build(BuildContext context) { + final cartCount = context.watch().totalItems; + + return Scaffold( + appBar: AppBar( + title: const Text('E-Commerce Portal'), + actions: [ + IconButton( + tooltip: 'Profile', + onPressed: () { + Navigator.of(context).push( + MaterialPageRoute(builder: (_) => const ProfileScreen()), + ); + }, + icon: const Icon(Icons.person), + ), + Stack( + children: [ + IconButton( + tooltip: 'Cart', + onPressed: () { + Navigator.of(context).push( + MaterialPageRoute(builder: (_) => const CartScreen()), + ); + }, + icon: const Icon(Icons.shopping_cart_outlined), + ), + if (cartCount > 0) + Positioned( + right: 8, + top: 8, + child: Container( + padding: + const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + decoration: BoxDecoration( + color: Colors.red, + borderRadius: BorderRadius.circular(10), + ), + child: Text( + '$cartCount', + style: const TextStyle( + color: Colors.white, + fontSize: 11, + ), + ), + ), + ), + ], + ), + IconButton( + tooltip: 'Sign out', + onPressed: () async { + await AuthService.instance.signOut(); + }, + icon: const Icon(Icons.logout), + ), + ], + ), + body: Padding( + padding: const EdgeInsets.all(12), + child: LayoutBuilder( + builder: (context, constraints) { + final width = constraints.maxWidth; + int count = 1; + if (width > 1200) { + count = 4; + } else if (width > 900) { + count = 3; + } else if (width > 600) { + count = 2; + } + + return GridView.builder( + itemCount: ProductRepository.products.length, + gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: count, + crossAxisSpacing: 12, + mainAxisSpacing: 12, + childAspectRatio: 0.72, + ), + itemBuilder: (context, index) { + final product = ProductRepository.products[index]; + return ProductCard( + product: product, + onView: () { + Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => ProductDetailScreen(product: product), + ), + ); + }, + onAddToCart: () { + context.read().addProduct(product); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('${product.name} added to cart')), + ); + }, + ); + }, + ); + }, + ), + ), + ); + } +} diff --git a/lib/screens/login_screen.dart b/lib/screens/login_screen.dart new file mode 100644 index 0000000..b05e909 --- /dev/null +++ b/lib/screens/login_screen.dart @@ -0,0 +1,168 @@ +import 'package:flutter/material.dart'; + +import '../services/auth_service.dart'; +import 'forgot_password_screen.dart'; +import 'register_screen.dart'; + +class LoginScreen extends StatefulWidget { + const LoginScreen({super.key}); + + @override + State createState() => _LoginScreenState(); +} + +class _LoginScreenState extends State { + final _formKey = GlobalKey(); + final _emailController = TextEditingController(); + final _passwordController = TextEditingController(); + bool _loading = false; + + @override + void dispose() { + _emailController.dispose(); + _passwordController.dispose(); + super.dispose(); + } + + Future _run(Future Function() action) async { + setState(() => _loading = true); + try { + await action(); + } catch (e) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(e.toString())), + ); + } finally { + if (mounted) setState(() => _loading = false); + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + body: Center( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 420), + child: Card( + margin: const EdgeInsets.all(20), + child: Padding( + padding: const EdgeInsets.all(20), + child: Form( + key: _formKey, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Text( + 'Welcome Back', + style: TextStyle( + fontSize: 28, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 16), + TextFormField( + controller: _emailController, + decoration: const InputDecoration(labelText: 'Email'), + validator: (value) => + (value == null || value.trim().isEmpty) + ? 'Email is required' + : null, + ), + const SizedBox(height: 12), + TextFormField( + controller: _passwordController, + obscureText: true, + decoration: const InputDecoration(labelText: 'Password'), + validator: (value) => + (value == null || value.isEmpty) + ? 'Password is required' + : null, + ), + const SizedBox(height: 16), + SizedBox( + width: double.infinity, + child: FilledButton( + onPressed: _loading + ? null + : () { + if (!_formKey.currentState!.validate()) return; + _run(() async { + await AuthService.instance + .signInWithEmailPassword( + email: _emailController.text.trim(), + password: _passwordController.text, + ); + }); + }, + child: _loading + ? const SizedBox( + width: 18, + height: 18, + child: + CircularProgressIndicator(strokeWidth: 2), + ) + : const Text('Sign In'), + ), + ), + TextButton( + onPressed: _loading + ? null + : () { + Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => const ForgotPasswordScreen(), + ), + ); + }, + child: const Text('Forgot Password?'), + ), + const Divider(height: 24), + SizedBox( + width: double.infinity, + child: OutlinedButton.icon( + onPressed: _loading + ? null + : () => _run(() async { + await AuthService.instance.signInWithGoogle(); + }), + icon: const Icon(Icons.login), + label: const Text('Continue with Google'), + ), + ), + const SizedBox(height: 8), + SizedBox( + width: double.infinity, + child: OutlinedButton.icon( + onPressed: _loading + ? null + : () => _run(() async { + await AuthService.instance.signInWithGithub(); + }), + icon: const Icon(Icons.code), + label: const Text('Continue with GitHub'), + ), + ), + const SizedBox(height: 8), + TextButton( + onPressed: _loading + ? null + : () { + Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => const RegisterScreen(), + ), + ); + }, + child: const Text('Create an account'), + ), + ], + ), + ), + ), + ), + ), + ), + ); + } +} diff --git a/lib/screens/product_detail_screen.dart b/lib/screens/product_detail_screen.dart new file mode 100644 index 0000000..93b9a05 --- /dev/null +++ b/lib/screens/product_detail_screen.dart @@ -0,0 +1,72 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +import '../models/product.dart'; +import '../services/cart_service.dart'; + +class ProductDetailScreen extends StatelessWidget { + final Product product; + + const ProductDetailScreen({super.key, required this.product}); + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: Text(product.name)), + body: Center( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 1100), + child: Padding( + padding: const EdgeInsets.all(16), + child: Wrap( + spacing: 20, + runSpacing: 20, + children: [ + SizedBox( + width: 480, + child: AspectRatio( + aspectRatio: 4 / 3, + child: ClipRRect( + borderRadius: BorderRadius.circular(12), + child: Image.network(product.imageUrl, fit: BoxFit.cover), + ), + ), + ), + SizedBox( + width: 460, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + product.name, + style: Theme.of(context).textTheme.headlineMedium, + ), + const SizedBox(height: 10), + Text( + '\$${product.price.toStringAsFixed(2)}', + style: Theme.of(context).textTheme.titleLarge, + ), + const SizedBox(height: 16), + Text(product.description), + const SizedBox(height: 20), + FilledButton.icon( + onPressed: () { + context.read().addProduct(product); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Item added to cart')), + ); + }, + icon: const Icon(Icons.add_shopping_cart), + label: const Text('Add to Cart'), + ), + ], + ), + ), + ], + ), + ), + ), + ), + ); + } +} diff --git a/lib/screens/profile_screen.dart b/lib/screens/profile_screen.dart new file mode 100644 index 0000000..30eb4b1 --- /dev/null +++ b/lib/screens/profile_screen.dart @@ -0,0 +1,53 @@ +import 'package:flutter/material.dart'; + +import '../services/auth_service.dart'; + +class ProfileScreen extends StatelessWidget { + const ProfileScreen({super.key}); + + @override + Widget build(BuildContext context) { + final user = AuthService.instance.currentUser; + final providers = user?.providerData.map((p) => p.providerId).join(', ') ?? ''; + + return Scaffold( + appBar: AppBar(title: const Text('Your Profile')), + body: Center( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 600), + child: Card( + margin: const EdgeInsets.all(20), + child: Padding( + padding: const EdgeInsets.all(20), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + 'Account Information', + style: TextStyle(fontSize: 24, fontWeight: FontWeight.bold), + ), + const SizedBox(height: 16), + Text('Display name: ${user?.displayName ?? 'Not set'}'), + Text('Email: ${user?.email ?? 'N/A'}'), + Text('UID: ${user?.uid ?? 'N/A'}'), + Text('Providers: $providers'), + const SizedBox(height: 16), + FilledButton.tonalIcon( + onPressed: () async { + await AuthService.instance.signOut(); + if (!context.mounted) return; + Navigator.of(context).pop(); + }, + icon: const Icon(Icons.logout), + label: const Text('Sign out'), + ), + ], + ), + ), + ), + ), + ), + ); + } +} diff --git a/lib/screens/register_screen.dart b/lib/screens/register_screen.dart new file mode 100644 index 0000000..3a91095 --- /dev/null +++ b/lib/screens/register_screen.dart @@ -0,0 +1,125 @@ +import 'package:flutter/material.dart'; + +import '../services/auth_service.dart'; + +class RegisterScreen extends StatefulWidget { + const RegisterScreen({super.key}); + + @override + State createState() => _RegisterScreenState(); +} + +class _RegisterScreenState extends State { + final _formKey = GlobalKey(); + final _emailController = TextEditingController(); + final _passwordController = TextEditingController(); + final _confirmController = TextEditingController(); + bool _loading = false; + + @override + void dispose() { + _emailController.dispose(); + _passwordController.dispose(); + _confirmController.dispose(); + super.dispose(); + } + + Future _register() async { + if (!_formKey.currentState!.validate()) return; + + setState(() => _loading = true); + try { + await AuthService.instance.registerWithEmailPassword( + email: _emailController.text.trim(), + password: _passwordController.text, + ); + if (!mounted) return; + Navigator.of(context).pop(); + } catch (e) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(e.toString())), + ); + } finally { + if (mounted) setState(() => _loading = false); + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text('Register')), + body: Center( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 420), + child: Card( + margin: const EdgeInsets.all(20), + child: Padding( + padding: const EdgeInsets.all(20), + child: Form( + key: _formKey, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + TextFormField( + controller: _emailController, + decoration: const InputDecoration(labelText: 'Email'), + validator: (value) => + (value == null || value.trim().isEmpty) + ? 'Email is required' + : null, + ), + const SizedBox(height: 12), + TextFormField( + controller: _passwordController, + obscureText: true, + decoration: + const InputDecoration(labelText: 'Password (min 6)'), + validator: (value) { + if (value == null || value.isEmpty) { + return 'Password is required'; + } + if (value.length < 6) { + return 'Must be at least 6 characters'; + } + return null; + }, + ), + const SizedBox(height: 12), + TextFormField( + controller: _confirmController, + obscureText: true, + decoration: + const InputDecoration(labelText: 'Confirm Password'), + validator: (value) { + if (value != _passwordController.text) { + return 'Passwords do not match'; + } + return null; + }, + ), + const SizedBox(height: 16), + SizedBox( + width: double.infinity, + child: FilledButton( + onPressed: _loading ? null : _register, + child: _loading + ? const SizedBox( + width: 18, + height: 18, + child: + CircularProgressIndicator(strokeWidth: 2), + ) + : const Text('Create Account'), + ), + ), + ], + ), + ), + ), + ), + ), + ), + ); + } +} diff --git a/lib/services/app_config_service.dart b/lib/services/app_config_service.dart new file mode 100644 index 0000000..6f77408 --- /dev/null +++ b/lib/services/app_config_service.dart @@ -0,0 +1,47 @@ +import 'dart:convert'; + +import 'package:firebase_core/firebase_core.dart'; +import 'package:flutter/services.dart'; + +class AppConfigService { + AppConfigService._(); + + static final AppConfigService instance = AppConfigService._(); + + bool _initialized = false; + + Future initialize() async { + if (_initialized) return; + + final configRaw = + await rootBundle.loadString('assets/config/runtime_config.json'); + final Map config = json.decode(configRaw); + + await Firebase.initializeApp( + options: FirebaseOptions( + apiKey: _required(config, 'firebase_api_key'), + appId: _required(config, 'firebase_app_id'), + messagingSenderId: _required(config, 'firebase_messaging_sender_id'), + projectId: _required(config, 'firebase_project_id'), + authDomain: _required(config, 'firebase_auth_domain'), + storageBucket: _required(config, 'firebase_storage_bucket'), + measurementId: _optional(config, 'firebase_measurement_id'), + ), + ); + + _initialized = true; + } + + String _required(Map config, String key) { + final value = (config[key] ?? '').toString().trim(); + if (value.isEmpty || value.startsWith('replace-with-')) { + throw StateError('Missing required runtime configuration: $key'); + } + return value; + } + + String? _optional(Map config, String key) { + final value = (config[key] ?? '').toString().trim(); + return value.isEmpty ? null : value; + } +} diff --git a/lib/services/auth_service.dart b/lib/services/auth_service.dart new file mode 100644 index 0000000..f861da4 --- /dev/null +++ b/lib/services/auth_service.dart @@ -0,0 +1,48 @@ +import 'package:firebase_auth/firebase_auth.dart'; + +class AuthService { + AuthService._(); + + static final AuthService instance = AuthService._(); + final FirebaseAuth _auth = FirebaseAuth.instance; + + Stream authStateChanges() => _auth.authStateChanges(); + + User? get currentUser => _auth.currentUser; + + Future signInWithEmailPassword({ + required String email, + required String password, + }) { + return _auth.signInWithEmailAndPassword(email: email, password: password); + } + + Future registerWithEmailPassword({ + required String email, + required String password, + }) { + return _auth.createUserWithEmailAndPassword( + email: email, + password: password, + ); + } + + Future sendPasswordResetEmail(String email) { + return _auth.sendPasswordResetEmail(email: email); + } + + Future signInWithGoogle() { + final provider = GoogleAuthProvider(); + provider.setCustomParameters({'prompt': 'select_account'}); + return _auth.signInWithPopup(provider); + } + + Future signInWithGithub() { + final provider = GithubAuthProvider(); + return _auth.signInWithPopup(provider); + } + + Future signOut() { + return _auth.signOut(); + } +} diff --git a/lib/services/cart_service.dart b/lib/services/cart_service.dart new file mode 100644 index 0000000..4710cdb --- /dev/null +++ b/lib/services/cart_service.dart @@ -0,0 +1,43 @@ +import 'package:flutter/foundation.dart'; + +import '../models/cart_item.dart'; +import '../models/product.dart'; + +class CartService extends ChangeNotifier { + final List _items = []; + + List get items => List.unmodifiable(_items); + + int get totalItems => _items.fold(0, (sum, item) => sum + item.quantity); + + double get totalPrice => + _items.fold(0.0, (sum, item) => sum + item.lineTotal); + + void addProduct(Product product) { + final index = _items.indexWhere((item) => item.product.id == product.id); + if (index == -1) { + _items.add(CartItem(product: product)); + } else { + _items[index].quantity += 1; + } + notifyListeners(); + } + + void removeProduct(String productId) { + _items.removeWhere((item) => item.product.id == productId); + notifyListeners(); + } + + void updateQuantity(String productId, int quantity) { + if (quantity < 1) return; + final index = _items.indexWhere((item) => item.product.id == productId); + if (index == -1) return; + _items[index].quantity = quantity; + notifyListeners(); + } + + void clear() { + _items.clear(); + notifyListeners(); + } +} diff --git a/lib/services/product_repository.dart b/lib/services/product_repository.dart new file mode 100644 index 0000000..9484b6d --- /dev/null +++ b/lib/services/product_repository.dart @@ -0,0 +1,55 @@ +import '../models/product.dart'; + +class ProductRepository { + static const List products = [ + Product( + id: 'p1', + name: 'Minimalist Chair', + description: 'Ergonomic chair with breathable fabric and oak finish.', + imageUrl: 'https://picsum.photos/seed/chair/900/600', + price: 129.99, + ), + Product( + id: 'p2', + name: 'Modern Table Lamp', + description: 'Warm ambient lighting with dimmable touch controls.', + imageUrl: 'https://picsum.photos/seed/lamp/900/600', + price: 59.50, + ), + Product( + id: 'p3', + name: 'Noise-Cancel Headphones', + description: 'Wireless over-ear headphones with premium audio.', + imageUrl: 'https://picsum.photos/seed/headphones/900/600', + price: 219.00, + ), + Product( + id: 'p4', + name: 'Smart Watch', + description: 'Fitness tracking, sleep monitoring, and message alerts.', + imageUrl: 'https://picsum.photos/seed/watch/900/600', + price: 179.90, + ), + Product( + id: 'p5', + name: 'Travel Backpack', + description: 'Water-resistant backpack with dedicated laptop sleeve.', + imageUrl: 'https://picsum.photos/seed/backpack/900/600', + price: 89.00, + ), + Product( + id: 'p6', + name: 'Mechanical Keyboard', + description: 'Hot-swappable switches and customizable RGB lighting.', + imageUrl: 'https://picsum.photos/seed/keyboard/900/600', + price: 149.00, + ), + ]; + + static Product? byId(String id) { + for (final product in products) { + if (product.id == id) return product; + } + return null; + } +} diff --git a/lib/widgets/product_card.dart b/lib/widgets/product_card.dart new file mode 100644 index 0000000..6bbaf07 --- /dev/null +++ b/lib/widgets/product_card.dart @@ -0,0 +1,69 @@ +import 'package:flutter/material.dart'; + +import '../models/product.dart'; + +class ProductCard extends StatelessWidget { + final Product product; + final VoidCallback onView; + final VoidCallback onAddToCart; + + const ProductCard({ + super.key, + required this.product, + required this.onView, + required this.onAddToCart, + }); + + @override + Widget build(BuildContext context) { + return Card( + clipBehavior: Clip.antiAlias, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Expanded( + child: Image.network( + product.imageUrl, + fit: BoxFit.cover, + ), + ), + Padding( + padding: const EdgeInsets.all(12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + product.name, + style: const TextStyle( + fontWeight: FontWeight.w600, + fontSize: 16, + ), + ), + const SizedBox(height: 4), + Text('\$${product.price.toStringAsFixed(2)}'), + const SizedBox(height: 10), + Row( + children: [ + Expanded( + child: OutlinedButton( + onPressed: onView, + child: const Text('Details'), + ), + ), + const SizedBox(width: 8), + Expanded( + child: FilledButton( + onPressed: onAddToCart, + child: const Text('Add'), + ), + ), + ], + ), + ], + ), + ) + ], + ), + ); + } +} diff --git a/pubspec.yaml b/pubspec.yaml new file mode 100644 index 0000000..b28c7f9 --- /dev/null +++ b/pubspec.yaml @@ -0,0 +1,25 @@ +name: flutter_ecommerce_portal +description: Dockerized Flutter web e-commerce portal with Firebase auth +publish_to: 'none' +version: 1.0.0+1 + +environment: + sdk: '>=3.4.0 <4.0.0' + +dependencies: + flutter: + sdk: flutter + cupertino_icons: ^1.0.8 + firebase_core: ^3.8.0 + firebase_auth: ^5.3.3 + provider: ^6.1.2 + +dev_dependencies: + flutter_test: + sdk: flutter + flutter_lints: ^4.0.0 + +flutter: + uses-material-design: true + assets: + - assets/config/runtime_config.json diff --git a/uninstall_portal.sh b/uninstall_portal.sh new file mode 100755 index 0000000..2cc9fef --- /dev/null +++ b/uninstall_portal.sh @@ -0,0 +1,34 @@ +#!/usr/bin/env bash + +set -Eeuo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_DIR="$SCRIPT_DIR" +COMPOSE_FILE="$PROJECT_DIR/docker-compose.yml" +ENV_FILE="$PROJECT_DIR/.env" +FORCE=0 + +if [[ "${1:-}" == "--force" ]]; then + FORCE=1 +fi + +if [[ $FORCE -eq 0 ]]; then + echo "WARNING: This will remove the Flutter portal containers, local images, and volumes for this project." + read -r -p "Continue? [y/N]: " answer + if [[ ! "$answer" =~ ^[Yy]$ ]]; then + echo "Uninstall cancelled." + exit 0 + fi +fi + +if [[ -f "$COMPOSE_FILE" ]]; then + if [[ -f "$ENV_FILE" ]]; then + docker compose -f "$COMPOSE_FILE" --env-file "$ENV_FILE" down --remove-orphans --volumes --rmi local || true + else + docker compose -f "$COMPOSE_FILE" down --remove-orphans --volumes --rmi local || true + fi +fi + +docker container rm -f flutter_ecommerce_portal_web >/dev/null 2>&1 || true + +echo "Cleanup complete for flutter_ecommerce_portal."