Wednesday, November 1, 2023

Flutter's Adaptive UI: Crafting Interfaces for Any Screen

Table of Contents

1. The Modern Challenge of a Multi-Device World

In the digital landscape of today, the user is king, and their kingdom is fragmented. A single user might start their day checking notifications on a smartwatch, browse news on a smartphone during their commute, work on a widescreen desktop monitor, and relax in the evening with a tablet or a foldable device. This proliferation of screen sizes, aspect ratios, and input methods presents a formidable challenge for application developers. How can we deliver a high-quality, consistent, and intuitive user experience across this vast and ever-growing ecosystem without tripling our development time and budget?

For years, the answer involved building and maintaining separate codebases: one for iOS, one for Android, a separate one for the web, and yet another for desktop platforms. This approach is not only resource-intensive but also prone to inconsistencies, where features and bug fixes lag on one platform while racing ahead on another. This is the very problem that cross-platform frameworks aim to solve.

Enter Flutter, Google's open-source UI toolkit designed to build beautiful, natively compiled applications for mobile, web, desktop, and embedded devices from a single codebase. Flutter's core promise is to empower developers to create expressive and flexible UIs with excellent performance. However, simply having a single codebase that runs everywhere is only half the battle. An application that looks and feels like a stretched-out phone app on a desktop monitor fails to meet user expectations and deliver a truly premium experience. The real goal is not just to be cross-platform, but to be platform-adaptive. This involves crafting an application that intelligently reconfigures its layout, components, and even its interaction models to feel perfectly at home on any device it runs on. This article explores the principles, tools, and strategies for achieving true adaptive design within the Flutter framework.

2. Understanding Flutter's Architectural Advantage

To fully appreciate how Flutter enables powerful adaptive UIs, it's essential to understand the core architectural decisions that set it apart. These foundations provide the flexibility and control necessary to build interfaces that can morph and adapt to any context.

2.1. Dart: The Engine of Performance and Productivity

Flutter applications are written in the Dart programming language, also developed by Google. Dart is not merely a choice of syntax; its features are deeply integrated with Flutter's capabilities. It's a modern, object-oriented, and type-safe language that provides a unique combination of development speed and execution performance.

  • JIT and AOT Compilation: During development, Dart utilizes a Just-In-Time (JIT) compiler. This enables one of Flutter's most beloved features: stateful hot reload. Developers can make changes to their code and see the results instantly in their running app, typically in under a second, without losing the current application state. For production releases, Dart uses an Ahead-Of-Time (AOT) compiler to compile the code directly into native ARM or x64 machine code. This results in fast startup times and consistently high performance, as there is no JavaScript bridge or interpretation layer to slow things down.
  • Sound Null Safety: Dart's type system includes sound null safety, which eliminates null reference errors (a common source of app crashes) at compile time. This leads to more robust and reliable applications, allowing developers to focus on building features rather than hunting down null-related bugs.
  • Concise and Familiar Syntax: For developers coming from languages like Java, C#, or JavaScript, Dart's syntax is easy to learn and adopt, reducing the initial learning curve.

// A simple "Hello World" in Flutter, written in Dart.
// This demonstrates the declarative UI approach.
import 'package:flutter/material.dart';

void main() {
  runApp(const MyApp());
}

class MyApp extends StatelessWidget {
  const MyApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      home: Scaffold(
        appBar: AppBar(
          title: const Text('Welcome to Flutter'),
        ),
        body: const Center(
          child: Text('Hello, Adaptive World!'),
        ),
      ),
    );
  }
}

2.2. The "Everything is a Widget" Philosophy

In Flutter, the central concept is the widget. A widget is an immutable declaration of a part of a user interface. Unlike other frameworks that separate views, controllers, and layouts, Flutter unifies these concepts into its widget hierarchy. Everything, from a button or a text label to padding, centering, and even the entire application itself, is a widget.

This compositional approach is incredibly powerful. You build your UI by composing simple widgets into more complex ones. For example, to create a centered button with some padding, you might wrap a TextButton widget inside a Padding widget, which is then wrapped inside a Center widget. This declarative style means you describe the UI for a given state, and the framework takes care of rendering it. When the state changes, Flutter efficiently rebuilds the necessary parts of the widget tree.

There are two primary types of widgets:

  • StatelessWidget: These are immutable widgets that do not have any internal state. Their properties are configured by their parent and do not change over their lifetime. Examples include Icon, Text, and SizedBox. They are efficient and are used for static parts of the UI.
  • StatefulWidget: These widgets can maintain state that might change during the lifetime of the widget. When their internal state changes (by calling setState()), they trigger a rebuild, allowing the UI to be updated dynamically. Examples include TextField, Checkbox, and any custom widget that needs to react to user interaction or data changes.

2.3. The Skia Graphics Engine: Pixel-Perfect Control

Perhaps the most significant architectural decision in Flutter is its rendering pipeline. Unlike frameworks that rely on platform-native UI components (OEM widgets), Flutter brings its own rendering engine. It uses Skia, a mature, open-source 2D graphics library (also used by Google Chrome and Android), to draw every single pixel on the screen. This means that a Flutter button on Android is not an Android `Button`, and a Flutter slider on iOS is not a `UISlider`. It's a set of pixels drawn on a blank canvas that is meticulously crafted to look and feel like its native counterpart.

This approach has profound implications:

  • Consistency: A widget will look and behave identically across all platforms and OS versions, eliminating device-specific UI bugs.
  • Performance: By communicating directly with the GPU via Skia, Flutter can achieve smooth, jank-free animations and transitions at 60 or even 120 frames per second.
  • Creative Freedom: Since Flutter controls every pixel, developers are not limited by the constraints of the platform's built-in widgets. This allows for the creation of highly customized and brand-centric UIs without fighting the native framework.

This very control is what makes Flutter an ideal candidate for adaptive design. Because the entire UI is constructed in code, it is trivial to introduce conditional logic that swaps out entire widget trees based on screen size, platform, or any other factor.

3. Responsive vs. Adaptive: A Critical Distinction for UI Design

Before diving into implementation, it's crucial to clarify two terms that are often used interchangeably but describe distinct design methodologies: responsive and adaptive.

3.1. What is Responsive Design? The Fluid Grid

Responsive design is a technique where a single layout fluidly adjusts to fit the available screen space. Think of it like pouring water into different shaped containers; the water (content) adapts its shape to fit the container (screen). This is typically achieved using flexible grids, relative units (like percentages), and media queries that apply different styling rules as the screen width changes. The core idea is that the layout is continuous and infinitely flexible. A web page that reflows its text and image columns as you resize the browser window is a classic example of responsive design.

In Flutter, you can achieve responsive design using widgets like Expanded, Flexible, and FractionallySizedBox within a Row or Column.

3.2. What is Adaptive Design? The Set of Tailored Suits

Adaptive design, on the other hand, involves creating several distinct, pre-defined layouts for specific screen sizes or "breakpoints." Instead of one fluid layout, the application detects the device's characteristics and serves the most appropriate layout from a set of options. Think of it not as one-size-fits-all spandex, but as a collection of tailored suits: one for small, one for medium, and one for large. The layout itself doesn't change between breakpoints; rather, the entire layout is swapped out for a different one.

This approach allows for more significant and optimized changes. For example:

  • On a phone, a navigation menu might be a bottom bar.
  • On a tablet, it might become a persistent side drawer or rail.
  • On a desktop, it could be a full-fledged top menu bar with sub-menus.

This is more than just reflowing content; it's a fundamental change in the UI structure and user interaction model to best suit the context.

3.3. Why This Matters for Flutter Development

Flutter is exceptionally well-suited for both approaches, but it truly shines when implementing adaptive design. Because the entire UI is a tree of widgets constructed in Dart code, you can use simple conditional logic (`if`/`else` statements or `switch` cases) to decide which widgets to build based on the screen size, platform, or orientation. This allows developers to go beyond simple resizing and create fundamentally different user experiences for different contexts, all from the same codebase.

A truly great cross-platform application often uses a hybrid approach: adaptive design at the high level to switch between major layouts (e.g., phone vs. tablet), and responsive design within those individual layouts to handle minor size variations smoothly.

4. The Core Toolkit for Building Adaptive UIs in Flutter

Flutter provides a set of essential widgets and classes that form the building blocks of any adaptive UI. Mastering these tools is the first step toward crafting sophisticated, context-aware applications.

4.1. MediaQuery: Your Window into the Device's World

The MediaQuery class is the most fundamental tool for adaptive design. It allows you to query the properties of the current media (typically the device screen). It's accessed via the `BuildContext`, which provides a handle to the widget's location in the widget tree.

You can retrieve a wealth of information:

  • MediaQuery.of(context).size: Returns a Size object with the screen's width and height in logical pixels.
  • MediaQuery.of(context).orientation: Returns an Orientation enum (either portrait or landscape).
  • MediaQuery.of(context).devicePixelRatio: The number of physical pixels for each logical pixel. Useful for handling high-resolution assets.
  • MediaQuery.of(context).padding: Provides information about system intrusions on the screen, such as the status bar at the top or the "notch" on certain devices. Using this ensures your UI doesn't get obscured.
  • MediaQuery.of(context).platformBrightness: Returns Brightness.light or Brightness.dark, allowing your app to react to the system's dark mode setting.

@override
Widget build(BuildContext context) {
  // Get screen dimensions
  final double screenWidth = MediaQuery.of(context).size.width;
  final double screenHeight = MediaQuery.of(context).size.height;
  final Orientation orientation = MediaQuery.of(context).orientation;

  return Scaffold(
    appBar: AppBar(
      title: Text('MediaQuery Demo'),
    ),
    body: Center(
      child: Text(
        'Width: ${screenWidth.toStringAsFixed(2)}\n'
        'Height: ${screenHeight.toStringAsFixed(2)}\n'
        'Orientation: $orientation',
        style: TextStyle(fontSize: 24),
      ),
    ),
  );
}

Important Note: Using MediaQuery.of(context) causes your widget to rebuild whenever any of its properties change (e.g., when the device is rotated or the app window is resized). This is powerful but can be inefficient if you only care about one specific property. In modern Flutter, it's often better to use more specific queries like MediaQuery.sizeOf(context) or MediaQuery.platformBrightnessOf(context), which will only trigger a rebuild when that specific value changes.

4.2. LayoutBuilder: Responding to Parent Constraints

While MediaQuery gives you information about the entire screen, LayoutBuilder provides information about the constraints of a widget's direct parent. This is a crucial distinction. LayoutBuilder is a widget whose `builder` callback provides a BuildContext and a BoxConstraints object. This object tells you the minimum and maximum width and height the parent widget allows its child to be.

This is particularly useful when you want a widget to adapt its layout based on the space it's given, not necessarily the entire screen size. For example, a widget placed in a wide sidebar should behave differently than the same widget placed in a narrow main content area, even on the same screen.


LayoutBuilder(
  builder: (BuildContext context, BoxConstraints constraints) {
    // constraints.maxWidth contains the maximum available width from the parent.
    if (constraints.maxWidth > 600) {
      // Return a layout suitable for wide spaces
      return _buildWideLayout();
    } else {
      // Return a layout suitable for narrow spaces
      return _buildNarrowLayout();
    }
  },
)

4.3. OrientationBuilder: A Specialized Tool for Rotations

As a convenient shorthand, Flutter provides the OrientationBuilder widget. It's essentially a simplified LayoutBuilder that rebuilds its child whenever the screen's orientation changes. Its `builder` function provides the current Orientation, making it easy to switch between portrait and landscape layouts.


OrientationBuilder(
  builder: (context, orientation) {
    return GridView.count(
      // Use 2 columns in portrait mode, 3 in landscape
      crossAxisCount: orientation == Orientation.portrait ? 2 : 3,
      children: List.generate(100, (index) {
        return Center(
          child: Text('Item $index'),
        );
      }),
    );
  },
)

4.4. Proportional Sizing with AspectRatio and FractionallySizedBox

To create layouts that scale gracefully, it's often better to use proportional sizing instead of fixed pixel values. Flutter provides excellent widgets for this:

  • AspectRatio: This widget attempts to size its child to a specific aspect ratio. For example, you can ensure a video player or an image preview is always 16:9, regardless of the available width.
  • FractionallySizedBox: This widget sizes its child to a fraction of the total available space. You can make a child 50% of the parent's width or 75% of its height, which is a core concept in responsive design.

5. A Practical Blueprint: Building an Adaptive News Application

Theory is useful, but practice is essential. Let's walk through the process of building a screen for a hypothetical news reader application that adapts its layout for phone, tablet, and desktop.

5.1. Step 1: Defining Our Breakpoints

First, we need to establish the screen width thresholds (breakpoints) at which our layout will change. These values are subjective, but common choices are based on Material Design guidelines. We can define them as constants for easy reuse.


// lib/core/breakpoints.dart
class Breakpoints {
  static const double mobile = 600;
  static const double tablet = 840;
  static const double desktop = 1200;
}

5.2. Step 2: The Mobile-First Layout (Single-Pane View)

For small screens (phones), the most common pattern is a single list of items. Tapping an item navigates the user to a new screen to view the details. This is our baseline layout.


// lib/features/news/presentation/widgets/article_list_view.dart
class ArticleListView extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return ListView.builder(
      itemCount: 20, // Dummy data
      itemBuilder: (context, index) {
        return ListTile(
          title: Text('Article Headline $index'),
          subtitle: Text('A brief summary of the article...'),
          onTap: () {
            // Navigate to a dedicated detail screen
            Navigator.of(context).push(
              MaterialPageRoute(builder: (context) => ArticleDetailScreen(articleId: index)),
            );
          },
        );
      },
    );
  }
}

5.3. Step 3: The Tablet Layout (Two-Pane Master-Detail)

For medium-sized screens (tablets), we can improve the user experience by showing the list of articles and the content of the selected article side-by-side. This is a classic "master-detail" flow.

We'll create a main screen widget that uses LayoutBuilder to decide which layout to display.


// lib/features/news/presentation/screens/news_home_screen.dart
class NewsHomeScreen extends StatefulWidget {
  @override
  _NewsHomeScreenState createState() => _NewsHomeScreenState();
}

class _NewsHomeScreenState extends State<NewsHomeScreen> {
  int? _selectedArticleId;

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: Text('Flutter News')),
      body: LayoutBuilder(
        builder: (context, constraints) {
          if (constraints.maxWidth < Breakpoints.tablet) {
            // Mobile Layout: Just show the list. Navigation handles details.
            return ArticleListView(
              onArticleTap: (id) {
                 Navigator.of(context).push(MaterialPageRoute(
                   builder: (context) => ArticleDetailScreen(articleId: id)
                 ));
              },
            );
          } else {
            // Tablet/Desktop Layout: Show master-detail view
            return Row(
              children: [
                // Master Pane (List)
                SizedBox(
                  width: 300,
                  child: ArticleListView(
                    onArticleTap: (id) {
                      setState(() {
                        _selectedArticleId = id;
                      });
                    },
                  ),
                ),
                // Divider
                const VerticalDivider(width: 1, thickness: 1),
                // Detail Pane
                Expanded(
                  child: _selectedArticleId == null
                      ? const Center(child: Text('Please select an article'))
                      : ArticleDetailView(articleId: _selectedArticleId!),
                ),
              ],
            );
          }
        },
      ),
    );
  }
}

In this example, we've introduced state (_selectedArticleId) to the home screen. On a wide layout, tapping the list updates this state, which causes the detail view on the right to rebuild with the new article content. On a narrow layout, the tap handler reverts to the traditional navigation method.

5.4. Step 4: The Desktop Layout (Three-Column with NavigationRail)

For even wider desktop screens, we can further enhance the layout. A common desktop pattern is to use a NavigationRail on the far left for top-level app navigation, followed by the article list, and then the detail view. This provides a more information-dense and powerful experience.


// In NewsHomeScreen's build method
body: LayoutBuilder(
  builder: (context, constraints) {
    if (constraints.maxWidth < Breakpoints.tablet) {
      // Mobile layout as before
      return ...;
    } else if (constraints.maxWidth < Breakpoints.desktop) {
      // Tablet layout as before
      return ...;
    } else {
      // Desktop Layout
      return Row(
        children: [
          NavigationRail(
            selectedIndex: 0,
            onDestinationSelected: (int index) {
              // Handle navigation
            },
            labelType: NavigationRailLabelType.all,
            destinations: [
              NavigationRailDestination(icon: Icon(Icons.article), label: Text('News')),
              NavigationRailDestination(icon: Icon(Icons.bookmark), label: Text('Saved')),
              NavigationRailDestination(icon: Icon(Icons.settings), label: Text('Settings')),
            ],
          ),
          const VerticalDivider(thickness: 1, width: 1),
          // Master Pane (List)
          SizedBox(
            width: 350,
            child: ArticleListView(...),
          ),
          const VerticalDivider(thickness: 1, width: 1),
          // Detail Pane
          Expanded(
            child: ArticleDetailView(...),
          ),
        ],
      );
    }
  },
),

With this progressive enhancement, we now have a single screen that provides an optimized layout for three distinct form factors, all managed with clean, readable conditional logic.

6. Beyond Layouts: True Platform Adaptation

A truly great adaptive app does more than just rearrange its layout. It adapts to the conventions, patterns, and input methods of the platform it's running on. This is where you can elevate your Flutter app from feeling like a good cross-platform app to feeling like a great native app.

6.1. Platform-Aware Widgets: Cupertino vs. Material

Flutter offers two main sets of widgets that conform to platform design languages: Material (for Android and general use) and Cupertino (for iOS). You can detect the current platform and render the appropriate widget.

A common example is an activity indicator:


// A simple widget that shows the correct loading spinner.
import 'dart:io' show Platform;
import 'package:flutter/material.dart';
import 'package:flutter/cupertino.dart';

Widget buildAdaptiveLoadingIndicator() {
  if (Platform.isIOS || Platform.isMacOS) {
    return const CupertinoActivityIndicator();
  } else {
    return const CircularProgressIndicator();
  }
}

This principle can be applied to switches, dialogs, sliders, and navigation bars. You can create your own "Adaptive" widgets that encapsulate this logic, making your UI code cleaner.


class AdaptiveSwitch extends StatelessWidget {
  final bool value;
  final ValueChanged<bool> onChanged;

  const AdaptiveSwitch({super.key, required this.value, required this.onChanged});

  @override
  Widget build(BuildContext context) {
    if (Platform.isIOS || Platform.isMacOS) {
      return CupertinoSwitch(value: value, onChanged: onChanged);
    } else {
      return Switch(value: value, onChanged: onChanged);
    }
  }
}

Navigation is a key area where platforms differ.

  • Mobile: Android typically uses a Drawer or BottomNavigationBar. iOS almost exclusively uses a CupertinoTabBar (bottom navigation).
  • Desktop/Web: A persistent NavigationRail or a full top-level menu bar is standard.

Your adaptive layout logic should also control the top-level navigation structure of your app, not just the content of a single screen.

Another example is the "pull-to-refresh" gesture. It's very common on mobile, but feels out of place on desktop, where a dedicated refresh button is expected.

6.3. Handling Different Input Methods: Touch, Mouse, and Keyboard

A desktop app is not just a big mobile app. Users expect to interact with it using a mouse and keyboard.

  • Mouse: Your app should respond to mouse hovers. Use widgets like InkWell or MouseRegion to change cursors (e.g., to a hand pointer over a link) or show hover effects (like a subtle background color change). Scrollbars should also be designed to be "grabbable" with a mouse.
  • Keyboard: Ensure your app has a logical focus order for tabbing through elements. Implement keyboard shortcuts for common actions (e.g., Ctrl+S to save). Widgets like FocusNode and Shortcuts are essential for this.
  • Touch: Ensure touch targets are large enough (at least 48x48 logical pixels) and that swipe gestures are intuitive.

A quality adaptive app will seamlessly support all relevant input methods.

7. Advanced Techniques and Helpful Packages

As your app grows, putting all the adaptive logic directly in your UI code can become cumbersome. Abstracting this logic into reusable components and leveraging community packages can significantly improve maintainability.

7.1. Creating a Reusable ResponsiveLayout Widget

We can encapsulate the breakpoint logic from our news app into a generic, reusable widget.


class ResponsiveLayout extends StatelessWidget {
  final Widget mobileBody;
  final Widget? tabletBody;
  final Widget? desktopBody;

  const ResponsiveLayout({
    super.key,
    required this.mobileBody,
    this.tabletBody,
    this.desktopBody,
  });

  @override
  Widget build(BuildContext context) {
    return LayoutBuilder(
      builder: (context, constraints) {
        if (desktopBody != null && constraints.maxWidth >= Breakpoints.desktop) {
          return desktopBody!;
        }
        if (tabletBody != null && constraints.maxWidth >= Breakpoints.tablet) {
          return tabletBody!;
        }
        return mobileBody;
      },
    );
  }
}

Now, our NewsHomeScreen can be simplified:


// In NewsHomeScreen's build method
@override
Widget build(BuildContext context) {
  return Scaffold(
    appBar: AppBar(title: Text('Flutter News')),
    body: ResponsiveLayout(
      mobileBody: buildMobileLayout(),
      tabletBody: buildTabletLayout(),
      desktopBody: buildDesktopLayout(),
    ),
  );
}

7.2. Leveraging the `flutter_adaptive_scaffold` Package

Recognizing the need for standardized adaptive patterns, the Flutter team created the flutter_adaptive_scaffold package. It provides a high-level widget, AdaptiveLayout, that simplifies the creation of common layouts like master-detail flows. It handles the breakpoint logic and animations for transitioning between layouts for you.


// Example using flutter_adaptive_scaffold
import 'package:flutter_adaptive_scaffold/flutter_adaptive_scaffold.dart';

// ... inside a build method
AdaptiveLayout(
  // Standard breakpoints are provided
  body: SlotLayout(
    config: <Breakpoint, SlotLayoutConfig>{
      // Map breakpoints to layout configurations
      Breakpoints.medium: SlotLayout.from(
        key: const Key('medium-layout'),
        builder: (context) => // Your tablet UI here
      ),
      Breakpoints.large: SlotLayout.from(
        key: const Key('large-layout'),
        builder: (context) => // Your desktop UI here
      ),
    },
  ),
  // Define primary and secondary navigation elements
  primaryNavigation: SlotLayout( ... ),
  secondaryBody: SlotLayout( ... ),
);

Using this package can save a lot of boilerplate code for standard adaptive layouts.

7.3. Exploring Other Community Packages

The Flutter ecosystem is rich with packages that can help. Packages like responsive_framework provide tools for automatically scaling your UI and offer more advanced breakpoint management, making it easier to ensure your app looks good on an even wider variety of screen sizes.

8. The Compelling Business Case for Adaptive Design

Implementing adaptive design is not just a technical exercise; it's a strategic business decision with tangible benefits. While it requires more upfront thought and planning than a simple, one-size-fits-all approach, the long-term payoff is significant.

  • Enhanced User Experience and Retention: Users have high expectations. An app that feels clunky, stretched, or awkward on their device will be quickly abandoned. A well-designed adaptive app that respects the user's context feels professional and intuitive, leading to higher user satisfaction, better reviews, and increased retention.
  • Expanded Market Reach: By building a single application that provides a first-class experience on phones, tablets, foldables, and desktops, you dramatically increase your total addressable market. You can reach users wherever they are, on whichever device they prefer, without the massive overhead of maintaining separate codebases.
  • Improved Development Efficiency and Maintainability: This may seem counterintuitive, but a well-architected adaptive app is more efficient in the long run. The alternative is either maintaining multiple native codebases (which is extremely expensive) or ignoring larger screen factors (which cedes a large market segment to competitors). By centralizing business logic and sharing UI components where possible, a single adaptive codebase reduces code duplication, streamlines bug fixing, and ensures new features can be rolled out consistently across all platforms.
  • Future-Proofing Your Application: The device landscape will only continue to fragment. By building with an adaptive mindset from the start, your application's architecture will be flexible enough to accommodate new form factors—like AR glasses or other novel devices—as they emerge.

9. Conclusion: Embracing the Adaptive Mindset

Flutter provides an unparalleled toolkit for building applications that can run anywhere from a single codebase. Its widget-based architecture, combined with powerful tools like LayoutBuilder and its direct control over rendering via Skia, gives developers the ultimate flexibility to create UIs that are not just cross-platform, but truly adaptive.

However, technology is only part of the equation. True success comes from adopting an adaptive mindset. It means moving beyond thinking of your app as a "phone app" or a "desktop app" and instead seeing it as a fluid digital experience that should be optimized for every context. It involves considering not just screen size, but also platform conventions, input methods, and user expectations from the very beginning of the design process. By combining Flutter's technical prowess with this user-centric, adaptive approach, you can build outstanding applications that delight users, no matter what screen they hold in their hands.


0 개의 댓글:

Post a Comment