updates
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 508 B |
|
After Width: | Height: | Size: 6.4 KiB |
|
After Width: | Height: | Size: 329 B |
|
|
@ -1,7 +1,7 @@
|
||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<!-- Modify this file to customize your launch splash screen -->
|
<!-- Modify this file to customize your launch splash screen -->
|
||||||
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
|
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
<item android:drawable="?android:colorBackground" />
|
<item android:drawable="@color/launch_background" />
|
||||||
|
|
||||||
<!-- You can insert your own image assets here -->
|
<!-- You can insert your own image assets here -->
|
||||||
<!-- <item>
|
<!-- <item>
|
||||||
|
|
|
||||||
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 680 B |
|
After Width: | Height: | Size: 33 KiB |
|
After Width: | Height: | Size: 1,019 B |
|
After Width: | Height: | Size: 54 KiB |
|
After Width: | Height: | Size: 1.7 KiB |
|
|
@ -1,7 +1,7 @@
|
||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<!-- Modify this file to customize your launch splash screen -->
|
<!-- Modify this file to customize your launch splash screen -->
|
||||||
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
|
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
<item android:drawable="@android:color/white" />
|
<item android:drawable="@color/launch_background" />
|
||||||
|
|
||||||
<!-- You can insert your own image assets here -->
|
<!-- You can insert your own image assets here -->
|
||||||
<!-- <item>
|
<!-- <item>
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,9 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<background android:drawable="@drawable/ic_launcher_background"/>
|
||||||
|
<foreground>
|
||||||
|
<inset
|
||||||
|
android:drawable="@drawable/ic_launcher_foreground"
|
||||||
|
android:inset="16%" />
|
||||||
|
</foreground>
|
||||||
|
</adaptive-icon>
|
||||||
|
Before Width: | Height: | Size: 2.1 KiB After Width: | Height: | Size: 3.7 KiB |
|
Before Width: | Height: | Size: 1.2 KiB After Width: | Height: | Size: 2.2 KiB |
|
Before Width: | Height: | Size: 3.1 KiB After Width: | Height: | Size: 5.5 KiB |
|
Before Width: | Height: | Size: 5.2 KiB After Width: | Height: | Size: 9.3 KiB |
|
Before Width: | Height: | Size: 7.6 KiB After Width: | Height: | Size: 14 KiB |
6
android/app/src/main/res/values/colors.xml
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<color name="ic_launcher_background">#191919</color>
|
||||||
|
<!-- Timbre canvas — used for the launch splash so it matches the app. -->
|
||||||
|
<color name="launch_background">#1A1A1A</color>
|
||||||
|
</resources>
|
||||||
116
app-store-description.md
Normal file
|
|
@ -0,0 +1,116 @@
|
||||||
|
# Timbre — App Store Listing
|
||||||
|
|
||||||
|
## Promotional Text
|
||||||
|
*(170-char limit)*
|
||||||
|
|
||||||
|
Your music, your server. Stream and download from any Subsonic or Navidrome library, control playback across your devices, and take it all offline.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Description
|
||||||
|
*(4,000-char limit)*
|
||||||
|
|
||||||
|
**Timbre is a fast, beautiful music player for your own Subsonic or Navidrome server.** Connect to the library you host, and your entire collection comes with you — streamed on Wi‑Fi and cellular, or saved for offline listening wherever you go.
|
||||||
|
|
||||||
|
Timbre pairs a distinctive, tactile design — an animated cassette on the Now Playing screen and accent colors drawn from each album's artwork — with the everyday features you actually reach for.
|
||||||
|
|
||||||
|
**STREAM YOUR LIBRARY**
|
||||||
|
• Works with any Subsonic-compatible server, including Navidrome
|
||||||
|
• Browse albums, artists, tracks, and playlists
|
||||||
|
• Save multiple servers and switch between them in a tap
|
||||||
|
• Home screen with Resume, Recently Played, and fresh picks
|
||||||
|
|
||||||
|
**FIND IT FAST**
|
||||||
|
• Search across your whole collection
|
||||||
|
• Filter and sort albums and tracks by genre, release year, and date added
|
||||||
|
• Mark favorites and organize with custom tags
|
||||||
|
|
||||||
|
**LISTEN OFFLINE**
|
||||||
|
• Download individual tracks, albums, or whole playlists
|
||||||
|
• "Download all" for one-tap saving
|
||||||
|
• Set how many tracks download at once to match your connection
|
||||||
|
• Keeps playing when you lose signal
|
||||||
|
|
||||||
|
**PLAYLISTS & TAGS**
|
||||||
|
• Create, rename, and reorder playlists
|
||||||
|
• Drag and drop to reorder your play queue
|
||||||
|
• Share playlists with other users on the same server
|
||||||
|
• Tag songs to group them your way
|
||||||
|
|
||||||
|
**CONTROL ACROSS DEVICES**
|
||||||
|
• Use one device as a remote for another on the same Wi‑Fi
|
||||||
|
• Mirror the queue and browse, enqueue, and reorder from either device
|
||||||
|
• Secure, automatic pairing — no codes to type
|
||||||
|
|
||||||
|
**BUILT FOR EVERYDAY PLAY**
|
||||||
|
• Background playback with lock screen and Control Center controls
|
||||||
|
• Gapless, high-quality audio
|
||||||
|
• Resume right where you left off
|
||||||
|
|
||||||
|
Timbre never locks you in. There's no subscription and no catalog to rent — just a clean, thoughtful player for the music you already own and host.
|
||||||
|
|
||||||
|
**Requires a Subsonic-compatible server (such as Navidrome). Timbre is a client only; you'll sign in with your own server details.**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Keywords
|
||||||
|
*(100-char limit, comma-separated, no spaces)*
|
||||||
|
|
||||||
|
subsonic,navidrome,music,player,streaming,offline,download,playlist,FLAC,audio,server,remote,gapless
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## App Review Information
|
||||||
|
|
||||||
|
### Sign-In Required
|
||||||
|
Yes — Timbre is a client for a self-hosted music server and requires server
|
||||||
|
credentials to sign in.
|
||||||
|
|
||||||
|
**Demo server (fill in before submitting):**
|
||||||
|
- Server URL: `https://<your-demo-server>`
|
||||||
|
- Username: `<demo-username>`
|
||||||
|
- Password: `<demo-password>`
|
||||||
|
|
||||||
|
### Contact Information
|
||||||
|
- First name: `<first name>`
|
||||||
|
- Last name: `<last name>`
|
||||||
|
- Phone: `<phone number>`
|
||||||
|
- Email: `<contact email>`
|
||||||
|
|
||||||
|
### Notes for the Reviewer
|
||||||
|
|
||||||
|
Thank you for reviewing Timbre.
|
||||||
|
|
||||||
|
WHAT THIS APP IS
|
||||||
|
Timbre is a client for self-hosted, Subsonic-compatible music servers (such as
|
||||||
|
Navidrome). It does not host any music itself — it plays back a library that the
|
||||||
|
user runs on their own server. We have provided demo server credentials above so
|
||||||
|
you can review full functionality.
|
||||||
|
|
||||||
|
HOW TO GET STARTED
|
||||||
|
1. Launch the app. On first run you will see the "Add server" / connect screen.
|
||||||
|
2. Enter the Server URL, Username, and Password from the demo credentials above.
|
||||||
|
3. Tap connect. The library (albums, artists, playlists) will load.
|
||||||
|
|
||||||
|
WHAT TO TRY
|
||||||
|
- Browse the Home, Library, Playlists, and Favorites tabs.
|
||||||
|
- Tap any album or track to start playback; open Now Playing.
|
||||||
|
- Search the library and apply genre / year / date-added filters.
|
||||||
|
- Download a track for offline playback (Downloads tab).
|
||||||
|
- Create a playlist or tag a song.
|
||||||
|
|
||||||
|
ABOUT PERMISSIONS / LOCAL NETWORK
|
||||||
|
Timbre includes an optional cross-device control feature: with two devices signed
|
||||||
|
into the same server on the same Wi-Fi network, one device can act as a remote for
|
||||||
|
the other. This is why the app requests Local Network access. The feature is
|
||||||
|
entirely optional — the app is fully functional on a single device without it, and
|
||||||
|
you may decline the Local Network prompt.
|
||||||
|
|
||||||
|
PRIVACY / DATA
|
||||||
|
Timbre collects no analytics and has no third-party accounts. The password is used
|
||||||
|
only to authenticate to the user's own server (via Subsonic's standard token-based
|
||||||
|
auth) and is stored securely on-device. There are no in-app purchases or ads in
|
||||||
|
this version.
|
||||||
|
|
||||||
|
Please contact us at the email above if you have any trouble connecting to the
|
||||||
|
demo server or need additional information.
|
||||||
BIN
assets/icon/icon_full.png
Normal file
|
After Width: | Height: | Size: 670 KiB |
BIN
assets/icon/icon_transparent.png
Normal file
|
After Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 90 KiB After Width: | Height: | Size: 256 KiB |
|
Before Width: | Height: | Size: 416 B After Width: | Height: | Size: 661 B |
|
Before Width: | Height: | Size: 899 B After Width: | Height: | Size: 1.7 KiB |
|
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 3 KiB |
|
Before Width: | Height: | Size: 617 B After Width: | Height: | Size: 1.1 KiB |
|
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 2.8 KiB |
|
Before Width: | Height: | Size: 2.6 KiB After Width: | Height: | Size: 4.8 KiB |
|
Before Width: | Height: | Size: 899 B After Width: | Height: | Size: 1.7 KiB |
|
Before Width: | Height: | Size: 2.3 KiB After Width: | Height: | Size: 4.3 KiB |
|
Before Width: | Height: | Size: 4 KiB After Width: | Height: | Size: 7.4 KiB |
|
Before Width: | Height: | Size: 1.2 KiB After Width: | Height: | Size: 2.3 KiB |
|
Before Width: | Height: | Size: 3.1 KiB After Width: | Height: | Size: 5.9 KiB |
|
Before Width: | Height: | Size: 1.4 KiB After Width: | Height: | Size: 2.7 KiB |
|
Before Width: | Height: | Size: 3.7 KiB After Width: | Height: | Size: 6.9 KiB |
|
Before Width: | Height: | Size: 4 KiB After Width: | Height: | Size: 7.4 KiB |
|
Before Width: | Height: | Size: 6.7 KiB After Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 2 KiB After Width: | Height: | Size: 3.7 KiB |
|
Before Width: | Height: | Size: 4.9 KiB After Width: | Height: | Size: 9.3 KiB |
|
Before Width: | Height: | Size: 2.1 KiB After Width: | Height: | Size: 4.1 KiB |
|
Before Width: | Height: | Size: 5.3 KiB After Width: | Height: | Size: 10 KiB |
|
Before Width: | Height: | Size: 6.1 KiB After Width: | Height: | Size: 12 KiB |
|
|
@ -19,7 +19,7 @@
|
||||||
<imageView opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" image="LaunchImage" translatesAutoresizingMaskIntoConstraints="NO" id="YRO-k0-Ey4">
|
<imageView opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" image="LaunchImage" translatesAutoresizingMaskIntoConstraints="NO" id="YRO-k0-Ey4">
|
||||||
</imageView>
|
</imageView>
|
||||||
</subviews>
|
</subviews>
|
||||||
<color key="backgroundColor" red="1" green="1" blue="1" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
|
<color key="backgroundColor" red="0.10196078431372549" green="0.10196078431372549" blue="0.10196078431372549" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
|
||||||
<constraints>
|
<constraints>
|
||||||
<constraint firstItem="YRO-k0-Ey4" firstAttribute="centerX" secondItem="Ze5-6b-2t3" secondAttribute="centerX" id="1a2-6s-vTC"/>
|
<constraint firstItem="YRO-k0-Ey4" firstAttribute="centerX" secondItem="Ze5-6b-2t3" secondAttribute="centerX" id="1a2-6s-vTC"/>
|
||||||
<constraint firstItem="YRO-k0-Ey4" firstAttribute="centerY" secondItem="Ze5-6b-2t3" secondAttribute="centerY" id="4X2-HB-R7a"/>
|
<constraint firstItem="YRO-k0-Ey4" firstAttribute="centerY" secondItem="Ze5-6b-2t3" secondAttribute="centerY" id="4X2-HB-R7a"/>
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ import 'shell/app_shell.dart';
|
||||||
import 'state/providers.dart';
|
import 'state/providers.dart';
|
||||||
import 'theme/accent.dart';
|
import 'theme/accent.dart';
|
||||||
import 'theme/app_theme.dart';
|
import 'theme/app_theme.dart';
|
||||||
|
import 'widgets/splash_screen.dart';
|
||||||
|
|
||||||
Future<void> main() async {
|
Future<void> main() async {
|
||||||
WidgetsFlutterBinding.ensureInitialized();
|
WidgetsFlutterBinding.ensureInitialized();
|
||||||
|
|
@ -50,7 +51,9 @@ class TimbreApp extends ConsumerWidget {
|
||||||
title: 'Timbre',
|
title: 'Timbre',
|
||||||
debugShowCheckedModeBanner: false,
|
debugShowCheckedModeBanner: false,
|
||||||
theme: buildTimbreTheme(accent),
|
theme: buildTimbreTheme(accent),
|
||||||
home: const AppShell(),
|
// AppShell mounts under the splash and boots behind it; the splash fades
|
||||||
|
// out after a couple of seconds.
|
||||||
|
home: const SplashGate(child: AppShell()),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
223
lib/widgets/splash_screen.dart
Normal file
|
|
@ -0,0 +1,223 @@
|
||||||
|
import 'dart:async';
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_svg/flutter_svg.dart';
|
||||||
|
|
||||||
|
import '../theme/tokens.dart';
|
||||||
|
|
||||||
|
/// How long the splash stays up before it fades away. The app mounts *behind*
|
||||||
|
/// the splash (see [SplashGate]), so this window also masks real cold-start
|
||||||
|
/// work (settings load, discovery, reconnect).
|
||||||
|
const _splashHold = Duration(seconds: 2);
|
||||||
|
const _splashFade = Duration(milliseconds: 350);
|
||||||
|
|
||||||
|
/// Holds the [child] app under a cassette splash overlay, then fades the
|
||||||
|
/// overlay out once [_splashHold] elapses. The child is live the whole time,
|
||||||
|
/// so it finishes booting behind the splash rather than after it.
|
||||||
|
class SplashGate extends StatefulWidget {
|
||||||
|
const SplashGate({super.key, required this.child});
|
||||||
|
|
||||||
|
final Widget child;
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<SplashGate> createState() => _SplashGateState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _SplashGateState extends State<SplashGate> {
|
||||||
|
Timer? _timer;
|
||||||
|
bool _fading = false; // began fading out
|
||||||
|
bool _gone = false; // fully removed from the tree
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_timer = Timer(_splashHold, () {
|
||||||
|
if (mounted) setState(() => _fading = true);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_timer?.cancel();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Stack(
|
||||||
|
children: [
|
||||||
|
widget.child,
|
||||||
|
if (!_gone)
|
||||||
|
// Absorb taps while opaque; let them through once we start fading.
|
||||||
|
IgnorePointer(
|
||||||
|
ignoring: _fading,
|
||||||
|
child: AnimatedOpacity(
|
||||||
|
opacity: _fading ? 0 : 1,
|
||||||
|
duration: _splashFade,
|
||||||
|
curve: Curves.easeOut,
|
||||||
|
onEnd: () {
|
||||||
|
if (mounted && _fading) setState(() => _gone = true);
|
||||||
|
},
|
||||||
|
child: const SplashScreen(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The full-screen cassette splash: a spinning cassette over the wordmark, on
|
||||||
|
/// Timbre's near-black canvas. Rebuilds nothing per frame beyond the two
|
||||||
|
/// spindle transforms.
|
||||||
|
class SplashScreen extends StatefulWidget {
|
||||||
|
const SplashScreen({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<SplashScreen> createState() => _SplashScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _SplashScreenState extends State<SplashScreen>
|
||||||
|
with SingleTickerProviderStateMixin {
|
||||||
|
// One full turn every 3s; both reels spin clockwise like a real cassette.
|
||||||
|
late final AnimationController _spin = AnimationController(
|
||||||
|
vsync: this,
|
||||||
|
duration: const Duration(seconds: 3),
|
||||||
|
)..repeat();
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_spin.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return ColoredBox(
|
||||||
|
color: TimbreColors.background,
|
||||||
|
child: Center(
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
SizedBox(
|
||||||
|
width: 240,
|
||||||
|
child: _SplashCassette(spin: _spin),
|
||||||
|
),
|
||||||
|
const SizedBox(height: TimbreSpacing.xl),
|
||||||
|
Text(
|
||||||
|
'TIMBRE',
|
||||||
|
style: Theme.of(context).textTheme.titleLarge?.copyWith(
|
||||||
|
color: TimbreColors.foreground,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
letterSpacing: 8,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: TimbreSpacing.md),
|
||||||
|
// Accent hairline — the one lively color, kept small.
|
||||||
|
Container(
|
||||||
|
width: 32,
|
||||||
|
height: 2,
|
||||||
|
color: TimbreColors.accentDefault,
|
||||||
|
),
|
||||||
|
const SizedBox(height: TimbreSpacing.md),
|
||||||
|
Text(
|
||||||
|
'loading…',
|
||||||
|
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||||
|
color: TimbreColors.dimmed,
|
||||||
|
letterSpacing: 2,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The cassette itself: static reels + shell (from the shared assets) with two
|
||||||
|
/// spinning spindles. Mirrors [CassetteView]'s layering and geometry but drops
|
||||||
|
/// the playback model — the reels sit at a fixed half-wound radius.
|
||||||
|
class _SplashCassette extends StatelessWidget {
|
||||||
|
const _SplashCassette({required this.spin});
|
||||||
|
|
||||||
|
final Animation<double> spin;
|
||||||
|
|
||||||
|
// Geometry measured off casette_shell.svg (viewBox 469×298), matching
|
||||||
|
// CassetteView's _CassetteConfig.
|
||||||
|
static const _viewBox = Size(469, 298);
|
||||||
|
static const _leftReel = Offset(135, 135);
|
||||||
|
static const _rightReel = Offset(334, 135);
|
||||||
|
static const _spindleSize = 36.0;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return AspectRatio(
|
||||||
|
aspectRatio: _viewBox.width / _viewBox.height,
|
||||||
|
child: FittedBox(
|
||||||
|
fit: BoxFit.contain,
|
||||||
|
child: SizedBox(
|
||||||
|
width: _viewBox.width,
|
||||||
|
height: _viewBox.height,
|
||||||
|
child: Stack(
|
||||||
|
children: [
|
||||||
|
// 1. Static tape discs behind the reel holes.
|
||||||
|
Positioned.fill(
|
||||||
|
child: CustomPaint(painter: _ReelPainter()),
|
||||||
|
),
|
||||||
|
// 2. Spinning cogs.
|
||||||
|
_spindle(_leftReel),
|
||||||
|
_spindle(_rightReel),
|
||||||
|
// 3. Static shell on top, framing the reels through its holes.
|
||||||
|
Positioned.fill(
|
||||||
|
child: SvgPicture.asset(
|
||||||
|
'assets/casette/casette_shell.svg',
|
||||||
|
fit: BoxFit.fill,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _spindle(Offset center) {
|
||||||
|
final cog = SvgPicture.asset(
|
||||||
|
'assets/casette/spindle.svg',
|
||||||
|
width: _spindleSize,
|
||||||
|
height: _spindleSize,
|
||||||
|
);
|
||||||
|
return Positioned(
|
||||||
|
left: center.dx - _spindleSize / 2,
|
||||||
|
top: center.dy - _spindleSize / 2,
|
||||||
|
width: _spindleSize,
|
||||||
|
height: _spindleSize,
|
||||||
|
child: RotationTransition(turns: spin, child: cog),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Paints the two wound-tape discs at a fixed half-wound radius, clipped to the
|
||||||
|
/// window band so they never touch the label. The shell (on top) masks them to
|
||||||
|
/// the reel holes + centre window.
|
||||||
|
class _ReelPainter extends CustomPainter {
|
||||||
|
static const _windowRect = Rect.fromLTRB(62.5, 98.5, 405.5, 172);
|
||||||
|
static const _leftReel = Offset(135, 135);
|
||||||
|
static const _rightReel = Offset(334, 135);
|
||||||
|
static const _radius = 62.0; // fixed ~half-wound (see CassetteView physics)
|
||||||
|
static const _tapeColor = Color(0xFF121212);
|
||||||
|
static const _padColor = Color(0xFF1A1A1A);
|
||||||
|
|
||||||
|
@override
|
||||||
|
void paint(Canvas canvas, Size size) {
|
||||||
|
canvas.save();
|
||||||
|
canvas.clipRect(_windowRect);
|
||||||
|
canvas.drawRect(_windowRect, Paint()..color = _padColor);
|
||||||
|
final tape = Paint()..color = _tapeColor;
|
||||||
|
canvas.drawCircle(_leftReel, _radius, tape);
|
||||||
|
canvas.drawCircle(_rightReel, _radius, tape);
|
||||||
|
canvas.restore();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool shouldRepaint(_ReelPainter oldDelegate) => false;
|
||||||
|
}
|
||||||
14
pubspec.yaml
|
|
@ -60,13 +60,19 @@ dev_dependencies:
|
||||||
flutter_launcher_icons: ^0.14.4
|
flutter_launcher_icons: ^0.14.4
|
||||||
|
|
||||||
flutter_launcher_icons:
|
flutter_launcher_icons:
|
||||||
image_path: "assets/icon/icon.png"
|
# Full-bleed square used for iOS and the Android legacy (pre-adaptive) icon.
|
||||||
|
image_path_ios: "assets/icon/icon_full.png"
|
||||||
|
image_path_android: "assets/icon/icon_full.png"
|
||||||
android: true
|
android: true
|
||||||
ios: true
|
ios: true
|
||||||
remove_alpha_ios: true
|
remove_alpha_ios: true
|
||||||
# Optional adaptive icon for Android (uncomment and provide a foreground PNG):
|
min_sdk_android: 21
|
||||||
# adaptive_icon_background: "#FFFFFF"
|
# Android adaptive icon: the full-bleed artwork goes in the BACKGROUND layer,
|
||||||
# adaptive_icon_foreground: "assets/icon/icon_foreground.png"
|
# which fills the whole canvas and is only clipped by the launcher's mask shape
|
||||||
|
# (no inset). The foreground is transparent. This gives edge-to-edge coverage;
|
||||||
|
# round/squircle masks will clip the cassette's rotated corners (accepted).
|
||||||
|
adaptive_icon_background: "assets/icon/icon_full.png"
|
||||||
|
adaptive_icon_foreground: "assets/icon/icon_transparent.png"
|
||||||
|
|
||||||
# For information on the generic Dart part of this file, see the
|
# For information on the generic Dart part of this file, see the
|
||||||
# following page: https://dart.dev/tools/pub/pubspec
|
# following page: https://dart.dev/tools/pub/pubspec
|
||||||
|
|
|
||||||