mobile/ionic-project-starter/SKILL.md
--- name: ionic-project-starter description: > Scaffold a production-ready Ionic 8 app with Capacitor 6, Angular/React/Vue framework support, Ionic UI components, native plugins for Camera and Geolocation, platform detection, and theming. category: mobile agent-type: coding compatibility: Node.js >= 20.x, npm >= 10.x, Ionic CLI, iOS: Xcode 16+ (macOS only), CocoaPods, Android: Android Studio with SDK 35+ --- # Ionic Project Starter > Scaffold a production-ready Ionic 8 app with Capacitor
npx skillsauth add achreftlili/deep-dev-skills mobile/ionic-project-starterInstall this skill globally with one command. Works with Claude Code, Cursor, and Windsurf.
3 of 9 scanners reported clean
Some scanners were skipped, did not run, or reported a non-clean status. Review each row below.
Scaffold a production-ready Ionic 8 app with Capacitor 6, Angular/React/Vue framework support, Ionic UI components, native plugins for Camera and Geolocation, platform detection, and theming.
npm install -g @ionic/cli)# Angular (default)
ionic start <project-name> tabs --type=angular --capacitor
cd <project-name>
# React variant
ionic start <project-name> tabs --type=react --capacitor
# Vue variant
ionic start <project-name> tabs --type=vue --capacitor
# Add native platforms
ionic capacitor add ios
ionic capacitor add android
# Install common native plugins
npm install @capacitor/camera @capacitor/geolocation @capacitor/preferences
npm install @capacitor/haptics @capacitor/status-bar @capacitor/keyboard
npm install @capacitor/app @capacitor/splash-screen
npx cap sync
src/
app/ # Angular variant shown (React/Vue similar)
app.module.ts # Root module
app-routing.module.ts # Route definitions
app.component.ts # Root component — platform setup
tabs/
tabs.page.ts # Tab bar layout
tabs-routing.module.ts
pages/
home/
home.page.ts # Tab page
home.page.html
home.page.scss
users/
user-list/
user-list.page.ts
user-list.page.html
user-detail/
user-detail.page.ts
user-detail.page.html
components/
user-card/
user-card.component.ts # Reusable component
user-card.component.html
services/
api.service.ts # HTTP client
auth.service.ts # Authentication
user.service.ts # User CRUD
storage.service.ts # Capacitor Preferences wrapper
platform.service.ts # Platform detection helpers
guards/
auth.guard.ts # Route guard
models/
user.model.ts # TypeScript interfaces
theme/
variables.scss # Ionic CSS variables (colors, fonts)
global.scss # Global styles
environments/
environment.ts
environment.prod.ts
capacitor.config.ts # Capacitor configuration
ionic.config.json
ion-button, ion-card, ion-list) for consistent cross-platform UI@capacitor/preferences for simple key-value storage (replaces @ionic/storage for most cases)isPlatform('hybrid') to check if running as native app vs PWAnpx cap sync after installing any Capacitor plugin or changing web assetstheme/variables.scssapp.component.ts (Angular)import { Component, OnInit } from '@angular/core';
import { Platform } from '@ionic/angular';
import { StatusBar, Style } from '@capacitor/status-bar';
import { SplashScreen } from '@capacitor/splash-screen';
import { Keyboard } from '@capacitor/keyboard';
import { Capacitor } from '@capacitor/core';
@Component({
selector: 'app-root',
template: '<ion-app><ion-router-outlet></ion-router-outlet></ion-app>',
})
export class AppComponent implements OnInit {
constructor(private platform: Platform) {}
async ngOnInit() {
await this.platform.ready();
if (Capacitor.isNativePlatform()) {
await StatusBar.setStyle({ style: Style.Light });
await SplashScreen.hide();
Keyboard.addListener('keyboardWillShow', (info) => {
document.body.style.setProperty('--keyboard-height', `${info.keyboardHeight}px`);
});
Keyboard.addListener('keyboardWillHide', () => {
document.body.style.setProperty('--keyboard-height', '0px');
});
}
}
}
tabs/tabs.page.html<ion-tabs>
<ion-tab-bar slot="bottom">
<ion-tab-button tab="home" href="/tabs/home">
<ion-icon name="home-outline"></ion-icon>
<ion-label>Home</ion-label>
</ion-tab-button>
<ion-tab-button tab="users" href="/tabs/users">
<ion-icon name="people-outline"></ion-icon>
<ion-label>Users</ion-label>
</ion-tab-button>
<ion-tab-button tab="settings" href="/tabs/settings">
<ion-icon name="settings-outline"></ion-icon>
<ion-label>Settings</ion-label>
</ion-tab-button>
</ion-tab-bar>
</ion-tabs>
pages/users/user-list/user-list.page.html<ion-header>
<ion-toolbar>
<ion-title>Users</ion-title>
<ion-buttons slot="end">
<ion-button (click)="addUser()">
<ion-icon slot="icon-only" name="add"></ion-icon>
</ion-button>
</ion-buttons>
</ion-toolbar>
<ion-toolbar>
<ion-searchbar
[(ngModel)]="searchTerm"
(ionInput)="onSearch($event)"
placeholder="Search users..."
></ion-searchbar>
</ion-toolbar>
</ion-header>
<ion-content>
<ion-refresher slot="fixed" (ionRefresh)="onRefresh($event)">
<ion-refresher-content></ion-refresher-content>
</ion-refresher>
<ion-list *ngIf="users.length > 0; else emptyState">
<app-user-card
*ngFor="let user of filteredUsers"
[user]="user"
(click)="viewUser(user.id)"
></app-user-card>
</ion-list>
<ng-template #emptyState>
<div class="ion-text-center ion-padding">
<ion-icon name="people-outline" size="large" color="medium"></ion-icon>
<p>No users found</p>
</div>
</ng-template>
<ion-infinite-scroll (ionInfinite)="loadMore($event)">
<ion-infinite-scroll-content></ion-infinite-scroll-content>
</ion-infinite-scroll>
</ion-content>
pages/users/user-list/user-list.page.tsimport { Component, OnInit } from '@angular/core';
import { NavController } from '@ionic/angular';
import { UserService } from '../../../services/user.service';
import { User } from '../../../models/user.model';
@Component({
selector: 'app-user-list',
templateUrl: './user-list.page.html',
styleUrls: ['./user-list.page.scss'],
})
export class UserListPage implements OnInit {
users: User[] = [];
filteredUsers: User[] = [];
searchTerm = '';
private page = 1;
constructor(
private userService: UserService,
private navCtrl: NavController,
) {}
async ngOnInit() {
await this.loadUsers();
}
async loadUsers() {
this.users = await this.userService.getAll(this.page);
this.filteredUsers = this.users;
}
onSearch(event: CustomEvent) {
const query = (event.detail.value ?? '').toLowerCase();
this.filteredUsers = this.users.filter(
(u) => u.name.toLowerCase().includes(query) || u.email.toLowerCase().includes(query),
);
}
async onRefresh(event: CustomEvent) {
this.page = 1;
await this.loadUsers();
(event.target as HTMLIonRefresherElement).complete();
}
async loadMore(event: CustomEvent) {
this.page++;
const more = await this.userService.getAll(this.page);
this.users.push(...more);
this.filteredUsers = this.users;
(event.target as HTMLIonInfiniteScrollElement).complete();
if (more.length === 0) {
(event.target as HTMLIonInfiniteScrollElement).disabled = true;
}
}
viewUser(id: string) {
this.navCtrl.navigateForward(`/tabs/users/${id}`);
}
addUser() {
this.navCtrl.navigateForward('/tabs/users/new');
}
}
import { Camera, CameraResultType, CameraSource } from '@capacitor/camera';
import { Capacitor } from '@capacitor/core';
async takePhoto(): Promise<string | undefined> {
// Check permissions
const permissions = await Camera.checkPermissions();
if (permissions.camera !== 'granted') {
await Camera.requestPermissions();
}
const photo = await Camera.getPhoto({
quality: 80,
allowEditing: false,
resultType: CameraResultType.Base64,
source: CameraSource.Camera,
width: 800,
});
return photo.base64String;
}
async pickFromGallery(): Promise<string | undefined> {
const photo = await Camera.getPhoto({
quality: 80,
resultType: CameraResultType.Uri,
source: CameraSource.Photos,
});
// Convert to web-usable URL
return Capacitor.convertFileSrc(photo.path!);
}
import { Geolocation, Position } from '@capacitor/geolocation';
async getCurrentPosition(): Promise<Position> {
const permissions = await Geolocation.checkPermissions();
if (permissions.location !== 'granted') {
await Geolocation.requestPermissions();
}
return Geolocation.getCurrentPosition({
enableHighAccuracy: true,
timeout: 10000,
});
}
watchPosition(callback: (position: Position) => void): Promise<string> {
return Geolocation.watchPosition(
{ enableHighAccuracy: true },
(position, err) => {
if (position) callback(position);
if (err) console.error('Geolocation error:', err);
},
);
}
async stopWatching(watchId: string) {
await Geolocation.clearWatch({ id: watchId });
}
services/platform.service.tsimport { Injectable } from '@angular/core';
import { Platform } from '@ionic/angular';
import { Capacitor } from '@capacitor/core';
@Injectable({ providedIn: 'root' })
export class PlatformService {
constructor(private platform: Platform) {}
/** Running as native app (iOS/Android) */
get isNative(): boolean {
return Capacitor.isNativePlatform();
}
/** Running in a browser (PWA or dev) */
get isWeb(): boolean {
return !this.isNative;
}
get isIOS(): boolean {
return this.platform.is('ios');
}
get isAndroid(): boolean {
return this.platform.is('android');
}
/** Check if a specific native plugin is available */
isPluginAvailable(name: string): boolean {
return Capacitor.isPluginAvailable(name);
}
}
services/storage.service.tsimport { Injectable } from '@angular/core';
import { Preferences } from '@capacitor/preferences';
@Injectable({ providedIn: 'root' })
export class StorageService {
async get<T>(key: string): Promise<T | null> {
const { value } = await Preferences.get({ key });
return value ? JSON.parse(value) : null;
}
async set<T>(key: string, value: T): Promise<void> {
await Preferences.set({ key, value: JSON.stringify(value) });
}
async remove(key: string): Promise<void> {
await Preferences.remove({ key });
}
async clear(): Promise<void> {
await Preferences.clear();
}
}
services/api.service.tsimport { Injectable } from '@angular/core';
import { HttpClient, HttpHeaders } from '@angular/common/http';
import { firstValueFrom } from 'rxjs';
import { environment } from '../../environments/environment';
import { StorageService } from './storage.service';
@Injectable({ providedIn: 'root' })
export class ApiService {
private baseUrl = environment.apiUrl;
constructor(
private http: HttpClient,
private storage: StorageService,
) {}
private async getHeaders(): Promise<HttpHeaders> {
const token = await this.storage.get<string>('auth_token');
let headers = new HttpHeaders({ 'Content-Type': 'application/json' });
if (token) {
headers = headers.set('Authorization', `Bearer ${token}`);
}
return headers;
}
async get<T>(path: string): Promise<T> {
const headers = await this.getHeaders();
return firstValueFrom(this.http.get<T>(`${this.baseUrl}${path}`, { headers }));
}
async post<T>(path: string, body: unknown): Promise<T> {
const headers = await this.getHeaders();
return firstValueFrom(this.http.post<T>(`${this.baseUrl}${path}`, body, { headers }));
}
async put<T>(path: string, body: unknown): Promise<T> {
const headers = await this.getHeaders();
return firstValueFrom(this.http.put<T>(`${this.baseUrl}${path}`, body, { headers }));
}
async delete(path: string): Promise<void> {
const headers = await this.getHeaders();
await firstValueFrom(this.http.delete(`${this.baseUrl}${path}`, { headers }));
}
}
theme/variables.scss:root {
// Primary brand color
--ion-color-primary: #3880ff;
--ion-color-primary-rgb: 56, 128, 255;
--ion-color-primary-contrast: #ffffff;
--ion-color-primary-shade: #3171e0;
--ion-color-primary-tint: #4c8dff;
// Custom app colors
--app-background: #f5f5f5;
--app-card-background: #ffffff;
--app-text-primary: #1a1a2e;
--app-text-secondary: #666666;
}
// Dark mode overrides
@media (prefers-color-scheme: dark) {
:root {
--ion-background-color: #1a1a2e;
--ion-text-color: #ffffff;
--app-background: #1a1a2e;
--app-card-background: #2a2a3e;
--app-text-primary: #ffffff;
--app-text-secondary: #aaaaaa;
}
}
// iOS-specific adjustments
.ios {
--ion-toolbar-background: var(--ion-background-color);
}
// Android/Material-specific adjustments
.md {
--ion-toolbar-background: var(--ion-color-primary);
--ion-toolbar-color: var(--ion-color-primary-contrast);
}
capacitor.config.tsimport type { CapacitorConfig } from '@capacitor/cli';
const config: CapacitorConfig = {
appId: 'com.example.myapp',
appName: 'My App',
webDir: 'www',
server: {
androidScheme: 'https',
},
plugins: {
SplashScreen: {
launchShowDuration: 2000,
backgroundColor: '#ffffff',
showSpinner: false,
},
StatusBar: {
style: 'LIGHT',
},
Keyboard: {
resize: 'body',
resizeOnFullScreen: true,
},
},
};
export default config;
components/user-card/user-card.component.html<ion-item detail>
<ion-avatar slot="start">
<ion-img [src]="user.avatarUrl || 'assets/default-avatar.svg'"></ion-img>
</ion-avatar>
<ion-label>
<h2>{{ user.name }}</h2>
<p>{{ user.email }}</p>
</ion-label>
<ion-note slot="end">
{{ user.createdAt | date:'shortDate' }}
</ion-note>
</ion-item>
models/user.model.tsexport interface User {
id: string;
email: string;
name: string;
avatarUrl?: string;
createdAt: string;
}
export interface CreateUserRequest {
email: string;
name: string;
password: string;
}
export interface PaginatedResponse<T> {
data: T[];
meta: {
page: number;
perPage: number;
total: number;
};
}
# Development server (browser)
ionic serve
# Run on iOS simulator
ionic capacitor run ios --livereload --external
# Run on Android emulator
ionic capacitor run android --livereload --external
# Build web assets
ionic build
ionic build --prod # Production build
# Sync web assets to native projects
npx cap sync
# Open native IDE
npx cap open ios
npx cap open android
# Add a new page
ionic generate page pages/settings
# Add a new component
ionic generate component components/header
# Add a new service
ionic generate service services/notification
# Update Capacitor plugins
npx cap update
# Check Capacitor doctor
npx cap doctor
# Build for production (iOS)
ionic build --prod && npx cap sync ios && npx cap open ios
# Build for production (Android)
ionic build --prod && npx cap sync android && npx cap open android
@ionic/react or @ionic/vue.@capacitor/* plugins.@capacitor/camera, @capacitor/geolocation, etc.) cover common needs. Community plugins on capacitor-community GitHub org. Custom plugins by extending Plugin class in Swift/Kotlin.Capacitor.isNativePlatform() to check native vs web. Use Platform.is('ios') / Platform.is('android') for platform-specific logic. Show/hide features based on platform capabilities.@angular/pwa for service worker support. Capacitor plugins gracefully degrade on web (some throw "not available" errors — wrap in isPluginAvailable checks).--ion-color-* variables for brand colors. Use @media (prefers-color-scheme: dark) for dark mode. Platform-specific overrides via .ios and .md CSS selectors.ionic build --prod, sync with npx cap sync, then build/archive in Xcode (iOS) or Android Studio (Android). Alternatively, use Appflow for cloud builds and CI/CD.@capacitor-firebase/authentication + @capacitor-firebase/firestore. Configure via google-services.json (Android) and GoogleService-Info.plist (iOS).@capacitor-community/sqlite) with network detection via the Network API (@capacitor/network).testing
Set up Vitest 2.x with TypeScript for unit and component testing using test/describe/it, vi.fn/vi.mock/vi.spyOn, component testing with Testing Library, coverage (v8/istanbul), workspace config, and snapshot testing.
testing
Set up pytest 8.x with Python for unit and integration testing using fixtures (scope, autouse, parametrize), async tests (pytest-asyncio), mocking (unittest.mock, pytest-mock), coverage (pytest-cov), conftest.py patterns, and markers.
testing
Set up Playwright 1.49+ with TypeScript for E2E testing using page object model, fixtures, test.describe/test blocks, assertions, selectors, network mocking, CI configuration, and trace viewer.
testing
Set up Jest 30+ with TypeScript for unit tests, integration tests, mocking (jest.fn, jest.mock, jest.spyOn), coverage configuration, custom matchers, snapshot testing, and setup/teardown patterns.