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 <oz-agent@warp.dev>
This commit is contained in:
rbhat
2026-04-10 19:08:30 +05:30
commit 39a4f3283f
29 changed files with 1405 additions and 0 deletions

8
.dockerignore Normal file
View File

@ -0,0 +1,8 @@
.git
.gitignore
build
.dart_tool
.idea
.vscode
*.iml
README.md

10
.env.example Normal file
View File

@ -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=

23
Dockerfile Normal file
View File

@ -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

81
README.md Normal file
View File

@ -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:<PORTAL_PORT>` 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.

View File

@ -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": ""
}

View File

@ -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}"
}

20
docker-compose.yml Normal file
View File

@ -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

View File

@ -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" <<JSON
{
"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:-}"
}
JSON
fi

16
docker/nginx/default.conf Normal file
View File

@ -0,0 +1,16 @@
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
location / {
try_files $uri $uri/ /index.html;
}
location ~* \.(?:js|css|png|jpg|jpeg|gif|svg|ico|woff2?)$ {
expires 7d;
add_header Cache-Control "public";
}
}

27
install_portal.sh Executable file
View File

@ -0,0 +1,27 @@
#!/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"
if [[ ! -f "$COMPOSE_FILE" ]]; then
echo "docker-compose.yml not found in $PROJECT_DIR"
exit 1
fi
if [[ ! -f "$ENV_FILE" ]]; then
cp "$PROJECT_DIR/.env.example" "$ENV_FILE"
echo "Created $ENV_FILE from .env.example"
echo "Update Firebase variables in .env before first production login test."
fi
docker compose -f "$COMPOSE_FILE" --env-file "$ENV_FILE" up -d --build
PORT="$(grep '^PORTAL_PORT=' "$ENV_FILE" | cut -d= -f2 || true)"
PORT="${PORT:-8081}"
echo
echo "Portal is running at: http://localhost:${PORT}"

34
lib/main.dart Normal file
View File

@ -0,0 +1,34 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'screens/auth_gate.dart';
import 'services/app_config_service.dart';
import 'services/cart_service.dart';
Future<void> 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(),
),
);
}
}

10
lib/models/cart_item.dart Normal file
View File

@ -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;
}

15
lib/models/product.dart Normal file
View File

@ -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,
});
}

View File

@ -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<User?>(
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();
},
);
}
}

View File

@ -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<CartService>();
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'),
),
],
),
),
],
),
);
}
}

View File

@ -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'),
],
),
),
),
),
),
);
}
}

View File

@ -0,0 +1,94 @@
import 'package:flutter/material.dart';
import '../services/auth_service.dart';
class ForgotPasswordScreen extends StatefulWidget {
const ForgotPasswordScreen({super.key});
@override
State<ForgotPasswordScreen> createState() => _ForgotPasswordScreenState();
}
class _ForgotPasswordScreenState extends State<ForgotPasswordScreen> {
final _formKey = GlobalKey<FormState>();
final _emailController = TextEditingController();
bool _loading = false;
@override
void dispose() {
_emailController.dispose();
super.dispose();
}
Future<void> _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'),
),
)
],
),
),
),
),
),
),
);
}
}

View File

@ -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<CartService>().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<CartService>().addProduct(product);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('${product.name} added to cart')),
);
},
);
},
);
},
),
),
);
}
}

View File

@ -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<LoginScreen> createState() => _LoginScreenState();
}
class _LoginScreenState extends State<LoginScreen> {
final _formKey = GlobalKey<FormState>();
final _emailController = TextEditingController();
final _passwordController = TextEditingController();
bool _loading = false;
@override
void dispose() {
_emailController.dispose();
_passwordController.dispose();
super.dispose();
}
Future<void> _run(Future<void> 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'),
),
],
),
),
),
),
),
),
);
}
}

View File

@ -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<CartService>().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'),
),
],
),
),
],
),
),
),
),
);
}
}

View File

@ -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'),
),
],
),
),
),
),
),
);
}
}

View File

@ -0,0 +1,125 @@
import 'package:flutter/material.dart';
import '../services/auth_service.dart';
class RegisterScreen extends StatefulWidget {
const RegisterScreen({super.key});
@override
State<RegisterScreen> createState() => _RegisterScreenState();
}
class _RegisterScreenState extends State<RegisterScreen> {
final _formKey = GlobalKey<FormState>();
final _emailController = TextEditingController();
final _passwordController = TextEditingController();
final _confirmController = TextEditingController();
bool _loading = false;
@override
void dispose() {
_emailController.dispose();
_passwordController.dispose();
_confirmController.dispose();
super.dispose();
}
Future<void> _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'),
),
),
],
),
),
),
),
),
),
);
}
}

View File

@ -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<void> initialize() async {
if (_initialized) return;
final configRaw =
await rootBundle.loadString('assets/config/runtime_config.json');
final Map<String, dynamic> 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<String, dynamic> 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<String, dynamic> config, String key) {
final value = (config[key] ?? '').toString().trim();
return value.isEmpty ? null : value;
}
}

View File

@ -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<User?> authStateChanges() => _auth.authStateChanges();
User? get currentUser => _auth.currentUser;
Future<UserCredential> signInWithEmailPassword({
required String email,
required String password,
}) {
return _auth.signInWithEmailAndPassword(email: email, password: password);
}
Future<UserCredential> registerWithEmailPassword({
required String email,
required String password,
}) {
return _auth.createUserWithEmailAndPassword(
email: email,
password: password,
);
}
Future<void> sendPasswordResetEmail(String email) {
return _auth.sendPasswordResetEmail(email: email);
}
Future<UserCredential> signInWithGoogle() {
final provider = GoogleAuthProvider();
provider.setCustomParameters({'prompt': 'select_account'});
return _auth.signInWithPopup(provider);
}
Future<UserCredential> signInWithGithub() {
final provider = GithubAuthProvider();
return _auth.signInWithPopup(provider);
}
Future<void> signOut() {
return _auth.signOut();
}
}

View File

@ -0,0 +1,43 @@
import 'package:flutter/foundation.dart';
import '../models/cart_item.dart';
import '../models/product.dart';
class CartService extends ChangeNotifier {
final List<CartItem> _items = [];
List<CartItem> 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();
}
}

View File

@ -0,0 +1,55 @@
import '../models/product.dart';
class ProductRepository {
static const List<Product> 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;
}
}

View File

@ -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'),
),
),
],
),
],
),
)
],
),
);
}
}

25
pubspec.yaml Normal file
View File

@ -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

34
uninstall_portal.sh Executable file
View File

@ -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."